상태: 실측 (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편의 공유기와 같은 모양의 길입니다.
- 문제의 뿌리는 같습니다. Service 주소를 파드 주소로 바꾸기만 하면(DNAT) 출발지와 목적지가 모두 자기 자신인 패킷이 됩니다. 리눅스는 이런 패킷을 바깥 장치에서 받으면 버리고, 브리지는 받은 포트로 다시 내보내지 않습니다.
- 해법도 같습니다. 출발지를 바꿉니다. kube-proxy iptables 모드는 「출발지가 그 엔드포인트 자신이면 마스커레이드」 규칙을 넣고, 브리지 기반 CNI 는
브리지 포트에
hairpin_mode를 켭니다. Cilium 은 eBPF 에서 출발지를 169.254.42.1 로 바꿉니다. - kubelet 의
hairpinMode는 지금 하는 일이 없습니다. 기본값이 남아 있고 문서도 그대로지만, 이 값을 쓰던 kubenet 이 1.24 에서 dockershim 과 함께 빠진 뒤로는 소스에서 설정 검증 말고 읽는 곳을 찾지 못했습니다. 헤어핀은 CNI 의 일입니다. - 실측: Cilium 1.20.1 에서 파드가 자기 Service 로 보낸 GET 은 200(1.17ms)이었고, 서버가 본 출발지는 169.254.42.1, 연결 추적에는
LBLoopback표시가 붙었습니다. 다른 파드에서 보낸 대조 요청 3번은 그 파드 주소가 그대로 보였습니다.
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 (자기 자신)
출발지와 목적지가 같은 패킷이 파드 바깥(노드 쪽)에서 다시 파드로 들어가야 합니다. 여기서 두 가지가 걸립니다.
- 브리지가 되돌려 보내지 않습니다. 파드들이 리눅스 브리지(예:
cni0)에 붙어 있으면, P 의 veth 로 들어온 패킷의 새 목적지가 다시 P 의 veth 입니다. 브리지는 기본적으로 받은 포트로는 내보내지 않습니다. 이를 허용하는 것이 브리지 포트의 hairpin 설정입니다.bridge(8)설명은 이렇습니다. 받은 포트로 다시 내보낼지를 정하며, reflective relay 라고도 부르고, 기본은 꺼짐입니다. sysfs 에서는/sys/class/net/<브리지>/brif/<포트>/hairpin_mode로 보입니다. - 출발지가 자기 주소인 패킷은 의심받습니다. 파드 안 커널이 보기에, 바깥 장치(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 를 처리하는 곳은 두 군데입니다.
- socket LB: 앱이
connect()를 부르는 순간 eBPF 가 Service 주소를 백엔드 주소로 바꿉니다. 소켓은 처음부터 백엔드에 연결되므로 패킷 단위의 NAT 가 필요 없습니다(kubeproxy-free 문서 「Socket LoadBalancer Bypass in Pod Namespace」). - tc LB: 파드 veth 의 tc eBPF 가 패킷마다 Service 를 찾아 바꿉니다. 같은 문서에 따르면
socketLB.hostNamespaceOnly=true(에이전트 플래그bpf-lb-sock-hostns-only)면 파드 안에서는 socket LB 를 건너뛰고 tc LB 를 씁니다. Istio 사이드카처럼 파드 안에서 원래 ClusterIP 를 봐야 하는 경우나 KubeVirt 처럼 socket LB 가 듣지 않는 파드를 위한 설정입니다.
헤어핀은 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 로 가는 요청은 백엔드가 그 파드 하나뿐이므로, 그 파드 안에서 보내면 반드시 자기 자신에게 돌아옵니다.
- 헤어핀: 그 파드 안에서 파이썬 소켓으로 Service 주소(S:80)에
GET /healthz를 한 번 보냈습니다. 소켓이 본 자기 주소와 상대 주소(getsockname,getpeername), 응답과 걸린 시간을 적었습니다. - 서버 쪽: 직후에 그 파드의 접속 기록에서 그 요청 줄을 찾았습니다.
- 연결 추적: 같은 노드의 Cilium 에이전트에서
cilium-dbg bpf ct list global로 그 연결의 항목을 읽었습니다. - 대조: 같은 노드에
--rm임시 파드를 하나 띄워 같은 Service 로 같은 요청을 세 번 보내고, 서버 기록에 찍힌 출발지를 보았습니다.
5. 실측 결과
| 항목 | 헤어핀(자기 자신 → 자기 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 ]
읽는 법입니다.
- 소켓은 Service 에 연결된 줄 압니다. 클라이언트 소켓의 상대가 S:80 이었습니다. 파드 안에서 socket LB 를 쓰지 않으므로(Hostns-only) 주소 변환이 소켓이 아니라 veth 의 패킷 단계에서 일어났다는 뜻입니다. socket LB 였다면 상대가 백엔드 주소로 보였을 것입니다.
- 서버는 169.254.42.1 에서 온 요청으로 봅니다. 3절의 소스 그대로 출발지가 루프백 주소로 바뀌었습니다. 포트(48766)는 그대로입니다.
- 연결 추적에 세 줄이 생겼습니다. Service 로 간 원래 연결(SVC), 그리고 루프백 주소에서 파드로 가는 양방향(OUT·IN)이며, 뒤의 둘에
LBLoopback이 붙었습니다. 답은 이 항목을 따라 Service 주소로 되돌려집니다. - 대조 요청은 출발지가 그대로였습니다. 다른 파드에서 온 요청은 SNAT 없이 그 파드의 주소로 기록됐습니다. 출발지를 바꾸는 것은 헤어핀일 때뿐입니다.
덤으로, 같은 기록에서 kubelet 의 상태 검사 요청은 그 노드의 Cilium 내부 주소(cilium_host)에서 온 것으로 찍혀 있었습니다.
1편의 공유기와 비교하면 두 가지가 같습니다. 출발지를 바꿔야 답이 같은 길로 돌아오고, 그 대가로 서버는 진짜 클라이언트를 볼 수 없습니다. 파드가 자기 Service 로 보낸 요청이 접속 기록에 169.254.42.1 로 찍히는 것은 고장이 아니라 이 설계의 결과입니다. 출발지 주소로 거르는 기능(허용 목록, 요청 수 제한)이 있다면 이 주소를 알아 두어야 합니다.
6. 한계
- 헤어핀 요청은 운영 파드 안에서 보낸 것이라 한 번만 보냈습니다. 시간 1.17ms 는 한 번의 값이고, 대조(3회)와의 차이를 성능 차이로 읽으면 안 됩니다.
- 설정을 바꾸지 않았으므로, 루프백 SNAT 가 없을 때 실제로 어떻게 실패하는지는 재지 않았습니다(미실측). 2절의 실패 설명은 소스 주석과 리눅스 동작에서 온 것입니다.
- kube-proxy iptables 모드와 bridge CNI 조합, 브리지 포트의
hairpin_mode를 끄고 켠 비교는 측정한 클러스터에 브리지가 없어 재지 않았습니다(미실측). - 파드 안에서 socket LB 를 쓰는 경우는 재지 않았습니다(3절의 추정).
- 대상 파드는 네트워크 정책 강제가 꺼져 있어, 헤어핀이 정책 검사를 건너뛴다는 소스 설명은 실측으로 확인하지 못했습니다(미실측).
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 우체국」 으로 바꿔 줘요. 제 일기장에 그 우체국 이름이 자꾸 보이면, 그건 제가 저한테 쓴 편지예요!