콘텐츠로 이동

9. Service

변하는 Pod 앞의 고정된 주소

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

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

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

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

섹션 제목: “Service의 정체 — 가상 IP와 커널 규칙”
sequenceDiagram
    participant API as kube-apiserver
    participant KP as kube-proxy · 노드마다
    participant K as 노드 커널 (iptables/ipvs)
    participant C as 클라이언트 Pod
    API->>KP: Service · EndpointSlice watch 이벤트
    KP->>K: DNAT 규칙 갱신<br/>10.96.0.10:80 → 10.244.1.5:8080 …
    Note over KP: 규칙만 심고 빠진다
    C->>K: 10.96.0.10:80 으로 패킷
    K->>K: 커널이 직접 DNAT
    K-->>C: Pod 이 응답
    Note over C,K: 트래픽은 kube-proxy 를 통과하지 않는다
  • ClusterIP는 아무 인터페이스에도 붙어 있지 않은 가상 IP다. ping이 안 된다
  • kube-proxy가 모든 노드의 커널에 규칙을 심는다: “목적지가 10.96.0.10:80이면 → 10.244.1.5:8080 또는 10.244.2.7:8080 으로 DNAT”
  • 그래서 트래픽이 어떤 프록시도 거치지 않는다. 커널이 직접 바꾼다
Terminal window
# 노드에서 직접 확인 (iptables 모드)
sudo iptables -t nat -L KUBE-SERVICES -n | head
sudo ipvsadm -Ln # ipvs 모드일 때

ClusterIP (기본)

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

NodePort

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

LoadBalancer

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

ExternalName

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

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

flowchart LR
    E["외부 클라이언트"] --> LB["LoadBalancer<br/>클라우드 LB"]
    LB --> NP["NodePort<br/>노드IP:30080"]
    NP --> CI["ClusterIP<br/>10.96.0.10:80"]
    CI --> P1["Pod :8080"]
    CI --> P2["Pod :8080"]
    IN["내부 Pod"] --> CI

    classDef key fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
    classDef ext fill:#fef3c7,stroke:#d97706,color:#78350f
    classDef pod fill:#dcfce7,stroke:#16a34a,color:#14532d
    class CI key
    class LB,NP ext
    class P1,P2 pod
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의 포트
Terminal window
kubectl expose deploy web --port=80 --target-port=8080 --name=web-svc
kubectl create svc clusterip web-svc --tcp=80:8080
flowchart LR
    EXT["외부 클라이언트"] -->|"nodePort<br/>30080"| N["노드"]
    N -->|"port<br/>80"| S["Service<br/>10.96.0.10"]
    INT["내부 Pod"] -->|"port<br/>80"| S
    S -->|"targetPort<br/>8080"| P["Pod<br/>10.244.1.5"]

    classDef np fill:#fef3c7,stroke:#d97706,color:#78350f
    classDef pt fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
    classDef tp fill:#dcfce7,stroke:#16a34a,color:#14532d
    class N np
    class S pt
    class P tp

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를 안 고쳐도 된다

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

섹션 제목: “셀렉터와 엔드포인트 — 실제 연결의 실체”
flowchart LR
    SVC["Service<br/>selector: app=web"] --> EC["엔드포인트 컨트롤러"]
    EC -->|"라벨이 맞고<br/>Ready 인 Pod 만"| ES["EndpointSlice<br/>10.244.1.5:8080<br/>10.244.2.7:8080"]
    P1["Pod app=web · Ready"] --> EC
    P2["Pod app=web · NotReady"] -.->|"제외 ❌"| EC
    P3["Pod app=api"] -.->|"라벨 불일치 · 제외 ❌"| EC
    ES --> KP["kube-proxy 가 규칙으로 반영"]

    classDef ok fill:#dcfce7,stroke:#16a34a,color:#14532d
    classDef bad fill:#fee2e2,stroke:#dc2626,color:#7f1d1d
    classDef key fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
    class P1 ok
    class P2,P3 bad
    class ES key
Terminal window
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 줄을 본다
  • 엔드포인트 컨트롤러가 셀렉터에 맞고 Ready인 Pod의 IP를 여기에 채운다
  • ENDPOINTS가 비어 있으면 Service는 아무 데도 못 보낸다
  • Endpoints오브젝트 하나에 모든 IP를 담았다 → Pod이 수천 개면 갱신 비용이 폭발
  • EndpointSlice100개씩 쪼개서 담는다. 지금은 이쪽이 실제 데이터 소스다
  • kubectl get endpoints는 여전히 동작한다 (호환용으로 유지)
Terminal window
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의 결과다. 이 한 줄이 트래픽을 켜고 끈다.

flowchart LR
    RP["readinessProbe"] -->|성공| R1["conditions.ready: true"] --> T1["트래픽 받는다 ✅"]
    RP -->|실패| R2["conditions.ready: false"] --> T2["엔드포인트에서 빠진다 ❌"]

    classDef ok fill:#dcfce7,stroke:#16a34a,color:#14532d
    classDef bad fill:#fee2e2,stroke:#dc2626,color:#7f1d1d
    classDef mute fill:#f1f5f9,stroke:#94a3b8,color:#334155
    class R1,T1 ok
    class R2,T2 bad
    class RP mute
spec:
type: NodePort
selector:
app: web
ports:
- port: 80
targetPort: 8080
nodePort: 30080 # 생략하면 30000-32767 중에서 자동 할당

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

flowchart LR
    C1["클라이언트"] -->|":30080"| N1["node01<br/>Pod 있음"]
    C2["클라이언트"] -->|":30080"| N2["node02<br/>Pod 없음"]
    N1 --> P["Pod :8080"]
    N2 -->|"다른 노드로 전달"| P

    classDef pod fill:#dcfce7,stroke:#16a34a,color:#14532d
    classDef node fill:#fef3c7,stroke:#d97706,color:#78350f
    classDef mute fill:#f1f5f9,stroke:#94a3b8,color:#334155
    class P pod
    class N1,N2 node
    class C1,C2 mute
  • 범위는 기본 30000-32767 (apiserver의 --service-node-port-range로 변경)
Terminal window
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 같은 것을 설치해야 한다)
flowchart LR
    S["type: LoadBalancer 생성"] --> Q{"클라우드 컨트롤러가<br/>있는가"}
    Q -->|"있다 · EKS/GKE 등"| A["실제 LB 프로비저닝<br/>EXTERNAL-IP 채워짐 ✅"]
    Q -->|"없다 · 온프레미스"| B["EXTERNAL-IP 가 영원히 pending ❌<br/>→ MetalLB 등을 설치"]

    classDef ok fill:#dcfce7,stroke:#16a34a,color:#14532d
    classDef bad fill:#fee2e2,stroke:#dc2626,color:#7f1d1d
    classDef mute fill:#f1f5f9,stroke:#94a3b8,color:#334155
    class A ok
    class B bad
    class S,Q mute
# ExternalName — 프록시하지 않는다. DNS CNAME만 준다
spec:
type: ExternalName
externalName: db.example.com

클러스터 안에서 my-db.default.svc.cluster.local 을 조회하면 db.example.com 으로 CNAME이 돌아온다. 셀렉터도 엔드포인트도 없다.

셀렉터 없는 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"]
flowchart LR
    APP["앱<br/>external-db:5432 로 접속"] --> SVC["Service external-db<br/>셀렉터 없음"]
    SVC -->|"kubernetes.io/service-name 라벨로 연결"| ES["수동 EndpointSlice<br/>192.168.10.50"]
    ES --> DB[("클러스터 밖 DB")]
    LATER["나중에 DB 를 클러스터 안으로"] -.->|"셀렉터를 붙이면 끝<br/>앱은 그대로 ✅"| SVC

    classDef key fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
    classDef ok fill:#dcfce7,stroke:#16a34a,color:#14532d
    classDef mute fill:#f1f5f9,stroke:#94a3b8,color:#334155
    class SVC key
    class LATER ok
    class APP,ES,DB mute
  • 클러스터 밖의 DB를 external-db라는 이름으로 부를 수 있다
  • 나중에 DB를 클러스터 안으로 옮겨도 앱은 그대로다 — 마이그레이션 기법
flowchart LR
    C["클라이언트"] -->|"DNS: web-svc"| VIP["가상 IP 하나<br/>10.96.0.10"]
    VIP -->|"커널이 하나를 골라 DNAT"| P1["Pod A"]
    VIP -.-> P2["Pod B"]
    VIP -.-> P3["Pod C"]

    classDef key fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
    classDef mute fill:#f1f5f9,stroke:#94a3b8,color:#334155
    class VIP key
    class C,P1,P2,P3 mute

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

쓰는 곳

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

셀렉터도 없고 clusterIP: None이면 DNS는 ExternalName이나 수동 엔드포인트를 따라간다.

모드 방식 특징
iptables NAT 규칙 체인 기본값. 서비스가 많으면 규칙이 선형적으로 늘어난다
ipvs 커널 L4 로드밸런서 대규모에 유리. 여러 알고리즘(rr, lc, sh…)
nftables iptables의 후속 신규 클러스터용. 성능 개선
Terminal window
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

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

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

externalTrafficPolicy (NodePort/LoadBalancer에만 해당)

flowchart LR
    C["클라이언트<br/>1.2.3.4"] --> N2["node02<br/>Pod 없음"]
    N2 -->|"SNAT 후 전달"| P["Pod on node01"]
    P -.->|"소스 IP 가 node02 로 보인다 ❌"| SEE["앱이 보는 IP: 10.244.0.1"]

    classDef bad fill:#fee2e2,stroke:#dc2626,color:#7f1d1d
    classDef ok fill:#dcfce7,stroke:#16a34a,color:#14532d
    classDef mute fill:#f1f5f9,stroke:#94a3b8,color:#334155
    class SEE bad
    class P ok
    class C,N2 mute

어느 노드로 와도 다른 노드의 Pod으로도 보낸다. 부하는 고르지만 소스 IP가 SNAT로 가려진다.

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 레코드와 Ingress의 port.name에서 참조된다
Terminal window
kubectl create svc clusterip web-svc --tcp=80:8080 --tcp=443:8443
  1. Service가 존재하고 셀렉터가 맞는가

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

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

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

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

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

    Terminal window
    kubectl run tmp --image=busybox:1.36 --rm -it --restart=Never -- \
    wget -qO- http://web-svc.default.svc.cluster.local
flowchart TD
    Q1{"Pod IP 로 직접<br/>되는가"}
    Q1 -->|안 된다| A1["앱 문제<br/>포트를 안 열었거나 죽었다"]
    Q1 -->|된다| Q2{"ClusterIP 로<br/>되는가"}
    Q2 -->|안 된다| A2["엔드포인트가 비었거나<br/>kube-proxy 문제"]
    Q2 -->|된다| Q3{"이름으로<br/>되는가"}
    Q3 -->|안 된다| A3["DNS 문제 · CoreDNS · 10장"]
    Q3 -->|된다| Q4{"다른 네임스페이스<br/>· 외부에서는"}
    Q4 -->|"다른 ns 만 안 된다"| A4["FQDN 필요 또는 NetworkPolicy · 12장"]
    Q4 -->|"외부만 안 된다"| A5["NodePort · LB 설정 또는 방화벽"]
    Q4 -->|"전부 된다"| OK["정상 ✅"]

    classDef ok fill:#dcfce7,stroke:#16a34a,color:#14532d
    classDef bad fill:#fee2e2,stroke:#dc2626,color:#7f1d1d
    classDef mute fill:#f1f5f9,stroke:#94a3b8,color:#334155
    class OK ok
    class A1,A2,A3,A4,A5 bad
    class Q1,Q2,Q3,Q4 mute
결과 원인
Pod IP 직접도 안 된다 앱 문제 — 포트를 안 열었거나 죽었다
Pod IP는 되는데 ClusterIP가 안 된다 엔드포인트 비어 있음 또는 kube-proxy 문제
ClusterIP는 되는데 이름이 안 된다 DNS 문제 — CoreDNS를 본다 (10장)
다른 네임스페이스에서만 안 된다 FQDN 필요 또는 NetworkPolicy (12장)
외부에서만 안 된다 NodePort/LB 설정 또는 방화벽
  • Service는 프로세스가 아니라 커널에 심긴 규칙이다. ping은 안 된다
  • 타입은 포함 관계: 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