背景と理由:異なる問題を解決する2つのプロトコル
アプリケーションが送信するすべてのパケットは、TCPかUDPのどちらかを経由して届く。その選択——そして、それが意図的なものだったかどうか——が、負荷・不安定な接続・パケットロスに対するサービスの振る舞いを決定する。深夜2時にレイテンシのスパイクを追いかけているとき、どのプロトコルが原因かを把握し、実際のトラフィックで確認できれば、どんなダッシュボードよりも速く根本原因にたどり着ける。
TCPとUDPはどちらもOSIモデルのレイヤー4(トランスポート層)に位置し、IPの上で動作する。ただし、その設計思想はまったく逆の方向を向いている。
TCP — 信頼性のために設計された
TCP(Transmission Control Protocol)はデータを流す前に3ウェイハンドシェイクで接続を確立する:
クライアント → サーバー: SYN
サーバー → クライアント: SYN-ACK
クライアント → サーバー: ACK
(接続確立、データ転送開始)
そこからTCPは、パケットが届くこと・順序通りに届くこと・送信側が損失を把握できることを保証する。再送、フロー制御、輻輳制御はすべて組み込み済みだ。その信頼性にはコストが伴う。TCPヘッダーは20〜60バイトあり、新しい接続ごとに最初のバイトが届くまで少なくとも1.5RTTの遅延が発生する。SSH、HTTP/HTTPS、PostgreSQL、MySQL——これらはデータの整合性が必須であるため、そのオーバーヘッドを受け入れている。
UDP — 速度のために設計された
UDP(User Datagram Protocol)はこれらすべてをスキップする。ハンドシェイクなし、確認応答なし、再送なし。パケットをデータグラムに詰め込んで送り出し、次に進む。パケットが失われても、プロトコルは関知しない——それはアプリケーション側が対処する(あるいは無視する)問題だ。
その恩恵は明確で、UDPのヘッダーはわずか8バイト、接続確立のオーバーヘッドもゼロだ。DNS、VoIP、動画ストリーミング、オンラインゲーム、NTP——これらがUDPを使うのは、パケットのロスが200msの再送遅延でユーザー体験が固まってしまうよりもはるかにマシだからだ。
実運用で実際に重要な点を並べると:
- 接続: TCPはハンドシェイクが必要、UDPはコネクションレス
- 信頼性: TCPは失われたパケットを再送、UDPはしない
- 順序: TCPは順序通りに届ける、UDPは順序が前後することがある
- 速度: UDPはオーバーヘッドが少なくレイテンシが低い
- 用途: データの整合性にはTCP、リアルタイム性能にはUDP
セットアップ:テストツールキットの準備
実際に試す前に、いくつかのツールが必要だ。Debian/Ubuntuの場合:
sudo apt update
sudo apt install -y netcat-openbsd tcpdump iproute2 python3
RHEL/CentOS/Fedoraの場合:
sudo dnf install -y nmap-ncat tcpdump iproute python3
準備ができているか確認する:
nc --version
tcpdump --version
ss --version
netcat(nc)はメインのテストツールで、フラグ一つの違いでシェルから直接TCPまたはUDP接続を開ける。tcpdumpはライブトラフィックをキャプチャして、実際のプロトコルヘッダーをワイヤーレベルで確認できる。ss(ソケット統計)は数年前にnetstatの後継となり、負荷の高いシステムでは明らかに高速だ。
実践:TCPとUDPを実際に使ってみる
NetcatでTCPをテストする
一つのターミナルでポート9000のTCPリスナーを開く:
nc -l -p 9000
別のターミナル(または別のマシン)から接続する:
nc 127.0.0.1 9000
何かを入力すると、リスナー側に表示される。裏では、TCPがまず接続をネゴシエートしている。クライアントをCtrl+Cで終了すると、サーバー側にEOFが届く。これがTCPのグレースフルクローズ(FIN/FIN-ACKシーケンス)だ。
NetcatでUDPをテストする
UDPには-uフラグを追加する:
# リスナー
nc -u -l -p 9001
# 送信側(別のターミナル)
echo "hello via udp" | nc -u 127.0.0.1 9001
接続の確立はない。パケットは届くか届かないかのどちらかだ。リスナーを終了してクライアントから送り続けても、エラーは出ない。UDPは相手がいなくなったことを知る手段を持っていないからだ。
ソケットプログラミング:PythonでのTCP vs UDP
APIレベルでの違いは文字通り定数一つだ。最小限のTCPサーバー:
import socket
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: # SOCK_STREAM = TCP
s.bind(('0.0.0.0', 9000))
s.listen(1)
conn, addr = s.accept() # クライアントが接続するまでブロック
with conn:
data = conn.recv(1024)
print(f"受信データ: {data.decode()}")
conn.sendall(b"ACK")
UDPの場合——accept()なし、接続状態なし:
import socket
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: # SOCK_DGRAM = UDP
s.bind(('0.0.0.0', 9001))
data, addr = s.recvfrom(1024) # ハンドシェイクなし、データグラムを待つだけ
print(f"{addr} から受信: {data.decode()}")
s.sendto(b"got it", addr)
SOCK_STREAM vs SOCK_DGRAM——APIレベルでの判断はこれだけだ。信頼性・順序保証・再送といったすべての挙動は、このフラグ一つから自動的に決まる。
アプリケーションに適したプロトコルを選ぶ
シンプルなルール:パケットが一つ失われるだけでユーザー体験が壊れたりデータが破損したりするならTCPを使う。パケットのロスが軽微な乱れに過ぎない——あるいはコンマ数秒後の次の更新で上書きされる——ならUDPが有利だ。
具体的なケース:
- REST API / データベースクエリ: TCP——完全なレスポンスを順序通りに受け取る必要がある
- DNS ルックアップ: UDP——一般的なクエリは512バイト以下で50ms未満で解決する。4096バイトを超えるレスポンス(EDNS)ではTCPにフォールバックする
- ビデオ通話: UDP——30fpsではフレームのドロップはほぼ気づかないが、TCPの500ms再送遅延が発生すると通話が完全に固まる
- ファイル転送(SCP、rsync): TCP——すべてのバイトが完全に届く必要がある
- ゲームの状態同期: UDP——位置情報の更新は次のパケットで上書きされるため問題ない
- ログ転送(syslog): UDPが一般的。ログが稀に欠けても通常は許容範囲内
確認と監視:TCPとUDPの動作をリアルタイムで見る
ssでアクティブな接続を確認する
システム上のすべてのTCP接続を確認する:
ss -tnp
UDPソケット(注:UDPにはESTABLISHEDの概念がなく、未接続ソケットはUNCONNと表示される):
ss -unp
特定のポートでフィルタリングする:
ss -tnp sport = :443
ss -unp dport = :53
tcpdumpでトラフィックをキャプチャする
ポート9000のTCPトラフィックをキャプチャする:
sudo tcpdump -i lo tcp port 9000 -v
データの前に3ウェイハンドシェイク——SYN、SYN-ACK、ACK——がはっきり見える。UDPの場合:
sudo tcpdump -i lo udp port 9001 -v
最初のパケットがそのままデータだ。セットアップもネゴシエーションもない。これがワイヤーレベルで目に見える、本質的な違いだ。
netstatでTCPの状態を確認する(レガシー)
ssが使えない場合:
netstat -tnp | grep ESTABLISHED
netstat -unp
TCPの再送を監視する
本番環境で再送レートが高い場合、通常はネットワーク輻輳かパケットロスがどこかで発生している。以下で確認できる:
ss -s
# または
cat /proc/net/snmp | grep -i retrans
レイテンシに敏感なサービスで再送が増加していたら、追跡する価値がある。輻輳の発生源を特定するか、あるいはそのトラフィックパターンが本当にTCPを必要としているのかを再考するかだ。
TCPとUDPは1983年の4.2BSDからBSDソケットAPIの一部として存在する——40年以上が経った今もなお、あらゆるネットワークスタックの基盤だ。迷わず適切なプロトコルを選び、推測ではなくtcpdumpで仮定を検証できるエンジニアは、本番障害のデバッグを一貫して速く解決し、信頼性とレイテンシがトレードオフになる局面でも的確な判断を下せる。

