【実務・中級編】 TCPスリーウェイ・ハンドシェイクのパケットフローとステート遷移 – ネットワーク基礎とWebセキュリティ実践ガイド

「APIのレスポンスが妙に遅い」「特定のクライアントからだけ、時折コネクションがリセットされる」――。

こうしたトラブルに直面したとき、君ならどこを疑うだろうか? アプリケーションのコードか、それともロードバランサーの設定だろうか。

私たちシニアネットワークエンジニアが真っ先に目を向けるのは、ネットワークの深淵、すなわちL4(トランスポート層)におけるTCPハンドシェイクの挙動だ。

現代のWebアーキテクチャは、きれいにカプセル化されたAPIと抽象化されたクラウドサービスの上で成り立っている。しかし、どれほど洗練されたWeb APIやゼロトラストネットワークを構築しようとも、その土台を支えているのはOSI参照モデルの第4層、TCP/IP階層モデルにおける「トランスポート層」のプロトコルに他ならない。

今回は、TCPコネクション確立の絶対的な基本でありながら、数々の深刻な障害の引き金にもなる「TCPスリーウェイ・ハンドシェイク」について、パケットの動き、カーネル内部のステート(状態)遷移、そして実務で役立つデバッグ手法までを徹底的に解剖しよう。

教科書をなぞるだけではない、現場で血肉となるリアルな知識をここで掴み取ってほしい。

—

1. TCP/IPにおけるトランスポート層の役割と「信頼性」の正体

まずは全体像から整理しよう。OSI参照モデルとTCP/IPモデルの対応関係において、TCP(Transmission Control Protocol)が位置するのはトランスポート層だ。

OSI参照モデル                  TCP/IPモデル
+------------------+         +------------------+
|  7. アプリケーション |         |                  |
|  6. プレゼンテーション| ------> |  アプリケーション層| (HTTP/2, HTTP/3, gRPC, DNS)
|  5. セッション      |         |                  |
+------------------+         +------------------+
|  4. トランスポート  | ------> |  トランスポート層  | (TCP, UDP)  <--- ★今回の主役
+------------------+         +------------------+
|  3. ネットワーク    | ------> |  インターネット層  | (IP, ICMP)
+------------------+         +------------------+
|  2. データリンク    | ------> |  ネットワーク      |
|  1. 物理          | ------> |  インターフェース層| (Ethernet, Wi-Fi)
+------------------+         +------------------+

下位のインターネット層(IP)は、パケットを目的地まで届ける「ベストエフォート(努力目標)」の配送しか提供しない。パケットが途中で消えようが、順番が入れ替わろうが、IP層は知らん顔だ。

そのカオスなネットワーク上で、「データが、確実に、順番通りに、壊れずに届く」という魔法のような信頼性をアプリケーション層(HTTPやgRPCなど)に提供するのが、トランスポート層のTCPである。

そして、その信頼性あふれる対話の第一歩となる儀式こそが、TCPスリーウェイ・ハンドシェイクなのだ。

—

2. スリーウェイ・ハンドシェイクのパケットフローとステート(状態)遷移

「SYN」「SYN-ACK」「ACK」の3ステップ。言葉で言うのは簡単だが、このときクライアントとサーバーの内部(Linuxカーネルなど)では、極めて厳密なステートの遷移が起きている。

まずは、そのシーケンスとステート(状態)の変化をビジュアルで脳裏に焼き付けてほしい。

クライアント (Client)                                  サーバー (Server)
  [ステート: CLOSED]                                     [ステート: LISTEN]
          |                                                      |
          |  1. SYN (Seq=X)                                      |
          |----------------------------------------------------->| (SYN Queueに格納)
     [SYN_SENT]                                             [SYN_RCVD]
          |                                                      |
          |  2. SYN-ACK (Seq=Y, Ack=X+1)                         |
          |<-----------------------------------------------------|
          |                                                      | (Accept Queueに移動)
     [ESTABLISHED]                                               |
          |  3. ACK (Seq=X+1, Ack=Y+1)                           |
          |----------------------------------------------------->|
          |                                                 [ESTABLISHED]
          v                                                      v
     (データの送受信開始)                                    (データの送受信開始)

ステップ1:SYN(Synchronize)――「接続を始めたい」

  • 送信元(クライアント)のアクション:

ランダムな初期シーケンス番号(ISN: Initial Sequence Number、ここでは X とする)を決定し、TCPヘッダの SYN フラグを立ててパケットを送信する。

  • クライアントのステート: CLOSED から SYN_SENT へ遷移。
  • 受信元(サーバー)のアクション:

LISTEN 状態で待ち受けていたポートに SYN が届くと、カーネルは接続要求を一時的なキュー(SYN Queue)に格納する。

  • サーバーのステート: LISTEN から SYN_RCVD へ遷移。

ステップ2:SYN-ACK(Synchronize-Acknowledgment)――「了解した。こちらも始めよう」

  • サーバーのアクション:

クライアントからの SYN を受け取った証として、確認応答番号(Ack)に X + 1 を設定。同時に、サーバー側からも自身の初期シーケンス番号 Y を決定し、SYN フラグと ACK フラグの両方を立てて返信する。

  • サーバーのステート: SYN_RCVD のまま、クライアントからの最後の応答を待つ。

ステップ3:ACK(Acknowledgment)――「承知した。接続完了だ」

  • クライアントのアクション:

サーバーから SYN-ACK を受け取ると、確認応答番号(Ack)に Y + 1 を設定し、ACK フラグを立てたパケットを送信する。

  • クライアントのステート: この瞬間に ESTABLISHED へ遷移。クライアントから見れば、この時点で通信路は確立されたとみなされる。
  • サーバーのアクション:

クライアントからの ACK を受信すると、カーネルは接続をSYN QueueからAccept Queue(別名: Listen Backlog)へと移動させ、アプリケーション層の accept() システムコールが呼び出されるのを待つ。

  • サーバーのステート: ESTABLISHED へ遷移。

—

3. 実務で知っておくべき、極めて重要なTCPパラメーター

ハンドシェイクの際、単に「接続を確立する」だけでなく、その後の通信効率を左右する極めて重要なパラメーターがネゴシエーション(交渉)されている。これらを理解していないと、クラウド移行や高並列APIの設計で必ず痛い目を見る。

① MSS(Maximum Segment Size)

TCPが一度に送信できるセグメント(データ本体)の最大バイト数。
ハンドシェイク時に互いのMSS(例: 1460 バイト)を提示し合い、小さい方の値を採用する。

  • 現場の罠: VPNやPPPoE回線、トンネリング技術(VXLANなど)を経由すると、カプセル化のオーバーヘッドにより実際の経路のMTU(Maximum Transmission Unit)が小さくなる。MSSの調整(MSS Clamping)を怠ると、ハンドシェイクは成功するのに「特定の巨大なAPIレスポンスだけが途中でドロップしてタイムアウトする」という地獄のデバッグ作業が始まる。

② ウィンドウサイズ(Window Size)とスケーリング(Window Scale)

TCPは、相手からの受信確認(ACK)を待たずに、一定量のデータをまとめて送信できる(スライディングウィンドウ方式)。
この「一度に送信できるデータ量」を示すのが Window Size(初期値は最大65,535バイト)だ。
現代の高速かつ遅延のあるネットワーク(BDP: Bandwidth-Delay Productが大きい環境)では、65KBでは帯域を使い切れない。そのため、ハンドシェイク時に Window Scale オプション(最大14乗、つまり最大1GBまで拡張)を有効化し、お互いの受信バッファ能力を拡張する。

③ SYN Queue と Accept Queue(バックログ)

サーバーのカーネル内部には、ハンドシェイク中のソケットを管理する2つのキューが存在する。
1. SYN Queue: SYN_RCVD 状態の接続を保持する。
2. Accept Queue: ESTABLISHED 状態になり、アプリケーション(NginxやNode.jsなど)が accept() するのを待っている接続を保持する。

  • 現場の罠:

大量のアクセス(あるいはSYNフラッド攻撃)が発生すると、SYN QueueやAccept Queueが溢れる。溢れた場合、サーバーは新しい SYN を黙ってドロップ(廃棄)する。
クライアント側からは「接続タイムアウト(Connection Timeout)」に見えるため、アプリのログには何も残らない。これぞインフラエンジニアが夜中に呼び出される典型例だ。

—

4. 現場でのトラブルシューティング:Linuxカーネルパラメータの最適化

サーバーが大量のAPIリクエストを捌く高負荷環境下では、デフォルトのTCP設定ではパンクする。
以下は、スリーウェイ・ハンドシェイクに関連する接続詰まりを解消するための、実戦的なLinuxカーネルパラメータ(/etc/sysctl.conf)の設定例だ。

# ====================================================================
# 高負荷Webサーバー/APIゲートウェイ向け TCPハンドシェイク最適化設定
# ====================================================================

# 1. Accept Queue(バックログ)の最大値を拡張
# アプリケーションが引き出していない、確立済みコネクションの最大キュー数
net.core.somaxconn = 32768

# 2. SYN Queueの最大値を拡張
# SYNを受け取り、SYN-ACKを返した「半オープン」状態のコネクションを保持する最大数
net.ipv4.tcp_max_syn_backlog = 16384

# 3. SYN Cookiesの有効化
# SYN Queueが溢れた際、接続情報をシーケンス番号に暗号化して埋め込み、
# キューを消費せずにハンドシェイクを継続する(SYN Flood攻撃対策の生命線)
net.ipv4.tcp_syncookies = 1

# 4. SYN/SYN-ACKの再送回数の制限
# 相手が応答しない場合、無駄に再送を繰り返してリソースを枯渇させないよう制限する
net.ipv4.tcp_syn_retries = 3
net.ipv4.tcp_synack_retries = 3

# 5. TCP Window Scalingを有効化
# 高速大容量回線で帯域を最大限活用するための必須設定
net.ipv4.tcp_window_scaling = 1

設定を反映するには、以下のコマンドを実行する。

# 設定ファイルの記述に誤りがないか確認しつつ反映
sudo sysctl -p

—

5. コマンドで観測するハンドシェイクの実態

能書きはここまでだ。実際に動いているパケットを見てみよう。
ローカル環境やテストサーバーで、実際に3ウェイ・ハンドシェイクが発生している様子を観察する。

tcpdump による生パケットのキャプチャ

特定のホスト(例: api.example.com)への接続時のハンドシェイクをキャプチャするには、以下のコマンドを実行する。

# インターフェース eth0 を対象に、TCPのポート80または443のハンドシェイク(SYN, ACK)をキャプチャ
sudo tcpdump -i eth0 -nn 'tcp[tcpflags] & (tcp-syn|tcp-ack) != 0 and (port 80 or port 443)'

この状態で、別ターミナルから curl を叩く。

# テストリクエストの送信
curl -I https://www.google.com

すると、tcpdump の出力に美しい3ステップのステップが刻まれるはずだ。

# 1. クライアント(192.168.11.5) -> サーバー(142.250.196.100) [SYN]
14:23:01.102345 IP 192.168.11.5.54321 > 142.250.196.100.443: Flags [S], seq 312048590, win 64240, options [mss 1460,nop,wscale 8]

# 2. サーバー -> クライアント [SYN-ACK]
14:23:01.122456 IP 142.250.196.100.443 > 192.168.11.5.54321: Flags [S.], seq 89520485, ack 312048591, win 65535, options [mss 1430,nop,wscale 8]

# 3. クライアント -> サーバー [ACK]
14:23:01.122589 IP 192.168.11.5.54321 > 142.250.196.100.443: Flags [.], ack 89520486, win 512
  • Flags [S] は SYN フラグが立っていることを示す。
  • Flags [S.] は SYN と ACK(.がACKを意味する)が立っていることを示す。
  • Flags [.] は ACK のみ。
  • シーケンス番号(seq)と確認応答番号(ack)が、正確に +1 されながらキャッチボールされているのが見て取れる。

ss コマンドによるステートの確認

現在、システム上のソケットがどのステートにあるかをリアルタイムで監視するには、ss コマンドが極めて強力だ(古くて遅い netstat はもう卒業しよう)。

# TCPソケットのステートごとのサマリーを表示
ss -s

# ESTABLISHED 状態のTCP接続のみをカウント
ss -tstate established

# SYN_RECV 状態のソケットを監視(SYN Flood攻撃やキュー溢れの予兆を検知する)
ss -tstate syn-recv -an

—

6. アプリケーション開発者が実践すべき「接続の再利用」

ここまで低レイヤーの話をしてきたが、これがどうWeb API開発やアプリケーション設計に繋がるのだろうか?

結論から言おう。「TCPハンドシェイクを毎回やらせるな」ということだ。

ハンドシェイクには最低でも「1往復半(1.5 RTT: Round Trip Time)」の時間がかかる。TLS(HTTPS)通信であれば、さらにその後にTLSハンドシェイク(1〜2 RTT)が加わる。
地理的に離れたサーバー間の通信(RTT = 100msとする)において、リクエストごとに毎回コネクションをクローズ(Connection: close)していると、データ送信が始まるまでに 300ms以上 の遅延が確定で発生する。

これを防ぐための、主要言語におけるベストプラクティスを示そう。

Python(Requestsライブラリ)でのコネクションプール(Keep-Alive)

悪い例として、ループ内で毎回単発の requests.get() を呼ぶと、毎回スリーウェイ・ハンドシェイクが発生する。
これを避けるために requests.Session を使い、コネクションを再利用(Keep-Alive)する。

import requests

# 接続先APIのベースURL
API_URL = "https://api.example.com/data"

# Sessionオブジェクトを使用することで、内部でTCPコネクションがプールされ、再利用される
with requests.Session() as session:
    # 1回目のリクエスト:ここでスリーウェイ・ハンドシェイク + TLSハンドシェイクが発生
    response_1 = session.get(API_URL)
    print(f"Request 1 Status: {response_1.status_code}")

    # 2回目のリクエスト:確立済みのTCPコネクションを使い回すため、ハンドシェイクのオーバーヘッドはゼロ!
    response_2 = session.get(API_URL)
    print(f"Request 2 Status: {response_2.status_code}")

Fetch API (Node.js) での Keep-Alive 設定

Node.js環境(v18以降のデフォルトの fetch や undici)でも、デフォルトでコネクションプールが有効になっているが、古いNode.js環境やカスタムHTTPエージェントを使う場合は、明示的な管理が必要だ。

const http = require('http');
const https = require('https');

// Keep-Aliveを有効にしたカスタムAgentを作成
const keepAliveAgent = new https.Agent({
  keepAlive: true,
  maxSockets: 100, // ドメインあたりの最大ソケット数
  keepAliveMsecs: 1000 // アクティブを維持する時間(ミリ秒)
});

// このエージェントをリクエストのオプションに渡すことで、ハンドシェイクを最小限に抑える
const options = {
  hostname: 'api.example.com',
  port: 443,
  path: '/v1/users',
  method: 'GET',
  agent: keepAliveAgent // コネクションプールを適用
};

const req = https.request(options, (res) => {
  res.on('data', (d) => {
    process.stdout.write(d);
  });
});
req.end();

—

7. まとめ:パケットの挙動を知る者が、真に堅牢なシステムを作る

現代のソフトウェア開発は、クラウドやフレームワークによって高度に抽象化されている。しかし、ネットワークの物理的な制約や、TCP/IPの基本プロトコルといった「枯れた技術」が引退したわけではない。

  • SYN Queue と Accept Queue の詰まりは、アプリケーションのバグではなくインフラの「サイレントな悲鳴」である。
  • MSSの不整合は、パケットの「ブラックホール化」を引き起こす。
  • Keep-Alive(コネクションプール)の欠如は、APIのパフォーマンスを無駄に低下させる。

これらの問題に直面したとき、パケットのシーケンス、そしてカーネル内部のステート遷移を脳内でトレースできるエンジニアこそが、真のトラブルシューターであり、信頼性の高いアーキテクチャを設計できるスペシャリストなのだ。

次に「接続が遅い」「タイムアウトが多発する」という報告を耳にしたら、恐れずに tcpdump を起動し、パケットたちの対話(ハンドシェイク)に耳を傾けてみてほしい。彼らは必ず、真実を語ってくれるはずだ。

コメント

タイトルとURLをコピーしました