0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Spring Boot on Lambda を実質常時稼働させた話 — warmup 10並列でコールドスタートを潰す

0
Last updated at Posted at 2026-05-23

はじめに

前回の記事「個人開発サービスを月$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% 削減 = 無料枠ギリギリで運用してる」 ということは意識しておく必要があります。

将来ユーザーが増えたら:

  1. メモリを 3008 → 2048 MB に下げる(起動時間とトレードオフ)
  2. warmup 並列数を抑える(cold start 増加とトレードオフ)
  3. Provisioned Concurrency を併用(有料だが安定)

のどれかを検討予定です。


まとめ

対策 効果 コスト
メモリ 3008 MB 起動時間短縮 GB秒は実行時間で相殺
AWS_LWA_ASYNC_INIT=true INIT timeout 回避 無料
不要 @Bean 排除 起動時間短縮 無料
warmup 10並列 実質常時稼働 無料枠の 95%
Loading UI 改善 万が一の離脱防止 無料

Spring Boot on Lambda でも、warmup の積み重ねで実質常時稼働は実現可能。
ただし「無料枠ギリギリ運用」の自覚を持ちながら設計する必要があります。


関連記事

参考になったら LGTM・ストックお願いします 🙏


サービス: Pitcher-vs-Batter

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?