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가 풀어야 할 과제다.
- IP가 바뀐다. Pod가 재스케줄되거나 재시작되면 IP가 달라진다. 클라이언트가 알고 있던 IP는 죽은 주소가 된다.
- 여러 Pod 중 어디로? "이 앱"이 Pod 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로도 안 된다"가 장애 진단의 핵심 분기.
생각해 볼 문제
- Pod IP로 직접 접속하는 코드를 Service로 바꿔야 한다. 구체적 변경점은? (이유까지)
- Service로 접속은 되는데 Pod IP로는 안 된다. 어느 층의 문제인가? 어떻게 진단하나?
- Service 이름으로 접속이 안 된다(DNS 실패). Pod IP로는 된다. 범인은? (02장 CoreDNS)
- iptables 모드와 IPVS 모드의 성능 차이가 규모에 따라 벌어지는 이유는? (데이터 구조 관점)
- LoadBalancer 타입이 클라우드에서만 동작하는 이유를 "누가 LB를 만드는가"로 설명하라. 온프렘 대안은?
- ClusterIP가 가상 IP여야 하는 이유는? 실제 IP를 한 노드가 가지면 어떤 일이 벌어지나?
- EndpointSlice가 없고 단일 Endpoints만 있다면, Pod가 수천 개인 Service에서 어떤 문제가 터지나? (03장 list-watch와 연결)
참고
- Kubernetes 공식 문서 - Service - 접근 2026-07-13 (ClusterIP/NodePort/LB, 셀렉터)
- Kubernetes 공식 문서 - EndpointSlices - 접근 2026-07-13 (Endpoints → EndpointSlice 전환, 규모)
- Kubernetes 공식 문서 - kube-proxy - 접근 2026-07-13 (iptables/IPVS 모드)
- Kubernetes 공식 문서 - Services 네트워킹 개요 - 접근 2026-07-13
- Kubernetes 1.36 API - ServiceSpec - 접근 2026-07-13
