Kubernetes Service: ClusterIP

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

공식 문서에 따르면 sessionAffinityNone(기본)과 ClientIP 두 가지만 지원한다. 쿠키 기반 세션 어피니티가 필요하면 Ingress Controller 레벨에서 설정해야 한다.

실전 체크리스트: Service 설계 7단계

  1. 접근 범위 결정 — 내부 전용이면 ClusterIP, 외부 노출이면 LoadBalancer 또는 Ingress 조합
  2. Headless 필요 여부 — StatefulSet 또는 개별 Pod 접근이 필요하면 clusterIP: None
  3. externalTrafficPolicy 선택 — 클라이언트 IP가 필요하면 Local, 균등 분산이 우선이면 Cluster
  4. port/targetPort/nodePort 명확히 구분 — port는 Service, targetPort는 Pod, nodePort는 노드
  5. 셀렉터 레이블 검증kubectl get endpoints <service-name>으로 실제 매칭된 Pod 확인
  6. LoadBalancer 비용 관리 — HTTP 서비스는 Ingress로 묶어 LB 수를 줄인다
  7. 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으로 외부 서비스를 추상화한다. externalTrafficPolicysessionAffinity는 운영 환경에서 반드시 의식적으로 선택해야 하는 옵션이다. 이 글의 모든 내용은 Kubernetes 공식 문서(Service, Virtual IPs and Service Proxies)를 근거로 한다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux