Redisの生き死にを握る禁断のスイッチ:`SCRIPT KILL`の限界と正しい設計解
こんにちは。プロダクトのアーキテクチャとパフォーマンスの最終防衛ラインを預かる立場として、今日のコードレビューは少しシリアスなテーマから始めたい。
RedisでのLuaスクリプト活用は、複数のコマンドをアトミックに実行し、ネットワーク往復(RTT)を削減するための極めて強力な武器だ。しかし、この「強力な武器」の扱いを一歩誤ると、本番環境でクラスタ全体を沈没させる致命的な凶器に変わる。
特に、「重いLuaスクリプトが暴走し、Redisが一切の応答を受け付けなくなった悪夢の瞬間」を君は経験したことがあるか?
その緊急停止ボタンとして用意されているのが `SCRIPT KILL` コマンドだ。だが、このコマンドを「困ったときの魔法の杖」だと思って設計しているなら、今すぐその認識を改めなければならない。今回は、`SCRIPT KILL`のメカニズムの裏側にある残酷な現実と、そもそもそれを呼ばずに済む「プロフェッショナルな設計パターン」を叩き込む。
—
1. `SCRIPT KILL`のメカニズムと残酷な仕様
まず、Redisの公式ドキュメントにはこうある。
> 「現在実行中のLuaスクリプトを停止する。ただし、スクリプト内でデータ書き込み(Keyの変更など)がまだ行われていない場合にのみ有効である。」
この仕様の何が危険なのか、アーキテクチャの観点から深掘りする。
なぜ「書き込み済み」だと殺せないのか?
Redisはシングルスレッド(正確にはメインイベントループがシングルスレッド)で動作する。Luaスクリプトもこのメインスレッド上で完全なアトミシティ(原子性)を保ったまま実行される。
もし、スクリプトの途中ですでにKeyへの書き込み(`HSET`, `SET` など)が発生している最中に`SCRIPT KILL`で強制終了を許可してしまうと、どうなるか?
- データの不整合(部分的な書き込み)が生まれ、Redisの生命線であるアトミシティと永続性(RDB/AOFの整合性)が完全に破壊される。
そのため、Redisは「書き込みが1つでも発生したスクリプトは、途中で殺すことを頑なに拒否する」設計になっている。
実行時の挙動シミュレーション
無限ループに陥ったLuaスクリプトを投入してしまった最悪のケースを想定しよう。
1. 意図せず無限ループする重いスクリプトを実行してしまう(クライアントはブロックされる)
EVAL “while true do k = 1 end” 0
別のターミナルから救出を試みる。
2. 焦って SCRIPT KILL を叩く
127.0.0.1:6379> SCRIPT KILL
(error) NOTBUSY No scripts in execution right now.
※もし実行中であっても、書き込みが行われていれば NOSCRIPT または ERR で弾かれる
もし、このスクリプトの途中に `redis.call(‘SET’, ‘foo’, ‘bar’)` が含まれていた場合、`SCRIPT KILL` は無力と化す。残された選択肢は、非情にも `SHUTDOWN NOSAVE`(メモリ上のデータを捨てて再起動)か、OSレベルでの `kill -9` しかなくなる。本番環境でこれをやれば、ビジネスは大事故だ。
—
2. 実務におけるデバッグ・緊急対応フロー
とはいえ、運悪く「書き込みを行っていない読み取り専用の重いクエリ/スクリプト」が暴走したケースに遭遇した場合の正しい実務手順を記す。
ステップ 1: 状況の確認
本当にLuaスクリプトが原因でブロックしているか、`INFO` や `CLIENT LIST` で確認する。
127.0.0.1:6379> INFO stats
暴走しているスクリプトの実行時間などを確認
ステップ 2: `SCRIPT KILL` の実行
書き込みがないことを確信している場合、以下のコマンドで安全に停止を試みる。
127.0.0.1:6379> SCRIPT KILL
OK
成功すれば、クライアント側には `BUSY Redis is busy running a script. You can only call SCRIPT KILL or SHUTDOWN NOSAVE.` というエラーが返り、処理が中断される。
—
3. 「SCRIPT KILLに頼らない」堅牢な設計パターン
シニアエンジニアとしての私の結論はこうだ:「`SCRIPT KILL`を一度も使わずに済むシステムを設計すること」。
緊急停止ボタンの存在をあてにした設計は、プロの仕事ではない。以下の3つの鉄則をコードレビューの基準として厳守してほしい。
鉄則 A:Luaスクリプト内でのループ処理の禁止
Lua内で `while` や `for` を使って数万件のキーをスキャンするようなコードを書くのは絶対にNGだ。Redisのシングルスレッドを数秒〜数分間完全に占有し、他のすべてのリクエストをブロック(レイテンシスパイク)させる。
アンチパターン:
— 絶対にやってはいけない:Lua内での全件走査とループ処理
local keys = redis.call(‘KEYS’, ‘user:’)
for i=1, #keys do
— 重い処理
redis.call(‘HSET’, keys[i], ‘processed’, ‘1’)
end
return #keys
正しいアプローチ(アプリケーション層での分割):
1. アプリケーション側から `SCAN` コマンドを使い、バッチサイズ(例: 500件ずつ)に分割して処理する。
2. もしどうしてもアトミシティが必要なら、分割した単位ごとに小さなLuaスクリプトを流し込む。
鉄則 B:`lua-time-limit` の適切な設定
Redisの設定ファイル(`redis.conf`)にある `lua-time-limit`(デフォルトは 5000ms = 5秒)を意識せよ。
この時間を超えて実行されたスクリプトに対して、Redisは自動的に `SCRIPT KILL` の受付状態に入る。しかし、前述の通り「書き込みなし」の場合に限るため、やはり過信は禁物だ。開発環境でストレステストを行い、10ms以内に終わることを厳格に担保すべきだ。
鉄則 C:危険なコマンドは EVALSHA と AOF/RDB の保護と組み合わせる
本番環境では、`EVAL` の代わりに `EVALSHA` を用いる運用が基本となるが、スクリプトの事前登録(`SCRIPT LOAD`)をデプロイパイプラインに組み込み、アドホック(場当たり的)なスクリプト実行をRedisのACL(Access Control List)で禁止することを強く推奨する。
—
チーフアーキテクトからの総括
`SCRIPT KILL` は、いわば航空機の「射出座席」のようなものだ。脱出できる確率やリスクを考えれば、作動させずに済むフライトを計画するのがエンジニアの腕の見せ所である。
- Luaスクリプトは「短く、速く、アトミックに」書く。
- ループや大量データ処理を中に持ち込まない。
- 「書き込みが入った瞬間にKILL不能になる」という物理制約を常に頭に置いて設計する。
次の設計レビューでは、これらの原則が守られているか、私が厳しくコードの隅々までチェックさせてもらう。準備を怠るな。
コメント