2019.12.25

XDPでPacket Rewriting – MySQLのポートを書き換える

次世代システム研究室のS.T.です。今回はLinuxカーネルに実装されているパケット処理基盤XDPで,特定ポート宛て通信の宛先ポート書き換えを試してみます。MySQLは脇役です。


はじめに

XDPとは

XDP(eXpress Data Path)はLinux kernel 4.8から実装された<fn>BPF Features by Linux Kernel Version</fn>パケット処理基盤です。古くからカーネルに実装されているパケットフィルタであるBPFを拡張して,カーネル内部の関数にアタッチしてメトリクス収集などを行えるようにしたeBPFを,ネットワークデバイスのドライバに近い関数に適用可能にしたもの,というとイメージがつきやすいかと思います。図1のようなイメージでパケットを処理します。XDPを使うと,カーネルのプロトコルスタックに入る前のパケットを参照・改変することができるため,フィルタリングやロードバランシングなどをより高速に実行することが可能になります<fn>Next steps for Linux Network stack approaching 100Gbit/s</fn>。

図1. XDPのパケットの流れのイメージ図

図1. XDPのパケットの流れのイメージ図


この記事のモチベーション

XDPはDockerコンテナ間のネットワーキングやKubernetesなどのオーケストレータへの応用も期待されており,Cilium<fn>Cilium</fn>のようなプロジェクトも登場しています。次世代システム研究室では既に本番環境にKubernetesを投入・運用しており<fn>Kubernetes をオンプレの本番環境に導入してそろそろ1年経つよっていう話</fn>,この方面への応用は興味深い話題です。さらに,RHEL8ではカーネルのバージョンが4.18ベースとなりXDPが実験的に利用可能となった<fn>Using the eXpress Data Path (XDP) in Red Hat Enterprise Linux 8</fn>(もちろんCentOS 8も)ことから,実際に検証を行うことができる環境が整ってきました。

もちろん,プロダクションで利用するとなった場合でもXDPのコードをゼロから書くということはなく,XDPをつかったネットワークプラグインなどを使うことになるかと思いますが,オンプレミス環境で一定のレギュレーションの範囲で構築・運用している関係から,ベースとなっている技術を試しておくことが思わぬところで役に立つこともあります。そこで,今回はMySQLの通信をお題に,MySQLサーバの設定と異なるTCPポートを指定して接続してきたクライアントの通信を,正しいポートに書き換えるXDPプログラムを作成してみます。

実装していく

今回は,「3307ポートを宛先としてやってきたTCPのパケットを,3306ポート宛てに書き換える」XDPプログラムを実装します。つまり,サーバ側でmysqldが3306ポートで待ち受けているとき,mysql-clientで3307宛てに接続して操作可能な状況を作り出します。もちろん実用的ではないですが,同じ要領でIPアドレスを書き換えたりパケットを再送出したりできるので,ロードバランシングやコンテナルーティングといった最も興味深い内容を理解するのに都合が良い例です。チュートリアル<fn>XDP Tutorial</fn>やLinuxのソースコードのサンプル<fn>torvalds/linux samples/bpf</fn>が参考になります。

開発環境

サンプルコードに入っているVagrantfileをupするだけで,すぐに使用可能な以下の開発環境が立ち上がります。eth1はXDPプログラムをアタッチするために利用するので,ホストPCからは127.0.01:[10022 or 20022]で接続します。
  • mysql-server:eth0 NAT(SSH 10022) / eth1  HostOnly 192.168.33.10
  • mysql-client:eth0 NAT(SSH 20022) / eth1 HostOnly 192.168.33.10
XDPのコード自体はCで実装し,これをコンパイルして得たeBPFバイナリをロードして使用します。Cはもちろんgo,Pythonなどの他の言語からロードすることもできますし,コンパイル済みのオブジェクトファイルをiproute(ipコマンド)でアタッチすることもできます。コンパイルはclangで行うことができます。最終的に使用した開発環境は以下のような構成です(Vagrantfileと同じもの)。
  • CentOS 8
  • Kernel 4.18.0-80.11.2.el8_0.x86_64 or newer
  • clang 7.0.1 or newer
  • mysql-server 8.0.17-3
  • Host:Windows 10 x86_64 / VirtualBox 5.2.10

実装

ネットワークインタフェースにパケットが到着すると,そのパケットが格納されたバッファを指すポインタを引数にXDPの関数が呼び出され,その戻り値に応じてパケットを通す・捨てるなどの処理が行われます。iprouteでアタッチする場合は,progセクション(またはアタッチする際に指定したセクション)の先頭から実行されるので,ここにstruct xdp_md *のポインタを受け取ってintを返す関数を作成します。
__section("prog")
int xdp_rewrite_port(struct xdp_md *ctx)
{
  ...
}
この関数内で必要に応じてパケットのバッファを操作して,以下の戻り値を返すことでその後の動作を決定します<fn>Prototype Kernel’s documentation XDP actions</fn>。
  • XDP_PASS
    • パケットを通す
    • バッファの変更は許されており,変更はそのまま反映される
  • XDP_DROP
    • パケットを破棄する
  • XDP_TX
    • パケットを同じインタフェースから送出する
  • XDP_ABORTED
    • 能動的に返す値ではない
    • eBPFプログラムのエラー時に返される
    • 処理はXDP_DROPと同じ(switch文フォールスルー)
引数で与えられたctxからバッファの先頭と末尾のアドレスを取得することができます。これがそのままイーサネットフレームになっているので,ヘッダの構造体のポインタにキャストして,IPか否か,TCPか否かを判定していきます。今回処理対象なのはTCP 3307ポート宛ての通信なので,それ以外はすべてXDP_PASSを返して本来の処理を続行させます。

また,データの中身を見る際は必ずパケットの末尾を超えていないかのチェックが必要です。これを忘れるとVerifierのチェックにひっかかり,アタッチすることができません。
// 16bitの上位バイトと下位バイトを入れ替える
#define SWAP_ORDER_16(X) ( (((X) & 0xff00) >> 8) | (((X) & 0xff) << 8))

...

void* data = (void*)(long)ctx->data;
void* data_end = (void*)(long)ctx->data_end;

// Ethernetヘッダ検査
struct ethhdr *eth = data;
if (data + sizeof(struct ethhdr) > data_end) {
  return XDP_PASS;
}
__u16 eth_payload_proto = eth->h_proto;
if (eth_payload_proto != SWAP_ORDER_16(ETH_P_IP)) {
  return XDP_PASS;
}

// IPヘッダ検査
struct iphdr *iph = data + sizeof(*eth);
if (data + sizeof(struct ethhdr) + sizeof(struct iphdr) > data_end) {
  return XDP_PASS;
}
__u16 ip_payload_proto = iph->protocol;
if (ip_payload_proto != IPPROTO_TCP) {
  return XDP_PASS;
}

// TCPヘッダ検査
struct tcphdr *tcph = data + sizeof(*eth) + sizeof(*iph);
if (data + sizeof(struct ethhdr) + sizeof(struct iphdr) + sizeof(struct iphdr) > data_end) {
  return XDP_PASS;
}
本来であればCPUとネットワークのバイトオーダの整合性をとるためにはhtonsやntohs関数を使用しますが,eBPFでは(カーネル内部のものであっても)関数の利用に制限がある(別途設定を記述する必要がある)ことと,htonsなどの関数が利用可能なbpf_helpers.hやbpf_endian.hはディストリビューションのパッケージのkernel-headersには含まれておらず準備が面倒なことから,今回は簡易的なマクロで対応しています。

さて,いよいよ宛先ポートを書き換えていきます。上記のコードでXDP_PASSをreturnしていないということは,TCPのパケットであることは確定しています。tcph->destが宛先ポート番号となっているため,ここの値を書き換えれば任意のポート宛てのパケットに改変することができます。また,TCPヘッダを書き換えることになるので,チェックサムの更新も必要です。幸いなことにTCPのチェックサムは1の補数和を使った単純なものなので,もとのチェックサムであるtcph->checkを少し加工することで簡単に計算できます<fn>IETF Tools RFC 1624 Incremental Internet Checksum</fn>。
if (tcph->dest == SWAP_ORDER_16(3307)) {
  tcph->dest = SWAP_ORDER_16(3306);
  unsigned long sum
    = SWAP_ORDER_16(tcph->check) + 3307 + ((~3306 & 0xffff) + 1);
  tcph->check = SWAP_ORDER_16(sum & 0xffff);
}
return XDP_PASS;
パケット改変後はそのままXDP_PASSを返すことで,rewriteされたパケットがカーネルのスタックに送りこまれます。あとは通常のフローで3306で待ち構えているmysqldが処理してくれます。

サーバ側は以上なのですが,クライアント側も忘れてはいけません。サーバ側は3306で通信している気分でいるので,当然返信の送信元も3306となります。一方で,クライアント側は3307からの返信を期待しているわけですから,このままではうまくいきません。そこで,クライアント側もXDPで送信元のポートをrewriteします。簡単のために今回はサーバ用のファイルをコピーして,宛先書き換え部分以外は流用します。tcph->sourceを書き換えることに注意してください。
if (tcph->source == SWAP_ORDER_16(3306)) {
  tcph->source = SWAP_ORDER_16(3307);
  unsigned long sum
    = SWAP_ORDER_16(tcph->check) + 3306 + ((~3307 & 0xffff) + 1);
  tcph->check = SWAP_ORDER_16(sum & 0xffff);
}

動かしてみる

まず,clangでtargetをbpfにしてコンパイルします。
clang -O2 -Wall -target bpf -c xdp_mysql_server.c -o xdp_mysql_server.o
clang -O2 -Wall -target bpf -c xdp_mysql_client.c -o xdp_mysql_client.o
mysql-server,mysql-clientの両ノードで,eth1にアタッチします。これで両ノードのTCP 3306-3307が繋がります。
[mysql-server]$ sudo ip link set dev eth1 xdp obj xdp_mysql_server.o
[mysql-client]$ sudo ip link set dev eth1 xdp obj xdp_mysql_client.o
次に,MySQLの起動と初期設定を済ませます。
[mysql-server]$ sudo systemctl start mysqld
[mysql-server]$ mysql_secure_installation
# 適当にパスワードの設定などを済ませる
[mysql-server]$ mysql -uroot -p
-- ユーザ名 freeuser パスワード freepass でユーザをつくっておく
mysql > CREATE USER 'freeuser'@'192.168.33.%' IDENTIFIED BY 'freepass';
あとはfreeuserで3307ポートで接続を試みて,うまくつながれば成功です。
[mysql-client]$ mysql -u freeuser -h 192.168.33.10 -P 3307 -p
tcpdumpでのぞき見してみると,サーバ側に届くパケットの宛先は3306,クライアント側に届くパケットの送信元は3307に書き換えられていることがわかります。
## Server
[mysql-server]$ sudo tcpdump -nn -i eth1 port 3306
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on eth1, link-type EN10MB (Ethernet), capture size 262144 bytes
05:49:20.047577 IP 192.168.33.20.54960 > 192.168.33.10.3306: Flags [S], ...
05:49:20.047613 IP 192.168.33.10.3306 > 192.168.33.20.54960: Flags [S.], ...
05:49:20.047722 IP 192.168.33.20.54960 > 192.168.33.10.3306: Flags [.], ...

## Client
[mysql-client]$ sudo tcpdump -nn -i eth1 port 3307
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on eth1, link-type EN10MB (Ethernet), capture size 262144 bytes
05:49:20.047376 IP 192.168.33.20.54960 > 192.168.33.10.3307: Flags [S], ...
05:49:20.047589 IP 192.168.33.10.3307 > 192.168.33.20.54960: Flags [S.], ...
05:49:20.047602 IP 192.168.33.20.54960 > 192.168.33.10.3307: Flags [.], ...
最後にeth1からモジュールをデタッチします。
[mysql-server]$ sudo ip link set dev eth1 xdp off
[mysql-client]$ sudo ip link set dev eth1 xdp off

細かい話

XDPはデバイスドライバの関数でフックされる関係上,非対応のデバイスドライバでは利用できませんでしたが,Linux kernel 4.12で実装された「Generic XDP」により,非対応のデバイスでも検証可能になりました。もちろんパフォーマンスは正式に対応されているデバイスには劣るので,プロダクション利用の場合はこの辺りの問題も考慮する必要があります。

今回は詳しく触れていませんが,XDP(eBPF)のプログラムには命令数やジャンプなどに関して厳しい制約があり条件を満たさないものはアタッチする際にVerifierに弾かれてしまいます。本格的な機能の実装は一般的なアプリケーションよりも気を使う部分が多く,実装コストは比較的高めです。

おわりに

実際にXDPでパケットの書き換えを行ったことで,XDPをDockerのネットワーキングに応用する際も内部でどのような処理が行われているのかイメージがつきやすくなりました。今回のサンプルでは単一の関数内で処理が完結していますが,実用的なアプリケーションでは処理を関数で切り出したり,動的にIPアドレスやポートの変換先を変更したり,ユーザ空間のアプリケーションとデータをやりとりしたりする必要があります。引き続きウォッチ・検証していきたい技術だと思います。

最後に、次世代システム研究室では、グループ全体のインテグレーションを支援してくれるアーキテクトを募集しています。アプリケーション開発の方、次世代システム研究室にご興味を持って頂ける方がいらっしゃいましたら、ぜひ募集職種一覧からご応募をお願いします。

皆さんのご応募をお待ちしています。

Pocket

関連記事