MNTSQ Techブログ

「MNTSQ(モンテスキュー)」のTechブログです。

EC2 上の Elasticsearch を AWS マネージドの OpenSearch に無停止で移行した記録(インフラ目線)

MNTSQ Tech Blog TOP > 記事一覧 > EC2 上の Elasticsearch を AWS マネージドの OpenSearch に無停止で移行した記録(インフラ目線)

はじめに

弊社の検索基盤は、長らく EC2 上でセルフホストした Elasticsearch 7.x で動いていました。 これを AWS マネージドの OpenSearch Service に無停止で移行するプロジェクトが、2025年10月から2026年6月にかけて動きました。 本稿はこの移行を SRE の立場から振り返る記録です。 検索品質やアプリケーションのコード対応といった SWE1 視点の話は別稿に譲り、本稿ではインフラ構成、移行の進め方、運用とコストの現実、そしてハマりどころに絞って取り扱います。

先に断っておくと、この移行は「セキュリティ対応のために始めた小さな作業が、気づけば大きな基盤刷新になっていた」という経緯を辿っています。 何がその転換点だったのかも含めて書いていきます。

なぜ移行したか

きっかけは地味なところにありました。 2025年10月、CI 上で Elasticsearch 7 系のコンテナが起動しなくなり、検索まわりのテストがことごとく落ちるようになったのです2

ローカルでは問題なく動くのに CI 上でだけ動かない。 実行環境側で何が変わってそうなったのかまでは詰めきれませんでしたが、原因が Elasticsearch 7 系の古さ、より正確にはそこで使われているライブラリにあることは見えており、当座は該当するテストをスキップして凌ぎました。 ちょうど Elasticsearch 7 系は EOL を迎えつつあり、遅かれ早かれ何らかの対応が必要だとは認識していたところに、CI の破綻という分かりやすい形で背中を押された格好でした。

この時点での主題は、あくまで Elasticsearch 8 系へのアップグレードでした。 実は当時から遡ること1年ほど前(2024年11月)にも Elasticsearch 8 または OpenSearch のどちらへ行くかを検討したことがあり、そのときは「コストメリットもあまりないので微妙」という判断で OpenSearch は見送られていました。 検索まわりには古くからの独自 Lucene プラグイン(日本語の読みを使った検索候補の補完機能)が組み込まれており、これが Elasticsearch 8 系はおろか OpenSearch でも動かないという制約につき、選択肢を狭めていた事情もあります。

流れが変わったのは、この独自プラグインを廃止する意思決定が、Elasticsearch 8 移行に向けて別途進んでいたことに、CI 破綻を受けた検討の場でメンバーの一人が気づいたときでした。 「独自プラグインが理由で OpenSearch に行けなかったはずだが、その独自プラグインはどのみち今回の対応で廃止する予定になっている。 ならば制約は実質的に無くなっているのではないか」という指摘です。 あわせて、マネージドサービスに寄せることで自前運用に起因するインデックス破損等のリスクが下がるはず、という意見も複数のメンバーから出ました。 この2点が決め手となり、Elasticsearch 8 ではなく OpenSearch への移行を実施するという意思決定がなされました。

無停止での移行方針(後述「進め方」の節を参照)も同じ議論の中で固まりました。 一括で止めて移行する案も検討されましたが、テナント数が増え続けている状況では、限られたメンテナンス時間内に確実にやり切れる保証がありません。 何より、切り戻しの効かない一発勝負に踏み切るリスクの方が重いという判断から、ダブルライトによる無停止移行を選んでいます。

アーキテクチャ

移行前は Elasticsearch のクラスタをテナント群ごとに複数用意する構成を取っていました。 各クラスタは数台のノードで構成され、クラスタの数はテナントの増加にあわせて増やしていく運用です。 これを、単一の OpenSearch ドメインにデータノードを集約する構成に転換しました。

移行前の構成は次のような形です。

flowchart TB
  API["検索 API"]

  subgraph SELF["EC2 上のセルフホスト Elasticsearch 7.x"]
    C1["クラスタ A<br/>ノード数台<br/>(マスターは兼任)"]
    C2["クラスタ B<br/>ノード数台<br/>(マスターは兼任)"]
    C3["クラスタ C, D, ...<br/>テナント増加に<br/>あわせて増設"]
  end

  API --> C1
  API --> C2
  API --> C3

テナント群ごとにクラスタが割り当てられ、専用のマスターノードは置かずデータノードに兼任させています。 テナントが増えればクラスタを足す、という形で伸ばしてきた構成です。

移行後はこうなりました。

flowchart TB
  API["検索 API"]
  EP["カスタムドメイン名<br/>スプリットビュー DNS + ACM 証明書"]

  subgraph DOM["OpenSearch ドメイン(単一 / VPC 内 / FGAC 有効)"]
    MST["専用マスターノード x3<br/>3 AZ に分散"]
    DAT["データノード<br/>全テナントぶんを 1 ドメインに集約"]
    MST -. クラスタ状態の管理 .-> DAT
  end

  API --> EP
  EP --> DOM

クラスタという単位そのものが無くなり、データノードは単一ドメインの中に集約されました。 クラスタ管理はデータノードから切り離され、専用マスターノードの仕事になっています。

集約にあたって議論になったのが、全データノードにシャードを均等配置することのデメリットです。 AWS の担当者を交えた技術ディスカッション3の中で、単一ドメインに寄せてもマネージドサービスとして問題なく運用できるとの見立てが得られ、この構成に決めています。

検索対象のインデックスには規模の差があり、特に契約書本体を格納する巨大インデックスと、それ以外の一般的なインデックスとでは望ましいシャード数が大きく異なります。 そこで index template を優先度違いで2段構成にし、対象インデックスの種類に応じて適用されるシャード数を出し分けるようにしました。

インデックス作成時に複数のテンプレートのパターンに一致した場合、priority がもっとも高いものだけが適用され、下位のテンプレートは無視されます。 巨大テナントのインデックスは共通テンプレートのパターンにも一致しますが、専用テンプレートのほうが priority が高いため、シャード数はそちらの値が採られます。 図にすると次のような関係です。

flowchart TB
  IDXA["一般テナントの<br/>インデックス"]
  IDXB["巨大テナントの<br/>インデックス"]

  TC["共通テンプレート<br/>priority 10<br/>documents_*, clauses_*, ...<br/>number_of_shards = 3"]
  TH["巨大テナント<br/>専用テンプレート<br/>priority 100<br/>対象インデックス名を列挙<br/>number_of_shards = 10"]

  IDXA ==>|適用される| TC
  IDXB -.->|一致するが無視される| TC
  IDXB ==>|適用される| TH

専用テンプレート側のシャード数は、1シャードあたりのデータ量が推奨される上限を超えないことを目安に決めています4

ネットワーク構成は VPC 内に閉じた形を採ったうえで、きめ細かなアクセスコントロール(FGAC)を有効化しています。 OpenSearch のドメインエンドポイントはそのままでは扱いにくいため、環境ごとに専用のカスタムドメイン名を用意し、スプリットビュー DNS と ACM 証明書で疎通できるようにしました。 この構成の詳細と Terraform コードの例は以下拙稿で扱っているので、そちらも参照ください。

ドメインそのものは Terraform で管理しています。 ここまでに触れた構成上の判断が実際のコードではどう現れるのか、要点だけ抜き出すと次のような形になります。

/*
 FGAC のマスターユーザに充てる IAM ロールを取得する
 IAM Identity Center が各メンバーアカウントへ配置する管理者権限セットのロールを使う
 */
data "aws_iam_roles" "sso_administrator" {
  name_regex  = "AWSReservedSSO_Administrator_.*"
  path_prefix = "/aws-reserved/sso.amazonaws.com/"
}

resource "aws_opensearch_domain" "main" {
  domain_name    = local.domain_name
  engine_version = "OpenSearch_2.19"

  cluster_config {
    instance_type  = var.instance_type
    instance_count = var.instance_count

    dedicated_master_enabled = true
    dedicated_master_count   = 3
    dedicated_master_type    = var.master_node_instance_type

    zone_awareness_enabled = true
    zone_awareness_config {
      availability_zone_count = length(var.private_subnet_ids)
    }
  }

  # インターネットには出さず VPC 内に閉じる
  vpc_options {
    subnet_ids         = var.private_subnet_ids
    security_group_ids = [aws_security_group.opensearch.id]
  }

  # 自動生成のドメインエンドポイントは扱いにくいのでカスタムドメイン名を与える
  domain_endpoint_options {
    enforce_https = true # advanced_security_options の要請による

    custom_endpoint_enabled         = true
    custom_endpoint                 = var.custom_endpoint
    custom_endpoint_certificate_arn = var.custom_endpoint_certificate_arn
  }

  advanced_security_options {
    enabled = true
    master_user_options {
      master_user_arn = one(data.aws_iam_roles.sso_administrator.arns)
    }
  }

  encrypt_at_rest {
    enabled = true
  }

  node_to_node_encryption {
    enabled = true
  }
}

専用マスターノードを3台にしているのは、消去法の結果です。 開発者ガイドでは1台構成が明示的に禁止されており、API リファレンスによれば2台と4台も設定できません。 選べるのは3台か5台です。 5台にすればマスターの2台同時喪失まで耐えられますが、常時アクティブなマスターは1台だけなので遊休ノードが4台分になり、多くの場合は過剰だとドキュメント自身が述べています。 ゆえに3台としました。

ノード数やインスタンスタイプを変数に寄せているのは、同じモジュールを検証環境から本番環境まで使い回すためです。 zone_awareness_config に渡すサブネット数と instance_count の関係には注意が必要で、AZ 数を下回るノード数を指定すると apply が通りません。 カスタムドメイン名の側は、この custom_endpoint に対応する CNAME レコードをスプリットビュー DNS のゾーンへ別途登録して名前解決させています。

なお、フィルタやアナライザーといった検索の中身に関わる設定は、独自プラグイン以外は基本的にすべてそのまま持ってくる方針を最初に決めています。 移行と同時に最適化まで検討すると収拾がつかなくなる、という判断からで、整理は移行完了後の課題として切り出しました。

進め方

採用したのはダブルライト方式です。 全体の流れは次のとおりです。

  1. 新しい OpenSearch ドメインを構築する
  2. 既存データを OpenSearch 側に流し込みつつ、以降の書き込みは Elasticsearch と OpenSearch の両方に対しておこなう(ダブルライト開始)
  3. 新旧の整合性を確認しながら、検索結果を新旧で比較するシャドウテストを実施する
  4. 問題がないことを確認できたら、実際にユーザへ返すレスポンスの参照先を OpenSearch 側に切り替える
  5. Elasticsearch への書き込みを止める
  6. Elasticsearch 側のリソースを削除する

出発点は、Elasticsearch だけが読み書きの対象になっている状態です。 OpenSearch のドメインは先に作っておきますが、この時点では中身が空で、どこからも参照されていません。

flowchart LR
  W["アプリケーション<br/>(書き込み)"]
  R["検索 API<br/>(読み取り)"]

  ES["Elasticsearch(EC2)"]
  OS["OpenSearch<br/>構築しただけで中身は空"]

  W ==>|書き込み| ES
  R ==>|ユーザに返すのはこちら| ES
  R ~~~ OS

ここからダブルライトを始めて参照先を切り替えるまでのあいだ、各コンポーネントの関係は次のようになります。

flowchart LR
  W["アプリケーション<br/>(書き込み)"]
  R["検索 API<br/>(読み取り)"]
  B["インデックス再構築<br/>ワーカー<br/>(既存データの移送)"]

  ES["Elasticsearch(EC2)"]
  OS["OpenSearch"]

  W ==>|書き込み| ES
  W ==>|書き込み| OS
  R ==>|ユーザに返すのはこちら| ES
  R -.->|比較のために投げるだけ| OS
  B ==>|既存データを流し込む| OS

書き込みを二重化しているので、OpenSearch 側も常に最新のデータを持ちます。 その状態で検索は Elasticsearch の結果だけをユーザに返し、OpenSearch には同じクエリを投げて結果を突き合わせるに留めます。 この段階では、OpenSearch 側の検索結果がどれだけ壊れていてもユーザには影響しません。

参照先を切り替えると、こうなります。

flowchart LR
  W["アプリケーション<br/>(書き込み)"]
  R["検索 API<br/>(読み取り)"]

  ES["Elasticsearch(EC2)<br/>切り戻し先として維持"]
  OS["OpenSearch"]

  W ==>|書き込み| ES
  W ==>|書き込み| OS
  R ==>|ユーザに返すのはこちら| OS

変わったのは、ユーザに返す結果をどちらから取るかだけです。 書き込みは両方に続けているため Elasticsearch のデータも最新のまま保たれ、問題が起きれば参照先を戻すだけで復旧できます。

切り替えたあとしばらく様子を見て、問題が出ないことを確認してから、Elasticsearch への書き込みを止めて残ったリソースを片付けます。

flowchart LR
  W["アプリケーション<br/>(書き込み)"]
  R["検索 API<br/>(読み取り)"]

  OS["OpenSearch"]

  W ==>|書き込み| OS
  R ==>|ユーザに返すのはこちら| OS

切り戻し先が無くなるのはこの段階です。

環境ごとの適用順は、検証環境を先行させ、本番環境は後段に回しています。 本番の切り替えに際しては、テナント単位で段階的に有効化できる仕組みを活用し、問題があった場合はすぐに元に戻せる状態を維持しながら進めました。 結果として、本番切り替えは無停止で完了し、顧客からの問い合わせもゼロでした。

ただし、移行に要した期間は当初の想定を大きく超えました。 プロジェクト全体を、1〜2ヶ月の集中作業に1ヶ月のバッファを見込む程度に見積もっていましたが、実際には本番の全件インデックス再構築に着手してからだけで2ヶ月を超えています。 テナント数が思いのほか増えていたこと、そして新旧の挙動差異への対応に都度時間を取られたことが主な要因です。

運用とコストの現実

マネージドサービスに移行したことで、真夜中にディスク逼迫で reindex をおこなう、プライマリとレプリカのスペックアップ手順を間違えないよう気を張る、といった自前運用ならではの負荷からは解放されました。 ノード追加やディスク拡張がコンソール操作で完結するようになったのは、素直な嬉しさがあります。 定期スナップショットについても、以前は仕組みとして整備されていなかったものが、移行後は自動で取得され、保持されるようになり、リストアの選択肢が確保されました。

コストは、構成が落ち着いたあとの日次で見ると、Elasticsearch 時代のもっとも安かった時期と比べても2割ほど低い水準に収まりました。 効いているのは、1ノードあたりのシャード数に上限があるために、リソースが余っていてもクラスタを増やさざるを得なかった構造が、単一ドメインへの集約によってなくなったことです。

一方で、移行の途中ではコストが大きく膨らみました。 Elasticsearch と OpenSearch を並行して動かしたうえ、既存データの全件インデックス再構築を進めるあいだは OpenSearch 側を厚めに構えていたため、ピークにあたる月の日次コストは移行前のおよそ倍に達しています。 並行して動かす期間をいかに短く畳むか、という意識が薄かったことは反省点です。

もう一つの反省点は、コストを抑えるための リザーブドインスタンス 購入のタイミングです。 移行が完了して構成が落ち着く前に契約してしまい、後からの調整余地を狭めてしまいました。 コスト最適化は移行が一段落してから着手する方が、結果的には無駄が少なかっただろうと思います。

シャード数の上限(1ノードあたり1,000という cluster.max_shards_per_node の既定値)にも移行の前後で何度か踏みました。 この上限はノード数に比例して実質的な天井が決まるため、ノード数の少ない検証環境ほど早く頭を打ちます。 移行時にシャード設計を作り込まず既存設定を横引きしたことのツケが、こういった形で後から現れています。

ハマりどころ

Terraform プロバイダのバグ

Terraform でのドメイン構築では、プロバイダのバグに難儀させられました。 パッケージの関連付けを管理するリソースで、既存の関連付け済みリソースをインポートできない、再適用すると意図しないエラーが返る、複数のパッケージを同時に適用しようとすると競合が起きる、といった挙動が確認できたため、アップストリームに issue を報告しつつ、手動での調整を織り交ぜて apply を進める必要がありました。

カスタムパッケージの Content-Type

検索の日本語解析には Sudachi を使っており、プラグイン本体は AWS がマネージドパッケージとして提供しているものをそのまま利用できます。 一方で辞書は、移行元の Elasticsearch が 2020年12月版という古いものを使っていた都合上、同じ版を踏襲する形で自前で用意しました。 配布元から zip で取得して展開し、S3 を経由してカスタムパッケージとして登録する、という仕組みです。

S3 に置いた辞書ファイルが OpenSearch 側に受け付けられず、internal error になりました。 原因は Content-Type です。 .dic という拡張子が text/x-c と判定されており、OpenSearch が要求する binary/octet-stream になっていなかったのです。 当時この制約はドキュメントに記載がなく、AWS サポートに問い合わせて初めて判明しました。 その後フィードバックが反映され、現在はカスタムパッケージのドキュメントに明記されています。 application/octet-stream では失敗する、という当時は分からなかった情報も添えられています。

tar コマンドの実装差

zip の展開処理を手元の macOS で書いたのですが、BSD tar は zip も展開できてしまうため、tar のまま動作確認を終えてしまいました。 GitHub Actions(Linux)の GNU tar は zip を扱えず、そこで失敗します。 unzip に切り替えて解消しました。

自動スナップショットと運用タスクの競合

移行が完了した後にも、新しい種類のハマりどころが出てきました。 OpenSearch の自動スナップショット取得と、テナント作成などの運用系タスクの実行タイミングが重なり、タスク側がタイムアウトする事象です。 当初は「特定の時間帯は一律で自動スナップショットを止める」という対策を検討しましたが、保護されない時間が広くなりすぎるとして採用を見送り、最終的には「保護したい作業の実行中だけ、参照カウント方式で一時停止して再開する」仕組みに落ち着きました。 移行そのものが完了した後も、運用フェーズならではの課題が形を変えて現れ続けるのだと実感した出来事です。

おわりに

今回の移行を振り返って一番印象に残っているのは、「なぜやるか」が途中で静かに変わっていたという点です。 始まりは EOL 対応という守りの話でしたが、実際に大きな決断を後押ししたのは、過去に一度見送った選択肢の制約が別の文脈でたまたま解消されていたことに、誰かが気づけたという偶然でした。 大規模な移行の意思決定は、こうした細部の気づきに支えられていることが多いのかもしれません。

無停止でのダブルライト移行は手間のかかるやり方ですが、切り戻しの選択肢を最後まで持ち続けられたことは、本番での事故を防ぐうえで確かな安心材料になりました。 一方でシャード設計は「後から直せばいい」で先送りにした分だけ、後になって顔を出しています。 移行そのものを安全に倒すための「先送り」と、あとで確実に効いてくる「先送り」は別物で、後者をどこまで見分けておけたかが、この移行の反省点だったように思います。

この記事が、Elasticsearch から OpenSearch への移行、またはセルフホスト版 Elasticsearch から AWS マネージドサービスとしての OpenSearch への移行検討の材料として一助になれば幸いです。

文責:MNTSQ 株式会社 SRE 秋本

注記:この記事は、Mermaid 図を含む内容の9割程度を、文責者の過去記事や社内の関連ドキュメントをもとに Claude Opus 5 が執筆しています。

追伸:本稿の執筆と推敲にあたっては、日本語技術文書の規範をまとめた k16shikano 氏の文書 を参照しました。 記して感謝します。


  1. ソフトウェアエンジニアの意です
  2. 古い Elasticsearch のコンテナが、新しいカーネルや systemd と組み合わさると起動しなくなる事象自体は広く知られています。JVM の cgroup 検出が失敗するもので、Elasticsearch に固有の話ではなく古い Java アプリケーション全般で起こります(mastodon/mastodon#33635)。GitHub Actions のランナーイメージでも、2026年2月にカーネルが 6.11 系から 6.14 系へ上がった際に Elasticsearch 7.17 が起動しなくなる事象が報告されており(actions/runner-images#13684)、我々の CI でもこの時期に再び Elasticsearch 関連のテストが落ちています。EOL の近い古いミドルウェアは、こちらの都合とは無関係に実行環境の更新で動かなくなる、という一例です。
  3. 移行の検討にあたっては、AWS のソリューションアーキテクトおよびスペシャリストの方々に技術ディスカッションの場でご相談させていただきました。シャード設計やノード構成の妥当性について具体的な見立てをいただけたことは、構成を決めるうえで大きな助けになりました。この場を借りて御礼申し上げます。
  4. AWS は1シャードあたりのサイズについて、検索ワークロードでは 10〜30 GiB、ログワークロードでは 30〜50 GiB を目安とし、50 GiB を上限とすることを推奨しています(Amazon OpenSearch Service の運用上のベストプラクティス)。