현재 저희 서비스의 DEV 환경은 다음과 같이 구성되어 있습니다.
- 오케스트레이션: AWS EKS 위에 Spring Boot 애플리케이션을 파드로 배포
- 배포: ArgoCD(GitOps)로 매니페스트를 동기화, 갱신은 기본 전략인 롤링 업데이트(rolling update)
- 트래픽 인입: ALB Ingress + AWS Load Balancer Controller, 타겟 모드는
target-type: ip - 헬스 체크: 쿠버네티스
readinessProbe와 ALB 자체 health check가 각각 동작
즉 트래픽은 사용자 → ALB → (파드 IP) → Spring Boot 파드 경로로 흐르고, 배포는 ArgoCD가 새 파드를 띄우고 구 파드를 내리는 식으로 진행됩니다.
ArgoCD에서는 모든 파드가 Healthy로 표시되는데, 배포 직후 API를 호출하면 간헐적으로 503 응답을 마주했습니다.
1. ArgoCD 배포 상태와 ALB 헬스 체크의 불일치 현상
저희는 ALB Ingress를 target-type: ip로 쓰고 있었습니다.
alb.ingress.kubernetes.io/target-type: ip이 설정에서 ALB는 노드(NodePort)를 거치지 않고 파드 IP에 직접 트래픽을 전달합니다. 이때 ALB의 판정 단위는 파드 하나하나가 됩니다. 새로 뜬 파드는 kubelet readinessProbe와 ALB 자체 health check를 각각 통과해야 트래픽을 받는데, 둘은 서로의 판정 결과를 주고받지 않습니다.
| 항목 | 쿠버네티스 | AWS ALB |
|---|---|---|
| healthy 판단 주체 | kubelet | ALB (보통 다른 AZ에서) |
| 통과 조건 | readinessProbe 1회 200 |
health check healthy_threshold 연속 200 |
| ArgoCD 가시성 | 보임 | 안 보임 |
kubelet은 자기 노드의 파드를 보고 readinessProbe가 한 번 200을 주면 곧장 Ready로 표시합니다. 그러면 K8s Endpoints에 파드 IP가 올라가고, ArgoCD는 이를 보고 Healthy를 표시합니다.
실제로 트래픽을 전달하는 주체는 ALB입니다. ALB는 ArgoCD가 보는 K8s 상태를 알지 못합니다. 자기만의 health check를 연속으로 통과시켜 healthy라고 판단하기 전까지는 그 파드에 요청을 보내지 않습니다.
즉 ArgoCD가 보는 healthy와 ALB가 보는 healthy는 같은 단어지만 서로 다른 상태를 가리킵니다. 이 둘이 어긋나는 순간이 배포 시점이었습니다.
참고:
target-type: instance였다면 ALB 타겟은 노드(NodePort)이므로, 새 파드가 떠도 ALB에 따로 등록할 필요 없이 라우팅은 kube-proxy가 맡습니다. 아래에서 볼 파드 단위 등록 지연이 애초에 생기지 않습니다. 대신 노드 한 단계를 더 거치는 트레이드오프가 있습니다.
2. 503은 코드 버그가 아니다: ALB가 직접 뱉는 신호
원인을 추적하기 전에 503의 의미부터 짚어야 했습니다. ALB는 백엔드(타겟)까지 도달하지 못하면 자기가 직접 상태 코드를 생성하기 때문입니다.
503(Service Unavailable)은 타겟 그룹에 healthy 타겟이 0일 때, ALB가 어디에도 요청을 보내지 못하고 자기 선에서 생성해 돌려주는 코드입니다. 즉 503은 코드가 실패했다는 뜻이 아니라 요청을 보낼 곳이 없다는 신호입니다.
따라서 ArgoCD가 Healthy인데도 503이 떴다는 것은, ALB 관점에서는 healthy 타겟이 0인 순간이 있었다는 의미입니다.
참고: 같은 5xx라도 502(Bad Gateway, upstream이 RST를 보낸 계열)·504(Gateway Timeout, upstream 응답 지연)는 요청을 보냈으나 실패한 쪽이라 원인이 다릅니다. 이번 건은 타겟이 0이라 아예 보내지 못한 503이었습니다.
3. 진짜 원인: Registration Lag
원인은 새 파드가 ALB에 등록되어 healthy가 되기까지의 지연(registration lag) 이었습니다. 배포 시점을 초 단위로 풀어보면 간극이 그대로 드러납니다.
t=0s : 새 Pod 생성
t=120s : kubelet readinessProbe 통과 → K8s Endpoints에 IP 추가
→ ArgoCD는 여기서 'Healthy' 표기 ✓
t=121s : AWS LB Controller가 ALB.RegisterTargets 호출
→ target state: 'initial'
t=121~270s : ALB 자체 health check 진행
(30초 간격 × healthy_threshold=5)
▶ 이 구간에 요청이 오면 503 (healthy target = 0)
t=270s : ALB target state: 'healthy' ← 이제야 트래픽 받을 준비 완료readinessProbe가 통과한 t=120s와 ALB가 healthy로 인정한 t=270s 사이에 약 150초의 빈 구간이 있습니다. 이 150초 동안 ArgoCD는 Healthy를 표시하지만, 롤링 업데이트가 같은 타이밍에 기존 파드를 종료시키면, ALB 입장에서는 트래픽을 받을 healthy 타겟이 0이 됩니다.
쿠버네티스의 readinessProbe는 앱 프로세스가 떴다는 것까지만 보장합니다. 그것을 곧바로 트래픽 받을 준비가 끝났다는 의미로 받아들여 구 파드를 내린 것이, ArgoCD는 Healthy인데 사용자는 503을 보는 상황의 원인이었습니다.
4. 해결: 두 Control Plane을 K8s readiness로 묶는다
원인은 한 줄로 정리됩니다. kubelet이 Ready라고 해서 ALB도 healthy로 인식하는 것은 아니다. 따라서 해법도 명확합니다. ALB가 healthy라고 인정하기 전까지는 쿠버네티스도 파드를 Ready로 표시하지 않게 만들어, 두 health 판정을 한 지점에서 일치시키면 됩니다.
이걸 그대로 구현한 게 AWS Load Balancer Controller의 Pod Readiness Gate입니다.
spec:
readinessGates:
- conditionType: target-health.elbv2.k8s.aws/<ingress>_<service>_<port>이 게이트가 붙으면 파드는 readinessProbe를 통과해도 ALB target이 healthy가 될 때까지 Ready로 마킹되지 않습니다. 즉 3번에서 본 150초의 간극 동안 롤아웃이 전진하지 못하므로, 기존 파드도 그대로 유지됩니다. healthy 타겟이 0이 되는 구간 자체가 사라집니다. (네임스페이스에 elbv2.k8s.aws/pod-readiness-gate-inject=enabled 라벨을 붙이면 컨트롤러 웹훅이 이 게이트를 자동 주입합니다.)
처방을 한 장으로 정리하면 이렇습니다.
| 문제 | 처방 | 원리 |
|---|---|---|
| Registration lag (503) | Pod Readiness Gate | 두 health 판정을 K8s readiness에서 일치 |
| 단일 replica | replicas ≥ 2 |
가용성 하한 확보 |
| Probe 모호 | readiness/liveness 분리 | health 정의를 layer별로 분리 |
실제로 적용한 매니페스트는 다음과 같습니다.
spec:
replicas: 2
template:
spec:
containers:
- name: api-admin
startupProbe:
httpGet: { path: /actuator/health/liveness, port: 8080 }
failureThreshold: 30
periodSeconds: 5
readinessProbe:
httpGet: { path: /actuator/health/readiness, port: 8080 }
periodSeconds: 5
failureThreshold: 2
livenessProbe:
httpGet: { path: /actuator/health/liveness, port: 8080 }
periodSeconds: 30
---
# Ingress annotations
alb.ingress.kubernetes.io/healthcheck-path: /actuator/health/readiness
alb.ingress.kubernetes.io/healthcheck-interval-seconds: '10'
alb.ingress.kubernetes.io/healthy-threshold-count: '2'health check 간격을 10초 × threshold 2로 줄여 등록 지연 자체를 짧게 만들었습니다(150초 → 약 20초). probe를 /actuator/health/readiness와 /liveness로 분리한 것도 핵심입니다. 살아 있다(liveness)와 트래픽을 받을 수 있다(readiness)는 서로 다른 판정이기 때문입니다. 거기에 replicas: 2로 두어, 롤링 중 한 파드가 교체되는 순간에도 healthy 타겟이 0으로 떨어지지 않도록 가용성 하한을 확보했습니다.
5. 마무리
쿠버네티스(kubelet)와 ALB라는 두 개의 Control Plane이 서로 다른 시점에 'healthy'를 판단하고, 그 propagation delay가 사용자에게 503으로 드러난 것이었습니다. ArgoCD가 Healthy를 표시하는 시점과 ALB가 트래픽을 전달해도 된다고 판단하는 시점은 처음부터 일치하지 않았습니다.
어떤 컴포넌트가 'healthy'라고 할 때 그것이 누구의 관점인지 확인하지 않으면, production에서 503을 만날 수 있습니다.
플랫폼이 무중단 배포를 보장해 줄 것 같지만, 서로 다른 두 시스템이 맞물리는 경계에는 이런 시차가 존재합니다. 그 시차를 명시적으로 견디도록 Pod Readiness Gate로 두 plane을 한 지점에 묶는 것이 이번 503을 메운 처방이었습니다.