はじめに
前回の記事「個人開発サービスを月$30→$1.5にした話」 の続編です。
Heroku → AWS Lambda にリプレイスしてコストは 95% 削減できました。が、Spring Boot を Lambda に載せた直後、最大の敵が立ちはだかりました。
コールドスタート。
本記事では、Spring Boot on Lambda のコールドスタート問題を実用ラインに乗せるためにやった以下を紹介します:
- メモリ増強(1024 → 3008 MB)
- Lambda Web Adapter の
AWS_LWA_ASYNC_INIT - 不要な
@Beanの排除 - warmup の 10並列化(メインディッシュ)
最終的に 「実質常時稼働」状態 にできて、ユーザー体験はほぼ Heroku 時代と遜色なし。ただし 無料枠ギリギリ という落とし穴もあるので、最後にコスト面の警告も。
TL;DR
| 対策 | 効果 |
|---|---|
| メモリ 1024 → 3008 MB | 起動 38s → 25s |
AWS_LWA_ASYNC_INIT=true |
Lambda のリクエスト受付開始タイミング前倒し |
不要な @Bean の排除 |
起動時間のさらなる短縮 |
| EventBridge Scheduler × 10並列 warmup | 実質常時稼働(10 コンテナ warm維持) |
| ローディング UI 改善 | 万が一の cold 時もユーザー離脱を防ぐ |
問題:Spring Boot on Lambda の起動は遅すぎる
1. 起動時間 25〜40秒の壁
Lambda のコールドスタートはランタイムごとに別物。Spring Boot は DI コンテナ初期化 + JVM JIT + コンテナイメージ pull で重く、本アプリ(zip ではなくコンテナイメージ + Spring Boot 3.2.5 + 多めの依存)の実測では 25〜40秒 かかりました。
| ランタイム | コールドスタート目安 | 備考 |
|---|---|---|
| Python / Node.js | 〜1秒 | 構成依存 |
| Go | 〜500ms | バイナリサイズ依存 |
| Java(軽量) | 2〜6秒 | 依存多いと伸びる |
| Spring Boot(本アプリ実測) | 25〜40秒 | 1024MB / コンテナイメージ / 依存多 |
※ 数値は構成(メモリ・デプロイ形式・依存関係・SnapStart 等)で大きくブレます。Spring Boot 行はあくまで 本アプリでの実測値 で、SnapStart / CDS / クラスロード最適化を入れている人だと 5〜15秒に短縮できるケースも多いです。
2. API Gateway HTTP API のタイムアウト 30秒固定
HTTP API のタイムアウトは 最大 30秒(変更不可)。Spring Boot の起動 40秒だと タイムアウトで 504 を食らいます。
これは詰み案件。
3. Lambda は「1コンテナ = 1リクエスト」制約
たとえ 1コンテナ warm でも、並列リクエスト が来ると 2 つ目以降は新コンテナ起動 → コールドスタートが発生します。
フロント(SPA)は初期表示時に 複数 API を並列で叩く ので、これがネック。
対策の積み重ね
対策 1:メモリ増強(1024 → 3008 MB)
Lambda は メモリに比例して CPU が割り当てられる 仕様。
Spring Boot の起動は CPU バウンドなので、メモリ増強で直接効きます。
| メモリ | 起動時間(実測) |
|---|---|
| 1024 MB | 38秒(タイムアウト直前) |
| 2048 MB | 28秒 |
| 3008 MB | 25秒 |
terraform/variables.tf:
variable "api_lambda_memory" {
description = "Memory size for API Lambda (MB)"
type = number
default = 3008
}
メモリ単価は上がりますが、実行時間が短くなるので GB秒(メモリ × 秒)の総量 で見ればそこまで増えません。
対策 2:Lambda Web Adapter の AWS_LWA_ASYNC_INIT
LWA(AWS Lambda Web Adapter)には 非同期初期化モード があります。
ENV AWS_LWA_ASYNC_INIT=true
これを有効にすると:
- LWA は Spring Boot の起動完了を待たずに Lambda の
INITフェーズを終了 - リクエスト到着までに起動が間に合えば、cold start として課金されない
- 起動中にリクエストが来ても、起動完了を待ってから処理
実質的に Lambda の「INIT phase 10秒タイムアウト」を回避 できます(INIT 中に終わらないと一度 timeout 扱いになる仕様の回避)。
対策 3:不要な @Bean / CommandLineRunner の排除
API Lambda に バッチ処理用の Bean が誤って紛れ込んでいました。
// 削除前: API Lambda 起動時にスクレイパーの初期化処理が動いていた
@Bean
public CommandLineRunner scraperInit(YahooScraperService service) {
// ...
}
これを Profile で分離(@Profile("scraper"))したところ、起動時間がさらに 3〜4秒短縮。
教訓:Lambda は処理単位で分離するなら、Bean も Profile で確実に分離する。
対策 4:warmup を 10並列に(メインディッシュ)
ここが本記事のキモ。
単純な「定期 ping」では足りない
「10分おきに warmup エンドポイントを叩けばいい」と最初は考えました。が、1コンテナしか warm にならない のでフロントの並列API(5系統同時)で 4 コンテナが cold start を起こします。
解決策:同時に 10並列で叩く
EventBridge Scheduler を 10個並べて、同じ cron 式で同時発火 させます。
warmup エンドポイントは 3秒スリープ して、AWS のスケジューラに「10並列のコンテナが必要」と認識させます。
Terraform 定義:
# API Lambda warmup schedules(コールドスタート対策)
# 10個のスケジュールを同時刻にcron発火させ、warmupエンドポイントが3秒sleepすることで
# 10並列コンテナをwarm維持する
resource "aws_scheduler_schedule" "api_warmup" {
count = 10
name = "baseball-api-warmup-${count.index + 1}"
group_name = "default"
flexible_time_window {
mode = "OFF"
}
schedule_expression = "cron(0/10 * * * ? *)"
target {
arn = aws_lambda_function.api.arn
role_arn = aws_iam_role.scheduler.arn
input = jsonencode({
requestContext = {
http = {
method = "GET"
path = "/baseball/api/warmup"
}
}
rawPath = "/baseball/api/warmup"
headers = {
"x-warmup" = "true"
}
})
}
}
Spring Boot 側の warmup エンドポイント:
/**
* Lambdaウォームアップ用エンドポイント
* EventBridgeから10並列で呼ばれる前提で、3秒スリープして並列コンテナを確保する
*/
@GetMapping("/warmup")
public ResponseEntity<String> warmup() {
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return ResponseEntity.ok("warm");
}
なぜ「2秒 → 3秒」に延長したか
EventBridge Scheduler は厳密な同時発火ではなく 数百ms のずれ(skew) があります。2秒だと最後の発火が他のコンテナの処理完了に被って 1コンテナで処理されてしまう事象が発生。3秒に延長 することで安定して 10コンテナ warm を維持できるようになりました。
なぜ 10並列?
最初は 5並列 で試してました。フロントが叩く API は 5系統(getInitData, getPitcherList, getBatterList, matchResultSearch, pitchDetail)なので 5 で足りるはずでした。
実際の運用で気付いたこと:
- ユーザーが「投手選択 → 打者選択 → 検索」を素早く操作すると 6系統同時 に
- 一部の警告ログで EventBridge skew のせいで 5コンテナを完全には維持しきれない
- 安全マージン込みで 10並列 に増やしたら、cold start ログが完全に消えた
トレードオフは 無料枠の消費(後述)。
ローディング UX の改善
それでも初回ユーザーが運悪く cold を引くケースはゼロにできません。離脱されないように UI で配慮:
<div v-if="isLoading" class="loading-overlay">
<div class="spinner"></div>
<p>読み込み中...</p>
<p v-if="showSlowLoadingMessage">
初回の読み込みには時間がかかることがあります(最大30秒程度)
</p>
</div>
watch: {
isLoading(newVal) {
if (newVal) {
this.slowLoadingTimer = setTimeout(() => {
this.showSlowLoadingMessage = true;
}, 5000);
} else {
clearTimeout(this.slowLoadingTimer);
this.showSlowLoadingMessage = false;
}
},
},
地味だけど cold 時の離脱率は大きく下がります。
⚠ コスト面の落とし穴:無料枠ギリギリで運用してます
ここが本記事で一番伝えたい注意点。
Lambda 無料枠
- 月 100万リクエスト
- 月 40万 GB秒(GB × 秒)
リクエスト数は warmup 含めても余裕で枠内ですが、GB秒 が要注意。
warmup だけで使う GB秒の試算
| 項目 | 値 |
|---|---|
| 発火回数 | 10並列 × 6回/h × 24h × 30d = 43,200 回/月 |
| Lambda メモリ | 3008 MB = 2.938 GB |
| 実行時間 | sleep 3秒 + α ≒ 3秒 |
| 1リクエスト当たり | 2.938 GB × 3秒 = 8.81 GB秒 |
| 合計 | 43,200 × 8.81 ≒ 380,000 GB秒/月 |
無料枠 40万 GB秒に対して 95% を warmup だけで消費している計算です。
有料化する条件
- warmup 並列数を増やす(11以上)
- warmup の sleep を延長(4秒以上)
- ユーザーの本物のアクセスが急増
「コスト 95% 削減 = 無料枠ギリギリで運用してる」 ということは意識しておく必要があります。
将来ユーザーが増えたら:
- メモリを 3008 → 2048 MB に下げる(起動時間とトレードオフ)
- warmup 並列数を抑える(cold start 増加とトレードオフ)
- Provisioned Concurrency を併用(有料だが安定)
のどれかを検討予定です。
まとめ
| 対策 | 効果 | コスト |
|---|---|---|
| メモリ 3008 MB | 起動時間短縮 | GB秒は実行時間で相殺 |
AWS_LWA_ASYNC_INIT=true |
INIT timeout 回避 | 無料 |
不要 @Bean 排除 |
起動時間短縮 | 無料 |
| warmup 10並列 | 実質常時稼働 | 無料枠の 95% |
| Loading UI 改善 | 万が一の離脱防止 | 無料 |
Spring Boot on Lambda でも、warmup の積み重ねで実質常時稼働は実現可能。
ただし「無料枠ギリギリ運用」の自覚を持ちながら設計する必要があります。
関連記事
- 個人開発サービスを月$30→$1.5にした話(Heroku→AWSコスト削減編)
- Vue.js SPA で SEO を本気でやった話 — noscript + JSON-LD + FAQPage でロングテール狙う
- 次回:MySQL → PostgreSQL 移行で詰まったポイントまとめ(予定)
参考になったら LGTM・ストックお願いします 🙏
サービス: Pitcher-vs-Batter