MongoDB シャードクラスタの書き込み経路シリーズの第4/6回である。
この記事が答える問い: primary の書き込みが止まったとき、killOp・maxTimeMS・replSetStepDown・正常終了(SIGTERM)はなぜ効かないのか? そのとき何をどの順序で行うべきか?
基準バージョン: MongoDB 4.2。公式ドキュメントのv4.2ブランチとサーバーソースのタグr4.2.12を読んだ。最新バージョンは確認していない。
MongoDB が操作を止める手段の大半は協調型である。終了の印を残し、対象スレッドがその印を確認するのを待つ。ストレージエンジン(WiredTiger)の中で動くスレッドは印を確認できないので、killOp も replSetStepDown も正常終了もそのスレッドを止められない。残る手段は、公式ドキュメントが禁じている kill -9 だけである。そのため、書き込み主体(クラスタに書き込みを送る API サーバー・バッチ・コンシューマー)を先に止めてから primary を降ろす順序を守る必要がある。
interrupt point は、操作が安全に中断できる位置である。4.2 の用語集の定義はこうである。
"A point in an operation's lifecycle when it can safely abort. MongoDB only terminates an operation at designated interrupt points."
サーバーソースでは、操作1つが OperationContext オブジェクト1つである。killOp はこのオブジェクトに対して markKilled を呼ぶだけである。ソースのコメントによれば、それ以降、操作スレッドが checkForInterrupt を呼ぶたびに、指定したエラーコードが返る(src/mongo/db/operation_context.h:261-272 (r4.2.12))。
killOp のドキュメントの表現はこうである。
"Operations may not actually be terminated until they reach an appropriate interruption point."
操作が終わったかは db.currentOp() で改めて確認する必要がある。killOp が返す ok: 1 は、印を付けることに成功したという応答である。
書き込み操作は、エンジンに入る前に write ticket(同時書き込みの入場券、4.2 のデフォルトは128個)を受け取る。ticket を待っている間は中断できる。4.2 のソースは ticket 待ちに OperationContext を渡し、待機中にも終了の印を確認させる(src/mongo/db/concurrency/lock_state.cpp:343-349 (r4.2.12))。
maxTimeMS も同じ方式である。ドキュメントは、締め切りを過ぎると "MongoDB interrupts the operation at the next interrupt point" と記している。
killOp と maxTimeMS が止めるのは、ticket 待ちの列に並んだ操作までである。すでに ticket を受け取ってエンジンの中に入った操作は止められない。WiredTiger のスナップショット待ちループには checkForInterrupt の呼び出しがない(src/third_party/wiredtiger/src/txn/txn.c:196-235 (r4.2.12))。このループがどのように終わりなく回るかは、WiredTiger スナップショットの pause ループによる livelock で扱う。
シャードクラスタでは落とし穴がもう1つある。mongos から送った killOp は、書き込み操作を shard に伝播しない。
"The killOp command from the mongos does not propagate to the shards when the operation to be killed is a write operation."
書き込み操作は、その shard の primary mongod に直接接続して止める必要がある。
まずロックのモードを2つ見る。
| モード | 意味 | 複数の操作が同時に握れるか |
|---|---|---|
| IX (intent exclusive) | 書き込みの意図を示す共有モード | 握れる |
| X (exclusive) | 排他モード | 1人だけが握る |
X を求める側は、IX を握っている操作がすべて手放すまで待つ。たとえば IX を握っている操作が128個なら、128個すべてが手放さないと X を得られない。127個が終わっても1個残っていれば得られない。
4.2 で primary が secondary に降りるには、RSTL(ReplicationStateTransitionLock、レプリケーション状態遷移ロック)を X で握る必要がある。読み取り・書き込みの操作はすべて、グローバルロック(mongod インスタンス全体にかけるロック)を握る前に RSTL を IX で握る。
secondaryCatchUpPeriodSecs(デフォルト10秒)は、secondary が primary に追いつくのを待つ時間である。
replication_coordinator_impl.cpp (r4.2.12) の stepDown の流れは次のとおりである。
force がなければ secondaryCatchUpPeriodSecs まで、force: true なら stepDown 期間全体までである(2054-2058行)。Starting to kill user operations を残し、書き込みと衝突する操作に終了の印を付ける(1887-1913行、1946行)。LockTimeout(Unable to acquire lock ... within ...)で失敗する(lock_state.cpp:964-966)。手順3の印も killOp と同じ協調型である。エンジン内のスレッドは印を確認できないので、RSTL IX を手放さない。そのため手順4で締め切りに引っかかる。force: true は締め切りを延ばすだけで、RSTL X が必要な点は同じである。
ドキュメントによれば、secondaryCatchUpPeriodSecs 以内に追いついた secondary がなくても stepDown はエラーを出す。エラーメッセージが Unable to acquire lock なら、catch-up ではなく RSTL X で止まっている。
4.2 のドキュメントは、primary が SIGTERM を受け取ると、終了前に stepDown を試みると記している。stepDown が失敗しても終了は続ける(SERVER-38994 で 3.6.15・4.0.8・4.1.10 に導入)。
src/mongo/db/db.cpp (r4.2.12) の終了経路は、次の順序で進む。
Enqueuing the ReplicationStateTransitionLock for shutdown # RSTL X 요청
Killing all operations for shutdown # 전체 종료 표시
Acquiring the ReplicationStateTransitionLock for shutdown # RSTL X 대기, Date_t::max() (1013-1029행)
Acquiring the global lock for shutdown # 글로벌 X 대기, Date_t::max() (1102-1106행)
Date_t::max() は期限を設けない値である。そのため、エンジン内のスレッドが RSTL とグローバルロックを手放さなければ、正常終了は終わらない。ソースを読んで導いた推論である。
SIGKILL を送ると、OS は協調を求めずにプロセスをすぐに終了させる。4.2 のドキュメントは警告ボックスにこう記している。
"Never use kill -9 (i.e. SIGKILL) to terminate a mongod instance."
kill -9 の後の journal 復旧の動作は確認していない。そのため kill -9 は、判別(下の「対応手順」の手順1)を終えた後に使う最後の手段とする。
| コマンド | 行うこと | エンジン内のスレッドに届くか | 代わりに止まるもの |
|---|---|---|---|
killOp |
対象操作に終了の印を付ける | 届かない | ticket 待ちの操作 |
maxTimeMS |
締め切り後、次の interrupt point で中断 | 届かない | ticket 待ちの列に並んだ操作 |
replSetStepDown |
書き込み操作に終了の印を付けた後、RSTL X を待つ | 届かない(締め切り後に LockTimeout) |
interrupt point に来る書き込み操作 |
| SIGTERM | stepDown を試み、全体に終了の印を付け、ロックを無期限に待つ | 届かない(終了が終わらない、推論) | interrupt point に来る操作 |
kill -9 |
OS がプロセスを即座に終了させる | 届く | 進行中の操作すべて |
協調型の手段が対象スレッドを止められないと、次のように見える。
ok: 1 を返したのに、db.currentOp() に同じ opid が残り続ける。replSetStepDown は締め切り(デフォルトは secondaryCatchUpPeriodSecs)を過ぎてから失敗する。エラーは LockTimeout で、ログには Starting to kill user operations が残っている。primary がエンジン内で止まったように見えたら、運用者は次の順序に従う。
kill -9 を送る。 確認は、各主体の処理指標が0になったかで行う。確信がなければ kill -9 を先延ばしにして待つ。kill -9 の後は heartbeat が途切れるので、secondary たちが選挙を始める(electionTimeoutMillis のデフォルトは10秒)。rs.status() で別のメンバーが PRIMARY になっているかを見る。そのあと書き込み主体を1つずつ有効にする。1つ有効にするたびに write ticket の余裕とスループットを確認し、次の主体に進む。してはいけないことは3つある。
書き込みを残したまま primary だけを降ろさない。 手順2を飛ばして kill -9 から始めると、同じ停止が新しい primary に移りうる。
「このクラスタに誰が書き込むのか、それぞれをどう止めるのか」を1ページのリストとしてあらかじめ用意しておく。下は形式の例で、値はすべて架空である。
| 書き込み主体 | 種類 | 止める方法 | 再開順序 |
|---|---|---|---|
store-inventory-writer |
Kafka コンシューマー | コンシューマーのレプリカ数を0に減らす | 2 |
| 変更イベントの CDC コネクター | CDC | コネクターを一時停止 | 1 |
リストには、止まったことを確認する指標も一緒に書く。リストから漏れた書き込み主体は、対応中も新しい primary に書き込み続ける。
実行で検証していない手順である。下の結果は、ドキュメントとソースから予想したものである。
エンジン内の spin の再現は、第3/6回の WiredTiger スナップショットの pause ループによる livelock で扱う。ここでは、interrupt point を通る長い書き込みに各手段がどう反応するかを、テスト用の3メンバーのレプリカセットで見る。
コレクションに文書100万件(例示値)を入れる。
use test
for (let b = 0; b < 100; b++)
db.store_inventory.insertMany(Array.from({ length: 10000 },
(_, j) => ({ _id: { productId: j % 1000, storeId: b * 10000 + j }, qty: 0 })))
シェル A で時間のかかる書き込みを実行する。
db.store_inventory.updateMany({}, { $inc: { qty: 1 } })
シェル B で opid を探して killOp を送る。
db.currentOp({ op: "update", ns: "test.store_inventory" }).inprog.map(o => o.opid)
db.killOp(<opid>)
予想される結果: killOp は ok: 1 を返す。シェル A の updateMany は、次の interrupt point で中断エラーとして終わる。
手順2をもう一度実行し、シェル B で stepDown を送る。
db.adminCommand({ replSetStepDown: 60, secondaryCatchUpPeriodSecs: 10 })
予想される結果: ログに Starting to kill user operations が残る。updateMany は終了の印を受けて終わる。primary は secondary に降りる。
手順2をもう一度実行し、その mongod に SIGTERM(kill -15)を送る。
予想される結果: 終了経路の4行がログに順に残り、プロセスが終了する。
シャードクラスタなら、手順2・3を mongos から行ってみる。予想される結果は、shard の primary に操作が残ることである。
kill -9の後、mongod が journal と最後のチェックポイントでどこまで復旧するかは確認していない。- SIGTERM による終了が終わらないという結論は、
db.cppを読んで導いた推論である。実際のログは見ていない。- 4.2 以降のバージョンの stepDown・終了経路は確認していない。