PHPのsocket拡張を使ってLinuxで動作するIPルーターを自作しています。 実機で動作確認している際に、データーを転送したところで「Message too long」のエラーがたまに出ていました。 今回はその原因を探った記事です。
今回の環境は次のようになっています。Host AとBは異なるサブネットに属していて、その間に自作のIPルーターを置いています。
Host A -> 自作IP Router -> Host B
TL;DR
- ワイヤ上(LANケーブル)では MTU 1500 の正常な Ethernet フレーム
- Linux カーネル内部で GRO/GSO により TCP 単位で合体
- AF_PACKET は 合体後の巨大 skb をそのまま渡す
- それを別 NIC に L2 で書き戻そうとして EMSGSIZE(Message too long) が発生
1. 発端:Message too long
自作ルータ(AF_PACKET 使用)で、「受け取った Ethernet フレームをそのまま別 NIC に転送する」という処理を行っていたところ、次のエラーが発生した。
PHP Warning: socket_write(): unable to write to socket [90]: Message too long
- MTU は 1500
- Jumbo Frame も VLAN も使っていない
- 単なる L2 転送
一見すると 起きるはずがない エラーだった。
2. Message too long(EMSGSIZE)とは何か
Linux における EMSGSIZE は次の意味を持つ。
このソケット種別・このインターフェースでは、そのサイズのメッセージは送れない
AF_PACKET + Ethernet の場合、
- socket_write() は 「このバイト列を 1 つの Ethernet フレームとして送れ」 という意味になる
- 送信 NIC の MTU を超えると 即座に EMSGSIZE
重要なのは、
- IP 的に正しいかどうかは関係ない
- L2 サイズが MTU を超えた瞬間に失敗する
という点
3. 実際に観測された「異常なパケット」
tshark(WiresharkのCLI版) で観測すると、次のようなフレームが見つかった。
- Frame Length: 1696 bytes
- IP Total Length: 1682
- TCP Segment Length: 1630
- DF(Don't Fragment): set
- Fragment Offset: 0
これは、
- MTU1500 環境では ワイヤ(LANケーブル)に存在できないサイズ
- IP フラグメントでもない
つまり ワイヤ上には流れていない TCP/IP パケット だった。
4. どこで巨大化したのか?
答えは Linux カーネル内部 にあった
受信skb(1460) + 受信skb(1460) + 受信skb(1460)
│
└── GRO ──▶ 1つの巨大skb(≈4380)
skbはsocket bufferというもので、NICから入ってきたデータをそこで管理しています。実際のデータ以外にメタ情報も持っていて、GROやチェックサムの有無などが記録されています。
5. GRO / GSO とは何か
GRO(Generic Receive Offload)
- 受信側の最適化
- 同一 TCP フローの複数パケットを カーネル内部で 1 つの skb に合体
GSO(Generic Segmentation Offload)
- 送信側の最適化
- MTU を超える skb を 後で分割される前提で扱う
重要なのは、どちらもNICで受信したデータではなくカーネル上で管理するデータという点
6. 実際に起きていたこと(時系列)
[ A ] ──(Ethernet MTU1500)──▶ [ Raspberry Pi ]
|
| ① NIC受信(正常)
v
skb(1500)
|
| ② GRO
v
skb(1696) ← TCP単位で合体
|
| ③ AF_PACKET で read
v
ユーザー空間(PHP)
|
| ④ socket_write()
v
別NIC (MTU1500)
|
✗ EMSGSIZE
- A から Raspberry Pi へ MTU1500 の正常な Ethernet フレームが届く
- NIC で受信(正常)
- カーネル内部で GRO が働き、複数の TCP セグメントが 1 つの skb に合体
- AF_PACKET が GRO 後の巨大 skb を読む
- それを L2 フレームだと信じて socket_write() する
- 送信 NIC の MTU を超え EMSGSIZE になる
7. GRO=on のとき、元の Ethernet フレームは読めるのか?
条件付きで読めない。
- GRO が適用されると、元の skb は破棄され 合体後の skb のみが残る
- AF_PACKET は「これは GRO 後の塊だ」という文脈を知らず、skb のデータをそのままユーザー空間に渡す
結果として、L2 だと思って読んだら、実は L4 再構成後という状況になる。
8. GRO/GSOが適用されたか判断する方法
- AF_PACKET と PACKET_AUXDATA を利用して skb が GRO/GSO かどうかを判断できる
PACKET_AUXDATA とは?
Linux の AF_PACKET には、データとは別に「補助情報」を渡す仕組みがある。
setsockopt(fd, SOL_PACKET, PACKET_AUXDATA, &one, sizeof(one));
これを有効にすると、recvmsg() で: - パケット本体(iov) - 補助制御メッセージ(cmsg) が一緒に届く。
ただ、PHPではこれらのデータを取得できるかは実験していないため不明です。
9. 解決策 GRO / GSO を off(今回の解)
ethtool -K eth0 gro off gso off
これでカーネルで行われるGRO/GSOをオフにできため、PHPのsocketからreadできる単位は1つのイーサネットフレームになった。 AF_PACKET で L2 を扱うなら、これが最も単純で確実。
10. 教訓
AF_PACKET は skb を信じる。skb は必ずしもワイヤを表していない。
設計指針は次の通り。
- L2 を扱う → GRO/GSO off
- L3/L4 を扱う → GRO/GSO on でも良い