Pod는 죽을 때마다 IP가 바뀌는데, 클라이언트는 어떻게 "이 앱"을 찾나

한 Pod의 IP는 10.0.1.5였다. 어플리케이션 클라이언트는 그 IP로 접속했다. 그런데 노드가 죽어 Pod가 다른 노드로 재스케줄됐고, IP가 10.0.1.9로 바뀌었다. 클라이언트는 여전히 10.0.1.5로 연결을 시도하다 실패했다 — 아무도 IP가 바뀌었다고 알려주지 않았으니까.

이것이 Pod IP로 직접 접속하면 안 되는 이유다. Pod는 재스케줄될 때마다 IP가 바뀐다(kubernetes 04장). 클라이언트가 "이 앱"을 안정적으로 찾으려면, 바뀌는 IP를 감추는 고정된 진입점이 필요하다. 그 역할을 Service가 한다.

이 글이 푸는 것은: Service가 바뀌는 Pod IP들을 어떻게 하나의 안정된 이름으로 묶는가, kube-proxy가 그 이름을 실제 Pod 트래픽으로 어떻게 연결하는가, 그리고 왜 Service의 IP(ClusterIP)는 가상이어야 하는가다. 이해하면 k8s networking 전체의 출발점이 선다.

Pod IP로는 안 되는 세 가지 이유

왜 Pod IP로 직접 접속하면 안 되는지, 문제를 정확히 분해하자. 이 세 가지가 Service가 풀어야 할 과제다.

  1. IP가 바뀐다. Pod가 재스케줄되거나 재시작되면 IP가 달라진다. 클라이언트가 알고 있던 IP는 죽은 주소가 된다.
  2. 여러 Pod 중 어디로? "이 앱"이 Pod 3개로 돌고 있으면, 클라이언트는 그중 어디로 연결해야 하나? 직접 고르면 로드밸런싱을 클라이언트가 다 떠안아야 한다.
  3. 죽은 Pod를 어떻게 빼? Pod 3개 중 하나가 죽으면, 클라이언트가 거기로 연결을 시도하다 실패한다. 누군가 "이제 이 IP는 죽었으니 빼"를 실시간으로 처리해야 한다.

이 세 가지를 각 클라이언트가 직접 처리하면 — 모든 클라이언트가 로드밸런서·헬스체커·IP 추적기를 따로 구현해야 한다. Kubernetes는 이것을 Service라는 하나의 추상화로 푼다.

Service가 세 가지를 한 번에 푼다

Service는 라벨 셀렉터로 "이 앱"에 해당하는 Pod들을 묶고, 그 묶음에 고정된 가상 IP(ClusterIP)DNS 이름을 준다.

flowchart LR
    C["클라이언트"] -->|"web:80<br/>(고정된 이름)"| SVC["Service<br/>ClusterIP 10.96.x.x"]
    SVC -. 라우팅 .-> P1["Pod1 (IP 바뀜)"]
    SVC -. 라우팅 .-> P2["Pod2"]
    SVC -. 라우팅 .-> P3["Pod3"]

이 구조가 앞의 세 문제를 어떻게 녹이는지 보자.

  • IP가 바뀐다 → Service의 ClusterIP는 Pod IP와 별개로 클러스터가 부여한 가상 주소다. Pod가 재스케줄돼 IP가 바뀌어도 ClusterIP는 그대로다. 클라이언트는 ClusterIP(또는 DNS 이름)만 알면 된다.
  • 여러 Pod 중 어디로? → Service가 묶인 Pod들 사이에 트래픽을 분산한다. 클라이언트는 "어디로"를 결정할 필요 없이 Service 이름으로 보낸다.
  • 죽은 Pod를 빼 → Service 뒤의 Pod 목록은 라벨 셀렉터로 자동 갱신된다. Pod가 죽어 app=web 라벨을 잃거나 삭제되면 Service가 그 Pod를 빼고, 새 Pod가 뜨면 넣는다. (Kubernetes docs - Service)
# Kubernetes 1.36
apiVersion: v1
kind: Service
metadata: {name: web}
spec:
  selector: {app: web}          # app=web 라벨의 Pod들을 묶음
  ports: [{port: 80, targetPort: 8080}]

이제 web.default.svc.cluster.local(또는 ClusterIP)로 접속하면 app=web Pod 중 하나로 간다.

# Kubernetes 1.36
kubectl expose deploy web --port=80 --target-port=8080
kubectl get svc web
NAME   TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)   AGE
web    ClusterIP   10.96.123.45    <none>        80/TCP    5s

Service가 로드밸런서이기도 한 건 맞다 — 여러 Pod에 트래픽을 분산. 하지만 로드밸런싱은 부수 효과고, 본질은 "바뀌는 Pod IP를 감추는 고정 진입점"이다. 이 순서로 이해해야 나중에 Headless Service(clusterIP: None, 로드밸런싱은 안 하고 이름만 주는)가 왜 존재하는지 보인다.

네 가지 타입 — 어디까지 노출할 것인가

Service가 어디까지 보이는지는 type으로 정한다.

타입 노출 범위 용도
ClusterIP(기본) 클러스터 내부 클러스터 안의 다른 Pod가 이 Service를 부를 때
NodePort 각 노드의 포트 외부에서 노드IP:포트로 접속 (고가용성 LB 전/테스트용)
LoadBalancer 클라우드 LB 외부용. 클라우드가 LB를 프로비전해 노드에 연결
ExternalName 외부 DNS 이름 클러스터 안에서 외부 서비스를 CNAME으로 가리킴
spec:
  type: LoadBalancer            # 클라우드 LB 자동 프로비전
  selector: {app: web}
  ports: [{port: 80, targetPort: 8080}]

LoadBalancer 타입은 클라우드(AWS/GCP/Azure)에서만 동작한다 — 클라우드 컨트롤러가 실제 LB를 만들어 노드에 연결하기 때문. 온프렘에서는 MetalLB 같은 도구로 같은 효과를 낸다. "누가 LB를 만드는가"가 핵심 — 클라우드 컨트롤러가 없으면 LoadBalancer 타입은 영원히 <pending> 상태로 남는다.

ClusterIP는 가상 IP다 — 아무도 응답하지 않는 주소

여기서 흥미로운 모순이 드러난다. 클라이언트가 ClusterIP(10.96.123.45)로 패킷을 보낸다. 그런데 그 IP에 바인딩된 프로세스가 어디에도 없다. 어떤 노드에서도, 어떤 Pod에서도, 그 IP로 듣는(listen) 소켓이 없다. 그런데도 패킷은 어떻게든 실제 Pod에 닿는다. 어떻게?

이게 Service 설계의 핵심이다. ClusterIP는 가상 IP다 — 존재하지만 응답하지 않는 주소. 누군가가 중간에서 이 IP로 오는 트래픽을 가로채, 실제 Pod IP로 목적지를 바꿔치기해야 한다. 그 역할이 kube-proxy다.

왜 가상 IP인가? 실제 IP를 쓰면 한 노드가 그 IP를 가져야 하는데, 그 노드가 죽으면 Service 전체가 죽는다(단일 장애점). 가상 IP는 "어떤 노드도 소유하지 않지만 모든 노드가 인식하는" 주소라 이 문제가 없다. 고가용성이 근본에서 보장되는 설계.

kube-proxy — 가상 IP를 실제 Pod로 연결한다

kube-proxy는 각 노드에서 DaemonSet으로 돈다. 이름은 "proxy"지만 실제로는 트래픽을 중계하지 않는다 — 대신 커널에 규칙을 깔아, 패킷이 노드에 들어올 때 알아서 목적지가 바뀌게 만든다.

이 규칙의 핵심 동작은 DNAT(Destination NAT)이다. ClusterIP로 오는 패킷의 목적지 주소를, 뒤의 Pod 중 하나의 IP로 슬쩍 바꾼다. 그러면 패킷은 그 Pod에게 간다.

클라이언트 패킷: 목적지 10.96.123.45 (ClusterIP)
    ↓ 노드 커널의 DNAT 규칙이 목적지를 바꿈
목적지 10.0.1.5 (실제 Pod IP)
    ↓ CNI(03장)가 Pod에게 전달
Pod 도착

이 규칙을 까는 방식이 kube-proxy의 모드다.

iptables 모드 (기본)

각 노드의 커널 iptables에 규칙을 프로그래밍한다: "ClusterIP로 오는 패킷은 EndpointSlice의 Pod IP 중 하나로 DNAT하라". kube-proxy는 EndpointSlice를 watch해 Pod가 추가/삭제될 때마다 규칙을 갱신하고, 로드밸런싱도 iptables의 무작위 매칭 확률로 구현한다.

# Kubernetes 1.36 — 노드에서 Service DNAT 규칙 직접 보기
docker exec <kind-node> iptables-save | grep -i "web" | head

확인할 것: ClusterIP(10.96.x.x)로 오는 패킷을 Pod IP로 DNAT하는 규칙이 보인다. kube-proxy가 깔아둔 것.

노드 종속이라 노드 접속/exec 필요. 규칙 형식은 CNI/버전에 따라 다를 수 있음.

IPVS 모드

iptables 대신 IPVS(IP Virtual Server, 커널의 L4 로드밸런서)로 비슷한 일을 한다. 차이는 데이터 구조: IPVS는 해시 테이블 기반이라 Service가 아무리 많아도 조회가 거의 일정한 속도. 반면 iptables 모드는 규칙을 순차 탐색해서, Service가 수천 개면 선형으로 느려진다. (Kubernetes docs - kube-proxy)

모드 선택은 클러스터 규모에 달렸다. 소규모는 iptables(기본), 대규모(수천 Service)는 IPVS. 단 Cilium(05장)은 kube-proxy 자체를 대체한다 — eBPF로 라우팅해 버리니까 iptables/IPVS 어느 쪽도 안 쓴다. 그땐 "kube-proxy 모드"라는 말 자체가 무의미해진다.

EndpointSlice — Pod가 계속 바뀌는데 규칙은 어떻게 최신으로?

kube-proxy가 규칙을 깔았다. 하지만 Pod는 끊임없이 생기고 죽는다 — 스케일, 재배포, 노드 장애. 그때마다 DNAT 규칙의 "목적지 Pod IP 목록"이 갱신돼야 한다. 그렇지 않으면 kube-proxy가 죽은 Pod IP로 계속 보내 버린다.

이 "현재 살아 있는 Pod IP 목록"을 유지하는 객체가 EndpointSlice다. Service를 만들면 같이 생겨, 셀렉터에 맞는 현재 Pod들의 IP:포트를 추적한다. Pod가 생기면 추가, 사라지면 제거. kube-proxy는 EndpointSlice를 watch(03장 list-watch)해 변화가 있을 때마다 자기 규칙을 다시 갱신한다.

# Kubernetes 1.36 — Service의 EndpointSlice 보기
kubectl get endpointslice -l kubernetes.io/service-name=web

확인할 것: Service web의 EndpointSlice에 현재 Pod IP들이 나열된다. Pod가 재생성돼 IP가 바뀌면 이 목록이 갱신되고, kube-proxy가 그것을 watch해 규칙을 바꾼다.

왜 "Endpoints"가 아니라 "EndpointSlice"인가 — 규모

이 객체의 이전 이름은 Endpoints였다. 하나의 Service에 대해 단일 Endpoints 객체가 모든 Pod IP를 담았다. 소규모에선 문제가 없었지만, 한 Service 뒤에 Pod가 수천 개가 되면서 터졌다:

  • 단일 Endpoints 객체가 거대 → Pod 하나 바뀔 때마다 전체 객체가 watch로 재전송(03장 list-watch의 비용).
  • 모든 관심 컴포넌트(kube-proxy, CoreDNS, 컨트롤러)가 한 객체를 watch → apiserver 부하 집중.

그래서 EndpointSlice는 한 Service의 엔드포인트를 여러 조각(slice)으로 쪼갰다.

Service "web" →
  endpointslice/web-abc (Pod 1~100)
  endpointslice/web-def (Pod 101~200)
  endpointslice/web-ghi (Pod 201~300)

각 slice는 최대 약 100개 엔드포인트. 변화가 한 slice에만 영향을 주면 watch는 그 slice만 갱신된다. 이것이 "규모 대응을 위해 단일 거대 객체를 쪼갰다"는 Kubernetes 설계 원칙의 사례다. (Kubernetes docs - EndpointSlices)

핵심 함정 — kube-proxy(Service 층) vs CNI(Pod 간 층)

이 글의 가장 중요한 구분:

  kube-proxy CNI
다루는 트래픽 Service → Pod (목적지 변환) Pod ↔ Pod (오버레이/라우팅)
ClusterIP로 온 패킷을 실제 Pod IP로 Pod1이 Pod2에 직접 IP로 연결
구현 iptables/IPVS (Service 규칙) Calico/Cilium (Pod 네트워크)

이 둘을 같은 층으로 보면 네트워크 장애를 잘못된 곳에서 찾는다. "Service로는 되는데 Pod IP로는 안 된다" → CNI 문제. "Pod IP로는 되는데 Service로는 안 된다" → kube-proxy/EndpointSlice 문제. 이 진단 분기가 02 영역 전체의 출발점이다.

kube-proxy와 CNI의 협력을 한 문장으로: kube-proxy가 "어느 Pod로 갈지"를 결정(DNAT)하고, CNI가 "거기까지 어떻게 보낼지"를 실행(라우팅). Service로 가는 패킷은 두 층을 순서대로 지난다.

직접 확인하기

# Kubernetes 1.36 — Service와 EndpointSlice, kube-proxy 파드
kubectl get svc,endpointslice -l kubernetes.io/service-name=kubernetes
kubectl get pods -n kube-system | grep kube-proxy

확인할 것: kube-proxy 파드가 노드마다(DaemonSet) 있고, Service→Pod 매핑이 EndpointSlice에 있다.

# DNS로 Service 이름 해석 (CoreDNS 경유)
kubectl run t --image=busybox:1.36 --rm -it --restart=Never -- nslookup web.default
Name:    web.default.svc.cluster.local
Address: 10.96.123.45
# Service로 접속 → 실제 Pod로 라우팅 확인
kubectl run t --image=busybox:1.36 --rm -it --restart=Never -- wget -qO- web

확인할 것: Service 이름으로 Pod 응답이 온다. Pod IP를 몰라도 된다.

session affinity — 같은 클라이언트를 같은 Pod로

Service는 기본적으로 무작위/라운드로빈 분배. 하지만 상태를 가진 앱(세션 메모리)은 "같은 클라이언트를 같은 Pod로" 보내야 한다. Service의 sessionAffinity:

spec:
  sessionAffinity: ClientIP              # 또는 None(기본)
  sessionAffinityConfig:
    clientIP: {timeoutSeconds: 10800}    # 3시간 고정

ClientIP affinity는 클라이언트 IP 기준으로 같은 Pod로 고정. 단점: 클라이언트가 NAT 뒤에 여럿이면 같은 IP로 보여 다 같은 Pod로 몰린다. 그리고 Pod가 죽으면 그 클라이언트의 세션도 깨진다(고정 대상이 사라지니). 진짜 상태 공유는 앱 단(StatefulSet이나 외부 세션 저장소)에서 해야 — Service affinity는 보조.

session affinity를 메시(Istio 등)가 더 정교하게 — IP가 아니라 쿠키/헤더 기반으로. "이 사용자 ID는 항상 v2로" 같은 L7 수준 고정. 11장에서.

externalTrafficPolicy — 외부 트래픽의 노드 홉 문제

NodePort/LoadBalancer Service에 externalTrafficPolicy가 성능에 큰 영향:

동작 문제
Cluster(기본) 외부 트래픽이 어느 노드로 와도 모든 노드의 Pod로 분산 소스 IP 보존 안 됨(SNAT), 추가 홉 가능
Local 트래픽이 도착한 노드의 Pod로만 소스 IP 보존, 홉 감소 / 단 Pod가 없는 노드는 패킷 드롭
spec:
  type: LoadBalancer
  externalTrafficPolicy: Local

Cluster(기본)의 문제: 외부 트래픽이 노드 A로 와도 Pod가 노드 B에 있으면 — 노드 A가 B로 전달(추가 홉). 그리고 소스 IP가 SNAT로 노드 IP로 바뀌어 앱이 실제 클라이언트 IP를 못 본다. Local은 이 두 문제를 해결하지만, "트래픽이 도착한 노드에 Pod가 없으면 드롭"이라는 제약이 생긴다. 부하 분산(DNS/LB)이 고르게 노드에 보내야 Local이 효과.

이것이 "외부 접속 앱이 클라이언트 IP를 못 본다"의 흔한 원인 — externalTrafficPolicy가 Cluster(SNAT)면 앱은 노드 IP만 본다. 보안/감사 목적의 클라이언트 IP 추적은 Local이 필요.

흔히 묻는 것, 흔히 틀리는 것

오해 정정
"Service는 Pod를 만든다" Pod를 묶어 진입점을 줄 뿐. 만드는 건 Deployment
"ClusterIP는 Pod IP다" 가상 IP. 응답하는 실체 없음. iptables/IPVS가 Pod로 돌림
"kube-proxy가 Pod 간 통신을 담당한다" Service→Pod만. Pod 간은 CNI. 다른 층
"LoadBalancer 타입은 어디서나 동작한다" 클라우드 컨트롤러 필요. 온프렘은 MetalLB 등 별도
"Endpoints와 EndpointSlice는 같다" EndpointSlice가 후속. 규모 대응(조각냄)
"Service 이름은 DNS 없이도 풀린다" CoreDNS가 풀어줌. CoreDNS 죽으면 이름 해석 실패
"ClusterIP는 특정 노드가 소유한다" 가상 IP. 어느 노드도 소유 안 함. 단일 장애점 회피 설계

요약 — 이 글의 결론

  • Pod IP로는 세 가지가 안 된다 — IP가 바뀌고, 여러 Pod 중 어디로 갈지 모르고, 죽은 Pod를 뺄 주체가 없다. Service가 이 셋을 하나의 추상화로 푼다.
  • Service는 바뀌는 Pod IP를 고정된 이름(ClusterIP + DNS) 뒤에 묶는다. 셀렉터로 Pod를 자동 추적해, 죽은 Pod는 빼고 새 Pod는 넣는다. 본질은 로드밸런서가 아니라 "안정된 진입점".
  • ClusterIP는 가상 IP다 — 아무도 응답하지 않는 주소. 단일 장애점을 피하려는 설계. 그래서 누군가 트래픽을 실제 Pod로 돌려야 한다.
  • kube-proxy가 그 역할을 한다 — 각 노드 커널에 DNAT 규칙을 깔아, ClusterIP로 오는 패킷의 목적지를 실제 Pod IP로 바꾼다. iptables(기본) 또는 IPVS(대규모) 모드.
  • EndpointSlice가 "현재 살아 있는 Pod IP 목록"을 유지한다. kube-proxy가 이것을 watch해 규칙을 최신으로. 단일 Endpoints를 쪼갠 건 규모 대응.
  • kube-proxy(Service 층)와 CNI(Pod 간 층)는 다른 층. "Service로만 안 된다" vs "Pod IP로도 안 된다"가 장애 진단의 핵심 분기.

생각해 볼 문제

  1. Pod IP로 직접 접속하는 코드를 Service로 바꿔야 한다. 구체적 변경점은? (이유까지)
  2. Service로 접속은 되는데 Pod IP로는 안 된다. 어느 층의 문제인가? 어떻게 진단하나?
  3. Service 이름으로 접속이 안 된다(DNS 실패). Pod IP로는 된다. 범인은? (02장 CoreDNS)
  4. iptables 모드와 IPVS 모드의 성능 차이가 규모에 따라 벌어지는 이유는? (데이터 구조 관점)
  5. LoadBalancer 타입이 클라우드에서만 동작하는 이유를 "누가 LB를 만드는가"로 설명하라. 온프렘 대안은?
  6. ClusterIP가 가상 IP여야 하는 이유는? 실제 IP를 한 노드가 가지면 어떤 일이 벌어지나?
  7. EndpointSlice가 없고 단일 Endpoints만 있다면, Pod가 수천 개인 Service에서 어떤 문제가 터지나? (03장 list-watch와 연결)

참고