TT Lab
Blog

한국어 English 日本語 中文

Get started
Blog

Blog

Hairpin NAT Measurement 2: When a Pod Calls Its Own Service

Status: Measured (2026-11-01). These values were measured on the TT Lab cluster (Kubernetes 1.34.10, Cilium 1.20.1). Cluster configurations were not changed, and no new Services were created. Among existing Services, one with a single endpoint was selected, a GET request was sent from inside that pod, and control measurements were performed using a temporary pod. Addresses have been rewritten to documentation examples (192.0.2.0/24), while Cilium's default value of 169.254.42.1 as well as times and ports are actual values. Writing rules: Measured data is the baseline; unmeasured items are marked as "Unmeasured", and interpretations whose causes could not be verified are marked as "Estimated".

Table of Contents

1. Starting with the Conclusion

It is common for a web app to call its own API using its Service name. If there is a single replica, or if load balancing happens to pick itself even when there are multiple replicas, the request returns to the sending pod. Part 1's path has the exact same shape as a router.

2. Why It Fails Out of the Box

Pod P (192.0.2.133) makes a request to Service S (192.0.2.96:80). S has only a single endpoint, which is P itself (port 8000). If the component handling the Service (whether kube-proxy's iptables rules or CNI's eBPF) changes only the destination, the packet looks like this.

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

A packet with the same source and destination must re-enter the pod from outside the pod (toward the node). Two things block this here.

  1. The bridge refuses to send it back. If the pods are attached to a Linux bridge (e.g., cni0), the new destination of the packet entering P's veth is once again P's veth. By default, a bridge does not forward packets out of the port they were received on. Allowing this is the hairpin setting of the bridge port. bridge(8) The explanation is as follows: it determines whether to send packets back out of the receiving port, also called reflective relay, and the default is off. In sysfs, it appears as /sys/class/net/<브리지>/brif/<포트>/hairpin_mode .
  2. Packets whose source is their own address are treated with suspicion. From the perspective of the kernel inside the pod, a packet entering through an external device (eth0) has its own address as the source. Cilium source code comments also point this out, explaining that Linux drops such packets as martian sources unless they are received from the loopback device (bpf_lxc.c lines 259–264). Even if accepted, the response becomes a packet going "to itself" and terminates inside the pod, bypassing the reverse NAT (reverse transformation) back to the Service address. The sending socket awaits a response from S:80, but receives a response from P:8000 instead, failing to establish a connection just like the router in Part 1.

Therefore, the solution is the same as in Part 1: change the source address to a different oneso that the response returns to the component handling the Service and undergoes reverse transformation.

3. Who Does What

kube-proxy: "SNAT if Self" Rule

kube-proxy's iptables mode creates a chain for each Service endpoint and places at the very front a rule to attach a masquerade mark if the source matches that endpoint address . The source code comment says "Traffic returning to the originator is handled via SNAT" (proxier.go lines 1342–1346, v1.34.0). Even with this rule, if the pod is attached to a bridge, problem 1 (bridge not sending back) remains, so hairpin on the bridge port is also required.

kubelet hairpinMode: Present But Does Nothing Now

The kubelet configuration has hairpinMode(flag --hairpin-mode) with three possible values (KubeletConfiguration v1beta1 types.go lines 463–476, v1.34.0).

Value Description (Source Comment)
promiscuous-bridge(default) Sets container bridge to promiscuous. Assumes a bridge named cbr0 exists
hairpin-veth Enables hairpin flag on container veth interfaces
none Does nothing

The place that actually used this value was the network plugin kubenet included in kubelet. Looking at the 1.23 source code, when kubenet is hairpin-veth , it injects hairpinMode: true into the bridge CNI configuration, or else sets the bridge to promiscuous (kubenet_linux.go, v1.23.0). kubenet was removed in 1.24 along with dockershim. In the current source code, we could find no places reading this value other than flag definition(cmd/kubelet/app/options/options.go), default value, and configuration validation (checked via GitHub code search on the default branch and confirming that kubelet.go·cmd/kubelet/app/server.go contains no mention of hairpin in v1.34.0, 2026-11-01). Nowadays, the CNI handles hairpins.

We also verified this on the measured cluster. The node's kubelet configuration (/configz) remained at its default value promiscuous-bridge , but the node had no bridge for pods (only an unused docker0 ), and the 14 pod veths were not attached to any bridge. The kubelet logs retained since July did not contain a single line with the word hairpin .

The Kubernetes official documentation section Debug Services "A Pod fails to reach itself via the Service IP" still guides users to check kubelet's hairpin-mode flag, the "Hairpin mode set to" log line, and the cbr0 bridge. Based on the source code above, this guidance is no longer accurate for clusters after version 1.24. If hairpins fail, you should inspect the CNI configuration rather than the kubelet.

Per CNI

CNI Pod Attachment Method Hairpin Handling (Basis for Verification)
bridge plugin Linux bridge Configuration hairpinMode(boolean, default false) enables hairpin on interfaces attached to the bridge (CNI bridge documentation). SNAT for the Service is kube-proxy's job
Flannel Delegated to the bridge plugin above The CNI configuration in the default manifest inserts delegate into "hairpinMode": true (kube-flannel.yml)
Cilium veth + eBPF without a bridge During Service translation, eBPF changes the source to 169.254.42.1 if the backend is the sending pod itself (below). If replacing kube-proxy, SNAT rules are also Cilium's responsibility

Other CNIs (such as Calico) are not listed here as we did not open their source code this time.

Cilium: Rewrites Source to 169.254.42.1

Cilium handles Services in two places.

Hairpins are a matter for the tc LB side. bpf_lxc.c selects a backend and, if the backend address matches the packet's source address , attaches a loopback mark to that connection (bpf_lxc.c lines 249–266). Then, lb4_dnat_request changes the destination to the backend while modifying the source to the configured value service_loopback_ipv4 (lb.h lines 2355–2376). Its default value is --ipv4-service-loopback-address of agent flag 169.254.42.1 , and its description is "IPv4 source address used for SNAT when a pod communicates with itself via a Service" (cilium-agent command reference). IPv6 is fe80::1 .

There is one more thing. According to comments in the same file, traffic flows where an endpoint goes to itself via a Service bypass Network Policy checks. This is because otherwise, users would have to write separate policies stating that "pods are allowed to communicate with themselves" (bpf_lxc.c lines 1501–1508).

If socket LB is configured to be used inside the pod as well, connect() directly connects to its own address, so the hairpin path itself above likely will not be created (estimation; the measured cluster did not use socket LB inside the pod, so it was not measured).

4. Measurement Environment and Method

Item Value
Kubernetes 1.34.10, no kube-proxy
Cilium 1.20.1. kube-proxy-replacement=true, tunnel (VXLAN), bpf-lb-sock-hostns-only=true → Agent status "Socket LB Coverage: Hostns-only"
Loopback address In the endpoint's eBPF configuration, ServiceLoopbackIPv4 = 169.254.42.1 (default)
Target An existing web service. Service port 80 → container port 8000, single endpoint, network policies forcibly disabled. Leaves the client address in the access logs.

Creating a new Service was described as not performing cluster writes. Instead, we chose an existing Service with only a single endpoint. Since requests to this Service have only that single pod as a backend, sending a request from within that pod will invariably return to itself.

  1. Hairpin: Sent a GET /healthz to the Service address (S:80) via a Python socket from inside the pod. Recorded the self-address and peer address seen by the socket (getsockname, getpeername), the response, and the elapsed time.
  2. Server side: Immediately afterwards, we found the request line in that pod's access logs.
  3. Connection tracking: Read the entry for that connection using cilium-dbg bpf ct list global on the same node's Cilium agent.
  4. Control: Launched a temporary pod --rm on the same node, sent three identical requests to the same Service, and checked the source IP recorded in the server logs.

5. Measurement Results

파드가 자기 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번 서버 로그에 그 파드의 주소가 그대로 찍혔다
Figure: The path where Cilium's tc eBPF changes the destination to itself and the source to 169.254.42.1 when a pod makes a request to its own Service, along with measurement results.
Item Hairpin (Self → Own Service) Control (Other Pod → Same Service)
Response HTTP 200, 1.17ms (1 attempt) HTTP 200, 2.64, 2.65, 3.21ms (3 attempts)
Peer seen by client socket S:80 S:80
Source in server logs 169.254.42.1:48766 That pod's address:51502, etc. (unchanged)
Connection tracking entry LBLoopback N/A

Here is a single line from the server logs and the connection tracking entry (only the addresses have been modified for the example).

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 ]

How to read this:

Additionally, in the same logs, kubelet's health check requests were recorded as originating from the node's Cilium internal address (cilium_host).

Comparing this with the router in Part 1, two things are identical: the source must be changed so that the response returns via the same path, and as a price, the server cannot see the real client. The fact that requests sent by a pod to its own Service are logged as 169.254.42.1 is not a bug, but a result of this design. If you have features that filter by source address (allowlists, rate limiting), you need to be aware of this address.

6. Limitations

7. Conclusion

Finding Reason
"Cannot access own Service" points to CNI rather than kubelet Post-1.24 kubelet's hairpinMode has no code that reads it
If using a bridge-based CNI, hairpinMode: true Bridges fundamentally do not send packets back out the port they arrived on
If kube-proxy is present, endpoint chain's -s <자기 주소> -j KUBE-MARK-MASQ This rule is what changes the source
If using Cilium, 169.254.42.1 in access logs is normal It is the source of requests sent to own Service
Source address filtering features Hairpin requests lose the real source (same for routers)

Titi's Word. There is a mailbox labeled 'Letters for Titi' attached to my room door, and if I put a letter in it, it comes right back to my room. But if my name is still written in the sender field, I throw it away thinking, 'What a weird letter.' That's why Cilium the mailman changes the sender to '169.254.42.1 Post Office.' If you keep seeing that post office name in my diary, it means I wrote a letter to myself!

Sign in to like

Comments

No comments yet.

Sign in to leave a comment