콘텐츠로 이동
Study NoteCKA

Service

변하는 Pod 앞의 고정된 주소

  • Pod IP는 변한다. 재시작·재배치될 때마다 새 IP를 받는다
  • Pod은 여러 개다. 어디로 보낼지 누군가 정해야 한다
  • 그렇다고 클라이언트가 Pod 목록을 직접 관리할 수는 없다

Service는 고정된 이름과 IP를 주고, 라벨 셀렉터에 맞는 Pod들에게 부하를 나눠 보낸다.

그런데 Service는 프로세스가 아니다. 어디서도 돌고 있지 않다. 이 점이 처음에 가장 헷갈린다 — 바로 아래에서 정체를 본다.

Service의 정체 — 가상 IP와 커널 규칙

섹션 제목: “Service의 정체 — 가상 IP와 커널 규칙”

답부터 말하면 노드마다 도는 kube-proxy가 커널에 심어 둔 규칙이다 (아키텍처의 노드 컴포넌트). 시작하기 전에의 축이 여기에도 그대로 적용된다 — Service·EndpointSlice라는 선언을 kube-proxy가 watch하고 있다가, 커널의 NAT 규칙이라는 실제 상태를 선언에 맞게 계속 고쳐 놓는 루프다.

kube-proxy 가 규칙만 심고 실제 트래픽은 노드 커널이 직접 DNAT 해서 kube-proxy 를 통과하지 않는 순서
  • ClusterIP는 아무 인터페이스에도 붙어 있지 않은 가상 IP다. ping이 안 된다
  • kube-proxy가 모든 노드의 커널에 규칙을 심는다: “목적지가 10.96.0.10:80이면 → 10.244.1.5:8080 또는 10.244.2.7:8080 으로 DNAT(Destination NAT — 목적지 주소를 바꿔치기)”
  • 그래서 트래픽이 어떤 프록시도 거치지 않는다. 커널이 직접 바꾼다
터미널 창
# 노드에서 직접 확인 (iptables 모드)
sudo iptables -t nat -L KUBE-SERVICES -n | head
sudo ipvsadm -Ln # ipvs 모드일 때

DNAT 다음은 배달 — kube-proxy와 CNI의 분업

섹션 제목: “DNAT 다음은 배달 — kube-proxy와 CNI의 분업”

위 시퀀스는 목적지 주소가 바뀌는 데까지만 보여 준다. DNAT은 패킷의 목적지를 Pod IP로 바꿔치기할 뿐, 바뀐 주소까지 실어 나르지는 않는다. 그 배달은 CNI(Container Network Interface) 플러그인 — Calico·Cilium·Flannel — 이 미리 깔아 둔 Pod 네트워크의 몫이다. Service 하나가 동작하려면 서로 다른 두 층이 맞물려야 한다.

층대역실현하는 주체하는 일
Service 층Service CIDR — apiserver의 --service-cluster-ip-rangekube-proxy가 커널 규칙으로가상 IP를 Pod IP로 번역(DNAT)
Pod 층Pod CIDR — controller-manager의 --cluster-cidr 또는 CNI 자체 IPAMCNI 플러그인이 인터페이스·라우팅으로Pod IP 할당과 노드 사이 배달

패킷 하나의 여정으로 보면 —

  1. 클라이언트 Pod가 10.96.0.10:80(ClusterIP)으로 보낸다. Service CIDR의 가상 주소다
  2. 그 노드의 커널이 kube-proxy가 심어 둔 규칙으로 목적지를 10.244.1.5:8080으로 바꾼다. Service 층의 일은 여기서 끝난다
  3. 바뀐 Pod IP까지는 CNI가 깔아 둔 라우팅·터널이 나른다. 백엔드 Pod가 다른 노드에 있어도 Pod 네트워크가 이어 준다

두 대역이 겹치면 안 되는 이유가 여기 있다 — 커널 입장에서 “번역해야 할 가상 주소”와 “그대로 배달할 진짜 주소”가 대역으로 구분되어야 하기 때문이다. CNI가 IP를 어떻게 주고 누가 실행하는지는 확장 CNI 절에서 본다. Cilium처럼 kube-proxy의 역할까지 eBPF로 흡수해 kube-proxy 없이 도는 CNI도 있지만, 시험 환경은 표준 구성(kube-proxy DaemonSet + CNI)이다.

ClusterIP (기본)

클러스터 내부만. 가상 IP 하나를 준다.

NodePort

클러스터 외부에서. 모든 노드의 특정 포트를 연다.

LoadBalancer

클러스터 외부에서. 클라우드 LB를 프로비저닝한다.

ExternalName

프록시하지 않는다. DNS CNAME만 반환한다.

포함 관계다. NodePort는 ClusterIP를 포함하고, LoadBalancer는 NodePort를 포함한다. LoadBalancer를 만들면 ClusterIP도, NodePort도 함께 생긴다.

외부 클라이언트가 LoadBalancer · NodePort 를 차례로 거쳐 ClusterIP 에 닿고 내부 Pod 은 곧장 ClusterIP 로 들어가는 계층
apiVersion: v1
kind: Service
metadata:
name: web-svc
spec:
type: ClusterIP
selector:
app: web # 이 라벨을 가진 Pod들에게 보낸다
ports:
- name: http
protocol: TCP
port: 80 # Service가 여는 포트
targetPort: 8080 # Pod의 포트
터미널 창
kubectl expose deploy web --port=80 --target-port=8080 --name=web-svc
kubectl create svc clusterip web-svc --tcp=80:8080

시험 중 YAML을 직접 써야 하면 공식 Service 문서의 예시를 복사해 이름·셀렉터·포트만 바꾸는 게 가장 빠르다.

nodePort 는 노드, port 는 Service, targetPort 는 Pod 을 가리켜 세 포트가 각각 다른 구간을 맡는다는 대응

port = Service의 포트 · targetPort = Pod의 포트 · nodePort = 노드의 포트

targetPort는 이름으로도 쓸 수 있다

섹션 제목: “targetPort는 이름으로도 쓸 수 있다”
# Pod
containers:
- name: app
ports:
- name: http-api # 이름을 붙인다
containerPort: 8080
# Service
ports:
- port: 80
targetPort: http-api # 숫자 대신 이름
  • Pod마다 포트가 달라도 같은 Service로 묶을 수 있다
  • 포트를 바꿔도 Service를 안 고쳐도 된다

셀렉터와 엔드포인트 — 실제 연결의 실체

섹션 제목: “셀렉터와 엔드포인트 — 실제 연결의 실체”

Service의 spec에는 셀렉터라는 조건만 있지, 실제 Pod IP는 한 줄도 없다. 그 조건을 “지금 이 순간의 IP 목록”으로 계속 번역해 주는 무언가가 없으면 kube-proxy는 규칙을 만들 수 없다 — 그 번역 결과를 담는 오브젝트가 엔드포인트다.

엔드포인트 컨트롤러가 라벨이 맞고 Ready 인 Pod 만 골라 EndpointSlice 에 넣고 kube-proxy 가 그것을 규칙으로 반영하는 흐름
터미널 창
kubectl get endpoints web-svc
# NAME ENDPOINTS AGE
# web-svc 10.244.1.5:8080,10.244.2.7:8080 5m
kubectl get endpointslices -l kubernetes.io/service-name=web-svc
kubectl describe svc web-svc # Endpoints 줄을 본다
  • 엔드포인트 컨트롤러(kube-controller-manager 안의 제어 루프 중 하나 — 아키텍처)가 셀렉터에 맞고 Ready인 Pod의 IP를 여기에 채운다
  • ENDPOINTS가 비어 있으면 Service는 아무 데도 못 보낸다
  • 옛 Endpoints는 오브젝트 하나에 모든 IP를 담았다 → Pod이 수천 개면 갱신 비용이 폭발
  • EndpointSlice는 100개씩 쪼개서 담는다. 지금은 이쪽이 실제 데이터 소스다 — 시험 커리큘럼에도 명시된 항목이니 두 이름의 관계는 알아둘 가치가 있다
  • kubectl get endpoints는 여전히 동작한다 (호환용으로 유지)
터미널 창
kubectl get endpointslices
# NAME ADDRESSTYPE PORTS ENDPOINTS AGE
# web-svc-x7k2p IPv4 8080 10.244.1.5,10.244.2.7 5m
kubectl get endpointslice web-svc-x7k2p -o yaml
# endpoints:
# - addresses: ["10.244.1.5"]
# conditions: { ready: true, serving: true, terminating: false }
# nodeName: node01

conditions.ready가 readinessProbe의 결과다. 이 한 줄이 트래픽을 켜고 끈다. 나머지 둘은 종료 처리용이다 — terminating은 Pod이 삭제 중이라는 표시, serving은 종료 중이어도 아직 응답은 가능하다는 표시다. 종료 중인 Pod에도 트래픽을 흘리는 특수한 경우에만 쓰이니 이름만 알아두면 된다.

readinessProbe 가 성공하면 ready true 로 트래픽을 받고 실패하면 ready false 로 엔드포인트에서 빠지는 두 갈래
spec:
type: NodePort
selector:
app: web
ports:
- port: 80
targetPort: 8080
nodePort: 30080 # 생략하면 30000-32767 중에서 자동 할당

모든 노드가 그 포트를 연다. Pod이 없는 노드로 가도 전달된다.

NodePort 는 모든 노드에서 열리므로 Pod 이 없는 노드로 들어와도 Pod 이 있는 노드로 전달된다는 흐름
  • 범위는 기본 30000-32767 (apiserver의 --service-node-port-range로 변경)
터미널 창
kubectl create svc nodeport web-svc --tcp=80:8080 --node-port=30080
curl http://<노드IP>:30080
# LoadBalancer
spec:
type: LoadBalancer
selector:
app: web
ports:
- port: 80
targetPort: 8080
  • 클라우드 컨트롤러가 실제 LB를 프로비저닝하고 status.loadBalancer.ingress에 주소를 채운다
  • 온프레미스에는 그 컨트롤러가 없다 → EXTERNAL-IP가 영원히 <pending> (MetalLB 같은 것을 설치해야 한다 — 온프레미스에서 LB 프로비저닝을 대신해 주는 오픈소스다. 이름만 알아두면 된다)
type LoadBalancer 는 클라우드 컨트롤러가 있어야 실제 LB 가 붙고 온프레미스에서는 EXTERNAL-IP 가 pending 에 머문다는 갈래
# ExternalName — 프록시하지 않는다. DNS CNAME만 준다
spec:
type: ExternalName
externalName: db.example.com

클러스터 안에서 my-db.default.svc.cluster.local 을 조회하면 db.example.com 으로 CNAME(Canonical Name — “이 이름은 저 이름의 별칭”이라는 DNS 레코드)이 돌아온다. 셀렉터도 엔드포인트도 없다.

실무에서도 드물게 쓰는 타입이다 — 깊게 팔 필요는 없고, “프록시 없이 DNS 별칭만 준다”는 한 줄이면 충분하다.

셀렉터 없는 Service — 외부를 클러스터 이름으로시험 비중 낮음

섹션 제목: “셀렉터 없는 Service — 외부를 클러스터 이름으로”
apiVersion: v1
kind: Service
metadata:
name: external-db
spec:
ports:
- port: 5432
targetPort: 5432
# selector 없음 → 엔드포인트를 직접 만든다
---
apiVersion: discovery.k8s.io/v1
kind: EndpointSlice
metadata:
name: external-db-1
labels:
kubernetes.io/service-name: external-db # ← 이 라벨로 연결된다
addressType: IPv4
ports:
- port: 5432
endpoints:
- addresses: ["192.168.10.50"]
셀렉터 없는 Service 와 수동 EndpointSlice 로 클러스터 밖 DB 를 가리키고 나중에 셀렉터만 붙이면 앱은 그대로 두고 안으로 옮길 수 있다는 구조
  • 클러스터 밖의 DB를 external-db라는 이름으로 부를 수 있다
  • 나중에 DB를 클러스터 안으로 옮겨도 앱은 그대로다 — 마이그레이션 기법

시험에 자주 나오는 패턴은 아니다 — “셀렉터 대신 수동 EndpointSlice로도 Service를 채울 수 있다”는 것만 기억해 두면 된다.

지금까지는 “여러 Pod을 IP 하나 뒤에 감춘다”가 장점이었다. 그런데 데이터베이스처럼 어느 인스턴스인지가 중요한 앱은 그 감춤이 오히려 방해가 된다 — 리더에게만 쓰기를 보내야 하는데 커널이 아무 Pod이나 고르기 때문이다. 그래서 가상 IP 없이 Pod IP 목록을 그대로 돌려주는 모드가 따로 있다.

일반 Service 는 가상 IP 하나를 주고 커널이 뒤의 Pod 중 하나를 골라 DNAT 하는 모습

DNS가 ClusterIP 하나를 준다. 어느 Pod으로 갈지는 커널이 정한다.

쓰는 곳

  • StatefulSet의 Pod별 안정적 DNS (워크로드)
  • 클라이언트 측 로드밸런싱 (gRPC 등)
  • 각 인스턴스를 구분해야 하는 클러스터형 소프트웨어 (Kafka, Cassandra…)

headless에 셀렉터까지 없는 조합이면 — 앞 절의 수동 EndpointSlice와 합쳐진 경우 — DNS는 수동으로 등록한 IP 목록을 그대로 반환한다.

모드방식특징
iptablesNAT 규칙 체인기본값. 서비스가 많으면 규칙이 선형적으로 늘어난다
ipvs커널 L4 로드밸런서대규모에 유리. 여러 분배 알고리즘(rr 라운드 로빈 · lc 최소 연결 · sh 소스 해싱…)
nftablesiptables의 후속신규 클러스터용. 성능 개선
터미널 창
kubectl get ds kube-proxy -n kube-system
kubectl get cm kube-proxy -n kube-system -o yaml | grep -i mode
kubectl logs -n kube-system -l k8s-app=kube-proxy --tail=20

어느 모드든 동작은 같다. 트래픽 경로가 아니라 규칙을 심는 방식이 다를 뿐이다.

시험에서 모드별 내부 구현까지 파고들지는 않는다 — 위 명령으로 지금 클러스터가 어느 모드인지 확인할 수 있으면 충분하다.

sessionAffinity — 기본값은 매 요청을 아무 Pod에나 분배하는 것이라, 로그인 세션처럼 Pod 안에 상태가 남는 앱은 요청이 다른 Pod으로 가면 깨진다. 같은 클라이언트를 같은 Pod에 고정하고 싶을 때 ClientIP를 준다. 시험에서 무게가 실리는 쪽은 아래 externalTrafficPolicy다 — sessionAffinity는 이런 필드가 있다는 것만 알아두면 된다.

spec:
sessionAffinity: ClientIP # None(기본) | ClientIP
sessionAffinityConfig:
clientIP:
timeoutSeconds: 10800
externalTrafficPolicy: Local # Cluster(기본) | Local

externalTrafficPolicy (NodePort/LoadBalancer에만 해당)

externalTrafficPolicy Cluster 에서는 다른 노드로 전달할 때 SNAT 이 걸려 앱이 보는 소스 IP 가 노드 IP 로 바뀐다는 경로

어느 노드로 와도 다른 노드의 Pod으로도 보낸다. 부하는 고르지만 소스 IP가 SNAT(Source NAT — 출발지 주소를 바꿔치기)로 가려진다.

spec:
selector:
app: web
ports:
- name: http # 포트가 2개 이상이면 name이 필수다
port: 80
targetPort: 8080
- name: https
port: 443
targetPort: 8443
- name: metrics
port: 9090
targetPort: 9090
protocol: TCP
  • 포트가 둘 이상이면 name을 반드시 줘야 한다 (하나면 생략 가능)
  • 이 이름은 DNS SRV(Service 레코드 — 이름으로 포트 번호까지 알려주는 DNS 레코드 종류)와 Ingress의 port.name에서 참조된다. SRV 쪽은 이름만 알아두면 된다 — 실제로 쓸 일은 Ingress·NetworkPolicy에서 포트를 이름으로 가리킬 때다
터미널 창
kubectl create svc clusterip web-svc --tcp=80:8080 --tcp=443:8443
  1. Service가 존재하고 셀렉터가 맞는가

    터미널 창
    kubectl get svc web-svc
    kubectl describe svc web-svc # Selector / Endpoints 확인
  2. 엔드포인트가 채워졌는가 — ★ 여기서 대부분 갈린다

    터미널 창
    kubectl get endpoints web-svc
  3. Pod 라벨이 셀렉터와 일치하는가

    터미널 창
    kubectl get pods -l app=web --show-labels
  4. Pod이 Ready인가 — readinessProbe 실패면 엔드포인트에서 빠진다

    터미널 창
    kubectl get pods -l app=web
  5. Pod에 직접 붙어보기 — Service를 건너뛴다

    터미널 창
    kubectl run tmp --image=busybox:1.36 --rm -it --restart=Never -- \
    wget -qO- http://10.244.1.5:8080
  6. Service를 통해 붙어보기

    터미널 창
    kubectl run tmp --image=busybox:1.36 --rm -it --restart=Never -- \
    wget -qO- http://web-svc.default.svc.cluster.local
Pod IP · ClusterIP · 이름 · 네임스페이스 순으로 좁혀 가며 Service 연결 문제의 층을 가르는 진단 트리
결과원인
Pod IP 직접도 안 된다앱 문제 — 포트를 안 열었거나 죽었다
Pod IP는 되는데 ClusterIP가 안 된다엔드포인트 비어 있음 또는 kube-proxy 문제
ClusterIP는 되는데 이름이 안 된다DNS 문제 — CoreDNS를 본다 (DNS)
다른 네임스페이스에서만 안 된다FQDN(Fully Qualified Domain Name — 네임스페이스까지 붙인 완전한 이름, DNS) 필요 또는 NetworkPolicy (NetworkPolicy)
외부에서만 안 된다NodePort/LB 설정 또는 방화벽
  • Service는 프로세스가 아니라 커널에 심긴 규칙이다. ping은 안 된다
  • kube-proxy는 번역(가상 IP → Pod IP), CNI는 배달(그 Pod IP까지) — Service는 두 층의 분업이다
  • 타입은 포함 관계: LoadBalancer ⊃ NodePort ⊃ ClusterIP
  • port / targetPort / nodePort 세 포트를 구분하라. targetPort 누락이 단골 실수
  • kubectl get endpoints가 진단의 1번이다. 비었으면 라벨 또는 Ready 문제
  • 지금의 실제 데이터는 EndpointSlice. conditions.ready가 readinessProbe 결과다
  • headless(clusterIP: None) 는 Pod IP를 직접 준다 — StatefulSet의 짝
  • 셀렉터 없는 Service + 수동 EndpointSlice로 외부 자원을 클러스터 이름으로 감쌀 수 있다
  • 소스 IP가 필요하면 externalTrafficPolicy: Local