TCP ディープダイブ 基礎

api.example.com:443 への接続とは、2 台のマシンが数字を合意することだ。主張は 3 つ、どれも手で動かせる —— 要るのは 2 ではなく 3 パケット。送信側は通知と推測、2 つのウィンドウに同時に従う。そして高くつく TCP の失敗はどれも無言だ。

01

3 つのパケット、そしてなぜ 2 ではないのか

curl が Enter を打った。リクエストの 1 バイトが出る前に、カーネルは一度も話したことのない相手と 2 つの数字を合意する必要がある。

あらゆる接続は SYN、SYN-ACK、ACK で始まる。 2 ではなく 3 なのは儀式ではない —— 両側がランダムな初期シーケンス番号を選び、相手が確認していない番号は誰にも信用できないからだ。

最初、両側とも相手の番号付けを知らない。各パケットはちょうど 1 つの事実を運ぶ。再生を押すか、スクラバーを掴んで、両側が何を知っているか読んでほしい:

まだ 1 つも送っていない

2 パケットの時点で図はすでに非対称だ。クライアントは両方の番号を知っている。サーバは y を送ったが、誰かが受け取ったかどうかを知らない。 3 番目のパケットは接続の確認ではない —— y を確認するものであり、サーバ自身の番号付けを信頼できるものにする唯一の手段だ。

それを要求する代償は、何も有用なものが渡らない 1 往復まるごとである。ハンドシェイクを 2 パケットに削り、古い重複 SYN をそこへ流してみてほしい:

3 パケットのハンドシェイク、3 中 0 送信

右側のサーバの状態を見てほしい。2 パケット版では、誰も開いていない接続で ESTABLISHED に達し、何かがタイムアウトするまでソケットとバッファを抱え続ける。3 パケット版ではクライアントが RST を返す —— x を送っていないクライアントは y を確認できない。 3 番目のパケットこそ、古い複製を尊重せず拒否させるものだ。

代金は 1 往復であり、その値段はリンクが決める。往復時間をドラッグし、暗号層がハンドシェイクの上に何を積むかを切り替えてほしい:

同一リージョン:セットアップに 12 ms

暗号層は自前の往復を要する。だからコストは加算ではなく乗算になる。 では、素の TCP が 140 ms、TLS 1.3 が 280 ms、TLS 1.2 が 420 ms —— すべてリクエストが書かれる前だ。これがコネクションプーリングの根拠であり、サーバ側の調整では触れられない。

3 番目のパケットが着くと、カーネルは接続をどこかに置かねばならない。そしてキューは 1 つではなく 2 つある。アプリが受理できる量を超えて到着レートを上げてほしい:

2 個のソケットが accept() 待ち

2 つのキューは違う壊れ方をし、うるさく壊れるのは片方だけだ。SYN キューが満杯になっても防御がある ——tcp_syncookies は状態の割り当てをやめ、サーバの ISN を 4-tuple のハッシュとして符号化するので、SYN フラッドはメモリを一切食わない。accept キューには何もない。カーネルは 3 番目の ACK を捨て、ログも残さず、クライアントは接続できたと信じ続ける。

こうして失敗はエラーではなく待ち時間としてクライアントに届く。その形は一度見れば十分だ。再送を 1 歩ずつ進めてほしい:

最初の SYN を送信

目盛りの間隔が倍になっていく。tcp_syn_retries = 6 はそれらを、3 s、7 s、15 s、31 s、 63 s に置き、connect() は 127 s で諦める。接続レイテンシが 1 秒と 3 秒に尖るなら、それは遅いサーバではない ——捨てられた SYN か、満杯の accept キューだ。

02

すべてのバイトに番号を振る

IP はパケットを失い、複製し、順序を入れ替え、そして何も言わない。 TCP が足すものはすべて 1 つの 32 ビットカウンタからできている。

すべてのバイトにシーケンス番号がある —— 数えるのはパケットではなくバイトだ。受信側が確認するのは位置であり、返す番号は受け取った最後ではなく次に期待するバイトである。

この 1 つの選択が、以降のすべてを決める。テープ上の確認点をドラッグして、確認済みの部分とまだ宙に浮いている部分が分かれる様子を見てほしい:

ack = 1. テープ上の確認点をドラッグする;矢印キーは 1 セグメントずつ動かし、Home で先頭に戻る
8 セグメント中 0 が確認済み —— ack = 1

ACK 番号が言えることは 1 つだけだ —— ある接頭辞が届いた、ということ。基本ヘッダには「1461–2920 と 5841–7300 はあるが真ん中はない」と言う手段がない。だから累積 ACK は最初の穴で止まり、そこに留まる —— つまり 1 セグメントのロスが、以降のすべての ACK を同一にしてしまう。

同一の ACK はそれ自体が情報であり、TCP はそれを使う。ロスと回復を進め、送信側で重複が積み上がるのを見てほしい:

8 中 1 ステップ

引き金は 3 番目の重複だ。送信側は時計を待たず、受信側自身の証拠で再送する。3 は恣意的ではない。1 つや 2 つの重複は普通の並べ替えが生むもので、しきい値を下げれば 2 つが入れ替わるたびに健全なデータを送り直すことになる。

ただし送信側が何を送り直せるかは、受信側が何を言ってよいかで決まる。テープ上の穴をドラッグし、選択的確認応答オプションを切ってみてほしい:

セグメント 6 が消失。テープ上の穴をドラッグする;矢印キーは 1 セグメントずつ動かし、Home で真ん中に戻る
セグメント 6 の修復に 1.4 KB を再送

SACK がなければ、送信側は穴の位置しか知らないので、穴とその後ろのすべてを送り直す。ロスがセグメント 6 のとき、回線に戻るのは 10 KB、うち 8.6 KB はすでに届いていた分だ。対する SACK は 1.4 KB —— 穴そのものだけ。 SACK(RFC 2018)は SYN で一度だけ交渉され、修復をちょうど 1 セグメントにする。

受信側が報告できない穴は別の問題であり、それこそがタイマの役目だ。平滑化された往復時間とその分散を設定してほしい:

RTO = 200 ms —— 床で止まっている

床を見てほしい。RFC 6298 はRTO = SRTT + max(G, 4·RTTVAR) を計算するが、Linux は 200 ms で下から押さえる。だから往復 0.2 ms の同一ラック経路では、タイムアウトは RTT の 1000 倍になる。早すぎる再送は、困っているネットワークに負荷を足すだけだからだ。

そしてタイムアウトが連発し始めても、その間隔は一定ではない。回数を上げて、経過した幅を見てほしい:

タイムアウト 1 回で経過 0.2 s

最初の数回が全体に占める割合の小ささに注目してほしい。15 回目の再送 ——tcp_retries2 の既定値 —— は 805 s に出ていき、カーネルはそこからさらに TCP_RTO_MAX 1 つぶん待って接続を殺す。net/ipv4/tcp_timer.c の tcp_model_timeout() が計算するのは((2<<9)−1)·200 ms + 6·120 s = であり、消えたピアとの接続がエラーを返すのは 13 分ではなく 15 分だ。ip-sysctl が 13 〜 30 分と書いているのは、実際の RTO が床にいることは稀だからだ。自前の期限を持たないサービスは、その全期間リクエストとスレッドを抱え続ける。

ここまでのすべてに見えないロスが 1 つある —— 最後のものだ。テールロスプローブを仕掛けて、同じやり取りをもう一度進めてほしい:

RTO を待つ、1 ステップ

末尾で失われたセグメントには、文句を言う後続がない。だから重複 ACK は生まれず、残るのはタイマだけ —— 20 ms の経路で 200 ms だ。プローブは 2·SRTT で発火し、160 ms を節約する。 RACK-TLP(RFC 8985)が Linux の既定である理由だ。

03

受信側が通知するウィンドウ

送信側は二重に縛られている。1 つ目は受信側が口を出せる唯一のもので、すべての ACK に乗っている。

すべての ACK は受信ウィンドウを運ぶ —— その瞬間に受信側のソケットバッファに残る空きだ。送信側は未確認バイトがその数を超えないようにする。だからフロー制御はロスレスだ。

このウィンドウは誰かが決めた定数ではなく、バッファ上の算術であり、アプリの読み取り位置がその反対側にあたる。位置を後ろへドラッグして、通知されるウィンドウが閉じるのを見てほしい:

未読 0 セグメント。バッファ上の読み取り位置をドラッグする;矢印キーは 1 セグメントずつ動かし、Home で追いつかせる
未読 0 セグメント —— rwnd = 11 KB

ここにネットワークの話は一つもない。受信ウィンドウが縮むのはアプリが遅いからで、読んだ瞬間に開き直す。だから受信側の停滞は送信側には帯域の上限と同じ顔で届く。回線上のキャプチャでは区別できない。

読み取り位置を最後まで後ろへ押すとウィンドウはゼロになる。プロトコルはこの状態から出られなければならない。パーシストタイマを 1 歩ずつ進めてほしい:

ウィンドウは閉じ、プローブはまだ

ウィンドウ更新はそれ自体が ACK で、ACK は再送されない。更新が失われれば接続は永久にデッドロックする。パーシストタイマが非常口だ —— 送信側は突き続け、間隔は 120 s まで倍になる。エラーもタイムアウトもログもない。詰まったコンシューマが沈黙のうちにパイプライン全体を止める。

ウィンドウにはもう 1 つ問題があり、こちらは振る舞いではなく算術だ。 SYN で交渉されるスケールシフトを上げて、生フィールドがどれだけ足りないかを見てほしい:

シフト 0:通知できる最大ウィンドウは 64 KB

16 ビットは通知ウィンドウを 64 KB に制限する。 1981 年には気前がよく、いまや 3 桁足りない。RFC 7323 は最大 214 倍して 1 GB に届かせる。このオプションはSYN にしか現れないので、剥ぐミドルボックスは接続の生涯を 64 KB に封じる —— 無言で。

64 KB が上限なのか余裕なのかは、完全にリンク次第だ。帯域と往復時間を設定し、パイプの容積とウィンドウが埋める分を並べて読んでほしい:

BDP = 146 KB

スループットはウィンドウ ÷ RTT なので、スケールなしの 64 KB は 70 ms で 7.5 Mbps にしか届かない —— リンクが何として売られていようと関係ない。その遅延の 経路は 8.3 MB を飛行中に保つ必要があり、公称速度の 1% も出せない。誰かが輻輳のせいにする前に、まずここを見る。

04

送信側が推測するウィンドウ

2 つ目の縛りは誰も通知しない。送信側が単独で、証拠から、見えないネットワークについて保つものだ。

輻輳ウィンドウは、経路がどれだけ吸収できるかについての送信側の私的な見積もりだ。ヘッダにも交渉にも現れず、飛行中のバイト数は 2 つのウィンドウの和ではなく小さいほうで抑えられる。

接続が期待どおり出ないとき、まず確かめるべきはどちらが効いているかだ。それが調べに行く先を決める。rwnd とcwnd を突き合わせて動かしてほしい:

飛行中は 86 KB に制限される —— 限界はネットワーク

2 つの診断がどれほど違うかに注目してほしい。rwnd が効いているなら問題は受信側のバッファかアプリ、cwnd が効いているならネットワークで、バッファ調整は効かない。ss -ti は両方を表示するので、これは推測ではなく 10 秒で済む問いだ。

新しい接続は証拠を一切持たないので、小さく始めて倍にしていく。往復を 1 歩ずつ進め、cwnd がパイプへ手を伸ばすのを見てほしい:

0 往復後、cwnd = 10 セグメント(14 KB)

cwnd は 1 往復ごとに倍になる。「スロー」スタートは実際にはいちばん速い局面だ。10 セグメント(RFC 6928、14 KB)が 7 ラウンドで 1,280 になる。破線は70 ms の 100 Mbps 経路が満たされる位置、854 KB —— 6 ラウンド、420 ms の助走だ。

永久に倍化すれば破滅するので、何かが失われた最初の瞬間に止まる。 2 回のロスを通してラウンドを歩かせてほしい:

ラウンド 0 —— cwnd 10 セグメント

最初のロスの後の形を見てほしい。輻輳ウィンドウは半分になり、1 往復ごとに 1 セグメント登る —— 加算的増加、乗算的減少 —— これが、1 つのボトルネックを共有する多数の送信側を振動ではなく公平な分割へ収束させる。回復時間もウィンドウに比例する。

この比例こそ Reno が置き換えられた理由のすべてだ。100 ms の経路での 2,000 セグメントのウィンドウの回復に沿ってカーソルをドラッグし、Reno と CUBIC を並べて見てほしい:

ロスから 0 秒。時間軸上のカーソルをドラッグする;矢印キーは 0.1 秒ずつ動かし、Home でロスの時点に戻る
ロスから 0 秒:CUBIC 1400、Reno 1000 セグメント

Reno は 1 往復に 1 セグメントしか足さない。 1 パケットで失ったウィンドウに戻るのに 1,000 往復 —— ここでは —— を要する。CUBIC の成長は往復数ではなく実時間の 3 次関数なので、 RTT がいくつでも 11 s で戻る。旧ピークを越えても 3 次曲線は加速し続け、それを止めるのはアルゴリズムではない —— 30 s で受信ウィンドウにぶつかり、そこで平らになる。自分のウィンドウがどれだけ速く育っても、送信側が従うのはmin(rwnd, cwnd) だからだ。 Linux は 2006 年以来これを既定にしている。

両者は現代の経路がしばしば破る仮定を共有している ——失われたパケットは輻輳だ、という仮定だ。リンクのランダムロス率を上げてほしい:

ロス 0.001% のとき:Reno 32 Mbps、CUBIC 113 Mbps、BBR 1000 Mbps

落ち方は急だが、ロス基準の 2 本は落ち方が違う。Reno の上限はおよそ MSS ÷ (RTT·√p) —— Mathis ら、1997 —— で、140 ms の経路の ではギガビットのリンクで 3.2 Mbps が頭打ちだ。CUBIC の応答関数(RFC 8312 §5.1)は p−1/2 ではなく p−3/4 で落ちるので、 では Reno の 32 Mbps に対して 113 Mbps —— ただし 0.15% より上では TCP フレンドリー領域が CUBIC を Reno の推定に戻し、2 本は 1 本になる。BBR はボトルネック速度と最小 RTT の積にペーシングするので、奪われるのは実際に失われたバイトだけだ。

ロス基準の送信側がそもそもロスを見なければならないのは、先に何かを満たしているからだ。ボトルネックバッファを深くドラッグし、スループットが動かないことを見てほしい:

バッファ 146 KB. バッファの深さをドラッグする;矢印キーは 1 段ずつ動かし、Home で 1 BDP に戻る
バッファ 146 KB —— 往復が 24 ms まで膨らむ

ロス基準の送信側は、後退する前に目の前のバッファを満たす。だから 100 Mbps のリンク上のは往復を 12 ms から 396 ms へ押し上げ、1 ビットも余分に届けない。対処はボトルネックでの能動的キュー管理(fq_codel が Linux の既定)か、遅延を見る送信側である。

05

クローズと、噛みついてくる部品

接続を開くのは対称で速い。閉じるのはどちらでもなく、本番の TCP 問題の大半はここに住んでいる。

接続は独立した 2 本のバイトストリームなので、閉じるのも独立した 2 つの出来事になる。各側は書き終えたときにFINを送り、相手のものを確認する —— 4 パケットで、2 つの半分は数分離れることもある。

面白いのはパケットではなく、それが残す状態のほうだ。クローズを 1 歩ずつ進め、 2 つの状態欄が分かれていくのを見てほしい:

4 パケット中 0 送信

最後に何かを抱えているのがどちら側かに注目してほしい。先にclose() を呼んだ側が TIME_WAIT に入り、 4 タプルを 2·MSL のあいだ保持する。Linux ではそれはinclude/net/tcp.h の TCP_TIMEWAIT_LEN —— コンパイル時定数の 60 s であって、FIN_WAIT_2 を司るtcp_fin_timeout ではない。あの sysctl を下げて TIME_WAIT を縮めようとするのは人気があり、そして何も起こらない。

60 秒は無料だ —— 1 つのクライアントがポート範囲の回収より速く接続を開き始めるまでは。同一ピアへの接続レートを上げてほしい:

28,232 ポート中 6.0K を保持

宛先が固定なら 4 タプルはローカルポートしか変わらない。ip_local_port_range が与えるのは 28,232 個で、枯渇レートは毎秒 471 接続だ。 ではconnect() が EADDRNOTAVAIL を返し始める。治療は接続の再利用だ。tcp_tw_recycle は NAT の背後のクライアントをすべて壊したため Linux 4.12 で削除された。

もう 1 つの古典はクローズとは無関係だ —— 単独では各々正しい 2 つの最適化である。小さな write() 2 回で書かれたリクエストを進めてほしい:

2 回書き、1 ステップ —— 追加 40 ms

最初のモードの空白を見てほしい。Nagle は 1 つ目が確認されるまで 2 つ目の小さな書き込みを保留する。遅延 ACK はその確認を最大 40 ms 保留し、サーバの応答に相乗りさせようとする —— だがサーバはリクエスト全体を見ていないので応答できない。TCP_NODELAY は停滞を消す代わりに 40 バイトのヘッダを 2 つ回線に載せる。writev() 1 回なら停滞も消え、回線に載るのは 1 セグメントだ。

ここまでのどれも、ただ消えたピアには気づかない —— FIN も RST もなく、沈黙だけだ。アイドルタイマを短くして検出時間を読んでほしい:

死んだピアの検出には 2.2 h かかる

tcp_keepalive_time の出荷値は 7,200 s なので、75 s 間隔の 9 回のプローブでは死んだピアに気づくのは 2.2 時間後だ。アイドルタイマだけを に下げても 12 分残る。プローブが支配的だからだ —— アイドル 60 s、間隔 10 s、プローブ 3 回で 90 s、3 つとも動かす必要がある。gRPC は自前の keepalive を積んでいる。

06

クイックリファレンス

そらで答えるべき 4 つの問い、答えを抱えた図 3 枚、そして 5 つの赤信号。

TCP を信頼できるものにしているのは何か?

不変条件が 1 つ、そしてそれは絵に描ける。ACK 番号より下では、すべてのバイトが順序どおりちょうど 1 回配送されている。その番号以上では、送信側がまだ複製を持っている。境界をドラッグして、2 つの行が入れ替わるのを見てほしい:

ack = 1. 確認の境界をドラッグする;矢印キーは 1 セグメントずつ動かし、Home で先頭に戻る
送信側は 0 B を捨ててよく、11 KB をまだ持っている

送信側があるバイトを忘れてよいのは ACK 番号がそれを追い越したときだけだ。だからそのバッファが信頼性の代金であり、受信ウィンドウは同じ取引の受信側の半分にあたる。再送も SACK もすべてのタイマも、規則を破らずにその 1 つの番号を進めるための機構である。

待つことの値段は実際いくらか?

このページで最も安い待ちと最も高い待ちのあいだには 9 桁の開きがある。値付けされている段を目盛りに沿って歩かせてほしい:

同一ラックの往復:0.2 ms

覚える価値があるのは 6 段目から 8 段目への跳びだ。までは経路の性質である。60 s の TIME_WAIT と 2.2 時間の keepalive は設定ファイルの性質であり、実際に変えられるのはこの 2 つだ。

rwnd か cwnd か —— どちらが足を引っ張っているか?

ss -ti で両方を読む。rwnd が小さいなら受信側のアプリか tcp_rmem が遅い。cwnd が小さいならロスか、スロースタートか、経路が長いかだ。どちらも BDP = 帯域 × RTT と比べれば、そもそもウィンドウが上限なのかが分かる —— そして接続が新規なら、答えはどちらでもないことが多い:

connect() から最後のバイトまで 84 ms

同一リージョンのリンクでは、新規接続は 84 ms のうち 24 ms をリクエストが送られる前に費やす。では同じ 200 KB のレスポンスに 980 ms かかり、そのうち 280 ms がセットアップ、560 ms がレスポンスを立ち上げるスロースタートだ。

静かに壊れるのはどれか?

3 つあり、どれもログ行を書かない。5 MB のダウンロードでそれぞれを起こし、同じ予算に何が上乗せされるかを見てほしい:

144 ms、そして何も問題はない

どれもエラーではなく待ち時間だ。accept キューの満杯は最初の SYN 再送ぶん 1.0 s、ゼロウィンドウはパーシスト間隔ぶん 1.6 s、は太平洋横断の経路で 9.9 s —— 転送が輻輳ではなくウィンドウで制限されるからだ。ss -ti と nstat -az TcpExtListenOverflows で読める。

下の 5 つの赤旗のうち最後の 1 つは、ここで手で感じられる。パイプラインを開けて ——レスポンスが戻る前にリクエストを 2 本以上ワイヤに載せて —— リンクを何も変えずに速度が動くのを見てほしい:

帯域に関係なく毎秒 14 リクエスト
  • まず TCP_NODELAY に手を伸ばす。40 ms の停滞は消えるが、1 リクエストあたり 40 バイトのヘッダが 10 個残る。ユーザ空間でまとめれば両方消える。
  • sysctl ファイルの tcp_tw_recycle = 1。Linux 4.12 で削除された。NAT の背後から来る SYN を黙って捨てていた。意図していたつまみは tcp_tw_reuse だ。

次の赤旗が失うのは時間ではなくデータだ。送信バッファを積んでから、 2 通りの閉じ方をそれぞれ試し、ピアが決して読めない分を見てほしい:

5.7 KB を配送し、その後 EOF
  • TIME_WAIT を縮めるために tcp_fin_timeout を下げる。TIME_WAIT はコンパイル時定数の 60 s であり、tcp_fin_timeout が司るのは別の状態、FIN_WAIT_2 だ。
  • 本番トラフィックでのタイムアウト 0 の SO_LINGER。FIN ではなく RST を強制するので、飛行中のデータは捨てられ、ピアには接続リセットが見える。
  • 一度に 1 メッセージだけを往復させるプロトコル。帯域がいくつでもスループットは payload ÷ RTT に縛られる。なら 1 接続あたり毎秒 14 リクエスト、4 本在途で 57 だ。