Elasticsearch 6系および7系への無停止アップグレード事例 - はてなブックマーク編

はてなブックマークチームのエンジニアリングマネージャー id:yigarashi です。はてなブックマークでは全文検索エンジンとしてElasticsearchを利用しており、最近6.8および7.10への無停止アップグレードを実施しました。非互換な変更の影響を真っ向から受けるユースケースでしたが、リスクを分割し少しずつ対処することで迅速かつ安全にアップグレードできました。本記事ではポイントを絞りつつアップグレードの様子をまとめます。

アップグレードに至る経緯

はてなブックマークでは長らくElasticsearchの5系を使っていました。エントリーとブックマークの検索を中心にサービスのかなりの部分を支える重要なミドルウェアですが、大きな変化は以下の記事にある2020年のAWSへの移転が最後(その時もメジャーバージョンは変わらず)で、なかなかElasticsearchの面倒を見られていませんでした。

developer.hatenastaff.com

もちろんその間アプリケーション基盤への投資をしていなかったわけではなく、他のコンポーネントのAWS移転およびコンテナ化、データ基盤の整備、MySQL 8.0化など、より優先度の高い改善に順番に取り組んでいたのでした。とはいえずっと放置することもできないわけで、今秋ついにElasticsearchと向き合うことになりました。

アップグレードの目的

アプリケーション基盤への投資を行うにあたって、その効果を言語化し投資の正当性を説明するのはエンジニアの責務です。SREの id:cohalz にアドバイスをもらいながら、今回のアップグレードの目的を以下のように定めました。

  • マネージドサービスで古いバージョンを使い続けるリスクの排除
    • はてなブックマークではAWS OpenSearch Serviceを使っています。2023/10現在はElasticsearch 5系もエンジンとしてサポートされていますが、Elastic社側のEOLはとっくに過ぎています。OpenSearch Serviceにおいても、サポートが終了して急なアップグレードを案内される可能性は否定できません。それが事業の重要なタイミングに重なってしまうとダメージが大きくなります。ダメージが少ないタイミングを狙って、能動的にアップグレードを実施することには価値があると考えられました
  • インスタンスタイプ変更によるインフラ費用の削減
    • これまでのコスト削減の取り組みによってElasticsearch以外のコンポーネントの費用はかなり最適化されており、Elasticsearchがインフラ費用のかなりの部分を占める状態になっていました。なんとかボトルネックに対処したいと考える中で、Elasticsearchを7系までアップグレードすることで、インスタンスタイプの選択肢が増えてさらなるコスト削減が見込めることがわかりました

以上の目的を達成するために、今回のプロジェクトでは5.7 → 6.8 → 7.10の2段階アップグレードを実施することに決まりました。漠然と「バージョンを上げる」というレベルで思考を止めていたら6系へのアップグレードで満足していた可能性が高く、目的や課題を詳細化する重要性がわかります。

アップグレードの特に非自明なポイント

ここからは実際のアップグレードについて紹介します。取り上げるのは次の2点です。

  • リスクを低減しながら無停止でアップグレードを行う段取り
  • 6系で実施されたmultiple mapping types廃止への対応

無停止でアップグレードを行う段取り

こうしたアップグレードを行うにあたって、無停止で実施できるに越したことはありません。サービスの可用性を損なわないのに加えて、固定のメンテナンスウィンドウを取って書き込みを禁止するといった複雑なオペレーションを避けることができます。場合によっては無停止で実施するための工夫の方が高くつくこともあるでしょうが、今回は検討の結果、うまく低コストに実施できるプランが見えたので無停止のアップグレードを行うことにしました。

またOpenSearch Serviceでのアップグレードでは、インプレースかクラスタの複製かの選択肢があります。インプレースアップグレードは低コストですが、巻き戻しが難しいためアップグレード時のリスクは上がります。クラスタの複製は切り戻しが簡単にできますが、本番相当のクラスタを複製するためコストが増えます。はてなブックマークではstaging環境に本番データを復元した低スペックのクラスタを常駐させていたので、それを使った検証でリスクを下げられると判断し、インプレースアップグレードを実施することにしました。

ここからは最終的に採用された4ステップの段取りを紹介します。新しいインデックスを同じクラスタ内に作成し、新旧インデックスが同期した状態を作り、少しずつ切り替えて旧インデックスを脱出するというのが大筋です。実際に6.8、7.10の2回とも無停止でアップグレードすることができました。

1. 新しいインデックスを作成し、ドキュメント作成および削除を新旧両方のインデックスに対して行う

Elasticsearchのアップグレードでは、ほとんどの場合インデックスの作り直し(再インデックス)が必要になります。例えば、あるバージョンで作成したインデックスは前後1メジャーバージョンとしか互換性がなく、その範囲を超える場合は再インデックスする必要があります。また、後述するようにそもそもインデックスやマッピングの仕様が変わってしまい再インデックスが必要になることもあります。新しく作るインデックスに限って有効にできない機能などもあり、テストや手元環境のことを考えると再インデックスせざるを得ないこともあります。

今回のケースでは2回とも再インデックスが必要だったので、その前提で段取りを説明します。

まずは通常のサービス稼働に伴って実行されているドキュメント作成と削除を新しいインデックスにも行うようにしました(ダブルライト)。普段インデックスに書き込みリクエストしているところで、新インデックスにもドキュメントの形を調整しつつ書き込みリクエストをすれば良いだけで、特に難しいことはありません。

2. Reindex APIでダブルライト開始以前のデータを同期する

ステップ1でダブルライト以降の情報は同期できたので、ダブルライト以前の情報も同期していきます。今回はElasticsearchのReindex APIを用いることにしました。Reindex APIでは内部的にScroll APIを用いているため、再インデックス開始時のスナップショットがインデックスされてしまうことに注意して進めなければいけません。

上述のAWS移行時の記事にある通り、ドキュメントのバージョンニングの仕組みが導入されていたので、基本的には素朴に全件を再インデックスするとうまくいくようになっていました。例えば、再インデックス開始後にドキュメントの追加が発生すると、素朴にはスナップショットの古いデータで上書きされてしまいますが、ドキュメントにバージョンを付与しておくことで新しい方のデータが生き残ります。

しかし、ひとつだけケアが必要なパターンがありました。それは旧インデックスのみに存在するドキュメントを再インデックス開始後に削除したケースです。この場合だけは、新インデックスでは存在しないドキュメントを削除しようとして空振りするのでバージョン情報が残らず、スナップショット上の本来消えて欲しいデータがインデックスされてしまいます。これに関しては、アプリケーション側でドキュメント削除のログを取っておき、それをあとから簡単なスクリプトで書き戻す対応を行いました。

最後に、ドキュメント数の調査や主要なユースケースのクエリの結果の比較を行い、再インデックスがうまくいっていることを確認しました。この確認をどこまで実施するかは悩みどころでしたが、アプリケーションのインデックス処理が冪等になっており、最悪データベースの情報をマスターとして復旧が可能になっていることから、簡単な調査で済む主要なケースの確認までにとどめました。

3. 実装を少しずつ新インデックスに向けていく

ここまでのステップで、新旧インデックスが同期された状態になりました。これにより特定のメソッドだけ新インデックスに向けるといった変更が可能になっています。特にインデックスの構造が変わっている場合は新しいインデックスに切り替えるのはリスクが高い部分ですが、それを小さく分割して実施できたのは非常に気が楽でした。

4. インプレースアップグレードの実施

ここまでで新バージョンで動作する新インデックスのみを使っている状態になったので、ついにアップグレードを実施します。まずはCIで使っているElasticsearchのバージョンを上げ、テストが全て通るように修正しました。次にstaging環境のクラスタをアップグレードし、Elasticsearchが関連する機能を全て触り、動作やアプリケーションログに異常がなくなるまで修正を行いました。

残るリスクはパフォーマンスの観点くらいでしたが、Elasticsearchの負荷の大部分を占めるバックグラウンド処理を一時的に止める合意が取れたので、ユーザー操作起因の処理のみを行う非常に低負荷な状態でアップグレードを実施できることになり、パフォーマンス観点での懸念もほぼ払拭されました。

以上の万全の準備を行い、無停止・無事故のアップグレードを実施することができました。クラスタのアップグレードはOpenSearch ServiceがBlue/Greenデプロイで行ってくれるため、ダウンタイムなく実行されました(こちらもstagingで実施した際に確認済み)。

multiple mapping types廃止への対応

Elasticsearchではインデックスに追加するドキュメントのスキーマを定義することができます。これをmappingと呼びます。Elasticsearch 5系まではmultiple mapping typesという形でひとつのインデックスで複数のmapping typeを定義し、typeごとにドキュメントを格納することが可能でした。MySQLで言うところのデータベースとテーブルのような関係をイメージすると、見た目上はだいたい合っています。

しかしElasticsearch 6系から、このmultiple mapping typesの機能が廃止されることになりました。詳細は以下の記事が詳しいですが、実は上に述べたMySQLのメタファーは内部的な実装と一致しておらず(「見た目上は」と書いたのはそのためです)、メタファーと一致しない振る舞いやパフォーマンスの出ない利用法を誘発してしまう側面があり、廃止する決断に至ったという話のようです。

www.elastic.co

我々はまさにこのmultiple mapping typesを利用していました。「はてなを本文に含むエントリーのブックマーク一覧」のようなブックマーク検索を実現するためには、multiple mapping typesとparent-child relationship(MySQLで言うところの外部キーを使ったJOINのような機能)を使うほかなかったのです。頑張って脱出するしかありません。

どうmappingを変更したか

最終的には上の記事でも提案されているカスタムタイプフィールドを採用しました。

もとのmappingは以下のような形でした(旧バージョンのElasticsearchのレスポンスを再現するのを諦めて勘で書いています。ご了承ください)。bookmark、entryのような複数のtypeを定義して、それらをparent-child relationshipで結ぶような定義になっていました。

{
  "bookmark": {
    "properties": {
      "comment": {
        "type": "text"
      },
      ...
    }
  },
  "entry": {
    "properties": {
      "title": {
        "type": "text"
      },
      ...
    }
  },
  ...
}

それを以下のように変更しました。どのtypeかを表すフィールドを自前で追加し、typeごとのフィールドをひとつのtypeに全て押し込む形としました。検索する時は都度termクエリでtypeの絞り込みをかけます。ここでは省略していますが、parent-child relationshipはjoin typeを素朴に使って置き換えました。

{
  "hatenabookmark": {
    "properties": {
      "type": {
        "type": "keyword"
      },
      "comment": {
        "type": "text"
      },
      "title": {
        "type": "text"
      },
      ...
    }
  }
}

上述のElastic社のブログ記事から、multiple mapping typesにおいても内部的にはLuceneのインデックスが単一であると推測できたのも今回の決定を後押ししました。上に提示したようなmappingに変えたとしてもLuceneのインデックスの様子は変わらないことが期待できました。実際、インデックスサイズやパフォーマンス、クエリの結果にはほとんど変化なくmultiple mapping typesの脱出に成功しています。

他の選択肢は以下のようなものがありましたが棄却されました。

  • typeごとにインデックスを分ける
    • join typeはインデックスを跨いで使うことはできないため、素朴にインデックスを分けてしまうと一部再現できない要件が存在しました
  • インデックスを分けて素朴に非正規化を行う
    • 検索に必要なエントリー情報をブックマークに持たせれば理論上は検索可能ですが、エントリー情報は本文を含む巨大なもので、非正規化によって複製されるとインデックスサイズが何十倍、何百倍にも膨れ上がる可能性が高いと考えられました
  • インデックスを分けて巧妙に非正規化を行う
    • ドキュメントの持たせ方を工夫して、アプリケーションで何段階かクエリを実行すると現実的に同じ結果を再現できることがわかりましたが、工数と複雑さがかなり増加することが予想されました

mappingを変更しつつ再インデックスする方法

上述のようにmultiple mapping typesを脱出するためにmappingを大きく変更することになりました。これに合わせてドキュメントを全て再インデックスする必要があります。Reindex APIでは、再インデックス前に各ドキュメントを通すscriptを定義することができ、今回のようにフィールド名やメタ情報が変わるケースにも対応できるようになっています。たとえばbookmarkを再インデックスする際には以下のようなJSONペイロードを指定しました(一部、実際の様子からは改変)。

{
  "conflicts": "proceed",  // バージョンコンフリクト時にスキップして続行する
  "source": {
    "index": "hatenabookmark-es5",
    "type": "bookmark"
  },
  "dest": {
    "index": "hatenabookmark-es6",
    "type": "hatenabookmark",
    "version_type": "external"  // 自前で含めたバージョン情報を使う
  },
  "script": {
    "source": """
      ctx._source.type = ctx._type;  // ES5でメタ情報だった_typeをカスタムタイプフィールドに載せ替える

      String entryId = ctx._source.entry_id.toString();
      ctx._routing = new StringBuffer(entryId);  // join typeに必要なroutingの指定

      Map join = new HashMap();
      join.put('name', 'bookmark');
      join.put('parent', entryId);
      ctx._source.put('entry_bookmark_join', join);  // join typeフィールドに対応するobjectを挿入
      ctx._parent = null;  // ES5のparent-child relationship用のフィールドを潰す
    """
  }
}

このmappingの変更は不確実性がかなり高かったので、プロジェクト全体を計画して進行する前にstaging環境で再インデックスを試して検証を行いました。join typeが機能すること、カスタムタイプフィールドによるフィルターで著しく遅くならないことを事前に確認して安心して進められる状態を作りました。

レビュー体制について

このアップグレードプロジェクトでは、プロジェクトの難易度や他プロジェクトの状態を鑑みて id:yigarashi が単独で進行するフォーメーションを取りました。この進め方にあたってはレビューと知識移転が大きな課題になります。自分が取り組んでいない複雑なプロジェクトのコードレビューを突然行うのは、他メンバーにとって大きな負担になります。また、この複雑さのプロジェクトを終えて、それを再現できるのが自分だけというのは勿体無い話です。

以上の観点から、今回のプロジェクトではコードレビューや相談を全てテックリードに集中させ全ての知識が集まるようにしました。さらに、お願いするレビューが少しでも非自明になる時は必ずペアレビューをして全てのコードを一緒になぞるようにしました。こうした工夫で深いコンテキストの共有を維持し続け、プロジェクト終盤にはテックリードからも「だいたいこんな感じとわかるのでさっとレビューできる」との声がありました。次回アップグレードするときはテックリードがさらにうまく進行してくれることでしょう。

まとめ

本記事では、はてなブックマークで実施したElasticsearch 6系および7系への無停止アップグレードの事例を紹介しました。OpenSearch Serviceの無停止インプレースアップグレード機能を前提として、ダブルライトをしながら段階的に新インデックスへ移行する段取りを解説しました。また、parent-child relationshipを大規模に利用していた場合にmultiple mapping typesを脱出できた様子を解説しました。今後もアプリケーション基盤に賢く向き合い、長く健康に続くシステムを構築していきます。