19장. 트래픽이 찾아오는 길 — Service·ClusterIP·클러스터 DNS
Service의 주소는 어느 인터페이스에도 없고 ping도 안 받습니다. 그런데 HTTP를 보내면 응답이 옵니다. 존재하지 않는 주소로 보낸 요청이 도착한 거예요.
{/* 삽화 자리: 벽에 창구 번호판 하나가 붙어 있는데 그 자리에 창구가 없다. 빈 벽이다. 그런데 사람이 그 번호판 앞에 서류를 내밀자, 바닥에 그어진 선이 서류를 옆으로 흘려보내 실제 창구 셋 중 하나로 데려간다. 번호판은 끝까지 아무것도 하지 않는다. 프롬프트: docs/book/illustration-prompts-container.md */}
배경
18장에서 Pod가 일회용이라는 걸 봤습니다. 지우면 이름도 IP도 바뀐 새 Pod가 생기고, 노드가 죽으면 통째로 다른 곳에 다시 생기죠.
그러면 그걸 어떻게 부를까요.
16장에서 같은 문제를 한 번 풀었습니다. compose의 내장 DNS가 서비스 이름을 주소로 바꿔줬죠. 그때 한계가 둘 남았고요.
| 16장이 남긴 것 | |
|---|---|
| 한계 ① | 한 이름이 여러 주소로 풀리는데 그게 부하 분산은 아니다 |
| 한계 ② | 살아 있기만 하면 못 받는 상태여도 계속 주소를 준다 |
이 장이 ①을 풀고, ②는 다음 장으로 넘깁니다. 왜 한 장에 안 되는지도 끝에서 보게 돼요.
스토리
Service를 하나 만들었습니다. Deployment 옆에 열 줄쯤 더 적으면 됩니다.
apiVersion: v1
kind: Service
metadata:
name: web
spec:
selector:
app: web # 이 라벨이 붙은 Pod 들을 뒤에 둔다
ports:
- port: 80 # Service 가 받는 포트
targetPort: 8080 # Pod 로 넘길 포트=== 1. Service 와 그 뒤의 Pod 들 ===
Service web ClusterIP=10.96.242.78 포트=80/TCP
Pod web-79c46d79d7-jsmqv IP=10.244.1.30 노드=ch18-worker2
Pod web-79c46d79d7-qh9vg IP=10.244.2.7 노드=ch18-worker
Pod web-79c46d79d7-xqlg8 IP=10.244.2.6 노드=ch18-worker✅ 실측 (kind 0.33.0 / Kubernetes 1.37.0 / Docker Engine 29.8.0 / Docker Desktop 4.91.0 / Apple M1 / macOS 26.6, 2026-09. 전체 소스: docs/book/code/container/ch19/)
주소가 하나 더 생겼습니다. 10.96.242.78이요. Pod들의 10.244.x.x와는 대역부터 다릅니다.
이게 뭔지 찾아봤습니다.
=== 2. ClusterIP 는 어디에 있는 주소인가 ===
ClusterIP: 10.96.242.78 (비교용 Pod IP: 10.244.2.22)
ch18-worker 인터페이스에 있나: 0건
ch18-worker2 인터페이스에 있나: 0건
ch18-control-plane 인터페이스에 있나: 0건
ping Pod IP → 응답함
ping ClusterIP → 응답없음
TCP 80번 → hello from pid 1
TCP 9999번 → wget: download timed out
이름으로 → hello from pid 1
노드에 10.96.0.0/16 경로가 있나: 0건
그 주소로 가는 길: 10.96.242.78 via 172.20.0.1 dev eth0 src 172.20.0.2✅ 실측 (같은 환경, 같은 소스)
세 노드 어디에도 없습니다. ping도 안 받고요. 같은 자리에서 Pod IP는 ping이 되니 환경 탓은 아닙니다.
그런데 80번으로 HTTP를 보내면 응답이 옵니다.
9999번은 또 안 됩니다. 그러니 “TCP는 되고 ICMP는 안 된다”가 아니에요. 되는 건 80번 하나입니다.
마지막 두 줄이 이유를 말해줍니다. 노드에는 10.96.0.0/16으로 가는 경로가 아예 없어요. 그 주소로 보낸 패킷은 기본 게이트웨이로 흘러나가 아무도 안 받습니다. 80번만 중간에 누가 가로채는 거죠.
핵심
이름은 주소 하나로 풀립니다
16장과 비교할 자리가 여기입니다.
=== 3. 이름은 몇 개 주소로 풀리나 (16장과 비교) ===
web → 10.96.242.78
FQDN 으로 물어도 → 10.96.242.78
Pod 안의 resolv.conf:
search ch19.svc.cluster.local svc.cluster.local cluster.local
nameserver 10.96.0.10
options ndots:5✅ 실측 (같은 환경, 같은 소스)
16장에서는 한 이름이 세 주소로 풀렸죠. 여기서는 하나입니다. Pod가 셋인데도요.
클라이언트 입장에서 이게 큰 차이입니다. 캐시해도 상관없거든요. 주소가 하나뿐이고 그 주소는 안 바뀌니까요. 16장에서 JVM이 30초 캐시하는 게 문제였는데, 여기서는 캐시하는 게 정상입니다.
그러면 분산은 누가 하나요. 주소 하나를 받았는데요.
resolv.conf도 봐둘 만합니다. nameserver가 10.96.0.10이에요. 16장에서는 컨테이너마다 리졸버가 따로 붙었죠(127.0.0.11). 여기서는 클러스터에 CoreDNS Pod를 몇 개 띄우고 그 앞에 주소 하나를 세워 모두가 그걸 씁니다.
=== 2-2. 그 nameserver 10.96.0.10 은 무엇인가 ===
Service kube-dns ClusterIP=10.96.0.10
CoreDNS Pod coredns-559f6c778d-gkl2w 10.244.0.3
CoreDNS Pod coredns-559f6c778d-nwx9j 10.244.0.4✅ 실측 (같은 환경, 같은 소스)
CoreDNS는 둘이고, 10.96.0.10은 그 앞에 선 Service의 ClusterIP입니다. 방금 “어디에도 없는 주소”라고 부른 바로 그 물건이에요.
search가 짧은 이름을 쓰게 해줍니다. web이라고만 쓰면 web.ch19.svc.cluster.local부터 차례로 붙여 물어봐요. 다른 네임스페이스의 것은 web.other처럼 한 칸 더 적으면 되고요.
ndots:5는 그 “차례로”를 언제까지 할지 정하는 문턱입니다. 이름에 점이 5개 미만이면 search를 먼저 다 시도해요. 그래서 api.github.com 같은 바깥 도메인은 점이 2개라, api.github.com.ch19.svc.cluster.local부터 세 번을 헛물켜고 나서야 진짜로 물어봅니다. 클러스터 안 이름을 편하게 쓰는 대가를 바깥 도메인이 치르는 구조예요.
분산은 커널이 합니다
주소가 하나인데 Pod는 셋입니다. 그 사이를 잇는 게 어디 있는지 노드에서 찾아봤습니다.
=== 4. 그 가짜 주소가 진짜로 바뀌는 자리 (9장의 그 NAT) ===
-A KUBE-SERVICES -d 10.96.242.78/32 -p tcp --dport 80 -j KUBE-SVC-IYH6ZFD4N2RO6CRF
-A KUBE-SVC-IYH6ZFD4N2RO6CRF ... "ch19/web -> 10.244.1.30:8080" -m statistic --mode random --probability 0.33333333349 -j KUBE-SEP-RIRYKVHFIZSPSCOH
-A KUBE-SVC-IYH6ZFD4N2RO6CRF ... "ch19/web -> 10.244.2.6:8080" -m statistic --mode random --probability 0.50000000000 -j KUBE-SEP-BV2XL55OXTIV7AZF
-A KUBE-SVC-IYH6ZFD4N2RO6CRF ... "ch19/web -> 10.244.2.7:8080" -j KUBE-SEP-NZVUP4C4MA3KER5B✅ 실측 (같은 환경, 같은 소스. 분기 규칙만 남기고 마스커레이드 규칙과 일부 열은 덜어냈습니다)
9장에서 포트 매핑의 정체가 iptables NAT 규칙이라는 걸 봤죠. 같은 자리입니다.
읽어봅시다. 10.96.242.78의 80번으로 가는 패킷을 잡아서, 3분의 1 확률로 첫 Pod에, 아니면 2분의 1 확률로 두 번째에, 그것도 아니면 세 번째로 보냅니다.
확률을 곱해보면 기댓값은 정확히 3등분입니다. 1/3, 그리고 남은 2/3의 1/2이 1/3, 나머지가 1/3.
다만 매번 주사위를 굴리는 거라 차례대로 돌지는 않습니다. 60번 보내고 규칙에 붙은 카운터를 읽어봤어요.
=== 4-2. 그 확률이 실제로 어떻게 나뉘나 (60번 보내고 규칙 카운터를 읽는다) ===
10.244.1.37:8080 → 21번
10.244.2.17:8080 → 24번
10.244.2.18:8080 → 15번✅ 실측 (같은 환경, 같은 소스)
기대값은 20/20/20인데 21/24/15가 나왔습니다. 16장에서 본 그 성질이 여기서도 남아 있어요. 고르게 도는 게 아니라 매번 뽑는 겁니다. 다만 뽑는 쪽이 클라이언트가 아니라 커널이고, 연결마다 새로 뽑습니다.
그러니까 ClusterIP는 어디에도 없는 주소가 맞습니다. 그 주소로 보낸 패킷이 노드를 떠나기 전에 커널이 목적지를 진짜 Pod IP로 바꿔버려요.
그리고 규칙은 -p tcp --dport 80에만 걸려 있습니다. ClusterIP는 주소 하나가 아니라 (주소, 프로토콜, 포트) 조합 하나인 셈이에요. ping도 9999번도 그 조합이 아니라서 가로채는 사람 없이 흘러나갑니다.
16장의 한계 ①이 여기서 풀립니다. 분산을 DNS가 하던 게 아니라 커널이 합니다. 클라이언트가 이름을 캐시하든 말든 상관없어요. 캐시한 그 주소가 매번 다른 Pod로 갑니다.
이 규칙을 노드마다 넣어주는 게 kube-proxy입니다. Service가 바뀌면 모든 노드의 규칙이 갱신돼요.
그러고 보니 아까 본 resolv.conf의 nameserver도 10.96.0.10, 같은 대역이었죠. DNS 서버 주소 자체가 ClusterIP입니다. 그럼 그 주소는 누가 이름으로 풀어줄까요. 아무도 안 풉니다. resolv.conf에 숫자로 박혀 있으니 이름 해석이 필요 없고, 거기서부터는 방금 본 iptables가 받습니다. 재귀가 거기서 끊겨요.
그 목록은 어디서 오나
iptables 규칙에 Pod IP가 박혀 있었죠. 그 Pod IP 목록을 관리하는 물건이 따로 있습니다.
=== 5. 그 목록의 출처: EndpointSlice ===
web-lq268 [10.244.2.6],[10.244.2.7],[10.244.1.30] true,true,true✅ 실측 (같은 환경, 같은 소스)
Service는 “app=web 라벨이 붙은 Pod”라고만 적어뒀습니다. 그 조건에 맞는 Pod가 실제로 누구인지를 계속 추적해 EndpointSlice에 적어두고, kube-proxy가 그걸 보고 규칙을 만들어요.
true,true,true가 준비 상태입니다. 이 값이 이 장의 마지막 문제로 이어집니다.
죽으면 목록에서 빠집니다, 그건 16장도 하던 일입니다
Pod 하나를 지워봤습니다.
=== 6. Pod 를 지우면 목록에서 빠지나 (16장이 못 하던 것) ===
before : 10.244.1.30 10.244.2.6 10.244.2.7
3초 후 : 10.244.1.31 10.244.2.6 10.244.2.7✅ 실측 (같은 환경, 같은 소스)
.1.30이 빠지고 .1.31이 들어왔습니다. 3초 만에요.
16장에서도 됐던 일입니다. 거기서도 컨테이너를 죽이면 3초 안에 DNS 기록에서 빠졌죠. 달라진 건 빠진 다음에 벌어지는 일뿐이에요. 여기서는 iptables 규칙까지 따라 바뀝니다.
16장이 못 하던 건 이쪽이 아니었습니다. 죽은 건 알아봤고, 못 받는 상태인 건 못 알아봤죠. 그 이야기는 이 장 끝에서 합니다.
headless로 만들면 16장으로 돌아갑니다
ClusterIP를 안 만들 수도 있습니다. clusterIP: None이라고 적으면요.
=== 7. headless 로 만들면 16장으로 되돌아간다 ===
web (ClusterIP) → 10.96.242.78
web-headless (None) → 10.244.1.31 10.244.2.6 10.244.2.7
실제 Pod IP 들 → 10.244.1.31 10.244.2.6 10.244.2.7✅ 실측 (같은 환경, 같은 소스)
이름이 Pod IP 전부로 풀립니다. 16장에서 본 그 모양이에요.
가짜 주소도 없고 iptables 분기도 없습니다. 클라이언트가 목록을 받아 직접 고르는 거죠. 그래서 16장의 문제들이 그대로 돌아옵니다. 캐시하면 한쪽으로만 가고요.
그런데도 쓸 데가 있습니다. 하나는 클라이언트가 개별 Pod를 구분해야 할 때예요. 데이터베이스 복제본처럼 “어느 하나”가 아니라 “그 하나”를 찾아야 하는 경우입니다.
다른 하나는 클라이언트가 직접 분산하고 싶을 때고요. ClusterIP의 분산은 연결 단위인데, gRPC처럼 커넥션 하나를 계속 재사용하는 프로토콜은 그 분산이 한 번밖에 안 먹습니다. 그럴 땐 클라이언트가 Pod 목록을 받아 스스로 나누는 편이 낫습니다.
기본은 ClusterIP입니다. headless는 이유가 있을 때만 쓰세요.
바깥에서 들어오려면
여기까지는 전부 클러스터 안 이야기입니다. ClusterIP는 클러스터 밖에서 못 씁니다.
밖에서 들어오는 가장 단순한 방법이 NodePort입니다.
=== 8. 바깥에서 들어오는 길: NodePort ===
web-np NodePort 포트=80:30080/TCP
ch18-worker (172.20.0.2) → hello from pid 1
ch18-worker2 (172.20.0.3) → hello from pid 1
ch18-control-plane (172.20.0.4) → hello from pid 1✅ 실측 (같은 환경, 같은 소스)
아무 노드로나 보내도 받습니다. 컨트롤 플레인에는 앱 Pod가 하나도 없는데도 응답이 와요.
kube-proxy가 모든 노드에 같은 규칙을 넣어뒀기 때문이에요. 앱이 없는 노드로 들어온 요청은 앱이 있는 노드로 넘어갑니다.
공짜는 아닙니다. 앱이 없는 노드에서 넘길 때 출발지 주소도 그 노드 것으로 바꿔버리거든요. 아까 §4에서 덜어냈던 KUBE-MARK-MASQ가 그 일을 합니다. 그래서 앱 로그에 찍히는 클라이언트 IP가 전부 노드 IP가 돼요. “왜 access log에 IP가 다 똑같지”가 여기서 나옵니다.
원래 IP를 지키려면 externalTrafficPolicy: Local을 주면 됩니다. 그러면 앱이 없는 노드는 아예 안 받아요. 둘 중 하나를 골라야 합니다.
실무에서 NodePort를 그대로 안 쓰는 이유가 이것까지 셋입니다. 포트 번호가 30000번대로 제한되고, 노드 IP가 바뀌면 클라이언트가 따라가야 하고, 클라이언트 IP가 지워지고요. 보통은 앞에 로드밸런서를 두거나 Ingress를 씁니다. 그 이야기는 이 책의 범위 밖입니다.
그런데 두 번째 한계는 안 풀렸습니다
앞에서 “죽으면 목록에서 빠진다”를 확인했죠. 그럼 반대는요. 아직 못 받는 Pod가 목록에 들어가는 건 어떨까요.
Pod를 하나 새로 띄우고 시각을 재봤습니다.
=== 9. Pod 는 언제 '받을 수 있다'고 표시되나 (readinessProbe 없음) ===
컨테이너 시작 : 2026-09-21T03:42:48Z
Pod Ready 표시: 2026-09-21T03:42:48Z
그런데 앱 로그 :
2026-09-21T03:42:50.718 ... Tomcat started on port 8080✅ 실측 (같은 환경, 같은 소스. 두 타임스탬프는 초 단위라 정밀도가 1초입니다)
같은 초에 Ready가 됐습니다. 컨테이너가 시작하자마자요.
그런데 앱이 요청을 받기 시작한 건 2초 남짓 뒤입니다. 그 사이에 Pod는 이미 EndpointSlice에 ready: true로 올라가 있었고, iptables 규칙에도 들어가 있었어요.
그 사이에 실제로 요청을 보내봤습니다.
=== 9-2. 그 사이에 실제로 요청을 보내보면 ===
8 wget: can't connect to remote host (10.244.2.22): Connection refused
32 hello from pid 1✅ 실측 (같은 환경, 같은 소스)
여덟 번 연속 거절당하고 아홉 번째부터 붙습니다. Service는 그동안 이 Pod로 트래픽을 보내고 있었고요.
아무것도 안 물어봤으니 당연합니다. 쿠버네티스는 컨테이너 프로세스가 떴다는 것밖에 몰라요. 15장에서 Docker가 알던 것과 똑같은 수준입니다.
16장의 한계 ②가 여기서 모양만 바꿔 돌아왔습니다. 거기서는 얼린 컨테이너에 계속 주소를 줬고, 여기서는 아직 안 뜬 앱에 트래픽을 보냅니다.
그래서 어떻게 잡나
| 상황 | 하면 좋은 것 |
|---|---|
| Pod를 부를 때 | Pod IP를 쓰지 않기. Service 이름을 쓴다 |
| 다른 네임스페이스의 Service | web.other처럼 한 칸 더 적기 |
| ClusterIP가 ping이 안 될 때 | 정상이다. 규칙은 Service에 적은 포트에만 걸린다. 확인은 그 포트로 |
| 바깥 API 호출이 이상하게 느릴 때 | ndots:5를 의심. 도메인 끝에 점을 찍으면(api.github.com.) search를 건너뛴다 |
| Service를 만들었는데 연결이 안 될 때 | EndpointSlice가 비어 있는지 먼저 보기. 라벨이 안 맞으면 목록이 빈다 |
| headless | 개별 Pod를 구분해야 할 때만. 기본은 ClusterIP |
| 배포 직후 잠깐 5xx가 날 때 | 다음 장 |
정리
- Service를 만들면 ClusterIP가 생깁니다. 어느 인터페이스에도 없고 ping도 안 받는 주소인데, 그 주소로 보낸 HTTP는 도착합니다.
- 클러스터 DNS는 그 이름을 주소 하나로 풀어줍니다. 16장에서 한 이름이 세 주소로 풀리던 것과 다릅니다. 그래서 클라이언트가 캐시해도 문제가 없어요.
- nameserver
10.96.0.10은 CoreDNS Pod들 앞에 선 Service의 ClusterIP입니다. DNS 서버 주소 자체가 가짜 주소인 셈이에요.resolv.conf에 숫자로 박혀 있어서 재귀가 거기서 끊깁니다. - 분산은 DNS가 아니라 커널이 합니다. iptables 규칙이 확률로 목적지를 골라 Pod IP로 바꿔요. 기댓값은 3등분인데 60번 실측은 21/24/15였습니다. 차례대로 도는 게 아니라 매번 뽑는 거예요.
- 16장이 남긴 한계 ①이 여기서 풀립니다. 클라이언트가 이름을 캐시해도, 그 하나의 주소가 매번 다른 Pod로 갑니다.
- 규칙은
(주소, 프로토콜, 포트)조합 하나에만 걸립니다. 그래서 ping도, 같은 TCP의 9999번도 안 됩니다. 노드에는 그 대역으로 가는 경로조차 없어요. - 그 규칙의 재료가 EndpointSlice입니다. 라벨에 맞는 Pod 목록을 추적해 두고 kube-proxy가 읽어 갑니다.
- Pod를 지우면 3초 만에 목록에서 빠졌습니다. 16장도 되던 일이에요. 달라지는 건 다음 줄입니다.
clusterIP: None으로 만들면 이름이 Pod IP 전부로 풀립니다. 16장의 모양으로 돌아가는 거예요. 개별 Pod를 구분해야 할 때만 씁니다.- NodePort는 아무 노드로나 보내도 받습니다. 실측에서 앱 Pod가 없는 컨트롤 플레인도 응답했어요. 대신 넘길 때 출발지 IP를 노드 것으로 바꿔버립니다.
- 다만 한계 ②는 안 풀렸습니다. Pod는 컨테이너가 시작한 그 순간 Ready로 표시됐고, 그 뒤 여덟 번의 요청이 연속으로 거절당했어요.
- 정리하면 16장이 남긴 둘 중 ①은 이 장이 풀었고, ②는 다음 장이 풉니다.
생각해볼 질문
이제 이름 하나로 셋을 부를 수 있습니다. 죽으면 목록에서 빠지고요.
그런데 방금 마지막 실측이 남았습니다. 아직 못 받는 Pod가 목록에 들어가 있던 그 2초요.
배포할 때마다 잠깐 5xx가 나는 건 대개 이것 때문입니다. 새 Pod가 뜨자마자 트래픽을 받는데 앱은 아직 준비가 안 된 거죠.
15장에서 같은 문제를 Docker에서 풀어봤습니다. 검사를 붙여 “받을 수 있다”를 따로 판정하게 했죠. 그때 결론이 하나 있었습니다. unhealthy는 앱의 상태가 아니라 검사의 결과라고요. 그리고 Docker Engine은 그 판정을 보고 아무것도 안 했습니다.
쿠버네티스는 그 판정을 보고 무엇을 할까요. 그리고 “살아 있다”와 “받을 수 있다”를 나눠 물어야 하는 이유는 뭘까요.