Lambdaは初期化をどこに書くかで10倍変わる|実測と、状態が残る罠

Lambdaは初期化をどこに書くかで10倍変わる|実測と、状態が残る罠

Lambda の設計で最初に効くのは、コードをハンドラの中に書くか、外に書くかです。ここを間違えると、毎回の呼び出しで同じ初期化を繰り返します。

どれくらい違うのかを、手元で実際に測りました。50msかかる初期化処理を、同じコンテナで10回呼び出したときの合計です。

同じコンテナで 10 回呼び出したときの合計

  A ハンドラの中で初期化   合計   523.0ms   1回あたり  52.3ms
  B ハンドラの外で初期化   合計     0.1ms   1回あたり     0ms

Bも初期化のコスト自体は払っていますが、それはコンテナが起動したときの1回だけです。2回目以降の呼び出しでは何もしません。

INITフェーズは1回だけ実行され、INVOKEフェーズが呼び出しごとに繰り返されることを示したLambdaのライフサイクル図
コードのどこに書くかで、実行される回数が変わる
目次

INIT と INVOKE を分けて考える

Lambda の実行環境には2つのフェーズがあります。

  • INIT
    コンテナが起動し、モジュールが読み込まれ、ハンドラの外側のコードが実行される。ここが「コールドスタート」
  • INVOKE
    ハンドラが呼ばれる。コンテナが再利用される限り、何度も繰り返される
// ここは INIT で1回だけ実行される
const dbClient = new Client(process.env.DB_URL);
const config   = JSON.parse(readFileSync('./config.json'));

export async function handler(event) {
  // ここは INVOKE ごとに毎回実行される
  return dbClient.query(...);
}

ハンドラの外に置くべきものは決まっています。

  • SDKクライアントの生成
  • データベースへの接続
  • 設定ファイルの読み込み・パース
  • 正規表現のコンパイル、定数テーブルの構築

ただし1つ注意があります。INIT フェーズで例外が出ると、そのコンテナは起動に失敗します。環境変数の不足などで落ちる場合、エラーの出方が分かりにくくなるので、INIT では「失敗しうる処理」を避けるという判断もあります。接続の確立は遅延させて、最初の呼び出し時に行う設計です。

再利用されるということは、状態が残るということ

ハンドラの外に置いたものが再利用されるのは、都合がいい反面、事故のもとにもなります。実際に動かしてみました。

const cache = [];                    // よくある「キャッシュ」

async function handler(event) {
  cache.push(event.userId);
  return cache.length;
}
  u1 のリクエスト後 … 配列の中身: ["u1"](1件)
  u2 のリクエスト後 … 配列の中身: ["u1","u2"](2件)
  u3 のリクエスト後 … 配列の中身: ["u1","u2","u3"](3件)

u3 のリクエストを処理している最中に、u1 と u2 のデータが見えています。これがモジュールスコープに可変の状態を置いたときの挙動です。

怖いのは、ローカルでも、テストでも、まず再現しないことです。1回ずつしか呼ばないので配列は常に1件です。本番でトラフィックが増え、コンテナが再利用されるようになって初めて現れます。

  • メモリが増え続ける
    コンテナが生きている限り解放されない
  • 別ユーザーのデータが混ざる
    情報漏洩になりうる
  • 結果が呼び出し順に依存する
    同じ入力で同じ出力にならない

原則はシンプルで、ハンドラの外には「読み取り専用のもの」だけを置くことです。接続や設定は共有していいものですが、リクエスト固有のデータを置いてはいけません。キャッシュが必要なら、外部のキャッシュストアを使います。

コールドスタートを減らす

初期化の位置を直したうえで、それでもコールドスタートが問題になる場合の手段です。効果とコストの順に並べます。

手段効果コスト
パッケージを小さくする読み込み時間が減るほぼなし。まずこれ
メモリを増やすCPUも比例して増えるので初期化が速くなる単価は上がるが、実行時間が縮んで合計が下がることがある
VPCの外で動かすENIの準備が不要になるVPC内リソースにアクセスできない
Provisioned Concurrency常に温めておく使わなくても課金される

意外に効くのが2番目です。Lambda のCPU性能はメモリ設定に比例します。メモリを2倍にすると処理も速くなるので、「単価2倍 × 実行時間半分」で合計は変わらず、レイテンシだけ改善する、ということが起こります。メモリ設定は、メモリ不足のときだけ触るものではありません。

1番目のパッケージサイズは、依存関係の整理が効きます。SDK全体ではなく必要なクライアントだけを読む、開発用の依存をバンドルから外す、といった基本的なところです。

夜明けの陸上トラックで、スターティングブロックを低く近くから撮った写真
走り出す前に済ませておけることがある

タイムアウトとリトライの掛け算

設計で見落としやすいのがここです。Lambda がタイムアウトしても、処理が止まるとは限りません。

非同期呼び出しの場合、Lambda は失敗を自動的にリトライします。既定では2回まで再試行されるので、合計3回実行されます。そしてタイムアウトした1回目の処理は、途中まで進んでいます。

  • 1回目
    DBに書き込んだ直後にタイムアウト
  • 2回目
    同じ処理が最初から走り、もう一度書き込む

これはジョブキューの記事で書いた「少なくとも1回」とまったく同じ問題です。Lambda の関数は冪等に書く必要があります。

タイムアウトの値そのものも設計対象です。

  • 長すぎる
    詰まった処理が最大時間まで居座り、同時実行数を食う
  • 短すぎる
    正常な処理が途中で切られ、リトライで二重実行される
  • 呼び出し元より長い
    API Gateway の上限は29秒。Lambda を60秒にしても、クライアントには先にタイムアウトが返る

3番目は特に混乱のもとです。クライアントにはエラーが返っているのに、Lambda は裏で処理を続けて成功するという状態になります。ユーザーが「失敗した」と思って再送すると、二重登録になります。

失敗したイベントを捨てない

同期呼び出しなら、失敗はそのまま呼び出し元に返るので気づけます。問題は非同期呼び出しです。3回試して全部失敗したイベントは、何も設定していないと消えます。

「S3にファイルが置かれたら処理する」といった構成で、処理されなかったファイルが黙って放置されることになります。しかもエラーは出ているので、ログを見ていれば分かる、という状態が続きます。

受け止め先を必ず設定します。

  • Lambda Destinations
    成功時と失敗時で別々の送り先を指定できる。エラーの内容とイベント本体の両方が送られるので、後から調査しやすい
  • デッドレターキュー
    SQSやSNSに送る。イベント本体は入るが、エラーの詳細は付かない

新しく作るなら Destinations のほうが情報量が多くて扱いやすいです。どちらにしても、送り先に何か入ったら通知するところまで作らないと、設定しただけで終わります。

SQSトリガーは「1件失敗すると全部やり直し」

SQS をトリガーにする場合、特有の落とし穴があります。Lambda は複数のメッセージをまとめて1回の呼び出しで受け取ります。バッチサイズが10なら、10件が同時に渡されます。

このとき、10件中1件だけ失敗して関数がエラーを返すと、10件すべてが未処理として扱われ、まるごと再配信されます。成功していた9件も、もう一度処理されます。

ここでも冪等性が効いてきますが、無駄な再処理は減らせます。失敗したメッセージのIDだけを返す仕組み(部分的なバッチ応答)を使うと、成功した分は確定します。

export async function handler(event) {
  const failures = [];
  for (const record of event.Records) {
    try {
      await process(record);
    } catch (e) {
      failures.push({ itemIdentifier: record.messageId });   // 失敗した分だけ返す
    }
  }
  return { batchItemFailures: failures };
}

ポイントは、1件の失敗で throw せず、最後まで回してから返すことです。途中で例外を投げると、それ以降のメッセージは処理されないまま全件再配信になります。イベントソースマッピング側でこの機能を有効にする設定も必要です。

同時実行数はアカウント全体で共有される

もう1つ、影響範囲が読みにくいのが同時実行数です。上限はアカウントとリージョン単位で共有されます。

つまり、ある関数が大量に呼ばれて枠を使い切ると、まったく無関係な別の関数がスロットリングされます。バッチ処理が動いた時間帯だけAPIが落ちる、という現象はこれが原因のことがあります。

対策は、関数ごとに予約同時実行数を設定して枠を区切ることです。重要な関数には確保し、バッチ系には上限を設けます。これは「その関数が使える最大数」でもあるので、設定しすぎると逆に詰まる点には注意が必要です。

下流にも同じ考え方が要ります。Lambda が1000並列で立ち上がると、接続先のRDSにも1000本の接続が来ます。Lambda はスケールしても、データベースの接続数上限はスケールしません。RDS Proxy のような接続を集約する仕組みを挟むか、予約同時実行数で上限を切ります。

まとめ

  • 初期化はハンドラの外へ
    10回の呼び出しで523ms と 0.1ms の差になった
  • 外に置けるのは読み取り専用のものだけ
    可変の状態は次のリクエストに残る
  • 状態の混入はローカルで再現しない
    本番でコンテナが再利用されて初めて出る
  • メモリ設定はCPU性能でもある
    増やすと合計コストが下がることがある
  • 非同期呼び出しは最大3回実行される
    関数は冪等に書く
  • 同時実行数はアカウント共有
    別の関数の影響を受ける。下流の接続数にも注意する

次に読む記事

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

わどこんのアバター わどこん

実務12年のバックエンド・インフラエンジニア。バックエンド開発からクラウド・インフラの設計・構築・運用まで担当しています。主要言語は Java・Kotlin・PHP・Python。運用の現場で拾った知見を、再現できる手順に落として残すのがこのブログのテーマです。

目次