Lambda ではホットスタートが続くときグローバル変数が共有される

Lambda ではある程度の短い間隔でリクエストが続く場合「ホットスタート」といって同一のインスタンスが処理を担い続けます。Lambda でだけ言えば「ウォームスタート」の方が一般的な用語かもしれません。

Operating Lambda: パフォーマンスの最適化 – Part 1 | Amazon Web Services
Operating Lambda: パフォーマンスの最適化 – Part 1 | Amazon Web Services
Operating Lambda シリーズでは、AWS Lambda ベースのアプリケーションを管理している開発者、アーキテクト、およびシステム管理者向けの重要なトピックを取り上げます。この 3 部構成のシリーズでは、Lambda ベースのアプリケーションのパフォーマンスの最適化について説明します。 サーバーレスアプリケーションは、並列化と同時実行が容易であることから、非常に高いパフォーマンスを実現することができます。Lambda サービスはスケーリングを自動的に管理しますが、アプリケーションで使用する個々の Lambda 関数を最適化することで、レイテンシーを削減し、スループットを向上させることもできます。 本稿では、Lambda 実行環境のライフサイクルやコールドスタートの定義、測定方法、およびその改善方法について説明します。 コールドスタートとレイテンシーを理解する Lambda サービスが Lambda API を介して関数を実行するリクエストを受け取ると、サービスは最初に実行環境を準備します。このステップでは、サービスは内部の Amazon S3 バケット (関数がコンテナパッケージを使用している場合は Amazon Elastic Container Registry) に保存されている関数のコードをダウンロードします。次にサービスは、指定されたメモリ、ランタイム、および各種設定に基づいた環境を作成します。これらが完了すると、Lambda はイベントハンドラー外に記述された初期化コードを実行した後に、最終的にハンドラーコードを実行します。 この図における、環境とコードをセットアップする最初の 2 つのステップは、しばしば「コールドスタート」と呼ばれます。Lambda が関数の準備に要する時間は課金されませんが、全体的な実行時間に対して遅延を及ぼします。 実行が完了すると、実行環境はフリーズされます。リソース管理とパフォーマンスを向上させるために、Lambda サービスは実行環境を不特定期間保持します。この間、同じ関数に対する追加のリクエストが到着すると、サービスは環境を再利用するよう試みます。通常、この 2 番目のリクエストはより迅速に終了します。これは、実行環境がすでに存在し、コードをダウンロードして初期化コードを実行する必要がないためです。これは「ウォームスタート」と呼ばれます。 本番環境の Lambda ワークロードの分析によると、コールドスタートが発生するのは通常、呼び出しの 1% 未満です。コールドスタートに要する時間は、100 ミリ秒未満から 1 秒以上までさまざまです。Lambda サービスはウォームな環境を後続の呼び出しに再利用するため、コールドスタートは通常、本番環境のワークロードよりも開発およびテスト中の関数で多くみられます。これは、一般的に開発やテスト中の関数が呼び出される頻度が、本番環境のそれよりも低いためです。 実行環境のライフサイクル Lambda サービスは、実行後すぐには実行環境を破棄せず、保持しつづけます。環境の存続期間はさまざまな要因の影響を受け、現在のところ開発者が設定することはできません。存続期間は Lambda サービスの運用上の要因によっても影響されます。 実行環境の再利用は有用ですが、パフォーマンスの最適化をこれに依存すべきではありません。Lambda は、AWS リージョン内の複数の […]

この 1 つの Lambda の単位で CloudWatch のログストリームも 1 つ分として構成されるのが基本ルールのようですし、何かと意識するタイミングは多いです。

実行速度など主にパフォーマンスの観点で語られることが多い概念ですが、それに関連して同一プロセス内で動作するような処理になるためにグローバルで定義した変数が共有されてしまうということがあるようです。

以下のようなコードを書いてしまったときにたまたま気が付きました。

from datetime import datetime

now = datetime.now()

def lambda_handler(event, context):
    # do something

AWS 側が用意するログのタイムスタンプと datetime.now() した時刻が全くもって合わないので「もしや?」と思えました。ちょうどいま初めて同じ内容でググってみたのですが、他にも全く同じパターンで気づいた方が多いみたいです(やっぱり現在時刻を冒頭に脳死で初期化するということ自体に問題を感じた…)。

ちなみに「グローバル」と言っているのは lambda_handler() の外かどうか、と同義です。

なので対策としては「ロジックで使うような変数は全てメイン関数の中で宣言しましょう」ということになります。

from datetime import datetime

def lambda_handler(event, context):
    now = datetime.now()

    # do something

ただこれを裏返して言うと、DB とのコネクションや AWS SDK のセッションなど、パフォーマンス的に積極的に使いまわしたいものはグローバルで宣言した方がいいということになりますね(実際、これは AWS が推奨するベストプラクティスの中でも特によく言われているパターンです)。

RDS を使うときのパフォーマンス測定でコネクションを共有するかどうかで実行時間の差をテストしたことがあるのですが、2回目以降は劇的に早くなります。当然コールドスタート時(ある程度前回からのリクエストから時間が空いたとき)だけは初動が遅くなってしまいますが、全体論で考えたらほとんどのユーザーの体感速度を上げられるのでプラスしかありません。

ちなみに RDS の例でいうとVPC 内にある Lambda の起動は驚くほど遅いのでどちらかというとそちらの問題をケアしたほうがよいです(昔より遥かに改善されましたがまだ差はあります)。

handler の中に入れるべきものとそうではないものをよく検討できるとよさそうです。