PR TIMESのOpenSearchをバージョンアップしました

  • URLをコピーしました!

こんにちは。バックエンドエンジニアの筒井(@tsuttsun_wind)です。PR TIMESの検索は、OpenSearchに支えられています。今回、OpenSearchのバージョンを1.3から2.19に上げる対応を行いました。この記事では、経緯や移行方法、移行中に発覚した改善が必要な実装などを交えて紹介していきます。

目次

背景

弊社のインフラは主にAWSを利用しています。

OpenSearchはAWSで展開されているOpenSearch Serviceを利用していて、バージョン1.3で運用していました。

最近、OpenSearch Serviceのドメインが一部EOLになることが発表されました。

以前利用していた1.3はまだ未定ですが、近い将来EOLになるリスクが高いと考えました。

Amazon OpenSearch Service では各バージョンの標準/延長サポート期間が定められており、標準サポート終了後は延長サポートに移行します。

また、OpenSearch 3.0がリリースされたため、バージョンを1つ飛ばした場合に多くの変更が必要になる可能性があります。

そのため、3.xへの移行を行いやすくするため、まず2.xの最新である2.19へバージョンアップを行いました。

Amazon Web Services
Amazon OpenSearch Service announces Standard and Extended Support dates for Elasticsearch and OpenSe... Today, we’re announcing timelines for end of Standard Support and Extended Support for legacy Elasticsearch versions up to 6.7, Elasticsearch versions 7.1 throu...

アップグレード方法の検討

アップグレードは以下の2通りで検討しました。

OpenSearch Service側で直接アップグレードをする

OpenSearch Serviceでは、既存コードの変更なしでAWSコンソール上からワンクリックでクラスターのバージョンをアップグレードすることが可能です。

  • メリット
    • ワンクリックでアップグレード可能、容易
    • 既存コードの変更を行う必要がない
  • デメリット
    • データの不整合が発生する可能性がある。
      • AWS OpenSearch ServiceはBlue/Greenデプロイを使用しているため、切り替えるタイミングでデータ更新が行われた場合、正常に更新されず不整合が発生する可能性がある
    • OpenSearch側で破壊的変更がある場合、該当機能が使えなくなりインシデントになる可能性がある
      • ダウングレードはスナップショットを利用して復元することが可能だが、復元完了まで機能が停止している状態のため、ビジネスインパクトが大きくなってしまう

新規作成したクラスターにデータを入れて最後に接続先を切り替える

  • メリット
    • 段階的にリリースが可能であり、問題が発生した場合は切り戻すことですぐに元の状態に戻せるため、安全に切り替えることが可能
  • デメリット
    • 既存クラスターを含め、移行中は2個クラスターがある状態のためコストが余計に掛かる
    • 移行するためにコードを追加で作成したりバッチを追加するなどいくつかのプロセスを踏む必要がある

今回は、本番環境を止めず・壊さずにアップグレードを行う必要があったため、2番目の「クラスターを新規作成して最後に宛先を切り替える」方法で実施しました。

クラスターの作成

クラスターの接続先を切り替えるため、事前に移行先のクラスターを用意します。

既存環境では、データノード、マスターノードの世代が古いインスタンスタイプであったため、最新世代のインスタンスを指定し、AZやネットワーク設定は既存クラスターと同一の設定にしました。

コードを移植する

本番環境では検索機能や更新バッチが常時動いているため、安易に既存コードに変更を加えると、デプロイ時の一瞬の変更時に機能が動かない、バッチが異常終了する問題が発生する可能性があります。

また、変更後に不具合が発生した場合、切り戻し中は機能が停止しているためビジネスインパクトを与えてしまいます。

そのため、既存の挙動を維持しながら移行する必要があります。

機能停止を防ぐため、既存のコードを新しいクラスへ移植し、移植先のコードでは宛先を新しいクラスターへ向けるように変更します。 確認したところ、OpenSearchのリクエストを飛ばすAPI仕様は変わっていなかったため、バージョンアップに伴う大きな変更は不要でした。

リビルドする

コードの移植が完了したら、新しいクラスターに対してインデックスを構築していく必要があります。

そこで、既存データのリビルドを実施しましたが、いくつか詰まりポイントがありました。

まず、検索機能で活用している辞書ツールであるmorphとsynonymsの事前導入です。

新クラスターにも辞書パッケージをあらかじめインストールしておく必要があったのですが、この作業を失念したままリビルドコマンドを実行してしまったため、辞書定義を参照しようとした時点でエラーとなり、処理が中断されてしまいました。

もう一つは、バルクサイズ(Bulk APIで一度に送信するドキュメント数や総データ量)の調整です。既存データは数百万件以上のドキュメントがあり、これを数千件単位で分割してBulk Updateしています。しかし、最初にバルクサイズを大きく設定しすぎたためOpenSearch側で空レスポンス(Empty reply from server)となり、エラーでリビルドが途中終了してしまう状況が発生しました。

そのため、リビルド処理を最初からやり直すこととなり、バルクサイズのチューニングに手間取りました。

結果的には、既存設定と同様の数千件程度で設定することでリビルドが安定しました。

RebuildIndex.ERROR: error: {
"message":"cURL error 52: Empty reply from server",
"file":"/usr/local/apache1/vendor/guzzlehttp/guzzle/src/Exception/RequestException.php",
"line":49,
"trace":"#0 /usr/local/apache1/vendor/guzzlehttp/guzzle/src/RequestFsm.php(103): 
GuzzleHttp\Exception\RequestException::wrapException(Object(GuzzleHttp\Message\Request), 
Object(GuzzleHttp\Ring\Exception\ConnectException))"
...
}

リビルドの負荷・時間

リビルド中は、New Relic上で負荷を監視していましたが、CPU使用率やJVMメモリプレッシャーは大きく上がらず、安定していました。

ですが、前述した通り数百万件以上のドキュメントがあり、数千件に分けてBulk Updateをしていたため、リビルド完了まで約10時間掛かりました。

リビルド完了後、企業とプレスリリースの情報を数分ごとに更新しているバッチも新クラスター用にあらかじめcronへ登録しておく必要があります。

これを行わないと、企業やリリースが新しく登録されても検索機能でヒットしないという問題が発生してしまいます。

リリース方法の検討

新クラスター用にリビルドとバッチの登録が完了したら、いよいよリリースです。

リリースに関しては、以下の3通りで検討しました。

  • ユーザー毎に振り分けてリリース
    • Cookieなどを用いてユーザー毎に振り分ける機能が必要
  • 機能毎に振り分けてリリース
    • 特定の機能やページ単位で新旧バージョンの切り換えが可能
    • 影響範囲を機能単位で制御可能
    • 利用可能にする機能の範囲をコード側で設定しておく必要がある
    • 全ユーザーが部分的に新機能を利用可能になる
  • 新旧のOpenSearchのホストを切り替えて即座にリリース
    • 全ユーザーが一斉に利用可能になる
    • コード側のOpenSearchを利用している機能の参照先が全て変わってしまうため、影響範囲が大きい
    • 新規クラスターはJVMメモリにデータが載ってない可能性があるため、負荷傾向によっては同じリクエストで過負荷になる可能性がある
      • そのため、ウォームアップを行わないとトラブル発生のリスクがある

即座にリリースすると、負荷や想定外な挙動が原因でインシデントが発生する可能性があります。

そのため、より安全にリリースしたいと考えました。

検討した方法は、ユーザー毎と機能毎に振り分けてリリースする方法の2つです。

前述の通り、バージョンアップに伴う修正箇所はほとんどなかったため、全機能が正常に動作することが期待できました。

そのため、ユーザー毎に振り分けて、少しずつ新しいOpenSearchを利用するユーザーの範囲を広げる方法でリリースを行うことに決めました。

ユーザーを振り分ける機能の導入

// OpenSearch V2 CookieName
public const OPENSEARCH_V2_COOKIE_NAME = 'opensearch-v2-experiment';

// OpenSearch V2を使用するかの重み
public const OPENSEARCH_V2_RELEASE_WEIGHT = 10;

/**
* OpenSearchV2エンジンを提供するか確認する
*
* @param Request|null $request
* @param bool $ignore_test_mode_const
* @return bool
*/
public static function checkUseOpenSearchV2Engine(Request $request = null, bool $ignore_test_mode_const = false): bool
{
    // すでにセットされている場合はセットされている値を返す
    if (isset($_COOKIE[self::OPENSEARCH_V2_COOKIE_NAME])) {
        return (int)$_COOKIE[self::OPENSEARCH_V2_COOKIE_NAME] <= self::OPENSEARCH_V2_RELEASE_WEIGHT;
    }
    /**
    * @noinspection PhpUnhandledExceptionInspection
    * random_intの処理の際に発生するRandomExceptionは回復不能なため無視する
    */
    Cookie::setCookie(self::OPENSEARCH_V2_COOKIE_NAME, (string)random_int(1, 100));

    // 確率に応じてOpenSearchV2エンジンを使用するかどうかを判定
    return isset($_COOKIE[self::OPENSEARCH_V2_COOKIE_NAME]) &&
        (int)$_COOKIE[self::OPENSEARCH_V2_COOKIE_NAME] <= self::OPENSEARCH_V2_RELEASE_WEIGHT;
} 

ユーザー毎に振り分けてリリースする用のCookieを作成し、1~100の値をランダムに割り当てて設定した重み以下なら新クラスターを利用した検索を提供します。

移行中に発覚した改善が必要な実装

class SearchService
{
    /**
     * @param string $keyword
     * @return SearchResult
     * @throws Exception
     */
    public static function get(
        string $keyword,
    ): SearchResult {
        $query = new SearchRequestBodyStruct();
        $query->keyword = $keyword;
        $index = SearchEngineService::getPublishedSearchTarget();
        
        // ここの new Clientの部分 (書き換え前)
        $client = new Client(Config::OLD_OPENSEARCH_HOST);
        // 書き換え後
        $client = new Client(Config::NEW_OPENSEARCH_HOST);
        
        $search_response = SearchEngineForWebAppService::search($client, $index, $query->toJson());
        return new SearchResult(
            $search_response->getTotal(),
            $query->from,
            $query->size,
            $search_response->getReleaseList()
        );
    }
}

新旧でクラスターのURLが異なるため、都度クライアントを作成している場合は機能ごとにクライアント生成時のコードを変更する必要があります。

移行を進めていく中で、数十箇所以上のコードから直接インスタンス化されている実装を見かけました。 現在の実装ではOpenSearchのURLを直接入れているため、次回バージョンアップ時にホスト名を切り替える際に同じことを実施する必要があります。 今回の対応としては、生成AIを活用してURLの置換作業を自動化する対応を行いました。

class OpenSearchSingletonClient
{
    private static ?Client $client = null;
    public static function getInstance(): Client
    {
        if (self::$client === null) {
            self::$client = self::createClient();
        }

        return self::$client;
    }

    private static function createClient(): Client
    {
        return new Client(Config::OPENSEARCH_V2_URL);
    }
}

その後、恒久対応として上記のSingletonを作成し、直接インスタンス化されている部分を全てSingletonを呼び出すように変更を行いました。

$client = OpenSearchSingletonClient::getInstance();

今後、OpenSearchのClientクラスを呼び出す時は上記のように呼び出すようにしておくことで、再度OpenSearchのバージョンを上げることになった際に、createClient内にあるURLの置き換えや、試験的機能のメソッドを呼び出してバージョンの切り替えが行いやすくなります。

今後の課題

今後、直接インスタンス化を防ぐためにPHPStanのCustom Ruleの導入を検討しています。

PHPStanのCustom Ruleを導入することによって、過去に指摘された負債になりうるコードの使用を意図的に禁止することが可能になります。

あわせて読みたい
PHPStanのカスタムルールを導入しました こんにちは。開発本部でインターンをしている笹山 雷雅です。 今回はPHPStanを用いて独自のLintRuleを導入したので紹介します。 【PHPStanとは】 PHPStanとはPHPのLint...

まとめ

今回のOpenSearchのバージョンアップでは、事前の段取りや影響範囲の整理がいかに大切かを改めて実感しました。移行時の一時的な手間や新クラスター作成による追加コストは発生しましたが、安全性や将来の運用のしやすさを優先した判断は正しかったと考えています。

また、クライアントの集中管理や段階的リリースを取り入れることで、将来のさらなるアップグレードや障害時にも柔軟な対応ができると実感しました。

今後も、持続的に運用しやすい設計・開発に取り組んでいきたいと思います。

  • URLをコピーしました!

この記事を書いた人

2024年入社のバックエンドエンジニアです。

目次