Code Day's Night

ichikawayのブログ

自作IPルーター実装時に「Message too long」はなぜ起きたのか ― Linux / AF_PACKET / GRO・GSO が生む“見えない巨大パケット”の正体 ―

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
  1. A から Raspberry Pi へ MTU1500 の正常な Ethernet フレームが届く
  2. NIC で受信(正常)
  3. カーネル内部で GRO が働き、複数の TCP セグメントが 1 つの skb に合体
  4. AF_PACKET が GRO 後の巨大 skb を読む
  5. それを L2 フレームだと信じて socket_write() する
  6. 送信 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 でも良い