TCPの3ウェイハンドシェイクとTIME_WAIT|接続の裏側を理解してエラーを減らす

kento_morota 12分で読めます
社内のAI・IT活用、技術がわかる相手に無料相談。 相談 →
目次

「サーバーにssを打ったらTIME_WAITが数万件並んでいて不安になった」「アプリから外部APIを叩き続けていたら、突然“Cannot assign requested address”で接続できなくなった」。こうした事象は、TCPが接続をどう確立し、どう切断しているかを知らないと原因にたどり着けません。

この記事では、3ウェイハンドシェイクによる接続確立、4回のやり取りによる切断と状態遷移、TIME_WAITが存在する理由と大量に残ったときの対処、Keep-Aliveが接続コストをどう減らすか、そしてssコマンドで現状を把握する方法までを扱います。TCP/IPの階層モデルやポート番号の基礎はエンジニアのためのネットワーク基礎を前提にしています。

3ウェイハンドシェイク:TCP接続はこうして確立される

SYN → SYN-ACK → ACK の3往復目で通信開始

TCPは「信頼性のある双方向のバイトストリーム」を提供するプロトコルです。データを送る前に、双方が「相手が生きていること」と「お互いの初期シーケンス番号」を確認する手続きが必要で、それが3ウェイハンドシェイクです。

  1. クライアントがSYNフラグ付きのセグメントを送る(状態:SYN_SENT)。「接続したい。私の初期シーケンス番号はxです」
  2. サーバーがSYNとACKを立てて返す(状態:SYN_RECV)。「了解。xを受け取った。私の初期シーケンス番号はyです」
  3. クライアントがACKを返す。この時点で双方がESTABLISHEDになり、データ送信が始まる

つまり、HTTPリクエストの最初の1バイトが届くまでに、最低でも1往復半(1.5 RTT)の時間がかかります。東京とバージニアの間のように往復に150ms前後かかる経路では、接続確立だけで200ms以上を消費します。TLSを使えばさらに鍵交換の往復が加わります。「接続を使い回す」ことが性能に直結する理由はここにあります。

接続は4つ組で識別される

確立した接続は「送信元IP・送信元ポート・宛先IP・宛先ポート」の4つ組(4-tuple)で一意に識別されます。サーバー側のポートは80や443に固定ですが、クライアント側のポートはOSが「エフェメラルポート」と呼ばれる範囲から自動で割り当てます。Linuxのデフォルト範囲はnet.ipv4.ip_local_port_rangeで32768〜60999、約28,000個です。この数が後述するポート枯渇の上限になります。

切断の状態遷移とTIME_WAITが存在する理由

切断はFINとACKの4回のやり取り

切断は確立より複雑です。TCPは双方向なので、「もう送るものはない」を片方向ずつ伝えます。先に切断を始めた側(アクティブクローズ側)はFINを送ってFIN_WAIT1になり、相手からのACKを受けてFIN_WAIT2、相手のFINを受けてACKを返した後にTIME_WAITへ入ります。一方、切断を受けた側(パッシブクローズ側)はFINを受け取るとCLOSE_WAITになり、アプリケーションがclose()を呼んでFINを送るとLAST_ACK、最後のACKを受けてCLOSEDになります。

状態どちら側意味大量に残るときの疑い
ESTABLISHED両方通信中正常(数が多ければ接続数の問題)
TIME_WAIT先に閉じた側切断完了後の待機短命接続の多発(設計上ほぼ正常)
CLOSE_WAIT閉じられた側アプリのclose待ちアプリのソケット閉じ忘れ(バグ)
SYN_RECVサーバーハンドシェイク途中SYNフラッド、バックログ不足
FIN_WAIT2先に閉じた側相手のFIN待ち相手側がcloseしない

TIME_WAITは「バグ」ではなく仕様上の安全装置

TIME_WAITは、アクティブクローズ側が最後のACKを送った後、2MSL(Maximum Segment Lifetime の2倍)の間ソケットを保持する状態です。Linuxではこの時間は60秒に固定されており、sysctlでは変更できません(tcp_fin_timeoutはFIN_WAIT2の待ち時間で、TIME_WAITとは別物です)。

なぜ待つのかというと、理由は2つあります。1つは、最後のACKが途中で失われた場合に相手がFINを再送してくるので、それに再びACKを返せるようにするためです。もう1つは、同じ4つ組で新しい接続をすぐに作ると、遅れて届いた古い接続のパケットが新しい接続のデータとして誤って受理される恐れがあるため、古いパケットがネットワークから消えるまで同じ4つ組の再利用を禁止するためです。TIME_WAITは接続の信頼性を守る仕組みであり、存在すること自体は正常です。

TIME_WAITが大量に残る原因とポート枯渇の対処

どちら側で溜まっているかで意味が変わる

TIME_WAITはメモリを少し消費するだけで、サーバー側(接続を受ける側)に数万件あっても、それ自体が性能問題になることはまれです。深刻なのはクライアント側、つまり「接続を開始する側」で溜まるケースです。Webアプリサーバーが外部APIやDB、Redisへ毎回新しい接続を張っては閉じる構成だと、宛先IP・宛先ポートが固定なので、使えるのは送信元ポートの約28,000個だけです。1秒に500接続を作れば60秒で30,000件になり、エフェメラルポートを使い切って新しい接続が作れなくなります。これがポート枯渇で、アプリ側にはEADDRNOTAVAIL: Cannot assign requested addressとして現れます。

対処の優先順位:まず接続を使い回す

根本対策は「接続を毎回作らない」ことです。DBならコネクションプール、HTTPならKeep-Aliveでの接続再利用が第一の選択肢になります。それでも足りない場合の補助策として、次のカーネルパラメータがあります。

# 現在値の確認
sysctl net.ipv4.ip_local_port_range net.ipv4.tcp_tw_reuse net.ipv4.tcp_fin_timeout

# エフェメラルポートの範囲を広げる(約28,000 → 約64,000)
sudo sysctl -w net.ipv4.ip_local_port_range="1024 65535"

# 外向き接続でTIME_WAIT中のソケットを安全に再利用(要 TCPタイムスタンプ有効)
sudo sysctl -w net.ipv4.tcp_tw_reuse=1

# 永続化する場合は /etc/sysctl.d/99-tcp.conf に記述して sysctl --system

tcp_tw_reuseは、TCPタイムスタンプで新旧のパケットを区別できることを前提に、外向き接続についてTIME_WAIT中のポートを再利用してよいとする設定です。効くのは接続を開始する側だけで、Webサーバーに設定してもインバウンドのTIME_WAITは減りません。なお、Linux 4.12以降ではデフォルト値が2(ループバック接続のみ再利用)になっており、かつて似た名前で存在したtcp_tw_recycleはNAT環境で接続が壊れる副作用のため廃止されています。古い記事の設定をそのまま真似ないよう注意してください。sysctlの扱い方全般はLinuxカーネルチューニング入門で扱っています。

Keep-Aliveの意義:接続コストを払うのは一度でいい

HTTP Keep-AliveとTCP Keepaliveは別物

名前が似ていて混同されやすいのですが、2つは目的が異なります。HTTP Keep-Alive(持続的接続)は、1つのTCP接続で複数のHTTPリクエストを順番に処理する仕組みで、HTTP/1.1ではデフォルトで有効です。ハンドシェイクとTLSのコストを1回にまとめられるため、レイテンシと前述のTIME_WAITの両方を減らせます。一方、TCP Keepalive(SO_KEEPALIVE)は、無通信状態が続いた接続に対してOSが定期的に小さなパケットを送り、相手が生きているかを確かめる機能です。Linuxのデフォルトではtcp_keepalive_timeが7200秒(2時間)で、NATやロードバランサーが数分でアイドル接続を捨てる環境では短すぎず長すぎる値に調整する必要があります。

Keep-Aliveを「両端で」有効にする

見落としやすいのは、Keep-Aliveはクライアントとサーバーの両方が対応して初めて機能する点です。NginxはクライアントとのKeep-Aliveをkeepalive_timeout(デフォルト75秒)で制御しますが、バックエンドへの接続はデフォルトでHTTP/1.0・毎回切断です。upstreamブロックにkeepaliveを書き、プロトコルとヘッダーを明示して初めて再利用されます。同様に、Node.jsのfetchhttp.requestもエージェント設定次第で毎回新規接続になります。

# Nginx: バックエンドへの接続を使い回す
upstream app {
    server 127.0.0.1:3000;
    keepalive 32;               # ワーカーごとに保持するアイドル接続数
}
server {
    location / {
        proxy_pass http://app;
        proxy_http_version 1.1;   # 1.0のままだとKeep-Aliveされない
        proxy_set_header Connection "";
    }
}
// Node.js: 外部APIへの接続を使い回す(Node 18以前の http/https)
const https = require('https');
const agent = new https.Agent({ keepAlive: true, maxSockets: 50 });
https.get('https://api.example.com/items', { agent }, (res) => { /* ... */ });
// Node 20以降の fetch(undici)はデフォルトで接続を再利用する

Nginx側のworkerやkeepalive値の具体的な調整はNginxパフォーマンスチューニングで扱っています。

ssコマンドで接続状態を確認する

状態別の集計から異常を見つける

ssnetstatの後継で、カーネルから直接ソケット情報を取得するため大量の接続があっても高速です。まず状態ごとの件数を俯瞰し、次に気になる状態だけを絞り込むのが定石です。ssipの基本的な使い方はLinuxネットワークコマンド入門を参照してください。

# 全体のサマリー(TCPの状態別件数が一目でわかる)
ss -s

# 状態ごとに集計
ss -tan | awk 'NR>1 {print $1}' | sort | uniq -c | sort -rn

# TIME_WAITだけを抽出し、宛先ごとに件数を数える(どこへの接続が短命か)
ss -tan state time-wait | awk 'NR>1 {print $4}' | sort | uniq -c | sort -rn | head

# CLOSE_WAITを持つプロセスを特定(閉じ忘れの犯人探し)
sudo ss -tanp state close-wait

TIME_WAITの宛先集計で「特定のIP:ポート」に集中していれば、そこへの接続を使い回せていないことがほぼ確定します。CLOSE_WAITが増え続けている場合は、相手が切断したのに自分のアプリがclose()を呼んでいない状態で、ソケットリーク(ファイルディスクリプタの枯渇)につながるためアプリ側の修正が必要です。

トラブル事例:バッチ実行中に外部API接続が失敗する

症状

夜間バッチで数十万件のデータを外部の決済APIに1件ずつ送信していたところ、処理開始から数分後にconnect EADDRNOTAVAILというエラーが断続的に発生し、リトライしても一定時間は失敗が続く。しばらく待つと自然に回復する、という現象でした。

原因

ss -sで確認すると、TIME_WAITが約28,000件で頭打ちになっていました。バッチはリクエストごとに新しいHTTPクライアントを生成しており、Keep-Aliveが効かず、1リクエストごとに1つのエフェメラルポートを60秒間占有していました。エラーの「自然回復」は、60秒経過したTIME_WAITが解放されるタイミングと一致していました。

対処

HTTPクライアントを1つのインスタンスに共有し、Keep-Aliveを有効にして接続を使い回すよう修正したところ、TIME_WAITは数十件に減り、エラーは消えました。あわせて処理時間も接続確立分だけ短縮されています。補助的にip_local_port_rangeを拡大し、tcp_tw_reuse=1を設定しましたが、これは「万一の保険」であって、主役はあくまでアプリ側の接続再利用です。カーネル設定だけで押し切ろうとすると、根本原因が残ったまま上限が少し伸びるだけになります。

まとめ

TCPは3ウェイハンドシェイクで接続を確立し、FIN/ACKの4回のやり取りで切断します。TIME_WAITは先に切断した側が古いパケットを排除するための安全装置で、Linuxでは60秒固定、存在自体は正常です。問題になるのは接続を開始する側で短命接続を大量に作る設計で、エフェメラルポートの約28,000個を使い切るとポート枯渇が起きます。対処の本筋は接続の再利用(Keep-Alive・コネクションプール)で、tcp_tw_reuseやポート範囲の拡大は補助策と位置づけてください。まずはss -sで状態別の件数を眺め、TIME_WAITとCLOSE_WAITがどこに集中しているかを確かめるところから始めましょう。TCPとUDPの根本的な性質の違いはTCPとUDPの違いと使い分けで解説しています。

接続数の設計やサーバーのチューニングでお困りの際は、Harmonic Societyのシステム開発・インフラ支援にご相談ください。

#TCP#TIME_WAIT#Keep-Alive#ネットワーク

Harmonic Society

この記事の内容、自社の業務でも活かせそうですか?

ローカルLLM・AI・クラウドなどの技術導入を、要件整理からPoC・社内展開まで代表エンジニアが伴走します。オンライン対応・全国OK。まずは30分の無料相談から。売り込みはしません。

共有:
無料メルマガ

週1回、最新の技術記事をお届け

AI・クラウド・開発の最新記事を毎週月曜にメールでお届けします。登録は無料、いつでも解除できます。

プライバシーポリシーに基づき管理します

関連記事

Related / 9 articles

  1. プログラミング

    DDoS攻撃の仕組みと対策入門|レイヤー別の防御とCDN・クラウドの活用

    DDoS攻撃をボリューム型・プロトコル型・アプリ層に分けて仕組みを解説し、自前サーバーで防げない理由、CloudflareやAWS Shieldの標準防御、オリジンIPの隠し方、レートリミットとBot対策、攻撃を受けたときの初動、費用が跳ね上がるDenial of Walletへの備えまでわかります。

  2. プログラミング

    WAFとは?仕組み・導入パターン・誤検知対策|Webアプリを攻撃から守る実践ガイド

    WAFがファイアウォールやIDSと何が違うのか、シグネチャとマネージドルールの仕組み、Cloudflare WAF・AWS WAF・ModSecurityの比較、フォーム送信がブロックされる誤検知の調査と例外設定、ログ監視、WAFが代替できないことまで実践的に解説します。

  3. プログラミング

    セキュリティヘッダー入門|CSP・HSTS・X-Frame-Optionsの設定と効果を実践解説

    CSP・HSTS・X-Frame-Options・X-Content-Type-Optionsなど主要セキュリティヘッダーが防ぐ攻撃と、CSPのReport-Onlyからの段階導入、nonce/hash、HSTS preloadの不可逆リスク、Nginx・Next.js・Astroでの設定例、確認方法を解説します。

  4. プログラミング

    クラウドの通信費(Egress)入門|データ転送量課金の仕組みと転送コストを抑える設計

    クラウドの「受信無料・送信有料」の原則、AZ間・リージョン間・インターネット向けの単価差、NATゲートウェイ処理料の罠、CDNで転送量を減らす方法、バックアップやログ転送の見落とし、請求書で転送料を特定する手順を解説。想定外の請求を防げます。

  5. プログラミング

    秘密情報をGitに入れない仕組み|.gitignore・git-secrets・履歴から漏れた鍵の削除

    APIキーや.envをGitにコミットしてしまう典型経路と、.gitignore・.env.exampleの運用、pre-commitでのgitleaks検知、GitHub secret scanningの活用、漏れた鍵の無効化と履歴書き換え(git filter-repo)の手順を解説。仕組みで再発を防げます。

  6. プログラミング

    開発・ステージング・本番環境の分離設計|環境差分をなくす構成とアクセス制御

    開発・ステージング・本番それぞれの目的と、構成をコードで揃える方法、環境別の設定注入、本番データを使わないテストデータ戦略、ステージングの保護(Basic認証・IP制限・noindex)、コストを抑える運用までを解説。環境差分による本番障害を防げます。

  7. プログラミング

    ngrok・Cloudflare Tunnelでローカルを公開|Webhook開発とデモ環境の作り方

    NAT内のローカル環境にStripeやLINEのWebhookを届けるトンネリングの仕組みを解説。ngrok・Cloudflare Tunnel・localtunnelの比較、固定ドメインと認証、リクエスト検査、公開時のセキュリティ、自宅サーバー公開への応用までわかります。

  8. プログラミング

    ローカル開発環境のHTTPS化|mkcert・hostsファイル・自己署名証明書の正しい使い方

    ローカル開発をHTTPS前提にすべき理由(Secure Cookie・Service Worker・OAuth)と、mkcertでローカルCAを作りhostsで独自ドメインを割り当ててVite・Next.js・Dockerで使う手順を解説。証明書警告を無視する癖の危険も理解できます。

  9. プログラミング

    localhost・0.0.0.0・127.0.0.1の違い|ポートとUnixソケットを理解して「つながらない」を解決

    localhost・127.0.0.1・0.0.0.0の意味の違い、Dockerで外から接続できない原因、host.docker.internal、ポート競合の調べ方、Unixソケットの利点と権限、1024未満ポートの制約を解説。「つながらない」を仕組みから解決できます。

Harmonic Society

「読んで終わり」にせず、自社の業務で試してみませんか?

AI・ローカルLLM・クラウドの導入を、要件整理からPoC・社内展開まで代表エンジニアが伴走します。オンライン対応・全国OK・売り込みなし。

無料・30分・オンラインOK|1営業日以内に返信します