この記事は Nikkei Advent Calendar 2022 の 19 日目の記事です。
こんにちは。エンジニアの田中です。 日経電子版開発の API・バックエンドチームで API 開発だったりデータ分析だったりをしています。 今年は記事の配信基盤に使われている Elasticsearch (ES) のバージョンを 6 系から 8 系にあげる作業をしているので、誰かの参考になればと思い、インターネットに放流しようと思います。
まず、日経電子版での記事配信システムについての概略を説明します。 その後、ES を 6 系から 8 系に上げる際の仕様変更について説明します。特に網羅的に説明はせず、本システムにおいて影響のあった仕様変更について述べます。 そして実際の移行戦略について説明します。
なお去年アドカレを書いた時も投稿日が 19 日でした。19 といえば素数。 素数と聞いてみなさんは何を思い浮かべるでしょうか。篩でしょうか。中学生の頃の素因数分解の記憶でしょうか。僕はいつもジョジョのプッチ神父が出てきます。
電子版での記事配信システムの概略
ES の更新手順の前に、ES の立ち位置を説明するために電子版での記事配信システムの概略を説明します。

配信システムの主な点としては API Gateway(APIGW)パターンを採用していることです。APIGW は記事配信用の API 以外にもいろんな API に接続されています。
記事の配信の API は Search API と呼んでいます。この Search API も APIGW を経由してリクエストを受け付けるようになっています。 また APIGW から受けたリクエストは Search API が ES にリクエストする形で記事を取得しています。 ES にリクエストする、と聞くと検索しているようにも思えますが、当然記事に振られた ID の完全一致条件でも ES へリクエストできるので、特定記事だけ取得する用途にも使われています。 配信しているのは日経電子版のみではありません。例えば今年新規にローンチされたバーティカルメディア、NIKKEI Prime といった別サービスの記事もこの APIGW を通して配信されています。なお、NIKKEI Prime は 2023 年 2 月末まで無料です。この記事をご覧になられた方は是非登録してお試しください。(とても自然で流暢な宣伝)
他の日経の Web サービスでもこの APIGW から配信されているものがあります。ただ今回はわかりやすく題打つために日経電子版の〜としています。
ES への記事の indexing はバッチジョブで動いています。バッチジョブといっても秒間隔で動いており、記事ストレージサービスに常にポーリングしにいく形で動作しています。ジョブスケジューラには Rundeck が採用されています。
また表示の組み立て自体は APIGW までのレイヤーでは責務を持っていません。Web やアプリなどの各サービスで個別に BFF や CDN などを持って、各々が表示を組み立てています。
日経電子版のシステムにおいて、ES は検索機能を実現させつつ、全ての記事の配信機能を担っている重要な基盤となっています。
本番環境と開発環境
本番環境とは別で、開発チーム横断で使われる開発環境を持っています。図中で記事入稿システムや BFF などを示しましたが、これらも当然開発環境を持っており、APIGW の開発環境と接続されています。この開発の挙動も本番と同様にモニタリングツールなどで監視されており、また日常的に開発者に動作確認で利用されています。 なので、開発にデプロイしただけでも、トラブルがあれば、ある程度は各種モニタリングツールで検知できるようになっていますし、直接連絡をもらうような形で検知できたりもします。 今回の移行検証ではこの開発環境を利用しています。
インフラについて
APIGW から後ろのサービスは主に AWS の Elastic Beanstalk を使ってデプロイされています。ただし、ES は独自で Docker image として包んでいるものの、EC2 インスタンスを独力で立てて、そこに Docker container を起動する形でデプロイしています。
ESの仕様変更
これから ES の仕様変更について述べます。全てを網羅して説明するのは困難なので、私たちのシステムで影響のあった仕様変更について説明します。
ESのクラスタ設定
discovery.zenの廃止
discovery.zenがなくなりました。 minimum_master_nodes などの最小ノード数の設定が利用できなくなったのですが、デフォルトでは 3 ノード以上ある時に起動するようになりました。
ただし私たちの環境では EC2 のディスカバリが必要なので次の設定を elasticsearch.yml に加えます。
discovery.seed_providers: ec2
補足ですが、discovery.seed_providers が含まれる、クラスタのbootstrappingに関する設定なしで起動すると単一のノードで自動起動するようです。こちらはスプリットブレインやデータロスの観点で非推奨のようなので、検証以外では、必ずつける必要があります。
nodeのroleの形式の変更
node.*に boolean で与える形式から node.roles にリストで与える形に変わっています。
旧
node.master: true
node.data: true
新
node.roles:
- master
- data
- remote_cluster_client
新しい方に増えている remote_cluster_client
ですが新しい kibana を立てる際に必要になったパラメータです。
mappings
ES6 の時点で非推奨でしたが、ES8 で完全にmapping typesが消えています。 ですので、次のように index 設定を書き換える必要があります。
旧
PUT my_index
{
"mappings": {
"my_type": {
"properties": {
"city": {
"type": "text",
"fields": {
"raw": {
"type": "keyword"
}
}
}
}
}
}
}
新
PUT my_index
{
"mappings": {
"properties": {
"city": {
"type": "text",
"fields": {
"raw": {
"type": "keyword"
}
}
}
}
}
}
mappings 下のラップしていた my_type を消してあげるだけですね。
totalの仕様変更
これは 6 系から 7 系へあげた際の影響ですが、total が integer で返却されていたのがオブジェクトになりました。 これは Search API で吸収できる変更なため、Search API 上でのレスポンスを変えないように、修正を加えました。
また、10000 件を超える場合はデフォルトでは正確に取得できなくなりました。取得には track_total_hits というパラメータを含める必要があります。
例
GET my-index-000001/_search
{
"track_total_hits": true,
"query": {
"match": {
"user.id": "elkbee"
}
}
}
一応仕様を変えないという方針で行っているので、移行後もリクエスト時に total は正確に返すようにパラメータを設定しています。ただこの辺りは性能を加味しての修正だと思うので、仕様変更してデフォルトでは total は 10000 件以上は返さず、Search API のパラメータによっては正確に返すようにする計画です。
pythonクライアントの変更
実際のところ一番面倒でした。
client設定
今までスキーマなしで設定できたりポートはデフォルトを 9200 として指定なしで引数に与えられていたのが、8系のクライアントでは厳密に与える必要があります。
旧(一例)
Elasticsearch("localhost")
新
Elasticsearch("http://localhost:9200")
メソッドはキーワードを明示する
いままで positional args を許していたのが一切許されなくなりました。もし positional args で値を渡していた場合は明示する必要があります。
旧
# 7.x UNSUPPORTED USAGE (Don't do this!):
client.indices.get("*")
新
# 8.0+ SUPPORTED USAGE:
client.indices.get(index="*")
doc_type
mapping types がなくなった影響で bulk 関数から doc_type パラメータが消えています。消しておきましょう。
旧
client.bulk(data, index=es_index, doc_type=es_doc_type, request_timeout=60)
新
client.bulk(operations=data, index=es_index, request_timeout=60)
移行戦略
要素としては次の二点です。
- レスポンス差分がないことの確認
- カナリアテストによって不測のトラブルの影響を小さくすること
それぞれ次項で述べます。
差分検証の方法
こちらは回帰テスト的な方針で実施しました。 Search API から ES へのリクエストペイロードを保存、ES6、ES8 のそれぞれのクラスタに投げて差分を確認していました。 実際には Search API へのリクエスト情報を保存して Search API からのリクエスト結果を確認するのがよかったです。実際のリクエスト結果で、一部互換が取れていないというミスをしてしまいました。
結果としてはソートスコアもトークナイザも変えていないため、特に差分はありませんでした。
肝要だったのはこういったリクエスト情報をロギングして検証に利用できることだったと思います。今回は先人の財産を再利用している形だったので、自分が今後 0 からシステムを作る時は意識しようと思いました。
カナリアテスト
カナリアテストを通してデプロイしていくことでトラブル時のユーザー影響を小さくするようにデプロイしていく方針を取りました。以下でやや詳細に述べます。
カナリアテストについて
カナリアテスト、またはカナリアリリースとは、アプリケーションやサーバーの更新をする際に、サービスへの影響を最小限にしながら新しいバージョンを検証する方法です。 カナリアテストでは、新しいバージョンと古いバージョンのアプリケーションを同時にデプロイ先の環境へと接続させつつ、そのアクセスを両方に確率的に振り分けます。 新しいバージョンへのリクエスト割合が小さくなるように(最初は 0.1~1%程度)確率を設定すれば、新しいバージョンへリクエストを流しつつ、問題があっても影響を受けるユーザーの割合は事前設定したパラメータが期待値となります。
実際は次のように構築しています。

p は 0 から 1 の値です。 バッチジョブまで含めて丸ごと Search API を複製しています。ジョブのスクリプトも python クライアントバージョンの影響を受けているので、それぞれを用意する必要があります。
リクエストの振り分け方法
先ほど説明しましたが、Search API へのリクエストは APIGW を経由します。APIGW は python アプリケーションなので、ここにロジックを記述して二つの Search API へリクエストを振り分けることができます。 Elastic Beanstalk の持つ機能を利用する方法もあったのですが、恥ずかしながら、検討中はこれに気づかなかったため、APIGW に設定を入れる形で対応していました。 また、再デプロイを行わずに済むように環境変数で振り分け確率を変更できるようにしています。ただしアプリの再起動は必要になります。
テスト終了時
元々永続的にカナリアテストしながらデプロイして運用していく前提ではないため、 古いスタックを削除して、ES の新バージョンに統合する必要があります。
まずテスト対象だった ES8 の API に全てのリクエストを流します
その後 Route 53 の ES6 のスタックに向いていたレコードを ES8 のスタックへ向けます。
その後、古いスタックとカナリア用の設定を全て削除します。
その他細かい手順
当然ですが API やジョブのスクリプト自体は GitHub で管理され、また CI を通してデプロイされています。 現行で動いているアプリたちを管理している main ブランチにガンガン修正を入れていくと止まってしまいます。 なので基本的にはサービスのデプロイは新バージョン専用の CI を作ったり、Docker は手元で build して AWS に push していくなどで回避しています。
終わりに
このポストを書いているときはまだ本番環境へはリリースしていません。 初めてのカナリアだったので、注意点を探るため、開発でも慎重に検証しています。 開発での結果を踏まえて、今後本番でも慎重に進めていく予定です。
ES のアップデートによるレスポンスタイムなどの非機能要件への影響はこれから調べていこうと思っていますが、今のところ indexing 速度自体は上がっているようにみえます。 (ただ indexing の手前である、バルクで記事データを取得してくる部分が圧倒的に重いらしくトータルでは違いがわかりませんでした。)
ES も最新版に追いついたので、これからは ES8 の新しい feature を使ってサービス改善に活かしていこうと思っています。
明日は SRE で活躍されている清水さんの「日経の共通メトリクス基盤 Titan をつくっています」の投稿があります。お楽しみに!