【実務・中級編】 Luaサンドボックス – Redis

Redis Luaサンドボックスの深淵:アトマシティの裏に潜む「見えない檻」と正しき設計

こんにちは。テックリードの私だ。
今日のコードレビューで、誰かが「Redis上で複雑なアトミック処理を行いたいから」という理由で、巨大なLuaスクリプトを放り込んできた。その気持ちは分かる。マルチキー操作を単一のトランザクションのように扱えるLuaは、RDB育ちのエンジニアにとって麻薬のような魅力がある。

だが、待ってほしい。
君たちは本当に、RedisのLua実行環境(サンドボックス)が課している「厳格な戒律」を理解してコードを書いているか?

「グローバル変数が使えない」
「ファイルI/Oができない」
「非決定的な処理が禁止されている」

これらは単なる「制限事項」ではない。単一スレッドで動く超高速インメモリデータベースの寿命を守り、レプリケーションの整合性を担保するための、極めて合理的で残酷な防壁なのだ。

今日は、このRedis Luaサンドボックスの内部構造と制限の本質を解き明かし、実務の現場で絶対に踏んではいけない地雷と、それを回避するプロフェッショナルな設計パターンを伝授する。

—

1. サンドボックスの本質:なぜここまで縛られるのか?

RedisのLuaスクリプトは、単に「Redis内でスクリプト言語が動く」というお気楽なものではない。
Redisは基本的にシングルスレッドでコマンドを処理する。Luaスクリプトの実行中も、CPUコアを占有し、その間他のすべてのクライアントリクエストはブロックされる。

さらに恐ろしいのは、「マスター・レプリカ間の整合性(Determinism)」だ。
Redisは、レプリカに対して「どのスクリプトをどう実行したか(EVALSHA等)」、あるいは「スクリプトが生成した書き込みコマンドの列(Redis 5以降のLua Replication)」を伝播させる。もしスクリプトの動作が非決定的であれば、マスターとレプリカでデータが乖離し、分散システムとしての根幹が崩壊する。

この世界観を守るために、RedisのLua環境(Lua 5.1ベース)は徹底的に牙を抜かれている。

—

2. 犯しがちな罪:サンドボックスの主な制限と実務的リスク

① グローバル変数の全面禁止(漏洩の拒絶)

Luaでは `a = 1` と書くだけでグローバル変数になるが、Redisのサンドボックスではこれが厳禁だ。

— ❌ やってはいけない例:グローバル変数の定義
function process_data(val)
counter = counter + 1 — counterがグローバル空間に漏出する!
return counter + val
end

なぜ禁止なのか?
Redisはスクリプトの実行環境(Lua State)をリクエスト間で使い回す。もしグローバル変数が許されると、あるクライアントのスクリプトが汚染した変数が、別のクライアントのスクリプト実行時に予期せぬバグを引き起こす(State Pollution)。
Redisはスクリプトロード時にグローバル変数の宣言を検知すると、容赦なくエラーを返す。

> 【対策】
> すべての変数は `local` キーワードを付与してスコープを閉じ込めろ。これすら忘れるプログラマーは、コードレビューの時点で容赦なく差し戻しだ。

② 非決定的な処理の排除(時を操るな)

以下のコードを見ただけで、シニアエンジニアなら冷汗をかくはずだ。

— ❌ 最悪のアンチパターン:非決定的処理
local current_time = os.time() — 現在時刻の取得
local random_val = math.random() — 乱数の生成

if current_time % 2 == 0 then
redis.call(‘SET’, KEYS[1], random_val)
end

なぜ禁止なのか?
先ほども言った通り、レプリケーションの都合だ。マスターで生成された「現在時刻」や「乱数」は、レプリカで全く同じ値になるとは限らない。結果、マスターとレプリカで異なるデータが生成され、データ不整合(Split-Brain状態)を引き起こす。
RedisのLua環境では、`math.random` や `math.randomseed` は厳しく制限されており、あらかじめシードが固定されているか、非決定的関数がフックされている。時刻を取得したい場合は、Redis側から `TIME` コマンド相当の値を引数として渡すのが鉄則だ。

③ ファイルシステム・OS機能へのアクセス遮断

`io` ライブラリ、`os` ライブラリの大半(`os.execute` など)は削除または無効化されている。Redisのプロセスから外側のホストOSをいじったり、ログファイルを勝手に書き出したりすることは一切できない。
インメモリデータベースとしての安全性を担保するため、Luaはあくまで「純粋なデータ加工とコマンド構築のエンジン」に限定されている。

—

3. 実務で使える堅牢な設計パターン

では、これらの制限を踏まえた上で、実務でどうコードを書くべきか。
「レートリミッター(Token Bucket)」を例に、プロダクションクオリティのLuaスクリプトの書き方を示そう。

【実装例】アトミックなトークンバッファ消費スクリプト

— KEYS[1]: バケットのキー (例: “rate_limit:user:123”)
— ARGV[1]: 最大容量 (Capacity)
— ARGV[2]: 補充レート (Tokens per second)
— ARGV[3]: 現在のタイムスタンプ (ミリ秒 – アプリケーション側から渡す!)
— ARGV[4]: 今回消費するトークン数 (Cost)

local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])

— ハッシュから現在の状態を取得
local bucket = redis.call(‘HMGET’, key, ‘tokens’, ‘last_updated’)
local tokens = tonumber(bucket[1])
local last_updated = tonumber(bucket[2])

if tokens == nil then
— 初回アクセス時は満タンの状態からスタート
tokens = capacity
last_updated = now
else
— 経過時間に応じたトークンの補充計算
local elapsed = math.max(0, now – last_updated)
local tokens_to_add = (elapsed / 1000.0) refill_rate
tokens = math.min(capacity, tokens + tokens_to_add)
last_updated = now
end

— トークンが足りるか判定
if tokens < requested then -- 拒否 return {0, tokens} end -- トークンを消費して状態を保存(TTLも付与してゴミを残さない) tokens = tokens - requested redis.call('HMSET', key, 'tokens', tokens, 'last_updated', last_updated) redis.call('PEXPIRE', key, math.ceil((capacity / refill_rate) 1000)) -- 許可(残トークン数を返す) return {1, tokens}

この設計の美しい点(レビューポイント)

1. 決定性の担保: `os.time()` に頼らず、アプリケーション側から `ARGV[3]` としてミリ秒単位のタイムスタンプを受け取っている。これにより、レプリケーションの整合性が完全に保証される。
2. ローカル変数の徹底: すべての変数に `local` を付与し、メモリリークや汚染を防いでいる。
3. 副作用の制御: Luaスクリプト内でのデータ書き込みはすべて安全な `redis.call()` を経由している。

—

4. パフォーマンス上の注意点:Lua地獄に落ちないために

最後に、パフォーマンスの観点から絶対に守るべき鉄則を共有する。

  • 重い処理を詰め込むな

Luaの中で何万件ものループを回してJSONのパースや複雑な文字列操作を行うな。Redisはシングルスレッドだ。1つのスクリプトが10msブロックすれば、その間Redis全体が停止する。複雑な計算はアプリケーションサーバー(Go, Rust, Node.js等)の仕事であり、Redisは「データの保管と超高速なアトミック操作」に特化させるべきだ。

  • EVALの代わりに `SCRIPT LOAD` + `EVALSHA` を使え

毎回数KB〜数十KBのスクリプト文字列をRedisに送信してパースさせるのはネットワークとCPUの無駄だ。あらかじめ起動時にスクリプトを登録し、ハッシュ値(SHA1)だけを指定して実行するフローを標準とせよ。

チーフアーキテクトからの総括

RedisのLuaサンドボックスは、開発者を縛るための「足かせ」ではない。巨大なトラフィックを捌き続けるRedisという野獣を、安全に手懐けるための「手綱」なのだ。

この制限を「面倒くさい」と嫌うのではなく、その裏にある設計思想(シングルスレッドモデルと分散一貫性)を理解してこそ、真にスケーラブルなシステムを構築できる。

次のレビューで、もし `os.time()` を使ったLuaスクリプトを見かけたら、この記事をそっと突きつけてやってほしい。「出直してこい」とな。

コメント

タイトルとURLをコピーしました