bobuhiro11/gokvm - GitHub

はじめに

gokvm開発 1 2 3 4 の続き。

最近の一連の開発によって、gokvm 上のVMに virtio-net によって仮想NICを提供することができた。 ネットワーキングのサポートは当初の目標の一つだったので、達成感がある。 この対応によって gokvm 上のVMはホスト(あるいはソフトウェアスイッチを経由して外部)との間で通信できるようになった。 WEBサーバを提供したり、SSHでログインできたり、と出来ることの幅が広がる大きな変更だと思う。 例によって、重要なコミットを抜き出して振り返りたい。

c5217550 Virt Queue データ構造の追加

そもそも Virt Queue とは何なのか。 Virt Queue は ゲスト・ホスト間におけるデータのやり取りに使うリング構造のキューを意味する。 例えば送受信でそれぞれ1つのキューを使うナイーブな virtio-net の場合には、 送受信それぞれ1つの Virt Queue (全体で合わせて2つのVirt Queue)が必要になる。 もちろん、マルチキューをサポートする場合やコントロールキューをサポートする場合には、さらに Virt Queue が必要になる。

1つの Virt Queue は Descripter Table、Avail Ring、Used Ring から構成される。 取り扱いたいデータのアドレスと長さを1つのディスクリプタとしてまとめ、それをテーブル状に並べたものが Descripter Table である。 Avail Ring と Used Ring は似ていて、どちらもディスクリプタのIDをゲスト・ホスト間で 伝え合うために利用される。 方向も決まっていて、Avail Ring がゲストからホスト宛、Used Ring がホストからゲスト宛 となる。 ちなみに virtio 仕様 5 の中では、ゲストをdriver、ホストをdevice と表現している。

dc12c9a5 Virt Queueのロード

さて Virt Queue はどこに存在しているのだろうか、そしてホスト側からどのように参照・書き込みすれば良いのだろうか。 実は Virt Queue はゲストの物理アドレス上に存在する領域で、ゲストのデバイスドライバ(要確認)がその領域の確保を担当する。 ゲストのカーネルが Virtio デバイスの Probe 処理を行う中で、Virtio Header 6 の QUEUE_PFNに書き込むことによって、 ホストに Virt Queue のゲスト物理メモリアドレスを伝えることができる 4。 もちろん複数のVirt Queue を初期化するために、QUEUE_SELを変更しながら、同様の処理を繰り返す。

出典:BitVisorのvirtio-netドライバの解説

無事 Virtioデバイスの Probe 処理が完了すると、ゲストから何らかのパケットが飛んでくるので、 Queue Notify でそれを検知して、Virt Queue の中のDescripter Table を見ると、 実際にパケットデータが入っていることを確認できる。

Queue Notify was written!
{{Addr:0xf092568, Len:0xa, Flags:0x1, Next:0x1}, ... }

71d91aeb Tapインターフェイスを生成

VMM が Virt Queue 経由でゲストから読み出したパケットをホスト(あるいはソフトウェアスイッチ)に伝えるにはいくつか方法がありそうだが、 ここではシンプルに Tap インターフェイスを利用した 7。 あるプログラムからTapインターフェイスを作ると、そのプログラムはread(2)、write(2)システムコールで パケットを読み書きできる。また、ホストカーネルはTapインターフェイスをネットワークサブシステムに登録するので、 ホストから見ると通常のNIC同様にパケットを送受信できる。 今回は、C言語で書かれた以下のサンプルコードを移植した。

#include <linux/if.h>
#include <linux/if_tun.h>

int tun_alloc(char *dev)
{
    struct ifreq ifr;
    int fd, err;

    if( (fd = open("/dev/net/tun", O_RDWR)) < 0 )
       return tun_alloc_old(dev);

    memset(&ifr, 0, sizeof(ifr));

    // Flags: IFF_TUN   - TUN device (no Ethernet headers)
    //        IFF_TAP   - TAP device
    //
    //        IFF_NO_PI - Do not provide packet information
    //
    ifr.ifr_flags = IFF_TUN;
    if( *dev )
       strscpy_pad(ifr.ifr_name, dev, IFNAMSIZ);

    if( (err = ioctl(fd, TUNSETIFF, (void *) &ifr)) < 0 ){
       close(fd);
       return err;
    }
    strcpy(dev, ifr.ifr_name);
    return fd;
}

14ae4091 ゲストが送ったパケットをホストで受信

ゲストがホストにパケットを送る場合には、まずDescripter Tableにパケットに相当するエントリを追加する。 その後 Avail Ring にそのディスクリプタのIDを挿入する。 そして Virtio Header の QUEUE_NOTIFY フィールドに書き込みことでホストに通知する。 ホストはAval Ringの処理状況を調べ、過去に処理していないエントリが見つかれば、それを取り出し、 ディスクリプタを経由して、最終的にパケットデータを取り出す。

詳細は省くが、1つのディスクリプタに収まらなサイズのデータを扱うときや、ゲスト物理メモリアドレス空間で連続しないデータを 扱うときには、複数のディスクリプタをLinked List の要領で繋げて1まとまりとして扱うこともある。

ここでハマってしまったのが、キューのサイズ。これは Virt Queue の Descripter Table、Avail Ring、Used Ring のそれぞれの エントリ数のことなのだが、これが少なすぎるが故に一定以上のパケットを送れないという事象に陥っていた。 カーネルのメッセージには何も表示されなかったため、解決には時間がかかってしまった。

原因はキューサイズが小さすぎることだった。 対象のコード片をカーネルのコードから抜き出すと以下になる 8 。 ここで MAC_SKB_FRAGS が 16 なので、Virt Queue に空きが 16+2 個以上でなければ、 パケットの送信が停止してしまうように読み取れる。 実際にキューサイズを8から32に増やすと正常にパケットを送ることができるようになった。

1694 static netdev_tx_t start_xmit(struct sk_buff *skb, struct net_device *dev)
1695 {

1748         if (sq->vq->num_free < 2+MAX_SKB_FRAGS) {  ここで停止
1749                 netif_stop_subqueue(dev, qnum);

8bbafbd5 Tapインターフェイスの改善

今後、Tap インターフェイスからパケットを取り出し、それをホストからゲストに送り出すにあたって、 事前に Tap インターフェイスを使いやすくしておきたい。 まず、Tap デバイスに wirte、read メソッドを追加して、io.readWriterインターフェイス(Golangのインターフェイスのこと)を 満たすよう実装を進めた。後々、テストコードを書くするときに、Golangのインターフェイスによる抽象化の恩恵を受けられる。 また、Tap インターフェイスからパケットを取り出すには read(2) システムコールを利用するが、デフォルトの挙動では ブロッキングIOになるので、パケットが存在するまでそのスレッド(goroutine)は実行がブロックしてしまう。 そこで、fcntl(2)システムコールで Tap インターフェイスのファイルディスクリプタに対して、ノンブロッキングでIOできるよう設定した。 これによって、もしパケットが存在しないときには EAGAIN が即座に返ってくるので扱いやすくなる。 また、パケットを受信されたタイミングで SIGIO シグナルを発火できるよう設定した。

3bcebf7b Tx専用goroutineを追加

ゲストからホストへパケットを送る際、これまでは Virtio Header の QUEUE_NOTIFY に書き込まれる度に そのスレッド(goroutine)で Tap インターフェイスへの書き込みを行なっていた。 この部分をTx専用goroutineとして、別のコンテキストに分離させた。 goroutine間の通知は txKick chan interface{} のように Golang の流儀に従い、チャネルで実装した。

6a9710ea Rx専用goroutineを追加

ここまで実装が進んでいれば、実はホストからゲストへのパケット送信は素直に実現できる。 この方向のデータ転送では、Avail Ring と Used Ring の使い方が少し特殊になる。

Avail Ring はゲストからホストへ空きバッファを通知するために使われれる。 この空きバッファはゲストカーネル(ドライバ)が用意するもので、ゲスト物理アドレスとその長さを ディスクリプテーブルに登録して、そのIDをAvail Ring に登録する。 ホスト側は、Avail Ring から必要な個数のディスクリプタを取り出し、パケットデータをコピーし、 順次 Used Ring に追加するだけで良い。 この辺りはゲスト側ドライバの仕事が大きく、ホスト側の仕事は少ない(データを書いてインクリメントするだけ)。

外部からTap インターフェイスにパケットが着弾したタイミングで SIGIO が発火されるので、
signal.Notify(res.rxKick, syscall.SIGIO) のような形でシグナルをチャネルに変換し、 Rx 専用 goroutine に通知を送れば良いので、ポーリングを避けることができる。

双方向で通信できるようになったので、以下のように ping による動作チェックができた。

on guest:
  $ ip addr add 192.168.1.1/24 dev eth0
  $ ip link set eth0 up

on host:
  $ sudo ip link set tap up
  $ sudo ip addr add 192.168.1.2/24 dev tap
  $ sudo ping 192.168.1.1 -i 0.1 -c 3
    PING 192.168.1.1 (192.168.1.1) 56(84) bytes of data.
    64 bytes from 192.168.1.1: icmp_seq=1 ttl=64 time=17.4 ms
    64 bytes from 192.168.1.1: icmp_seq=2 ttl=64 time=19.6 ms
    64 bytes from 192.168.1.1: icmp_seq=3 ttl=64 time=20.7 ms

    --- 192.168.1.1 ping statistics ---
    3 packets transmitted, 3 received, 0% packet loss, time 201ms
    rtt min/avg/max/mdev = 17.440/19.246/20.702/1.354 ms

e9d54ab9 ブラックボックステストの追加

VMM を使って仮想マシンを起動し、Tap インターフェイスを使ってホスト側と Ping による通信ができることを、ブラックボックステストとして追加し、CIによる自動テストをできるようにした。

終わりに

思ったより時間はかかったが、無事動くところまで持っていくことができた。 ネットワーキングをサポートできるようになったので、この自作VMMを使ってできる幅が大きく広がった。 virtio の基礎的な挙動を把握することができたので virtio-blk などの実装にも応用できると思う。 粛々と実装を進めていきたい。