WEARの検索基盤をElasticsearch 7.10.2からOpenSearch 2.19.0へ無停止で移行する ── ダブルライトとカナリアリリースによる段階的アプローチ

WEARの検索基盤をElasticsearch 7.10.2からOpenSearch 2.19.0へ無停止で移行する ── ダブルライトとカナリアリリースによる段階的アプローチ

はじめに

こんにちは、WEAR開発部バックエンドブロックの小山です。普段は弊社サービスであるWEARのバックエンド開発を担当しています。

WEARではハイブリッド検索などの新たな検索体験の実現を目指しています。その実現に必要なハイブリッド検索はOpenSearch 2.11で導入された機能です。Elasticsearch 7.10.2では利用できないため、Amazon OpenSearch Service上のエンジンをOpenSearch 2.11.0以上へ移行する必要がありました。今回はOpenSearch 2系の最新バージョンだった2.19.0を採用しました。本記事では、この移行にあたり対応したSearchkickの導入、ダブルライト戦略によるインデクシング移行、カナリアリリースによる段階的トラフィック切り替えについてご紹介します。

目次

抱えていた課題

Elasticsearch 7.10.2の限界

WEARではコーディネートや動画、メイクの投稿検索にAmazon OpenSearch Service上でElasticsearch 7.10.2を利用していました。しかし、以下の課題がありました。

  • 新機能の利用不可:WEARではハイブリッド検索などの新たな検索体験を計画していたが、Elasticsearch 7.10.2はハイブリッド検索に対応しておらず、実現できない状態
  • サポートの先行き不透明:Elasticsearch 7.10.2は、Amazon OpenSearch Serviceで提供される最終のオープンソースElasticsearchバージョン。今後の新機能追加やセキュリティパッチの提供が見込めない状態。Elasticsearch 7.1〜7.8の標準サポートは2025年11月に終了しており、7.10.2も同様のサポート終了が予想される状態。AWS側でもOpenSearchエンジンへの移行を推奨
  • ライブラリのメンテナンス性:elasticsearch gem 7.14.0以降ではAmazon OpenSearch Service上のElasticsearchへ接続不可。gemのバージョンを7.13.3に固定せざるを得ず、アップデートができない状態

既存のアーキテクチャ

WEARの検索基盤は、以下のシステム構成で運用していました。

WEAR検索基盤のシステム構成図

  • 検索機能:elasticsearch-model gemを利用し、検索メソッドを提供。内部ではelasticsearch gemが提供するElasticsearch::Clientを通じてOpenSearch Serviceと通信
  • マッピング定義:elasticsearch-model gemを利用し、モデルにマッピング定義を記述
  • インデックス操作:elasticsearch gemを利用し、Rakeタスクによるインデックス作成、エイリアス切り替え、旧インデックス削除、ドキュメント削除
  • インデクシング:トラフィックを考慮し、レコード更新ごとではなくDigdagワークフローとEmbulkによる定時バッチ(日次の洗い替えと差分更新)でインデクシング

課題を解決したアプローチ

今回の移行では、既存ドメインのインプレースアップグレードではなく、OpenSearch 2.19.0の新規ドメインを作成し、エンドポイントを段階的に切り替える方法を採用しました。その理由は以下の通りです。

  • インプレースアップグレードでは、Elasticsearch 7.10.2からOpenSearch 2.19.0へ直接移行できず、OpenSearch 1.xを経由する必要がある
  • elasticsearch-model/elasticsearchからsearchkick/opensearch-rubyへのgem移行が必要であり、アプリケーションコードに破壊的変更が生じる
  • 検索基盤は影響範囲が大きいため、カナリアリリースで段階的にリリースしたい

これらを踏まえ、Elasticsearchをダウンタイムなく移行させるために以下のアプローチで段階的に進めました。

  1. Searchkickとopensearch-rubyへの移行
  2. インデクシングのダブルライト戦略
  3. クエリ種別ごとの動作確認
  4. 負荷試験
  5. カナリアリリースによる段階的トラフィック移行

1. Searchkickとopensearch-rubyへの移行

移行前後のgemの対応関係は以下の通りです。

責務 Elasticsearch利用時 OpenSearch移行後
検索機能 elasticsearch-model(内部でelasticsearchを利用) searchkick(内部でopensearch-rubyを利用)
マッピング定義 elasticsearch-model searchkick
インデックス操作 elasticsearch 直接利用 opensearch-ruby 直接利用

elasticsearch-modelからSearchkickへ

検索機能とマッピング定義については、既存のelasticsearch-modelの代わりに、searchkickに移行しました。Searchkickを選定した理由は以下の通りです。

  • OpenSearchを公式にサポートしている
  • リポジトリが継続的にメンテナンスされている
  • nested型への対応など、elasticsearch-modelとの互換性がある
  • reindex時のアトミックなエイリアス切り替えが組み込まれているほか、ハイブリッド検索やセマンティック検索にも対応しており、高度な機能を備えている

elasticsearchからopensearch-rubyへ

インデックス操作のRakeタスクでは、elasticsearchを使用していました。OpenSearch移行に伴い、これをopensearch-rubyに置き換えました。

- require 'elasticsearch'
- client = Elasticsearch::Client.new(client_options)
+ require 'opensearch-ruby'
+ client = OpenSearch::Client.new(client_options)
  client.indices.update_aliases(...)
  client.indices.delete(...)

opensearch-rubyelasticsearchとAPIの互換性が高いため、クライアントの初期化部分とエラークラスの変更で、既存のインデックス操作ロジックをそのまま利用できました。

唯一の例外がインデックス作成タスクで、ここではSearchkick経由でマッピング定義を取得して作成しています。

task :create_index, [:index_name] => :environment do |_, args|
  index_class = index_class_name(args[:index_name]).singularize.capitalize.constantize
  index = Searchkick::Index.new(args[:index_name])
  model_config = index_class.search_index.index_options  # Searchkickからマッピング取得
  index.create(model_config)                              # Searchkick経由で作成
end

このように、マッピング定義はSearchkickに一元化しつつ、その他のインデックス操作はopensearch-rubyを直接使用する構成としました。

既存Searchableとの並存

WEARでは、モデルごとに*Searchableというconcernを定義し、elasticsearch-modelを利用した検索用のデータ定義とマッピング定義を集約していました。

移行期間中は、Elasticsearchを利用するサーバーとOpenSearchを利用するサーバーを並行稼働させる必要がありました。そこで、モデルごとに*OpensearchSearchable concernを新設し、既存の*Searchableと並存させる構成をとりました。

既存の*SearchableはElasticsearch用のconcernです。

# 既存: Elasticsearch用
module Searchable
  extend ActiveSupport::Concern
  # elasticsearch-model を利用したデータ定義とマッピング定義
end

新設した*OpensearchSearchableはOpenSearch用のconcernです。

# 新規: OpenSearch用
module OpensearchSearchable
  extend ActiveSupport::Concern

  included do
    searchkick index_name: Rails.configuration.x.application[:opensearch][:index_name],
               settings: Rails.configuration.x.application[:opensearch][:settings],
               callbacks: false,
               merge_mappings: true,
               mappings: search_mappings

    def search_data
      # searchkick を利用したデータ定義
    end
  end

  module ClassMethods
    def search_mappings
      # searchkick を利用したマッピング定義
    end
  end
end

merge_mappings: trueを指定することで、独自に定義したマッピングをSearchkickの自動生成マッピングにマージしています。

callbacks: falseを指定することで、Searchkickの自動インデクシングを無効化し、既存のEmbulkによるインデクシングとの競合を防いでいます。

2. インデクシングのダブルライト戦略

移行期間中、ElasticsearchとOpenSearchの両方にデータを投入するダブルライトを実施しました。WEARのインデクシングは日次バッチによる洗い替え方式のため、ダブルライトを開始した時点で既存データも含めてOpenSearchに自動で同期されます。そのため、既存データの移行作業を別途行う必要はありませんでした。

embulk-outputの変更

前述の通り、既存の構成ではEmbulkを介して、BigQueryからデータを取得してElasticsearchにインデクシングしていました。インデクシング時のBigQueryのクエリコストが高額なため、OpenSearchにもインデクシングを行う際に単純にジョブを複製してしまうと、費用が2重に掛かってしまうという課題がありました。

そこで、embulk-outputの出力先をElasticsearchとOpenSearchの両方に向けることで、SQLの実行は一度だけで双方にデータを転送できるようにしました。

移行前はElasticsearchのみに出力していました。

# Elasticsearchへのインデクシング時
out:
  type: elasticsearch
  mode: insert
  nodes:
  - {host: {{ elasticsearch_host }}, port: {{ elasticsearch_port }}}
  index: {{ elasticsearch_index }}
  {% Elasticsearchの設定値 %}

ダブルライト時はtype: multiを使い、ElasticsearchとOpenSearchの両方に出力しました。

# ElasticsearchとOpenSearchにダブルライトするインデクシング時
out:
  type: multi
  outputs:
    - type: elasticsearch
      mode: insert
      nodes:
      - {host: {{ elasticsearch_host }}, port: {{ elasticsearch_port }}}
      index: {{ elasticsearch_index }}
      {% Elasticsearchの設定値 %}
    - type: elasticsearch
      mode: insert
      nodes:
      - {host: {{ opensearch_host }}, port: {{ opensearch_port }}}
      index: {{ opensearch_index }}
      {% OpenSearchの設定値 %}

ダブルライトのためにembulk-output-multiを新たに導入し、複数出力先への分岐を実現しました。OpenSearch側の出力も type: elasticsearch を指定しています。embulk-output-elasticsearchはOpenSearchとのAPI互換性により、そのままOpenSearchへの出力にも利用できました。

RakeタスクとDigdagワークフローの追加

OpenSearch向けのインデックス操作のRakeタスクとDigdagワークフローを作成し、OpenSearchに対しても実行できるようにしました。

# 既存のElasticsearchのインデックス作成
+create_index_elasticsearch:
  sh>: ... rails "elasticsearch:create_index[${index_name}]"

# 追加したOpenSearchのインデックス作成
+create_index_opensearch:
  sh>: ... rails "opensearch:create_index[${index_name}]"

3. クエリ種別ごとの動作確認

OpenSearch移行後にすべてのクエリ種別が正常に動作するかをQA環境で確認しました。

確認の目的と方針

Elasticsearchに送信されるクエリの種別ごとに、OpenSearch上でも同等の結果が返ることを確認しました。クエリ種別が重複するエンドポイントは確認対象外とし、効率的に網羅性を担保しました。

確認対象の抽出方法

確認対象の抽出は以下の手順で行いました。

  • 対象エンドポイントの洗い出し:リポジトリ内でElasticsearchのQueryクラスを呼び出している箇所をリストアップ
  • WEAR Webの対象画面の特定:Webマスタ仕様書から対象エンドポイントが使用されている画面を確認
  • クエリの特定:APIのリクエストパラメーターから生成されるOpenSearchのクエリJSONを特定し、使用されているクエリ種別を分類

確認したクエリ種別

以下のクエリ種別を対象に、WEAR iOS・Android・Webの各プラットフォームで動作確認を実施しました。

分類 クエリ種別
検索クエリ termtermsrangenestedboolfilter/must_not/must/should)、function_scoreexists
ソート sort
ページング fromsize
グループ化 collapse
複合検索 msearch

確認方法

WEAR iOS・Android・Webの各プラットフォームで、以下の方法で確認しました。また、対応するRSpecテストを実行し、OpenSearchに対するクエリが正常に動作することはCI上で確認しています。

  • WEAR iOS・Android:QA環境のAPIに対してcurlコマンドでリクエストを送信し、レスポンスを確認。
  • WEAR Web:ブラウザ上で対象画面を操作し、APIレスポンスと画面表示を目視確認。

すべてのクエリ種別で正常な動作を確認し、負荷試験に進みました。

4. 負荷試験

本番リリース前に、OpenSearchクラスターがElasticsearch利用時と同等のリクエスト量を処理できるかを確認するため、QA環境で負荷試験を実施しました。

試験条件

  • QA環境のOpenSearchクラスターを本番環境のElasticsearchと同等のスペックに設定
  • 検索エンドポイントのRedisキャッシュを無効化し、OpenSearchへの直接的な負荷を計測
  • k6を用いて、各検索エンドポイントに対して本番のピーク帯のMAX rps相当のリクエストを6時間継続

試験結果

  • レイテンシ:Datadog APMで各検索エンドポイントのp99レイテンシを直近1か月の平均と比較した結果、OpenSearchがボトルネックとなるレイテンシ劣化は観測されなかった
  • エラー:Datadog APMで各検索エンドポイントを確認した結果、OpenSearch起因のエラーは発生しなかった
  • クラスターメトリクス:本番のピーク帯MAX値相当のリクエストを6時間継続した。CPUUtilizationはリクエスト量に対して許容範囲内、JVMMemoryPressureは本番環境と同程度であり、各種メトリクスに大きな影響はなかった

この結果をもとに、カナリアリリースによる段階的な本番投入を判断しました。

5. カナリアリリースによる段階的トラフィック移行

本番リリースでは、カナリアリリースによって段階的にトラフィックを移行しました。

リリーススケジュール

日時 内容
2025/9/30 13:00 canary podの作成、APIの正常確認、1%リリース
2025/9/30 17:00 10%リリース
2025/10/1 14:00 50%リリース
2025/10/2 13:30 100%リリース
2025/10/2〜10/6 正常性の継続監視

各段階での確認項目

各段階で以下の項目を確認し、問題がなければ次の段階に進みました。

  • OpenSearchのレイテンシ比較とエラー確認:Datadog APMでOpenSearchとElasticsearchのレイテンシを比較し、劣化がないことを確認。OpenSearchのエラーがないことを確認。
  • 各検索エンドポイントのレイテンシ比較とエラー確認:Datadog APMで各検索エンドポイントのレイテンシを比較し、劣化がないことを確認。OpenSearch起因のエラーがないことを確認。
  • クラスターメトリクス:SearchLatency、IndexingLatency、CPUUtilization、JVMMemoryPressureを監視し、劣化がないことを確認。
  • インデックスの整合性:ElasticsearchとOpenSearchのドキュメント件数に差異がないことを確認。

確認結果

  • OpenSearchでレイテンシが低い傾向を確認した(平均・最小・最大いずれもOpenSearchの方が高速)
  • OpenSearch起因のエラーが発生しなかった
  • OpenSearchでJVMMemoryPressureがやや高い傾向にあったが、MAXでも60%未満であり問題なかった
  • CPUUtilizationはOpenSearchの方が低い傾向だった
  • 100%リリース後の監視でも劣化が見られず、移行完了を判断した

効果と得られた知見

移行後のアーキテクチャ

移行後の検索基盤は、以下のシステム構成になりました。

移行後のWEAR検索基盤のシステム構成図

  • 検索機能:searchkick gemを利用し、検索メソッドを提供。内部ではopensearch-ruby gemが提供するOpenSearch::Clientを通じてOpenSearch Serviceと通信
  • マッピング定義:searchkick gemを利用し、モデルにマッピング定義を記述
  • インデックス操作:opensearch-ruby gemを利用し、Rakeタスクによるインデックス作成、エイリアス切り替え、旧インデックス削除、ドキュメント削除
  • インデクシング:既存のDigdagワークフローとEmbulkによる定時バッチ(日次の洗い替えと差分更新)でのインデクシングを継続

Searchkickとopensearch-rubyへの移行による保守性向上

elasticsearch-modelからsearchkickelasticsearchからopensearch-rubyに移行し、以下の効果と知見がありました。

  • OpenSearchの将来的なバージョンアップへの追随が容易になった
  • reindex処理のアトミックなエイリアス切り替えが組み込みで利用可能になった
  • ハイブリッド検索の機能が利用可能になった
  • opensearch-rubyはAPI互換性が高く、Rakeタスクの移行コストが低かった

並行稼働時のインデクサー移行方法

ダブルライト戦略により、以下のメリットがありました。

  • ElasticsearchとOpenSearchを並行稼働させることで、いつでも切り戻し可能な状態を維持
  • Embulkを利用した既存のインデクシングパイプラインを最小限の変更で拡張
  • 移行時のクエリコスト増大を防止
  • Digdagワークフロー層での制御により、アプリケーションコードへの影響を最小化

カナリアリリースの有効性

段階的なトラフィック移行により、以下の知見が得られました。

  • 1%リリースと10%リリースで、JVMMemoryPressureの変動が大きく見られた。これは、リリース後の低トラフィック時にキャッシュヒット率が低いことに起因する可能性が高く、50%リリース以降は安定した。
  • 検索基盤のような影響範囲の大きいミドルウェアの移行にはカナリアリリースが有効であることを実感した。

おわりに

本記事ではWEARにおけるElasticsearch 7.10.2からOpenSearch 2.19.0への移行プロセスを紹介しました。同様の移行を検討している方の参考になれば幸いです。

ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。

corp.zozo.com

カテゴリー