DevOps/Kubernetes

[Kubernetes] Service

러러 2026. 7. 30. 10:25

Service는 프로세스가 아니라 규칙(rule) 이다.
어딘가에 트래픽을 받아 넘겨주는 프록시 서버가 떠 있는 게 아니라, 모든 노드의 커널에 심어진 패킷 변환 규칙의 집합이다. 이 한 문장을 이해하면 Service의 거의 모든 동작이 설명된다.


Service가 왜 필요한가

Pod는 일회용(ephemeral) 이다. 재시작되면 새 IP를 받고, 스케일 아웃되면 개수가 바뀌고, 노드가 죽으면 다른 노드에서 태어난다. 그래서 Pod IP를 직접 부르는 구조는 성립할 수 없다.

문제 Service가 없다면 Service가 있다면
Pod 재생성 시 IP 변경 호출하는 쪽 설정을 매번 수정 ClusterIP는 Service 수명 동안 불변
Pod가 N개 클라이언트가 직접 분배 로직 구현 커널 레벨에서 자동 분산
Pod 위치를 모름 IP를 조회해 하드코딩 DNS 이름으로 호출
Pod가 죽는 중 죽은 Pod로 계속 요청 Ready 아닌 Pod는 자동 제외

정리하면 Service는 Service Discovery(어디에 있는지)Load Balancing(누구에게 보낼지) 를 동시에 해결한다.

Service가 다루는 계층
Service는 L4(TCP/UDP/SCTP) 리소스다. HTTP 경로(/api)나 Host 헤더로 나누는 건 Service의 일이 아니다. 그건 Ingress 또는 Gateway API의 영역이다.

핵심 동작 원리

전체 흐름

  1. Service 생성 → API Server가 Service CIDR 대역에서 ClusterIP를 할당한다.
  2. EndpointSlice Controller(kube-controller-manager)가 selector와 label이 일치하는 Pod를 watch한다.
  3. 조건을 만족하는 Pod의 IP:Port를 EndpointSlice에 기록한다.
  4. 각 노드의 kube-proxy가 EndpointSlice를 watch해서 자기 노드 커널에 규칙을 심는다.
  5. 클라이언트가 ClusterIP로 패킷을 보내면 커널이 목적지를 실제 Pod IP로 DNAT 한다.
트래픽은 Service를 "거치지" 않는다

ClusterIP는 어떤 인터페이스에도 바인딩되지 않은
가상 IP다. ping이 안 되는 게 정상이다.
패킷은 Service라는 중간 지점을 경유하는 게 아니라, 출발 노드의 커널에서 곧바로 목적지 Pod IP로 주소가 바뀌어 나간다. 그래서 Service는 SPOF가 없고 홉이 하나도 늘지 않는다.

실제로 심기는 규칙 (iptables 모드)

iptables-save를 떠보면 체인이 3단으로 걸려 있다.

KUBE-SERVICES                 # ClusterIP:Port 매칭 → 해당 Service 체인으로
  └─ KUBE-SVC-XXXXXXXX        # 백엔드 중 하나를 확률적으로 선택
       ├─ KUBE-SEP-AAAA  (probability 0.333…)  → DNAT to 10.244.1.5:8080
       ├─ KUBE-SEP-BBBB  (probability 0.500)   → DNAT to 10.244.2.7:8080
       └─ KUBE-SEP-CCCC  (나머지)               → DNAT to 10.244.3.9:8080
sudo iptables-save -t nat | grep KUBE-SVC
  • SVC = Service, SEP = Service EndPoint.
  • 로드밸런싱은 라운드로빈이 아니라 iptables statistic 모듈의 무작위 확률 분배다.
    확률이 1/3 → 1/2 → 1로 커지는 이유는, 규칙을 위에서부터 순차 평가하면서 남은 후보 중 균등하게 고르기 위해서다.
  • 규칙이 선형 리스트라 Service·Pod가 수천 개가 되면 갱신 비용이 O(n) 으로 커진다. IPVS/nftables 모드가 나온 이유다.

kube-proxy 모드 비교

모드 방식 특징
userspace kube-proxy가 직접 프록시 홉이 늘어 느림. 제거됨
iptables netfilter 규칙 (기본값) 커널 처리라 빠름. 규칙 수가 많아지면 갱신이 느려짐
IPVS 커널 L4 로드밸런서 + ipset 해시 테이블 O(1). rr, lc, sh 등 알고리즘 선택 가능
nftables iptables 후속 netfilter API 대규모에서 갱신·조회 성능 개선
kubectl -n kube-system get cm kube-proxy -o yaml | grep -i mode
버전 확인
nftables 모드, trafficDistribution 같은 항목은 비교적 최근에 추가·승격됐다. 쓰기 전에 kubectl version으로 서버 버전 확인이 필수이다.

Endpoints와 EndpointSlice

Service가 selector로 Pod를 고른 결과가 저장되는 리소스다. Service와 같은 이름으로 자동 생성된다.

kubectl get endpoints my-svc
kubectl get endpointslice -l kubernetes.io/service-name=my-svc

Endpoints (레거시)

apiVersion: v1
kind: Endpoints
metadata:
  name: my-svc          # Service와 이름이 같아야 연결된다
subsets:                # 같은 포트 구성을 공유하는 주소 묶음
  - addresses:          # Ready 상태 Pod
      - ip: 10.244.1.5
      - ip: 10.244.2.7
    notReadyAddresses:  # 아직/이미 Ready가 아닌 Pod
      - ip: 10.244.3.9
    ports:
      - port: 8080

EndpointSlice (현재 기본)

apiVersion: discovery.k8s.io/v1
kind: EndpointSlice
metadata:
  name: my-svc-7vxqp
  labels:
    kubernetes.io/service-name: my-svc   # 이 label로 Service와 연결
addressType: IPv4
endpoints:
  - addresses: ["10.244.1.5"]
    conditions:
      ready: true
      serving: true        # 실제로 트래픽 처리 가능한지
      terminating: false   # 종료 진행 중인지
    nodeName: node-1
    zone: ap-northeast-2a  # 토폴로지 기반 라우팅에 사용
ports:
  - port: 8080
    protocol: TCP

 

  Endpoints (v1)  EndpointSlice (discovery.k8s.io/v1)
구조 1개 오브젝트에 전체 목록 조각 단위로 분할 (기본 조각당 100개)
변경 전파 Pod 하나만 바뀌어도 전체를 재전송 → 모든 노드에 부하 바뀐 조각만 전송
규모 한계 주소 1000개에서 잘림 (endpoints.kubernetes.io/over-capacity 어노테이션) 조각을 늘려 확장
부가 정보 없음 zone, nodeName, serving, terminating
상태 deprecated, 호환용으로만 유지 v1.21부터 기본
ready와 serving은 다르다

Pod가 종료되기 시작하면 ready: false가 되지만, graceful shutdown 중이라 아직 응답은 할 수 있다. 이 상태를 표현하는 게 serving: true, terminating: true다.
덕분에 모든 백엔드가 종료 중일 때 요청을 즉시 실패시키는 대신 종료 중인 Pod로라도 흘려보낼 수 있다.

 

등록되는 조건

  • Pod의 label이 selector를 전부 만족할 것 (selector는 AND, matchExpressions 미지원)
  • Pod에 IP가 할당되어 있을 것
  • Pod가 Ready 일 것 → Readiness Probe 실패 시 자동으로 빠진다
    • Ready가 아니어도 등록하려면 spec.publishNotReadyAddresses: true (StatefulSet headless에서 사용)
무중단 배포의 진짜 병목

Pod가 삭제되면 (a) EndpointSlice에서 제거되는 흐름과 (b) 컨테이너에 SIGTERM이 가는 흐름이 동시에 진행된다. kube-proxy가 규칙을 지우기 전에 앱이 먼저 죽으면 그 짧은 순간 커넥션 에러가 난다.
→ preStop 훅에 몇 초 sleep을 줘서 규칙 삭제가 전파될 시간을 벌어주는 패턴이 흔하다.

Service 타입

LoadBalancer ⊃ NodePort ⊃ ClusterIP. 상위 타입을 만들면 하위 기능이 자동으로 딸려온다. NodePort를 만들면 ClusterIP도 같이 생긴다.

ClusterIP (기본값)

클러스터 내부 전용. 백엔드 API, DB 등 외부에 열 필요 없는 대상.

apiVersion: v1
kind: Service
metadata:
  name: my-svc
spec:
  type: ClusterIP
  selector:
    app: my-app
  ports:
    - name: http
      port: 80          # Service가 여는 포트
      targetPort: 8080  # Pod 컨테이너 포트
      protocol: TCP

NodePort

모든 노드의 같은 포트(기본 30000–32767)를 연다. Pod가 없는 노드로 들어와도 규칙에 의해 Pod가 있는 노드로 넘어간다.

spec:
  type: NodePort
  ports:
    - port: 80
      targetPort: 8080
      nodePort: 30080   # 생략하면 범위 내 자동 할당
  • 포트 범위 변경: kube-apiserver --service-node-port-range
  • 노드 IP가 바뀌거나 늘어나면 클라이언트가 그걸 알아야 해서 운영 환경에서 단독으로 쓰기엔 부적절하다. 보통 LB의 백엔드로 쓰인다.

LoadBalancer

클라우드 프로바이더에 실제 L4 로드밸런서를 요청한다. Cloud Controller Manager가 처리한다.

spec:
  type: LoadBalancer
  loadBalancerClass: service.k8s.aws/nlb   # LB 구현이 여럿일 때 지정
  allocateLoadBalancerNodePorts: false     # LB가 Pod IP로 직접 갈 때 NodePort 낭비 방지
  ports:
    - port: 80
      targetPort: 8080
  • EXTERNAL-IP가 계속 <pending> → 클라우드 연동이 없는 환경(온프렘, kind, minikube). MetalLB 등이 필요하다.
  • Service 하나당 LB 하나 = 비용도 하나. HTTP 트래픽을 여러 서비스로 나눌 거면 LB 하나 + Ingress 조합이 정석이다.

ExternalName

selector도 EndpointSlice도 ClusterIP도 없다. CoreDNS가 CNAME만 돌려준다.

spec:
  type: ExternalName
  externalName: my-db.ap-northeast-2.rds.amazonaws.com
  • 앱 코드는 my-db만 부르면 되고, 환경별로 이 Service만 갈아끼우면 된다.
  • DNS 단계에서 끝나므로 포트 매핑이 불가능하고, HTTP 클라이언트가 Host 헤더를 원래 이름으로 보내 TLS/가상호스트에서 문제가 생길 수 있다.

3개의 포트와 멀티 포트

port / targetPort / nodePort

-  port :
Service가 노출하는 포트. my-svc:80의 80.
-  targetPort : Pod 컨테이너의 포트. 실제 도착지. 생략하면 port와 같은 값.
-  nodePort : 노드에 열리는 포트. NodePort/LoadBalancer에서만.

 

targetPort에 숫자 대신 컨테이너 포트 이름을 쓰면, Pod마다 포트가 달라도 Service 하나로 커버할 수 있다.

# Pod
ports:
  - name: web
    containerPort: 8080
---
# Service
ports:
  - port: 80
    targetPort: web     # 이름으로 참조

포트를 2개 이상 열 때는 name이 필수다.

ports:
  - name: http
    port: 80
    targetPort: 8080
  - name: metrics
    port: 9090
    targetPort: 9090

 


Headless Service

clusterIP: None → 가상 IP를 할당하지 않고 kube-proxy도 규칙을 만들지 않는다. DNS가 Pod IP 목록을 그대로 반환한다.

spec:
  clusterIP: None
  selector:
    app: my-db
nslookup my-db
# 10.244.1.5, 10.244.2.7, 10.244.3.9  ← ClusterIP가 아니라 Pod IP들

쓰는 이유:

  • 개별 Pod를 특정해야 할 때 — StatefulSet의 주 용도.
    my-db-0.my-db.default.svc.cluster.local 처럼 Pod 하나를 지목할 수 있다. (DB primary/replica 구분 등)
  • 클라이언트가 직접 로드밸런싱할 때 — gRPC처럼 커넥션을 오래 유지하는 프로토콜은 L4 분산이 무의미하다(첫 연결 이후 계속 같은 Pod로 간다). 클라이언트에 Pod 목록을 주고 요청 단위로 분산시키는 편이 낫다.

Service Discovery: DNS와 환경변수

DNS (권장)

<service>.<namespace>.svc.cluster.local

호출 위치 쓸 수 있는 이름

같은 namespace my-svc
다른 namespace my-svc.other-ns
FQDN my-svc.default.svc.cluster.local

이름 있는 포트는 SRV 레코드로도 조회된다.

_http._tcp.my-svc.default.svc.cluster.local
ndots 때문에 DNS가 느려질 때
Pod의 /etc/resolv.conf에는 search 도메인이 여러 개 있고 ndots:5가 기본이다. 그래서 google.com 같은 외부 도메인을 조회하면 실패할 질의를 여러 번 반복한 뒤에야 성공한다.
외부 호출이 많다면 FQDN(끝에 .)을 쓰거나 dnsConfig.options로 ndots를 낮춘다.

환경변수 (레거시)

Pod 생성 시점에 이미 존재하는 Service들이 환경변수로 주입된다.

MY_SVC_SERVICE_HOST=10.96.0.10
MY_SVC_SERVICE_PORT=80

Service보다 Pod가 먼저 생기면 값이 안 들어온다(순서 의존). 쓰지 말고 DNS를 쓰자.

Service가 많으면 환경변수 폭증으로 기동이 느려지므로 spec.enableServiceLinks: false로 끌 수 있다.


selector 없는 Service (외부 엔드포인트 수동 연결)

selector를 비우면 EndpointSlice가 자동 생성되지 않는다. 직접 만들어주면 클러스터 밖 IP를 Service처럼 쓸 수 있다.

apiVersion: v1
kind: Service
metadata:
  name: legacy-db
spec:
  ports:
    - port: 3306
      targetPort: 3306
---
apiVersion: discovery.k8s.io/v1
kind: EndpointSlice
metadata:
  name: legacy-db-1
  labels:
    kubernetes.io/service-name: legacy-db   # 이 label로 Service와 매칭
addressType: IPv4
endpoints:
  - addresses: ["192.168.0.100"]            # 클러스터 외부 IP
ports:
  - port: 3306

 

  ExternalName selector 없는 Service
바라보는 대상 DNS 이름 (CNAME) IP 주소
ClusterIP 없음 있음
포트 매핑 불가 가능
활용 RDS 등 DNS로 제공되는 엔드포인트 고정 IP 레거시 서버, 온프렘 DB

트래픽 정책  

필드 효과
sessionAffinity None(기본) / ClientIP 같은 출발지 IP를 같은 Pod로 고정.
sessionAffinityConfig.clientIP.timeoutSeconds로 유지시간 조절(기본 10800초)
externalTrafficPolicy Cluster(기본) / Local 외부에서 들어온 트래픽 처리 방식
internalTrafficPolicy Cluster(기본) / Local 클러스터 내부 트래픽 처리 방식
trafficDistribution PreferClose 같은 zone의 엔드포인트 우선 선택 (교차 AZ 요금 절감)

externalTrafficPolicy가 중요한 이유

  • Cluster : 모든 노드가 트래픽을 받고 필요하면 다른 노드로 넘긴다.
    균등하게 분산되지만 SNAT 때문에 클라이언트 원본 IP가 사라지고 홉이 하나 늘어난다.
  • Local : 자기 노드의 Pod로만 보낸다. 원본 IP가 보존되고 홉이 없다. (DaemonSet일 때 유리)
    대신 Pod가 없는 노드로 온 트래픽은 버려지고, 노드별 Pod 수가 다르면 분산이 치우친다.
    • 클라우드 LB는 healthCheckNodePort 헬스체크로 "Pod가 있는 노드"만 골라 보내므로 실제로는 드롭되지 않는다.
    • 접속 로그에 클라이언트 IP를 남겨야 하거나 IP 기반 rate limit이 필요하면 사실상 필수다.

그 외 알아둘 필드

spec:
  ipFamilyPolicy: PreferDualStack   # SingleStack / PreferDualStack / RequireDualStack
  ipFamilies: [IPv4, IPv6]
  externalIPs: ["203.0.113.10"]     # 이 IP로 노드에 들어온 트래픽도 이 Service로
  publishNotReadyAddresses: true
  ports:
    - port: 80
      appProtocol: http             # L7 프로토콜 힌트 (LB 컨트롤러가 참고)

Service의 한계와 다음 단계

필요한 것 Service로 되나 대안
경로/호스트 기반 라우팅 ❌ (L4) Ingress, Gateway API
TLS 종료 Ingress, Gateway API
가중치 기반 카나리 Gateway API, Service Mesh
재시도·서킷브레이커 Service Mesh (Istio, Linkerd)
gRPC 요청 단위 분산 ❌ (커넥션 단위) Headless + 클라이언트 LB, Mesh
트래픽 암호화(mTLS) Service Mesh

트러블슈팅

kubectl get svc my-svc                      # ClusterIP / TYPE / PORT
kubectl get endpointslice -l kubernetes.io/service-name=my-svc -o yaml
kubectl describe svc my-svc                 # Selector, Events
kubectl get pods --show-labels              # label 오타 확인
kubectl run tmp --rm -it --image=nicolaka/netshoot -- bash
#   nslookup my-svc  /  curl -v my-svc:80  /  nc -zv my-svc 80
sudo iptables-save -t nat | grep KUBE-SVC   # 노드에서 규칙 확인

 

증상 확인할 것
ENDPOINTS: <none> selector ↔ Pod label 불일치, Readiness Probe 실패, Pod가 다른 namespace
Endpoints는 있는데 연결 실패 targetPort ↔ 컨테이너 실제 리스닝 포트 불일치, 앱이 127.0.0.1에만 바인딩(→ 0.0.0.0 필요)
DNS가 안 풀림 namespace 누락, CoreDNS Pod 상태, resolv.conf의 search/ndots
간헐적으로만 실패 일부 Pod만 비정상인데 Endpoints에 남아 있음 → Readiness Probe 부재
NodePort 접근 불가 보안그룹·방화벽에서 30000–32767 차단
특정 Pod로만 쏠림 Keep-Alive/gRPC 커넥션 재사용, 또는 sessionAffinity: ClientIP
배포 중 502·커넥션 끊김 preStop 훅 부재, graceful shutdown 미구현
클라이언트 IP가 노드 IP로 찍힘 externalTrafficPolicy: Cluster → Local 검토