DNS는 풀리는데 트래픽은 어디로 사라졌나
1편에서 CoreDNS 이름 해석을 정리했고, 2편에서 NetworkPolicy 차단이 아니라는 것도 확인했습니다. 그런데도 애플리케이션 파드에서 curl http://my-svc:8080을 때리면 여전히 connection refused가 돌아옵니다. kubectl describe svc my-svc를 보니 답이 한 줄에 있습니다.
Endpoints: <none>이 시리즈가 다루는 계층은 세 겹입니다. 이름 해석(1편) → 정책 차단(2편) → Service와 Pod의 결합(3편). 이번 편은 세 번째, 즉 이름은 풀리고 정책도 열려 있는데 "보낼 곳 자체가 존재하지 않는" 상태를 다룹니다.
트래픽 경로를 텍스트로 펼치면 이렇습니다.
Client Pod
→ DNS 조회 (my-svc.default.svc.cluster.local → 10.96.x.x) [1편 영역]
→ ClusterIP 10.96.x.x:8080
→ kube-proxy가 설치한 DNAT 룰 (iptables / IPVS / nftables)
→ EndpointSlice에 등록된 Pod IP:targetPort 목록
→ Pod IP 10.244.x.x:8080 [4편 영역]Endpoints: <none>은 위 흐름에서 네 번째 줄이 빈 배열이라는 뜻입니다. 컨트롤 플레인의 endpointslice controller는 Service의 selector에 매칭되고 Ready 상태인 파드를 찾아 EndpointSlice를 채우는데, 그 결과가 0건이면 kube-proxy는 전달할 대상이 없다는 사실을 데이터플레인에 반영합니다.
애플리케이션 로그에 찍히는 no endpoints available for service "default/my-svc"도 사실상 같은 신호입니다. 이 문구는 API 서버의 프록시 경로나 Ingress 컨트롤러가 백엔드 목록을 조회했을 때 비어 있음을 알리는 메시지이고, 원인 계층은 Endpoints: <none>과 완전히 동일합니다.
여기서 첫 갈림길이 하나 생깁니다. connection refused냐 timeout이냐입니다.
| 증상 | kube-proxy 동작 | 시사점 |
|---|---|---|
즉시 connection refused | 백엔드가 0건이라 REJECT 룰이 설치됨(iptables 기본 동작) | Endpoints가 비었을 확률이 높음 |
수 초~수십 초 후 timeout | DNAT는 됐지만 패킷이 응답 없이 사라짐 | Endpoints는 채워졌고, CNI·정책·앱 미응답 쪽 |
즉 connection refused가 즉시 떨어진다면 이 글의 진단표부터 보면 되고, timeout이라면 3장 후반과 4장으로 바로 건너뛰는 편이 빠릅니다. 참고로 증상이 겹치는 케이스는 kubectl get endpoints <none>·Service connection refused 5분 진단에서 기본 흐름을 다뤘고, 이번 글은 원인 7종 분해와 버전별 함정, 자동화 게이트에 무게를 둡니다.
적용 범위는 Kubernetes 1.21 이상(EndpointSlice 기본 활성화 이후), kubectl 1.25 이상, kube-proxy iptables/IPVS/nftables 모드 전부입니다.
30초 1차 진단: 명령 6줄로 범위 좁히기
장애 상황에서는 생각하지 말고 위에서부터 순서대로 치는 편이 빠릅니다. 서비스명과 네임스페이스만 바꿔 그대로 복사하세요.
SVC=my-svc
NS=default
# 1) 레거시 Endpoints 객체 확인 (가장 빠른 신호)
kubectl -n "$NS" get endpoints "$SVC"
# 2) EndpointSlice 확인 (1.21+ 실제 소스 오브 트루스)
kubectl -n "$NS" get endpointslices -l "kubernetes.io/service-name=$SVC" -o wide
# 3) Service 정의와 이벤트
kubectl -n "$NS" describe svc "$SVC"
# 4) Service selector 원문
kubectl -n "$NS" get svc "$SVC" -o jsonpath='{.spec.selector}{"\n"}'
# 5) 파드 라벨 전량 확인
kubectl -n "$NS" get pods --show-labels
# 6) selector로 실제 매칭되는 파드 수
kubectl -n "$NS" get pods -l "$(kubectl -n "$NS" get svc "$SVC" -o jsonpath='{range .spec.selector.*}{"\n"}{end}' >/dev/null; kubectl -n "$NS" get svc "$SVC" -o jsonpath='{.spec.selector}' | tr -d '{}"' | tr ',' ',')"예상 정상 결과는 이렇습니다.
NAME ENDPOINTS AGE
my-svc 10.244.1.7:8080,10.244.2.9:8080 12dENDPOINTS 칸에 <none>이 찍혔다면 확정입니다. 2번 명령의 EndpointSlice가 아예 존재하지 않거나 ENDPOINTS 컬럼이 비었다면 같은 결론입니다.
계층 분리: Pod IP 직접 curl로 문제를 양분한다
여기서 가장 중요한 판단은 "앱이 죽은 건가, Service 결합이 끊긴 건가, 노드 간 통신이 막힌 건가"입니다. netshoot 임시 파드 하나면 30초 안에 갈립니다.
kubectl -n default run tmp-netshoot --rm -it --restart=Never \
--image=nicolaka/netshoot -- /bin/bash파드 셸 안에서 세 계층을 순서대로 때립니다.
# ① Pod IP 직접 (Service를 건너뜀)
curl -sS -m 3 -o /dev/null -w "podip:%{http_code}\n" http://10.244.1.7:8080/
# ② ClusterIP
curl -sS -m 3 -o /dev/null -w "clusterip:%{http_code}\n" http://10.96.30.11:8080/
# ③ NodePort (해당 서비스가 NodePort 타입일 때)
curl -sS -m 3 -o /dev/null -w "nodeport:%{http_code}\n" http://192.168.10.21:30080/결과 조합으로 바로 범위가 잘립니다.
| ① Pod IP | ② ClusterIP | ③ NodePort | 판정 | 다음 행동 |
|---|---|---|---|---|
| 성공 | 실패 | 실패 | Service ↔ Pod 결합 문제 | 3장 판정표 (a)~(g) 순회 |
| 실패 | 실패 | 실패 | 애플리케이션·컨테이너 포트 문제 | 컨테이너 로그와 ss -lntp로 리슨 포트 확인 |
| 성공 | 성공 | 실패 | 노드 외부 진입·externalTrafficPolicy 문제 | 4장 externalTrafficPolicy: Local 절 |
| 성공 | 간헐 실패 | 간헐 실패 | 일부 백엔드만 비정상 또는 노드 간 통신 문제 | 4장 kube-proxy 룰 확인 후 4편(CNI) |
ss -lntp 확인은 대상 컨테이너 안에서 이렇게 합니다.
kubectl -n default exec -it deploy/web -- sh -c "ss -lntp || netstat -lntp"정상이라면 LISTEN 0 128 0.0.0.0:8080 같은 줄이 보여야 합니다. 127.0.0.1:8080만 보인다면 앱이 루프백에만 바인딩된 것이고, 이 경우 Endpoints가 채워져도 트래픽은 실패합니다.
한 화면에 뽑는 통합 진단 스크립트
반복 장애 대응용으로 파일 하나 만들어 두면 편합니다.
#!/usr/bin/env bash
# svc-diag.sh — Service/Endpoint 결합 상태 일괄 점검
# usage: ./svc-diag.sh <service-name> [namespace]
set -euo pipefail
SVC="${1:?service name required}"
NS="${2:-default}"
line() { printf '\n=== %s ===\n' "$1"; }
line "Service spec"
kubectl -n "$NS" get svc "$SVC" -o yaml | grep -E 'clusterIP:|type:|externalTrafficPolicy:|publishNotReadyAddresses:' || true
line "Selector"
SELECTOR=$(kubectl -n "$NS" get svc "$SVC" -o jsonpath='{.spec.selector}')
echo "raw: ${SELECTOR:-<empty>}"
line "Ports (port -> targetPort)"
kubectl -n "$NS" get svc "$SVC" \
-o jsonpath='{range .spec.ports[*]}{.name}{" "}{.port}{" -> "}{.targetPort}{"\n"}{end}'
line "Endpoints (legacy)"
kubectl -n "$NS" get endpoints "$SVC" -o wide || echo "no endpoints object"
line "EndpointSlices"
kubectl -n "$NS" get endpointslices -l "kubernetes.io/service-name=$SVC" -o wide || true
line "EndpointSlice ready conditions"
kubectl -n "$NS" get endpointslices -l "kubernetes.io/service-name=$SVC" \
-o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{range .endpoints[*]}{.addresses[0]}{"=ready:"}{.conditions.ready}{" "}{end}{"\n"}{end}' || true
line "Matching pods"
SEL_KV=$(kubectl -n "$NS" get svc "$SVC" \
-o jsonpath='{range .spec.selector}{@}{end}' | tr -d '{}"' )
if [ -n "$SEL_KV" ]; then
kubectl -n "$NS" get pods -l "$SEL_KV" -o wide || true
else
echo "selector is empty (selector-less Service: manual EndpointSlice required)"
fi
line "All pod labels in namespace"
kubectl -n "$NS" get pods --show-labels
line "Recent events"
kubectl -n "$NS" get events --sort-by=.lastTimestamp | tail -20chmod +x svc-diag.sh
./svc-diag.sh my-svc defaultMatching pods 섹션이 No resources found로 나오면 원인은 거의 확정적으로 (a) selector 불일치입니다. 파드는 나오는데 EndpointSlice가 비었다면 (c) Readiness 계열입니다.
원인별 판정표: 7가지 케이스와 1줄 복구
먼저 전체 지도를 봅니다.
| # | 증상 | 원인 | 확정 명령 | 복구 1줄 |
|---|---|---|---|---|
| a | Endpoints <none>, selector로 조회 시 0건 | selector 라벨 오타·불일치 | kubectl get pods -l app=web | kubectl patch svc my-svc -p '{"spec":{"selector":{"app":"web-api"}}}' |
| b | Endpoints는 있는데 연결 실패 또는 <none> | targetPort ↔ containerPort 불일치, named port 미정의 | kubectl get svc my-svc -o jsonpath='{.spec.ports[*].targetPort}' | kubectl patch svc my-svc --type=json -p '[{"op":"replace","path":"/spec/ports/0/targetPort","value":8080}]' |
| c | 파드는 Running인데 EndpointSlice ready=false | Readiness probe 실패로 NotReady | kubectl get pods -o wide의 READY 컬럼 + kubectl describe pod | kubectl patch deploy web --type=json -p '[{"op":"replace","path":"/spec/template/spec/containers/0/readinessProbe/httpGet/path","value":"/healthz"}]' |
| d | 다른 네임스페이스에서만 접속 실패 | Service는 네임스페이스를 넘지 않음 | kubectl get pods -A -l app=web | curl http://my-svc.other-ns.svc.cluster.local:8080 로 FQDN 사용 |
| e | Endpoints: <none>이지만 DNS는 파드 IP 다수 응답 | Headless Service(clusterIP: None) 오설정 | kubectl get svc my-svc -o jsonpath='{.spec.clusterIP}' | Service를 삭제 후 clusterIP: None 제거한 매니페스트로 재적용 |
| f | selector가 비어 있고 EndpointSlice도 없음 | selector-less Service에 수동 EndpointSlice 누락 | kubectl get svc my-svc -o jsonpath='{.spec.selector}' 결과 공백 | 수동 EndpointSlice YAML 적용(아래 예시) |
| g | 일부 파드만 등록·특정 노드에서만 실패 | hostNetwork 파드의 포트 충돌 | kubectl get pods -o wide에서 동일 노드 중복 확인 | kubectl patch deploy web -p '{"spec":{"template":{"spec":{"affinity":{"podAntiAffinity":{"requiredDuringSchedulingIgnoredDuringExecution":[{"labelSelector":{"matchLabels":{"app":"web"}},"topologyKey":"kubernetes.io/hostname"}]}}}}}}' |
(a) selector 라벨 오타·불일치
가장 흔한 케이스입니다. Deployment 템플릿 라벨은 app: web-api인데 Service selector는 app: web으로 적혀 있는 상황입니다.
# 잘못된 버전
apiVersion: v1
kind: Service
metadata:
name: my-svc
namespace: default
spec:
selector:
app: web # 파드 라벨은 web-api
ports:
- port: 8080
targetPort: 8080# 수정 버전
apiVersion: v1
kind: Service
metadata:
name: my-svc
namespace: default
spec:
selector:
app: web-api
ports:
- name: http
port: 8080
targetPort: 8080
protocol: TCP확정은 아래 한 줄로 끝납니다.
kubectl -n default get pods -l app=webNo resources found in default namespace.가 나오면 selector가 아무것도 잡지 못한다는 뜻입니다. 주의할 점은 Service selector가 AND 조건이라는 것입니다. app: web과 tier: backend를 함께 적으면 두 라벨을 모두 가진 파드만 매칭됩니다. 파드에 tier 라벨이 없으면 0건이 됩니다.
(b) targetPort ↔ containerPort 불일치, named port 미정의
named port를 쓰면 가독성은 좋아지지만, 컨테이너 쪽에 이름이 정의되어 있지 않으면 EndpointSlice의 포트가 비거나 잘못된 값으로 채워집니다.
# 잘못된 버전: Service는 http라는 이름을 찾는데 컨테이너에 이름이 없음
apiVersion: v1
kind: Service
metadata:
name: my-svc
spec:
selector:
app: web-api
ports:
- port: 8080
targetPort: http
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 2
selector:
matchLabels:
app: web-api
template:
metadata:
labels:
app: web-api
spec:
containers:
- name: app
image: nginx:1.27
ports:
- containerPort: 8080 # name 지정 누락# 수정 버전: 컨테이너 포트에 name: http 정의
apiVersion: v1
kind: Service
metadata:
name: my-svc
spec:
selector:
app: web-api
ports:
- name: http
port: 8080
targetPort: http
protocol: TCP
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 2
selector:
matchLabels:
app: web-api
template:
metadata:
labels:
app: web-api
spec:
containers:
- name: app
image: nginx:1.27
ports:
- name: http
containerPort: 8080
protocol: TCP확인 명령과 예상 결과입니다.
kubectl -n default get endpointslices -l kubernetes.io/service-name=my-svc \
-o jsonpath='{range .items[*]}{range .ports[*]}{.name}{":"}{.port}{"\n"}{end}{end}'정상이면 http:8080처럼 실제 숫자가 찍힙니다. 이름만 있고 포트가 비어 있다면 (b)가 확정입니다.
(c) Readiness probe 실패로 NotReady
파드는 Running인데 READY 0/1이라면 endpointslice controller가 conditions.ready: false로 표시하고, kube-proxy는 해당 주소를 서비스 백엔드에서 제외합니다.
kubectl -n default get pods -l app=web-api
kubectl -n default describe pod <pod-name> | grep -A5 "Readiness"NAME READY STATUS RESTARTS AGE
web-6f9c8d5b4c-2xk7p 0/1 Running 0 3m이때 Warning Unhealthy ... Readiness probe failed: HTTP probe failed with statuscode: 404 같은 이벤트가 함께 보입니다. probe 경로·포트가 앱과 어긋난 전형적인 케이스입니다. probe 실패 원인 자체를 파고들어야 한다면 K8s Liveness/Readiness probe failed·connection refused 원인별 해결의 분기표를 함께 보면 좋습니다.
publishNotReadyAddresses: true는 NotReady 주소까지 EndpointSlice에 싣는 옵션입니다.
apiVersion: v1
kind: Service
metadata:
name: db-headless
spec:
clusterIP: None
publishNotReadyAddresses: true
selector:
app: postgres
ports:
- name: pg
port: 5432
targetPort: 5432용도는 명확합니다. StatefulSet 기반 클러스터 소프트웨어가 부팅 중 서로를 발견해야 하는 피어 디스커버리 상황입니다. 반대로 일반 웹 트래픽 Service에 이 옵션을 켜면 준비되지 않은 파드로 사용자 요청이 흘러가 5xx가 늘어납니다. 장애를 숨기려고 켜는 순간 회귀 불가능한 부채가 되므로, 임시 우회로 쓰더라도 티켓을 남기고 원복 기한을 정하세요.
(d) Pod가 다른 네임스페이스에 있음
Service의 selector는 같은 네임스페이스 안에서만 파드를 찾습니다. 네임스페이스를 넘는 selector는 존재하지 않습니다.
kubectl get pods -A -l app=web-api -o wide파드가 prod 네임스페이스에 있고 Service가 default에 있다면, Service를 옮기거나 클라이언트가 FQDN으로 접근해야 합니다.
curl -sS http://my-svc.prod.svc.cluster.local:8080/healthz외부 이름을 별칭으로 두고 싶다면 ExternalName을 씁니다.
apiVersion: v1
kind: Service
metadata:
name: my-svc
namespace: default
spec:
type: ExternalName
externalName: my-svc.prod.svc.cluster.localExternalName은 CNAME만 반환하므로 Endpoints는 원래 비어 있는 것이 정상입니다. 이 경우의 <none>은 장애가 아닙니다.
(e) Headless Service 오설정
StatefulSet용 매니페스트를 복사해 붙이다 clusterIP: None이 딸려온 케이스입니다.
# 잘못된 버전: 일반 API 서비스인데 headless
apiVersion: v1
kind: Service
metadata:
name: my-svc
spec:
clusterIP: None
selector:
app: web-api
ports:
- port: 8080
targetPort: 8080# 수정 버전: ClusterIP 할당
apiVersion: v1
kind: Service
metadata:
name: my-svc
spec:
type: ClusterIP
selector:
app: web-api
ports:
- name: http
port: 8080
targetPort: 8080spec.clusterIP는 불변 필드라 patch로 바꿀 수 없습니다. 삭제 후 재생성해야 합니다.
kubectl -n default delete svc my-svc
kubectl -n default apply -f my-svc-fixed.yamlDNS 응답 형태 차이로도 구분됩니다.
kubectl run dnsq --rm -it --restart=Never --image=nicolaka/netshoot -- \
dig +short my-svc.default.svc.cluster.local| 구성 | dig 결과 | 클라이언트 동작 |
|---|---|---|
| 일반 ClusterIP | 10.96.30.11 한 줄 | kube-proxy가 로드밸런싱 |
| Headless | 10.244.1.7, 10.244.2.9 등 파드 IP 다수 | 클라이언트가 직접 선택, 커넥션 풀 편향 위험 |
(f) selector 없는 Service + 수동 EndpointSlice
외부 DB나 클러스터 밖 레거시 API를 클러스터 내부 이름으로 노출할 때 쓰는 패턴입니다. selector가 없으면 컨트롤러는 아무것도 채워주지 않으므로 EndpointSlice를 직접 만들어야 합니다.
apiVersion: v1
kind: Service
metadata:
name: legacy-db
namespace: default
spec:
ports:
- name: pg
port: 5432
targetPort: 5432
protocol: TCP
---
apiVersion: discovery.k8s.io/v1
kind: EndpointSlice
metadata:
name: legacy-db-1
namespace: default
labels:
kubernetes.io/service-name: legacy-db
addressType: IPv4
ports:
- name: pg
port: 5432
protocol: TCP
endpoints:
- addresses:
- "192.168.50.31"
conditions:
ready: true여기서 자주 빠뜨리는 세 가지입니다.
labels."kubernetes.io/service-name"— 이 라벨이 없으면 Service와 연결되지 않아 영원히 비어 있습니다.addressType—IPv4,IPv6,FQDN중 하나를 반드시 명시해야 합니다.ports[].name— Service의 포트 이름과 정확히 일치해야 합니다. 한쪽만 이름이 있으면 매칭이 깨집니다.
kubectl -n default apply -f legacy-db.yaml
kubectl -n default get endpointslices -l kubernetes.io/service-name=legacy-db -o wide정상이면 ENDPOINTS 컬럼에 192.168.50.31이 보입니다.
(g) hostNetwork 파드와 포트 충돌
hostNetwork: true 파드는 노드의 네트워크 네임스페이스를 그대로 씁니다. 같은 노드에 같은 포트를 쓰는 파드가 둘 스케줄되면 두 번째는 포트 바인딩에 실패해 NotReady로 남고, 결과적으로 일부 백엔드만 등록되는 부분 실패가 됩니다.
kubectl -n default get pods -l app=web-api -o wideNODE 컬럼에 동일 노드명이 중복되면서 그중 하나가 0/1이라면 이 케이스입니다.
# 수정 버전: 노드당 1개만 뜨도록 anti-affinity 부여
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 3
selector:
matchLabels:
app: web-api
template:
metadata:
labels:
app: web-api
spec:
hostNetwork: true
dnsPolicy: ClusterFirstWithHostNet
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app: web-api
topologyKey: kubernetes.io/hostname
containers:
- name: app
image: nginx:1.27
ports:
- name: http
containerPort: 8080hostNetwork 파드에서 dnsPolicy: ClusterFirstWithHostNet을 빠뜨리면 클러스터 DNS를 쓰지 못해 1편에서 다룬 이름 해석 문제가 재발합니다. 세트로 기억하세요.
Endpoints는 채워졌는데도 실패할 때 + 버전별 함정
버전 차이가 진단 명령을 바꾼다
- 1.21: EndpointSlice가 기본 데이터 소스가 되었습니다. 이후 kube-proxy는 EndpointSlice를 감시하고, 레거시
Endpoints객체는 호환성을 위해 컨트롤러가 미러링해 만들어 줍니다. - 1.33 기준 권장: 진단은
kubectl get endpointslices로 하는 것이 정확합니다. Endpoints API는 신규 기능(예: 트래픽 분배 관련 필드, 다중 addressType)을 반영하지 않는 방향으로 정리되고 있습니다. - 100개 초과 분할: EndpointSlice는 기본적으로 슬라이스당 최대 100개 엔드포인트를 담습니다. 백엔드가 250개면 슬라이스가 3개로 쪼개집니다. 이때 레거시 미러링 Endpoints 객체는 표시가 잘리거나 전체를 반영하지 못할 수 있어, "엔드포인트가 100개밖에 없네"라는 오진을 부릅니다.
대규모 서비스에서는 아래처럼 슬라이스 전체를 합산해서 세는 습관이 필요합니다.
kubectl -n default get endpointslices -l kubernetes.io/service-name=my-svc \
-o jsonpath='{range .items[*]}{range .endpoints[*]}{.addresses[0]}{"\n"}{end}{end}' \
| sort -u | wc -l정상이라면 실제 Ready 파드 수와 같은 숫자가 나옵니다. kubectl get endpoints의 출력 길이와 다르다면 EndpointSlice 쪽 숫자를 믿으세요.
실패 분기 트리: kube-proxy 모드부터 확인
Endpoints가 정상인데도 ClusterIP 접속이 실패한다면, 다음은 데이터플레인입니다. 먼저 모드를 확인합니다.
kubectl -n kube-system get cm kube-proxy -o yaml | grep -i "mode"모드별 룰 확인 명령입니다. 노드에 직접 접속하거나 특권 디버그 파드에서 실행합니다.
# iptables 모드
sudo iptables-save | grep my-svc
# IPVS 모드
sudo ipvsadm -Ln | grep -A3 10.96.30.11
# nftables 모드 (1.31+ 에서 사용 가능)
sudo nft list ruleset | grep my-svc예상 정상 결과는 각각 이렇습니다.
| 모드 | 정상 출력 특징 | 비정상일 때 |
|---|---|---|
| iptables | KUBE-SVC-XXXX 체인과 백엔드 수만큼의 KUBE-SEP-XXXX 점프 | 체인은 있는데 SEP가 0개 → Endpoints 반영 안 됨 |
| IPVS | TCP 10.96.30.11:8080 아래에 real server 목록 | real server 0줄 → 동일 |
| nftables | kube-proxy 테이블 내 서비스 체인과 verdict map | 항목 누락 → kube-proxy 파드 로그 확인 |
셋 다 룰이 정상인데 실패한다면 kube-proxy 자체가 아니라 노드 간 경로 문제이므로 4편 영역입니다.
여기서 하나 짚어둘 흐름이 있습니다. Cilium 같은 eBPF 기반 CNI에서 kube-proxy replacement를 켜면 위 명령들이 전부 무의미해집니다. iptables-save에 서비스 체인이 아예 없는 것이 정상 상태이며, 진단은 cilium service list, cilium endpoint list 쪽으로 이동합니다. "iptables에 룰이 없다 = 장애"라고 단정하기 전에 CNI 구성을 먼저 확인하세요.
externalTrafficPolicy: Local의 특정 노드 실패 패턴
NodePort/LoadBalancer에서 클라이언트 소스 IP를 보존하려고 Local을 설정하면, 해당 노드에 백엔드 파드가 없을 때 그 노드로 들어온 요청은 전달되지 않고 끊깁니다. "3대 중 1대로 붙을 때만 실패"하는 간헐 장애의 전형적 원인입니다.
kubectl -n default get svc my-svc -o jsonpath='{.spec.externalTrafficPolicy}{"\n"}'
kubectl -n default get pods -l app=web-api -o wide
kubectl get nodes -o name파드가 떠 있는 노드 목록과 전체 노드 목록을 비교해, 파드가 없는 노드로 요청이 가는지 확인합니다. 해결 방향은 두 갈래입니다.
# 1) 소스 IP 보존이 필수가 아니면 Cluster로 전환
kubectl -n default patch svc my-svc -p '{"spec":{"externalTrafficPolicy":"Cluster"}}'# 2) Local을 유지해야 하면 모든 노드에 파드를 배치 (DaemonSet 또는 anti-affinity + 충분한 replicas)
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: web
spec:
selector:
matchLabels:
app: web-api
template:
metadata:
labels:
app: web-api
spec:
containers:
- name: app
image: nginx:1.27
ports:
- name: http
containerPort: 8080여기까지 확인했는데도 특정 노드 조합에서만 패킷이 사라진다면 오버레이 터널·MTU·라우팅 문제이고, 이는 4편의 주제입니다.
재발 방지: CI 게이트, probe 설계, 알람
yq 기반 매니페스트 정합성 검증
배포 전에 라벨과 포트를 기계적으로 대조하면 (a), (b) 두 케이스는 프로덕션에 도달하지 못합니다.
#!/usr/bin/env bash
# validate-svc-match.sh — Service selector ↔ Deployment 라벨/포트 정합성 검증
# usage: ./validate-svc-match.sh deploy.yaml svc.yaml
set -euo pipefail
DEPLOY_FILE="${1:?deployment yaml required}"
SVC_FILE="${2:?service yaml required}"
FAIL=0
POD_LABELS=$(yq -o=json '.spec.template.metadata.labels' "$DEPLOY_FILE")
SELECTOR=$(yq -o=json '.spec.selector' "$SVC_FILE")
echo "pod labels : $POD_LABELS"
echo "selector : $SELECTOR"
# selector의 모든 key/value가 pod labels에 포함되는지 검사
MISSING=$(echo "$SELECTOR" | jq -r --argjson labels "$POD_LABELS" \
'to_entries[] | select(($labels[.key] // "") != .value) | .key')
if [ -n "$MISSING" ]; then
echo "FAIL: selector keys not matched in pod labels -> $MISSING"
FAIL=1
else
echo "OK: selector matches pod labels"
fi
# targetPort가 숫자인 경우 containerPort 존재 확인
TARGET=$(yq '.spec.ports[0].targetPort' "$SVC_FILE")
if [[ "$TARGET" =~ ^[0-9]+$ ]]; then
HIT=$(yq ".spec.template.spec.containers[].ports[] | select(.containerPort == $TARGET) | .containerPort" "$DEPLOY_FILE" || true)
if [ -z "$HIT" ]; then
echo "FAIL: targetPort $TARGET has no matching containerPort"
FAIL=1
else
echo "OK: targetPort $TARGET matches containerPort"
fi
else
# named port인 경우 이름 정의 확인
HIT=$(yq ".spec.template.spec.containers[].ports[] | select(.name == \"$TARGET\") | .name" "$DEPLOY_FILE" || true)
if [ -z "$HIT" ]; then
echo "FAIL: named targetPort '$TARGET' is not defined in containers[].ports[].name"
FAIL=1
else
echo "OK: named port '$TARGET' defined"
fi
fi
exit "$FAIL"스키마 검증은 kubeconform으로 함께 겁니다.
kubeconform -strict -summary -kubernetes-version 1.33.0 deploy.yaml svc.yamlGitHub Actions 예시입니다.
name: k8s-manifest-gate
on: [pull_request]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install tools
run: |
sudo wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64
sudo chmod +x /usr/local/bin/yq
curl -sSL https://github.com/yannh/kubeconform/releases/latest/download/kubeconform-linux-amd64.tar.gz | tar xz
sudo mv kubeconform /usr/local/bin/
- name: Schema validation
run: kubeconform -strict -summary -kubernetes-version 1.33.0 manifests/
- name: Selector/port match
run: ./validate-svc-match.sh manifests/deploy.yaml manifests/svc.yamlReadiness probe 설계 원칙 3가지
- 의존성을 probe에 넣지 말 것. DB 연결까지 검사하는 readiness는 DB 순간 장애 때 전 파드를 동시에 백엔드에서 빼버려
Endpoints: <none>을 스스로 만듭니다. readiness는 "이 프로세스가 요청을 받을 준비가 되었는가"만 답하게 하고, 의존성 상태는 별도 메트릭으로 노출하세요. - 긴
initialDelaySeconds대신startupProbe를 쓸 것. 부팅이 느린 JVM 앱에 initialDelay를 크게 잡으면 장애 감지도 그만큼 늦어집니다. startupProbe로 부팅 구간만 분리하면 readiness 주기는 짧게 유지할 수 있습니다. - 롤아웃 중 전면 NotReady를 막을 것.
maxUnavailable과 PodDisruptionBudget을 함께 설정합니다.
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 4
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 1
maxSurge: 1
selector:
matchLabels:
app: web-api
template:
metadata:
labels:
app: web-api
spec:
containers:
- name: app
image: nginx:1.27
ports:
- name: http
containerPort: 8080
startupProbe:
httpGet:
path: /healthz
port: http
failureThreshold: 30
periodSeconds: 5
readinessProbe:
httpGet:
path: /healthz
port: http
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 3
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: web-pdb
spec:
minAvailable: 2
selector:
matchLabels:
app: web-api알람: 엔드포인트 0개를 5분 안에 잡는다
kube-state-metrics 기반 PrometheusRule입니다.
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: service-endpoint-rules
namespace: monitoring
labels:
release: kube-prometheus-stack
spec:
groups:
- name: service-endpoints
rules:
- alert: ServiceHasNoEndpoints
expr: |
kube_endpoint_address_available == 0
and on (namespace, service)
label_replace(
kube_service_spec_type{type!="ExternalName"},
"service", "$1", "service", "(.*)"
)
for: 5m
labels:
severity: critical
annotations:
summary: "Service {{ $labels.namespace }}/{{ $labels.service }} has zero endpoints"
description: "5분 이상 사용 가능한 엔드포인트가 0개입니다. selector 라벨, targetPort, readiness probe 순서로 확인하세요."
- alert: ServiceEndpointsDropped
expr: |
delta(kube_endpoint_address_available[10m]) < 0
and kube_endpoint_address_available < 2
for: 10m
labels:
severity: warning
annotations:
summary: "Endpoints decreasing for {{ $labels.namespace }}/{{ $labels.service }}"
description: "엔드포인트 수가 감소해 2개 미만입니다. 롤아웃 또는 readiness 실패 여부를 확인하세요."ExternalName 타입은 Endpoints가 비어 있는 것이 정상이므로 반드시 제외 조건을 넣어야 오탐이 줄어듭니다. 메트릭 이름은 kube-state-metrics 버전에 따라 kube_endpointslice_* 계열로도 제공되므로, 배포된 버전의 메트릭 목록을 먼저 확인하고 적용하세요.
이번 편 체크리스트와 다음 편 예고
장애 상황에서 위에서부터 순서대로만 치면 됩니다.
kubectl get endpoints <svc>와kubectl get endpointslices -l kubernetes.io/service-name=<svc>로 비었는지 확정한다.- netshoot에서 Pod IP → ClusterIP → NodePort 순으로 curl해 계층을 양분한다.
kubectl get pods -l <selector>0건이면 (a) selector 불일치를 먼저 의심한다.- 파드는 잡히는데 비었다면 READY 컬럼과 readiness 이벤트로 (c)를 확인한다.
targetPort가 named port면 컨테이너에 같은 이름이 정의됐는지 확인한다.- selector가 비어 있으면 수동 EndpointSlice의
kubernetes.io/service-name라벨과addressType을 점검한다. - Endpoints가 정상인데 실패하면 kube-proxy 모드별 룰과
externalTrafficPolicy: Local을 본다.
복구 후에도 즉시 반영되지 않는 경우가 있는데, kube-proxy가 EndpointSlice 변경을 감지해 룰을 동기화하는 데 수 초가 걸리고 conntrack에 남은 기존 세션이 이전 경로를 유지하기 때문입니다. 조급하게 추가 변경을 얹지 말고 30초 정도 재확인하는 여유가 필요합니다.
다음 편은 **4편 "Endpoints는 정상인데 노드를 넘으면 끊긴다 — CNI 오버레이·MTU·kube-proxy 데이터플레인 디버깅"**입니다. 같은 노드 안에서는 되는데 노드를 넘으면 죽는 패턴, MTU 불일치로 큰 응답만 사라지는 현상, VXLAN 캡슐화 구간 추적을 다룹니다.
공식 참고 자료로는 Kubernetes 공식 문서의 Service, EndpointSlice, Virtual IPs and Service Proxies 문서를 함께 보시길 권합니다.
자주 묻는 질문 (FAQ)
Q1. Endpoints와 EndpointSlice 중 무엇을 봐야 하나요?
A. 1.21 이후 실제 데이터 소스는 EndpointSlice입니다. 빠른 확인용으로 kubectl get endpoints를 써도 되지만, 백엔드가 100개를 넘어 슬라이스가 분할되는 규모에서는 목록이 잘려 보일 수 있습니다. 정확한 판단이 필요하면 kubectl get endpointslices -l kubernetes.io/service-name=<svc>를 기준으로 삼으세요.
Q2. NotReady 파드로도 트래픽을 보내고 싶습니다.
A. spec.publishNotReadyAddresses: true로 가능합니다. 다만 이 옵션의 정당한 용도는 StatefulSet 피어 디스커버리처럼 부팅 중 상호 발견이 필요한 경우입니다. 일반 사용자 트래픽 Service에 적용하면 준비되지 않은 파드로 요청이 흘러 5xx가 발생하므로, 임시 우회라면 원복 기한을 반드시 정하세요.
Q3. Headless Service에서 Endpoints: <none>이면 항상 장애인가요?
A. 아닙니다. clusterIP: None인 Headless Service는 kubectl 출력 형태가 다를 수 있고, ExternalName 타입은 애초에 엔드포인트 개념이 없어 비어 있는 것이 정상입니다. 판단 기준은 dig로 파드 IP가 여러 개 반환되는지, EndpointSlice에 주소가 실제로 존재하는지입니다.
Q4. 매니페스트를 고쳤는데 왜 바로 복구되지 않나요? A. endpointslice controller가 변경을 반영하고 kube-proxy가 각 노드의 룰을 동기화하는 데 시간이 걸립니다. 여기에 conntrack 테이블의 기존 세션이 이전 목적지로 유지되면서 체감 복구가 늦어집니다. 클라이언트 커넥션 풀을 새로 맺게 하거나 확인용 요청을 새 연결로 보내면 차이를 구분할 수 있습니다.
Q5. connection refused와 timeout 중 어느 쪽이 어떤 원인을 시사하나요?
A. 즉시 떨어지는 connection refused는 대체로 백엔드가 0건이라 REJECT 룰이 응답한 경우로, 이 글의 7가지 원인부터 확인하면 됩니다. 반면 timeout은 패킷이 응답 없이 사라진 것이므로 NetworkPolicy 차단(2편), CNI 경로·MTU 문제(4편), 또는 앱이 응답하지 않는 상황을 의심하는 편이 빠릅니다.
Nodelog는 모든 콘텐츠의 내용과 출처를 공개 전에 검토합니다. 환경(OS·버전)에 따라 결과가 달라질 수 있는 기술 정보는 공식 문서와 함께 확인하며, 검토 기준과 정정 원칙은 편집 정책에서 안내합니다. 오류를 발견하시면 이메일로 제보해 주세요 — 확인 후 신속히 정정합니다.
댓글
첫 번째 댓글을 남겨보세요.