ネットワークスタック 基礎

クライアントが curl https://api.example.com/user/42 を実行する。このプロセスと対岸のプロセスの間には 5 層ある。それについての 3 つの主張は、どれも隣の図が証明する。各層のコストは数えられるバイトである、経路上のあらゆる天井は具体的な数である、そしてこれらの失敗は設計上どれも静かである。

01

各層が足しているもの

5 層のそれぞれが下るときにヘッダを前置し、上るときに剥がす。ワイヤに届くのは、アプリが書いたバイト列ではない。

大学は OSI の 7 層を教える。現場が考えるのは 5 層だ。L7 の HTTP、L4 の TCP、L3 の IPv4、L2 の Ethernet、 L1 の線符号である。

リクエストは 500 バイトの HTTP。スライダを 1 層ずつ動かし、ペイロードが元の大きさのまま、各ヘッダがその前に付くのを見てほしい:

HTTP ペイロード — まだフレーミングなし

どの層もペイロードを書き換えない。TCP が 20、IPv4 が 20、Ethernet が前に 14 と後ろに 4 ——58 バイトのフレーミングが 500 バイトを包み、ワイヤ上は 558 バイト。これがスタック全体の不変条件だ。各層は自分のヘッダだけを持ち、上は不透明なバイト列として扱う。

58 バイトは 500 バイトには安く、小さなリクエストには致命的だ。曲線上のマーカーをドラッグして —— 図全体がドラッグ面だ ——フレーミング比率と、それが包むペイロードの関係を読む:

ペイロード 500 B → ワイヤ 558 B
ペイロード 500 B → ワイヤ 558 B

500 バイトならフレーミングはワイヤの 10.4%。なら 98.3% —— 1 バイト届けるのに 59 バイト動かす。おしゃべりなプロトコルがリンクの埋まる前にバッチ型へ負ける理由であり、Nagle アルゴリズムの存在理由でもある。未確認の ACK が返るまで小さな書き込みを溜める。

上りでは同じフレームが剥かれ、各ヘッダの本当の仕事は上の層を名指しすることだ。再生を押して、各多重分離キーが残りをただ 1 つの上位層へ渡すのを見てほしい:

Ethernet — EtherType が IPv4 を選ぶ

サイズではなくキーを見る。EtherType 0x0800 は IPv4、プロトコルバイト 6 は TCP、宛先ポート 443 はどの struct sock かを指す。層は探索しない —— 1 フィールド読んで振り分けるだけだ。受信は 3 回の表引きで、だから接続数に対して O(1) になる。

ワイヤは任意の大きさのフレームを運ばない。Ethernet のペイロード上限は 1500 バイトで、IP の 20 と TCP の 20 を引くと1 セグメントは最大 1460 バイト。書き込み量をその線の向こうへドラッグして、TCP がどう割るかを見る:

write 1,200 B

書き込みが を越えた途端、2 番目のセグメントは 1 バイトしか運ばず、 58 バイトのフレーミングを追加で払う。TCP はバイトストリームなのでアプリは分割を見ない。MSS より 1 バイト大きいだけのプロトコルは、パケット数も損失面も 2 倍になる。

アドレスが変われば天井も動く。IPv6 のヘッダは 20 ではなく 40 バイトなので、同じ 1500 バイトのフレームが運ぶペイロードは 1460 ではなく 1440 になる。書き込み量はそのままにバージョンを切り替える:

ペイロード 1,200 B

1,460 バイトの書き込みは IPv4 で 1 セグメント、IPv6 で 2 —— 「デュアルスタックにしただけで遅くなった」の正体だ。トンネルも積み重なる。 VLAN 4 バイト、GRE 24、IPsec は 50 前後、どれも同じ 1500 から引かれる。

さらに媒体自身が、どのヘッダにも計上されない税を取る。プリアンブル 7 バイト、フレーム開始デリミタ 1 バイト、そしてフレームごとに 12 バイトのフレーム間ギャップだ。フレーム長を横にドラッグして、リンクが実際に届ける量を読む:

1,518 B のフレーム
1,518 B のフレーム

最小フレームの では、10 Gbps のリンクは 14.88 Mpps でグッドプット 714 Mbps —— 93% がフレーミングとギャップに消える。1,518 バイトでは813 Kpps、9.49 Gbps。どちらも section 04 で戻る。

02

フローがぶら下がるソケット

ファイル記述子は、2 つのバッファと TCP 状態機械、そしてフローを識別する4 つの数を持つカーネルオブジェクトを指す。

socket() は struct sock を包む struct socket を確保する。TCP なら struct tcp_sock で、それを最初のメンバに埋め込む。記述子は普通の VFS ハンドルであり、だから read が効く。

4 つの数とは、送信元アドレスとポート、宛先アドレスとポートだ。到着したセグメントを 1 つずつ辿り、この tuple がちょうど 1 つのソケットを選ぶのを見てほしい:

セグメント 1 / 6

多重分離が依存する不変条件に注目。確立済みの任意の 2 接続は 4 つのうち少なくとも 1 つで異なる。上では 2 つが送信元 IP を、別の 2 つが送信元ポートを共有するが、どちらも衝突しない。誰も名乗らない tuple はリスナに落ち、リスナも無ければRST が返る。

この規則はサーバに寛大で、クライアントに厳しい。(*, 443) のサーバは相手側が変わるので数百万接続を抱えられるが、1 つの宛先へ向かうクライアントは自分のポートしか変えられない。接続数を上げてエフェメラル範囲が埋まるのを見る:

8,000 接続

Linux の既定 net.ipv4.ip_local_port_range は 32768 60999 —— 、つまり 1 つの送信元 IP から 1 つの (宛先 IP, 宛先ポート) へは同時 28,232 接続まで。超えると connect() が EADDRNOTAVAIL を返す。これは音を立てて失敗する良いケースだ。静かな方は、閉じたあと 60 秒 TIME_WAIT に居座るソケットが同じ範囲を食い潰す。

天井のもう 1 つはウィンドウだ。未確認のバイト数は受信側の広告分を超えられず、スループットはウィンドウ ÷ 往復を超えない。往復時間を上げて、既定のウィンドウの行き先を見る:

RTT 10 ms · ウィンドウ 200 KB

100 ms では 1 Gbps を埋めるのに12.5 MB の飛行中データが要るが、200 KBのウィンドウが出せるのは 16.4 Mbps —— リンクの 1.6%、輻輳も損失もないのにだ。だから Linux は tcp_rmem を自動調整するし、SO_RCVBUF を手で設定するのは誤りになる。

accept 経路にはさらに2 つの天井がある。listen(fd, backlog) が 2 つ目、tcp_max_syn_backlog が 1 つ目だ。誰も accept() を呼ばない状態で到着数を上げる:

到着 0

24 件目の後に何が起きるかを見てほしい。カーネルは拒否せず、アプリも何も記録しない —— SYN-ACK が再送され、最後は破棄される。クライアントにはタイムアウトが、サーバには健康なプロセスが見える。教えてくれるのは nstat -az TcpExtListenOverflows と ss -lnt の Recv-Q だけだ。

確立後は同じ圧力が接続の内側に現れる。受信バッファは届いて読まれていないバイトを保持し、広告ウィンドウはその残りだ。読み手を遅くしてみる:

アプリは到着量の 100% を読む

到着レートのおよそ 40% を下回るとバッファが埋まり、広告ウィンドウはゼロに達する —— 送信側は止まり、再開させられるのは読み手がバイトを引き取ることだけだ。これが端から端までのバックプレッシャであり、遅いコンシューマがカーネルのメモリ増ではなくプロデューサ側のレイテンシとして現れる理由でもある。

これを数万個抱えるサーバは 1 つずつ問い合わせない。epoll は変化した記述子だけを返す。記述子数を上げ、2 つの API を切り替える:

500 記述子、8 個がレディ

select() は毎回集合全体をコピーして走査するので、コストは「見ている記述子の数」。epoll_wait() はレディなリストを返すので、コストは「レディな数」だ。しかも select() は で完全に止まる —— コンパイル時定数であって、チューニング項目ではない。

UDP は同じ struct から状態機械を抜いたものだ。sendto 1 回が1 パケット、recvfrom 1 回も 1 パケット。受信バッファはやはり埋まり、課金される対象は送ったものとは別物だ:

100 × 100 B データグラム

バッファは skb->truesize —— 確保量まるごと、小さなデータグラムなら約 768 バイト —— で計上する。だから 212,992 バイトの rmem_default が保持できるのは 2,130 個ではなく277 個で、残りは何の合図もなく破棄される。

03

パケットはどう道を見つけるか

ソケットを出たパケットには 3 つ要る。インタフェース、次ホップのアドレス、宛先 MAC だ。ルーティングが最初の 2 つ、ARP が 3 つ目に答え、経路 MTU が結果の大きさを決める。

ルーティング表は上から順に舐めるリストではない。最長プレフィックスマッチだ。宛先を覆う経路がすべて候補になり、固定ビットが最も多いものが勝つ。

8 つの宛先を辿り、どのプレフィックスが覆うかを見る。アドレスが手の中にあり、勝つ経路はそれを覆う最も長いバーだ:

宛先 10.0.0.7

デフォルト経路 0.0.0.0/0 が 8 つすべてを覆うことに注目 —— 固定ビット 0 なので常に一致し、常に負ける。メトリックが効くのは同じ長さの同点だけで、酷いメトリックの/24 でも完璧な /0 に勝つ。「使われないようメトリックを高くする」が効かない理由だ。

経路は次ホップを与えるが、次ホップは宛先ではない。4 ホップを辿り、ヘッダの 2 つのアドレス —— IPとMAC —— を比べる:

ホップ 1 / 4

層構造が具体になる場所だ。L3 は端から端まで、L2 はホップごと。宛先 IP は一度書いたきり変わらず、宛先 MAC は各ルータが書き換える。次の機器を指すだけのものだからだ。ホップごとに 1 減る TTL があるから traceroute が成立する。

その MAC には問い合わせが要り、答えは近隣テーブルに寿命付きでキャッシュされる。トラフィックが流れ続けている 1 エントリで時間を進めてみる:

t = 0 s

30 秒 —— base_reachable_time_ms、実値は 0.5〜1.5 倍で乱択される —— でエントリは STALE になり、トラフィックが続くのですぐ DELAY に入る。肝心なのは、その間ずっとパケットは古いアドレスのまま流れることだ。再検証は前ではなく横で走る。1 秒間隔の探索が 3 回、その後にFAILED だ。

このやり取りに認証はない。どのホストもどのアドレスの代理でも応答でき、Linux は既存エントリを更新する非請求応答を信じる。同じ L2 の攻撃者がそれで何を得るかを辿る:

0 件の非請求応答

被害者のルーティング表は手つかず、ARP キャッシュには構文的に完璧なエントリがあり、全パケットが第三者経由で転送される。L2 での解決策はない。答えはスイッチの動的 ARP 検査、802.1X、あるいは信頼できないホストを同じセグメントに置かないこと —— クラウドの VPC がテナントごとの仮想 L2 で代行しているのがこれだ。

最後はフレームの大きさだ。答えは経路上で最小の MTU で、その経路には誰も教えてくれなかったリンクが含まれる。真ん中のトンネルを下げてみる:

トンネル MTU 1,500 B
トンネル MTU 1,500 B

は 24 バイト、 PPPoE は 8、IPsec は 50 前後を取る —— それらを知らない送信側は DF を立てた 1500 バイトを出し続ける。転送できないルータは捨て、通せる MTU を載せた ICMP type 3 code 4 を返し、送信側が MSS を絞る。これが経路 MTU 探索で、動くかどうかは ICMP 1 通の差だ。

では「セキュリティのため」に ICMP を遮断してみる。小さな応答は無傷だ。許可と遮断のそれぞれでサイズを上げていく:

応答 1,200 B

どの技術者もいつか debug する形がこれだ。は完璧に通り、は止まる。送信側に何も伝わらないので、同じ大きすぎるセグメントを接続が死ぬまで再送する —— ハンドシェイクは成功し、ヘッダも届き、本文だけがブラックホールに落ちる。ICMP type 3 を許可するか、net.ipv4.tcp_mtu_probing(PLPMTUD、RFC 4821)を有効にする。

古い答えは絞る代わりに分割することだったが、算術上の問題がある。データグラムはすべてのフラグメントが生き残ったときだけ生き残るので、生存率は(1 − p) のフラグメント数乗だ。両方を上げる:

1 フラグメント · パケット損失 1.0%

リンク損失 1% では、6 フラグメントのデータグラムは 5.9% の確率で失われる ——渡ったリンクの損失率の 5.9×だ —— しかも IP に部分再送はなく、1 片の損失が丸ごとの損失になる。 TCP が DF を立てて絞るのはこのためだ。

04

スタックのいちばん下

IP の下は、1 台のマシンが毎秒 10 万パケットで頭打ちになるか 1000 万まで伸びるかを決める領域だ。

NIC とカーネルは主記憶上の 1 対のリングバッファで出会う。カーネルは空の 2 KB空の 2 KB バッファを指す記述子を置き、カードは到着フレームを次へ DMA してテールを進める。誰もコピーせず、誰も待たない。

これはカーネルの回収がカードの詰め込みを上回る限り成り立つ。到着レートを吸い出しレートより上げてみる:

到着 400 Kpps

リングが何のためにあるかに注目。512 記述子のリングは 300 Kpps の過負荷を1.71 ms だけ吸収し、あとは超過分を捨てる —— ethtool -G eth0 rx 4096 が買うのは 13.7 ms だ。リングサイズが買うのはバースト耐性でありスループットではない。ethtool -S の増える rx_missed_errorsは、問題がカードでなく CPU にあることを意味する。

吸い出しこそ CPU が消える場所だ。2003 年まではフレームごとにハードウェア割り込みが上がっていた。満フレームの 10 Gbps リンクが であることは section 01 で出した。そのレートで 2 つの通知方式を切り替える:

到着 813 Kpps

割り込みは入口・出口・冷えたキャッシュ込みでおよそ 1.5 µs。つまり 813 Kpps は1.22 コアがまるまる割り込みで、1 バイトもソケットに届かないうちにマシンが飽和する。NAPI は最初のパケットでそのキューの割り込みを止め、 1 回の呼び出しで64 フレームの予算まで poll する。負荷時はコストがその分下がり、届き続ける間は再有効化すらしない。

1 コアで poll しても 1 コアは 1 コアだ。今日の NIC は複数の受信キューを持ち、 4-tuple をハッシュしてそのキューの CPU に割り込む。フロー数を上げ、次に形を切り替える:

8 フロー

ハッシュが 4-tuple 上なので、1 フローの全パケットが 1 キュー、1 コア、1 つの温かいキャッシュに落ちる —— 並べ替えもコア間ロックもない。同時に1 本の巨大フローが割れないことも意味する。8 Gbps の転送 1 本が 1 コアを %si 100% に張り付かせ、残り 7 コアは遊ぶ。

ワイヤとアプリの間の各段には固有のキュー、上限、カウンタがあり、戻りの経路にもう 1 段ある ——netstat -s 単体で全部は見えない。鎖を下りていく:

NIC 受信リング

コマンドではなくこの梯子を覚えるとよい。リングなら CPU が遅れ、CPU ごとの backlog なら 1 コアが遅れ(net.core.netdev_max_backlog)、ソケットバッファならアプリが遅れ、accept キューならアプリが accept を呼んでいない。段ごとに直し方は違い、別の段で落ちたパケットは指標に映らない。

捨てるつもりのトラフィックなら、いちばん安い捨て場所はそれが存在する前だ。XDP は sk_buff の確保前、ドライバ内で生の DMA バッファに eBPF を走らせる。の下で 2 つの破棄地点を比べる:

洪水 1 Mpps

iptables での破棄が数マイクロ秒かかるのは、すでにソケットバッファを与えられチェインを歩いた後だからだ。XDP_DROP が約 50 ns なのは、そのどれもまだ起きていないから。10 Mpps では 25 コア対 0.5コアになる。Cloudflare の L3 スクラバや Meta の Katran が XDP に住む理由だ。

その先の出口はカーネルごと出ていく。目標レートを決め、各データパスが必要とするコア数を読む —— カーネルスタックとバイパス経路の比較だ:

目標 1 Mpps

1 Mpps ではカーネルスタックが 1.0 コア、DPDK が 0.07 コア。14.88 Mpps —— section 01 の 64 バイトラインレート —— では DPDK が 1.0 コア、カーネルなら 14.9 コア要る。請求書は、TCP も再送も輻輳制御も ARP もあなたが書くことになる、というものだ。

05

クイックリファレンス

そらで答えられるとよい 2 つの問いと、レビューで拾いたい 5 つの赤旗。

1 回の HTTPS リクエストで、時間は実際どこへ行くのか?

ほとんど伝送ではない。コールドなリクエストは名前解決、ハンドシェイク、TLS 1.3、問い合わせと応答にそれぞれ往復を払う。往復時間をドラッグして、サーバ自身の取り分を読む:

RTT 50 ms
RTT 50 ms

では答えは 225 ms 後に届き、そのうちサーバは 25 ms だけだ。接続を再利用すると 4 往復のうち 3 つが消えて 75 ms になる。この RTT では keep-alive も TLS セッション再開も、どんなサーバ側最適化よりよく効く。

忙しいクライアントが、他の何より先にポートを使い切るのはなぜか?

閉じた接続が TIME_WAIT の間ポートを握るからだ —— Linux では 60 秒でハードコードだ。だから使用中のポート数は同時実行数ではなくレート × その時間になる。この範囲が持つ 28,232 に対してレートを上げる:

毎秒 400 リクエスト · TIME_WAIT 60 s

再利用しなければ、この範囲は で尽きる。keep-alive があれば必要なのは12 本の接続だけだ。

  • SO_RCVBUF を手で設定。 自動調整が死ぬ。
  • 1 つのバックエンドへ数万接続。 送信元 IP あたり 28,232 ポート。
  • 境界で ICMP を全遮断。 PMTUD が黙って死ぬ。
  • 「N バイト溜まるまで読む」UDP。 1 回 = 1 データグラム。
  • ホットパスでフィルタなしの tcpdump。 BPF 式を渡す。