TT Lab
ブログ

한국어 English 日本語 中文

はじめる
ブログ

ブログ

ヘアピンNATの実測2:Podが自身のServiceを呼び出す場合

ステータス:実測(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部のルーターと同じ形の経路です。

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つの問題に直面します。

  1. ブリッジが送り返さない。 PodがLinuxブリッジ(例: cni0)に接続されている場合、Pのvethに入ってきたパケットの新しい宛先は再びPのvethになります。ブリッジは基本的には 受信したポートへは送信しません。 これを許可するのがブリッジポートのhairpin設定です。 bridge(8) 説明は次の通りです。受信したポートへと再び送信するかどうかを決定し、reflective relayとも呼ばれ、デフォルトは無効です。sysfsでは /sys/class/net/<브리지>/brif/<포트>/hairpin_mode として表示されます。
  2. 送信元が自身のアドレスであるパケットは疑われる。 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つあります。

ヘアピンは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 つだけであるため、そのポッド内から送信すると必ず自分自身に戻ってきます。

  1. ヘアピン: そのポッド内で Python ソケットを使用して Service アドレス(S:80)に GET /healthz を 1 回送信しました。ソケットが認識した自身のアドレスとリモートアドレス(getsockname, getpeername)、レスポンスと所要時間を記録しました。
  2. サーバー側: 直後にそのポッドのアクセスログから該当するリクエスト行を探しました。
  3. 接続追跡: 同じノードの Cilium エージェントから cilium-dbg bpf ct list global でその接続のエントリを読み取りました。
  4. 対照: 同じノードに --rm 一時ポッドを 1 つ起動し、同じ Service に同じリクエストを 3 回送信して、サーバーのログに記録された送信元を確認しました。

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 該当なし

サーバーのログ 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 ]

読み方の解説です。

おまけとして、同じログにおいて kubelet の状態確認リクエストはそのノードの Cilium 内部アドレス(cilium_host)から来たものとして記録されていました。

第 1 篇のルーターと比較すると、2 つの点が共通しています。送信元を変更しなければ応答が同じ経路で戻ってこず、その代償として サーバーは実際のクライアントを確認できません。 ポッドが自分の 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 に到達したリクエストの送信元である
送信元アドレスによるフィルタリング機能 ヘアピンリクエストは実際の送信元が失われる(ルーターも同様)

Titi の一言。 私の部屋のドアに「Titi 宛ての郵便受け」が取り付けられているのですが、私がそこに手紙を入れるとまた私の部屋に戻ってきます。しかし差出人の欄に自分の名前がそのまま書かれていると、「変な手紙だな」と思って捨ててしまいます。そこで郵便配達員の Cilium おじさんが差出人を「169.254.42.1 郵便局」に変えてくれます。私の日記帳にその郵便局の名前が何度も見つかったら、それは私が自分自身に宛てて書いた手紙なのです!

ログインするといいねできます

コメント

まだコメントはありません。

ログインするとコメントできます