GoSuda

Ciliumの物語:小さなコード変更がもたらした驚くべきネットワーク安定性向上

By iwanhae
views ...

緒論

先日、以前の職場の同僚によるCiliumプロジェクトのPRを拝見しました。

bpf:nat: Restore ORG NAT entry if it's not found

(テストコードを除けば)修正量自体はif文ブロックを1つ追加する程度と少ないです。しかし、この修正がもたらした影響力は絶大であり、単純なアイデア一つがシステム安定性に非常に大きく貢献し得るという事実に個人的に面白さを感じました。そこで、ネットワーク分野の専門知識がない方々にもこの事例を容易に理解していただけるよう、一度話を展開してみようと思います。

背景知識

スマートフォンと同じくらい重要な現代人の必需品があるとすれば、おそらくWi-Fiルーターではないでしょうか。Wi-Fiルーターは、Wi-Fiという通信規格を通じてデバイスと通信を行い、自身が持つグローバルIPアドレスを複数のデバイスで利用できるように共有する役割を果たします。ここで生じる技術的な特異点は、どのように「共有」するのか、という点です。

ここで使用される技術がNetwork Address Translation (NAT)です。NATは、TCPまたはUDP通信がIPアドレスとポート情報の組み合わせで構成されるという点において、プライベートIP:Portで構成される内部通信を、現在使用されていないグローバルIP:Portにマッピングすることで、外部との通信を可能にする技術です。

NAT

NAT機器は、内部デバイスが外部インターネットに接続しようとする際、そのデバイスのプライベートIPアドレスとポート番号の組み合わせを、自身のグローバルIPアドレスと使用されていない任意のポート番号に変換します。この変換情報は、NAT機器内部のNATテーブルと呼ばれる場所に記録されます。

例えば、家の中のスマートフォン(プライベートIP: a.a.a.a、ポート: 50000)がウェブサーバー(グローバルIP: c.c.c.c、ポート: 80)に接続しようと仮定してみましょう。

1スマートフォン (a.a.a.a:50000) ==> ルーター (b.b.b.b) ==> ウェブサーバー (c.c.c.c:80)

ルーターはスマートフォンのリクエストを受け取ると、次のようなTCP Packetを見ることになるでしょう。

1# ルーターが受け取ったTCPパケット、スマートフォン => ルーター
2| src ip  | src port | dst ip  | dst port |
3-------------------------------------------
4| a.a.a.a | 50000    | c.c.c.c | 80       |

このパケットをそのままウェブサーバー(c.c.c.c)に送った場合、プライベートIPアドレスを持つスマートフォン(a.a.a.a)に応答が返ってこないため、ルーターはまず現在通信に関与していない任意のポート(例: 60000)を見つけ出し、内部のNATテーブルに記録します。

1# ルーター内部NATテーブル
2| local ip  | local port | global ip  | global port |
3-----------------------------------------------------
4| a.a.a.a   | 50000      | b.b.b.b    | 60000       |

ルーターはNATテーブルに新しいエントリを記録した後、スマートフォンから受け取ったTCPパケットの送信元IPアドレスとポート番号を、自身のグローバルIPアドレス(b.b.b.b)と新しく割り当てたポート番号(60000)に変更してウェブサーバーに送信します。

1# ルーターが送信したTCPパケット、ルーター => ウェブサーバー
2# SNATを実行
3| src ip  | src port | dst ip  | dst port |
4-------------------------------------------
5| b.b.b.b | 60000    | c.c.c.c | 80       |

これでウェブサーバー(c.c.c.c)はルーター(b.b.b.b)の60000番ポートからのリクエストとして認識し、応答パケットを次のようにルーターに送信します。

1# ルーターが受け取ったTCPパケット、ウェブサーバー => ルーター
2| src ip  | src port | dst ip  | dst port |
3-------------------------------------------
4| c.c.c.c | 80       | b.b.b.b | 60000    |

ルーターはこの応答パケットを受け取ると、NATテーブルから宛先IPアドレス(b.b.b.b)とポート番号(60000)に該当する元のプライベートIPアドレス(a.a.a.a)とポート番号(50000)を見つけ出し、パケットの宛先をスマートフォンに変更します。

1# ルーターが送信したTCPパケット、ルーター => スマートフォン
2# DNATを実行
3| src ip  | src port | dst ip  | dst port |
4-------------------------------------------
5| c.c.c.c | 80       | a.a.a.a | 50000    |

このプロセスを通じて、スマートフォンはあたかも自身が直接グローバルIPアドレスを持ち、ウェブサーバーと通信しているかのように感じられます。NATのおかげで、1つのグローバルIPアドレスで複数の内部デバイスが同時にインターネットを利用できるようになるのです。

Kubernetes

Kubernetesは、最近登場した技術の中でも最高に精巧かつ複雑なネットワーク構造を持っています。そしてもちろん、前述のNATも様々な場所で活用されています。代表的な事例は次の2つです。

Pod内部からクラスター外部と通信を行う場合

Kubernetesクラスター内部のPodは、一般的にクラスターネットワーク内でのみ通信可能なプライベートIPアドレスを割り当てられます。したがって、Podが外部インターネットと通信するためには、クラスター外部へのトラフィックに対してNATが必要です。このとき、NATは主に当該Podが実行されているKubernetesノード(クラスターの各サーバー)で実行されます。Podが外部へ向かうパケットを送信すると、当該パケットはまずPodが属するノードに転送されます。ノードはこのパケットの送信元IPアドレス(PodのプライベートIP)を自身のグローバルIPアドレスに変換し、送信元ポートも適切に変更して外部に転送します。このプロセスは、前述のWi-FiルーターでのNATプロセスと類似しています。

例えば、Kubernetesクラスター内のPod(10.0.1.10、ポート: 40000)が外部のAPIサーバー(203.0.113.45、ポート: 443)に接続すると仮定すると、KubernetesノードはPodから次のようなパケットを受け取ることになり、

1# ノードが受け取ったTCPパケット、Pod => ノード
2| src ip    | src port | dst ip        | dst port |
3---------------------------------------------------
4| 10.0.1.10 | 40000    | 203.0.113.45  | 443      |

ノードは次のような内容を記録した後、

1# ノード内部NATテーブル (例)
2| local ip    | local port | global ip     | global port |
3---------------------------------------------------------
4| 10.0.1.10   | 40000      | 192.168.1.5   | 50000       |

次のようにSNATを実行した後、外部へパケットを送信します。

1# ノードが送信したTCPパケット、ノード => APIサーバー
2# SNATを実行
3| src ip      | src port | dst ip        | dst port |
4-----------------------------------------------------
5| 192.168.1.5 | 50000    | 203.0.113.45  | 443      |

その後は、前述のスマートフォンルーターの事例と同じプロセスを経ることになります。

クラスター外部からNodePortを介してPodと通信する場合

Kubernetesでサービスを外部に公開する方法の一つは、NodePortサービスを使用することです。NodePortサービスは、クラスター内のすべてのノードの特定のポート(NodePort)を開放し、このポートに入ってくるトラフィックをサービスに属するPodに転送する方式です。外部ユーザーは、クラスターのノードのIPアドレスとNodePortを介してサービスにアクセスできます。

このとき、NATは重要な役割を果たし、特にDNAT (Destination NAT)SNAT (Source NAT)同時に発生します。外部から特定のノードのNodePortにトラフィックが流入すると、Kubernetesネットワークはこのトラフィックを最終的に当該サービスを提供するPodに転送する必要があります。この過程で、まずDNATが発生し、パケットの宛先IPアドレスとポート番号がPodのIPアドレスとポート番号に変更されます。

例えば、クラスター外部のユーザー(203.0.113.10、ポート: 30000)がKubernetesクラスターの1ノード(192.168.1.5)のNodePort(30001)を介してサービスにアクセスすると仮定してみましょう。このサービスは内部的にIPアドレスが10.0.2.15でポートが8080のPodを指していると仮定します。

1外部ユーザー (203.0.113.10:30000) ==> Kubernetesノード (外部:192.168.1.5:30001 / 内部: 10.0.1.1:42132) ==> Kubernetes Pod (10.0.2.15:8080)

ここでKubernetesノードの場合、外部からアクセス可能なノードのIPアドレス192.168.1.5と、内部Kubernetesネットワークで有効なIPアドレス10.0.1.1の両方を持っています。(使用するCNIの種類によってこれに関連するポリシーは多様になりますが、この文章ではCiliumを基準に説明を進めます。)

外部ユーザーのリクエストがノードに到着すると、ノードはこのリクエストを処理するPodに転送する必要があります。このとき、ノードは次のようなDNATルールを適用してパケットの宛先IPアドレスとポート番号を変更します。

1# ノードがPodに送信しようと準備中のTCPパケット
2# DNAT適用後
3| src ip        | src port | dst ip    | dst port |
4---------------------------------------------------
5| 203.0.113.10 | 30000    | 10.0.2.15 | 8080     |

ここで重要な点は、Podがこのリクエストに対する応答を送信する際、送信元IPアドレスは自身のIPアドレス(10.0.2.15)であり、宛先IPアドレスはリクエストを送信した外部ユーザーのIPアドレス(203.0.113.10)になることです。この場合、外部ユーザーは自身がリクエストした覚えのない存在しないIPアドレスからの応答を受け取り、当該パケットをそのままDROPしてしまいます。そのため、KubernetesノードはPodが外部に応答パケットを送信する際にSNATを追加的に実行し、パケットの送信元IPアドレスをノードのIPアドレス(192.168.1.5または内部ネットワークIPである10.0.1.1、この場合は10.0.1.1で進行)に変更します。

1# ノードがPodに送信しようと準備中のTCPパケット
2# DNAT, SNAT適用後
3| src ip        | src port | dst ip    | dst port |
4---------------------------------------------------
5| 10.0.1.1     | 40021    | 10.0.2.15 | 8080     |

これで、当該パケットを受け取ったPodは、最初にNodePortでリクエストを受け取ったノードに応答を返すことになり、ノードは全く同じDNAT、SNATプロセスを逆適用して外部ユーザーに情報を返します。この過程で、各ノードは次のような情報を保存することになるでしょう。

1# ノード内部DNATテーブル
2| original ip     | original port | destination ip  | destination port |
3------------------------------------------------------------------------
4| 192.168.1.5     | 30001         | 10.0.2.15       | 8080             |
5
6# ノード内部SNATテーブル
7| original ip     | original port | destination ip  | destination port |
8------------------------------------------------------------------------
9| 203.0.113.10    | 30000         | 10.0.1.1        | 42132            |

本論

一般的にLinuxでは、このようなNATプロセスはiptablesを介してconntrackというサブシステムによって管理・動作します。実際にflannelやcalicoなどの他のCNIプロジェクトでは、これを利用して上述の問題を処理しています。ただし、問題はCiliumがeBPFという技術を使用し、これらの伝統的なLinuxネットワークスタックを完全に無視してしまう点です。🤣

https://cilium.io/blog/2021/05/11/cni-benchmark/

その結果、Ciliumは上記の図で従来のLinuxネットワークスタックが担っていた業務のうち、Kubernetesの状況で必要な機能のみを直接実装する道を選択しました。そのため、前述のSNATプロセスについて、CiliumはSNATテーブルをLRU Hash Map (BPF_MAP_TYPE_LRU_HASH) の形で直接管理しています。

1# Cilium SNATテーブル
2# !簡単な説明のための例。実際の定義は: https://github.com/cilium/cilium/blob/v1.18.0-pre.1/bpf/lib/nat.h#L149-L166
3| src ip     | src port | dst ip  | dst port | protocol, conntrackなどその他のメタデータ
4----------------------------------------------
5|            |          |         |          |

そして、ハッシュテーブルであるため、高速な検索のためにキー値が存在し、src ipsrc portdst ipdst port組み合わせをキー値として使用しています。

問題認識

現象 - 1: 検索

そのため発生する問題点が一つあります。eBPFを通過するパケットがNATプロセスにおいてSNATまたはDNATプロセスを実行する必要があるパケットであるかを検証するために、上記のハッシュテーブルを検索する必要がありますが、これまで見てきたように、SNATプロセスには2種類のパケットが存在します。1. 内部から外部へ出ていくパケット、そして2. その応答として外部から内部へ入ってくるパケットです。これら2つのパケットは、NATプロセスにおける変換が必要であると同時に、src ip, portとdst ip, portの値が逆転するという特徴があります。

したがって、高速な検索のために、srcとdstが逆転した値をキーとしてハッシュテーブルに値をもう一つ追加するか、SNATと関係のないパケットであってもすべてのパケットに対して同じハッシュテーブルを2回検索する必要があります。当然、Ciliumはより良いパフォーマンスのために、RevSNATという名前で同じデータを2回挿入する方式を採用しました。

現象 - 2: LRU

そして、上記の課題とは別に、すべてのハードウェアに無限のリソースは存在し得ず、特に高速性能が要求されるハードウェアロジックであるため、動的なデータ構造も使用できない状況において、リソースが不足した際には既存のデータをevictする必要があります。Ciliumでは、これをLinuxで標準提供される基本データ構造であるLRU Hash Mapを使用することで解決しました。

現象 1 + 現象 2 = コネクション喪失

https://github.com/cilium/cilium/issues/31643

つまり、一つのSNATされたTCP(あるいはUDP)コネクションについて、

  1. 一つのHash Tableに、出ていくパケットと入ってくるパケットについて同じデータが二度記録されており、
  2. LRUロジックに従って、いつでも両方のデータのうち一方が失われる可能性がある状況で、

外部に出ていく、あるいは外部から入ってくるパケットに関するNAT情報(以下、entry)のいずれか一つでもLRUによって失われると、正常にNATを実行できず、全体のコネクション喪失につながる可能性があるということです。

解決

ここで、先に言及した以下のPRが登場します。

bpf:nat: restore a NAT entry if its REV NAT is not found

bpf:nat: Restore ORG NAT entry if it's not found

以前は、パケットがeBPFを通過する際、SNATテーブルでsrc ip, src port, dst ip, dst portの組み合わせでキーを作成して検索を試みました。もしキーが存在しない場合、SNATルールに従って新しいNAT情報を生成し、テーブルに記録します。新しいコネクションの場合、これは正常な通信につながるでしょう。もしLRUによって意図せずキーが削除された場合、既存の通信で使用していたポートとは異なるポートで新たにNATを実行することになり、パケットを受信する側が受信を拒否し、RSTパケットとともにコネクションが終了することになるでしょう。

ここで上記のPRが取ったアプローチは単純です。

どちらの方向であってもパケットが一度観測されたら、その逆方向のentryも新しく更新しよう。

どちらの方向であれ通信が観測されれば、両方のentryが新しく更新されることでLRUロジックのEviction優先順位の対象から遠ざかり、これによりどちらか一方のentryだけが削除されて全体の通信が崩壊するシナリオになる可能性を低減することができます。

これは非常に単純なアプローチであり、簡単なアイデアのように見えるかもしれませんが、このアプローチを通じて応答パケットに関するNAT情報が先に期限切れになって接続が切断される問題を効果的に解決し、システムの安定性を大幅に向上させることができました。また、ネットワークの安定性の観点から、次のような成果を上げた重要な改善と言えるでしょう。

benchmark

結論

私は、このPRがNATがどのように動作するかという基本的なCS知識から始まり、複雑なシステム内部においてもシンプルなアイデア一つがどれほど大きな変化をもたらし得るかを示す、非常に良い事例だと考えています。

ああ、もちろん、複雑なシステムの事例をこの文章で直接お見せしたわけではありません。しかし、このPRを正しく理解するために、私はDeepSeek V3 0324Pleaseという言葉まで付け加えて3時間近く懇願し、その結果、Ciliumに関する知識+1と、以下のような図を一つ得ることができました。😇

diagram

そして、イシューとPRを読みながら、私が以前作成した何かのせいでイシューが発生したのではないかという不吉な予感に対する報酬心理として、このような記事を書いてみました。

後記 - 1

ちなみに、この問題には非常に効果的な問題回避策が存在します。問題が発生する根本原因はNATテーブルの容量不足なので、NATテーブルのサイズを増やせば解決します。:-D

誰かが同じ問題に遭遇した際に、イシューも残さずにNATテーブルのサイズを増やして逃げたという状況の中、本人が直接関与する問題ではないにもかかわらず、徹底的に分析し理解し、客観的な根拠データとともにCiliumエコシステムに貢献までしてくださったgyutaeb様の情熱には感銘を受け、尊敬の念を抱きます。

これがこの記事を書こうと決心したきっかけでした。

後記 - 2

この話は、Go言語を専門的に扱うGosudaとは直接的に関係のないテーマかもしれません。しかし、Go言語とクラウドエコシステムは密接に関連しており、Ciliumの貢献者たちはGo言語にある程度の知識を持っているため、個人ブログに掲載する内容をGosudaに一度持ち込んでみました。

管理者の一人(私自身)の許可があったので、おそらく問題ないだろうと思います。

問題があるとお考えの方は、いつ削除されるかわからないので、すぐにPDFとして保存しておくことをお勧めします。;)

後記 - 3

今回の記事作成には、ClineとLlama 4 Mavericの多大な助けを得ました。Geminiで分析を開始し、DeepSeekに懇願したものの、実際に助けてくれたのはLlama 4でした。Llama 4は素晴らしいです。ぜひ使ってみてください。