Redisの心臓部を暴く:`SCRIPT KILL`の限界とLua実行モデルの低レイヤ解剖
RedisにおけるLuaスクリプトの実行は、そのアトミック性を保証するためにシングルスレッドのイベントループを完全に占有する。この設計は、複雑なデータ操作をトランザクションのオーバーヘッドなしに高速実行するための諸刃の剣である。
もし、プロダクション環境で無限ループや非効率なO(N²)の走査を含むLuaスクリプトが投下された場合、Redisインスタンスは完全に沈黙する。他のすべてのクライアントリクエストはブロックされ、レイテンシメトリクスは跳ね上がり、最悪の場合はヘルスチェックのタイムアウトによってフェイルオーバーが誘発される。
この絶望的な状況において、唯一の救命索となるのが `SCRIPT KILL` コマンドだ。
しかし、このコマンドの挙動と内部制約を正確に理解しているエンジニアは驚くほど少ない。「スクリプトを強制停止する魔法のコマンド」という認識でいるならば、大規模システムのアーキテクトとしては失格だと言わざるを得ない。
本稿では、`SCRIPT KILL`のソースコードレベルの挙動、書き込み保護のメカニズム、そして真の可用性を担保するためのアーキテクチャ設計について、極限の低レイヤ視点から解き明かす。
—
1. Lua実行モデルとイベントループの占有
Redisは単一のメインスレッドでネットワークI/Oの処理とコマンドの実行を行う。通常、各コマンドはマイクロ秒単位で完了するため、このシングルスレッドモデルは驚異的なスループットを生み出す。
しかし、`EVAL`や`EVALSHA`によって呼び出されたLuaスクリプトは、このイベントループをブロックし続ける。
[Client A] —> EVAL (重いスクリプト) —> [Redis Main Thread] (ループ占有・他の処理停止)
[Client B] —> GET key —> (ブロックされる)
この実行中、Redisは新しいコマンドを受け付けず、クライアントからのソケットバッファを読み取らない(厳密にはTCPのウィンドウサイズが満杯になるまで内核でバッファリングされる)。
この膠着状態を打破するために発動するのが `SCRIPT KILL` である。別のクライアントコネクションからこのコマンドを叩くことで、Redisのメインスレッドに割り込みをかけることができる。
—
2. `SCRIPT KILL`の鉄の掟:なぜ「書き込み前」でなければならないのか?
`SCRIPT KILL`の最大の制約、そして実運用で最もハマるポイントがこれだ。
> 「スクリプト内で1つでもデータに対する書き込み(Write)操作が行われていた場合、`SCRIPT KILL`は機能せず、エラーを返す」
なぜこのような厳格な制約が存在するのか? 答えは C言語レベルのデータ整合性(Atomicity)の死守 にある。
内部メカニズムの深層
RedisのLuaエンジン(Lua 5.1ベース)は、スクリプトの実行開始時に「このスクリプトが書き込みを行ったかどうか」を示すフラグ(`server.lua_write_pointer` や関連するフラグメント)を監視している。
1. 読み取り専用スクリプトの場合(`GET`, `SMEMBERS`などのみ):
状態の変更が一切ないため、いつでも安全にLuaの仮想マシン(VM)の状態を破棄し、スクリプトの実行をアボート(中断)できる。メモリリークやデータの不整合リスクがゼロであるため、`SCRIPT KILL`は成功する。
2. 書き込みが発生した場合(`SET`, `HSET`, `ZADD`などが1回でも実行された後):
もしここで強制停止を許容すると、データストアの状態が「中途半端に更新された(Dirty)」状態でロック解除されることになる。これはRedisの核心である「アトミック性の保証」を完全に破壊する。
実際に書き込み済みスクリプトに対して`SCRIPT KILL`を実行した際の挙動を見てみよう。
書き込みを伴う無限ループスクリプトを実行中とする
127.0.0.1:6379> EVAL “while true do redis.call(‘set’, ‘foo’, ‘bar’) end” 0
^C (別クライアントから介入)
別のクライアントから KILL を試みる
127.0.0.1:6379> SCRIPT KILL
(error) UNREACHABLE Sorry, the script already executed write commands. Use SHUTDOWN NOSAVE if you want to stop it.
このエラーメッセージが示す通り、すでに書き込みが行われたスクリプトを止める唯一の方法は、`SHUTDOWN NOSAVE`(データをディスクに保存せずに即座にプロセスを終了する)のみである。これはプロダクション環境においては最終手段(Nuclear Option)に他ならない。
—
3. 実践:安全なスクリプト停止と検知のフロー
では、インシデント発生時に現場のエンジニアはどのように立ち回るべきか。アルゴリズム的な手順を定義する。
[Luaスクリプト暴走検知]
│
├─> ログ/メトリクスで慢性的ブロックを確認
│
▼
[SCRIPT KILLの試行]
│
├─> 【成功】 (ReadOnlyスクリプトだった場合)
│ └─> アプリケーション側のバグ修正・再発防止へ
│
└─> 【失敗: UNREACHABLEエラー】 (Write済みスクリプト)
│
├─> レプリケーション構成の確認
└─> SHUTDOWN NOSAVEによる強制再起動 (データロスを覚悟)
タイムアウト機構 `lua-time-limit` の活用
完全な無限ループを防ぐために、Redisには `lua-time-limit` という設定項目がある(デフォルトは5000ミリ秒=5秒)。
この制限時間を超えると、スクリプトは自動的に停止するわけではない。超えた後に実行できるコマンドが `SCRIPT KILL` と `SHUTDOWN NOSAVE` に制限されるようになる。
redis.conf の設定例:
lua-time-limit 5000
もしスクリプトがタイムアウトした場合、Redisログには以下のような警告が出力される。
1:M 01 Jan 2023 00:00:00.000 # WARNING: Script execution timed out for too long.
1:M 01 Jan 2023 00:00:00.001 # A script is executing for a long time…
この状態に陥った際、安全にスクリプトをキルできるのは「そのスクリプトが書き込みを行っていない場合」に限定されるという事実を、オペレーターは常に肝に銘じておく必要がある。
—
4. アーキテクトが取るべき根本的な対策
`SCRIPT KILL`に頼る状況を作ること自体が、アーキテクチャの欠陥を示している。極限のパフォーマンスと可用性を両立させるためには、以下の設計思想を徹底せよ。
1. Luaスクリプト内でのループの排除:
Luaスクリプト内で `while` や `for` による巨大なコレクションの走査を行ってはならない。O(N)の処理が必要な場合は、Redisのネイティブコマンド(`SCAN`など)をアプリケーション側(クライアントサイド)のループで制御するか、適切なデータ構造(Sorted Setなど)の設計で回避する。
2. べき等性とトランザクションの分離:
書き込みを伴う複雑なロジックを1つの巨大なLuaスクリプトに詰め込むのはアンチパターンである。万が一の暴走時に `SCRIPT KILL` が効かないリスクを常に考慮し、処理を分割せよ。
3. ステージング環境での `redis-cli –eval` による検証:
本番投入前のLuaスクリプトは、必ずパフォーマンステストを行い、実行時間が数ミリ秒以内に収まることを担保する。
—
5. 結言
`SCRIPT KILL`は、誤った設計のスクリプトに対して救いの手を差し伸べるが、その慈悲は「書き込み前」という冷酷な条件によって厳しく制限されている。
真に堅牢なRedisシステムを構築するアーキテクトは、`SCRIPT KILL`という「緊急脱出装置」の存在に依存するのではなく、「決してそのボタンを押さなくてよいコードベースとデータ設計」をコードの細部に宿すべきである。
低レイヤの制約を知り尽くした者だけが、高可用な分散システムの王座に君臨できる。
コメント