virtio-netデバイスを自前実装してTAP経由でホストと通信する——ping疎通で動作確認する

virtio-mmioとvirtqueueを自前実装し、virtio-consoleで動作確認するまで実装したvirtqueueの読み書きロジックを土台に、今回はvirtio-netデバイスを実装します。virtio-consoleとの違いはデバイス種別(DEVICE_ID)と、descriptor chainの中身の解釈だけです。virtio-consoleでは受け取ったバイト列をそのままホストの標準出力へ書き出していましたが、今回はホスト側に用意したTAPデバイスへ橋渡しすることで、ゲストVMのネットワークインターフェースとホストのネットワークを実際につなぎます。

TAPデバイスとVMMの役割

TAPデバイスは、Linuxがソフトウェアだけで提供する「イーサネットケーブルの片側」のようなインターフェースです。tap0のような名前で通常のNICと同じようにIPアドレスを設定したりリンクアップしたりできますが、反対側の端子に何をつなぐかはアプリケーション側が/dev/net/tunを開いてread()/write()するだけで自由に決められます。

今回作ったVMMは、この「ケーブルの反対側」の役割を担います。

  • ゲストが仮想NIC(virtio-net)からフレームを送信 → VMMがTAPへwrite()
  • ホストがTAP宛にフレームを送信 → VMMがTAPからread()してゲストの受信キューへ書き込む

TAPデバイス自体の作成(TUNSETIFF)にはCAP_NET_ADMINが必要で、通常は root権限が要ります。ただし一度rootでpersistentなTAPデバイスを作り、所有者を自分のユーザーに設定しておけば、以降はVMMプロセスを非rootのまま起動してもTUNSETIFFで名前指定してattachできます。今回は次のように事前準備しました。

sudo ip tuntap add dev tap0 mode tap user $(whoami)
sudo ip link set tap0 up
sudo ip addr add 192.168.100.1/24 dev tap0

この事前準備をせずに非rootのままTUNSETIFFを呼ぶと、ioctl TUNSETIFF: Operation not permittedになります。/dev/net/tun自体は誰でも読み書きできるパーミッションですが、新規デバイスの作成権限とは別ということです。

fn open_tap(name: &str) -> File {
    let path = CString::new("/dev/net/tun").unwrap();
    let fd = unsafe { libc::open(path.as_ptr(), libc::O_RDWR) };
    // ...
    let mut ifr = IfReqFlags {
        ifr_name: [0; libc::IFNAMSIZ],
        ifr_flags: (libc::IFF_TAP | libc::IFF_NO_PI) as i16,
        _pad: [0; 22],
    };
    ifr.ifr_name[..name.len()].copy_from_slice(name.as_bytes());
    unsafe { libc::ioctl(fd, libc::TUNSETIFF, &mut ifr) };
    unsafe { File::from_raw_fd(fd) }
}

IFF_NO_PIは、フレームの前に4バイトのプロトコル情報ヘッダを付けない指定です。これを付けないと、TAPから読み書きするデータの先頭に余計なヘッダが挟まり、virtio-net側のフレーム処理と噛み合わなくなります。

MACアドレスの通知とvirtio_net_hdr

virtio-mmioのconfig space(オフセット0x100以降)を使って、デバイス固有の情報をゲストへ渡せます。今回はVIRTIO_NET_F_MAC(機能ビット5)を有効にし、config spaceの先頭6バイトにMACアドレスを置きました。

0x010 => {
    if self.device_features_sel == 1 {
        (VIRTIO_F_VERSION_1 >> 32) as u32
    } else {
        VIRTIO_NET_F_MAC as u32
    }
}
// ...
0x100 => GUEST_MAC[0] as u32,
0x101 => GUEST_MAC[1] as u32,
// ...(0x105まで1バイトずつ)

virtio-netのキューでやり取りするデータには、実際のイーサネットフレームの前にvirtio_net_hdrという固定長ヘッダが付きます。チェックサムオフロードやTCP Segmentation Offloadなどの機能を一切ネゴシエーションしていないため、今回はこのヘッダの中身を意識する必要はほぼありません。唯一注意が必要なのはヘッダ長で、VIRTIO_F_VERSION_1を使う場合はnum_buffersフィールドを含む12バイト固定になります(legacyかつVIRTIO_NET_F_MRG_RXBUF未使用の場合は10バイト)。この12バイトという数値をNET_HDR_LENとして、TX/RX双方の処理で使っています。

TX/RXという2つの方向

ここから先は、ゲストの送受信キューをそれぞれTX・RXと呼びます。ネットワーク機器の世界でよく使われる略語で、TX(Transmit、送信)・RX(Receive、受信)ともにそのデバイス自身から見た向きを指します。つまり、ゲストのvirtio-net(仮想NIC)を主語にすると、TXは「ゲストが外へ送るデータ」、RXは「ゲストが外から受け取るデータ」です。前回のvirtio-consoleでもqueue 1をTX、queue 0をRXとして扱っており、今回もその番号を引き継いでいます。

TX: ゲストの送信キューをTAPへ橋渡しする

virtio-mmioとvirtqueueを自前で実装するで実装したdescriptor chainの読み取りロジックはそのまま使い、集めたバイト列の解釈だけを変更しました。descriptor chain全体を1つのバッファへ結合し、先頭12バイト(virtio_net_hdr)を取り除いた残りをそのままイーサネットフレームとしてTAPへ書き込みます。

let mut desc_index = head;
let mut frame = Vec::new();
loop {
    // ...(descriptor読み取り)
    frame.extend_from_slice(&buf);
    if flags & VIRTQ_DESC_F_NEXT == 0 {
        break;
    }
    desc_index = next;
}

if frame.len() > NET_HDR_LEN {
    let _ = (&*tap).write(&frame[NET_HDR_LEN..]);
}

RX: 別スレッドでTAPを待ち受ける

virtio-consoleでは受信キュー(queue 0)を一切使いませんでしたが、今回は逆方向(ホスト→ゲスト)のデータも扱う必要があります。TAPからのread()はデータが来るまでブロックするため、vCPUの実行ループ(vcpu.run())と同じスレッドで待ち受けることはできません。そこで、TAP受信専用のスレッドを1本立てました。

fn run_rx_thread(
    tap: Arc<File>,
    guest_memory: Arc<GuestMemoryMmap<()>>,
    vm: Arc<VmFd>,
    device: Arc<Mutex<VirtioNet>>,
) {
    let mut buf = [0u8; 1600];
    loop {
        let n = match (&*tap).read(&mut buf) {
            Ok(n) => n,
            Err(_) => continue,
        };
        device.lock().unwrap().inject_rx(&guest_memory, &vm, &buf[..n]);
    }
}

デバイスの状態(VirtioNet)はメインスレッド(TX・レジスタアクセス)とRXスレッドの両方から触るためArc<Mutex<_>>で共有し、TAPのFileArc<File>で共有しています。FileRead/Writeをどちらも&Fileに対して実装しているため、読み込み専用のRXスレッドと書き込み専用のメインスレッドが同じfdへロックなしで同時にアクセスできます。

受信したフレームの先頭にvirtio_net_hdr(num_buffers=1以外は全ゼロ)を付け、ゲストが積んでいる受信バッファの先頭descriptorへ書き込みます。

fn inject_rx(&mut self, guest_memory: &GuestMemoryMmap<()>, vm: &VmFd, frame: &[u8]) {
    let vq = &mut self.queues[0];
    if !vq.ready || vq.num == 0 {
        return;
    }
    // ...(avail ringから空きバッファのdescriptorを取得)

    let mut packet = vec![0u8; NET_HDR_LEN];
    packet[10..12].copy_from_slice(&1u16.to_le_bytes()); // num_buffers = 1
    packet.extend_from_slice(frame);

    let write_len = packet.len().min(cap as usize);
    guest_memory.write_slice(&packet[..write_len], GuestAddress(addr)).unwrap();
    // ...(used ring更新・割り込み配送)
}

ゲストが受信用バッファを1つも積んでいない場合は、フレームをそのまま捨てています。今回の実装は1descriptor=1バッファ構成のみに対応しており、VIRTIO_NET_F_MRG_RXBUFのような複数バッファへのマージには対応していません。

initramfsへのvirtio_netモジュール追加

ホストのvirtio_netはモジュール(=m)としてビルドされており、前回使ったvirtio-consoleのように組み込み(=y)ではありません。initramfs内のRust製initから、finit_moduleシステムコールで明示的にロードする必要があります。

fn insmod(path: &str) {
    let file = File::open(path).unwrap();
    let params = CString::new("").unwrap();
    unsafe {
        libc::syscall(libc::SYS_finit_module, file.as_raw_fd(), params.as_ptr(), 0)
    };
}

virtio_net.konet_failover.koに、net_failover.kofailover.koに依存しているため、次の順序でロードします。

insmod("/lib/modules/failover.ko");
insmod("/lib/modules/net_failover.ko");
insmod("/lib/modules/virtio_net.ko");

いずれもホストの/usr/lib/modules/$(uname -r)/以下からinitramfs/lib/modules/へコピーしたものです。

ゲスト側でのIPアドレス設定

initramfsにbusyboxのようなネットワーク設定ツールを入れていないため、eth0へのIPアドレス割り当てもSIOCSIFADDR/SIOCSIFFLAGSをRustから直接ioctlして行っています。DHCPクライアントは使わず、固定IP(192.168.100.2/24)を設定するだけの最小構成です。

fn configure_eth0(ip: [u8; 4], netmask: [u8; 4]) {
    let sock = unsafe { libc::socket(libc::AF_INET, libc::SOCK_DGRAM, 0) };
    // SIOCSIFADDR, SIOCSIFNETMASK でIP/ネットマスクを設定
    // SIOCSIFFLAGS で IFF_UP を立ててリンクアップ
}

動作確認

ホスト側でtap0192.168.100.1/24を設定した状態でVMMを起動し、ゲストがeth0 upのログを出したタイミングで、別ターミナルからホストの192.168.100.2(ゲストのIP)へpingを打ちました。

$ ping 192.168.100.2
PING 192.168.100.2 (192.168.100.2) 56(84) bytes of data.
64 bytes from 192.168.100.2: icmp_seq=1 ttl=64 time=0.825 ms
64 bytes from 192.168.100.2: icmp_seq=2 ttl=64 time=0.156 ms
...

ホストのARPテーブルにも、config spaceで渡したMACアドレスが正しく登録されました。

$ ip neigh show 192.168.100.2
192.168.100.2 dev tap0 lladdr 02:00:00:00:00:01 REACHABLE

これは、以下がすべて正しく動作した結果です。

  1. ホストが送ったARP要求がTAP経由でゲストへ届く(RX)
  2. ゲストがARP応答を送信し、TAP経由でホストへ届く(TX)
  3. ホストが送ったICMP echo requestがゲストへ届く(RX)
  4. ゲストのTCP/IPスタックが自動でecho replyを送り返す(TX)

virtqueueの読み書き、config spaceでのMACアドレス通知、TAPとの橋渡し、割り込み配送までが、送受信どちらの方向でも一通りつながったことを確認できました。

現時点の実装では、TAPはホスト内だけのインターフェースでNAT/ルーティングは設定しておらず外部ネットワークへは出られません。また受信バッファは1descriptor構成のみに対応しており、IPアドレスもDHCPではなく固定設定です。ゲスト内で動くアプリケーションへホストからアクセスする、といった実践的な確認は、virtio-block(フェーズ12)を経た後のフェーズ15で改めて扱う予定です。