[배포 파이프라인 보안] ALB는 들어오는데 헬스체크만 못 들어오는 컨테이너
Next.js standalone 이미지를 ECS Fargate에 올렸더니 컨테이너 헬스체크만 실패하고 배포가 롤백됐습니다. Dockerfile에 ENV HOSTNAME=0.0.0.0을 분명히 넣었는데도 서버가 다른 주소에 바인딩되고 있었습니다. 원인과 한 줄짜리 해결을 공유합니다.
증상
수동 배포되던 Next.js 서비스를 GitHub Actions 배포로 옮기면서 ECS 컨테이너 헬스체크를 처음 붙였습니다. 이미지 안에서 /api/health를 호출하는 방식입니다.
"healthCheck": {
"command": [
"CMD-SHELL",
"node -e \"fetch('http://127.0.0.1:3000/api/health').then(r => process.exit(r.ok ? 0 : 1), () => process.exit(1))\""
],
"interval": 30,
"timeout": 5,
"retries": 3,
"startPeriod": 30
}
첫 배포에서 새 태스크가 끝내 healthy가 되지 못했고, deployment circuit breaker가 직전 리비전으로 롤백했습니다. 서비스는 구 태스크로 계속 돌았으니 사용자 영향은 없었습니다.
이상한 점은 로컬에서 같은 프로덕션 빌드를 띄우면 /api/health가 200을 줬다는 것입니다.
원인
Dockerfile은 Next.js standalone 구성의 흔한 형태였습니다.
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
CMD ["node", "server.js"]
standalone 빌드가 만드는 server.js는 HOSTNAME 환경변수를 읽어 그 주소에 바인딩합니다. 그래서 0.0.0.0을 넣어 모든 인터페이스에서 받게 하는 것이 일반적입니다.
그런데 Fargate가 런타임에 HOSTNAME을 태스크의 호스트명으로 덮어썼습니다. 이미지에 ENV로 넣은 값보다 런타임에 들어온 값이 이깁니다. 결과적으로 server.js는 0.0.0.0이 아니라 태스크 ENI의 IP 하나에만 바인딩했습니다.
의도: 0.0.0.0:3000 → 127.0.0.1 로도, ENI IP 로도 받는다
실제: <ENI IP>:3000 → ENI IP 로만 받는다
컨테이너 헬스체크는 컨테이너 안에서 127.0.0.1:3000으로 요청합니다. 서버가 그 주소에서 듣고 있지 않으니 연결이 거부되고, 헬스체크가 연속으로 실패해 태스크가 unhealthy로 판정됩니다.
찾기 어려웠던 이유는 바깥에서 보면 정상이기 때문입니다. ALB는 타깃의 ENI IP로 들어오므로 이 바인딩에 걸리지 않고, 로컬에서는 재현되지 않습니다. 실패하는 건 루프백으로 들어오는 요청뿐입니다.
기존 서비스에서 이 문제가 없었던 것은 컨테이너 헬스체크가 아예 없었기 때문입니다(healthCheck: null). ALB 헬스체크만 있었고, ALB는 ENI IP로 들어오니 아무 문제가 없었습니다. 헬스체크를 처음 켜면서 드러난 것입니다.
해결
HOSTNAME을 ENV가 아니라 실행 시점에 고정합니다.
ENV NODE_ENV=production
ENV PORT=3000
# HOSTNAME 을 ENV 로 두면 Fargate 가 런타임에 태스크 호스트명으로 덮어쓰고, server.js 가
# 그 ENI IP 에만 바인딩한다. 그러면 컨테이너 헬스체크의 127.0.0.1 이 거부돼 배포가 롤백된다.
# 실행 시점에 고정한다. env 가 node 로 exec 하므로 PID 1 은 그대로 node 다
CMD ["env", "HOSTNAME=0.0.0.0", "node", "server.js"]
env는 주어진 변수를 설정한 뒤 다음 명령으로 exec합니다. 셸을 거치지 않고 프로세스가 node로 교체되므로 PID 1은 그대로 node입니다. ECS가 태스크를 멈출 때 보내는 SIGTERM이 셸에 막히지 않고 node에 바로 전달됩니다. sh -c "HOSTNAME=0.0.0.0 node server.js"처럼 셸로 감싸면 이 성질을 잃을 수 있어서 env를 썼습니다.
수정 후 재배포에서 새 태스크가 healthy가 됐고 롤아웃이 정상적으로 끝났습니다. 롤백부터 수정 커밋까지 4분이었습니다. 원인을 찾고 나니 한 줄이었습니다.
정리
- Next.js standalone의 server.js는 HOSTNAME에 바인딩합니다
- Fargate는 런타임에 HOSTNAME을 태스크 호스트명으로 덮어씁니다. Dockerfile의 ENV보다 런타임 값이 이깁니다
- 그 결과 서버는 ENI IP에만 바인딩되고, 127.0.0.1로 들어오는 컨테이너 헬스체크만 실패합니다. ALB 헬스체크는 통과하고 로컬에서는 재현되지 않아 원인을 찾기 어렵습니다
- CMD ["env", "HOSTNAME=0.0.0.0", "node", "server.js"]로 실행 시점에 고정하면 해결됩니다
이 문제는 첫 자동 배포를 prod가 아니라 dev로 보냈기 때문에 사용자 영향 없이 지나갈 수 있었습니다. circuit breaker가 롤백까지 해줬습니다. 배포 파이프라인을 처음 바꿀 때 사용자가 없는 환경에 먼저 배포하는 일이 왜 필요한지 보여준 사례였습니다.
'Architecture > CI-CD' 카테고리의 다른 글
| [배포 파이프라인 보안] 액세스 키가 말없이 해주던 일들 (0) | 2026.10.08 |
|---|---|
| [배포 파이프라인 보안] AWS 정적 키를 GitHub OIDC로 걷어내기 (0) | 2026.10.01 |
| AI 테스트 코드 자동 생성, GitHub Actions로 누락까지 잡기 (0) | 2026.09.11 |
| iOS 빌드 인프라, 어디서 돌릴까 — 6가지 옵션 비교와 self-hosted runner 구축기 (0) | 2026.06.05 |
| 웹 모노레포 CI/CD 개편기 — 라벨 배포에서 태그 배포로 (0) | 2026.04.17 |