kubectl get svc 를 찍으면 나오는 ClusterIP — 이를테면 10.96.0.10 — 을 노드에서 ping 해보면 응답이 없다. ip addr 로 뒤져봐도 이 주소가 붙은 인터페이스는 없고, ARP를 물어봐도 대답하는 장비가 없다. 라우팅 테이블에도 이 주소로 가는 경로가 없다. 요약하면 네트워크 어디에도 실재하지 않는 주소다. 그런데 Pod 안에서 curl 10.96.0.10:80 을 하면 멀쩡히 백엔드 Pod로 요청이 도달한다.
나는 오래 "kube-proxy가 그 트래픽을 프록시한다"고 이해하고 있었다. kube-proxy라는 프로세스가 패킷을 받아서 실제 Pod로 중계해준다고 생각한 것이다. iptables 모드에서는 이 이해가 틀렸다. 이 글은 "실재하지 않는 IP로 보낸 패킷이 어떻게 실제 Pod에 닿는가"를 커널 계층까지 따라가 정리한 것이다. 핵심 한 문장을 먼저 박아두면 이렇다.
iptables 모드의 kube-proxy는 패킷을 나르지 않는다. 커널 nat 테이블에 DNAT 규칙 사슬을 심어 둘 뿐이고, 실제 목적지 변환·부하분산·역방향 복원은 전부 커널 netfilter와 conntrack이 수행한다.
먼저 ClusterIP의 정체를 다시 정의해야 한다. ClusterIP는 "패킷이 도달할 목적지"가 아니라 iptables 규칙이 걸리는 조건(매칭 키) 이다.
패킷이 리눅스 네트워크 스택을 통과할 때 netfilter의 여러 훅(hook)을 지난다. 그중 nat 테이블의 PREROUTING(외부에서 들어온 패킷)과 OUTPUT(로컬에서 나가는 패킷) 훅에서, kube-proxy가 심어둔 규칙이 "목적지 IP가 이 ClusterIP고 포트가 이거면"이라는 조건으로 패킷을 붙잡는다. 즉 ClusterIP의 의미는 "라우팅 목적지"가 아니라 "DNAT 규칙의 조건절" 이다. 그래서 인터페이스에 없어도, 라우트가 없어도 문제가 되지 않는다. 애초에 그 주소로 "가는" 게 아니라, 그 주소로 향하는 패킷을 규칙이 낚아채 실제 Pod IP로 주소를 바꿔치기(DNAT) 하기 때문이다.
ping ClusterIP가 안 되는 것도 이걸로 설명된다. 규칙은 -p tcp --dport 80 같은 TCP 조건에만 걸려 있고, ICMP(핑)에 응답할 주체는 어디에도 없다. 핑이 안 되는 게 오히려 정상이다.
그럼 kube-proxy는 무엇을 하는가. API 서버의 Service·EndpointSlice 변화를 watch 하다가, 그 상태를 iptables 규칙으로 번역해 커널에 반영한다. 딱 여기까지다. 실제 패킷 한 개도 kube-proxy를 거치지 않는다. 데이터 플레인(패킷 처리)은 순수하게 커널 netfilter의 몫이다.
이 구분에서 실무적으로 중요한 성질이 하나 나온다. kube-proxy가 잠깐 죽어도 기존 트래픽은 계속 흐른다. 이미 커널에 설치된 규칙은 그대로 남아 있기 때문이다. kube-proxy가 멈추면 "새로운 Service·엔드포인트 변화가 규칙에 반영되지 않을" 뿐, 라우팅 자체가 끊기지는 않는다. (참고로 지금은 쓰지 않는 초기의 userspace 모드에서는 kube-proxy가 실제로 패킷을 중계했다. 이름의 "proxy"가 오해를 부르는 이유다.)
nat 테이블에는 kube-proxy가 만든 커스텀 체인들이 3단으로 점프하는 구조로 놓인다.
PREROUTING / OUTPUT
└─▶ KUBE-SERVICES (모든 Service의 진입점, 목적지=ClusterIP:port 매칭)
└─▶ KUBE-SVC-<hash> (특정 Service. 여기서 엔드포인트 하나를 "고른다")
├─▶ KUBE-SEP-<hashA> (endpoint A: DNAT → PodA_IP:port)
├─▶ KUBE-SEP-<hashB> (endpoint B: DNAT → PodB_IP:port)
└─▶ KUBE-SEP-<hashC> (endpoint C: DNAT → PodC_IP:port)
KUBE-SERVICES는 -d 10.96.0.10/32 -p tcp --dport 80 -j KUBE-SVC-XXXX 형태로, 목적지가 특정 ClusterIP:port면 해당 Service 체인으로 점프시킨다. KUBE-SVC-XXXX는 준비된 엔드포인트(Pod) 하나를 고르고, 실제 DNAT --to-destination PodIP:port은 KUBE-SEP-YYYY(Service EndPoint) 체인에서 일어난다.
흥미로운 지점은 KUBE-SVC 체인이 엔드포인트를 어떻게 균등하게 고르느냐다. iptables에는 라운드로빈 기능이 없다. 대신 netfilter statistic 모듈의 --mode random --probability p 를 쓴다. 규칙이 확률 p로 매칭(=점프)되고, 매칭 안 되면 다음 규칙으로 흐른다.
여기서 확률을 그냥 1/n로 똑같이 주면 균등 분포가 나오지 않는다. 앞 규칙이 이미 트래픽 일부를 흡수하고 흘려보낸 나머지만 뒷 규칙이 나눠 갖기 때문이다. 그래서 확률을 1/n, 1/(n-1), …, 1/2, 1(마지막 무조건) 로 계단식으로 준다. 엔드포인트 3개라면:
규칙1: -m statistic --mode random --probability 0.33333 -j KUBE-SEP-A
규칙2: -m statistic --mode random --probability 0.50000 -j KUBE-SEP-B
규칙3: (무조건) -j KUBE-SEP-C
이 "남은 확률에 대한 조건부 확률" 설계가 계단식 분모의 이유다. 일반화하면 n개 엔드포인트에서 k번째 규칙의 확률을 1/(n-k+1)로 두면 각 엔드포인트의 최종 선택 확률이 모두 1/n로 떨어진다.
여기서 자연스러운 의문 하나. "요청마다 확률로 Pod를 고른다면, 같은 연결의 패킷이 매번 다른 Pod로 튀어서 연결이 깨지지 않나?" 아니다. 그 답이 conntrack이다.
KUBE-SEP에서 DNAT이 일어나면 목적지 IP/port가 ClusterIP → PodIP로 바뀐다. 그런데 이 규칙 평가는 연결(flow)의 첫 패킷에만 적용된다. 커널의 conntrack(connection tracking)이 이 변환을 하나의 엔트리로 기록하기 때문이다.
original: src=Pod_CLIENT dst=10.96.0.10:80 (클라이언트가 보낸 그대로)
reply: src=PodB_IP:8080 dst=Pod_CLIENT (DNAT 반영된 실제 목적지)
노드에서 직접 규칙과 상태를 덤프하면 위 구조가 그대로 보인다.
# Service 진입 규칙 (ClusterIP → KUBE-SVC 점프)
iptables -t nat -L KUBE-SERVICES -n | grep 10.96.0.10
# 특정 Service의 엔드포인트 선택 (계단식 확률)
iptables -t nat -L KUBE-SVC-XXXX -n
# ... statistic mode random probability 0.33333 -> KUBE-SEP-A
# ... statistic mode random probability 0.50000 -> KUBE-SEP-B
# ... -> KUBE-SEP-C
# flow가 conntrack에 고정됨을 확인
conntrack -L -d 10.96.0.10
# tcp ... src=10.244.0.7 dst=10.96.0.10 ... [ASSURED]
iptables 규칙은 본질적으로 리스트를 위에서부터 선형 평가한다. Service·엔드포인트가 늘면 KUBE-SERVICES를 지나며 매칭까지 훑는 규칙 수가 O(n)으로 늘고, 규칙 갱신 비용도 커진다. Pod 하나가 추가·삭제될 때 큰 룰 테이블을 다시 써야 하는 상황이 대표적이다. 수천 개 Service 규모에서는 이 갱신 지연이 체감된다고 알려져 있다.
그래서 대규모 클러스터는 IPVS 모드를 쓴다. IPVS는 커널 IP 가상 서버로, 해시 테이블 기반의 O(1) 조회에 rr(round-robin)·lc(least-connection) 등 실제 스케줄러를 제공한다. 다만 "존재하지 않는 가상 IP를 커널이 DNAT하고 conntrack으로 flow를 고정한다"는 큰 그림은 iptables 모드와 동일하다. 바뀌는 것은 엔드포인트를 고르는 자료구조와 알고리즘이지, 라우팅의 원리 자체가 아니다.
한 줄로 요약하면 이렇다.
ClusterIP는 실재하는 주소가 아니라 커널 nat 규칙의 매칭 조건이고, kube-proxy는 그 규칙을 설치하는 컨트롤 플레인일 뿐이며, 실제 목적지 변환(DNAT)·부하분산(계단식 확률)·연결 고정(conntrack)·응답 복원(un-DNAT)은 전부 커널이 한다.
내가 고쳐 잡은 오해 세 가지를 다시 적어둔다.
더 파고들 만한 주제로는 (1) IPVS 모드의 내부 스케줄러와 conntrack의 관계, (2) externalTrafficPolicy: Local이 SNAT·소스 IP 보존과 부하 불균형에 미치는 영향, (3) nf_conntrack: table full 로 대표되는 conntrack 테이블 고갈 장애 패턴이 있다.
pkg/proxy/iptables/proxier.go (KUBE-SERVICES / KUBE-SVC / KUBE-SEP 체인 생성)statistic 모듈 · netfilter conntrack 매뉴얼