AWS ALB Lambda serverless 502

AWS ALB + Lambda - 502 Bad Gateway przez przekroczenie limitu payload lub timeout

Naprawa 502 Bad Gateway na ALB z Lambda target: przekroczenie limitu payload 1MB, timeout ALB 29s, malformed JSON response, Lambda cold start + ALB idle timeout.

Jerzy Kopaczewski ·
Application Load Balancer (ALB) zwraca klientom odpowiedź 502 Bad Gateway. Backend to Lambda function zarejestrowana jako target w ALB Target Group. Funkcja Lambda działa poprawnie przy bezpośrednim wywołaniu (invoke), ale przez ALB zwraca 502. Problem może być sporadyczny (tylko przy dużych payloadach) lub ciągły (po wdrożeniu nowej wersji).

Ten runbook opisuje specyficzne przyczyny błędu 502 w konfiguracji ALB → Lambda. Ogólniejszy runbook dla ALB 502 z EC2/ECS targetami znajdziesz w ALB 502 - Target Health Check Failures. Temat kosztów i architektury serverless omawiamy w artykule Serverless bez finansowych niespodzianek - Lambda pricing. Potrzebujesz pomocy z architekturą serverless? Umów konsultacje.

Objaw

ALB zwraca HTTP 502 dla żądań routowanych do Lambda target group. CloudWatch metryki ALB pokazują wzrost HTTPCode_ELB_502_Count:

# Sprawdź metryki 502 na ALB
aws cloudwatch get-metric-statistics \
  --namespace AWS/ApplicationELB \
  --metric-name HTTPCode_ELB_502_Count \
  --dimensions Name=LoadBalancer,Value=app/my-alb/1234567890abcdef \
  --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 300 \
  --statistics Sum

# Sprawdź access logi ALB (S3) - szukaj 502 z informacją o przyczynie
# Kolumna "actions_executed" i "target_status_code" wskazują źródło
aws s3 cp s3://my-alb-logs/AWSLogs/123456789012/elasticloadbalancing/eu-west-1/$(date +%Y/%m/%d)/ /tmp/alb-logs/ --recursive
zcat /tmp/alb-logs/*.gz | grep " 502 " | head -20

# Format ALB access log - kluczowe kolumny:
# target_status_code = "-" oznacza że Lambda nie odpowiedziała w ogóle
# target_processing_time = "-" oznacza timeout lub błąd połączenia
# actions_executed = "forward" + target_status_code "-" = Lambda crash/timeout

# Sprawdź Lambda errors w CloudWatch Logs
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 "ERROR Task timed out" \
  --max-items 10

# Sprawdź Lambda CloudWatch metryki
aws cloudwatch get-metric-statistics \
  --namespace AWS/Lambda \
  --metric-name Errors \
  --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 300 \
  --statistics Sum

# Sprawdź Duration vs Timeout
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 300 \
  --statistics Maximum

Diagnoza różnicowa - co powoduje 502:

Objaw w access log target_status_code target_processing_time Przyczyna
502 - - Lambda nie została w ogóle wywołana (payload za duży)
502 - 29.xxx ALB timeout (Lambda potrzebuje >29s)
502 - <1s Lambda crash (OOM, unhandled exception)
502 200 ale 502 klientowi Normalny Malformed Lambda response (niepoprawny JSON)

Przyczyna

ALB → Lambda ma specyficzne ograniczenia, których nie ma przy bezpośrednim wykonaniu:

1. Limit payload request body: 1 MB (ALB → Lambda):

ALB ogranicza body żądania do Lambda do 1 MB. Jeśli klient wysyła request większy niż 1 MB (np. upload pliku, duży JSON), ALB natychmiast zwraca 502 bez wywoływania Lambdy. Lambda sama obsługuje do 6 MB payload, ale ALB obcina do 1 MB.

2. Limit payload response body: 1 MB (Lambda → ALB):

Odpowiedź Lambda do ALB również nie może przekroczyć 1 MB. Jeśli Lambda zwróci większy JSON/body, ALB odrzuca odpowiedź i zwraca 502 klientowi.

3. ALB connection timeout: 29 sekund (hardcoded, nie konfigurowalny):

ALB czeka na odpowiedź Lambda przez maksymalnie 29 sekund. Nawet jeśli Lambda ma timeout 15 minut, ALB przerwie połączenie po 29s i zwróci 502. To jest twarde ograniczenie - nie można go zmienić.

4. Malformed Lambda response:

ALB oczekuje od Lambda odpowiedzi w ściśle określonym formacie JSON. Brak wymaganych pól (statusCode, body) lub niepoprawny typ (statusCode jako string zamiast int) powoduje 502.

5. Zimny start Lambda + ALB health check:

Przy zimnym starcie Lambda może potrzebować 5-15s na inicjalizację. Jeśli ALB wykonuje health check w tym czasie i nie otrzyma odpowiedzi w 5s (domyślny health check timeout), oznacza target jako unhealthy.

Rozwiązanie

A) Payload za duży - multipart upload przez S3 presigned URL:

# lambda_handler.py - obejście limitu 1MB na upload
import json
import boto3
import uuid

s3 = boto3.client("s3")
BUCKET = "my-uploads-bucket"

def handler(event, context):
    """Endpoint do generowania presigned URL dla dużych uploadów."""
    # Klient najpierw pyta o URL do uploadu
    if event["path"] == "/upload/presign" and event["httpMethod"] == "POST":
        file_key = f"uploads/{uuid.uuid4()}"
        presigned_url = s3.generate_presigned_url(
            "put_object",
            Params={"Bucket": BUCKET, "Key": file_key},
            ExpiresIn=300,  # 5 minut na upload
        )
        return {
            "statusCode": 200,
            "headers": {"Content-Type": "application/json"},
            "body": json.dumps({
                "upload_url": presigned_url,
                "file_key": file_key,
            }),
        }

    # Po uploadzie klient informuje Lambda o pliku do przetworzenia
    if event["path"] == "/upload/process" and event["httpMethod"] == "POST":
        body = json.loads(event["body"])
        file_key = body["file_key"]
        # Przetwórz plik bezpośrednio z S3 (brak limitu 1MB)
        obj = s3.get_object(Bucket=BUCKET, Key=file_key)
        content = obj["Body"].read()
        # ... przetwarzanie ...
        return {
            "statusCode": 200,
            "headers": {"Content-Type": "application/json"},
            "body": json.dumps({"status": "processed", "size": len(content)}),
        }

B) Response za duży - streaming przez S3 lub paginacja:

# Gdy Lambda generuje response >1MB, zapisz do S3 i zwróć link
import json
import boto3
import gzip

s3 = boto3.client("s3")

def handler(event, context):
    """Zwraca duże odpowiedzi przez S3 presigned URL."""
    # Generuj dużą odpowiedź
    large_result = generate_report()  # np. 5MB JSON

    result_json = json.dumps(large_result)

    if len(result_json) > 900_000:  # Zostaw margines (limit 1MB)
        # Zapisz do S3 i zwróć presigned URL do pobrania
        key = f"results/{context.aws_request_id}.json.gz"
        s3.put_object(
            Bucket="my-results-bucket",
            Key=key,
            Body=gzip.compress(result_json.encode()),
            ContentType="application/json",
            ContentEncoding="gzip",
        )
        download_url = s3.generate_presigned_url(
            "get_object",
            Params={"Bucket": "my-results-bucket", "Key": key},
            ExpiresIn=3600,
        )
        return {
            "statusCode": 200,
            "headers": {"Content-Type": "application/json"},
            "body": json.dumps({
                "status": "complete",
                "result_url": download_url,
                "size_bytes": len(result_json),
            }),
        }
    else:
        return {
            "statusCode": 200,
            "headers": {"Content-Type": "application/json"},
            "body": result_json,
        }

C) Timeout 29s - asynchroniczny pattern z polling:

# Pattern: ALB → Lambda (start job) → SQS/Step Functions → Lambda (process)
# Klient polluje status lub używa WebSocket

import json
import boto3
import uuid

sfn = boto3.client("stepfunctions")
dynamodb = boto3.resource("dynamodb")
jobs_table = dynamodb.Table("async-jobs")

STATE_MACHINE_ARN = "arn:aws:states:eu-west-1:123456789012:stateMachine:long-processing"

def handler(event, context):
    """Asynchroniczny endpoint dla operacji trwających >29s."""
    path = event["path"]
    method = event["httpMethod"]

    # POST /jobs - rozpocznij długą operację
    if path == "/jobs" and method == "POST":
        job_id = str(uuid.uuid4())
        body = json.loads(event.get("body", "{}"))

        # Uruchom Step Function (może trwać godziny)
        sfn.start_execution(
            stateMachineArn=STATE_MACHINE_ARN,
            name=job_id,
            input=json.dumps({"job_id": job_id, "params": body}),
        )

        # Zapisz status w DynamoDB
        jobs_table.put_item(Item={
            "job_id": job_id,
            "status": "PROCESSING",
            "created_at": context.get_remaining_time_in_millis(),
        })

        return {
            "statusCode": 202,  # Accepted
            "headers": {"Content-Type": "application/json"},
            "body": json.dumps({
                "job_id": job_id,
                "status": "PROCESSING",
                "poll_url": f"/jobs/{job_id}",
            }),
        }

    # GET /jobs/{id} - sprawdź status (polling)
    if path.startswith("/jobs/") and method == "GET":
        job_id = path.split("/")[-1]
        item = jobs_table.get_item(Key={"job_id": job_id}).get("Item")

        if not item:
            return {"statusCode": 404, "body": json.dumps({"error": "Job not found"})}

        return {
            "statusCode": 200,
            "headers": {"Content-Type": "application/json"},
            "body": json.dumps({
                "job_id": job_id,
                "status": item["status"],
                "result_url": item.get("result_url"),
            }),
        }

D) Napraw format response Lambda:

# POPRAWNY format odpowiedzi Lambda dla ALB:
def handler(event, context):
    return {
        "statusCode": 200,                    # WYMAGANE: int, nie string
        "statusDescription": "200 OK",        # opcjonalne
        "headers": {                           # WYMAGANE: dict
            "Content-Type": "application/json",
        },
        "isBase64Encoded": False,             # WYMAGANE: bool
        "body": json.dumps({"key": "value"})  # WYMAGANE: string (nie dict!)
    }

# BŁĘDNE formaty powodujące 502:
# ❌ return {"statusCode": "200", ...}         # statusCode jako string
# ❌ return {"status_code": 200, ...}          # błędna nazwa klucza
# ❌ return {"statusCode": 200, "body": {...}} # body jako dict zamiast string
# ❌ return "Hello World"                       # string zamiast dict
# ❌ return {"statusCode": 200}                # brak body i headers

E) Cold start - provisioned concurrency + health check tuning:

# Włącz provisioned concurrency (eliminuje cold start)
aws lambda put-provisioned-concurrency-config \
  --function-name my-api-function \
  --qualifier prod \
  --provisioned-concurrent-executions 5

# Dostosuj health check ALB Target Group
aws elbv2 modify-target-group \
  --target-group-arn arn:aws:elasticloadbalancing:eu-west-1:123456789012:targetgroup/my-lambda-tg/1234567890abcdef \
  --health-check-enabled \
  --health-check-path /health \
  --health-check-interval-seconds 35 \
  --health-check-timeout-seconds 30 \
  --healthy-threshold-count 2 \
  --unhealthy-threshold-count 3

Walidacja

# 1. Test z payloadem < 1MB (powinien działać)
curl -s -o /dev/null -w "%{http_code}" \
  -X POST https://my-alb.example.com/api/data \
  -H "Content-Type: application/json" \
  -d '{"small": "payload"}'
# Expected: 200

# 2. Test z payloadem > 1MB (powinien zwrócić 413 lub redirect do presigned URL)
dd if=/dev/urandom bs=1100000 count=1 | base64 > /tmp/big-payload.txt
curl -s -o /dev/null -w "%{http_code}" \
  -X POST https://my-alb.example.com/api/upload \
  -H "Content-Type: application/octet-stream" \
  -d @/tmp/big-payload.txt
# Expected: 413 (Payload Too Large) z instrukcją użycia presigned URL
# LUB: 200 jeśli zaimplementowałeś redirect

# 3. Test timeout - endpoint wykonujący się <29s
curl -s -o /dev/null -w "%{http_code} %{time_total}s" \
  https://my-alb.example.com/api/quick-operation
# Expected: 200, time < 29s

# 4. Test formatu response (sprawdź czy nie ma 502)
curl -v https://my-alb.example.com/api/data 2>&1 | grep "< HTTP"
# Expected: "< HTTP/1.1 200 OK"

# 5. Sprawdź CloudWatch - brak nowych 502
aws cloudwatch get-metric-statistics \
  --namespace AWS/ApplicationELB \
  --metric-name HTTPCode_ELB_502_Count \
  --dimensions Name=LoadBalancer,Value=app/my-alb/1234567890abcdef \
  --start-time $(date -u -d '30 minutes ago' +%Y-%m-%dT%H:%M:%S) \
  --end-time $(date -u +%Y-%m-%dT%H:%M:%S) \
  --period 300 \
  --statistics Sum
# Expected: Sum = 0
Kluczowe limity ALB → Lambda, których nie da się zmienić: request body 1 MB, response body 1 MB, connection timeout 29s. Te ograniczenia nie dotyczą bezpośredniego wykonania Lambda (6 MB payload, 15 min timeout). Jeśli Twoja aplikacja potrzebuje większych payloadów lub dłuższego przetwarzania - użyj API Gateway (10 MB payload, 29s timeout) lub przenieś na asynchroniczny pattern z S3 + Step Functions.

 

Jerzy Kopaczewski

ALB + Lambda zwraca 502?

Umów bezpłatną 30-minutową rozmowę. Zdiagnozujemy przyczynę 502 i zaprojektujemy architekturę serverless obsługującą Twoje wymagania bez limitów ALB.