GoSuda

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

By iwanhae
views ...

序論

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

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

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

背景知識

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

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

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のおかげで、一つのグローバルIPアドレスで複数の内部機器が同時にインターネットを利用できるようになるのです。

Kubernetes

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

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クラスターのあるノード(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|            |          |         |          |

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

問題認識

現象 - 1: 検索

そのため、一つの問題が発生します。eBPFを経由するパケットがNAT処理のためにSNATまたはDNAT処理を行う必要があるかどうかを検証するために、上記のHash Tableを検索する必要があるのですが、これまで見てきたようにSNAT処理には2種類のパケットが存在します。1. 内部から外部へ出るパケット、そして2. その応答として外部から内部へ入るパケットです。これら2つのパケットは、NAT処理における変換が必要でありながら、src ip, portとdst ip, portの値が入れ替わるという特徴があります。

そのため、高速な検索のために、srcとdstが入れ替わった値をキーとしてHash Tableに値をもう一つ追加するか、SNATと関係のないパケットであってもすべてのパケットに対して同じHash Tableを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

そして、issueやPRを読み進める中で、私が以前作った何かが原因でissueが発生したのではないかという不吉な予感に対する報復心理から、このような文章を執筆しています。

後記 - 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は素晴らしいです。ぜひ使ってみてください。