Incident 2026 09 29 Nginx 연결 고갈 - 100-hours-a-week/KTB4-2nd-Cloud GitHub Wiki
강사님이 운영 도메인에 의도적으로 부하를 보낸 통제 테스트에서, 약 5분 동안 로그인이 되지 않고 일부 API가 500을 반환했습니다. CPU와 메모리에는 여유가 있었고, Nginx의 동시 연결 수가 먼저 한도에 닿은 게 원인이었습니다.
| 항목 | 내용 |
|---|---|
| 영향 시간 | 21:29 ~ 21:34 (KST) |
| 영향 | 로그인 실패, 일부 API 500 |
| 원인 |
/api/trips/map으로 몰린 요청이 Nginx 연결 슬롯을 모두 사용 |
| 감지 | Nginx 5xx Alarm → Discord |
| 복구 | 부하 종료 후 자동 복구, 재시작 없음 |
21시 31분에 Discord로 Alarm이 왔습니다.

Alarm의 12:26:00은 UTC 기준 5분 집계 구간의 시작입니다. 21시 26분부터 5분 동안 Nginx 5xx가 41건 쌓여, 구간이 끝난 21시 31분에 알림이 왔습니다.
| 시각 | 확인한 내용 |
|---|---|
| 21:28 | TCP 연결 22개, CPU 3.85% (평소 수준) |
| 21:29 | TCP 연결 1,521개로 급증, Nginx 연결 부족 오류 시작 |
| 21:30 | TCP 연결 1,557개, CPU 44.46% |
| 21:31 | Nginx 5xx 41건으로 Alarm 발생 |
| 21:32 ~ 21:33 | TCP 연결 1,550개 안팎 유지, 연결 부족 오류 계속 |
| 21:34 | TCP 연결 52개로 감소 |
| 21:35 | TCP 연결 13개, CPU 2.53%로 정상 복귀 |
EC2 상태 검사는 전체 구간에서 정상이었습니다.
Nginx 로그를 경로와 상태 코드로 묶어보니 요청 대부분이 인증 없는 지도 조회였습니다.
| 요청 | 상태 | Upstream 상태 | 건수 |
|---|---|---|---|
GET /api/trips/map |
401 | 401 | 24,356 |
GET /api/trips/map |
500 | - |
37 |
GET /api/trips/map |
499 | - |
6 |

대부분은 Backend까지 가서 1ms 만에 401로 끝났습니다. 500으로 끝난 요청은 Upstream 상태가 -여서, Backend에 도착하지도 못하고 Nginx에서 끝난 요청이었습니다.
Alarm의 41건은 모든 경로의 5xx이고, 위 37건은 그중 /api/trips/map만 센 값입니다.
같은 구간 Nginx Error 로그에 이 메시지가 반복됐습니다.
worker_connections are not enough
| 시각 | 오류 건수 |
|---|---|
| 21:29 | 1,198 |
| 21:30 | 18,264 |
| 21:31 | 22,494 |
| 21:32 | 22,858 |
| 21:33 | 24,156 |
| 21:34 | 8,766 |

같은 연결에서 오류가 반복 기록되기 때문에, 이 숫자가 요청 수나 사용자 수를 뜻하지는 않습니다.
당시 Nginx 설정은 이랬습니다.
worker_processes auto; (App CPU 2개 → Worker 2개)
worker_connections 768;
Worker 두 개의 연결 한도를 합치면 1,536개입니다. 장애 구간의 TCP 연결 약 1,550개와 거의 같고, 연결 부족 오류도 딱 그 구간에만 나왔습니다.

CPU는 최고 44%로 여유가 있었습니다. Backend가 401을 빠르게 돌려줘도 요청마다 연결은 계속 필요했고, 연결 슬롯이 먼저 바닥났습니다.
장애를 키운 조건은 이렇게 정리했습니다.
- IP별 요청 속도 제한이 없음
- IP별 동시 연결 제한이 없음
- Nginx 연결 수와 연결 부족 오류를 보는 Alarm이 없음
- Access 로그에 Client IP가 없어 요청 출처를 확인할 수 없음
- Nginx 앞에 CloudFront, ALB, WAF 같은 계층이 없음
worker_connections만 늘리면 늘어난 슬롯도 같은 방식으로 다 쓸 수 있어서, 숫자를 올리는 것만으로는 해결되지 않는다고 봤습니다.
같은 시각 AI 로그에도 요청이 몰린 것처럼 보이는 구간이 있었는데, 확인해보니 이번 부하와 관계없는 로그였습니다.
-
/ready와/health가 몇 ms 간격으로 찍힌 건 health check 한 번에 내부 확인이 함께 기록된 것 - 21시 29분 51초 전후로 몰린 요청은 여행 121의 AI 처리 진행률과 결과 전달
공격 요청은 인증 없는 조회라 AI 작업을 만들지 않고, Worker 8000번은 App Security Group에서만 접근할 수 있습니다.
부하가 끝난 뒤 재시작 없이 다음 응답을 확인했습니다.
GET https://yeodam-2gether.com/ → 307 (로그인 경로로 이동)
GET https://yeodam-2gether.com/api/actuator/health → 200
GET https://yeodam-2gether.com/api/trips/map → 401
| 항목 | 방향 |
|---|---|
| 요청 제한 | 일반 API에 Nginx limit_req, limit_conn 적용 |
| 반복 호출 경로 |
/api/trips/map, 인증 API에 별도 제한 검토 |
| 사진 업로드 | 일반 API와 다른 제한과 Timeout 적용 |
| 연결 한도 | OS 파일 디스크립터 확인 후 worker_connections 조정 검토 |
| 감지 | TCP 연결 급증, 연결 부족 오류 Alarm 추가 |
| 출처 확인 | Client IP 기록 범위와 보존 기간 결정 |
| 앞단 보호 | CloudFront 또는 ALB를 도입하는 단계에서 AWS WAF 적용 검토 |
사진 업로드는 정상 요청도 전송이 몇십 초씩 걸립니다. 일반 API와 같은 강한 제한을 걸면 정상 사용자가 막힐 수 있어서, 조회 API와 업로드 API의 제한은 나눠서 정하고 부하 테스트로 확인한 뒤 적용합니다.
공격 도구와 요청 출처는 로그에 남아 있지 않아 확인하지 못했습니다.
다시 조회하는 방법
경로별 상태 코드 (/yeodam/v1/nginx/access, 2026-09-29 21:26 ~ 21:31 KST)
fields method, normalized_route, status, upstream_status
| filter method = "GET"
| filter normalized_route = "/api/trips/map"
| stats count(*) as requests by status, upstream_status
| sort status asc연결 부족 오류 (/yeodam/v1/nginx/error, 2026-09-29 21:28 ~ 21:36 KST)
fields @timestamp, @message
| filter @message like /worker_connections are not enough/
| stats count(*) as errors by bin(1m) as minute
| sort minute ascHost 지표 (2026-09-29 21:26 ~ 21:35 KST, 1분 단위)
-
AWS/EC2: CPUUtilization, NetworkIn, NetworkOut, StatusCheckFailed -
CWAgent: mem_used_percent, netstat_tcp_established, netstat_tcp_time_wait