Claude CodeでElasticsearchのreindexを無停止に進める — aliasの付け替え手順
新インデックスの作成、reindex、件数とmappingの照合、aliasの付け替えとロールバックまでをClaude Codeに計画させます。書き込み系だけ確認を挟むpermissions設定も示します。
Elasticsearchのmappingは、すでにマップされたフィールドの型を多くの場合あとから変えられません。変更するには新しいインデックスを作り、reindexでデータを移し、aliasを新しい側へ向け直します。アプリが読み書きの宛先をaliasで持っていれば、この一連の作業は検索を止めずに進められます。
手順そのものは短いのですが、事故は手順の間で起きます。mappingを写し忘れる、件数を見ずに切り替える、戻し方を決めていない、といった具合です。ここでは、Claude Codeに実行前の計画と照合を担当させ、本番に効く書き込みだけは人が承認する構成を作ります。PostgreSQLのDDLを対象にした同種の審査はPostgresのマイグレーションレビューにあります。
無停止reindexの全体像
流れは5段です。aliasの付け替えだけが読み手から見える瞬間で、それ以外の4段は旧インデックスをそのまま使い続けたまま進みます。
aliasを軸にした切り替えの5段
- 1
新インデックスを明示的に作る
mappingとsettingsを指定して
products_v2を作ります。reindexはsourceのsettingsもテンプレートもコピーしません。 - 2
reindexで写す
非同期で実行し、
requests_per_secondで負荷を抑えます。 - 3
件数とmappingを照合する
refreshしてから件数を比べ、mappingの差分を読みます。
- 4
aliasを1回の要求で付け替える
removeとaddを同じ
_aliases要求に入れます。 - 5
旧インデックスを残して様子を見る
戻すときは同じ要求の向きを逆にします。旧インデックスの削除は最後です。
前提が1つあります。アプリがproducts_v1のような実インデックス名を直接使っているなら、付け替える対象のaliasがまだありません。aliasを経由する設定への変更が先で、これはreindexの外側の作業になります。
Claude Codeに持たせる権限を読み取りと書き込みで分ける
Elasticsearchの操作は、curlを直接許可すると読み取りも削除も同じ許可になります。Claude Codeのpermissionsは引数の中身で安全に切り分けるのが苦手です。ドキュメントも、引数を制約するBashルールは壊れやすいと明記し、curlのURLを絞る例で、オプションの位置やプロトコルが変わると一致しなくなることを示しています。
そこで、権限の切れ目をコマンド名に持たせます。読み取り専用と書き込み用の2本のシェルスクリプトを用意し、前者だけを許可、後者は毎回確認、curlの直叩きは拒否にします。
mkdir -p scripts
cat > scripts/es-read.sh <<'EOF'
#!/usr/bin/env bash
# 読み取り専用。許可するパスだけ通す
set -euo pipefail
path="${1:?path required}"
case "$path" in
_cat/*|*/_count|*/_mapping|*/_settings|_alias*|*/_alias*|_tasks/*) ;;
*) echo "read-only paths only: $path" >&2; exit 2 ;;
esac
exec curl -sS -X GET -H "Authorization: ApiKey ${ES_API_KEY:?}" \
"${ES_URL:?}/${path}"
EOF
cat > scripts/es-write.sh <<'EOF'
#!/usr/bin/env bash
# 書き込み用。メソッド・パス・本文ファイルを引数で受ける
set -euo pipefail
method="${1:?method}"; path="${2:?path}"; body="${3:-}"
exec curl -sS -X "$method" -H 'Content-Type: application/json' \
-H "Authorization: ApiKey ${ES_API_KEY:?}" \
${body:+--data-binary @"$body"} "${ES_URL:?}/${path}"
EOF
chmod +x scripts/es-read.sh scripts/es-write.sh.claude/settings.jsonには次の3行を入れます。
{
"permissions": {
"allow": ["Bash(./scripts/es-read.sh *)"],
"ask": ["Bash(./scripts/es-write.sh *)"],
"deny": ["Bash(curl *)", "Bash(wget *)"]
}
}ルールはdeny、ask、allowの順に評価され、最初に一致したものが結果を決めます。より具体的なallowがあっても、askに一致すれば確認が出ます。書き込み用スクリプトの確認を、あとから足したallowで外してしまう事故は、この順序が防ぎます。
落とし穴
curlのdenyは「curlを使わせない」保証にならない
Bash(curl *)が止めるのはcurlで始まる形です。/usr/bin/curlやsh -c 'curl ...'は止まりません。スクリプトを経由させる運用を強制したいときは、サンドボックスのネットワーク制限か、コマンド全文を検査するPreToolUseフックを併用します。
読み取り用スクリプトの許可リストは、スクリプト側でも持たせています。*を使ったallowルールは引数を絞らないので、_tasks/<id>/_cancelのようなPOSTが要るパスは、そもそもGET固定のこのスクリプトでは実行できない作りにしてあります。
Claude Codeに渡す依頼文
計画の段階では何も実行させず、確認項目と戻し方まで書かせます。
products(alias)の実体を products_v1 から products_v2 に切り替えたい。
まず scripts/es-read.sh で以下を調べ、reindex-plan.md に書いて。実行はしない。
1. products_v1 の _mapping と _settings
2. products_v1 の _count
3. 現在の products alias の向き(_alias/products)
計画には次を含めること:
- products_v2 を作る PUT 本文(mapping 変更点を v1 との差分で明示)
- _reindex の本文(wait_for_completion=false、requests_per_second を指定)
- 件数と mapping の照合コマンド
- _aliases の付け替え本文と、戻すときの本文
- 切り替え後に失敗を検知する確認項目書き込みの実行は計画を読んでからです。es-write.shの呼び出しごとに確認が出るので、本文ファイルの中身を見て承認します。
新インデックスを作る — settingsは自動で引き継がれない
reindexのドキュメントには、宛先は呼び出し前に希望どおりに設定しておくこと、reindexはsourceのsettingsやそのテンプレートをコピーしないこと、mappingやシャード数、レプリカ数は事前に設定が必要なことが書かれています。さらに、reindexには全ドキュメントで_sourceが有効なことが必要です。
つまり、新インデックスの作成は計画の中で最初に確認する箇所です。Claude Codeに旧インデックスのmappingを取らせ、変更したい点だけが差分になっているかを見せます。
./scripts/es-read.sh products_v1/_mapping > v1-mapping.json
./scripts/es-read.sh products_v1/_settings > v1-settings.json例えば、skuをtextからkeywordへ変えるなら、products_v2の本文ファイルv2-create.jsonではskuの型だけがv1-mapping.jsonと違う状態にします。それ以外のフィールドは写したままにします。写し忘れたフィールドは、差分を取る照合の段で見つかります。
reindexは非同期で、負荷を絞って流す
長時間のreindexには非同期の要求が推奨されています。同期で呼ぶとクライアントが待ち続け、トランスポート層のタイムアウトに当たりやすくなります。クライアントが切れてもreindex自体はタスクとして続きますが、状況が返ってきません。
wait_for_completion=falseを付けるとタスクIDが返ります。requests_per_secondには正の小数を指定でき、バッチの間に待ち時間を挟んで流量を抑えます。
cat > reindex.json <<'EOF'
{
"source": { "index": "products_v1" },
"dest": { "index": "products_v2" }
}
EOF
./scripts/es-write.sh POST "_reindex?wait_for_completion=false&requests_per_second=500" reindex.json返ってきたタスクIDは、読み取り用スクリプトで_tasks/<task_id>を引いて追います。completedがfalseのうちは実行中です。流量を後から変えるときは_rethrottle、止めるときはタスクのキャンセルAPIを使いますが、どちらも書き込み用スクリプトを通り、確認が出ます。
移行中に旧インデックスへ入った更新
reindexのAPIページには、実行中に旧インデックスへ入った更新を新側へ追いつかせる仕組みの記載がありません。書き込みを止められない環境では、2回目の差分流しを計画に入れます。
destでversion_typeをexternalにすると、sourceのバージョンを保持し、宛先に無い文書は作り、宛先のバージョンが古い文書は更新します。conflictsをproceedにすれば、バージョン衝突で止まらず、衝突の件数が応答に載ります。既定では衝突があるとreindexが中断されます。
{
"conflicts": "proceed",
"source": { "index": "products_v1" },
"dest": { "index": "products_v2", "version_type": "external" }
}切り替え後も旧側への書き込みが残る構成(アプリの古い版が動いているなど)かどうかは、アプリ側の事情です。Claude Codeには、アプリの設定やデプロイ定義を読ませて、書き込み先がaliasになっているかを確認させます。
件数とmappingを照合してから切り替える
reindexのrefreshパラメーターは既定でfalseです。完了直後の件数は、検索から見えない文書を含まない値になりえます。件数を比べる前に新インデックスをrefreshします。
./scripts/es-write.sh POST products_v2/_refresh
./scripts/es-read.sh products_v1/_count
./scripts/es-read.sh products_v2/_countmappingの差分は、取得した2つのJSONをjqで正規化して比べます。応答の最上位キーはインデックス名なので、先に中身だけを取り出します。
./scripts/es-read.sh products_v1/_mapping | jq -S 'to_entries[0].value.mappings' > m1.json
./scripts/es-read.sh products_v2/_mapping | jq -S 'to_entries[0].value.mappings' > m2.json
diff m1.json m2.jsondiffは意図した変更(上の例ならskuの型)だけを出すはずです。想定外の行が出たら、その時点で切り替えを止めます。件数が一致しない場合も、原因を読んでから進めます。クエリ付きのreindexやmax_docsを使っていないか、衝突で止まっていないかを、Claude Codeにタスクの応答から読ませると早く済みます。
切り替え前に見る2つの数字
件数
refreshの後に旧新で一致するか。差があれば、衝突や失敗の件数をタスクの応答で確認します。
mapping
旧新の差分が、意図した変更だけに収まっているか。
aliasの付け替えは1回の要求で行う
aliases APIは、複数の操作を1つのアトミックな操作として実行できます。ドキュメントの例では、removeとaddを同じ要求に入れてaliasの向きを入れ替えており、入れ替えの間、aliasは使えなくならず、2つの宛先を同時に指すこともありません。
{
"actions": [
{ "remove": { "index": "products_v1", "alias": "products", "must_exist": true } },
{ "add": { "index": "products_v2", "alias": "products" } }
]
}./scripts/es-write.sh POST _aliases swap.json
./scripts/es-read.sh _alias/products要求のあとに_alias/productsを引く行は省略しないでください。ドキュメントには、複数の操作のうち一部が成功し一部が失敗した場合に、操作ごとの結果が返ると書かれています。存在しないaliasのremoveが失敗してもaddは成功する例が載っています。応答を読み、aliasの向き先がproducts_v2の1つだけになっていることを確認します。
must_existをtrueにすると、aliasが存在することが操作の条件になります。付け替え前にaliasが想定どおりの状態かを、要求の中で条件にできます。
落とし穴
aliasに複数のインデックスを向けると書き込みが拒否される
aliasが複数のインデックスを指し、is_write_indexが設定されていない場合、そのaliasは書き込み要求を拒否します。1つのインデックスだけを指すaliasなら、そのインデックスが自動で書き込み先になります。removeとaddを別々の要求に分けると、aliasが2つのインデックスを指す途中の状態を作りかねません。
ロールバックは旧インデックスを残しておく前提で組む
戻し方は、付け替えの向きを逆にした要求です。これが成立するのは旧インデックスが残っているときだけです。計画に「旧インデックスの削除は、切り替え後に十分な観察期間を置いてから、別の確認つきで行う」と書かせます。
{
"actions": [
{ "remove": { "index": "products_v2", "alias": "products", "must_exist": true } },
{ "add": { "index": "products_v1", "alias": "products" } }
]
}切り替え後に新側へ書かれた更新は、旧インデックスにありません。戻す場合の差分は、新から旧へversion_type: externalで流し戻す必要があるかを、戻す前に判断します。
書き込み用スクリプトはすべての呼び出しがaskの対象なので、旧インデックスの削除も、aliasの付け替えと同じく承認の場面を通ります。承認の前に、メソッドとパスと本文ファイルを読みます。
この構成で分かること・分からないこと
アプリが実インデックス名を握っていないか、他のサービスが旧インデックスへ直接書いていないかは、リポジトリの外の事実です。Claude Codeが読めるのは、読ませたファイルだけです。同様に、クラスターの負荷や、reindex中の検索レイテンシーへの影響は、監視の数字を人が見て判断します。
Elasticsearchの接続自体をMCPで行う方法はElasticsearch MCPサーバーの使い方で扱っています。MCP経由の接続は検索や分析の用途に向き、今回のような書き込み系の段取りは、スクリプトとpermissionsで切るほうが承認の対象が明確です。バージョンを固定するかaliasで追従させるかという設計判断そのものは、モデルの引退でのバージョン固定とエイリアスでも同じ形で現れます。
まとめ
無停止のreindexは、新インデックスの事前設定、非同期のreindex、refresh後の件数とmappingの照合、aliasの1回の付け替え、旧インデックスを残したロールバックの5つがそろって成立します。Claude Codeには、読み取りの調査と計画書の作成を任せ、書き込みは確認つきのスクリプト経由に限定します。承認の場面で人が見るのは、本文ファイルの中身とaliasの向きの2点です。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。