はじめに
AWSのLambda + DynamoDB構成では、負荷試験は「API(関数)ごとに負荷を掛けて、性能を測る」形になりがちだと思います。
Lambdaは関数ごとにメモリが独立していてオートスケールも任せられます。
私自身、Cloud Runの負荷試験で同じ進め方をしていました。
APIごとのメモリに着目し、「秒間5回、10回」や「5並列、10並列」で流して、問題なしと判断していたのです。
ただ、これはコンテナの中で複数のAPIが同時に動き、メモリの状況が変わるという観点が抜けた試験でした。
コンテナ構成にLambdaの考え方を持ち込むと、OOM(メモリ不足)に気付けないまま本番に出てしまう可能性があります。
この記事では、負荷試験で何をすべきかを整理したうえで、Lambdaとコンテナの特性の違いを公式ドキュメントで確認し、コンテナでOOMに気付けるシナリオの考え方をまとめます。
目次
- 負荷試験で何をすべきか
- AWSとGCPの特性チェックリスト
- Lambdaの負荷試験が「APIごと」で成り立つ理由
- コンテナ(Cloud Run)の特性
- APIごとの試験でOOMに気付けない理由と、気付くためのシナリオ
- 負荷試験のチェックリスト
- まとめ
1. 負荷試験で何をすべきか
目的は「速さの計測」ではなく「限界と壊れ方の把握」
負荷試験というと「このAPIは何ミリ秒か」を測るものというイメージがあります。
ただ、それは結果の一部で、本来の目的は本番で何が先に壊れるかを、壊れる前に知ることではないでしょうか。
確認することは、大きく4つに分けられると思います。
| 確認すること | 観点 | 見るもの |
|---|---|---|
| 容量 | 想定するピークを、目標の応答時間とエラー率で処理できるか | p95/p99のレイテンシ、エラー率、スループット |
| 限界 | どこまで負荷を上げると崩れるか。最初に崩れるのはどこか | ボトルネックになるリソース(CPU・メモリ・接続数・外部APIのクォータ) |
| スケール | 負荷の増減に、オートスケールが間に合うか | インスタンス数・同時実行数の推移、スロットリング |
| 安定性 | 長時間負荷をかけても、劣化しないか | メモリ・接続数・レイテンシの時間推移 |
試験の種類は、確認したいことに合わせて使い分けます。
| 種類 | 負荷のかけ方 | 確認すること |
|---|---|---|
| ロードテスト | 想定ピークまで段階的に上げ一定時間保持 | 容量 |
| ストレステスト | 想定を超えて上げ続ける | 限界、壊れ方(劣化か、停止か) |
| スパイクテスト | 短時間で急増させる | スケール速度、スロットリング |
| ソークテスト | 一定の負荷を長時間かける | メモリリーク、メモリ解放、安定性 |
進め方
進め方は、次の6ステップに整理できそうです。
- 目標を決める(想定ピークのリクエスト数、合格基準のp95とエラー率)
- シナリオを設計する(実運用のAPIの比率・データ量・同時接続数を再現する)
- データと環境を用意する(試験データの作成が失敗していないかも確認する)
- 負荷を段階的にかける(ランプアップし、各段を一定時間保持する)
- 観測する(レイテンシだけでなく、CPU・メモリ・接続数・インスタンス数・DB・外部API)
- 判定と再試験(合否だけでなく、最初に限界に達したリソースを特定し、他リソースとのバランスや設定を見直す)
負荷試験の目的として、AWSもLambdaのベストプラクティスではタイムアウト値の決定と、Lambdaのスケールに下流のサービスが耐えられるかの確認を挙げています。
参考:
https://docs.aws.amazon.com/lambda/latest/dg/best-practices.html
「何を測るか」は基盤の特性で決まる
手順は同じでも、基盤によって「最初に崩れる場所」と「見るべき指標」が変わります。
- Lambdaは1環境1リクエストで、関数ごとにメモリが独立している。崩れやすいのは同時実行数・スケール速度・下流の接続
- コンテナは1インスタンスに複数のリクエストが入り、メモリが共有される。崩れやすいのはメモリ・CPU・同時実行数の釣り合い
そのため、Lambdaで成り立つ試験の組み立てをコンテナに持ち込むと適切な負荷試験になりません。
次の章で、その違いを公式ドキュメントで確認します。
2. AWSとGCPの特性チェックリスト
まず、サービスの対応関係です。
| 用途 | AWS | GCP |
|---|---|---|
| 関数(イベント駆動) | Lambda | Cloud Run functions |
| 常駐コンテナ(HTTP) | ECS (Fargate) + ALB、App Runner | Cloud Run Service |
Cloud Run functionsの第2世代は、Cloud Runサービス上に作られています。
Cloud Runサービスは、ECS (Fargate) + ALB + Auto Scalingが提供するものを1つにまとめたサービスで、AWSではApp Runnerが最も近い位置づけです。
参考:
https://docs.cloud.google.com/functions/docs/concepts/version-comparison
次に、負荷試験の結果を左右する特性です。
メモリ・同時実行
| チェック項目 | AWS Lambda | AWS ECS (Fargate) | GCP Cloud Runサービス |
|---|---|---|---|
| 1つの実行単位が同時に処理する数 | 1リクエストのみ(公式) | アプリ次第(1タスク内で並行処理) | 最大1,000。設定値は上限で、CPUが高いと振り分けが減る(公式) |
| メモリはリクエストごとか、全体で共有か | 実行環境ごとに独立。設定するのは関数1つ分(公式) | タスク全体で共有 | インスタンス全体で共有。ピーク = 常時 + 1リクエスト分 × 同時実行数(公式) |
| メモリ超過時 | その呼び出しが失敗し、実行環境がリセットされる(公式) | コンテナ(memoryのハード制限)が強制終了される。必須(essential)コンテナなら、タスクの他のコンテナも停止する(公式) |
インスタンスが終了される。リクエストはHTTP 500/503になる(公式) |
| 一時ファイル |
/tmpに512 MB〜10 GB(メモリとは別枠) |
エフェメラルストレージ。既定20 GiB、最大200 GiB(メモリとは別枠)(公式) | インメモリFS。書いた分だけメモリを消費(公式) |
スケール
| チェック項目 | AWS Lambda | AWS ECS (Fargate) | GCP Cloud Runサービス |
|---|---|---|---|
| スケールの指標 | 同時実行数。環境が足りなければ増やす | CPU・メモリ・ALBリクエスト数などを選べる(公式) | CPU使用率(目標60%)と同時実行数(目標60%)。メモリでのスケールは非対応(公式) |
| スケール速度の制限 | 関数ごとに10秒あたり1,000環境 | Auto Scalingのクールダウンに従う | 公式に固定の速度は明記なし。最大インスタンス数は短時間超過することがある |
| ゼロへのスケール | 可能 | 最小キャパシティ0にすれば可能 | min instances 0で可能 |
| 実行時間の上限 | 15分 | なし(常駐) | 既定5分・最大60分(公式) |
起動・DB
| チェック項目 | AWS Lambda | GCP Cloud Run |
|---|---|---|
| コールドスタート対策 | Provisioned Concurrency、SnapStart(公式) | min instances、Startup CPU boost(公式) |
| 初期化コードを書く場所 | ハンドラーの外(公式) | グローバルスコープ(公式) |
| DBの急増対策 | DynamoDBは前回ピークの2倍まで即時(公式) | 最大インスタンス数 × プールサイズがDB上限に収まるか計算する |
ここから分かること
この表から読み取れるのは、次の3点です。
- Lambdaだけが、メモリを「1リクエスト単位」で考えられる。ECSもCloud Runも、コンテナ全体で共有する
- ECSとCloud Runの最大の違いは、スケール指標にメモリを選べるかどうか
- Cloud RunをLambdaに近づけたいときは、同時実行数を1にする(レポート作成など)
3. Lambdaの負荷試験が「APIごと」で成り立つ理由
Lambdaは同時リクエストごとに別の実行環境を用意し、1つの環境は処理中に他のリクエストを受けません。
関数ごとに実行環境が分かれ、メモリも関数ごとに設定します。
そのため、次の性質が成り立ちます。
- 関数(API)ごとにメモリが独立していて、あるAPIの負荷が別のAPIのメモリに影響しない
- メモリ不足は、その呼び出し1回の失敗として現れ、他のリクエストは巻き込まれない
- 1回ごとの使用量が見える(各呼び出しの
REPORTログにMax Memory Usedが出る)
つまり、「APIごとに負荷を掛け、各APIのp95とエラー率、Max Memory Usedを見る」という試験が、そのままメモリの妥当性の確認にもなります。
あとはスケールの上限(同時実行数1,000、10秒あたり1,000環境)と、DynamoDBの「前回ピークの2倍」(ランプアップに30分以上かける)を押さえれば、シナリオはほぼ組めます。
SnapStartを使う場合は、1つのスナップショットから複数の環境を復元するため、初期化時に生成した乱数・ID・接続は復元後に作り直す必要があります。
参考:
https://docs.aws.amazon.com/lambda/latest/dg/snapstart.html
4. コンテナ(Cloud Run)の特性
メモリは「インスタンス全体」で共有される
Cloud Runは1インスタンスが複数のリクエストを同時に処理します(最大1,000)。
メモリは同じインスタンスで共有され、公式はピークメモリを次の式で見積もるとしています。
ピークメモリ = 常時メモリ + (1 リクエストあたりのメモリ × 同時実行数)
ここでの「1リクエストあたりのメモリ」は、APIごとに違います。
そして1つのインスタンスには、複数のAPIのリクエストが混ざって入ります。
また、ディスクはインメモリFSなので、書き込んだファイルもメモリを消費します。
オートスケールはメモリを見ない
Cloud Runのオートスケールの指標は、CPU使用率(目標60%)と同時リクエスト数(目標60%)です。
公式には「メモリ使用率に基づくスケールはサポートしない」と明記されています。
参考:
https://docs.cloud.google.com/run/docs/about-instance-autoscaling
つまりメモリが膨らんでもインスタンスは増えません。
CPUが低く、同時実行数も上限に達しない場合スケールアウトで負荷が分散されないままメモリだけが使われ続けることになります。
超過すると、インスタンスごと終了する
メモリ上限を超えたインスタンスは終了されます。
公式のトラブルシューティングでは、HTTP 500/503と次のメッセージが症状として挙げられています。
While handling this request, the container instance was found to be using too much memory
原因としては、メモリリーク、ローカルファイルシステムへの過度な書き込み、/var/log/以外へのログ出力が挙げられています。
対処はメモリ制限を上げるか、同時実行数を下げることです。
参考:
https://docs.cloud.google.com/run/docs/troubleshooting
ECSとの違い
ECS(Fargate)も、タスク全体でメモリを共有します。
違いはスケール指標にメモリを選べることです(ECSServiceAverageMemoryUtilizationなど)。
Cloud Runは選べないため、メモリの余裕は同時実行数と割当で先に設計する必要があります。
5. APIごとの試験でOOMに気付けない理由と、気付くためのシナリオ
APIごとの試験でOOMに気付けない理由
冒頭で書いたとおり、私はAPIごとに「秒間5回、10回」「同時に5並列、10並列」と流して問題なしと見ていました。
どのAPIも単体では問題がなかったのでメモリも大丈夫だと思っていたためです。
しかし、本番のコンテナでは1つのインスタンスの中で複数のAPIが同時に動きます。
API単体の結果だけを見てもそのコンテナ全体のメモリ状況は分かりません。
Lambda流に「APIごとに1本ずつ負荷を掛ける」と、次の理由でメモリの問題が見えにくくなります。
| 理由 | 何が起きるか |
|---|---|
| メモリが他のAPIと混ざらない | 1本ずつ流すと、そのインスタンスには1種類のAPIしか入らない。実運用の「重いAPIと軽いAPIが同居する状態」にならない |
| スケールアウトが逃がしてしまう | CPU指標で増えたインスタンスに負荷が分散し、1インスタンスあたりのメモリが下がる。一方で、CPUが上がりにくいAPIは1インスタンスに集中して詰まる |
さらに、メモリは平均で見ると安全に見えることが多く、問題は「最も重いAPIが、最も詰まったインスタンスで同時に走ったとき」に起きます。
Lambdaでは、APIごとに独立していたからこそ、APIごとの試験で足りていました。
コンテナではその前提が崩れます。
気付くためのシナリオ
OOMに気付くには、次のシナリオを足す必要がありそうです。
① APIの混在シナリオを入れる
実運用に近い比率で、全APIのリクエストを同時に流します。
API別の試験は「性能の把握」に、混在シナリオは「メモリとスケールの確認」に使い分けます。
②最も重いAPIを、同時実行数の上限まで同一インスタンスに入れる
式のとおり、ワースト条件は「最も重いAPIが同時実行数の上限分1インスタンスに入る」ことです。
負荷の同時接続数(VU数)を同時実行数の設定値より大きくして、上限まで詰め込まれる状況を作ります。
VU数が設定値より小さいと、上限まで詰め込まれる前にシナリオが終わってしまいます。
③長時間(ソーク)を1本入れる
リークやキャッシュの蓄積を見るために、一定の負荷で長めに流します。
起動後の経過時間で、メモリがどう推移するかを確認します。
④一時ファイルや大きなレスポンスを使う処理を、同時実行数を上げて流す
インメモリFSは、書いた分だけメモリを消費します。
ファイル生成・アップロード・動画/PDF変換などは、別のシナリオとして同時実行数を上げて流します。
⑤負荷はゆるやかに上げ、各段を一定時間保持する
Cloud Runの同時実行数ベースのスケールは、過去1分と10分の平均の最大値を使います。
短い段ではスケールが追い付く前の過渡状態を測ってしまいます。
min instances 0の環境では、起動待ちの滞留も加わります。
観測するもの
観測する指標もLambdaとコンテナでは変わります。
| 見るもの | Lambda | Cloud Run |
|---|---|---|
| メモリ | 呼び出しごとのMax Memory Used(REPORTログ) |
Cloud MonitoringのContainer memory utilization。平均ではなく最大側で見る |
| 並行度 | 同時実行数 | Maximum concurrent requests、Container instance count |
| CPU | 関数の実行時間 | Container CPU utilization |
| エラー | 呼び出しの失敗 | HTTP 500/503と、メモリ超過のログメッセージ |
参考:
https://docs.cloud.google.com/run/docs/monitoring
OOMの判定はAPIごとのエラー率ではなく、インスタンス数の急変・500/503・メモリ使用率の最大値で行うのがよさそうです。
インスタンスごと終了すると、同じインスタンスで処理中だった別のAPIのリクエストも失敗するため、APIごとの集計では原因と無関係なAPIのエラーとして散らばってしまいます。
見積もりの例(仮の数値)
次のAPIを、同じCloud Runサービスにデプロイした場合で考えてみます。
| 1リクエストあたりのメモリ | |
|---|---|
| 軽いAPI(一覧取得など) | 20 MiB |
| 重いAPI(帳票生成など) | 200 MiB |
| 常時メモリ(ランタイム、キャッシュ) | 300 MiB |
メモリ2 GiB・同時実行数80の場合です。
- 軽いAPIだけで試験: 300 + 20 × 80 = 1,900 MiB(収まる)
- 重いAPIが同時に混ざった場合: 300 + 200 × 80 = 16,300 MiB(2 GiBを大きく超える)
軽いAPIだけの試験では問題が出ません。
重いAPIが同時に詰まる条件で初めてOOMになります。
対策は
- メモリを上げる
- 同時実行数を下げる
-
重いAPIを別サービスに分けて同時実行数を小さくする
のいずれかです。
6. 負荷試験のチェックリスト
実際に使うなら、チェックリストは長すぎると続きません。
そのため、試験前にさっと見る版と、設計時にじっくり見る版に分けてみました。
試験前5分版
- [ ] API別の試験とは別に、実運用の比率で全APIを混在させたシナリオがあるか
- [ ] 最も重いAPIを、同時実行数の上限まで詰め込むシナリオがあるか
- [ ] VU数は、同時実行数の設定値より大きいか
- [ ] 長時間(ソーク)の試験があるか
- [ ] メモリ使用率を、平均ではなく最大側で見ているか
- [ ] OOMをインスタンス数の急変・500/503・メモリ超過ログで判定できるか
じっくり版
## シナリオ設計
- [ ] 全APIを実運用の比率で混在させたシナリオがある
- [ ] 最も重いAPIを、同時実行数の上限まで同一インスタンスに入れるシナリオがある
- [ ] VU数が、同時実行数の設定値より大きい
- [ ] 長時間(ソーク)の試験で、メモリの推移を見ている
- [ ] 一時ファイル・大きなレスポンスを扱う処理を、同時実行数を上げて試験している
- [ ] 各段の保持時間が、スケールの判定窓(1〜10分)より短すぎない
## 設計
- [ ] 常時メモリ + 最も重いAPIのメモリ × 同時実行数が、割当に収まる
- [ ] メモリを食う処理を、別サービスに分けて同時実行数を小さくしている
- [ ] Cloud Runはメモリでスケールしない前提で、余裕を設計している
- [ ] 最大インスタンス数 × プールサイズが、DBの接続上限に収まる
- [ ] グローバルで初期化したオブジェクトは、並行実行に耐える
## 観測
- [ ] メモリ使用率を、最大側で見ている
- [ ] OOMを、インスタンス数の急変・500/503・メモリ超過ログで判定している
## Lambda側で忘れがちな点
- [ ] DynamoDBオンデマンドは「前回ピークの2倍まで」。ランプに30分以上かける
- [ ] SnapStartはNode.js非対応で、Provisioned Concurrencyと併用できない。初期化時の乱数・接続を作り直す
- [ ] RDSはRDS Proxy、またはリザーブド同時実行で下流を守る
7. まとめ
Lambdaは「環境を増やす」ことで関数ごとにメモリが独立します。
だからAPIごとの負荷試験で足りていました。
一方でコンテナは、「同じインスタンスにどれだけ詰めるか」でスケールを設計します。
メモリは全APIで共有され、Cloud Runではオートスケールの対象にもなりません。
そのため、APIごとの試験に加えて全APIの混在・同時実行数の上限・長時間のシナリオを足して初めてOOMに気付けます。
基盤が変われば、負荷試験で「最初に崩れる場所」も変わります。
Lambdaの感覚をそのまま持ち込まず、基盤の特性に合わせてシナリオと観測する指標を選ぶことが適切な負荷試験を実施する上で大切なこととなります。