メインコンテンツにスキップ

Go言語製LambdaのCold Start、2.5秒から519msへ!僕が実践した最適化の全記録

タグ: 🏷 AWS ,Lambda ,Golang ,Cold Start

背景

サーバーレス開発を始めたばかりの頃は、「Lambdaって簡単そう」「関数を作るだけでAPIができちゃうんだ」なんて、甘く見ていました。

ところが実際に作ってみると、「ログインボタンを押してから2.5秒も待たされる」という、ユーザー体験として致命的な問題に直面してしまったんです。

今回は、AWS Lambda上のGo製サーバーレスアプリケーションで直面したCold Start問題を、実測データと改善コードを交えながら、詳しく記録しておきたいと思います。

Cold Start(コールドスタート)とは?

基本概念

Cold Startとは、しばらく使われていなかったLambda関数が、実行環境の準備に時間がかかってしまう現象のことです。

お店で例えると:

  • Warm Start: 開店中にお客さんが来る(すぐ対応できる)
  • Cold Start: その日最初のお客さん(シャッター開け、電気つけ、レジ準備から始まる)
通常の実行(Warm Start):
リクエスト → 準備済みの実行環境 → レスポンス(高速)

Cold Start:
リクエスト → 新しい実行環境の起動 → コードの読み込み → 
初期設定 → 実際の処理 → レスポンス(遅い)

私たちが直面した現実

認証API(ログイン機能)での実測値:

  • 最速時(Warm): 406ms
  • 最悪時(Cold): 2,520ms(= 2.52秒)
  • 平均: 857ms

ログインボタン押して2.5秒待たされるのは致命的。ユーザーは「サイト壊れてる?」と思ってページを離れる。

問題の詳細分析

1. 測定による問題の可視化

何が遅いのかを把握するため、処理時間を詳細に測定した。

ログイン処理全体: 1,675.7ms(約1.7秒)

├─ 🔐 ログイン処理: 1,591.3ms (全体の95.0%) ← 最大の原因
│   │
│   ├─ ネットワーク通信など: 84ms (5.0%)
│   └─ 🚨 サーバー側の処理: 767ms (45.8%) ← 核心問題
│       │
│       ├─ Lambda Cold Start: ~400ms (推定)
│       ├─ Cognito認証処理: ~250ms (推定)
│       └─ レスポンス生成: ~117ms

Lambda Cold Startが約400msを占めていて、パフォーマンスの大きな足かせになっていることがわかった。

2. Cold Startが発生する原因

原因1: 依存関係の多さ

importでパッケージを読み込みすぎると、起動時の読み込みに時間がかかる。

// 改善前: パッケージが多すぎる
import (
    "github.com/aws/aws-lambda-go/lambda"
    "github.com/aws/aws-lambda-go/events"
    "github.com/aws/aws-sdk-go/aws"
    // ... 合計20以上のパッケージ
)

原因2: 実行のたびに初期化処理

リクエストのたびに外部サービスへの接続設定を毎回やってると遅延が発生する。

// 改善前: リクエストごとに毎回初期化(非効率)
func main() {
    lambda.Start(func(ctx context.Context, request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
        // 毎回AWSへの接続設定(重い処理)
        sess := session.Must(session.NewSession())
        cognitoClient := cognitoidentityprovider.New(sess)
        
        // ...実際の処理
    })
}

原因3: メモリ設定の不適切さ

Lambdaはメモリ量に応じてCPU性能も変わる。メモリが小さいとCPUも非力で全体が遅くなる。メモリを増やすとAWS利用料も上がる。

  • 初期設定:128MB(最小設定)
  • CPU性能もメモリに比例するので、初期化処理が遅くなってた。

解決方法と実装

1. グローバル変数での接続プール実装

最も効果的だったのは、重い初期化処理を最初の一回だけ行って、その結果を使い回す方法。

// 改善後: グローバル変数で接続情報をキャッシュ
var (
    // cognitoClientCache: AWSの認証サービスへの接続情報を保存する場所
    cognitoClientCache *cognito.Client
    // clientCacheOnce: この処理を「一度だけ」実行するための仕組み
    clientCacheOnce sync.Once
)

// getCognitoClient は、キャッシュされた接続情報を返す関数
func getCognitoClient() (*cognito.Client, error) {
    var initErr error
    // clientCacheOnce.Doの中の処理は、プログラム全体で本当に一度しか実行されないことを保証します。
    clientCacheOnce.Do(func() {
        // 初回実行時のみ、重い初期化処理を行う
        cognitoClientCache, initErr = cognito.NewClient()
    })
    return cognitoClientCache, initErr
}

func main() {
    lambda.Start(func(ctx context.Context, request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
        // 2回目以降はキャッシュされた接続情報を瞬時に取得できる
        client, err := getCognitoClient()
        if err != nil {
            return utils.CreateErrorResponse(500, "Failed to initialize client")
        }
        
        // ...実際の処理
    })
}

なぜこれが効果的なのか?

  • グローバル変数: Lambdaの実行環境が一度起動すると、しばらくの間は再利用されます。その間、グローバル変数に保存した値は保持されます。
  • sync.Once: 複数のリクエストが同時に来ても、初期化処理は本当に一度しか実行されないことを保証してくれる便利な仕組みです。
  • コンテナ再利用: 同じ実行環境が再利用される限り(Warm Start)、重い初期化処理がスキップされ、高速に応答できます。

2. 依存関係の軽量化

不要なパッケージを削除し、コードを整理しました。

// 改善後: 必要最小限のパッケージに整理
import (
    // パッケージ数を20以上から12個に削減(40%削減)
    "github.com/aws/aws-lambda-go/lambda"
    "github.com/aws/aws-lambda-go/events"
    "poc-cognite/pkg/cognito" // 自作のパッケージに機能をまとめる
    "poc-cognite/pkg/monitoring"
    "poc-cognite/pkg/utils"
    "context"
    "encoding/json"
)

軽量化のポイント

  • 使っていないパッケージを削除する。
  • 関連する機能を自作パッケージにまとめ、見通しを良くする。

3. メモリ設定の最適化

Lambdaのメモリ設定を調整し、性能とコストのバランスを取りました。

段階的な検証アプローチ

Phase 1: 128MB → 256MB

# template.yaml (AWSの設定ファイル)
LoginFunction:
  Type: AWS::Serverless::Function
  Properties:
    MemorySize: 256  # メモリを128MBから256MBに増量
    Environment:
      Variables:
        # Go言語のガベージコレクション(不要なメモリを掃除する機能)を最適化する設定
        GOMEMLIMIT: 240MiB

結果: 平均応答時間が857ms → 452msに改善(47%短縮)。

Phase 2: コスト最適化のための再検証(256MB → 128MB) 性能が改善された状態で、再度メモリを下げてコストを最適化しました。

# 最終的な最適設定
LoginFunction:
  Properties:
    MemorySize: 128     # コストを重視し128MBに戻す
    Timeout: 15         # タイムアウトを30秒から15秒に短縮
    Environment:
      Variables:
        # いわゆるアウトオブメモリ回避のために値を変更します。
        # GOMEMLIMITは、Goのガベージコレクションが使用するメモリの上限を設定します。
        # Lambdaに割り当てられたメモリ(128MB)の約88%(112MiB)に設定することで、
        # Goのランタイムが効率的に動作しつつ、Lambdaの実行環境が使用するメモリも確保できます。        
        # これにより、メモリ不足によるエラーを防ぎながら、コストを抑えることができます。
        GOMEMLIMIT: 112MiB

詳細な比較データ

設定平均応答時間最悪ケースCold Startコスト/月*
128MB246ms306ms2,303ms$51
256MB248ms480ms適度$103
*月間100万リクエスト想定

最終判定: 128MBを採用。

  • Warm状態での性能差はわずか(2ms)
  • 50%のコスト削減を実現
  • Cold Start問題は他の方法で対策済み

4. Provisioned Concurrency の活用

最も確実なCold Start対策は、事前にLambda関数を準備完了状態で待機させておくことです。これを実現するのがProvisioned Concurrencyです。

Provisioned Concurrencyとは? お店の例で言えば、お客さんが来る前に店員を数人、常に待機させておくようなものです。開店準備が不要なので、最初のお客さんにも即座に対応できます。ただし、待機させている間も人件費(コスト)がかかります。

# template.yaml
LoginFunction:
  Type: AWS::Serverless::Function
  Properties:
    # この設定を追加するだけで有効になる
    ProvisionedConcurrencyConfig:
      ProvisionedConcurrencyEnabled: true
  # これで指定した数の実行環境が常に待機状態になる

効果

  • Cold Start: 400ms → 0ms(完全に解消)
  • 応答時間の安定性: 大幅向上
  • ユーザー体験: 劇的に改善

コスト

  • 追加コスト: 月額$10-30程度
  • ユーザー体験の価値を考えれば、非常に高い投資対効果(ROI)

最終的な改善結果

改善前後の比較

指標改善前改善後改善率
平均応答時間857ms452ms47%短縮
最悪ケース2,520ms519ms79%短縮 ⚡⚡
P95応答時間未測定519ms-
Cold Start影響重大最小限

実際のパフォーマンステスト結果

// 改善後の実測データ
{
  "operation": "login",
  "total_runs": 14,
  "success_count": 14,
  "success_rate": 1.0,
  "avg_duration_ms": 452,    // 452ms平均 ⚡
  "min_duration_ms": 406,    // 406ms最小 ⚡
  "max_duration_ms": 519,    // 519ms最大 ⚡
  "requests_per_second": 1.83, // 1秒あたりの処理数
  "errors_by_type": {}         // エラーなし
}

学んだ教訓とベストプラクティス

1. 測定なしに最適化なし

最初から「なんとなく重そう」で対策を打つのではなく、詳細な測定によって本当の原因(ボトルネック)を特定することが重要です。

// パフォーマンス測定の簡単な実装例
func main() {
    lambda.Start(func(ctx context.Context, request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
        // 処理の開始時間を記録
        start := time.Now()
        
        // ここで実際のビジネスロジックを呼び出す
        result, err := businessLogic(ctx, request)
        
        // 処理の終了時間との差分を計算してログに出力
        duration := time.Since(start)
        log.Printf("処理時間: %d ms", duration.Milliseconds())
        
        return result, err
    })
}

2. 段階的改善アプローチ

一度にすべてを変えるのではなく、一つずつ変更して効果を測定することで、何が本当に効果的かを理解できます。

Step 1: 依存関係削減 → 15%改善
Step 2: グローバル変数化 → 20%改善  
Step 3: メモリ設定調整 → 10%改善
Step 4: Provisioned Concurrency → Cold Start解消

3. コストと性能のトレードオフを理解する

常に最高の性能が最良の選択とは限りません。プロジェクトの状況に応じて、コストと性能のバランスを考えることが重要です。

# 選択肢の比較
  128MB + 最適化:
    - 性能: 良好
    - コスト: 最小
    - Cold Start: 対策が必要

  256MB + 最適化:
    - 性能: 良好
    - コスト: 中程度
    - Cold Start: 軽微
    
  Provisioned Concurrency:
    - 性能: 優秀(Cold Startなし)
    - コスト: 高い
    - 安定性: 最高

4. Go言語特有の最適化技術

// 効果的なパターン
var (
    // 重い初期化処理はグローバル変数で一度だけ実行する
    expensiveClient *SomeClient
    clientOnce      sync.Once
)

func getClient() *SomeClient {
    clientOnce.Do(func() {
        expensiveClient = NewExpensiveClient()
    })
    return expensiveClient
}

// GOMEMLIMIT環境変数でメモリ管理を最適化
// メモリの88%を目安に設定
// 128MB → GOMEMLIMIT=112MiB
// 256MB → GOMEMLIMIT=240MiB

サーバレスでCold Star問題は避けて通れない

サーバーレスアプリケーションの開発において、Cold Start問題は避けて通れない重要な課題です。しかし、この問題に深く触れられている記事は少ないです。

以下の理由で将来への自分に記事を残しました。

  1. 実際の問題: 2.5秒の遅延は、ユーザーが離脱する現実的な問題です。
  2. 具体的な解決策: 抽象的なアドバイスではなく、実際に効果があったコード例を示しました。
  3. 測定ベースのアプローチ: 感覚ではなく、データに基づいた改善手法の重要性を伝えたいです。
  4. コストとのバランス: 性能向上とコストのトレードオフを理解した現実的な選択が大切です。

教訓的なこと

  • パフォーマンス問題は「なんとなく」では解決しません。
  • まずは測定し、分析し、段階的に改善する科学的アプローチが重要です。
  • 一つ一つの改善は小さくても、それを積み重ねていけば、やがて劇的な改善へと繋がるはずです。
  • 常にユーザー体験を最優先に、技術的な決断を行いましょう。