TT Lab
블로그

한국어

시작하기
블로그

블로그

헤어핀 NAT 실측 2: 파드가 자기 Service 를 부를 때

상태: 실측 (2026-11-01). TT Lab 클러스터(쿠버네티스 1.34.10, Cilium 1.20.1)에서 잰 값입니다. 클러스터 설정을 바꾸지 않았고, Service 도 새로 만들지 않았습니다. 이미 있는 Service 가운데 엔드포인트가 하나뿐인 것을 골라 그 파드 안에서 GET 을 한 번 보냈고, 대조는 임시 파드로 했습니다. 주소는 문서용 예시(192.0.2.0/24)로 바꿔 적었고, Cilium 의 기본값인 169.254.42.1 과 시간·포트는 실제 값입니다. 작성 규칙: 실측 데이터가 기본이고, 못 잰 것은 「미실측」, 원인을 확인하지 못한 해석은 「추정」 으로 표시합니다.

목차

1. 결론부터

웹 앱이 자기 API 를 Service 이름으로 부르는 일은 흔합니다. 레플리카가 하나이거나, 여럿이어도 로드밸런싱이 우연히 자기 자신을 고르면 요청은 보낸 파드로 돌아옵니다. 1편의 공유기와 같은 모양의 길입니다.

2. 왜 그냥은 안 되나

파드 P(192.0.2.133)가 Service S(192.0.2.96:80)로 요청합니다. S 의 엔드포인트는 P 자신(포트 8000) 하나뿐입니다. Service 를 처리하는 쪽(kube-proxy 의 iptables 규칙이든 CNI 의 eBPF 든)이 목적지만 바꾸면 패킷은 이렇게 됩니다.

바꾸기 전  출발 192.0.2.133:48766  →  도착 192.0.2.96:80   (Service)
DNAT 뒤    출발 192.0.2.133:48766  →  도착 192.0.2.133:8000 (자기 자신)

출발지와 목적지가 같은 패킷이 파드 바깥(노드 쪽)에서 다시 파드로 들어가야 합니다. 여기서 두 가지가 걸립니다.

  1. 브리지가 되돌려 보내지 않습니다. 파드들이 리눅스 브리지(예: cni0)에 붙어 있으면, P 의 veth 로 들어온 패킷의 새 목적지가 다시 P 의 veth 입니다. 브리지는 기본적으로 받은 포트로는 내보내지 않습니다. 이를 허용하는 것이 브리지 포트의 hairpin 설정입니다. bridge(8) 설명은 이렇습니다. 받은 포트로 다시 내보낼지를 정하며, reflective relay 라고도 부르고, 기본은 꺼짐입니다. sysfs 에서는 /sys/class/net/<브리지>/brif/<포트>/hairpin_mode 로 보입니다.
  2. 출발지가 자기 주소인 패킷은 의심받습니다. 파드 안 커널이 보기에, 바깥 장치(eth0)로 들어온 패킷의 출발지가 자기 주소입니다. Cilium 소스의 주석도 이 점을 들어, 리눅스는 이런 패킷을 루프백 장치에서 받은 것이 아니면 martian source 로 보고 버린다고 설명합니다 (bpf_lxc.c 259~264줄). 설령 받아들여도 답은 「자기 자신에게」 가는 패킷이 되어 파드 안에서 끝나므로, Service 주소로 되돌리는 역변환(reverse NAT)을 거치지 않습니다. 보낸 쪽 소켓은 S:80 의 답을 기다리는데 P:8000 에서 답이 오는 셈이고, 1편의 공유기와 똑같이 연결이 맺어지지 않습니다.

그래서 해법도 1편과 같습니다. 출발지를 다른 주소로 바꿔, 답이 Service 를 처리하는 쪽으로 돌아가 역변환을 거치게 합니다.

3. 누가 무엇을 하나

kube-proxy: 「자기 자신이면 SNAT」 규칙

kube-proxy iptables 모드는 Service 의 엔드포인트마다 체인을 만들고, 그 맨 앞에 출발지가 그 엔드포인트 주소와 같으면 마스커레이드 표시를 붙이는 규칙을 넣습니다. 소스의 주석은 「출발자에게 되돌아가는 트래픽은 SNAT 로 처리한다」 입니다 (proxier.go 1342~1346줄, v1.34.0). 이 규칙이 있어도 파드가 브리지에 붙어 있으면 1의 문제(브리지가 되돌려 보내지 않음)는 남으므로, 브리지 포트의 hairpin 이 함께 필요합니다.

kubelet hairpinMode: 남아 있지만 지금은 하는 일이 없다

kubelet 설정에는 hairpinMode(플래그 --hairpin-mode)가 있고 값은 셋입니다 (KubeletConfiguration v1beta1 types.go 463~476줄, v1.34.0).

값 설명(소스 주석)
promiscuous-bridge(기본) 컨테이너 브리지를 promiscuous 로 둔다. cbr0 라는 브리지가 있다고 가정한다
hairpin-veth 컨테이너 veth 인터페이스에 hairpin 표시를 켠다
none 아무것도 하지 않는다

이 값을 실제로 쓰던 곳은 kubelet 에 들어 있던 네트워크 플러그인 kubenet 이었습니다. 1.23 소스를 보면 kubenet 이 hairpin-veth 일 때 bridge CNI 설정에 hairpinMode: true 를 넣고, 아니면 브리지를 promiscuous 로 둡니다 (kubenet_linux.go, v1.23.0). kubenet 은 dockershim 과 함께 1.24 에서 빠졌습니다. 지금 소스에서는 플래그 정의(cmd/kubelet/app/options/options.go), 기본값, 설정 검증 말고는 이 값을 읽는 곳을 찾지 못했습니다(기본 브랜치 GitHub 코드 검색, 그리고 1.34.0 의 kubelet.go·cmd/kubelet/app/server.go 에 hairpin 이라는 글자가 없음을 확인, 2026-11-01). 지금은 CNI 가 헤어핀을 맡습니다.

측정한 클러스터에서도 확인했습니다. 노드의 kubelet 설정(/configz)은 기본값 promiscuous-bridge 그대로였지만, 노드에는 파드용 브리지가 없었고(쓰지 않는 docker0 하나뿐), 파드의 veth 14개는 어느 브리지에도 붙어 있지 않았습니다. 7월부터 남아 있는 kubelet 로그에는 hairpin 이라는 글자가 한 줄도 없었습니다.

쿠버네티스 공식 문서의 Debug Services 「A Pod fails to reach itself via the Service IP」 절은 아직 kubelet 의 hairpin-mode 플래그와 「Hairpin mode set to」 로그 줄, cbr0 브리지를 확인하라고 안내합니다. 위의 소스를 보면 1.24 이후의 클러스터에는 맞지 않는 안내입니다. 헤어핀이 안 되면 kubelet 이 아니라 CNI 설정을 봐야 합니다.

CNI 마다

CNI 파드를 붙이는 방식 헤어핀 처리(확인한 근거)
bridge 플러그인 리눅스 브리지 설정 hairpinMode(불리언, 기본 false)가 브리지에 붙는 인터페이스의 hairpin 을 켠다(CNI bridge 문서). Service 의 SNAT 는 kube-proxy 몫
Flannel 위 bridge 플러그인에 위임 기본 매니페스트의 CNI 설정이 delegate 에 "hairpinMode": true 를 넣는다(kube-flannel.yml)
Cilium 브리지 없이 veth + eBPF eBPF 가 Service 변환 때 백엔드가 보낸 파드 자신이면 출발지를 169.254.42.1 로 바꾼다(아래). kube-proxy 를 대체하면 SNAT 규칙도 Cilium 몫

다른 CNI(Calico 등)는 이번에 소스를 열어 보지 않아 적지 않았습니다.

Cilium: 169.254.42.1 로 출발지를 바꾼다

Cilium 이 Service 를 처리하는 곳은 두 군데입니다.

헤어핀은 tc LB 쪽 이야기입니다. bpf_lxc.c 는 백엔드를 고른 뒤 백엔드 주소가 패킷의 출발지와 같으면 그 연결에 loopback 표시를 붙입니다 (bpf_lxc.c 249~266줄). 그러면 lb4_dnat_request 가 목적지를 백엔드로 바꾸면서 출발지를 설정값 service_loopback_ipv4 로 바꿉니다(lb.h 2355~2376줄). 그 기본값이 에이전트 플래그 --ipv4-service-loopback-address 의 169.254.42.1 이고, 설명은 「파드가 Service 로 자기 자신과 통신할 때 SNAT 에 쓰는 IPv4 출발지 주소」 입니다(cilium-agent 명령 참조). IPv6 는 fe80::1 입니다.

하나 더 있습니다. 같은 파일의 주석에 따르면, 엔드포인트가 Service 를 거쳐 자기 자신에게 가는 흐름은 네트워크 정책 검사를 건너뜁니다. 그러지 않으면 사용자가 「파드가 자기 자신과 통신해도 된다」 는 정책을 따로 써야 하기 때문입니다 (bpf_lxc.c 1501~1508줄).

socket LB 를 파드 안에서도 쓰는 설정이라면, connect() 가 곧바로 자기 주소로 연결되므로 위의 헤어핀 경로 자체가 생기지 않을 것입니다(추정. 측정한 클러스터는 파드 안에서 socket LB 를 쓰지 않아 재지 못했습니다).

4. 측정 환경과 방법

항목 값
쿠버네티스 1.34.10, kube-proxy 없음
Cilium 1.20.1. kube-proxy-replacement=true, 터널(VXLAN), bpf-lb-sock-hostns-only=true → 에이전트 상태 「Socket LB Coverage: Hostns-only」
루프백 주소 엔드포인트의 eBPF 설정에 ServiceLoopbackIPv4 = 169.254.42.1(기본값)
대상 이미 있는 웹 서비스 하나. Service 포트 80 → 컨테이너 포트 8000, 엔드포인트 하나, 네트워크 정책 강제 꺼짐. 접속 기록에 클라이언트 주소를 남긴다

Service 를 새로 만드는 것은 클러스터 쓰기라 하지 않았습니다. 대신 엔드포인트가 하나뿐인 기존 Service 를 골랐습니다. 이 Service 로 가는 요청은 백엔드가 그 파드 하나뿐이므로, 그 파드 안에서 보내면 반드시 자기 자신에게 돌아옵니다.

  1. 헤어핀: 그 파드 안에서 파이썬 소켓으로 Service 주소(S:80)에 GET /healthz 를 한 번 보냈습니다. 소켓이 본 자기 주소와 상대 주소(getsockname, getpeername), 응답과 걸린 시간을 적었습니다.
  2. 서버 쪽: 직후에 그 파드의 접속 기록에서 그 요청 줄을 찾았습니다.
  3. 연결 추적: 같은 노드의 Cilium 에이전트에서 cilium-dbg bpf ct list global 로 그 연결의 항목을 읽었습니다.
  4. 대조: 같은 노드에 --rm 임시 파드를 하나 띄워 같은 Service 로 같은 요청을 세 번 보내고, 서버 기록에 찍힌 출발지를 보았습니다.

5. 실측 결과

파드가 자기 Service 로 요청할 때 (Cilium, 실측) 엔드포인트가 자기 하나뿐인 Service 에 GET 한 번 파드 P (192.0.2.133) 앱이 보냄 S 192.0.2.96:80 으로 같은 앱이 받음 포트 8000 ① 192.0.2.133:48766 → S:80 파드 veth 의 tc eBPF (Cilium) 백엔드를 고르니 보낸 파드 자신이다 도착을 바꾼다(DNAT): S:80 → 192.0.2.133:8000 출발도 바꾼다(SNAT): → 169.254.42.1 ② 169.254.42.1:48766 → P:8000 실측으로 본 것 응답 200, 1.17ms 클라이언트 소켓의 상대 S:80 (getpeername) 서버 로그의 출발지 169.254.42.1:48766 연결 추적 표시 LBLoopback 대조: 다른 파드에서 같은 Service 로 3번 서버 로그에 그 파드의 주소가 그대로 찍혔다
그림: 파드가 자기 Service 로 요청할 때 Cilium 의 tc eBPF 가 목적지를 자기 자신으로, 출발지를 169.254.42.1 로 바꾸는 길과 실측 결과
항목 헤어핀(자기 자신 → 자기 Service) 대조(다른 파드 → 같은 Service)
응답 HTTP 200, 1.17ms (1회) HTTP 200, 2.64·2.65·3.21ms (3회)
클라이언트 소켓이 본 상대 S:80 S:80
서버 기록의 출발지 169.254.42.1:48766 그 파드의 주소:51502 등 (그대로)
연결 추적 표시 LBLoopback 해당 없음

서버 기록 한 줄과 연결 추적 항목은 이렇습니다(주소만 예시로 바꿨습니다).

INFO:     169.254.42.1:48766 - "GET /healthz HTTP/1.1" 200 OK

TCP SVC 192.0.2.133:48766 -> 192.0.2.96:80  ... RevNAT=177 BackendID=216
TCP OUT 169.254.42.1:48766 -> 192.0.2.133:8000 ... Flags=0x001b [ RxClosing TxClosing LBLoopback SeenNonSyn ] RevNAT=177
TCP IN  169.254.42.1:48766 -> 192.0.2.133:8000 ... Flags=0x001b [ RxClosing TxClosing LBLoopback SeenNonSyn ]

읽는 법입니다.

덤으로, 같은 기록에서 kubelet 의 상태 검사 요청은 그 노드의 Cilium 내부 주소(cilium_host)에서 온 것으로 찍혀 있었습니다.

1편의 공유기와 비교하면 두 가지가 같습니다. 출발지를 바꿔야 답이 같은 길로 돌아오고, 그 대가로 서버는 진짜 클라이언트를 볼 수 없습니다. 파드가 자기 Service 로 보낸 요청이 접속 기록에 169.254.42.1 로 찍히는 것은 고장이 아니라 이 설계의 결과입니다. 출발지 주소로 거르는 기능(허용 목록, 요청 수 제한)이 있다면 이 주소를 알아 두어야 합니다.

6. 한계

7. 정리

확인 이유
「자기 Service 로는 안 된다」 면 kubelet 이 아니라 CNI 를 본다 1.24 이후 kubelet 의 hairpinMode 는 읽는 곳이 없다
브리지 기반 CNI 면 hairpinMode: true 브리지는 기본적으로 받은 포트로 되돌려 보내지 않는다
kube-proxy 가 있으면 엔드포인트 체인의 -s <자기 주소> -j KUBE-MARK-MASQ 출발지를 바꾸는 쪽이 이 규칙이다
Cilium 이면 접속 기록의 169.254.42.1 은 정상 자기 Service 로 온 요청의 출발지다
출발지 주소로 거르는 기능 헤어핀 요청은 진짜 출발지를 잃는다(공유기도 같다)

티티 한마디. 제 방 문에 「티티 앞으로 온 편지함」 이 붙어 있는데, 제가 거기다 편지를 넣으면 다시 제 방으로 와요. 그런데 보낸 사람 칸에 제 이름이 그대로 있으면 「이상한 편지네」 하고 버려요. 그래서 우체부 Cilium 아저씨가 보낸 사람을 「169.254.42.1 우체국」 으로 바꿔 줘요. 제 일기장에 그 우체국 이름이 자꾸 보이면, 그건 제가 저한테 쓴 편지예요!

로그인하면 좋아요를 누를 수 있습니다

댓글

아직 댓글이 없습니다.

로그인하면 댓글을 쓸 수 있습니다