Service가 필요한 이유: Pod IP의 불안정성
Kubernetes에서 Pod는 생성될 때마다 새로운 IP를 받는다. Deployment가 롤링 업데이트를 수행하면 기존 Pod는 삭제되고 새 Pod가 다른 IP로 뜬다. 클라이언트가 Pod IP를 직접 사용하면 매번 끊긴다. Service는 이 문제를 해결하는 추상화 계층으로, 셀렉터로 매칭되는 Pod 그룹에 안정적인 엔드포인트(가상 IP + DNS)를 부여한다.
Kubernetes 공식 문서는 Service를 “논리적 Pod 집합과 그에 접근하는 정책을 정의하는 추상화”로 정의한다. 이 글에서는 네 가지 Service 타입의 내부 동작, kube-proxy 모드별 차이, 그리고 운영에서 자주 마주치는 함정을 공식 문서 기반으로 정리한다.
네 가지 Service 타입 비교
| 타입 | 접근 범위 | IP 할당 | 주요 용도 |
|---|---|---|---|
ClusterIP |
클러스터 내부만 | 가상 IP (서비스 CIDR) | 내부 마이크로서비스 간 통신 |
NodePort |
노드 IP:포트로 외부 접근 | ClusterIP + 노드 포트 (30000-32767) | 개발/테스트, 외부 LB 직접 연결 |
LoadBalancer |
외부 LB를 통한 인터넷 접근 | ClusterIP + NodePort + 외부 IP | 프로덕션 외부 트래픽 수신 |
ExternalName |
DNS CNAME 반환 | 없음 (프록시 없음) | 외부 서비스(RDS 등) 추상화 |
핵심: LoadBalancer는 NodePort를 포함하고, NodePort는 ClusterIP를 포함한다. 상위 타입은 하위 타입의 기능을 모두 갖는 계층 구조다.
ClusterIP: 내부 통신의 기본
apiVersion: v1
kind: Service
metadata:
name: user-service
spec:
type: ClusterIP # 기본값이므로 생략 가능
selector:
app: user-api
ports:
- protocol: TCP
port: 80 # Service가 노출하는 포트
targetPort: 3000 # Pod 컨테이너의 실제 포트
ClusterIP Service를 생성하면 Kubernetes가 서비스 CIDR 범위에서 가상 IP를 할당한다. 클러스터 내부의 모든 Pod는 이 IP 또는 DNS 이름(user-service.default.svc.cluster.local)으로 접근할 수 있다.
Headless Service: ClusterIP를 None으로
clusterIP: None을 지정하면 가상 IP가 할당되지 않는다. DNS 질의 시 개별 Pod IP가 A 레코드로 반환된다. StatefulSet과 함께 사용해 각 Pod에 직접 접근해야 하는 경우(MySQL 리플리케이션, Kafka 브로커 등)에 필수적이다.
apiVersion: v1
kind: Service
metadata:
name: mysql-headless
spec:
clusterIP: None # Headless Service
selector:
app: mysql
ports:
- port: 3306
targetPort: 3306
# DNS 질의 결과:
# mysql-headless.default.svc.cluster.local → Pod IP 목록
# mysql-0.mysql-headless.default.svc.cluster.local → 특정 Pod IP
| 항목 | 일반 ClusterIP | Headless (clusterIP: None) |
|---|---|---|
| 가상 IP | 할당됨 | 없음 |
| DNS A 레코드 | ClusterIP 1개 | Pod IP 여러 개 |
| kube-proxy 로드밸런싱 | ✅ | ❌ (클라이언트가 직접 선택) |
| 주요 용도 | Stateless 서비스 | StatefulSet, 서비스 디스커버리 |
NodePort: 외부에서 노드 IP로 접근
apiVersion: v1
kind: Service
metadata:
name: web-nodeport
spec:
type: NodePort
selector:
app: web
ports:
- protocol: TCP
port: 80
targetPort: 8080
nodePort: 31000 # 생략하면 30000-32767에서 자동 할당
NodePort Service를 만들면 모든 노드의 해당 포트에서 트래픽을 받는다. 요청이 Pod가 없는 노드에 도착해도 kube-proxy가 Pod가 있는 노드로 전달한다. 이때 추가 홉(hop)이 발생한다.
externalTrafficPolicy: 추가 홉 제거
apiVersion: v1
kind: Service
metadata:
name: web-nodeport-local
spec:
type: NodePort
externalTrafficPolicy: Local # 기본값은 Cluster
selector:
app: web
ports:
- port: 80
targetPort: 8080
nodePort: 31000
| 정책 | 동작 | 클라이언트 IP 보존 | 부하 분산 |
|---|---|---|---|
Cluster (기본) |
모든 노드의 모든 Pod로 전달 | ❌ (SNAT 발생) | 균등 |
Local |
해당 노드의 로컬 Pod로만 전달 | ✅ | 불균등 가능 (Pod 수 차이) |
Local 정책에서 해당 노드에 Pod가 없으면 트래픽이 드롭된다. 외부 로드밸런서의 헬스체크가 이를 감지하도록 설정해야 한다.
LoadBalancer: 클라우드 환경의 외부 노출
apiVersion: v1
kind: Service
metadata:
name: web-lb
annotations:
# AWS NLB 예시 — 어노테이션은 클라우드 프로바이더별로 다르다
service.beta.kubernetes.io/aws-load-balancer-type: "nlb"
service.beta.kubernetes.io/aws-load-balancer-scheme: "internet-facing"
spec:
type: LoadBalancer
externalTrafficPolicy: Local # 클라이언트 IP 보존 + 홉 감소
selector:
app: web
ports:
- protocol: TCP
port: 443
targetPort: 8443
LoadBalancer Service를 만들면 클라우드 컨트롤러 매니저(CCM)가 외부 로드밸런서를 프로비저닝하고, status.loadBalancer.ingress에 외부 IP/호스트명을 기록한다. 내부적으로는 NodePort → ClusterIP 순서로 트래픽이 흐른다.
LoadBalancer vs Ingress
| 항목 | LoadBalancer Service | Ingress + ClusterIP |
|---|---|---|
| L4/L7 | L4 (TCP/UDP) | L7 (HTTP/HTTPS) |
| 외부 LB 수 | Service당 1개 (비용 증가) | Ingress Controller 1개로 다수 서비스 |
| 호스트/경로 라우팅 | ❌ | ✅ |
| TLS 종료 | LB 또는 Pod에서 | Ingress Controller에서 |
| TCP/gRPC | ✅ 네이티브 | Ingress Controller에 따라 다름 |
실무 기준: HTTP 서비스가 여러 개면 Ingress로 묶어 LB 비용을 줄인다. TCP/UDP 또는 gRPC가 필요하면 LoadBalancer Service를 직접 사용한다.
ExternalName: 외부 서비스를 DNS로 추상화
apiVersion: v1
kind: Service
metadata:
name: database
spec:
type: ExternalName
externalName: prod-mysql.abc123.us-east-1.rds.amazonaws.com
# Pod에서 database.default.svc.cluster.local로 접근하면
# CNAME → prod-mysql.abc123.us-east-1.rds.amazonaws.com으로 해석
ExternalName은 kube-proxy를 거치지 않는다. CoreDNS가 CNAME 레코드를 반환할 뿐이다. 셀렉터도 없고, Endpoints도 생성되지 않는다. 외부 DB나 레거시 API의 주소를 서비스 이름으로 추상화해 코드 변경 없이 엔드포인트를 교체할 수 있다.
제약: CNAME은 포트 정보를 포함하지 않는다. 기본 포트가 아닌 경우 클라이언트가 포트를 알아야 한다. 또한 HTTP Host 헤더가 ExternalName 값이 아닌 서비스 이름으로 전송되어 가상 호스팅 문제가 발생할 수 있다.
kube-proxy 모드: iptables vs IPVS
Service의 실제 트래픽 라우팅은 각 노드의 kube-proxy가 담당한다. 공식 문서에서 설명하는 두 가지 주요 모드를 비교한다.
| 항목 | iptables 모드 (기본) | IPVS 모드 |
|---|---|---|
| 로드밸런싱 | 랜덤 (probability 기반) | rr, lc, dh, sh, sed, nq 등 다양 |
| 규칙 업데이트 | 전체 규칙 재작성 (O(n)) | 해시 테이블 기반 (O(1) 조회) |
| 대규모 서비스 (1000+) | 규칙 수 폭증, 지연 증가 | 안정적 |
| 커넥션 드레인 | 제한적 | graceful connection drain 지원 |
| 필요 커널 모듈 | 기본 포함 | ip_vs, ip_vs_rr 등 필요 |
sessionAffinity와 세션 고정
기본적으로 Service는 요청마다 랜덤으로 Pod를 선택한다. 특정 클라이언트의 요청을 같은 Pod로 보내려면 sessionAffinity를 사용한다.
apiVersion: v1
kind: Service
metadata:
name: sticky-service
spec:
selector:
app: web
sessionAffinity: ClientIP
sessionAffinityConfig:
clientIP:
timeoutSeconds: 10800 # 3시간 (기본값: 10800, 최대: 86400)
ports:
- port: 80
targetPort: 8080
공식 문서에 따르면 sessionAffinity는 None(기본)과 ClientIP 두 가지만 지원한다. 쿠키 기반 세션 어피니티가 필요하면 Ingress Controller 레벨에서 설정해야 한다.
실전 체크리스트: Service 설계 7단계
- 접근 범위 결정 — 내부 전용이면 ClusterIP, 외부 노출이면 LoadBalancer 또는 Ingress 조합
- Headless 필요 여부 — StatefulSet 또는 개별 Pod 접근이 필요하면
clusterIP: None - externalTrafficPolicy 선택 — 클라이언트 IP가 필요하면
Local, 균등 분산이 우선이면Cluster - port/targetPort/nodePort 명확히 구분 — port는 Service, targetPort는 Pod, nodePort는 노드
- 셀렉터 레이블 검증 —
kubectl get endpoints <service-name>으로 실제 매칭된 Pod 확인 - LoadBalancer 비용 관리 — HTTP 서비스는 Ingress로 묶어 LB 수를 줄인다
- ExternalName 제약 인지 — 포트 정보 없음, Host 헤더 불일치 가능
흔한 실수 4가지와 방지법
실수 1: 셀렉터 레이블 불일치로 Endpoints가 비어 있음
증상: Service를 만들었는데 kubectl get endpoints에 IP가 없다. 요청이 모두 실패한다.
방지: Service의 selector와 Pod의 metadata.labels가 정확히 일치하는지 확인한다. kubectl get pods --show-labels와 대조한다.
실수 2: externalTrafficPolicy: Local에서 Pod 없는 노드로 트래픽 유입
증상: 일부 요청이 타임아웃된다. 해당 노드에 Pod가 스케줄되지 않아 트래픽이 드롭된다.
방지: 외부 LB의 헬스체크가 healthCheckNodePort(Service 생성 시 자동 할당)를 사용하도록 설정한다. Pod가 없는 노드는 헬스체크 실패로 LB 풀에서 제거된다.
실수 3: NodePort 범위를 모르고 포트 충돌
증상: nodePort: 8080을 지정했더니 “provided port is not in the valid range” 에러.
방지: NodePort 기본 범위는 30000-32767이다. 이 범위 밖의 포트를 사용하려면 kube-apiserver의 --service-node-port-range 플래그를 변경해야 한다.
실수 4: Service 없이 Pod IP를 직접 사용
증상: 디플로이먼트 업데이트 후 클라이언트 연결이 끊긴다. Pod IP가 바뀌었기 때문이다.
방지: Pod IP를 직접 사용하지 않는다. 항상 Service DNS 이름(<service>.<namespace>.svc.cluster.local)을 사용한다. 이것이 Service가 존재하는 이유다.
마무리
Kubernetes Service는 불안정한 Pod IP 위에 안정적인 네트워크 추상화를 제공한다. ClusterIP로 내부 통신을 묶고, NodePort/LoadBalancer로 외부 트래픽을 받으며, ExternalName으로 외부 서비스를 추상화한다. externalTrafficPolicy와 sessionAffinity는 운영 환경에서 반드시 의식적으로 선택해야 하는 옵션이다. 이 글의 모든 내용은 Kubernetes 공식 문서(Service, Virtual IPs and Service Proxies)를 근거로 한다.