Published on

EKS에서 NLB 타겟 Unhealthy - 헬스체크·Pod·SG

Authors

서버는 살아 있는데 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 (현장 체감 순)

  1. 헬스체크 포트가 틀림: traffic-port vs 고정 포트 혼동, NodePort/targetPort 불일치
  2. 헬스체크 경로/응답코드 문제: 301/401/403/500, matcher 불일치
  3. 컨테이너가 127.0.0.1에만 바인딩: ip 타겟에서 100% 실패
  4. readinessProbe 실패로 Endpoint에 Pod가 없음
  5. 노드 SG에 NodePort 인바운드 누락(instance 타겟)
  6. Pod SG/노드 SG 인바운드 누락(ip 타겟, SG for Pods)
  7. NACL/ephemeral port 반환 차단: “가끔 됨/특정 포트만 안 됨” 패턴

10) 정리: 추천 트러블슈팅 순서(시간 절약)

  1. Target type 확인(ip vs instance) → 트래픽 경로를 머릿속에 그린다

  2. Target Group 헬스체크 설정 확인(프로토콜/포트/path/matcher)

  3. K8s Service 포트 정합성 확인(port/targetPort/NodePort)

  4. Endpoints/EndpointSlice가 비었는지 확인(readiness 영향)

  5. Pod 내부에서 실제로 포트가 열렸는지 확인(0.0.0.0 바인딩)

  6. SG 점검

  • instance: 노드 SG에 NodePort 허용
  • ip: Pod SG/노드 SG에 targetPort 허용
  1. NACL/서브넷 라우팅 확인(stateless, ephemeral port)

이 순서대로 보면, “헬스체크는 가는데 왜 Unhealthy지?” 같은 막막함이 구체적인 실패 지점으로 쪼개집니다. 다음 단계로는 실제 운영 환경(Service/Ingress YAML, TG 설정, SG/NACL 스냅샷)을 기준으로 체크리스트를 자동화(예: 스크립트로 describe 결과 비교)하면, 재발 시 복구 시간이 크게 줄어듭니다.