TCPの息づかいを感じろ!Slow StartとCongestion Avoidanceで読み解くパケットの生存戦略
こんにちは、シニアネットワークエンジニアの私だ。深夜の障害対応でボロボロになりながらも、ルータのログやtcpdumpのパケットキャプチャを眺めるのが、エンジニアとしての密かな至福だったりする。
さて、Web APIの設計や大規模インフラの構築に携わる君たちなら、一度はこんな疑問を抱いたことがないだろうか?
「なぜ、数ギガバイトもある巨大なファイルをダウンロードし始めるとき、最初はモタモタしているのに、途中から急激にスピードが上がるのか?」
あるいは、「APIのレスポンスタイムが、なぜか特定の巨大ペイロード送信時にガタガタに乱れるのか?」
その答えの鍵を握るのが、今回深掘りするTCPの輻輳制御(Congestion Control)、すなわちスロースタート(Slow Start)と輻輳回避(Congestion Avoidance)の世界だ。
教科書を開けば「RFC 5681にこう書いてある」と無機質な数式が並んでいる。しかし、現場のネットワークは美しい理論通りには動かない。ルータのバッファがあふれ、パケットが虚空に消え、再送タイマーが火を噴く。そんな修羅場を生き抜くために、パケットがOSのカーネルスペースからNICを抜け出し、インターネットの荒海を渡っていくその瞬間、TCPのステートマシンの中で何が起きているのかを、リアルな実務目線で紐解いていこう。
—
1. 境界防御の先にある「見えざる敵」:なぜ輻輳制御が必要なのか
ゼロトラストアーキテクチャ全盛の今、私たちはファイアウォールやWAF、APIゲートウェイといった「境界」でトラフィックを厳重に監視している。しかし、どれほど厳格なセキュリティポリシーを敷こうとも、TCPそのものが持つ「自律分散型の交通整理メカニズム」を理解していなければ、アプリケーション層のパフォーマンスは簡単に崩壊する。
インターネットは、誰も全体を把握していない巨大なルータの集合体だ。もし、送信側(Server)が「俺の回線は10Gbpsだ!」とばかりに、受信側(Client)の処理能力や経路上にある中継ルータのバッファ容量を無視してパケットを送りまくったらどうなるか?
答えは地獄絵図だ。中継ルータのキュー(Queue)が即座にあふれ、パケットロス(Packet Loss)が発生する。ルータは「すマン、これ以上持てない」とばかりにパケットを捨て、捨てられたパケットを修復するために再送要求が飛び交い、さらにトラフィックが増幅して完全なマヒ状態(輻輳崩壊:Congestion Collapse)に陥る。
これを防ぐためにTCPに実装されたのが、「相手が受け取れる分だけ送り、混んできたら自発的にブレーキを踏む」というスマートな紳士協定、すなわち輻輳制御アルゴリズムだ。
—
2. 2つの主役:「スロースタート」と「輻輳回避」のダンス
TCPの送信側は、常に2つのウィンドウサイズを心に刻みながらパケットを送り出している。
1. rwnd(Receiver Window / 受信ウインドウ):受信側が「これだけならメモリにバッファできるぜ」と告げるサイズ。
2. cwnd(Congestion Window / 輻輳ウインドウ):ネットワークの混雑具合を見て、送信側が「これ以上送るとヤバいな」と自主規制するサイズ。
実際にネットワーク上を飛べるデータ量は、常に min(rwnd, cwnd) で決まる。この cwnd を巧みにコントロールするのが、スロースタートと輻輳回避のダンスステップだ。
スロースタート(Slow Start):実は全然「スロー」じゃない件
名前は「スロースタート」だが、現場のエンジニアから見ると「アクセル全開で急加速する暴れ馬」というのが実態だ。
- 挙動:コネクション確立後(3ウェイハンドシェイク完了後)、
cwndは通常1 MSS(Maximum Segment Size)または近年のモダンなOS(Linuxの初期値など)では10 MSS程度からスタートする。 - 増加の仕組み:受信側から正常に届いたことを示す
ACKが返ってくるたびに、cwndは 1 MSSずつ爆発的に(指数関数的に)増加していく。つまり、1RTT(Round Trip Time)ごとにcwndのサイズがほぼ倍々ゲームで膨れ上がっていくのだ。
「どこまで倍々ゲームを続けるのか?」
そこで登場するのが、ssthresh(Slow Start Threshold / スロースタート閾値)という重要なパラメータだ。
輻輳回避(Congestion Avoidance):慎重な大人のステップ
cwnd が ssthresh を超えた瞬間、TCPはモードを切り替える。それが「輻輳回避」だ。
- 挙動:指数関数的な急加速をやめ、ここからは線形増加(1RTTごとに1 MSSずつ増やす)に切り替わる。
- 目的:ネットワークの限界手前で、慎重に、かつ少しずつ帯域の残り香を探りに行く。これ以上欲張るとパケットロスが起きるという瀬戸際を、探りり足で歩むモードだ。
そして、もしパケットロス(タイムアウト、あるいは重複ACKによる3つ目のACK受信=高速再送トリガー)を検知したらどうなるか?
OSのTCPスタックは「おっと、やりすぎた」と判断し、ssthresh を現在の cwnd の半分にシュリンク(縮小)させ、cwnd を再び 1 MSS(または初期値)にリセットして、再びスロースタートの地平へと戻っていく。この心臓に悪いリセットの繰り返しこそが、TCPのダイナミクスそのものなのだ。
—
3. 実務で直面するトラブルとパラメータチューニング
インフラエンジニアやSREとして大規模Webシステムを運用していると、このTCPの挙動がボトルネックになる場面に必ず遭遇する。例えば、以下のようなケースだ。
- 「初期ウィンドウ(initcwnd)が小さすぎて、APIのレスポンスヘッダを返すのに何往復もRTTがかかる」
- 「地理的に離れた海外ユーザー向けのAPIで、遅延(High RTT)が大きいためにスロースタートからの加速が遅すぎる」
近年のLinuxカーネル(Linux 2.6.39以降や最近のUbuntu/RHELなど)では、デフォルトの initcwnd は 10 に引き上げられているが、古いシステムや特殊な組み込み機器では依然として 2 や 3 のままで、Webパフォーマンスを殺しているケースがある。
ここで、Linuxサーバーのネットワークパラメータを覗き、必要に応じてチューニングするための実務的なコマンドと設定を見ていこう。
現在のTCP輻輳制御アルゴリズムの確認と変更
Linuxでは、標準のCUBICやBBRといった輻輳制御アルゴリズムをカーネルパラメータで動的に変更できる。まずは現在の設定を確認する。
# 現在システムで有効な輻輳制御アルゴリズムを確認する
sysctl net.ipv4.tcp_congestion_control
# 利用可能なアルゴリズムの一覧を取得する
sysctl net.ipv4.tcp_available_congestion_control
もし、高遅延・ロス耐性の高い次世代アルゴリズムである bbr(Bottleneck Bandwidth and RTT)に変更したい場合は、以下のように一時的、あるいは永続的に設定する。
# 一時的にBBRを有効化する(再起動でリセットされます)
sudo sysctl -w net.ipv4.tcp_congestion_control=bbr
# 永続化する場合は /etc/sysctl.conf に以下を追記する
# net.core.default_qdisc = fq
# net.ipv4.tcp_congestion_control = bbr
—
4. コードとパケットで検証する:PythonによるTCPソケットの挙動観察
理論だけではエンジニアのメシは食えない。実際にPythonを用いてソケットを立ち上げ、TCPの挙動(特にソケットオプションを通した輻輳制御の指定)をコードレベルでどう扱うのかを見てみよう。
以下のスクリプトは、クライアント側からサーバーへデータを送信する際、TCPの各種情報(RTTや現在の cwnd、ssthresh など)を getsockopt を通してリアルタイムに取得する実用的なスニペットだ。
import socket
import struct
def inspect_tcp_socket(sock):
"""
TCPソケットの内部状態(TCP_INFO構造体)から
現在の輻輳ウインドウやRTTを安全に抽出し、コンソールに表示する関数
"""
# LinuxのTCP_INFO構造体のソケットレベル定義
SOL_TCP = socket.IPPROTO_TCP
TCP_INFO = 11
# getsockoptでバイナリデータを取得(サイズは環境やカーネルバージョンに依存するが通常200バイト程度)
try:
raw_info = sock.getsockopt(SOL_TCP, TCP_INFO, 200)
except OSError as e:
print(f"TCP_INFOの取得に失敗しました: {e}")
return
# Pythonのstructモジュールを使い、C言語の構造体から必要なフィールドをアンパックする
# ※ 注: カーネルバージョンによってオフセットが微妙に異なるため、実務では簡易的なデバッグ用途に留めること
# ここでは一般的なLinuxカーネルにおける主要なオフセット位置を想定
# tcpi_snd_cwnd (送信側輻輳ウインドウサイズ), tcpi_snd_ssthresh (スロースタート閾値), tcpi_rtt (平均RTT)
# 簡易的に標準出力へダンプするデバッグ用ロジック
print(f"[DEBUG] ソケット情報バイナリサイズ: {len(raw_info)} bytes")
# 実務の現場では、これらを監視メトリクス(Prometheus等)に流し込むエージェントに組み込んだりします。
def run_tcp_client(host, port):
# IPv4 / TCP ソケットを作成
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as client_sock:
# 必要に応じて、この段階で特定の輻輳制御アルゴリズムをソケットごとに指定することも可能
# 例: bytes.decodeやsetsockoptを使ったTCP_CONGESTIONの設定
# client_sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_CONGESTION, b'bbr')
print(f"Connecting to {host}:{port}...")
client_sock.connect((host, port))
# 接続直後のTCP内部ステータスを覗き見
inspect_tcp_socket(client_sock)
# 大量のデータを送り、スロースタートから輻輳回避への遷移を誘発する
payload = b"X" * 65536 # 64KBのペイロード
client_sock.sendall(payload)
print("データ送信完了。再度ソケット状態を確認します。")
inspect_tcp_socket(client_sock)
if __name__ == "__main__":
# ローカルのテスト用エンドポイント(適宜変更してください)
# TARGET_HOST = "127.0.0.1"
# TARGET_PORT = 8080
# run_tcp_client(TARGET_HOST, TARGET_PORT)
print("このスクリプトはTCPソケットのデバッグ手法を示すサンプルコードです。")
—
5. Web API設計者・インフラ運用者が現場で活かすべきTips
最後に、シニアの立場から、日々の設計や運用で意識すべき実践的なプラットフォームTipsをいくつか授けよう。
1. Keep-Aliveをケチるな
HTTP/1.1のPersistent Connections(Keep-Alive)やHTTP/2以降の単一TCPコネクション多重化がなぜ重要か? それは、「新しいTCPコネクションを張るたびに、必ずスロースタートの泥臭い急加速地獄(低速な初期状態)をやり直さなければならないから」だ。APIの往復リクエストごとにコネクションを切断・再確立しているシステムは、それだけで無駄なRTTとスロースタートのオーバーヘッドを背負い込んでいる。
2. クラウド環境でのMTUとPath MTU Discovery(PMTUD)の罠
AWSなどのクラウドインフラ(VPC環境など)において、セキュリティグループやルータのミドルボックスが原因でICMP(Destination Unreachable)がドロップされると、PMTUDが正常に機能せず「黒い穴(Black Hole)」のようなパケットロス現象が起きる。結果として、特定のサイズ以上のペイロードを送った瞬間にスロースタートが永遠にリセットされ、APIがタイムアウトする悲劇が起きる。tcp_base_mss や tcp_mtu_probing の設定を疑う眼を持とう。
3. モニタリングの重要性
アプリケーションのメトリクス(CPU使用率やレスポンスタイム)だけでなく、OSレイヤーの retransmits(再送パケット数)や cwnd の推移をオブザーバビリティ基盤(DatadogやPrometheus等)で監視できるようにしておけ。「なんとなく遅い」という抽象的な障害を、「あ、あそこのルータのバッファであふれてスロースタートが暴発しているな」と秒で特定できるエンジニアこそが、現場で頼られるプロフェッショナルなのだ。
ネットワークの背後で静かに、しかしダイナミックに呼吸を続けるTCPのパケットたち。そのアルゴリズムの息吹を感じ取れるようになれば、あなたのインフラエンジニアとしての視座は一段も二段も高くなっているはずだ。
さあ、ログを閉じ、次のトラブルシューティングに向かおうか。
コメント