ステータス:実測(2026-11-01)。 TT Labクラスター(Kubernetes 1.34.10、Cilium 1.20.1)で測定した値です。クラスターの設定は変更せず、Serviceも新規作成していません。既存のServiceのうちエンドポイントが1つだけのものを選び、そのPod内からGETリクエストを1回送信し、対照実験は一時的なPodで行いました。アドレスはドキュメント用の例(192.0.2.0/24)に書き換え、Ciliumのデフォルト値である169.254.42.1および時間・ポートは実際の値です。作成ルール:実測データを基本とし、測定できなかったものは「未測定」、原因を確認できなかった解釈は「推定」と表記します。
目次
1. 結論から
ウェブアプリが自身のAPIをService名で呼び出すことはよくあります。レプリカが1つであるか、複数であってもロードバランシングによって偶然自分自身が選ばれた場合、リクエストは送信元のPodに戻ってきます。 送信元のPodに戻ります。 第1部のルーターと同じ形の経路です。
- 問題の根源は同じです。 ServiceアドレスをPodアドレスに書き換えるだけ(DNAT)では、送信元と宛先がどちらも自分自身であるパケットになってしまいます。Linuxはこのようなパケットを外部デバイスから受信すると破棄し、ブリッジは受信したポートへと再度送信しません。
- 解決策も同様です。送信元を書き換えます。 kube-proxyのiptablesモードは「送信元がそのエンドポイント自身であればマスカレードする」ルールを追加し、ブリッジベースのCNIはブリッジポートに
hairpin_modeを有効にします。CiliumはeBPFで送信元を169.254.42.1に変更します。 - kubeletの
hairpinModeは現在何もしていません。 デフォルト値が残り、ドキュメントもそのままですが、この値を使用していたkubenetが1.24でdockershimとともに削除されて以降、ソースコード内で設定検証以外にこの値を読み込んでいる箇所は見つかりませんでした。ヘアピンはCNIの役割です。 - 実測: Cilium 1.20.1において、Podが自分のServiceへ送信したGETリクエストは200(1.17ms)であり、サーバーから見た送信元は169.254.42.1、コネクション追跡には
LBLoopbackという表示が付いていました。別のPodから送信された対照用のリクエスト3回では、そのPodのアドレスがそのまま見えていました。
2. なぜそのままではうまくいかないのか
Pod P(192.0.2.133)がService S(192.0.2.96:80)にリクエストを送信します。SのエンドポイントはP自身(ポート8000)の1つだけです。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 (자기 자신)
送信元と宛先が同じパケットが、Podの外部(ノード側)から再度Podに入り込む必要があります。ここで2つの問題に直面します。
- ブリッジが送り返さない。 PodがLinuxブリッジ(例:
cni0)に接続されている場合、Pのvethに入ってきたパケットの新しい宛先は再びPのvethになります。ブリッジは基本的には 受信したポートへは送信しません。 これを許可するのがブリッジポートのhairpin設定です。bridge(8)説明は次の通りです。受信したポートへと再び送信するかどうかを決定し、reflective relayとも呼ばれ、デフォルトは無効です。sysfsでは/sys/class/net/<브리지>/brif/<포트>/hairpin_modeとして表示されます。 - 送信元が自身のアドレスであるパケットは疑われる。 Pod内のカーネルから見ると、外部デバイス(eth0)に入ってきたパケットの送信元が自分自身のアドレスになっています。Ciliumソースコードのコメントでもこの点に言及しており、Linuxはこのようなパケットをループバックデバイスから受信したものでない限りmartian source(火星のソース)とみなして破棄すると説明しています(bpf_lxc.c 259〜264行目)。仮に受け入れたとしても、応答は「自分自身に向かう」パケットとなりPod内で完結してしまうため、Serviceアドレスに戻す逆変換(リバースNAT)を経由しません。送信側のソケットはS:80からの応答を待っているのにP:8000から応答が返ってくる形になり、第1部のルーターと同様にコネクションが確立されません。
そのため、解決策も第1部と同じです。 送信元を別のアドレスに変更し、応答がServiceを処理する側に戻り、逆変換を経由するようにします。
3. 誰が何をするのか
kube-proxy:「自分自身であればSNAT」ルール
kube-proxyのiptablesモードは、Serviceのエンドポイントごとにチェインを作成し、その先頭に 送信元がそのエンドポイントアドレスと等しい場合にマスカレードフラグを付与する ルールを追加します。ソースコードのコメントは「送信元に戻るトラフィックはSNATで処理する」です(proxier.go 1342〜1346行目、v1.34.0)。このルールが存在していても、Podがブリッジに接続されている場合は1の問題(ブリッジが送り返さない)が残るため、ブリッジポートのhairpinが併せて必要になります。
kubelet hairpinMode:残っているが現在は何もしていない
kubeletの設定には hairpinMode(フラグ --hairpin-mode)があり、値は3つあります(},{KubeletConfiguration v1beta1 types.go 463〜476行目、v1.34.0).
| 値 | 説明(ソースコードのコメント) |
|---|---|
promiscuous-bridge(デフォルト) |
コンテナブリッジをプロミスキャスモードにする。 cbr0 というブリッジが存在すると仮定する |
hairpin-veth |
コンテナのvethインターフェースにhairpinフラグを有効にする |
none |
何もしない |
この値を実際に使用していたのは、kubeletに組み込まれていたネットワークプラグインの kubenet でした。1.23のソースコードを見ると、kubenetが hairpin-veth のとき、bridge CNIの設定に hairpinMode: true を挿入し、そうでなければブリッジをプロミスキャスモードにします(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 のままでしたが、ノードにはPod用のブリッジが存在せず(使用されていない
docker0 が1つあるのみ)、Podの14個のvethはどのブリッジにも接続されていませんでした。7月から残っているkubeletのログには、 hairpin という文字は1行もありませんでした。
Kubernetes公式ドキュメントの 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 | Podを接続する方式 | ヘアピン処理(確認の根拠) |
|---|---|---|
| bridgeプラグイン | Linuxブリッジ | 設定 hairpinMode(ブール値、デフォルトfalse)により、ブリッジに接続されるインターフェースのhairpinを有効にする(CNI bridgeドキュメント)。ServiceのSNATはkube-proxyの役割 |
| Flannel | 上記のbridgeプラグインに委任 | デフォルトマニフェストのCNI設定が delegate に "hairpinMode": true を挿入する(kube-flannel.yml) |
| Cilium | ブリッジなし、veth + eBPF | eBPFがService変換時にバックエンドが送信元Pod自身である場合、送信金を 169.254.42.1 に変更する(下記)。kube-proxyを代替する場合、SNATルールもCiliumの役割 |
他のCNI(Calicoなど)は今回ソースコードを確認していないため記載していません。
Cilium:169.254.42.1に送信元を書き換える
CiliumがServiceを処理する箇所は2つあります。
- socket LB: アプリが
connect()を呼び出した瞬間、eBPFがServiceアドレスをバックエンドアドレスに書き換えます。ソケットは最初からバックエンドに接続されるため、パケット単位のNATは不要です(kubeproxy-freeドキュメント 「Socket LoadBalancer Bypass in Pod Namespace」). - tc LB: Pod vethのtc eBPFがパケットごとにServiceを検索して書き換えます。同じドキュメントによると、もし
socketLB.hostNamespaceOnly=true(エージェントフラグbpf-lb-sock-hostns-only)であれば、Pod内ではsocket LBをバイパスしてtc LBを使用します。IstioサイドカーのようにPod内で本来のClusterIPを確認する必要がある場合や、KubeVirtのようにsocket LBがリスンしないPodのための設定です。
ヘアピンは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 であり、説明は「PodがServiceを通じて自分自身と通信するときのSNATに使用するIPv4送信元アドレス」です(cilium-agentコマンドリファレンス)。IPv6は fe80::1 です。
もう1つあります。同じファイルのコメントによると、エンドポイントがServiceを経由して自分自身に向かうフローは、 ネットワークポリシーの検査をバイパスします。 そうしないと、ユーザーが「Podが自分自身と通信してもよい」というポリシーを個別に記述しなければならないためです(bpf_lxc.c 1501〜1508行目).
)。socket LBをPod内でも使用する設定である場合、 connect() が直ちに自分のアドレスに接続されるため、上記のヘアピン経路自体が発生しないと考えられます(推測。測定したクラスターはポッド内で socket LB を使用していないため測定できませんでした)。
4. 測定環境と方法
| 項目 | 値 |
|---|---|
| Kubernetes | 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(デフォルト値) |
| 対象 | 既存のウェブサービス 1 つ。Service ポート 80 → コンテナポート 8000、エンドポイント 1 つ、ネットワークポリシー強制無効。アクセスログにクライアントアドレスを残す |
Service を新しく作成することはクラスターへの書き込みに該当するため行いませんでした。代わりにエンドポイントが 1 つだけの既存の Service を選択しました。この Service へのリクエストはバックエンドがそのポッド 1 つだけであるため、そのポッド内から送信すると必ず自分自身に戻ってきます。
- ヘアピン: そのポッド内で Python ソケットを使用して Service アドレス(S:80)に
GET /healthzを 1 回送信しました。ソケットが認識した自身のアドレスとリモートアドレス(getsockname,getpeername)、レスポンスと所要時間を記録しました。 - サーバー側: 直後にそのポッドのアクセスログから該当するリクエスト行を探しました。
- 接続追跡: 同じノードの Cilium エージェントから
cilium-dbg bpf ct list globalでその接続のエントリを読み取りました。 - 対照: 同じノードに
--rm一時ポッドを 1 つ起動し、同じ Service に同じリクエストを 3 回送信して、サーバーのログに記録された送信元を確認しました。
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 |
該当なし |
サーバーのログ 1 行と接続追跡のエントリは以下のとおりです(アドレスのみ例示用に変更しています)。
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)はそのままです。
- 接続追跡に 3 行のエントリが作成されました。 Service に向かった元の接続(SVC)、そしてループバックアドレスからポッドに向かう双方向(OUT・IN)であり、後の 2 つには
LBLoopbackが付いています。応答はこのエントリに沿って Service アドレスへと送り返されます。 - 対照リクエストは送信元がそのままでした。 他のポッドからのリクエストは SNAT なしでそのポッドのアドレスとして記録されました。送信元を変更するのはヘアピンの場合のみです。
おまけとして、同じログにおいて kubelet の状態確認リクエストはそのノードの Cilium 内部アドレス(cilium_host)から来たものとして記録されていました。
第 1 篇のルーターと比較すると、2 つの点が共通しています。送信元を変更しなければ応答が同じ経路で戻ってこず、その代償として サーバーは実際のクライアントを確認できません。 ポッドが自分の Service に送信したリクエストがアクセスログに 169.254.42.1 として記録されるのは障害ではなく、この設計の結果です。送信元アドレスでフィルタリングする機能(許可リスト、リクエスト数制限)がある場合は、このアドレスを把握しておく必要があります。
6. 制限事項
- ヘアピンリクエストは稼働中のポッド内から送信したため、 1回のみ送信しました。1.17ms という時間は 1 回分の値であり、対照(3回)との差を性能差として捉えてはなりません。
- 設定を変更していないため、ループバック SNAT がない場合に実際どのように失敗するのかは測定していません(未測定)。2 節の失敗に関する説明は、ソースコードのコメントと Linux の動作に由来するものです。
- kube-proxy iptables モードとブリッジ 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 に到達したリクエストの送信元である |
| 送信元アドレスによるフィルタリング機能 | ヘアピンリクエストは実際の送信元が失われる(ルーターも同様) |
Titi の一言。 私の部屋のドアに「Titi 宛ての郵便受け」が取り付けられているのですが、私がそこに手紙を入れるとまた私の部屋に戻ってきます。しかし差出人の欄に自分の名前がそのまま書かれていると、「変な手紙だな」と思って捨ててしまいます。そこで郵便配達員の Cilium おじさんが差出人を「169.254.42.1 郵便局」に変えてくれます。私の日記帳にその郵便局の名前が何度も見つかったら、それは私が自分自身に宛てて書いた手紙なのです!