外部記憶装置

外付け記憶装置

Mewz on libkrun - その9 TSI の仕組み

Mewz (WebAssembly x Unikernel) を libkrun で動かしてみた - 外部記憶装置 の詳細を記すシリーズ

目次

TSI とは

Transparent Socket Impersonation (TSI) は、ゲストOS側の TCP/IP スタックに代わり virtio-vsock 経由で TCP/IP のソケットを実現する likbrun の機能である。 TSI が用いられる背景としては、他のコンテナと共存するためにホスト側のアドレスを利用したいという需要があるのではないかと思う。 ゲストが virtio-net を用いる場合は、passt によりいったん接続を終端してホスト側のアドレスを使う構造となっているが性能面において難があるため TSI が実装されたと考えられる。 詳細は以下の記事で解説されている。

rheb.hatenablog.com

同記事より、TSI の全体図を引用する。

https://rheb.hatenablog.com/entry/libkrun-networking より

ゲストOSでは、AF_INET で作成されたソケットについて、内部の TSI 専用モジュールで処理を行う。 connect(2) や bind(2) に相当する処理が呼ばれたとき、TSI モジュールは libkrun に対して接続先やバインドするポートの情報を制御用の vsock 経由で通知しつつ、通信用の vsock を作成する。 接続後については、 send(2), recv(2) に対応する処理が呼ばれると通信用の vsock に対して送受信を行う。

libkrun では、制御用の vsock 経由の通知に応じてホスト上で AF_INET なソケットの確保や接続処理、vsock ソケットへの通信の中継を担う。

このように、TSI の要素としては virtio-vsock 上に構築される通信中継と、既存の AF_INET なソケットの枠組みへの統合という2点が中心である。 virtio-vsock については前回実装したため、今回は TSI における通信の制御方法をまとめる。

TSI における通信の制御

connect 時の流れ

connect 時は以下のような流れで TcpProxy を作成し、Proxy が AF_INET なソケットと通信用 vsock 間の中継を行う。

listen 時の流れ

listen 時は libkrun 起点で通信用 vsock を新しく作成するため、複雑なフローとなっている。

VSOCK_TYPE_DGRAM による制御通信

libkrun では TSI の制御通信に独自の VSOCK_TYPE_DGRAM = 3 を用いる。 これはコネクションレスな通信を提供するものであり、UDP のパケロスが生じないものが概念としては類似している。

https://github.com/naoki9911/libkrun/blob/ec84848039177fb37da6716255b545b8d2f5c8e3/src/devices/src/virtio/vsock/mod.rs#L111

libkrun では、ポートに応じて制御通信の用途を切り替えている。

https://github.com/naoki9911/libkrun/blob/ec84848039177fb37da6716255b545b8d2f5c8e3/src/devices/src/virtio/vsock/muxer.rs#L458-L492

    pub(crate) fn send_dgram_pkt(&mut self, pkt: &VsockPacket) -> super::Result<()> {
...
     pkt.dst_port() {
            defs::TSI_PROXY_CREATE => self.process_proxy_create(pkt), // 1024 番
            defs::TSI_CONNECT => self.process_connect(pkt), // 1025 番
            defs::TSI_GETNAME => self.process_getname(pkt), // 1026 番
            defs::TSI_SENDTO_ADDR => self.process_sendto_addr(pkt), // 1027 番
            defs::TSI_SENDTO_DATA => self.process_sendto_data(pkt), // 1028 番
            defs::TSI_LISTEN => self.process_listen_request(pkt), // 1029 番
            defs::TSI_ACCEPT => self.process_accept_request(pkt), // 1030 番
            defs::TSI_PROXY_RELEASE => self.process_proxy_release(pkt),   // 1031 番
            _ => {
                if pkt.op() == uapi::VSOCK_OP_RW {
                    self.process_dgram_rw(pkt);
                } else {
                    error!("unexpected dgram pkt: {}", pkt.op());
                }
            }
        }

        Ok(())
    }

TSI_PROXY_CREATE

TSI では、まず AF_INET なソケットに対応するプロキシを作成する。 制御用の vsock 経由で下記のパケット(TsiProxyCreate)をホスト(libkrun) に対して送信する。

https://github.com/naoki9911/libkrun/blob/ec84848039177fb37da6716255b545b8d2f5c8e3/src/devices/src/virtio/vsock/packet.rs#L96-L100

#[repr(C)]
pub struct TsiProxyCreate {
    pub peer_port: u32,
    pub _type: u16,
}

peer_port はゲスト側が作成した通信用 vsock のローカルポートを指定する。 likbrun の内部では、peer_port から TcpProxy の管理用 id を生成する。 _type は通信の種類(SOCK_STREAM, SOCK_DGRAM) を指定する。

https://github.com/naoki9911/libkrun/blob/ec84848039177fb37da6716255b545b8d2f5c8e3/src/devices/src/virtio/vsock/muxer.rs#L272-L316

            match req._type {
                defs::SOCK_STREAM => {
                    debug!("vsock: proxy create stream");
                    let id = (req.peer_port as u64) << 32 | defs::TSI_PROXY_PORT as u64;
                    match TcpProxy::new(
                        id,
                        self.cid,
                        defs::TSI_PROXY_PORT,
                        req.peer_port,
                        pkt.src_port(),
                        mem.clone(),
                        queue.clone(),
                        self.rxq.clone(),
                    ) 

これにより、libkrun 内部で TcpProxy ないしは UdpProxy が作成され通信用 vsock からのデータを libkrun が持つ AF_INET なソケットから送受信する準備が整う。

TSI_PROXY_RELEASE

Proxy について、作成されたものはゲスト側のソケットの close 時に削除する必要がある。 削除についても制御通信経由で行う。 削除するためには下記のパケット(TsiReleaseReq)を制御通信経由で送信する。

https://github.com/naoki9911/libkrun/blob/ec84848039177fb37da6716255b545b8d2f5c8e3/src/devices/src/virtio/vsock/packet.rs#L176-L180

#[repr(C)]
pub struct TsiReleaseReq {
    pub peer_port: u32,
    pub local_port: u32,
}

作成時と異なり、local_port を指定する必要がある。 これは通信用 vsock のホスト側(libkrun) のローカルポートに対応する。 このようになっている理由としては、ゲスト側で accept を行い接続を受け入れると、その都度新しい Proxy が作成されるためである。 新しく作られた接続に関する通信用の vsock の libkrun 側のローカルポートはランダムに生成され、それが Proxy の管理 id として用いられる。

https://github.com/naoki9911/libkrun/blob/ec84848039177fb37da6716255b545b8d2f5c8e3/src/devices/src/virtio/vsock/muxer_thread.rs#L114-L136

        if let Some((peer_port, accept_fd)) = update.new_proxy {
            let local_port: u32 = thread_rng.gen_range(1024..u32::MAX);
            let new_id: u64 = (peer_port as u64) << 32 | local_port as u64;
            let new_proxy = TcpProxy::new_reverse(
                new_id,
                self.cid,
                id,
                local_port,
                peer_port,
                accept_fd,
                self.mem.clone(),
                self.queue.clone(),
                self.rxq.clone(),
            );

TsiReleaseReq を受け取ると、libkrun は Proxy の削除を行う。

TSI_CONNECT

ゲストから TSI を用いた接続を行う場合、制御通信で接続先のエンドポイントに関する情報を通知する必要がある。 まず、ゲストから libkrun に対して制御通信で下記のパケット(TsiConnectReq)を送信する。

https://github.com/naoki9911/libkrun/blob/ec84848039177fb37da6716255b545b8d2f5c8e3/src/devices/src/virtio/vsock/packet.rs#L102-L107

#[repr(C)]
pub struct TsiConnectReq {
    pub peer_port: u32,
    pub addr: Ipv4Addr,
    pub port: u16,
}

peer_port は TSI_CREATE_PROXY と同様に通信用 vsock のゲスト側ポートである。 addr, port はそれぞれ接続先エンドポイントの情報を示している。

ゲスト側が TsiConnectReq を送出したのち、libkrun 側では AF_INET なソケットを用いて接続を試みる。

https://github.com/naoki9911/libkrun/blob/ec84848039177fb37da6716255b545b8d2f5c8e3/src/devices/src/virtio/vsock/tcp.rs#L366-L403

        if self.status == ProxyStatus::Connecting {
            update.polling = Some((self.id, self.fd, EventSet::IN | EventSet::OUT));
        } else {
            if self.status == ProxyStatus::Connected {
                update.polling = Some((self.id, self.fd, EventSet::IN));
            }
            self.push_connect_rsp(result);
        }

接続処理が完了し、接続成功か失敗か判明した時点で、ゲスト側に対してその結果を含むパケットを制御用 vsock 経由で返送する。

https://github.com/naoki9911/libkrun/blob/ec84848039177fb37da6716255b545b8d2f5c8e3/src/devices/src/virtio/vsock/packet.rs#L109-L112

#[repr(C)]
pub struct TsiConnectRsp {
    pub result: i32,
}

ゲスト側はこのパケットの受信をもって接続処理が完了したとみなし、結果に応じて後続の処理を行う。

TSI_LISTEN

ゲスト側で listen を行う場合、bind するポートやそれを受け入れる通信用 vsock のポートを通知する必要がある。 ゲスト側は下記のパケット(TsiListenReq)をlibkrun に対して送信する。

https://github.com/naoki9911/libkrun/blob/ec84848039177fb37da6716255b545b8d2f5c8e3/src/devices/src/virtio/vsock/packet.rs#L147-L155

#[repr(C)]
#[derive(Debug)]
pub struct TsiListenReq {
    pub peer_port: u32,
    pub addr: Ipv4Addr,
    pub port: u16,
    pub vm_port: u32,
    pub backlog: i32,
}

peer_port はこれまでと同じように通信用 vsock のゲスト側のローカルポートを指定する。 addr と port については AF_INET なソケットを bind(2) するためのアドレスを指定する。 vm_port については、新たに受け入れた接続を中継する先となる通信用 vsock のゲスト側ローカルポートを指定する。

TsiListenReq を受け取った likbrun 側では、bind(2) と listen(2) を行う。 この時、ゲスト側のポートをホスト側で公開するポートマッピングの設定に従い、実際にはマップにおけるホスト側のポートに bind(2) を行う。

https://github.com/naoki9911/libkrun/blob/ec84848039177fb37da6716255b545b8d2f5c8e3/src/devices/src/virtio/vsock/tcp.rs#L174-L219

    fn try_listen(&mut self, req: &TsiListenReq, host_port_map: &Option<HashMap<u16, u16>>) -> i32 {
        if self.status == ProxyStatus::Listening || self.status == ProxyStatus::WaitingOnAccept {
            return 0;
        }

        let port = if let Some(port_map) = host_port_map {
            if let Some(port) = port_map.get(&req.port) {
                *port
            } else {
                return -libc::EPERM;
            }
        } else {
            req.port
        };

        match bind(
            self.fd,
            &SockaddrIn::from(SocketAddrV4::new(req.addr, port)),
        ) 

ポートマップの設定はAPIとして用意されている krun_set_port_map 経由で行う。 https://github.com/naoki9911/libkrun/blob/ec84848039177fb37da6716255b545b8d2f5c8e3/examples/chroot_vm.c#L270-L277

    const char *const port_map[] =
    {
        "18000:8000",
        0
    };
...
    // Map port 18000 in the host to 8000 in the guest (if networking uses TSI)
    if (cmdline.net_mode == NET_MODE_TSI) {
        if (err = krun_set_port_map(ctx_id, &port_map[0])) {
            errno = -err;
            perror("Error configuring port map");
            return -1;
        }

bind(2), listen(2) 完了後、libkrun はゲストに対して TsiListenRsp パケットを返送し完了通知を行う。

https://github.com/naoki9911/libkrun/blob/ec84848039177fb37da6716255b545b8d2f5c8e3/src/devices/src/virtio/vsock/packet.rs#L157-L161

#[repr(C)]
#[derive(Debug)]
pub struct TsiListenRsp {
    pub result: i32,
}

TSI_ACCEPT

accept についてはこれまでと異なるフローとなっている。 まず、ゲスト側から libkrun に対して下記のパケットを送信し、新しい接続の有無を確認する。

https://github.com/naoki9911/libkrun/blob/ec84848039177fb37da6716255b545b8d2f5c8e3/src/devices/src/virtio/vsock/packet.rs#L163-L168

#[repr(C)]
#[derive(Debug)]
pub struct TsiAcceptReq {
    pub peer_port: u32,
    pub flags: u32,
}

libkrun 側では、pending となっている接続が存在する、ないしはノンブロックな場合はそれに応じて TsiAcceptRes を返送する。

https://github.com/naoki9911/libkrun/blob/ec84848039177fb37da6716255b545b8d2f5c8e3/src/devices/src/virtio/vsock/tcp.rs#L546-L563

    fn accept(&mut self, req: TsiAcceptReq) -> ProxyUpdate {
        debug!("accept: id={} flags={}", req.peer_port, req.flags);

        let mut update = ProxyUpdate::default();

        if self.pending_accepts > 0 {
            self.pending_accepts -= 1;
            self.push_accept_rsp(0);
            update.signal_queue = true;
        } else if (req.flags & libc::O_NONBLOCK as u32) != 0 {
            self.push_accept_rsp(-libc::EWOULDBLOCK);
            update.signal_queue = true;
        } else {
            self.status = ProxyStatus::WaitingOnAccept;
        }

        update
    }

https://github.com/naoki9911/libkrun/blob/ec84848039177fb37da6716255b545b8d2f5c8e3/src/devices/src/virtio/vsock/packet.rs#L170-L174

#[repr(C)]
#[derive(Debug)]
pub struct TsiAcceptRsp {
    pub result: i32,
}

ここで、AF_INET 側の accept(2) の処理について追いかける。 Listen を行うと、libkrun 側では Proxy に対応するワーカースレッドが立ち上がり、接続待機状態になる。 Proxy の process_event では、epoll(7) で通知されたイベントに応じて accept(2) を行う。

https://github.com/naoki9911/libkrun/blob/ec84848039177fb37da6716255b545b8d2f5c8e3/src/devices/src/virtio/vsock/tcp.rs#L727-L737

                match accept(self.fd) {
                    Ok(accept_fd) => {
                        update.new_proxy = Some((self.peer_port, accept_fd));
                    }
                    Err(e) => warn!("error accepting connection: id={}, err={}", self.id, e),
                };

新しい接続を受け入れると、それに対応した Proxy を作成し、ゲスト側の通信用 vsock に対して VSOCK_OP_REQUEST を送信し接続処理を開始する。

https://github.com/naoki9911/libkrun/blob/ec84848039177fb37da6716255b545b8d2f5c8e3/src/devices/src/virtio/vsock/muxer_thread.rs#L114-L136

            let local_port: u32 = thread_rng.gen_range(1024..u32::MAX);
            let new_id: u64 = (peer_port as u64) << 32 | local_port as u64;
            let new_proxy = TcpProxy::new_reverse(
                new_id,
                self.cid,
                id,
                local_port,
                peer_port,
                accept_fd,
                self.mem.clone(),
                self.queue.clone(),
                self.rxq.clone(),
            );
            self.proxy_map
                .write()
                .unwrap()
                .insert(new_id, Mutex::new(Box::new(new_proxy)));
            if let Some(proxy) = self.proxy_map.read().unwrap().get(&new_id) {
                proxy.lock().unwrap().push_op_request();
            };

ゲスト側では対応する通信用 vsock が新しい接続を受け入れ、VSOCK_OP_RESPONSE を返す。 libkrun では、VSOCK_OP_RESPONSE を受け取ると enqueue_accept で状態に応じて TsiAcceptRes を返すか、処理待ちとして pending_accepts をインクリメントする。

https://github.com/naoki9911/libkrun/blob/ec84848039177fb37da6716255b545b8d2f5c8e3/src/devices/src/virtio/vsock/tcp.rs#L617-L626

    fn enqueue_accept(&mut self) {
        debug!("enqueue_accept: control_port: {}", self.control_port);

        if self.status == ProxyStatus::WaitingOnAccept {
            self.status = ProxyStatus::Listening;
            self.push_accept_rsp(0);
        } else {
            self.pending_accepts += 1;
        }
    }

https://github.com/naoki9911/libkrun/blob/ec84848039177fb37da6716255b545b8d2f5c8e3/src/devices/src/virtio/vsock/packet.rs#L157-L161

#[repr(C)]
#[derive(Debug)]
pub struct TsiListenRsp {
    pub result: i32,
}

機能としては AF_INET で受け入れた接続について通信用 vsock で先んじて接続を行い、accept については API として新規接続の有無の確認のみ行っていることになる。

TSI_GETNAME

getpeername(2) に相当する機能を提供する。 ゲスト側はlibkrun に対して TsiGetnameReq パケットを送信する。

https://github.com/naoki9911/libkrun/blob/ec84848039177fb37da6716255b545b8d2f5c8e3/src/devices/src/virtio/vsock/packet.rs#L114-L119

#[repr(C)]
pub struct TsiGetnameReq {
    pub peer_port: u32,
    pub local_port: u32,
    pub peer: u32,
}

peer_port, local_port については TSI_PROXY_RELEASE と同じである。 peer については未使用であるため、使途は不明である。

libkrun は対応する AF_INET なソケットに対して getpeername(2) を実行し、その結果を TsiGetnameRsp に入れて返す。

https://github.com/naoki9911/libkrun/blob/ec84848039177fb37da6716255b545b8d2f5c8e3/src/devices/src/virtio/vsock/packet.rs#L121-L127

#[repr(C)]
#[derive(Debug)]
pub struct TsiGetnameRsp {
    pub addr: Ipv4Addr,
    pub port: u16,
    pub result: i32,
}

TSI_SENDTO_ADDR, TSI_SENDTO_DATA

これらは UDP なソケットにおける sendto(2) に相当する機能を提供する。 TcpProxy では無視される。

TSI_SENDTO_ADDR で送信先を指定し、TSI_SENDTO_DATA でデータ本体を送信する。

TSI_SENDTO_ADDR では下記のパケット(TsiSendtoAddr)に送信先アドレスを入れてlibkrunに対して送信する。

https://github.com/naoki9911/libkrun/blob/ec84848039177fb37da6716255b545b8d2f5c8e3/src/devices/src/virtio/vsock/packet.rs#L139-L145

#[repr(C)]
#[derive(Debug)]
pub struct TsiSendtoAddr {
    pub peer_port: u32,
    pub addr: Ipv4Addr,
    pub port: u16,
}

libkrun 内部では、指定したアドレスが sendto 送信先アドレスとして登録される。 また、AF_INET なソケットがbind(2)されていない場合は bind(2) も実行する。

TSI_SENDTO_DATA については、パケットに含まれるデータ全体をペイロードとして UdpProxy から送信する。

まとめ

TSI は vsock 経由で AF_INET なソケットを実現する libkrun の機能である。 ゲストOS は virtio-vsock 経由で libkrun の TSI モジュールと制御通信を行うことでその機能を利用できる。 今回はその制御周りをまとめた。 次回は Mewz への実装を行う。

Mewz on libkrun - その8 Mewz 追加実装(virtio-vsock)

Mewz (WebAssembly x Unikernel) を libkrun で動かしてみた - 外部記憶装置 の詳細を記すシリーズ

目次

Virtio Socket (virtio-vsock) のドライバ実装

virtio-vsock 向けのドライバの実装はいたってシンプルである。 Virtqueue としては rx, tx, event の3つを実装すればよい。 rx, tx キューについては、それぞれ後述の vsocket の処理層へ受け渡す、受け取る機構を用意すればよい。 event キューについては、VM のマイグレーションによりゲスト側の CID が変わった場合など、トランスポート層の処理自体がリセットされた場合に VIRTIO_VSOCK_EVENT_TRANSPORT_RESET を受け取ることになる。 今回は VM のマイグレーションについては考慮していないため、event キューの処理は行わない方針とした。

https://docs.oasis-open.org/virtio/virtio/v1.3/csd01/virtio-v1.3-csd01.html#x1-4800006

実装としてはこれまでの virtio デバイスと変わらないため、詳細は省略する。

github.com

なお、ホスト側では受け口として /dev/vhost-vsock を用いている。 これを用いることで、カーネル側で virtio-vsock について接続処理等を行い、ユーザープロセスから AF_VSOCK を指定することでソケットAPI経由の操作を行うことができる。

ソケットの vsock への対応

vsock ソケットの実装

前回説明したとおり、virtio-vsock においてはドライバそのものよりその上に構築されるソケットの実装が重要となる。 Mewz への実装にあたり、すでに用意されているソケットの仕組みを利用しつつ下図のような vsock 向けのソケットを実装した。

実装については、前回まとめた接続処理に対応する動作を実装すればよい。 vsock の自体は以下のファイルにまとめられている。

github.com

構造としては、VsockMuxer が受け取ったパケットを対応する VsockSocket に対して受け渡し、各 vsock ソケットの状態を含めて管理する形となっている。 この時、どのポートに bind されているか、listen, 接続状態にあるかなどを管理し、意図しない挙動であれば VIRTIO_VSOCK_OP_RST を返すといった基本的なエラー処理も実装している。

詳細については process_rx 関数に記述されたハンドラを理解しやすい。

github.com

既存ソケット・WASI API との統合

既存ソケットへ統合は stream.zig でストリーム系のAPIをまとめて管理している部分に vsock も追加する形で行った。

https://github.com/naoki9911/mewz/blob/6d6b946b67d9fe19353b784535f1b4f5561ebe13/src/stream.zig#L89-L94

pub const Stream = union(enum) {
    uart: void,
    socket: Socket,
    vsock: VSocket,
    opened_file: OpenedFile,
    dir: Directory,

Stream union が内部的にソケット API に相当する関数群を提供しているため、それぞれ対応する vsock.zig の実装を呼び出す形で実装した。

WASI API との統合については、AddressFamily に追加で AF_VSOCK に相当する VSOCK を追加し、各種 API においても vsock 用の実装を追加した。 なお、AF_VSOCK の値については Linux の実装と合わせている。

https://github.com/naoki9911/mewz/blob/6d6b946b67d9fe19353b784535f1b4f5561ebe13/src/wasi/types.zig#L339-L344

pub const AddressFamily = enum(i32) {
    Unspec = 0,
    INET4 = 1,
    INET6 = 2,
    VSOCK = 40,
};

WASI API に対応する実装については現状すべては網羅できていない。 例えば、connect(2) に相当する WASI API である sock_open では、宛先の CID は "2" つまりホストに限定され、ポートのみ指定する形となっている。

https://github.com/naoki9911/mewz/blob/6d6b946b67d9fe19353b784535f1b4f5561ebe13/src/wasi.zig#L536-L555

pub export fn sock_connect(fd: i32, buf_ioved_addr: i32, port: i32) WasiError {
    log.debug.printf("WASI sock_connect: {d} {d} {d}\n", .{ fd, buf_ioved_addr, port });

    @setRuntimeSafety(false);

    const s = stream.fd_table.get(fd) orelse return WasiError.BADF;
    switch (s.*) {
        Stream.socket => |*socket| {
            const buf_iovec = @as(*IoVec, @ptrFromInt(@as(usize, @intCast(buf_ioved_addr)) + linear_memory_offset));
            const ip_addr_ptr = @as(*anyopaque, @ptrFromInt(@as(usize, @intCast(buf_iovec.buf)) + linear_memory_offset));
            socket.connect(ip_addr_ptr, port) catch return WasiError.INVAL;
        },
        Stream.vsock => |*vss| {
            vss.connect(2, @intCast(port)) catch return WasiError.INVAL;
        },
        else => return WasiError.BADF,
    }

    return WasiError.SUCCESS;
}

また、getlocaladdr といったアドレス情報を取得するための API についても未対応である。 これらについては、後述するユーザープログラム向けのライブラリも含めて今後対応する必要がある。

https://github.com/naoki9911/mewz/blob/6d6b946b67d9fe19353b784535f1b4f5561ebe13/src/wasi.zig#L646-L675

pub export fn sock_getlocaladdr(fd: i32, ip_iovec_addr: i32, type_addr: i32, port_addr: i32) WasiError {
    log.debug.printf("WASI sock_getlocaladdr: {d} {d} {d} {d}\n", .{ fd, ip_iovec_addr, type_addr, port_addr });

    @setRuntimeSafety(false);

    var s = stream.fd_table.get(fd) orelse return WasiError.BADF;
    var socket = switch (s.*) {
        Stream.socket => &s.socket,
        Stream.vsock => @panic("unimplemented! sock_getlocaladdr for vsock"),
        else => return WasiError.BADF,
    };

ユーザープログラム向けのライブラリ対応

AF_VSOCK について、WASI 向けの対応はまだ一般的ではないようである。 そのため、ユーザー向けに専用のライブラリを通して AF_VSOCK に対応する機能を提供する必要がある。

今回は wasmedge_wasi_socket をフォークして対応した。 対応個所としては AddressFamily に Vsock を追加しただけである。 ユーザープログラムはソケット作成時に Vsock を指定すれば vsock なソケットを利用できる。 アドレスについては std::net::Ipv4Addr で指定し、前述したようにポート番号のみ利用する。

github.com

#[derive(Copy, Clone, Debug)]
#[repr(u8, align(1))]
pub enum AddressFamily {
    Unspec,
    Inet4,
    Inet6,
    Vsock = 40,
}

ここまでの動作確認

WebAssembly アプリケーションとして、vsock ソケットを利用した簡易的な Echo サーバーを実装した。

https://github.com/naoki9911/mewz/blob/6278db7ee37527416eeee5a060c35ca076198711/examples/echo_server_vsock/src/main.rs

use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use wasmedge_wasi_socket::socket::{Socket, SocketType, AddressFamily};

fn main() {
    let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 1234);
    let sock = Socket::new(AddressFamily::Vsock, SocketType::Stream).unwrap();
    sock.bind(&addr).unwrap();
    sock.listen(10).unwrap();
    let mut buf: [u8; 100] = [0; 100];
    loop {
        let new_sock = sock.accept(false).unwrap();
        let recv_cnt = new_sock.recv(&mut buf).unwrap();
        let recv_utf8 = std::string::String::from_utf8((&buf[0..recv_cnt]).to_vec()).unwrap();
        println!("recv_cnt={} content={:?}", recv_cnt, recv_utf8);
        let sent_cnt = new_sock.send(&buf[0..recv_cnt]).unwrap();
        println!("sent_cnt={}", sent_cnt);
    }
}

ホスト側のクライアントについては Python で記述した。

https://github.com/naoki9911/mewz/blob/7d8a686e9737d088d34ae7ac9cb1200119e4a978/examples/echo_server_vsock/connect_vsock.py

import socket

sock = socket.socket(socket.AF_VSOCK, socket.SOCK_STREAM)
sock.connect((3, 1234))

msg = "Hello from host"
sock.send(msg.encode())
res = sock.recv(1024).decode()
print("res = {}".format(res))

sock.close()

前回の環境を引き続き利用し、動作確認を行った。なお、 GitHub - naoki9911/mewz-on-libkrun の submodule は更新済みであり、必要に応じて pull すること。

$ cd mewz/examples/echo_server_vsock/
$ ./build.sh
$ cd ../../
$ zig build -Dapp-obj=examples/echo_server_vsock/wasm.o run

別ターミナルでクライアントを実行するとエコーバックされていることが分かる。

$ python3 examples/echo_server_vsock/connect_vsock.py
res = Hello from host

このとき Mewz 側では

Booting from ROM..
UART: recv_cnt=15 content="Hello from host"
UART: sent_cnt=15

となり、クライアント側のメッセージを受信し正しく送信できていることが分かる。

Mewz on libkrun - その7 virtio-vsock について

Mewz (WebAssembly x Unikernel) を libkrun で動かしてみた - 外部記憶装置 の詳細を記すシリーズ

目次

Virtio Socket (virtio-vsock) について

docs.oasis-open.org

Virtio Socket (virtio-vsock) はゲストとホスト間をいわゆるソケットを用いて通信を行うためのデバイスである。 virtio-vsock のデバイス自体は Virtuqueue を3つ(rx, tx, event) 持つだけのシンプルな構造である。 ただし、ソケット相当の機能を提供するためにそれらの Virtqueue 上でソケットの multiplexing やフロー制御を行う必要がある。

virtio-vsock の概要

virtio-vsock では、ホスト・ゲストの識別子として 64bit 長の Context ID (CID) を持つ。また、ポートの概念も持ち、32bit 長の識別子で扱う。

これらの識別子については TCP, UDP のようにヘッダに格納されペイロードと合わせてパケットとして Virtqueue を流れる。

https://docs.oasis-open.org/virtio/virtio/v1.3/csd01/virtio-v1.3-csd01.html#x1-4800006

virtio-vsock ではソケットの管理やAPIについては言及されていないため、ゲスト側でソケットと送受信するパケットを適切に振り分ける仕組みが別途必要となる。 そのため、これまでの virtio-net や virtio-console と異なり、ドライバ単体だけでなく OS 側のソケットのサブシステム自体にも手をいれることになる。

通信の種類

virtio-vsock では、VIRTIO_VSOCK_TYPE_STREAM と VIRTIO_VSOCK_TYPE_SEQPACKET の2種類の通信方式が定義されている。

VIRTIO_VSOCK_TYPE_STREAM はいわゆる SOCK_STREAM に相当するものであり、順序保証付きコネクション指向型のメッセージ境界がない(=ストリーム形式) 通信を提供する。プロトコルとしては TCP 相当の機能を提供する。

VIRTIO_VSOCK_TYPE_SEQPACKET はいわゆる SOCK_SEQPACKET に相当するものであり、順序保証付きコネクション指向型でメッセージ境界が存在する通信を提供する。IP で利用されるプロトコルとしては SCTP が近い。

virtio-vsock ではこれらの通信を用途に応じて使い分けることができる。

プロトコル

virtio-vsock はコネクション指向型の通信を提供するため、セッションの確立および切断について上図に示すプロトコルが存在する。

接続

接続の起点は VIRTIO_VSOCK_OP_REQUESTを送信するところからである。 dst_cid,dst_portに対応するソケットが存在し接続を受け入れる場合は VIRTIO_VSOCK_OP_RESPONSE を返す。接続を拒否する場合は VIRTIO_VSOCK_OP_RST を返す。接続については以上で完了する。

データの送受信

その後、VIRTIO_VSOCK_OP_RW でデータの送受信を行う。virtio-vsock はロスレスな完全性が保証された通信路を前提とするため、Ack を行う必要はない。 データの送受信中、ピアのソケットバッファの状況を把握するためのリクエストとして VIRTIO_VSOCK_OP_CREDIT_REQUEST を送信すると、ピアは VIRTIO_VSOCK_OP_CREDIT_UPDATE としてバッファの状況をヘッダに記して返信する。VIRTIO_VSOCK_OP_CREDIT_UPDATE についてはリクエストがない場合一方的に送っても構わない。

切断

切断については VIRTIO_VSOCK_OP_SHUTDOWN により行う。フラグとして、ピアは以降いかなるデータも受信しないことを示す VIRTIO_VSOCK_SHUTDOWN_F_RECEIVE と、いかなるデータも送信しないことを示す VIRTIO_VSOCK_SHUTDOWN_F_SEND がある。これらのフラグを用いて、ピアに対してデータのこぼれが生じないように終了処理を行う。 いかなるデータの送受信が行われることが無いことが確認された時点で、切断処理は完了する。つまり、必要に応じて複数回のVIRTIO_VSOCK_OP_SHUTDOWN がやり取りされる場合がある。例えば、VSockA→VSockBでVIRTIO_VSOCK_SHTDOWN_F_RECEIVEとし、VSockB→VSockA で VIRTIO_VSOCK_SHTUDOWN_F_RECEIVEとした場合、両者ともにデータの受信を行わないことを通知しているため切断とみなすことができ、VSockA→VSockB でVIRTIO_VSOCK_OP_RSTを送信して切断処理を完了する。なお、一定時間VIRIO_VSOCK_OP_RSTが届かなかった場合、VIRTIO_VSOCK_OP_RSTを送信して終了とする。

SEQPACKET について

VIRTIO_VSOCK_TYPE_SEQPACKET では、2種類のメッセージ境界(VIRTIO_VSOCK_SEQ_EOM, VIRTIO_VSOCK_SEQ_EOR)を扱うことができる。最小単位は "Message" であり、メッセージを複数の RW パケットに分割後、最後のパケットに VIRTIO_VSOCK_SEQ_EOM フラグを立てることで境界となる。

もう一つのメッセージ境界としては、複数のメッセージをまとめたものである "Record" がある。Record については 一連の連続した Message をまとめ、Record 間の区切りは VIRTIO_VSOCK_SEQ_EOR フラグを RW パケットに立てることで行う。

フロー制御

virtio-vsock はパケットロスが一切許容されないロスレスな通信路を前提としているため再送機構を持たない。 そのため、相手のバッファ溢れによるパケットロスが生じないようにフロー制御を行っている。

フロー制御としてはシンプルなものであり、ピアから通知されるバッファサイズや転送カウントを元に、バッファの空き状況に応じてデータの送信可否を決定する。

https://docs.oasis-open.org/virtio/virtio/v1.3/csd01/virtio-v1.3-csd01.html#x1-4800006

実際の計算では、(ピアのバッファサイズ) - (送信済みバイト数 - ピアが処理したバイト数) となる。 つまり、RW パケット送信時点で相手が少なくとも持ちうる空き容量を計算している。 ピアのバッファサイズや処理したバイト数については、ピアが送信する RW パケットや VIRTIO_VSOCK_OP_CREDIT_UPDATE パケットヘッダから得ることができる。

まとめ

virtio-vsock のデバイス自体はシンプルであるが、その上にソケットの仕組みを実装することになる。 そのため、カーネルが持つソケット相当のサブシステムに対して上手く組み込む必要がある。 次回は実際に Mewz に対してソケットとの統合も含めて virtio-vsock の実装を行う。

Mewz on libkrun - その6 Mewz 追加実装(virtio-console)

Mewz (WebAssembly x Unikernel) を libkrun で動かしてみた - 外部記憶装置 の詳細を記すシリーズ

目次

Virtio Console (virtio-console) について

docs.oasis-open.org

virtio-console はコンソール入出力向けの virtio デバイスである。 一般的に、シリアル経由で行われるコンソール入出力を置き換えるために用いられる。

libkrun においても、コンソールデバイスとして標準で用いられる。 シリアルの場合 x86_64 や aarch64 でそれぞれ対応するデバイスが異なり、ドライバを複数用意する必要がある。 virtio-console を用いることで、そういったデバイスドライバを用意する必要が無くなる。

Mewz においては今のところ x86_64 を対象としているため、IO Port 経由でシリアルデバイスを操作することでコンソールの入出力を行っている。 libkrun は virtio-console を用いることが想定されているため、これについても追加実装を行い対応する。

基本機能の実装

virtio-console は Virtqueue を送受信用で計2つ用意し、他の virtio デバイスと同じ手順で初期化すれば基本的な機能は利用できる。 コンソール出力については transmitq(port0)、入力については receiveq(port0) で扱い、Mewz 側でこれに対応するだけで良い。

QEMU においては、-device virtio-serial -device virtconsole,chardev=my_console -chardev socket,id=my_console,host=127.0.0.1,port=3334,server,nowait をオプションとして与え、別ターミナルで telnet localhost 3334 を実行すれば telnet 経由で virtio-console の入出力を扱うことができる。

VIRTIO_CONSOLE_F_MULTIPORT

libkrun では、VIRTIO_CONSOLE_F_MULTIPORT と呼ばれる拡張機能を前提としているため、上述の単純な virtio-console ドライバでは動作しない。 この拡張はその名の通り、複数 virtio-console ポートを扱うためのものであり、Virtqueue 初期化後に各ポートの初期化処理を行う必要がある。 これらの制御は専用の Virtqueue である control receiveqとcontrol transmitqを介して行われる。

VIRTIO_CONSOLE_F_MULTIPORT feature を利用する場合、control キューでは以下のフォーマットに従い制御通信を行う。

struct virtio_console_control { 
        le32 id;    /* Port number */ 
        le16 event; /* The kind of control event */ 
        le16 value; /* Extra information for the event */ 
};

id はポート番号、event は制御用コード、value はステータスコード等を含める。 ポートの初期化は以下の流れに従う。

Virtqueue の初期化後、VIRTIO_CONSOLE_DEVICE_READY を libkrun(Device) に対して通知することでデバイスの初期化が完了する。

その後、Device からポートの追加要求(VIRTIO_CONSOLE_DEVICE_ADD) を受け取る。id で指定されたポートの準備が完了した後、成功を示す value=1 を持つVIRTIO_CONSOLE_PORT_READYメッセージで返答する。

ポートをコンソールの入出力として利用する場合、Device は VIRTIO_CONSOLE_CONSOLE_PORT により Driver に通知を行う。Driver はこれを受けて VIRTIO_CONSOLE_PORT_OPEN に value=1 を入れて返答する。※なお、QEMU においては VIRTIO_CONSOLE_PORT_OPEN を返答しなくとも動くようではあるが、Virtio の仕様上は "MUST" となっているため必ず返答する必要がある。

その後、Device 側から VIRTIO_CONSOLE_PORT_OPEN を受け取ると、該当ポートは既に利用可能な状態であるため、Driver はコンソールの入出力を対応する Virtqueue に流せば良い。

Mewz への実装

drivers/virtio/console.zig に Multiport 対応のドライバを実装した。

github.com

libkrun については標準で port0 だけでコンソールの入出力を扱えるため、port0 のみ受け付け、それ以外のポートは拒否するように実装した。

    fn handleCtrl(self: *Self, ctrl: *VirtioConsoleControl) void {
        switch (ctrl.event) {
            .VIRTIO_CONSOLE_DEVICE_ADD => {
                // only accept port 0
                if (ctrl.id == 0) {
                    log.info.printf("virtio.console: ctrl port added (port={})\n", .{ctrl.id});
                    self.port0_added = true;
                    self.ctrlTransmit(ctrl.id, .VIRTIO_CONSOLE_PORT_READY, 1);
                } else {
                    log.warn.printf("virtio.console: ctrl port is not ready (port={})\n", .{ctrl.id});
                    self.ctrlTransmit(ctrl.id, .VIRTIO_CONSOLE_PORT_READY, 0);
                }
            },
            .VIRTIO_CONSOLE_CONSOLE_PORT => {
                if (ctrl.id != 0 or !self.port0_added) {
                    log.warn.printf("virtio.console: cannot use port{} as a console: not added\n", .{ctrl.id});
                } else {
                    self.port0_console = true;
                    log.info.print("virtio.console: port0 is specified as a console\n");
                    self.ctrlTransmit(0, .VIRTIO_CONSOLE_PORT_OPEN, 1);
                }
            },
            .VIRTIO_CONSOLE_PORT_OPEN => {
                if (ctrl.id != 0 or !self.port0_added) {
                    log.warn.printf("virtio.console: cannot open port{}: not added\n", .{ctrl.id});
                } else {
                    if (ctrl.value == 1) {
                        self.port0_opened = true;
                        log.info.print("virtio.console: port0 is opened\n");
                    } else if (ctrl.value == 0) {
                        self.port0_opened = false;
                        log.info.print("virtio.console: port0 is closed\n");
                    } else {
                        log.warn.printf("virtio.console: invalid value: {}\n", .{ctrl});
                    }
                }
            },
            .VIRTIO_CONSOLE_RESIZE => {
                log.warn.print("virtio.console: VIRTIO_CONSOLE_RESIZE is ignored\n");
            },

UART(シリアル)との共存については、virtio-console の port0 がコンソールとして利用かつ、既に OPEN な状態にある場合は port0 を利用し、それ以外はシリアルにコンソール出力を流すようにした。

github.com

他にも、virtio-console の PCI BAR が IO space mapped な場合への対応等を行い、QEMU と libkrun のどちらでも動作するようにした。

pci: Handle IO space mapped BAR · naoki9911/mewz@8495787 · GitHub

ここまでの動作確認

前回の環境を引き続き利用し、動作確認を行った。なお、 GitHub - naoki9911/mewz-on-libkrun の submodule は更新済みであり、必要に応じて pull すること。

$ cd mewz
$ zig build -Dapp-obj=wasm.o -Dlog-level=info -Denable-pci=false libkrunfw
$ cd ../libkrun/examples
$ sudo LD_LIBRARY_PATH=../lib ./chroot_vm --net=passt dummy dummy
[sudo] password for naoki:
UART: [LOG INFO]: booted with linux zero page
UART: [LOG INFO]: zeropage: e820_entries=2
UART: [LOG INFO]: E820 Entry [1] addr=0x0 size=0x9fc00 type=1
UART: [LOG INFO]: E820 Entry [2] addr=0x100000 size=0x200b0001 type=1
UART: [LOG INFO]: available memory: 144eb000 - 201b0001
UART: [LOG INFO]: virtio_mmio device detected: addr=0xd0000000 size=0x1000 IRQ=5
UART: [LOG INFO]: virtio_mmio device detected: addr=0xd0001000 size=0x1000 IRQ=6
UART: [LOG INFO]: virtio_mmio device detected: addr=0xd0002000 size=0x1000 IRQ=7
UART: [LOG INFO]: virtio_mmio device detected: addr=0xd0003000 size=0x1000 IRQ=8
UART: [LOG INFO]: virtio_mmio device detected: addr=0xd0004000 size=0x1000 IRQ=9
UART: [LOG INFO]: virtio.console: found mmio device
UART: [LOG INFO]: virtio.console: initialized mmio device
UART: [LOG INFO]: virtio.net: found mmio device
UART: [LOG INFO]: mac: 5a:94:ef:e4:c:ee
UART: [LOG INFO]: virtio.net: initialized mmio device

UART: [LOG INFO]: virtio.console: ctrl port added (port=0)
UART: [LOG INFO]: virtio.console: port0 is specified as a console
VC: [LOG INFO]: virtio.console: port0 is opened
VC: [LOG WARN]: virtio.console: VIRTIO_CONSOLE_RESIZE is ignored
VC: Hello, wasker

UART 経由の出力については UART:、virtio-console 経由の出力については VC:を先頭に付与する実装としている。 virtio-console ドライバで port0 が open となった時点で出力が virtio-console となっており、意図した通り動作していることがわかる。

2024年振り返り

時間がないため簡単に 2024 年を振り返る。

主な開発したもの

public にできるもの、かつ、わりと時間をかけて開発したものをまとめる。

quic-zig

「そういや QUIC の詳細な仕様は知らないなぁ」で勉強がてらハンドシェイクのみ Zig で実装した。 暗号化部には tls13-zig を使っているため、TLS 1.3 も含めて全部スクラッチしたことになる。 tls13-zig も quic-zig も実験的な実装であるため、もっと実用的な実装(テストも含む)にしたいと思っている。

github.com

研究室内でその知見を共有する機会があったため、その時のスライドの一部を公開している。

speakerdeck.com

bcachefs_exporter

github.com

bcachefs の各種メトリクスを扱う prometheus expoter。

自宅サーバーを更新し、ストレージ用のファイルシステムも ZFS から bcachefs に乗り換えたため、その監視に用いている。

そういえば自鯖の再構築に関する知見をまとめようとしていたが忘れていたことを思い出した。

rv32-zig

「そういやプロセッサの割り込みや権限管理の詳細な仕組みは知らないなぁ」で勉強がてら RISC-V(rv32imasu)のエミュレータを Zig で実装した。 先人たちの実装は多く存在するが、あえてそれらを読まず仕様書のみで実装し、riscv-tests の該当テスト(rv32ui, rv32um, rv32ua, rv32mi, rv32si)をパスする状態になっている。 ファームウェアとしては OpenSBI が動作する。 Linux も buildroot で作った rootfs が途中まで動いたが、シェルまでは到達できていない。

あるアイデアを試すためにも実装しているが、年内には実装が終わらなかったため来年頑張る。

github.com

出版した論文

bypass4netns: Accelerating TCP/IP Communications in Rootless Containers

bypass4netns は須田瑛大さんの PoC を元に僕が 2022 年の NTT 研究所インターン時から開発している、Rootless Containers の通信を高速化するモジュールである。 昨年 reject となった論文を書き直し、8月の AINTEC 2024 (Asian Internet Engineering Conference) で発表した。

光栄なことに Best Paper Award をもらい、自分としては研究的にも一定の評価が得られたと思っている。

12 月の Container Runtime Meetup #6 でも発表させていただいた。

speakerdeck.com

今年の振り返り・来年の抱負

今年は博士課程2年目にあたり、色々取り組んでいたものをまとめ論文にする作業をしていた。 bypass4netns 以外にも、論文誌に投稿したものが採択され来年出版される予定であり、研究活動はぼちぼち(?)であった。 本来はもっと「研究」をすべきなのだが、なかなか難しく、興味の赴くままに色々調べて実装をするという一般的な博士課程学生とは少し異なる1年であったように思う。

1年を通して、自分の向かう先や存在意義などを問う時間が多かった。これについては年末に書くべきでない事柄であり、機会があればポエムとしてまとめるかもしれない。

来年は博士論文・審査があり、己を試される1年となる。ここまで来たからには折れることなく乗り越えたい。

Mewz on libkrun - その5 Mewz on libkrun してみた

Mewz (WebAssembly x Unikernel) を libkrun で動かしてみた - 外部記憶装置 の詳細を記すシリーズ

目次

ビルド

Mewz on libkrun 用のイメージを用いてビルドする。

$ git clone --recursive http://github.com/naoki9911/mewz-on-libkrun
$ cd mewz-on-libkrun
$ docker run --rm -v $(pwd):/work ghcr.io/naoki9911/mewz-on-libkrun:main /work/build.sh

Mewz on libkrun してみた

ホスト側で chroot_vm を実行する。

$ cd likbrun/examples
$ sudo LD_LIBRARY_PATH=../lib ./chroot_vm --net=passt dummy dummy
Don't run as root. Changing to nobody...
No routable interface for IPv6: IPv6 is disabled
Template interface: eth0 (IPv4)
MAC:
    host: 00:15:5d:f5:94:7a
DHCP:
    assign: 192.168.10.2
    mask: 255.255.255.0
    router: 192.168.10.1
DNS:
    10.255.255.254
DNS search list:
    tailbffcc.ts.net
[LOG INFO]: booted with linux zero page
[LOG INFO]: zeropage: e820_entries=2
[LOG INFO]: E820 Entry [1] addr=0x0 size=0x9fc00 type=1
[LOG INFO]: E820 Entry [2] addr=0x100000 size=0x20310001 type=1
[LOG INFO]: available memory: 14748000 - 20410001
[LOG INFO]: virtio_mmio device detected: addr=0xd0000000 size=0x1000 IRQ=5
[LOG INFO]: virtio_mmio device detected: addr=0xd0001000 size=0x1000 IRQ=6
[LOG INFO]: virtio_mmio device detected: addr=0xd0002000 size=0x1000 IRQ=7
[LOG INFO]: virtio_mmio device detected: addr=0xd0003000 size=0x1000 IRQ=8
[LOG INFO]: virtio_mmio device detected: addr=0xd0004000 size=0x1000 IRQ=9
[LOG INFO]: virtio.net: found mmio device
[LOG INFO]: mac: 5a:94:ef:e4:c:ee
[LOG INFO]: virtio.net: initialized mmio device

Listening on http://0.0.0.0:1234

別ターミナルで curl を実行する。

$ curl localhost:1234
Hello World!

完璧である(勝利)。

ここまでのまとめ

ここまでで Mewz を libkrun で動かし、Mewz 上で稼働する Web サーバーと通信することができた。 libkrun 自体は crun 等との連携も可能であり、libkrun を用いることで Mewz と、各種コンテナエンジンや Kubernetes との連携がさらに容易になると考えられる。

libkrun は virtio-fs を用いたホスト上のディレクトリのマウントや TSI (Transparent Socket Impersonation) というゲスト側で TCP/IP スタックを必要としない仕組みを持つ。

rheb.hatenablog.com

TSI については既に Mewz に組み込み動作させることには成功しているため、次回以降その詳細についてまとめる予定である。

Mewz on libkrun - その4 Mewz 追加実装(Virtio MMIO)

Mewz (WebAssembly x Unikernel) を libkrun で動かしてみた - 外部記憶装置 の詳細を記すシリーズ

目次

Virtio MMIO について

正しくは Virtio Over MMIO である。 PCI 経由で Virtio デバイスを提供するのではなく、Memory Mapped IO として提供する。 PCI デバイスのスキャン等の必要がなく、デバイスのレジスタが存在するアドレスを指定すればよいため Virtio Over PCI よりもシンプルである。

docs.oasis-open.org

Mewz への追加実装

Mewz は既に Virtio Over PCI な virtio-net ドライバを持つ。 そのため、Virtqueue などのプリミティブなバッファ系は既に実装されているため、それらを流用し Virtio MMIO な virtio-net デバイスへ対応する。

MMIO Device Register Layout

Virtio MMIO はデバイスごとに指定されたデバイスについて Device Register を持つ。この Device Register から DeviceID 等を読み取り、必要に応じてデバイスを初期化する。

Virtual I/O Device (VIRTIO) Version 1.3

Zig においては packed struct を用いることでメモリマップドなレジスタ群についても容易に扱うことができる。実際には以下のような実装となっている。

https://github.com/naoki9911/mewz/blob/12b61046f478f174de5af634aa67eb0f13839e23/src/drivers/virtio/mmio.zig#L148-L190

pub const DeviceRegister = packed struct {
    magic: u32, // 0x000
    version: u32, // 0x004
    device_id: u32, // 0x008
    vendor_id: u32, // 0x00C
    device_features: u32, // 0x010
    device_features_sel: u32, // 0x014
    _pad1: u64, // 0x018
    driver_features: u32, // 0x020
    driver_features_sel: u32, // 0x024
    _pad2: u64, // 0x28
    queue_sel: u32, // 0x30
    queue_size_max: u32, // 0x34
    queue_size: u32, // 0x38
    _pad3: u64, // 0x3C
...

Vritio MMIO デバイスの初期化

デバイスの初期化についても、Virtio PCI なデバイスとは異なる手順となっている。Virtqueue の用意を行う部分が異なり、残りの部分については "Further initialization MUST follow the procedure described in 3.1 Device Initialization." とあるように Virtio PCI と変わらないため、これについても既存のドライバを流用しつつ実装した。

Virtual I/O Device (VIRTIO) Version 1.3

なお、そのまま実装すると正しく動作しなかったため、mfence 命令をはさみ記述どおりの順番でメモリへの読み書きが行われるようにした。

mewz/src/drivers/virtio/mmio.zig at 12b61046f478f174de5af634aa67eb0f13839e23 · naoki9911/mewz · GitHub

            const virtqueues = try allocator.alloc(common.Virtqueue, queue_num);
            for (0..queue_num) |i| {
                const queue_index = @as(u16, @intCast(i));

                transport.common_config.queue_sel = queue_index;
                x64.mfence();

                if (transport.common_config.queue_ready != 0) {
                    log.fatal.printf("virtio.mmio: virtqueue[{}] is not ready", .{i});
                    @panic("virio.mmio is not available");
                }

                const queue_size: u16 = @intCast(transport.common_config.queue_size_max & 0xFFFF);
                log.debug.printf("virtio.mmio: virtqueue[{}] queue size is {}\n", .{ i, queue_size });
                if (queue_size == 0) {
                    log.fatal.printf("virtio.mmio: virtqueue[{}] is not avaiable", .{i});
                    @panic("virio.mmio is not available");
                }

                const virtqueue = try common.Virtqueue.new(queue_index, queue_size, allocator);
                transport.common_config.queue_size = queue_size;
                const desc = @as(u64, @intFromPtr(virtqueue.desc));
                const driver = @as(u64, @intCast(virtqueue.avail.addr()));
                const device = @as(u64, @intCast(virtqueue.used.addr()));
                transport.common_config.queue_desc_low = @intCast(desc & 0xFFFFFFFF);
                transport.common_config.queue_desc_high = @intCast(desc >> 32);
                transport.common_config.queue_driver_low = @intCast(driver & 0xFFFFFFFF);
                transport.common_config.queue_driver_high = @intCast(driver >> 32);
                transport.common_config.queue_device_low = @intCast(device & 0xFFFFFFFF);
                transport.common_config.queue_device_high = @intCast(device >> 32);
                x64.mfence();
                transport.common_config.queue_ready = 1;
                virtqueues[i] = virtqueue;
                x64.mfence();
            }

割り込み周りの修正

Virtio MMIO では、割り込み終了後明示的に Device Register の InterruptACK にフラグを立てる必要がある。 Virtio MMIO の場合についてのみ該当処理を行う形で対応した。

mewz/src/drivers/virtio/net.zig at 12b61046f478f174de5af634aa67eb0f13839e23 · naoki9911/mewz · GitHub

fn handleIrq(frame: *interrupt.InterruptFrame) void {
    _ = frame;
    log.debug.print("interrupt\n");
    if (virtio_net) |vn| {
        vn.receive();
        // acknowledge irq
        switch (vn.virtio) {
            .mmio => |*m| m.transport.common_config.interuupt_ack = 1,
            else => {},
        }

Virtio PCI との共存

Mewz は QEMU、つまり Virtio PCI を利用する環境がデフォルトとして想定されている。 QEMU は Virtio MMIO についてもサポートしているため、Virtio PCI 自体をサポートしないという選択肢も考えられるが、今回は Virtio MMIO と Virtio PCI の両者に対してドライバを対応させた。

Virtio PCI と Virtio MMIO の違いとしてはそれぞれのデバイス初期化等の設定周りがほとんどであり、比較的容易に共存させることができる。 Zig では struct の union が提供されているため、今回はそれを利用した。

mewz/src/drivers/virtio/common.zig at 12b61046f478f174de5af634aa67eb0f13839e23 · naoki9911/mewz · GitHub

pub fn Virtio(comptime DeviceConfigType: type) type {
    return union(enum) {
        pci: VirtioPCI(DeviceConfigType),
        mmio: VirtioMMIO(DeviceConfigType),
    };
}

呼び出し時は関数名が同じならば Zig 0.10.0 からサポートされた inline else を使うことで以下のように簡潔に記述できる。

mewz/src/drivers/virtio/net.zig at 12b61046f478f174de5af634aa67eb0f13839e23 · naoki9911/mewz · GitHub

    fn receiveq(self: *Self) *common.Virtqueue {
        switch (self.virtio) {
            inline else => |v| return &v.virtqueues[0],
        }
    }

その他の修正

Mewz では、IP アドレスとデフォルトゲートウェイ(DGW)のアドレスは kernel cmd params 経由で受け取る。 libkrun では環境変数を自由に設定することができるため、環境変数としてアドレス類を受け渡すように対応した。

github.com

libkrun chroot_vm.c の修正

これまで libkrun の実行に利用している chroot_vm.c では、標準では virtio-net ではなく TSI と呼ばれる virtio-vsock 経由の通信機能を提供する。 virtio-net を利用する場合は --net=passt を指定することで virtio-net を利用できる。 --net=passt は QEMU における -netdev user と類似した機能であり、ゲスト側は指定された IP アドレスと DGW を使う必要がある。 本来は DHCP Client を実装すればよいが、面倒なためとりあえず環境変数経由で指定することにした。 chroot_vm.c 側においても明示的に利用するアドレス類を指定するようにした。

また、ポートを公開する設定として、-t 1234 を指定している。 これは QEMU の hostfwd に相当するオプションであり、ホスト側の1234/tcpポートに対する接続をゲスト側の1234/tcpへと中継する設定である。

github.com

ここまでの動作確認

通信機能を持つアプリケーションとして、Mewz が example として提供している hello_server を利用した。 環境は前回の動作確認した時の環境を使いまわしている。

$ sudo apt install passt
$ cd mewz/examples/hello_server
$ cargo build --target wasm32-wasi
$ wasker target/wasm32-wasi/debug/hello_server.wasm
$ cd ../../
$ zig build -Dapp-obj=examples/hello_server/wasm.o -Dlog-level=info -Denable-pci=false libkrunfw
$ cd ../libkrun/examples
$ make
$ sudo LD_LIBRARY_PATH=../lib ./chroot_vm --net=passt dummy dummy
Don't run as root. Changing to nobody...
No routable interface for IPv6: IPv6 is disabled
Template interface: eth0 (IPv4)
MAC:
    host: 00:15:5d:f5:94:7a
DHCP:
    assign: 192.168.10.2
    mask: 255.255.255.0
    router: 192.168.10.1
DNS:
    10.255.255.254
DNS search list:
    tailbffcc.ts.net
    flets-west.jp
    iptvf.jp
[LOG INFO]: booted with linux zero page
[LOG INFO]: zeropage: e820_entries=2
[LOG INFO]: E820 Entry [1] addr=0x0 size=0x9fc00 type=1
[LOG INFO]: E820 Entry [2] addr=0x100000 size=0x20310001 type=1
[LOG INFO]: available memory: 14747000 - 20410001
[LOG INFO]: virtio_mmio device detected: addr=0xd0000000 size=0x1000 IRQ=5
[LOG INFO]: virtio_mmio device detected: addr=0xd0001000 size=0x1000 IRQ=6
[LOG INFO]: virtio_mmio device detected: addr=0xd0002000 size=0x1000 IRQ=7
[LOG INFO]: virtio_mmio device detected: addr=0xd0003000 size=0x1000 IRQ=8
[LOG INFO]: virtio_mmio device detected: addr=0xd0004000 size=0x1000 IRQ=9
[LOG INFO]: virtio.net: found mmio device
[LOG INFO]: mac: 5a:94:ef:e4:c:ee
[LOG INFO]: virtio.net: initialized mmio device

Listening on http://0.0.0.0:1234

別ターミナルで curl を実行する。

$ curl localhost:1234
Hello World!

となり、無事 Mewz で動作しているサーバーからレスポンスが得られた。