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.
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
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.