はじめに — 小さなリクエストは通り、大きな応答だけ止まります
症状がとりわけ特徴的な障害があります。
- ping は100パーセント成功し、往復時間も正常です。
- ヘルスチェックのエンドポイントはきちんと応答します。
- ところが一覧取得 API のように応答が少し大きくなると、リクエストがそのまま止まります。エラーでもなく、ただ止まります。
- ssh は接続できるのに、大きなディレクトリで
ls -laを打つと画面が凍りつきます。 - git clone は進捗がある地点で止まったまま終わりません。
- あるサイトは TLS ハンドシェイクの段階で止まります。
この組み合わせを見たら、ほかを見る必要はありません。経路 MTU の問題です。そしてたいていは VPN、トンネル、オーバーレイネットワークを新しく付けたか、経路が変わった直後に現れます。
なぜサイズによって結果が分かれるのか、そしてどうすれば数分で確定できるのかをまとめます。
MTU と MSS — 40バイトの算数
MTU は一つのインターフェースが一度に送り出せる IP パケットの最大サイズです。イーサネットの既定値は1500バイトです。
TCP はこの値からヘッダーを引いて、自分が載せられるデータのサイズを計算します。IPv4 ヘッダー20バイト、TCP ヘッダー20バイトを引くと1460バイトです。この値が MSS であり、ハンドシェイクの SYN パケットにオプションとして載って相手に伝わります。
ip link show ens5
2: ens5: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq state UP mode DEFAULT group default qlen 1000
link/ether 06:1f:2a:3b:4c:5d brd ff:ff:ff:ff:ff:ff
sudo tcpdump -nn -i any 'tcp[tcpflags] & tcp-syn != 0' -c 2
14:22:03.104512 IP 10.0.1.20.51244 > 10.20.0.5.443: Flags [S], seq 2841029371, win 62727, options [mss 1460,sackOK,TS val 33012 ecr 0,nop,wscale 7], length 0
14:22:03.106880 IP 10.20.0.5.443 > 10.0.1.20.51244: Flags [S.], seq 991044820, ack 2841029372, win 65160, options [mss 1460,sackOK,TS val 88103 ecr 33012,nop,wscale 7], length 0
ここから決定的な事実が一つ出てきます。どちらの側も自分のインターフェースの MTU だけを見て MSS を決めます。経路の途中にもっと狭い区間があるかどうかは誰も知りません。上のキャプチャで二つのホストは1460バイトのセグメントをやり取りすると合意しましたが、途中に1420バイトのトンネルがあれば、その合意は守られません。
小さなリクエストが成功する理由がここにあります。ヘルスチェックの応答は200バイトのセグメント一つで終わるので、狭い区間を無事に通過します。ping も既定のペイロードが56バイトなので常に通ります。サイズが閾値を超えたパケットだけが消えるので、症状がサイズによって分かれます。
TLS ハンドシェイクで止まる場合も同じ理由です。ClientHello は小さいのですが、サーバーが送る証明書チェーンは3千から5千バイトあり、ただちに最大サイズのセグメントになります。だから接続はできるのにハンドシェイクが終わらない、という形になります。
PMTUD ブラックホールが作られる正確な過程
TCP はもともとこの問題を解決する仕組みを持っています。パス MTU 探索です。順序はこうです。
- Linux は TCP パケットに DF(Don't Fragment)ビットを既定で立てます。
- 中間ルーターが自分の次ホップの MTU より大きい DF パケットを受け取ると、分割できないのでパケットを捨てます。
- 代わりに送信者へ ICMP タイプ3コード4、すなわち Fragmentation Needed を送ります。このメッセージには次ホップの MTU 値が入っています。
- 送信者のカーネルはその値を経路別のキャッシュに保存し、セグメントを小さく切って送り直します。
正常に動作すればこう見えます。
ip route get 10.20.0.5
10.20.0.5 via 10.0.1.1 dev ens5 src 10.0.1.20 uid 1000
cache expires 588sec mtu 1420
mtu 1420 がキャッシュに入ったということは、パス MTU 探索が動作したという意味です。
問題は3段階目です。ICMP をすべて遮断したファイアウォール、ICMP を応答しないルーター、ICMP を元の送信者に返せない非対称経路があると、このメッセージが消えます。すると送信者は何も知らないまま大きなパケットを送り続け、そのパケットは捨てられ続けます。再送も同じサイズなのでやはり捨てられます。この状態を PMTUD ブラックホールと呼びます。
ブラックホールの特徴はエラーが出ないことです。接続は ESTABLISHED のまま残り、アプリケーションは応答を待って自分のタイムアウトにかかってようやく終わります。そのため「ネットワークは正常なのにアプリケーションが遅い」という誤った結論につながります。
ここで広く広まった誤解を正しておく必要があります。「セキュリティのために ICMP はすべて塞ぐ」という方針は、それ自体が障害要因です。塞いでよいのは echo request と echo reply くらいであり、タイプ3コード4は必ず通さなければなりません。IPv6 では Packet Too Big、すなわち ICMPv6 タイプ2が同じ役割を果たし、こちらには選択の余地すらありません。
ICMP が実際に届いているか確認します。
sudo tcpdump -nn -i any 'icmp[icmptype] == 3 and icmp[icmpcode] == 4'
14:31:02.884411 IP 10.0.1.1 > 10.0.1.20: ICMP 10.20.0.5 unreachable - need to frag (mtu 1420), length 556
この行が見えれば PMTUD は生きています。大きなパケットを送り続けているのにこの行が一度も見えなければブラックホールです。
Linux にはブラックホールを自分で検知する機能があります。既定では無効です。
sysctl net.ipv4.tcp_mtu_probing
net.ipv4.tcp_mtu_probing = 0
# 1: ブラックホールが疑われるときだけ探索、2: 常に探索
sudo sysctl -w net.ipv4.tcp_mtu_probing=1
値を1にしておくと、再送が繰り返されるときにカーネルがセグメントサイズを自分で小さくしながら、通るサイズを探します。根本的な解決ではありませんが、原因が分からない状態でサービスを生かしておくには効果的です。
トンネルが持っていくバイト
経路が狭くなる原因はたいていカプセル化です。元のパケットを別のヘッダーで包むので、内側に使える空間が減ります。数字を覚えておくと診断が速くなります。
| カプセル化方式 | 追加ヘッダーのサイズ | 1500 基準の実効 MTU | 推奨 MSS クランプ値 |
|---|---|---|---|
| PPPoE | 8バイト | 1492 | 1452 |
| GRE | 24バイト | 1476 | 1436 |
| VXLAN (IPv4 外側) | 50バイト | 1450 | 1410 |
| Geneve (オプションなし) | 50バイト以上 | 1450 以下 | 1410 以下 |
| WireGuard (IPv4 外側) | 60バイト | 1440 | 1400 |
| WireGuard (IPv6 外側) | 80バイト | 1420 | 1380 |
| IPsec ESP トンネルモード | 最大およそ73バイト | 1427 | 1387 |
| IPsec ESP + NAT-T | 最大およそ81バイト | 1419 | 1379 |
いくつか注意すべき点があります。
wg-quick が WireGuard インターフェースに既定で1420を設定する理由は、IPv6 の外側ヘッダーまで見込んだ安全値だからです。IPv4 だけで使うなら1440も可能ですが、エンドポイントが IPv6 に変わった瞬間に壊れるので、1420 のままにしておくほうがよいでしょう。
IPsec は暗号アルゴリズムとパディングのせいでオーバーヘッドが固定されていません。そのため最悪値を基準に取ります。AES-CBC はブロックパディングのため可変で、AES-GCM は相対的に小さくなります。
クラウドには逆方向の落とし穴があります。AWS のほとんどのインスタンスタイプは VPC 内部で MTU 9001 を使います。同じ VPC の中ではうまく動きますが、インターネットゲートウェイや VPN を通る瞬間に1500以下へ減ります。そのため「内部通信は問題ないのに外部 API 呼び出しだけ大きな応答で止まる」という形で現れます。
経路 MTU を二分探索で見つける
最も確実な方法は直接測定することです。DF ビットを立て、サイズを調節しながら通るかどうかを見ます。
# -M do: DF ビットを立てる, -s: ICMP ペイロードのサイズ
ping -M do -s 1472 -c 1 10.20.0.5
PING 10.20.0.5 (10.20.0.5) 1472(1500) bytes of data.
From 10.0.1.1 icmp_seq=1 Frag needed and DF set (mtu = 1420)
--- 10.20.0.5 ping statistics ---
1 packets transmitted, 0 received, +1 errors, 100% packet loss
運がよければこうしてルーターが正確な MTU を教えてくれます。ICMP が遮断された経路ならただの無応答になるので、自分で絞り込む必要があります。
for s in 1472 1420 1400 1392 1380 1372; do
printf '%5d bytes payload (MTU %5d): ' "$s" "$((s + 28))"
if ping -M do -s "$s" -c 1 -W 1 10.20.0.5 >/dev/null 2>&1; then
echo OK
else
echo FAIL
fi
done
1472 bytes payload (MTU 1500): FAIL
1420 bytes payload (MTU 1448): FAIL
1400 bytes payload (MTU 1428): FAIL
1392 bytes payload (MTU 1420): OK
1380 bytes payload (MTU 1408): OK
1372 bytes payload (MTU 1400): OK
ここでの28は ICMP ヘッダー8バイトと IPv4 ヘッダー20バイトです。通過する最大ペイロードが1392なので、経路 MTU は1420です。そうすると TCP の MSS は1420から40を引いた1380であるべきです。
tracepath を使えば一度で出ることもあります。どのホップで減るのかまで見せてくれます。
tracepath -n 10.20.0.5
1?: [LOCALHOST] pmtu 1500
1: 10.0.1.1 0.412ms
1: 10.0.1.1 0.318ms
2: 10.0.1.1 0.286ms pmtu 1420
2: 10.60.4.1 8.114ms
3: 10.20.0.5 8.902ms reached
Resume: pmtu 1420 hops 3 back 3
pmtu 1420 が2番目のホップで現れました。その地点がトンネルの入口です。
カーネルがすでに学習した値があるかも確認します。
ip route get 10.20.0.5
ip route show cache
誤って学習された値を消すにはキャッシュを空にします。
sudo ip route flush cache
MSS クランプが実務の正解である理由
経路が狭いことが分かりました。あとは直すだけです。候補は三つあります。
一つめ、インターフェースの MTU を下げます。
sudo ip link set dev ens5 mtu 1420
この方法はそのホストが送るパケットにだけ効果があります。そして相手が送るパケットも制限はします。こちらが広告する MSS が減るからです。問題は適用範囲です。狭い区間が二つのルーターの間のトンネルで、両端に数百台のサーバーと数千台のクライアントがあるなら、すべての機器の MTU に手を入れることはできません。
二つめ、ICMP を通してパス MTU 探索を生かします。原則として最も正しい解法であり、必ず併行すべきです。ただし経路途中のファイアウォールが我々の管理外である場合が多く、インターネットの向こう側のどこかで ICMP が塞がれていることまでは手を出せません。
三つめ、MSS クランプです。トンネルを通過するルーターが、通過する SYN パケットの MSS オプション値を直接書き換えます。
# 経路 MTU に合わせて自動で合わせる
sudo iptables -t mangle -A FORWARD -p tcp --tcp-flags SYN,RST SYN \
-j TCPMSS --clamp-mss-to-pmtu
# トンネルインターフェースへ出るトラフィックだけ固定値で制限する
sudo iptables -t mangle -A FORWARD -p tcp --tcp-flags SYN,RST SYN \
-o wg0 -j TCPMSS --set-mss 1380
# nftables 表記
sudo nft add rule inet mangle forward tcp flags syn tcp option maxseg size set rt mtu
この方式が実務の標準になった理由ははっきりしています。
ハンドシェイクの SYN と SYN-ACK だけ触ればよいのでコストが事実上ありません。そして MSS は「自分が受け取れる最大サイズ」を知らせる値なので、双方向の SYN を両方書き換えれば、両側とも小さなセグメントしか送らなくなります。何より ICMP にまったく依存しないので、ブラックホール環境でも確実に動作します。
適用後はキャプチャで確認します。
sudo tcpdump -nn -i wg0 'tcp[tcpflags] & tcp-syn != 0' -c 2
15:02:11.104512 IP 10.60.1.20.51302 > 10.20.0.5.443: Flags [S], seq 118203993, win 62727, options [mss 1380,sackOK,TS val 41120 ecr 0,nop,wscale 7], length 0
15:02:11.112880 IP 10.20.0.5.443 > 10.60.1.20.51302: Flags [S.], seq 771102384, ack 118203994, win 65160, options [mss 1380,sackOK,TS val 90221 ecr 41120,nop,wscale 7], length 0
両側の mss が1380に変わりました。
注意すべき点もあります。MSS クランプは TCP にだけ適用されます。UDP ベースのプロトコルは保護されません。QUIC ベースの HTTP/3、大きな応答を UDP で受け取る DNS、WireGuard の中のさらに別の UDP トンネルがこれに該当します。QUIC は自前で DPLPMTUD を行い既定の1200バイトを安全値として使うのでおおむね耐えますが、UDP を直接扱うアプリケーションはデータグラムのサイズを自分で制限しなければなりません。
Kubernetes の CNI と IPv6 で変わる点
Kubernetes ではこの問題がオーバーレイネットワークのせいで定期的に再発します。Flannel VXLAN、Calico VXLAN、Cilium の VXLAN や Geneve モードがいずれもカプセル化を使います。
症状は特徴的です。
- Pod どうしの ping は通り、小さなリクエストもうまくいきます。
- 大きな本文を送る POST リクエストだけ止まります。
kubectl execは通るのに、ログの多い Pod のkubectl logsが途中で止まります。- イメージのプルは始まるのに、大きなレイヤーで止まります。
確認はインターフェースの MTU 比較から始めます。
# ノードの物理インターフェース
ip link show ens5 | head -1
# オーバーレイインターフェース
ip link show flannel.1 | head -1
# Pod の中
kubectl exec -it deploy/api -- ip link show eth0 | head -1
2: ens5: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq state UP mode DEFAULT group default qlen 1000
5: flannel.1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UNKNOWN mode DEFAULT group default
3: eth0@if42: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default
ここに誤りがあります。VXLAN インターフェースである flannel.1 の MTU がノードと同じ1500です。VXLAN は50バイトを持っていくので1450であるべきです。Pod の eth0 も1450であるべきです。
正常な状態はこうです。
2: ens5: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 ...
5: flannel.1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1450 ...
3: eth0@if42: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1450 ...
CNI に MTU を明示するのが最も確実です。自動検出に任せると、ノードのインターフェースが変わったりクラウドがジャンボフレームを使ったりしたときに食い違います。
# Flannel: net-conf.json
{
"Network": "10.244.0.0/16",
"Backend": { "Type": "vxlan", "MTU": 1450 }
}
# Calico: 設定された MTU を確認
kubectl get configmap -n kube-system calico-config -o jsonpath='{.data.veth_mtu}'
# Cilium: 検出された値を確認
kubectl exec -n kube-system ds/cilium -- cilium-dbg status --verbose | grep -i mtu
AWS でノードが9001を使っているのに CNI が1500になっている場合は問題になりませんが、逆にノードが1500なのに CNI が9001になっていると即座にブラックホールになります。ノードの MTU をまず確認し、その値からカプセル化のオーバーヘッドを引くという順序を守るべきです。
IPv6 は規則が違います。三つ覚えておけば十分です。
一つめ、IPv6 のルーターはパケットを分割しません。IPv4 では DF ビットがなければルーターが自分で切って送れましたが、IPv6 ではそんな選択肢がそもそもありません。分割は送信ホストだけができます。
二つめ、だからこそ ICMPv6 の Packet Too Big、タイプ2メッセージが必須です。これを塞ぐと IPv6 通信は IPv4 より確実に壊れます。ICMPv6 のフィルタリング指針は RFC 4890 にまとめられており、タイプ2は必ず許可対象です。
三つめ、IPv6 の最小 MTU は1280バイトです。どの経路でも最低限このサイズは保証されるので、原因が分からない状態で一時的にサービスを生かす必要があるなら、1280が安全な下限です。
# IPv6 の経路 MTU 測定
ping6 -M do -s 1232 -c 1 2001:db8::5
# IPv6 の ICMP 到達可否を確認
sudo tcpdump -nn -i any 'icmp6 and ip6[40] == 2'
15:18:44.201188 IP6 2001:db8:1::1 > 2001:db8:2::20: ICMP6, packet too big, mtu 1400, length 1240
おわりに — サイズによって分かれるなら MTU を疑ってください
診断の出発点は症状の形です。接続はできるのにサイズが大きくなるときだけ止まるなら、そしてエラーもなくただ止まるなら、ほかを見る前に経路 MTU を測るべきです。コマンド一つで済みます。
ping -M do -s 1472 -c 1 宛先アドレス
これが失敗して小さなサイズは成功するなら確定です。
覚えることは三つです。TCP は自分のインターフェースだけを見て MSS を決めるので、経路途中のボトルネックを自分では知りようがありません。それを教える唯一の仕組みが ICMP タイプ3コード4であり、そのメッセージを塞いだ瞬間に静かなブラックホールが作られます。そして我々が経路全体を制御できないからこそ、トンネル入口での MSS クランプが現実的な正解になります。
トンネルやオーバーレイを新しく導入するときは、オーバーヘッドの計算と MSS クランプを最初から設計に入れておくのがよいでしょう。あとから見つけると症状があまりに曖昧で、何日も費やすことになります。
현재 단락 (1/151)
症状がとりわけ特徴的な障害があります。