Code Day's Night

ichikawayのブログ

自作IPルーターを実装して見えた、tcpdumpに映らないカーネル内部の世界

現在PHPでIPルーターを作り始めています。簡単なものを実装してpingが通るところまで完成しました。

 

次にUDPパケットをルーターに通してみたところ、なぜかうまくいかない現象が発生し、1週間以上調査した結果おもしろい現象だったので、ルーターの話はやめてその現象の話を書きたいと思います。

今回の現象で面白かったところは、tcpdumpでは問題ないダンプデータだったが、色々な要素の組み合わせにより実際のパケット以外のメタデータによって問題が発生していたところでした。

 

前提となる環境

Docker 上に2つのネットワークを作成し、ルーターはNIC2枚構成でコンテナAとBのネットワークにそれぞれ繋がっています。

コンテナAからUDPパケットをコンテナBに向けて送信し、途中自作のIPルータを通り、コンテナBまでUDPパケットは届いていました。(コンテナBでtcpdumpしたらそのパケットは見える)
しかし、なぜかコンテナBのカーネルでUDPパケットは破棄されていて、BのサーバプログラムまでUDPデータが届きませんでした。

そこでルーターコンテナで自作IPルーターではなく LinuxのIP forward機能を使い同じUDPパケットを送ったところ、こちらでは問題は発生せずコンテナBでうまくUDPパケットがプログラムまで届きました。

大きな謎

自作のIPルーター経由でコンテナBまで届いた時のtcpdump結果と、IP forward機能経由でBまで届いた時のtcpdump結果を見ると、UDPパケットは同じ状態でした。

Bの中でみるUDPパケットに差がないのに、なぜか関係ないルーターの違いで現象が異なっている、本当に不思議な状況でした。

問題は解決したが謎は大きく残ったまま

色々調べた結果、コンテナA側でNICのオフロード機能をオフにしたところ、自作IPルーターを経由したUDPパケットでもカーネルで破棄されず、B側のプログラムにデーターが到達しました🎉

オフロード機能は、チェックサム計算をCPUでやらずにNIC側で処理させることでCPU負荷を下げる機能です。

 

つまり、Aから送信するUDPパケットのオフロードがオンだったため、UDPのチェックサムはダミーの値のままNICに渡されてルータ経由でBに送信、コンテナBでカーネルがUDPパケットを受け取るとチェックサム計算でエラーとなりパケットが破棄されているようでした。

ここで大きな疑問がでます。IP forwardを使った場合でも同じようにチェックサム計算はされずBに届くので、自作IPルーターと差はないですよね? にもかかわらずB側でパケットが破棄されたりされなかったり、どうにも謎が残りました。

今回の発生環境

今回の調査を得て分かったこととして、問題が発生する環境は次のものが合わさった場合でした。(そのため多くの環境では発生しません)

  • Docker NetworkやLinuxのNamespaceをつかった仮想ネットワーク環境
  • 送信側はオフロードがオンの状態(UDPパケット作成時はチェックサム計算していない)
  • 自作ルーターを使った場合のみ(IP forwardでは発生しない)

 

原因の解説

まずは問題なくBのプログラムまでUDPが到達したIP forwardの方から解説していきます。

IP forwardではなぜ問題が発生しなかったか

DockerネットワークやLinuxのnamespaceを使ってこのような2つのネットワークを作っていますが、AからRouterのネットワークと、RouterからBのネットワークはveth という仮想的なネットワークインターフェースが使われます。(コンテナから見るとただのNIC eth0が存在しているように見える)

同じvethの中のネットワークのパケットのやりとりは、本当のネットワーク環境のようにNICからデータ(イーサネットフレームなど)が送信されているわけではありません。

AからRouterにデータをsocketを使ってsendすると、仮想的なNIC(veth)を通る時にカーネルが持つSocket Buffer(SKB)という構造体のデータがホストOS側でコピーされてRouterのコンテナに渡されます。

(お前は何を言ってるんだ?という気持ちですよね)

つまり通常のネットワークのパケットでやりとりしている以上のデータがRouterのカーネルに渡されていたのです。Socket Bufferの中には実際のパケットデータの他に、タイムスタンプやオフロード時のチェックサム計算したかどうかのメタ情報が入っています。

そう、チェックサムをどこでやってるか、どう扱って欲しいかという情報がSocket Bufferに入っていて、AのSocket Bufferのチェックサム扱い情報がRouterまで届いていたのです!(な・・なんだってー

そして、IP forwardはlinuxカーネル内で動く機能であり、受け取ったSocket BufferのデータをそのままB側のNICにルーティングしていました。RouterからBの間もvethのためSocket Bufferがコピーされて伝わっていたのです。

 

今回、コンテナAではオフロードがオンのためNIC側でチェックサム計算するのですがvethネットワークの中ではチェックサム計算を全てスキップさせるために 

ip_summed=CHECKSUM_UNNECESSARY 

というメタ情報がセットされます。

そう、このAでつけたメタ情報がDockerやLinux namespaceの特殊な環境でSocket Bufferのコピーという形で伝搬した結果、BのカーネルでUDPチェックサム計算がスキップされて問題なくプログラムに到達したのです。

な・・長いですね解説が。

 

自作IPルーターではなぜ問題が発生したのか

ではなぜ自作IPルーターではB側のカーネルでUDPが破棄されたのでしょうか。

大きな違いは、Routerの中でルーティング機能として動く場所がカーネルではなく、ユーザランドのPHPで処理していたからでした。

ユーザランドまで登ってPHPのsocketで処理されたネットワークのデーターは、カーネルの外に一旦でるためSocket Bufferなどカーネル内部で持っている情報が一度リセットされます。

今回は、Routerの中でip_summedが CHECKSUM_UNNECESSARYからCHECKSUM_NONEに変更されています。

CHECKSUM_NONEは、チェックサム計算はNICではしてないのでカーネルでチェックサムの確認してね、というフラグです。

実際のLinuxのソースコードで見ると、raw.c (RAW socketの処理)でip_summedがCHECKSUM_NONEになってますね。

linux/net/ipv4/raw.c at master · torvalds/linux · GitHub

 

自作ルーターを経由して CHECKSUM_NONE となったSocket BufferがBに届き、B側ではCHECKSUM_NONEのフラグをみてカーネルでUDPのチェックサム計算をしていました。計算するとダミーデータなのでチェックサムエラーとなり破棄された、ということですね。

 

さいごに

今回の問題点の発覚からtcpdumpを使っても原因がわからず、途中からDockerの影響とわかり調査が進みました。

ネットワークプログラミングをしていてtcpdumpで見えない世界があったとは驚いてしまって世界に発信したくなり、このブログ記事を書いています。

おそらくほとんど人がこの記事の途中で離脱したと思うので、だれか一人にでも面白さが伝わると願っています。

ネットワークプログラミングを実際にやってみると思わぬハマりポイントがあり、その調査は1週間や1ヶ月ぐらいかかる時はありますが、その時が一番テンションが上がって面白いのですよね。

自作IPルーターの検証環境でDockerやLinux namespaceを使う時は気をつけよう!

 

補足

vethではなぜSocket Bufferを共有するのでしょうか、できるのでしょうか。

Dockerで同じホストOS上にいるコンテナで同じvethのネットワークに接続している場合は、動いているLinuxカーネルはホストOSのものです。

ホストOSのあるプロセス(コンテナ)の通信を別のプロセスに渡すだけになるため、実際のネットワーク処理(送信キューにいれて仮想NICが処理して相手の受信キューに入る)は不要になります。

であれば、カーネル内部で持っているSocket Bufferをそのまま相手のコンテナに共有すればゼロコピーとなり効率的だから、かと想像します。

相手のコンテナに共有するvethのコードはおそらくこの箇所

https://github.com/torvalds/linux/blob/master/drivers/net/veth.c#L375

loopback(127.0.0.1)の通信はどうなるのか?

vethではなくLinuxのOS上の2つのプロセスが127.0.0.1のloopbackアドレスを使ってローカル通信する場合も同じでしょうか? 

答えばNoでした。

Loopbackアドレスの場合は実際の物理ネットワークの通信に近いためSocket Bufferは共有されません。ただ、効率化のために送信側の送信キューにはデータを入れずに受信キューに直接データを入れるようです。受信キューにいれるSocket Bufferは送信側のSocket Bufferから詰め替えられた新しいもの。

NICオフロードの動き

通常の物理ネットワークでは、オフロードオンの場合はカーネルでUDPパケットを作る際にチェックサムは計算されずダミーの値がセットされます。その時にSocket Bufferのip_summedはCHECKSUM_PARTIAL という値となり、カーネルからNICにデータを渡す時にNIC側でチェックサム計算してUDPヘッダのチェックサムの値が書き換えられます。

vethネットワークの場合は、物理NICがなくオフロード不要ですし、カーネル内でパケットを転送しているのでパケットが欠落することもありません。そのためオフロードオンであってもvethの場合はSocket Bufferのip_summedは常にCHECKSUM_UNNECESSARYとなりチェックサム計算されません(UDPのチェックサムの値もダミーのまま)

送信時にCHECKSUM_UNNECESSARYとなり、相手側の受信キューに同じSocket Bufferがゼロコピーで入れられるので、相手側もCHECKSUM_UNNECESSARYとなり、相手側でもチェックサム計算がスキップできて効率的、ということですね。

 

参考資料やメモ