AWS Lambda API Gateway serverless cold start timeout

AWS Lambda - zimny start powoduje 504 Gateway Timeout na API Gateway

Naprawa 504 Timeout na API Gateway spowodowanego przez zimny start Lambda: diagnoza VPC ENI, optymalizacja pakietu, provisioned concurrency, SnapStart dla Java.

Jerzy Kopaczewski ·
API Gateway (REST API lub HTTP API) sporadycznie zwraca klientom 504 Gateway Timeout. Problem występuje nieregularnie - głównie po okresach bezczynności (rano, po weekendzie, po deploy). Funkcja Lambda odpowiada poprawnie przy kolejnych wywołaniach, ale pierwsze żądanie po okresie braku ruchu trwa 10-30 sekund i przekracza limit integracji API Gateway (29s dla REST API, 30s dla HTTP API).

Ten runbook opisuje diagnozę i eliminację problemu zimnego startu Lambda powodującego timeouty na API Gateway. Koszty serverless i optymalizację Lambda omawiamy w artykule Serverless bez finansowych niespodzianek - Lambda pricing. Jeśli potrzebujesz pomocy z architekturą serverless - umów konsultacje.

Objaw

API Gateway zwraca HTTP 504 sporadycznie, korelując z pierwszymi żądaniami po okresie bezczynności:

# Sprawdź metryki 5xx na API Gateway
aws cloudwatch get-metric-statistics \
  --namespace AWS/ApiGateway \
  --metric-name 5XXError \
  --dimensions Name=ApiName,Value=my-api \
  --start-time $(date -u -d '24 hours ago' +%Y-%m-%dT%H:%M:%S) \
  --end-time $(date -u +%Y-%m-%dT%H:%M:%S) \
  --period 3600 \
  --statistics Sum

# Sprawdź IntegrationLatency - kluczowa metryka (ile czeka na Lambda)
aws cloudwatch get-metric-statistics \
  --namespace AWS/ApiGateway \
  --metric-name IntegrationLatency \
  --dimensions Name=ApiName,Value=my-api \
  --start-time $(date -u -d '1 hour ago' +%Y-%m-%dT%H:%M:%S) \
  --end-time $(date -u +%Y-%m-%dT%H:%M:%S) \
  --period 60 \
  --statistics Maximum
# Cold start: Maximum > 10000ms (10s+)
# Warm: Maximum < 500ms

# Lambda Duration vs Init Duration
aws cloudwatch get-metric-statistics \
  --namespace AWS/Lambda \
  --metric-name Duration \
  --dimensions Name=FunctionName,Value=my-api-function \
  --start-time $(date -u -d '1 hour ago' +%Y-%m-%dT%H:%M:%S) \
  --end-time $(date -u +%Y-%m-%dT%H:%M:%S) \
  --period 60 \
  --statistics Maximum
# Max > 29000 = przekracza timeout API Gateway

# Szukaj Init Duration w CloudWatch Logs (cold start marker)
aws logs filter-log-events \
  --log-group-name /aws/lambda/my-api-function \
  --start-time $(date -u -d '24 hours ago' +%s)000 \
  --filter-pattern "Init Duration" \
  --max-items 20 \
  --query 'events[].message'
# Format: "REPORT ... Init Duration: 8523.45 ms ..."
# Init Duration > 10000ms = VPC cold start lub duży package

Charakterystyka zimnego startu vs rozgrzana instancja:

Metryka Cold start Warm invocation
Init Duration 3-30s (zależnie od runtime + VPC) 0 (brak)
Duration (total) Init + execution time Tylko execution time
Billed Duration Init + execution Execution
API Gateway IntegrationLatency Init + execution > 29s = 504 < 500ms (typowo)

Przyczyna

Zimny start Lambda to czas inicjalizacji nowej instancji środowiska wykonawczego. Składa się z:

1. Pobranie i dekompresja pakietu wdrożeniowego:

  • Mały package (5 MB): ~200ms
  • Duży package (50 MB, np. Python z numpy/pandas): ~2-5s
  • Package z Lambda Layer: dodatkowy czas per layer

2. Runtime initialization (interpreter/JVM start):

  • Python: ~200-500ms
  • Node.js: ~100-300ms
  • Java (zimny start JVM): ~3-8s
  • .NET: ~1-3s

3. VPC ENI attachment (jeśli Lambda jest w VPC):

  • Hyperplane ENI: ~1-2s (po 2019 improvement)
  • Stare konta/konfiguracje: do 10-15s
  • Cross-AZ: dodatkowe ~500ms

4. Handler initialization code (import modules, DB connections):

  • Import heavy libraries (PyTorch, TensorFlow): 5-15s
  • Nawiązanie połączenia DB/Redis: 1-3s
  • Download secrets z Secrets Manager: 0.5-1s

Suma: VPC Lambda z dużym pakietem w Java może mieć zimny start 15-30s → przekroczenie 29s timeout API Gateway.

5. Kiedy zimny start się pojawia:

  • Pierwszy request po deploy (wszystkie instancje zimne)
  • Po ~5-15 min bezczynności (AWS recykluje instancję)
  • Przy nagłym spike’u ruchu (nowe instancje startują równolegle)
  • Po skalowaniu w dół do 0

Rozwiązanie

A) Provisioned Concurrency - eliminacja zimnego startu:

# Włącz provisioned concurrency na opublikowanej wersji/alias
# 1. Opublikuj wersję
VERSION=$(aws lambda publish-version \
  --function-name my-api-function \
  --query 'Version' --output text)

# 2. Utwórz/aktualizuj alias 'live' wskazujący na wersję
aws lambda update-alias \
  --function-name my-api-function \
  --name live \
  --function-version $VERSION

# 3. Ustaw provisioned concurrency na alias
aws lambda put-provisioned-concurrency-config \
  --function-name my-api-function \
  --qualifier live \
  --provisioned-concurrent-executions 5

# 4. Sprawdź status (musi być "Ready")
aws lambda get-provisioned-concurrency-config \
  --function-name my-api-function \
  --qualifier live \
  --query '{status:Status, requested:RequestedProvisionedConcurrentExecutions, available:AvailableProvisionedConcurrentExecutions}'

# 5. Skieruj API Gateway na alias (nie $LATEST!)
# W API Gateway integration URI zmień na:
# arn:aws:lambda:eu-west-1:123456789012:function:my-api-function:live

Autoscaling provisioned concurrency (dla zmiennego ruchu):

# Zarejestruj target do Application Auto Scaling
aws application-autoscaling register-scalable-target \
  --service-namespace lambda \
  --resource-id function:my-api-function:live \
  --scalable-dimension lambda:function:ProvisionedConcurrency \
  --min-capacity 2 \
  --max-capacity 50

# Target tracking - utrzymuj utilization ~70%
aws application-autoscaling put-scaling-policy \
  --service-namespace lambda \
  --resource-id function:my-api-function:live \
  --scalable-dimension lambda:function:ProvisionedConcurrency \
  --policy-name target-tracking-70pct \
  --policy-type TargetTrackingScaling \
  --target-tracking-scaling-policy-configuration '{
    "TargetValue": 0.7,
    "PredefinedMetricSpecification": {
      "PredefinedMetricType": "LambdaProvisionedConcurrencyUtilization"
    },
    "ScaleInCooldown": 300,
    "ScaleOutCooldown": 60
  }'

B) Optymalizacja pakietu wdrożeniowego (redukuj czas inicjalizacji):

# Sprawdź aktualny rozmiar package
aws lambda get-function \
  --function-name my-api-function \
  --query 'Configuration.{CodeSize:CodeSize, Layers:Layers[].LayerArn, MemorySize:MemorySize, Runtime:Runtime}'

# Python - użyj Lambda Powertools Layer zamiast bundlowania
# Node.js - tree-shaking z esbuild
# Java - użyj GraalVM native-image lub SnapStart
# Dockerfile - multi-stage build dla minimalnego image (container Lambda)
FROM public.ecr.aws/lambda/python:3.12 as builder

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt -t /opt/python/

# Usuń niepotrzebne pliki (testy, docs, cache)
RUN find /opt/python -name "*.pyc" -delete && \
    find /opt/python -name "__pycache__" -type d -exec rm -rf {} + && \
    find /opt/python -name "tests" -type d -exec rm -rf {} + && \
    find /opt/python -name "*.dist-info" -type d -exec rm -rf {} +

FROM public.ecr.aws/lambda/python:3.12
COPY --from=builder /opt/python /opt/python
ENV PYTHONPATH=/opt/python
COPY app/ ${LAMBDA_TASK_ROOT}/
CMD ["app.handler.handler"]
# handler.py - lazy imports (importuj ciężkie biblioteki w handlerze, nie globalnie)
import json
import os

# ✅ Lekkie importy globalne
import boto3

# ❌ NIE importuj globalnie ciężkich bibliotek
# import pandas as pd  # +3s do cold start
# import numpy as np   # +2s do cold start

def handler(event, context):
    # ✅ Lazy import - tylko gdy potrzebne
    if event["path"] == "/reports":
        import pandas as pd  # Importowane tylko dla tego endpointu
        # ...

    return {
        "statusCode": 200,
        "body": json.dumps({"status": "ok"})
    }

C) SnapStart (Java) - eliminacja zimnego startu JVM:

# SAM template - włączenie SnapStart dla Java Lambda
Resources:
  MyApiFunction:
    Type: AWS::Serverless::Function
    Properties:
      Runtime: java21
      Handler: com.example.Handler::handleRequest
      MemorySize: 1024
      SnapStart:
        ApplyOn: PublishedVersions  # Kluczowe!
      AutoPublishAlias: live
      Events:
        Api:
          Type: HttpApi
          Properties:
            Path: /{proxy+}
            Method: ANY

D) Warmer (tanie rozwiązanie dla niskiego ruchu):

# CloudWatch EventBridge rule - "ping" Lambda co 5 minut
Resources:
  WarmingRule:
    Type: AWS::Events::Rule
    Properties:
      ScheduleExpression: rate(5 minutes)
      State: ENABLED
      Targets:
        - Id: keep-warm
          Arn: !GetAtt MyApiFunction.Arn
          Input: '{"httpMethod":"GET","path":"/warmup","isWarmer":true}'

  WarmingPermission:
    Type: AWS::Lambda::Permission
    Properties:
      FunctionName: !Ref MyApiFunction
      Action: lambda:InvokeFunction
      Principal: events.amazonaws.com
      SourceArn: !GetAtt WarmingRule.Arn
# W handlerze - wykryj warming request i odpowiedz natychmiast
def handler(event, context):
    if event.get("isWarmer"):
        return {"statusCode": 200, "body": "warm"}
    # ... normalna logika

E) Zwiększ pamięć (więcej CPU = szybsza inicjalizacja):

# Lambda memory = proporcjonalnie więcej CPU
# 128 MB = 1/10 vCPU → wolna inicjalizacja
# 1024 MB = ~0.6 vCPU → szybka inicjalizacja
# 1769 MB = 1 vCPU → optymalna dla compute-heavy init

# Testuj różne wartości memory z Lambda Power Tuning:
aws lambda update-function-configuration \
  --function-name my-api-function \
  --memory-size 1024

# Koszt: 1024MB Lambda kosztuje 8x więcej per ms niż 128MB
# ALE: init time spada 3-5x, więc łączny koszt może być NIŻSZY

Walidacja

# 1. Sprawdź Init Duration po optymalizacji
aws logs filter-log-events \
  --log-group-name /aws/lambda/my-api-function \
  --start-time $(date -u -d '1 hour ago' +%s)000 \
  --filter-pattern "Init Duration" \
  --max-items 5 \
  --query 'events[].message'
# Expected: Init Duration < 5000ms (przed optymalizacją było 10-30s)
# Jeśli provisioned concurrency: BRAK Init Duration w logach = sukces

# 2. Test cold start z wymuszeniem (deploy nowej wersji)
aws lambda update-function-configuration \
  --function-name my-api-function \
  --environment Variables={FORCE_COLD=$(date +%s)}
sleep 5
time curl -s https://my-api.execute-api.eu-west-1.amazonaws.com/prod/health
# Expected: response time < 5s (przed fix: 15-30s lub timeout)

# 3. Sprawdź metryki API Gateway - brak 504
aws cloudwatch get-metric-statistics \
  --namespace AWS/ApiGateway \
  --metric-name 5XXError \
  --dimensions Name=ApiName,Value=my-api \
  --start-time $(date -u -d '6 hours ago' +%Y-%m-%dT%H:%M:%S) \
  --end-time $(date -u +%Y-%m-%dT%H:%M:%S) \
  --period 3600 \
  --statistics Sum
# Expected: Sum = 0 we wszystkich okresach

# 4. Provisioned concurrency status
aws lambda get-provisioned-concurrency-config \
  --function-name my-api-function \
  --qualifier live
# Expected: Status = "Ready", Available = Requested

# 5. Symuluj spike ruchu (k6 / artillery)
# Sprawdź czy autoscaling provisioned concurrency skaluje w górę
aws cloudwatch get-metric-statistics \
  --namespace AWS/Lambda \
  --metric-name ProvisionedConcurrencySpilloverInvocations \
  --dimensions Name=FunctionName,Value=my-api-function \
  --start-time $(date -u -d '1 hour ago' +%Y-%m-%dT%H:%M:%S) \
  --end-time $(date -u +%Y-%m-%dT%H:%M:%S) \
  --period 60 \
  --statistics Sum
# Expected: 0 (spillover = zapytania trafiające na cold instances)
Provisioned Concurrency eliminuje zimny start całkowicie, ale kosztuje ~$0.015/GB-h nawet bez ruchu. Dla Lambda 1024MB z 5 provisioned instances to ~$55/miesiąc. Porównaj z kosztem utraty klientów przez sporadyczne 504. Dla niskobudżetowych API: warmer (EventBridge co 5 min) + optymalizacja pakietu daje 80% efektu za $0. Dla produkcyjnych API obsługujących klientów: provisioned concurrency jest jedynym rozwiązaniem gwarantującym brak zimnych startów.

 

Jerzy Kopaczewski

Zimny start Lambda powoduje timeouty?

Umów bezpłatną 30-minutową rozmowę. Zoptymalizujemy Twoją architekturę serverless - od rozmiaru package po provisioned concurrency - aby wyeliminować 504 i obniżyć koszty.