AWS LB idle Timeout x keepalive 502
최근 사내에서 사용하는 HTTP HealthCheck Tool (stauts.io) 에서 502 Bad Gateway 가 발생했다. 장애까지는 아니었지만, 왜 이런이슈가 났는지 디버깅을 하다가 공부한 내용을 기재해본다.
AWS LB idle Timeout x keepalive 502
최근 사내에서 사용하는 HTTP HealthCheck Tool (stauts.io) 에서 502 Bad Gateway 가 발생했다. 장애까지는 아니었지만, 왜 이런이슈가 났는지 디버깅을 하다가 공부한 내용을 기재해본다.
상황 설명

- ALB 에 여러 Listener 를 구성하는 형태로 사용중임
- status.io -> ALB -> Listener -> Target Group -> EC2 로 이어지는 구조임
- 이때 status.io 에서 502 Bad Gateway가 발생함
- 이때 각 구간별로 확인해봤을때 ALB 단에서 502를 반환함
1. Client <-> ALB
Client 와 ALB 통신 시, TCP Conneciton이 생긴다. 세부적인 골치아프니까 생략하겠지만 ALB Attribute에는 Session Idle Timeout 값이 있다.
해당 값(Session Idle Timeout)은 말그대로 해당시간동안 트래픽이 감지되지 않으면 ALB 단에서 연결을 끊는다 (FIN)

이때 ALB는 각 Client 별로 각각의 TCP Connection을 가지며, Session Idle Timeout 시간을 계산한다.
좀… 더 확인해보면
추가적으로 LB 는 기본적으로 LCU 라는 측정단위가 존재하며, 이를 통해서 LB 가 시간당 일한 값들을 알수있다.
- New Connection (25 / s) -> 클라이언트 트래픽 진단 (매번 새로운 connection을 맺는지 검사)
- Active Connection (3000 / min) -> 누수 idle 진단 (좀비 conn / 누수 확인)
- Process Bytes (1GB / hour) -> 대역폭 Payload 진단 (payload 대비, 압축 미사용 여부 파악)
- Rule Evaulation ( 1,000 / sec ) -> rule 복잡도 진단 (매칭이 비효율인지 확인)
이때 위 값을 1 LCU 이며, 위 값기준으로 비용 계산을 진행하며 각 항목별로 LB 진단도 가능하다. 또한 LB 도 결국 AWS Side 단에서 동작하는 컴퓨팅 자원이며 해당 자원은 수평확장을 진행하지만 갑작스런 트래픽에 대응하지 못한다.

대응하지 못한다는게, 아예못한다는게 아니라 ALB 가 가진 Capa가 큰 트래픽을 맞게되면 실제 트래픽에 대응할때까지 LB Node가 증가되는데 이 구간에 503이 발생한다. 그렇기때문에 만약에 큰 트래픽이 예상된다면 prewarming을 신청해서 이슈를 해결하자. (좀 멀리 돌았는데 나중에 이글은 또 쓰리고 하고, 밑에 계속)
2. ALB <-> EC2 (Webserver)
위에서 말했듯, ALB Idle Timeout은 TCP Connection당 발생하고 이건 Backend 에서도 동일하게 적용된다. 이때 서버단에서도 keep-alive 옵션을 가지고있다. 그럼 이 두개는 어떤 형태로 서로 유후시간을 계산할까?

idle timeout, keep-alive 모두 동시에 시간이 흐른다. 그렇기때문에 keep-alive가 idle_timeout 값보다 적다면 502 에러가 발생한다 (idle_timeout > keep-alive)
그렇기때문에 문서상에서도 KeepAlive값을 ALB 의 IdleTimeout 값보다 높게설정하라고 가이드한다
3. 근데 지금 문제는? ALB 단에서 502에러가 발생했다.
Target Group 에서는 5xx 에러가 발생하지 않았고, ALB 단에서 502에러를 반환했다. 추가적으로 Support Case상 ALB 단 이슈는 없었다고 한다.
보통 이런경우에는 keep-alive race 로 인한 RST 가 흔한 경우라고한다. (draw.io 로 그리려고했는데 힘들어서 md로 대체)
# ALB TCP Lifecycle — Client ↔ ALB ↔ Backend
전 구간의 TCP/TLS/HTTP 라이프사이클과, 502가 발생하는 race condition까지 한 장에 담은 시퀀스 다이어그램.
---
## 전체 라이프사이클 — 시간순
[Client] [ALB Kernel] [ALB App] [Backend] │ │ │ │ │ │ │ │
╔═══════════════════════════════════════════════════════════════════════╗ ║ PHASE 1. Client ↔ ALB ─ TCP 3-way Handshake ║ ║ (Established) ║ ╠═══════════════════════════════════════════════════════════════════════╣ │ │ │ │ │ ─── 1. SYN ───────▶│ │ │ │ ◀── 2. SYN-ACK ── │ │ │ │ ─── 3. ACK ───────▶│ │ │ │ │ │ │ │ [Front conn ESTABLISHED] │ │ │ │ │
╔═══════════════════════════════════════════════════════════════════════╗ ║ PHASE 2. Client ↔ ALB ─ TLS Handshake (:443) ║ ║ (TLS Session Setup, ACM 인증서 제시) ║ ╠═══════════════════════════════════════════════════════════════════════╣ │ │ │ │ │ ─── 4. ClientHello ──────────────▶│ │ │ (SNI, cipher list) │ │ │ │ │ │ │ ◀── 5. ServerHello + Cert ──────│ (ACM 인증서 제시) │ │ │ │ │ │ ─── 6. KeyExchange + Finished ──▶│ │ │ │ │ │ │ ◀── 7. Finished ───────────────│ │ │ │ │ │ │ ★ TLS 세션 완료 │ │ │ ★ NewConnectionCount +1 │ │ │ │ │ │
╔═══════════════════════════════════════════════════════════════════════╗ ║ PHASE 3. Client → ALB ─ HTTPS Request 송신 ║ ╠═══════════════════════════════════════════════════════════════════════╣ │ │ │ │ │ ─── 8. HTTPS req ────────────────▶│ │ │ GET /api HTTP/1.1│ │ │ │ Host: example.com │ decrypt ──▶│ │ │ │ │ │
╔═══════════════════════════════════════════════════════════════════════╗ ║ PHASE 4. ALB 내부 ─ 라우팅 결정 ║ ╠═══════════════════════════════════════════════════════════════════════╣ │ │ │ │ │ │ │ 9. Listener :443 매칭 │ │ │ │ 10. Rule 평가 │ │ │ │ · prio 1 검사 → match │ │ │ 11. Target Group A 결정 │ │ │ │ 12. TG 라우팅 알고리즘 │ │ │ │ · RR / LOR 등 │ │ │ │ → EC2-1 선택 │ │ │ │ 13. Back-conn pool 확인 │ │ │ │ · 재사용 가능? YES → P6 │ │ │ · 없음? NO → P5 │ │ │ │
╔═══════════════════════════════════════════════════════════════════════╗ ║ PHASE 5. ALB ↔ Backend ─ TCP 3-way Handshake ║ ║ (재사용 시 생략됨 — 새 conn 만들 때만) ║ ╠═══════════════════════════════════════════════════════════════════════╣ │ │ │ │ │ │ │ ─── 14. SYN ─────────▶│ │ │ │ ◀── 15. SYN-ACK ──── │ │ │ │ ─── 16. ACK ─────────▶│ │ │ │ │ │ │ [Back conn ESTABLISHED] │ │ │ │ │ │ │ ★ (HTTPS면 TLS 또는 그냥 HTTP, │ │ target group 설정에 따름) │ │ │ │
╔═══════════════════════════════════════════════════════════════════════╗ ║ PHASE 6. ALB → Backend ─ HTTP Request 송신 ║ ╠═══════════════════════════════════════════════════════════════════════╣ │ │ │ │ │ │ │ ─── 17. HTTP req ────▶│ │ │ │ X-Forwarded-For 등 │ │ │ │ 헤더 주입 │ │ │ │ │ │ │ │ 18. Backend App 처리 │ │ │ │
╔═══════════════════════════════════════════════════════════════════════╗ ║ PHASE 7. Backend → Client ─ HTTP Response ║ ╠═══════════════════════════════════════════════════════════════════════╣ │ │ │ │ │ │ │ ◀── 19. HTTP resp ───│ │ │ │ 200 OK │ │ │ │ Body │ │ │ │ │ │ │ ◀ encrypt ──│ │ │ ◀── 20. HTTPS resp ─────────────│ │ │ 200 OK │ │ │ │ │ │ │ │ ★ Front & Back conn 양쪽 idle 타이머 모두 리셋 │ │ ★ ConsumedLCUs / RequestCount / ProcessedBytes 증가 │ │ │ │ │
╔═══════════════════════════════════════════════════════════════════════╗ ║ PHASE 8. Idle 구간 ─ 양쪽이 각자 타이머 카운트 ║ ╠═══════════════════════════════════════════════════════════════════════╣ │ │ │ │ │ ─── 트래픽 없음 ─── │ │ ─── 트래픽 없음 ─── │ │ │ │ │ │ [Front side timer] │ [Back side timer] │ │ · ALB idle = 60s │ · ALB idle = 60s │ │ · Client idle = ? │ · Backend ka = 65s │ │ │ │ │ │ Active conn 메트릭 카운트 중 │ Active conn 메트릭 카운트 중 │ │ │ │
╔═══════════════════════════════════════════════════════════════════════╗ ║ PHASE 9-A. 정상 종료 ─ ALB가 먼저 FIN (Back side) ║ ║ (안전: Backend ka > ALB idle) ║ ╠═══════════════════════════════════════════════════════════════════════╣ │ │ │ │ │ │ │ T=60s: idle 만료 │ │ │ │ → close(conn-B) │ │ │ │ │ │ │ │ ─── 21. FIN ─────────▶│ │ │ │ ◀── 22. ACK ──────── │ │ │ │ ◀── 23. FIN ──────── │ │ │ │ ─── 24. ACK ─────────▶│ │ │ │ │ │ │ │ [Back CLOSED] ✅ │ │ │ │ │ │ (Front도 비슷한 시점에 ALB idle 만료 → 동일한 FIN 절차) │ │ ◀── 25. FIN ──────│ │ │ │ ─── 26. ACK ──────▶│ │ │ │ ─── 27. FIN ──────▶│ │ │ │ ◀── 28. ACK ──────│ │ │ │ [Front CLOSED] ✅ │ │ │ │ │ │
╔═══════════════════════════════════════════════════════════════════════╗ ║ PHASE 9-B. 502 시나리오 ─ Backend가 먼저 FIN (Back side) ⚠️ ║ ║ (위험: Backend ka < ALB idle) ║ ╠═══════════════════════════════════════════════════════════════════════╣ │ │ │ │ │ │ │ T=5s: Backend ka 만료 │ │ │ │ │ │ │ ◀── 21. FIN ──────────────────── │ │ │ [CLOSE_WAIT]│ │ │ │ ❌ App엔 알림 없음 │ │ │ │ │ │ │ ─── 22. ACK ────────────────────▶│ │ │ │ [BE 자원 정리] │ │ │ │ │ │ │ │ │ T=5.2s 새 요청 │ │ │ │ ─── 23. HTTPS req ──────────────▶│ │ │ │ decrypt ──▶ │ │ │ │ │ App: "풀에서 conn 골라" │ │ │ conn#7 선택 (이미 죽음) │ │ │ │ │ │ │ ─── 24. HTTP req ────▶│ │ │ │ (죽은 conn 사용) │ │ │ │ │ │ │ │ Backend: "모르는 conn" │ │ │ │ │ │ ◀── 25. RST ──────────────────── │ │ │ [CLOSED] │ │ │ │ ───────────▶│ App: "어 죽었네" │ │ │ │ 풀에서 제거 │ │ │ │ 502 생성 │ │ │ │ │ │ │ ◀ encrypt ──│ │ │ ◀── 26. HTTPS 502 ────────────── │ │ │ Bad Gateway │ │ │ │ │ │ │ │ ★ HTTPCode_ELB_502_Count ↑ │ │ ★ HTTPCode_Target_5XX_Count = 0 (변화 없음) │ │ ★ Backend access log에 흔적 없음 │ │ │ │ │
---
## Phase별 CloudWatch 메트릭 영향
| Phase | 메트릭 변화 |
|-------|-------------|
| 1 (TCP) | NewConnectionCount +1 (front) |
| 2 (TLS) | ProcessedBytes 약간 (handshake) |
| 3 (req) | RequestCount +1, ProcessedBytes += req size |
| 4 (routing) | RuleEvaluations += 매칭된 룰 개수 |
| 5 (back TCP) | (재사용이면 0, 새로 만들면 back side new conn) |
| 6 (back req) | TargetRequestCount +1 |
| 7 (response) | ProcessedBytes += resp size, TargetResponseTime 기록 |
| 8 (idle) | ActiveConnectionCount 동안 유지 |
| 9-A (정상) | 메트릭에 무영향 |
| **9-B (502)** | **HTTPCode_ELB_502_Count +1**, HTTPCode_Target_5XX_Count = 0 |
---
그럼…정리하자면
위 구조에선 결국 keep-alive 값이 제대로 튜닝되지 않아서 발생한 이슈다. 그런데 현재 설정은 아래와 같았다.
- ALB Idle Timeout -> 175s
- Python Gunicorn Keey-Alive -> 180s
좀더 면밀히 봐야하겠지만, Buffer가 좀 적지 않았나.. 싶다 (5초), 실무권장 패턴은 10s ~ 15s 라고 함. 근데 왜 저런 이슈가날까?
2가지 가설이 생긴다. (9-B 상황)
첫번째는 ALB 가 175초가 아닌 좀더 뒤에서 FIN 을 해서 keep-alive가 발생함 (keep-alive < idle_timeout)
두번째는 keep-alive가 180초로 설정되어있으나, 갑작스럽게 alb idle_timeout보다 더 먼저 fin 을 줬을때 (keep-alive < idle_timeout)
4. gunicorn 설정을 보아하니…
그래서 뭐가문제일까. 웹서버 설정을 보니 아래와같은 구성이 있었다. 저 max_requests는 worker memory 누수 방지를 위해 max_request가 1000개 처리하면 자체적으로 worker를 재시작하는 옵션이다. 즉 재시작을 한다는것이 모든 conn을 강제로 종료하고 이로인해 alb는 그 사실을 모르고, 모든 pool들에 conn이 갑자기 죽어서 -> 다음요청 -> RST -> 502 가 나는게 아닐까 하는생각이들었다.
# gunicorn.py
max_requests = 1000
max_requests_fitter = 50
즉, 워커가 4개라고 가정했을때, max_requset수에 의해 재시작을 트리거하는데 (실제로 950 ~ 1,050 랜덤) 하게 도는데 이때 master가 sigterm 을 송신하고 워커내 모든 keep-alive conn에 close() 를 호출한다 (FIN 송신)
이때 ALB 에서는 워커 A 가 들고있던 keep-alive conn이 풀에남아있었고, 해당 pool 중에는 백단에서 이미 FIN을 줬을거고, 그 FIN받은걸 alb가 받았을때 RST 가 발생한게 아닐까…
[Client] [ALB Kernel] [ALB App] [gunicorn master] [Worker A] [Worker B] │ │ │ │ │ │
╔════════════════════════════════════════════════════════════════════════════════╗ ║ PHASE 1. 정상 운영 — ALB 풀에 워커 A로의 conn 다수 보관 중 ║ ╠════════════════════════════════════════════════════════════════════════════════╣ │ ── req ──▶│ ── decode ▶ │ │ │ │ │ │ │ conn 선택 │ │ │ │ │ │ ─────────── HTTP req ─────▶│ │ │ │ │ │ │ 처리 │ │ │ │ ◀────────── HTTP resp ────│ │ │ ◀── resp ─│ ◀── encode ─│ │ │ │ │ │ │ [ALB 풀 상태] │ │ │ │ │ conn#1 ─▶ Worker A │ │ │ │ │ conn#2 ─▶ Worker A │ │ │ │ │ conn#3 ─▶ Worker A │ │ │ │ │ conn#4 ─▶ Worker B │ │ │ │ │ conn#5 ─▶ Worker B │ │ │ │ │ │ [요청 카운트: 950 → 1000]
╔════════════════════════════════════════════════════════════════════════════════╗ ║ PHASE 2. Worker A가 max_requests 도달 ─ 재시작 트리거 ║ ╠════════════════════════════════════════════════════════════════════════════════╣ │ │ │ │ [요청 카운트 1000 + jitter] │ │ │ │ ── SIGTERM ─▶│ │ │ │ │ │ │ "종료 절차"
╔════════════════════════════════════════════════════════════════════════════════╗ ║ PHASE 3. Worker A 종료 ─ keep-alive conn들에 FIN 송신 ║ ╠════════════════════════════════════════════════════════════════════════════════╣ │ │ │ │ │ close(conn#1) │ │ │ │ │ close(conn#2) │ │ │ │ │ close(conn#3) │ │ ◀── FIN(conn#1) ───────────────────────────│ │ │ │ ◀── FIN(conn#2) ───────────────────────────│ │ │ │ ◀── FIN(conn#3) ───────────────────────────│ │ │ │ [Kernel만 받음] │ │ │ │ │ conn#1~3: ESTABLISHED → CLOSE_WAIT │ │ │ │ │ ❌ App엔 알림 없음 │ │ │ │ 풀에는 여전히 "살아있다고 믿는" │ │ │ │ conn#1, #2, #3 그대로 남아있음 │ │ │ ── ACK ─────────────────────────────────▶│ │ │ │ │ │ [자원 정리] │ │ │ │ │ [프로세스 종료] │ │ │ │ │ × │ │ │ │ │ ── 새 워커 spawn ────────▶│ │ │ │ │ [Worker A' 시작]
╔════════════════════════════════════════════════════════════════════════════════╗ ║ PHASE 4. 새 요청 도착 ─ ALB가 풀에서 stale conn을 골라 사용 ║ ╠════════════════════════════════════════════════════════════════════════════════╣ │ ── req ──▶│ ── decode ▶ │ │ │ │ │ │ 풀에서 conn 선택: │ │ │ │ "conn#2 살아있음 (실은 죽음)" │ │ │ │ → 잘못된 선택 │ │ │ ◀── write(conn#2, "GET /api...") ── │ │ │ Kernel: "CLOSE_WAIT여도 write 자체는 허용" │ │ │ ── HTTP req ──────────────────────────────────▶ [Worker A' 또는 │ │ │ │ 그 호스트]
╔════════════════════════════════════════════════════════════════════════════════╗ ║ PHASE 5. Backend의 OS Kernel이 RST 응답 ║ ╠════════════════════════════════════════════════════════════════════════════════╣ │ │ │ │ [Backend Kernel] │ │ │ │ │ "이 conn은 내 │ │ │ │ │ connection table에 │ │ │ │ │ 없는 conn이야" │ │ │ │ │ "RST로 응답해야지" │ │ │ ◀── RST ────────────────────────────────────────────── │ │ │ Kernel: conn#2 → CLOSED │ │ │ ── 에러 전달 ▶│ │ │ │ │ │ App: "어 conn#2 죽었네" │ │ │ │ 풀에서 제거 │ │ │ │ 502 응답 생성 │
╔════════════════════════════════════════════════════════════════════════════════╗ ║ PHASE 6. Client에게 502 반환 ║ ╠════════════════════════════════════════════════════════════════════════════════╣ │ │ ◀── encrypt ─│ │ │ │ ◀── 502 ──│ │ │ │ │ ★ HTTPCode_ELB_502_Count +1 │ │ ★ HTTPCode_Target_5XX_Count = 0 (변화 없음) │ │ ★ Worker A' 의 access log에 흔적 없음 │
결론
- 가설이지만, 위 이슈는 ALB <-> Backend 단에서 worker 종료 (Sigterm) 로 인한 Connection 종료 (FIN) 을 ALB 단에서 Connection pool내 재활횽하다가 502 가 발생한 이슈 (확인해봐야함)
- ALB 에 Idle_timeout 값보다 서버단에 keep-alive 을 더 많이주자 (keep-alive > idle_timeout)
- 서버군마다 keep-alive best practice 를 확인하자 (502 Bad Gateway)
메타데이터
- post_id
- 7aa2f596c2a6
- slug
- aws-load-balacner-deep-dive-7aa2f596c2a6
- url
- https://medium.com/@zkfmapf999/aws-load-balacner-deep-dive-7aa2f596c2a6
- canonical_url
- https://medium.com/@zkfmapf999/aws-load-balacner-deep-dive-7aa2f596c2a6
- author_url
- https://medium.com/@zkfmapf999
- status
- ok
- fetched_at
- 2026-06-26 03:39:16