「ネットワークは詳しい人に任せればいい」——そう思っていられるのは、本番が順調なうちだけです。ECONNRESET がログに溢れる。デプロイ直後だけ接続が詰まる。L7ロードバランサ越しのAPIが30秒でタイムアウトする。決済リクエストが「成功したのか失敗したのか分からない」状態で返ってくる。これらはすべて、アプリのバグではなく TCP/IP の挙動そのものです。 仕組みを知らないと、ログは「呪文」のままで、対症療法のリトライを足すたびに状況が悪化します。
この記事は、TCP/IP を「資格試験の暗記」ではなく 本番の設計判断に使える知識へ変えるための実装ガイドです。各レイヤーが何を保証し、何を保証しないのかを IETF の一次情報(RFC)で押さえ、Node.js/TypeScript の実コードで動かし、そして「公式ドキュメントには書かれていないが本番で必ず効く運用知識」(TIME_WAIT・Keep-Alive・MTU・コネクションプール)までを地続きでつなぎます。題材には、私が決済信頼性レイヤーを設計・主導したサーバーレス決済プラットフォーム(モバイル回線のタイムアウトと再送を前提に、冪等性で本番二重課金0件を達成)での判断も交えます。
この記事のルール:プロトコルの規定は IETF の一次情報(RFC) に基づきます。中核は TCP = RFC 9293(2022年8月・RFC 793 ほかを廃止)、UDP = RFC 768(1980年)、IPv4 = RFC 791、IPv6 = RFC 8200、ホスト要件と階層 = RFC 1122 です。RFC は改訂・廃止されるため、本番設計の前に必ず最新版を rfc-editor.org で確認してください。コードは Node.js の標準ライブラリ(
net・dgram)で動く形に整えていますが、ポート・ホスト・タイムアウト値は環境変数前提で、本番では必ず観測値に基づき調整してください。
0. まず立ち位置:「TCP/IP」は1つの規格ではない
設計の前に、用語の正体を3行で押さえます。
- TCP/IP は「プロトコル群(suite)」の総称です。2つの代表プロトコル(TCP と IP)の名を借りた呼び名で、実体は IP・TCP・UDP・ICMP・ARP・DNS など多数のプロトコルの集合です。
- IETF はこれを「階層」として定義します。 RFC 1122(Host Requirements)は、インターネット通信を Application / Transport / Internet / Link の4層に分けて規定しています。学校で習う OSI 参照モデル(7層)は概念整理用で、実装の正典はこちらです。
- 各層は「下の層を信頼しない」前提で積まれています。 IP は届くことを保証しない。だから TCP がその上で信頼性を作る。この「保証の境界」を理解することが、TCP/IP を学ぶ唯一にして最大の勘所です。
この記事は下から上へ——Internet層(IP)→ Transport層(TCP/UDP)→ アプリでの実装と運用——の順で、各層が「何を保証し、何を保証しないか」を軸に進めます。
1. 4階層モデルとカプセル化——データはどう包まれて飛ぶか
1.1 4層モデルと OSI の対応
| RFC 1122 の階層 | 役割(何を保証するか) | 代表プロトコル | アドレス単位 | データの呼称 |
|---|---|---|---|---|
| Application | アプリ間の意味(HTTP, gRPC など) | HTTP, DNS, TLS, SMTP | — | メッセージ |
| Transport | プロセス間の多重化・(TCPは)信頼性 | TCP, UDP | ポート番号 | セグメント / データグラム |
| Internet | ホスト間のエンドツーエンド配送 | IP, ICMP | IPアドレス | パケット |
| Link | 同一リンク内のノード間転送 | Ethernet, Wi-Fi, ARP | MACアドレス | フレーム |
OSI 7層との対応はおおよそ次の通りです。OSIの「セッション層・プレゼンテーション層」に相当する関心事(TLS・文字コード・圧縮)は、TCP/IPでは Application 層がまとめて担います。
OSI 7層 TCP/IP 4層 (RFC 1122)
Application ┐
Presentation ├──► Application (HTTP, TLS, DNS, gRPC)
Session ┘
Transport ───► Transport (TCP, UDP)
Network ───► Internet (IP, ICMP)
Data Link ┐
Physical ┴──► Link (Ethernet, Wi-Fi)
1.2 カプセル化(encapsulation)——「封筒の入れ子」
送信時、上位層のデータは下位層のヘッダで順に包まれます。これがカプセル化です。GET / という HTTP リクエストが物理的に飛ぶまでに、こう包まれます。
[ Ethernet ヘッダ [ IP ヘッダ [ TCP ヘッダ [ HTTP データ ] ] ] FCS ]
└ Link 層 └ Internet 層 └ Transport 層 └ Application 層
受信側は逆順に開封(逆カプセル化)し、各層のヘッダを剥がして上位へ渡します。重要なのは、各層が「自分のヘッダ」しか見ないこと。IP層はTCPの中身を知らないし、TCP層はHTTPの意味を知りません。この関心の分離が、TCP/IPが半世紀スケールしてきた設計の核です(SRP がプロトコル設計に効いている好例です)。
設計への示唆:この入れ子は、各層が独立に進化できることを意味します。HTTP/1.1 → HTTP/2 はTCPを変えずに実現でき、HTTP/3 は逆にTransport層をTCPからQUIC(UDPベース)へ差し替えました。「どの層を差し替えると何が変わるか」を意識すると、技術選定の解像度が上がります。
2. Internet 層:IP は「ベストエフォート」——届くことを保証しない
2.1 IP が保証すること・しないこと
RFC 791(IPv4)と RFC 8200(IPv6)が定める IP の役割は、**「宛先IPアドレスへパケットを届けようと最善を尽くす」**こと、ただそれだけです。具体的には次を 保証しません。
- 到達保証なし:経路の途中で破棄されうる(輻輳・TTL切れ・ルーティング障害)。
- 順序保証なし:別経路を通って順番が入れ替わりうる。
- 重複排除なし:同じパケットが複数届きうる。
- 完全性の保証は限定的:IPv4ヘッダにはチェックサムがあるがヘッダのみ対象。ペイロードの完全性はTrasport層(TCP/UDPのチェックサム)に委ねる。IPv6はヘッダチェックサム自体を廃止した。
つまり IP は「投函はするが配達は保証しない郵便」です。この割り切りこそが、上位の TCP に「信頼性をどこで作るか」という設計責務を生みます。
2.2 IPアドレスとCIDR——本番で必ず使う知識に絞る
IPv4 は32ビット(例 192.0.2.10)、IPv6 は128ビット(例 2001:db8::1)。本番のインフラ設計で必須なのは次の3点です。
(1) CIDR 表記(RFC 4632):10.0.0.0/16 の /16 は「上位16ビットがネットワーク部」を意味します。VPCのサブネット設計はこれを読めないと始まりません。
10.0.0.0/16 → 10.0.0.0 〜 10.0.255.255 (65,536 アドレス、ホスト 65,534)
10.0.1.0/24 → 10.0.1.0 〜 10.0.1.255 (256 アドレス、ホスト 254)
※ 各サブネットでネットワークアドレスとブロードキャストの2つは予約
(2) プライベートアドレス(RFC 1918):インターネットに出ない閉域用の予約レンジ。VPC・社内網はここから切ります。
10.0.0.0/8 (10.x.x.x) 最大規模
172.16.0.0/12 (172.16〜172.31.x.x) 中規模・Docker のデフォルト
192.168.0.0/16 (192.168.x.x) 家庭・小規模
(3) MTU と MSS:1つのリンクで運べる最大ペイロード(MTU)はEthernetで通常 1500バイト。ここからIP/TCPヘッダを引いた **MSS(Maximum Segment Size)**が、TCPが1セグメントで送れる上限です(標準的に 1460 バイト)。VPN・トンネル・クラウド間でMTUが縮むと、大きなパケットが黙って落ちて「特定サイズの通信だけ刺さる」という悪夢の障害になります。Path MTU Discovery(RFC 8201)が効かない経路では、ここを疑うと当たります。
2.3 IPv4 ヘッダの読みどころ
全フィールドの暗記は不要です。障害解析で実際に見るのは次だけです。
- TTL(Time To Live):ルータを1つ越えるごとに1減り、0で破棄。ループ防止であり、
tracerouteの原理でもある。 - Protocol:上位プロトコルの種別。6 = TCP、17 = UDP、1 = ICMP。
tcpdumpでこの値を見れば中身の見当がつく。 - Source / Destination Address:送信元・宛先のIP。NAT越えで送信元が書き換わる点に注意。
3. Transport 層:TCP と UDP——「信頼性」をどこで作るか
Internet層が「ベストエフォート」だと決めた以上、信頼性が必要なら誰かが作らねばなりません。その責務をフルセットで引き受けるのが TCP、あえて引き受けず最小限に留めるのが UDP です。
3.1 ポート番号——1台のホストで複数プロセスを多重化する
IPアドレスが「どのホストか」を、ポート番号(16ビット, 0〜65535)が「そのホストのどのプロセスか」を指します。(送信元IP, 送信元ポート, 宛先IP, 宛先ポート, プロトコル) の5タプルで1本の通信が一意に識別されます。
- Well-known ports(0〜1023):HTTP=80, HTTPS=443, SSH=22, DNS=53 など。
- Ephemeral ports(49152〜65535、IANA推奨):クライアントが接続のたびに動的に使う一時ポート。この枯渇が、後述する TIME_WAIT 問題の本質です。
3.2 TCP(RFC 9293)が提供する4つの保証
RFC 9293 によれば、TCP は コネクション指向の信頼ストリームを提供します。具体的には次の4つです。
- 信頼性(reliability):シーケンス番号とACK・再送で、失われたデータを回復する。
- 順序保証(ordering):受信側はシーケンス番号で並べ直し、アプリには順序通りに渡す。
- フロー制御(flow control):受信側の Window フィールド(受け取れるバイト数)で、速い送信側が遅い受信側を溢れさせないよう制御する。
- 輻輳制御(congestion control):ネットワークの混雑を検知して送信速度を自律的に絞る(RFC 5681 のスロースタート・輻輳回避)。RFC 9293 は輻輳制御の実装を必須としています。
これらの代償は「接続確立のための往復(3-way handshake)」「状態の保持」「ヘッドオブラインブロッキング」です。仕組みの詳細(ハンドシェイク・11状態の状態機械・再送・輻輳制御)は、専用記事で深掘りします(本クラスタの『TCP の仕組み完全解説』)。
3.3 UDP(RFC 768)——あえて何もしない潔さ
RFC 768 の UDP ヘッダはわずか 8バイト(送信元ポート・宛先ポート・長さ・チェックサム)。仕様は明確に「delivery and duplicate protection are not guaranteed(配送と重複排除は保証しない)」と述べます。接続確立もなく、いきなりデータグラムを投げます。
この「潔さ」が武器になる領域があります——リアルタイム音声・映像、DNS、ゲーム、そして QUIC(HTTP/3 の土台)。TCP と UDP のどちらを選ぶかは、本クラスタの『TCP と UDP の違いと使い分け』で、QUIC/HTTP/3 まで含めて判断軸を示します。
4. 実コード:Node.js で TCP / UDP を「触って」理解する
理屈は手を動かすと定着します。Node.js 標準ライブラリだけで、TCP と UDP の差を体感します。型は TypeScript で固めます。
4.1 TCP エコーサーバー(net モジュール)
import net from "node:net";
const PORT = Number(process.env.TCP_PORT ?? 9000);
const server = net.createServer((socket: net.Socket) => {
// 5タプルでこの接続を識別できる
const peer = `${socket.remoteAddress}:${socket.remotePort}`;
console.log(`[open] ${peer}`);
// TCP はバイトストリーム。データは「メッセージ単位」では届かない点に注意(後述)
socket.on("data", (chunk: Buffer) => {
console.log(`[recv] ${peer} ${chunk.length} bytes`);
socket.write(chunk); // そのまま返す(echo)
});
socket.on("end", () => console.log(`[end] ${peer}`)); // 相手が FIN を送った
socket.on("error", (err) => console.error(`[err] ${peer} ${err.message}`)); // RST 等
});
server.listen(PORT, () => console.log(`TCP echo listening on :${PORT}`));
// クライアント
import net from "node:net";
const socket = net.createConnection(
{ host: "127.0.0.1", port: Number(process.env.TCP_PORT ?? 9000) },
() => socket.write("hello tcp"), // 接続確立(3-way handshake 完了)後に送信
);
socket.on("data", (data: Buffer) => {
console.log("echo:", data.toString());
socket.end(); // FIN を送って正常クローズ
});
socket.setTimeout(5_000, () => socket.destroy(new Error("idle timeout")));
4.2 致命的な落とし穴:「TCP はメッセージの境界を保たない」
上のサーバーで、クライアントが socket.write("AB") と socket.write("CD") を連続で呼んでも、サーバーの data イベントは "ABCD" が1回で来るかもしれないし、"A" と "BCD" の2回かもしれません。TCP はバイトストリームであり、write 1回が data 1回に対応する保証はない——これは初学者が必ず踏むバグです。
だからアプリ層で「フレーミング(区切り)」を自分で実装する必要があります。代表は「長さプレフィックス」方式です。
// 4バイトのビッグエンディアン長 + 本体、というフレームを復元する
class LengthPrefixedDecoder {
private buf = Buffer.alloc(0);
/** チャンクを push し、完成したメッセージだけを配列で返す(総関数:未完成なら []) */
push(chunk: Buffer): Buffer[] {
this.buf = Buffer.concat([this.buf, chunk]);
const out: Buffer[] = [];
while (this.buf.length >= 4) {
const len = this.buf.readUInt32BE(0);
if (this.buf.length < 4 + len) break; // まだ本体が揃っていない
out.push(this.buf.subarray(4, 4 + len));
this.buf = this.buf.subarray(4 + len); // 消費した分を捨てる
}
return out;
}
}
HTTPやgRPCが「メッセージ指向」に見えるのは、こうしたフレーミング(HTTPなら
Content-Lengthやchunked、gRPCなら5バイトのプレフィックス)を上位で実装しているからです。生のTCPを使うときは、この層を自分で用意する必要があると肝に銘じてください。
4.3 UDP(dgram モジュール)——境界は保たれるが、届く保証はない
import dgram from "node:dgram";
const PORT = Number(process.env.UDP_PORT ?? 9001);
const server = dgram.createSocket("udp4");
// UDP は「1 send = 1 メッセージ」。境界は保たれる。ただし順序も到達も保証されない
server.on("message", (msg: Buffer, rinfo) => {
console.log(`[recv] ${rinfo.address}:${rinfo.port} "${msg}"`);
server.send(msg, rinfo.port, rinfo.address); // エコー(届かなくても誰も気づかない)
});
server.bind(PORT, () => console.log(`UDP listening on :${PORT}`));
TCPとUDPの本質的な差がコードに出ています:TCPは「接続」を張ってバイトを流すが境界を保たない。UDPは「接続」なしでメッセージ単位に投げるが届く保証がない。この非対称性が、すべての設計判断の根です。
5. 本番で効くTCPの挙動——RFCの先にある「運用知識」
ここからが、検定試験には出ないが本番で必ず効く領域です。私が決済基盤で実際に踏み、設計で対処した論点を中心に挙げます。
5.1 TIME_WAIT——「接続を閉じたのに繋がらない」の正体
TCP接続を能動的に閉じた側(多くはクライアント、あるいはL7プロキシ)は、接続を閉じた後 TIME_WAIT 状態に **2×MSL(Maximum Segment Lifetime、典型的に合計60秒前後)**留まります。これは「遅延して届いた古いパケットが、同じ5タプルの新しい接続を汚染しないため」の安全装置で、RFC 9293 が規定する正しい挙動です。
問題は高頻度で短命な接続を大量に張るケース(例:プロキシが上流APIへ毎回新規接続)。TIME_WAIT が溜まると Ephemeral ポートが枯渇し、EADDRNOTAVAIL で新規接続が張れなくなります。対策は次の優先順位です。
- 接続を使い回す(最重要):Keep-Alive とコネクションプール。そもそも閉じなければ TIME_WAIT は生まれない。
- カーネルパラメータの調整(
net.ipv4.tcp_tw_reuse等)は症状緩和の最終手段。アプリ設計で接続を減らすのが先。
5.2 Keep-Alive とコネクションプール——「閉じない」が正義
import net from "node:net";
const socket = net.createConnection({ host, port });
// TCP Keep-Alive:アイドル接続が生きているかを定期的に確認(死活監視)
socket.setKeepAlive(true, 30_000); // 30秒アイドルで keepalive プローブ開始
// Nagle アルゴリズムを無効化:小さなパケットを即送る(後述)
socket.setNoDelay(true);
HTTPクライアントなら、コネクションプールを必ず使うこと。Node.js の undici(fetch の実体)や AWS SDK v3 は既定でプールします。プールサイズとアイドルタイムアウトを上流のタイムアウトより短く設定するのが鉄則です(上流が先に切ると、こちらが死んだ接続を掴んで ECONNRESET を踏む)。
5.3 Nagle アルゴリズムと遅延ACK——相性最悪の組み合わせ
- Nagle アルゴリズム:小さなデータを溜めてまとめて送る(帯域の節約)。
- 遅延ACK:ACKを少し遅らせてまとめる(同上)。
この2つが噛み合うと、「送信側はACK待ち、受信側はデータ待ち」で最大数百msのデッドロック的遅延が発生します。対話的な小さなRPCを低レイテンシで流したいなら setNoDelay(true)(TCP_NODELAY)でNagleを切るのが定石です。一方、スループット重視のバルク転送では切らない方が良いこともある——「常に切る」ではなくワークロードで判断します。
5.4 backlog と SYN——「デプロイ直後だけ詰まる」
listen(backlog) の backlog は、まだ accept されていない確立済み接続を保持するキュー長です。これが小さいと、起動直後やスパイク時に接続が黙って落ちる/遅延する。Node.js の既定(511)で足りないことは稀ですが、フロントにいるリバースプロキシやLBの backlog/接続数上限と合わせて設計する必要があります。
5.5 タイムアウトは「3種類」を区別する
「タイムアウト」を一括りにすると設計を誤ります。最低限この3つを分けます。
| 種別 | 何を待つ時間か | 短すぎると | 長すぎると |
|---|---|---|---|
| 接続タイムアウト | 3-way handshake の完了 | 健全な接続も切る | 死んだ宛先を長く掴む |
| アイドル/受信タイムアウト | データが来ない時間 | 正常な低速応答を切る | ハングを検知できない |
| 全体(リクエスト)タイムアウト | 1リクエストの総時間 | リトライ嵐を誘発 | リソースを長く占有 |
決済基盤では、これらを上流から末端まで**「外側ほど長く、内側ほど短く」**の階段状に設計しました(タイムアウトバジェット)。内側が外側より長いと、外側が諦めた後も内側が走り続け、リソースを食い潰しながら誰も待っていない仕事をする最悪の状態になります。
6. 観測とデバッグ——「繋がらない」を事実で切り分ける
TCPの状態遷移を読めれば、障害の切り分けは推測から観測に変わります。Linux での実務ツールを挙げます。
# 1) いまの TCP 接続と状態を一覧(ss は netstat の後継・高速)
ss -tan
# State Recv-Q Send-Q Local Address:Port Peer Address:Port
# ESTAB 0 0 10.0.1.5:443 10.0.2.9:51324
# TIME-WAIT 0 0 10.0.1.5:51200 10.0.3.4:5432 ← 大量なら 5.1 を疑う
# SYN-SENT 0 1 10.0.1.5:40012 10.0.9.9:80 ← 相手が SYN-ACK を返していない
# 2) 状態ごとに件数を集計(TIME-WAIT 肥大の検知に有効)
ss -tan | awk 'NR>1{print $1}' | sort | uniq -c | sort -rn
# 3) パケットを直接見る(handshake が成立しているか)
sudo tcpdump -ni eth0 'tcp port 443 and (tcp[tcpflags] & (tcp-syn|tcp-rst) != 0)'
# 4) 経路途中のどこで詰まるか(TTL を 1 ずつ増やして応答元を見る)
traceroute -T -p 443 api.example.com # -T で TCP SYN を使う
状態から原因を引く早見表:
SYN-SENTが滞留 → 宛先までSYNは出たがSYN-ACKが返らない。FW/SG/ルーティング/宛先プロセス停止を疑う。SYN-RECVが大量 → SYN フラッドか backlog 枯渇。SYN Cookies の確認。TIME-WAITが大量 → 短命接続の作りすぎ(§5.1)。Keep-Alive 化。CLOSE-WAITが滞留 → アプリがclose()を呼んでいない(こちらのコードのバグ)。リソースリークの典型。ESTABのまま無反応・Recv-Q増大 → アプリがデータを読んでいない(処理が詰まっている)。
CLOSE-WAITの滞留だけはほぼ確実に自分のコードのバグ(FIN を受けたのにソケットを閉じていない)です。他は相手やネットワークの可能性がありますが、ここはアプリを疑ってください。
7. 信頼性の設計:TCP の「信頼性」に頼り切らない
最後に、最も実務的な教訓です。TCP は信頼性を「再送」で作ります。 これは裏を返せば、**ネットワークから見れば配送は at-least-once(少なくとも1回)**だということです。さらにその上の世界——ロードバランサ、APIゲートウェイ、モバイル回線、Lambda のリトライ——では、同じリクエストが複数回到達するのは異常ではなく日常です。
決済プラットフォームで私が設計した中核がここでした。課題はまさに「モバイル回線のタイムアウトや API Gateway/Lambda の再試行により、同一の決済リクエストが複数回到達する」こと。これに対し、
- リトライ自体は正常系として受け入れる(ネットワークは必ず再送する、と認める)。
- そのうえで課金は1回だけに収束させる——クライアント発行の冪等性キーを条件付き書き込み(
attribute_not_exists)で1度きりに制約し、DynamoDB の原子的トランザクションで残高更新をアトミックにする。
結果として、TCP より上のすべての層で再送が起きても、アプリの意味としては exactly-onceになり、本番二重課金0件を達成しました。
教訓を一般化すると:
TCP の信頼性は「同一接続内のバイトの欠落」を直すだけ。接続が切れて張り直す・上位がリトライする、という現実の重複は直してくれない。だから決済・在庫・メッセージングのような領域では、信頼性をネットワークに丸投げせず、アプリ層で冪等性として設計する。
これは TCP/IP の知識が、低レイヤの教養で終わらず、本番の金銭的正しさに直結する好例です。
8. まとめ:TCP/IP を「設計判断」に変えるチェックリスト
- 保証の境界を言える:IP はベストエフォート(到達・順序・重複排除を保証しない)。信頼性は TCP が、最小限の多重化だけは UDP が足す。
- カプセル化の入れ子を説明できる(各層は自分のヘッダしか見ない=関心の分離)。
- CIDR / RFC 1918 / MTU・MSS を読める(VPC設計とMTU起因障害の切り分け)。
- TCP はバイトストリーム——
writeとdataは1対1でない。生TCPではフレーミングを自前で実装する。 - TIME_WAIT・Keep-Alive・Nagle・backlog・タイムアウト3種を運用観点で設計している。
-
ssで TCP 状態を読み、SYN-SENT/CLOSE-WAIT/TIME-WAITから原因を引ける。 - 信頼性をネットワークに丸投げせず、重複前提でアプリを冪等に設計している。
TCP/IP は「暗記する古典」ではなく、本番のレイテンシ・コスト・正しさを左右する現役の設計知識です。次は、その心臓部である TCP の状態機械・再送・輻輳制御を、RFC 9293 に沿って深掘りします。
私(友田 陽大)は、一人 × 生成AI(Claude Code)で、低レイヤの挙動まで踏まえた落ちない・追える・正しいバックエンドを設計・実装しています。「ECONNRESET が止まらない」「TIME_WAIT でポートが枯れる」「タイムアウトとリトライの設計が分からない」「決済・在庫で二重処理が怖い」——こうしたネットワーク/信頼性起因の課題は、現象の再現と観測から原因を特定し、冪等性と適切なタイムアウト設計で根治します。お気軽にご相談ください。