- Published on
EKS에서 NLB 타겟 Unhealthy - 헬스체크·Pod·SG
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버는 살아 있는데 NLB Target Group이 계속 Unhealthy로 표시되면, 문제는 대개 헬스체크가 도달하지 못하거나(네트워크/SG/NACL), 도달은 하는데 애플리케이션이 준비되지 않았거나(ready/port/path), NLB가 바라보는 타겟(노드/Pod/IP)이 기대와 다르게 구성된 경우입니다.
이 글은 EKS에서 NLB를 쓸 때(특히 AWS Load Balancer Controller 기반) Unhealthy를 빠르게 수습하기 위한 우선순위 높은 체크리스트와, 각 단계에서 무엇을 확인해야 하는지 명령어/매니페스트 예시로 정리합니다.
> 네트워크 계층 이슈가 얽히면 증상이 비슷하게 나타납니다. Pod DNS는 되는데 HTTPS만 실패하는 케이스처럼, “일부만 된다”가 힌트인 경우도 많습니다. 관련 점검은 EKS Pod DNS는 되는데 HTTPS만 실패할 때 점검도 함께 참고하면 좋습니다.
1) 먼저 구조부터: NLB 타겟 타입과 트래픽 경로
NLB가 무엇을 타겟으로 보느냐에 따라, Unhealthy의 원인 지점이 달라집니다.
- Target type = instance: NLB → (노드 ENI) → NodePort → kube-proxy → Pod
- 흔한 문제: NodePort 미오픈, 노드 SG 인바운드 누락, externalTrafficPolicy/헬스체크 포트 불일치
- Target type = ip: NLB → Pod IP(또는 엔드포인트 IP)
- 흔한 문제: Pod SG(보안 그룹 for Pods) 미설정/미허용, 서브넷 라우팅, NACL, Pod가 readiness 전
AWS Load Balancer Controller(이하 LBC)를 쓴다면 Service/Ingress의 annotation으로 target-type이 결정됩니다.
kubectl -n <ns> get svc <svc-name> -o yaml | yq '.metadata.annotations'
대표 annotation:
service.beta.kubernetes.io/aws-load-balancer-type: "nlb"
service.beta.kubernetes.io/aws-load-balancer-nlb-target-type: "ip" # 또는 "instance"
2) NLB 헬스체크가 “어디로, 어떻게” 가는지 확인
Unhealthy의 1차 원인은 헬스체크 설정과 실제 리스너/백엔드 포트가 어긋난 것입니다. NLB는 기본적으로 TCP 헬스체크를 많이 쓰지만, HTTP/HTTPS 헬스체크도 구성할 수 있습니다.
2-1. Target Group 헬스체크 설정 확인
aws elbv2 describe-target-groups --names <tg-name>
aws elbv2 describe-target-health --target-group-arn <tg-arn>
여기서 확인할 것:
HealthCheckProtocol(TCP/HTTP/HTTPS)HealthCheckPort(traffic-port인지, 특정 포트인지)HealthCheckPath(HTTP/HTTPS일 때)Matcher(HTTP 코드 범위)
특히 traffic-port로 되어 있는데 실제 서비스는 다른 포트에서만 열려 있거나, 반대로 헬스체크 포트를 고정했는데 NodePort/PodPort가 달라지는 경우가 흔합니다.
2-2. Kubernetes Service 포트/targetPort/NodePort 정합성
kubectl -n <ns> get svc <svc-name> -o wide
kubectl -n <ns> describe svc <svc-name>
예를 들어 다음과 같은 Service가 있다고 합시다.
apiVersion: v1
kind: Service
metadata:
name: api
namespace: app
annotations:
service.beta.kubernetes.io/aws-load-balancer-type: "nlb"
service.beta.kubernetes.io/aws-load-balancer-nlb-target-type: "instance"
spec:
type: LoadBalancer
ports:
- name: http
port: 80
targetPort: 8080
selector:
app: api
instance타겟이면 NLB는 노드의 NodePort로 붙습니다(컨트롤러가 NodePort를 할당).- 이때 헬스체크가 NodePort로 들어오는데, 노드 SG에서 NodePort 대역(기본 30000-32767)이 막혀 있으면 바로 Unhealthy가 됩니다.
반대로 ip 타겟이면 NLB가 Pod IP:targetPort로 직접 붙을 수 있어, SG/NACL/라우팅이 핵심이 됩니다.
3) Pod 레벨: readiness/liveness와 “헬스체크 엔드포인트”
NLB 헬스체크가 네트워크적으로는 도달하는데도 Unhealthy라면, 다음을 의심해야 합니다.
- 애플리케이션이 실제로 해당 포트에 바인딩하지 않음(0.0.0.0 vs 127.0.0.1)
- readinessProbe가 실패해서 Endpoint에서 빠짐
- 헬스체크 경로가 301/302/401/403 등으로 응답
- 애플리케이션이 느려서 timeout
3-1. Endpoints/EndpointSlice에 Pod가 들어있는지
Service selector가 맞아도 readiness가 false면 Endpoint에서 제외됩니다.
kubectl -n <ns> get endpoints <svc-name> -o yaml
kubectl -n <ns> get endpointslices -l kubernetes.io/service-name=<svc-name>
Endpoints가 비어 있으면 NLB는 타겟을 정상으로 만들 수 없습니다(혹은 타겟 등록 자체가 이상해집니다).
3-2. Pod가 실제로 포트를 열고 있는지(바인딩 주소 포함)
Pod 내부에서 확인:
kubectl -n <ns> exec -it deploy/api -- sh -lc 'netstat -tnlp || ss -tnlp'
kubectl -n <ns> exec -it deploy/api -- sh -lc 'curl -sv http://127.0.0.1:8080/healthz'
kubectl -n <ns> exec -it deploy/api -- sh -lc 'curl -sv http://0.0.0.0:8080/healthz || true'
컨테이너가 127.0.0.1에만 바인딩하면 **Pod IP로 접근하는 NLB(ip target)**에서 실패합니다. 반드시 0.0.0.0 바인딩이 필요합니다.
3-3. readinessProbe와 헬스체크 경로를 맞추기
권장 패턴은 NLB 헬스체크와 readinessProbe를 동일한 엔드포인트로 맞추는 것입니다.
readinessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 3
livenessProbe:
httpGet:
path: /livez
port: 8080
initialDelaySeconds: 20
periodSeconds: 10
/healthz는 의존성(DB/캐시)까지 포함해 “트래픽 받아도 됨”을 의미/livez는 프로세스 생존만 의미
헬스체크가 /로 되어 있고 앱이 /에서 301 리다이렉트(예: /docs로)하거나 401을 반환하면, NLB가 Unhealthy로 판단할 수 있습니다. HTTP 헬스체크를 쓰면 **성공 코드(Matcher)**도 확인하세요.
4) Security Group: NLB → 노드/Pod로 들어오는 인바운드
Unhealthy의 2대 원인은 보안그룹입니다. 특히 instance 타겟이면 노드 SG, ip 타겟이면 **Pod SG(또는 노드 SG + Pod SG 조합)**가 관건입니다.
4-1. instance 타겟: 노드 SG에서 NodePort 허용
NLB는 클라이언트 트래픽/헬스체크를 노드의 NodePort로 보냅니다.
- 노드 SG 인바운드에 **NodePort 대역(30000-32767)**을 NLB 소스(대개 NLB의 SG가 아니라, NLB가 붙은 서브넷 CIDR 또는 AWS-managed 방식)로 허용해야 하는 상황이 생길 수 있습니다.
- 현실적으로는 LBC가 적절히 SG 규칙을 만들도록 유도하거나, 운영 정책에 맞게 명시적으로 열어야 합니다.
점검:
kubectl -n kube-system logs deploy/aws-load-balancer-controller | grep -i securitygroup
4-2. ip 타겟: Security Groups for Pods 사용 여부
ip 타겟일 때 Pod IP로 직접 트래픽이 들어오면, 다음 중 하나입니다.
- Pod가 노드 SG의 보호를 받는 구조(기본 VPC CNI 설정)
- Security Groups for Pods를 써서 Pod에 별도 SG가 붙는 구조
후자라면 Pod SG 인바운드에 NLB에서 오는 포트가 열려 있어야 합니다. 또한 Pod가 있는 서브넷/라우팅/네트워크 정책까지 함께 봐야 합니다.
5) NACL/서브넷/라우팅: “헬스체크는 TCP인데 왜 안 되지?”
SG가 맞는데도 Unhealthy라면, NACL이 왕복 트래픽을 막는 경우가 있습니다.
- NACL은 stateless라서 인바운드만 열어도 아웃바운드가 막히면 실패합니다.
- 헬스체크/트래픽의 ephemeral port(임시 포트) 반환이 막히는 케이스가 빈번합니다.
점검 포인트:
- NLB가 붙은 서브넷 NACL
- 워커 노드(또는 Pod ENI)가 있는 서브넷 NACL
- 인바운드/아웃바운드 모두에서 필요한 포트 허용
네트워크가 애매하게 “DNS는 되는데 HTTPS만 안 됨” 같은 형태로 보이면 MTU/SNAT/NACL까지 확장 점검이 필요합니다. 이때는 위에서 언급한 내부 글(EKS Pod DNS는 되는데 HTTPS만 실패할 때 점검)의 흐름이 그대로 도움이 됩니다.
6) externalTrafficPolicy와 헬스체크(특히 instance 타겟)
type: LoadBalancer Service에서 다음 옵션은 헬스체크/트래픽 경로에 영향을 줍니다.
externalTrafficPolicy: Cluster(기본)- 어떤 노드로 들어와도 kube-proxy가 다른 노드의 Pod로 라우팅 가능
externalTrafficPolicy: Local- Pod가 있는 노드만 트래픽 처리
- Pod 없는 노드는 헬스체크에 실패할 수 있음
예:
spec:
externalTrafficPolicy: Local
Local을 쓰는 이유(클라이언트 IP 보존 등)가 명확하지 않다면, Unhealthy 트러블슈팅 중에는 Cluster로 바꿔 현상을 단순화하는 것도 방법입니다.
7) AWS Load Balancer Controller 이벤트/로그로 “정답”에 가까워지기
LBC는 타겟 등록/헬스체크/보안그룹 룰 구성에서 많은 힌트를 줍니다.
7-1. Service 이벤트 확인
kubectl -n <ns> describe svc <svc-name>
하단 Events에서 다음을 봅니다.
- 타겟 그룹 생성/수정 실패
- 보안 그룹 규칙 생성 실패(권한/IAM)
- 서브넷 태그/선택 문제
7-2. 컨트롤러 로그에서 에러 키워드 검색
kubectl -n kube-system logs deploy/aws-load-balancer-controller --since=30m | \
egrep -i 'error|targetgroup|health|security|subnet|accessdenied'
IAM 권한이 부족하면 SG 룰을 못 만들고, 결과적으로 헬스체크가 실패할 수 있습니다.
8) 빠른 재현/검증: 같은 서브넷에서 직접 curl로 찍어보기
헬스체크는 결국 네트워크 연결성 문제로 귀결될 때가 많습니다. 가장 빠른 검증은 NLB와 같은 VPC/서브넷 경로에서 백엔드로 직접 접근해 보는 것입니다.
- 노드로 SSH(또는 SSM) 접속 가능하면 NodePort/Pod IP로 curl
- 혹은 디버그용 Pod 띄워서 Service/POD로 curl
디버그 Pod 예시:
kubectl -n app run netshoot --rm -it --image=nicolaka/netshoot -- bash
# 내부에서
curl -sv http://api.app.svc.cluster.local:80/healthz
curl -sv http://<pod-ip>:8080/healthz
이 단계에서 내부 통신이 되는데도 NLB만 Unhealthy면, SG/NACL/서브넷 라우팅 또는 타겟 타입/포트 정합성 쪽으로 다시 좁혀집니다.
9) 자주 나오는 원인 TOP 7 (현장 체감 순)
- 헬스체크 포트가 틀림: traffic-port vs 고정 포트 혼동, NodePort/targetPort 불일치
- 헬스체크 경로/응답코드 문제: 301/401/403/500, matcher 불일치
- 컨테이너가 127.0.0.1에만 바인딩: ip 타겟에서 100% 실패
- readinessProbe 실패로 Endpoint에 Pod가 없음
- 노드 SG에 NodePort 인바운드 누락(instance 타겟)
- Pod SG/노드 SG 인바운드 누락(ip 타겟, SG for Pods)
- NACL/ephemeral port 반환 차단: “가끔 됨/특정 포트만 안 됨” 패턴
10) 정리: 추천 트러블슈팅 순서(시간 절약)
Target type 확인(
ipvsinstance) → 트래픽 경로를 머릿속에 그린다Target Group 헬스체크 설정 확인(프로토콜/포트/path/matcher)
K8s Service 포트 정합성 확인(port/targetPort/NodePort)
Endpoints/EndpointSlice가 비었는지 확인(readiness 영향)
Pod 내부에서 실제로 포트가 열렸는지 확인(0.0.0.0 바인딩)
SG 점검
- instance: 노드 SG에 NodePort 허용
- ip: Pod SG/노드 SG에 targetPort 허용
- NACL/서브넷 라우팅 확인(stateless, ephemeral port)
이 순서대로 보면, “헬스체크는 가는데 왜 Unhealthy지?” 같은 막막함이 구체적인 실패 지점으로 쪼개집니다. 다음 단계로는 실제 운영 환경(Service/Ingress YAML, TG 설정, SG/NACL 스냅샷)을 기준으로 체크리스트를 자동화(예: 스크립트로 describe 결과 비교)하면, 재발 시 복구 시간이 크게 줄어듭니다.