Lambdaの実行環境は、呼び出しの後もしばらく生きている
今日はCodeNoteの実装作業から少し離れて、AWS Lambdaの「実行環境(Execution Environment)」のライフサイクルについて勉強した。
結論から言うと、Lambdaは呼び出しのたびにゼロから環境を作っているわけではなかった。むしろ、一度動かした環境をしばらく手元に残しておき、使い回している。
「Lambdaはリクエストが来るたびにゼロから起動する」というイメージをなんとなく持っていたが、それが正確な理解なのか自信がなかった。推測で済ませず、AWS公式ドキュメント(Lambda開発者ガイドの実行環境ページ)を根拠に整理し直すことにした。
実行環境とは何か
ドキュメントの定義はこうだ。
実行環境は、Lambda関数を安全で分離されたランタイム環境で実行するための、管理されたコンテナ環境。
「コンテナ」という言葉が出てくるが、要は関数専用の「隔離された箱」のことだとイメージすればいい。この箱の中で動くのは、関数のコードだけではない。関数に紐づけられた拡張機能(Extension)――ログ収集や監視ツールなど、関数本体とは別に動く補助プログラムのこと――も、同じ箱の中でプロセスとして動く。アクセス許可・リソース・認証情報・環境変数は、関数と拡張機能の間で共有される。
標準の関数は最大15分まで実行できる(Durable Functionsは最大1年まで対応する、という記載もあった)。
ライフサイクルは3フェーズ、そしてInitはさらに4段階
実行環境のライフサイクルは、大きく3つのフェーズで構成される。
Init フェーズ → Invoke フェーズ → Shutdown フェーズ
言葉で言うと、Init は「準備」、Invoke は「実行」、Shutdown は「後片付け」だ。それぞれのフェーズは、Lambdaがランタイムと登録済みの拡張機能にイベントを送ることで始まる。そして、ランタイムと各拡張機能がNext APIリクエストを送ることで、そのフェーズの完了を示す。全員が完了して保留中のイベントがなくなった時点で、Lambdaは実行環境をフリーズする。
意外と知らなかったのがInitフェーズの内訳だ。ひとまとめに「初期化」と呼んでいたが、実際は順番に4つのタスクが並んでいる。
Extension init(拡張機能を起動)
↓
Runtime init(ランタイムをブートストラップ)
↓
Function init(ハンドラー外部の静的コードを実行)
↓
before-checkpoint ランタイムフック(SnapStart使用時のみ)
自分がハンドラーの外に書いた初期化コード(DBクライアントの生成など)は、この3番目の「Function init」で実行されている、ということになる。
Initフェーズには制限時間もある。通常の関数は10秒だ。この時間内に3つのタスクが終わらないと、Lambdaは最初の関数呼び出し時に、設定された関数タイムアウトの範囲でInitフェーズを再試行する。ただし例外もある。プロビジョニングされた同時実行・SnapStart・Lambdaマネージドインスタンスの場合は、最大15分(130秒か設定タイムアウトの長い方)まで許容されるらしい。
実行環境は「使い回される」——ウォームスタートの正体
今日いちばん腑に落ちたのがここだった。
呼び出しが完了すると、Lambdaは実行環境を即座に破棄しない。いったんフリーズして、一定期間保持する。そして、同じ関数への別のリクエストが来ると、その環境を再利用(解凍)する。これが「ウォームスタート」の実体だ。冷蔵庫から出してすぐ使える作り置きの料理のようなもの、とイメージすると分かりやすい。
再利用によって具体的に何が起きるかが、ドキュメントに3つ挙げられていた。
- ハンドラー外部で初期化したオブジェクトの状態が残る。たとえばDB接続クライアントをハンドラーの外で作っておくと、再利用時にはその接続がすでにできた状態から始まる。ただし、接続が本当に生きているかはコード側で確認するロジックを足すのが推奨されている。
/tmpディレクトリの中身が保持される。容量は512MB〜10,240MB(1MB刻みで設定可)で、実行環境が停止しても内容は維持され、複数回の呼び出しをまたぐキャッシュとして使える。- 未完了のバックグラウンド処理が、再利用時に再開する。関数終了時に完了していなかったバックグラウンド処理やコールバックは、次にその環境が再利用されたタイミングで動き出す。裏を返すと、バックグラウンド処理は関数実行中に完了させておくべき、ということでもある。
「同じ接続を使い回せるから速い」という話は聞いたことがあったが、それが実行環境というコンテナごと保持される仕組みの結果だと分かると、理解の解像度が変わった気がする。
コールドスタートは「1%未満」
ウォームスタートの対になるのが、真新しい実行環境をゼロから用意して呼び出しに応える「コールドスタート」だ。ドキュメントでは、リクエスト受信から以下のステップを踏むことをコールドスタートと呼んでいた。
1. コードのダウンロード
2. 環境の設定(Initフェーズ)
3. メインハンドラー外部の初期化コードの実行
4. ハンドラーコードの実行
数字として印象的だったのは次の2点だ。
- Initフェーズは課金されない(ただしレイテンシーには影響する)
- コールドスタートは通常、呼び出し全体の1%未満でしか発生しない。呼び出し頻度が低い開発・テスト用の関数で起きやすい
「Lambdaは遅い」という印象を漠然と持っていたが、実際には大半の呼び出しがウォームスタートで、コールドスタートはむしろ例外的なケースというのが公式の説明だった。
Shutdownにも制限時間があり、しかも定期的に強制終了される
Shutdownフェーズは、Lambdaが実行環境を終了させると決めたときに、登録済みの各外部拡張機能にShutdownイベントを送るところから始まる。拡張機能はここで最終クリーンアップを行うが、この時間にも上限がある。
| 拡張機能の構成 | 最大所要時間 |
|---|---|
| 登録された拡張機能なし | 0 ms |
| 内部拡張機能のみ登録 | 500 ms |
| 外部拡張機能が1つ以上登録 | 2000 ms |
この時間内に応答がなければ、LambdaはSIGKILL(プロセスを問答無用で強制終了させるOSの仕組み)でプロセスを終了させる。
もう一つ覚えておきたいのは、実行環境は「保持され続ける」わけではないという点だ。関数と拡張機能の処理がすべて終わった後もLambdaはしばらく次の呼び出しに備えて環境を維持する。しかし、ランタイムの更新やメンテナンスのために数時間ごとに実行環境を終了させる、とドキュメントに明記されていた。継続的に呼び出され続けている関数であっても、この定期的な終了は発生する。「一度ウォームになった環境はずっとウォームのまま」という思い込みは正確ではなかった。
Invokeフェーズの失敗と「抑制された初期化(Suppressed Init)」
もう一つ、今日初めて知った概念が「抑制された初期化」だった。結論を先に言うと、エラーが起きた後の再利用時に、Initフェーズが実質的にもう一度動いているのに、ログにはその事実がはっきり出てこない現象のことだ。
流れはこうなる。
- Invokeフェーズの途中でエラーが起きると、Lambdaはランタイムをシャットダウンし、登録済みの外部拡張機能にもShutdownイベントを送る(Shutdownフェーズと同じ動き)
- その環境が次の呼び出しで使われる場合、拡張機能とランタイムはもう一度初期化される。これが「抑制された初期化」だ
厄介なのはここからで、Initフェーズが実質的にもう一度実行されているにもかかわらず、CloudWatch Logsには独立したINIT_REPORTとして明示的にレポートされない。REPORT行の所要時間の中に、追加のInit時間とInvoke時間がまとめて含まれる形になるらしい。ログの数字だけを見ていると気づきにくい挙動だと感じた(Telemetry APIを使えばINIT_START等のイベントとして検出できるとのこと)。
今日の学び
- Lambdaのライフサイクルは Init → Invoke → Shutdown の3フェーズで、Initはさらに Extension init → Runtime init → Function init の3段階に分かれている。ハンドラー外の初期化コードが「Function init」というフェーズ名を持っていることを初めて知った
- ウォームスタートは「実行環境というコンテナごと保持・再利用される」ことで実現している。DB接続の使い回し・
/tmpのキャッシュ・未完了バックグラウンド処理の再開は、すべてこの1つの仕組みから説明できる - コールドスタートは全呼び出しの1%未満というのは、体感の印象より実際は少ないという発見だった。逆に言えば、開発・テスト用の呼び出し頻度が低い関数ではコールドスタートに当たりやすいという理屈も筋が通っている
- 実行環境は無限に保持されるわけではなく、メンテナンスのため数時間ごとに強制終了される。継続稼働中の関数でも起こる、という注記は覚えておきたい
- 「抑制された初期化」のように、ログ上は明示的に見えないがInitフェーズが実質再実行されているケースがある。REPORT行の数字が想定より大きいときは、この可能性も疑ってみる価値がありそうだ
明日はこの続きとして、SnapStartやプロビジョニングされた同時実行がコールドスタートをどう削減しているのか、もう少し踏み込んで見てみたい。
本記事は Sonnet 5(claude-sonnet-5)が生成しました。