5.2 Service
Pod IP는 안정적인 주소가 아니다. Deployment가 Pod를 교체하면 새 Pod는 새 IP를 받고, 스케일링으로 Pod 수가 늘고 줄면 대상 집합 자체가 바뀐다. 클라이언트가 Pod IP를 직접 알고 접속하는 구조라면 상대가 사라질 때마다 주소록을 다시 만들어야 한다. Service는 이 문제를 고정된 가상 IP 하나로 해결한다. 클라이언트는 Service의 IP와 이름만 알면 되고, 그 뒤에서 실제 Pod 집합이 어떻게 바뀌는지는 Kubernetes가 추적한다.
ClusterIP와 EndpointSlice
Service의 기본형은 ClusterIP다. Service를 만들면 apiserver가 --service-cluster-ip-range 대역에서 IP 하나를 할당하고, 이 IP는 Service가 삭제될 때까지 바뀌지 않는다. 어느 Pod에게 트래픽을 전달할지는 selector가 정한다.
apiVersion: v1
kind: Service
metadata:
name: web
spec:
selector:
app: web
ports:
- port: 80 # Service가 노출하는 포트
targetPort: 8080 # Pod 컨테이너의 실제 포트selector에 맞는 Pod의 실제 IP 목록은 Service 객체 자체가 아니라 EndpointSlice가 담는다. EndpointSlice 컨트롤러가 selector와 일치하고 준비(ready) 상태인 Pod의 IP와 포트를 EndpointSlice 객체로 유지하며, Pod가 생기고 죽을 때마다 이 목록을 갱신한다. 과거에는 Service당 하나의 Endpoints 객체가 전체 목록을 담았는데, 대상 Pod가 수천 개인 Service에서는 Pod 하나가 바뀔 때마다 거대한 객체 전체가 다시 전파되는 부담이 있었다. EndpointSlice는 목록을 기본 100개 단위 조각으로 나눠 바뀐 조각만 갱신되게 한 후속 설계로, 1.21에서 GA가 됐다.
readiness probe가 이 지점에서 트래픽과 연결된다. probe에 실패한 Pod는 EndpointSlice에서 준비되지 않음으로 표시되어 Service 트래픽 대상에서 빠진다. 애플리케이션이 기동 중이거나 과부하일 때 트래픽을 받지 않게 하는 장치가 바로 이 연결이다. probe 자체는 Part IV. 워크로드에서 다룬다.
타입 4종
Service의 type 필드는 접근 범위를 정한다.
| 타입 | 접근 범위 | 방식 |
|---|---|---|
| ClusterIP | 클러스터 내부 | 가상 IP 할당 |
| NodePort | 노드 IP로 외부 접근 | 모든 노드의 고정 포트 개방 |
| LoadBalancer | 외부 로드밸런서 경유 | 클라우드 LB 프로비저닝 |
| ExternalName | 외부 도메인으로 위임 | DNS CNAME 반환 |
세 타입은 포함 관계다. NodePort를 만들면 ClusterIP도 함께 할당되고, 모든 노드가 30000~32767 범위(기본값)의 같은 포트를 열어 그 포트로 온 트래픽을 Service로 전달한다. 어느 노드로 접속해도 되고, 대상 Pod가 없는 노드로 들어와도 다른 노드의 Pod로 전달된다. LoadBalancer는 다시 NodePort를 포함하면서, cloud-controller-manager를 통해 클라우드 제공자의 로드밸런서를 실제로 생성해 앞단에 배치한다. 클라우드 연동이 없는 베어메탈 환경에서는 LoadBalancer 타입을 만들어도 external IP가 pending에 머무르며, MetalLB 같은 별도 구현을 설치해야 동작한다.
ExternalName은 결이 다르다. 프록시도 IP 할당도 없이, 클러스터 DNS가 이 Service 이름을 조회하면 지정한 외부 도메인의 CNAME을 반환할 뿐이다. 클러스터 밖 데이터베이스를 내부 이름으로 참조하게 만들 때 쓰인다.
LoadBalancer 타입은 Service마다 로드밸런서가 하나씩 생기므로, HTTP 서비스가 여러 개라면 비용과 관리 부담이 커진다. 이 지점을 L7 라우팅으로 해결하는 것이 5.4의 Ingress다.
kube-proxy가 만드는 데이터 경로
ClusterIP에는 이상한 성질이 있다. 이 IP는 어떤 노드의 어떤 인터페이스에도 붙어 있지 않다. ping에 응답하는 실체가 없고, 오직 각 노드의 패킷 처리 규칙 안에서만 존재한다. 그 규칙을 만드는 것이 모든 노드에서 DaemonSet으로 동작하는 kube-proxy다.
kube-proxy는 apiserver에서 Service와 EndpointSlice를 감시하다가, 변화가 있을 때마다 노드의 규칙을 다시 쓴다. 기본인 iptables 모드에서는 “목적지가 ClusterIP:port인 패킷은 대상 Pod 중 하나의 IP:targetPort로 DNAT한다"는 iptables 체인을 생성한다. Pod가 Service IP로 보낸 패킷은 자기 노드를 떠나기 전에 이미 목적지가 실제 Pod IP로 바뀌어 있고, 그 뒤는 5.1의 Pod 간 통신 경로를 그대로 따른다.
flowchart TD
C["클라이언트 Pod"] -->|"목적지 10.96.0.15:80"| N["노드 netfilter"]
N -->|"iptables DNAT"| P1["Pod 10.244.1.5:8080"]
N -->|"iptables DNAT"| P2["Pod 10.244.2.7:8080"]
KP["kube-proxy"] -->|"규칙 갱신"| N
API["apiserver"] -->|"Service/Slice watch"| KP
이름과 달리 kube-proxy는 패킷을 직접 중계하지 않는다. 커널의 netfilter가 패킷을 처리하도록 규칙만 관리하며, 데이터 경로에서 kube-proxy 프로세스가 죽어도 이미 설치된 규칙은 계속 동작한다. iptables 모드는 규칙을 순차 평가하므로 Service가 수만 개 규모가 되면 규칙 갱신 비용이 커지는데, 이때는 커널 해시 테이블 기반인 IPVS 모드가 대안이다. 최근에는 nftables 모드도 추가됐다. Cilium처럼 kube-proxy 자체를 eBPF 구현으로 대체하는 CNI 플러그인도 있다.
headless Service와 sessionAffinity
가상 IP와 부하 분산이 오히려 방해가 되는 워크로드가 있다. 데이터베이스 replication처럼 클라이언트가 개별 Pod를 구분해서 접속해야 하는 경우다. clusterIP: None으로 선언하는 headless Service는 가상 IP를 할당하지 않으며, DNS 조회에 대상 Pod들의 IP를 직접 반환한다. 부하 분산 지점이 없어지고 선택권이 클라이언트로 넘어온다.
StatefulSet이 이 구조의 대표 사용처다. StatefulSet은 serviceName으로 headless Service를 지정하도록 요구하고, 그 결과 각 Pod가 web-0.web.default.svc.cluster.local 같은 자기만의 안정된 DNS 이름을 얻는다. Pod가 재생성되어 IP가 바뀌어도 이름은 유지되므로, primary와 replica를 구분해야 하는 데이터베이스 구성이 이 이름을 기준으로 동작한다.
한편 일반 Service에서 같은 클라이언트를 같은 Pod로 계속 보내고 싶다면 sessionAffinity: ClientIP를 설정한다. 출발지 IP 기준으로 대상 Pod가 고정되며, 유지 시간은 service.spec.sessionAffinityConfig.clientIP.timeoutSeconds(기본 10800초)로 조정한다. 다만 출발지 IP가 NAT나 프록시 뒤에 있으면 여러 클라이언트가 한 IP로 묶이므로 분산이 심하게 치우칠 여지가 있다.
정리
Service는 바뀌는 Pod IP 앞에 고정된 가상 IP와 이름을 세우는 추상이고, 실제 대상 목록은 EndpointSlice가, 데이터 경로는 각 노드에서 kube-proxy가 만든 DNAT 규칙이 담당한다. 외부 노출은 NodePort와 LoadBalancer가 단계적으로 확장하며, 개별 Pod 접근이 필요한 StatefulSet은 headless Service로 가상 IP 없이 Pod의 DNS 이름을 직접 노출한다.