Red Hatの織です。本記事はRed Hat Advent Calendar 12/5分の記事です (遅くなってしまいました)。また、Advent Calendar 12/3の「libkrunで遊ぶ」の続編でもあります。
Transparent Socket Impersonation
libkrunでのネットワーク通信は、Transparent Socket Impersonation (TSI) という仕組みを使っています(直訳すると「透過的なソケットのなりすまし」)。TSIはlibkrun以外では聞いたことがないので、おそらく一般的な用語ではなくて、libkrun特有の概念ではないかと思います。TSIの仕組みによって、仮想インターフェース (virtio-net) を使わずにネットワーク通信ができるようになります (ただしAF_INTのSOCK_DGRAMおよびSOCK_STREAMの通信のみ)。
TSI概要
libkrunでは、ゲストOSのカーネルおよびFirmwareに相当するlibkrunfwというコンポーネントがあります。libkrunのゲストOS内のプロセスが発行したシステムコールは、libkrunfwが処理します。 プロセスが通信を行う際はソケットAPIを使うことが多いと思いますが、ソケット関連のシステムコールもlibkrunfwによって処理されます。
libkrunfwのLinuxカーネルには、「タイプがSOCK_DGRAMもしくはSOCK_STREAMのAF_INETソケットはAF_TSIに変換する」というパッチが当たっています。したがって、該当するソケット通信は全て、(クライアントはAF_INETとして通信しているつもりですがカーネル内ではAF_INETではなく)AF_TSIとして処理されます。
AF_TSIはAF_INETとAF_VSOCKの両方を透過的に処理できるような構造になっており、libkrunfwカーネルはゲスト内のプロセスが出すAF_INETのソケット通信をAF_VSOCKに偽装します。この仕組みを使うことで、ゲスト内のプロセスからのAF_INETのソケットは、virtio-vsockデバイスを経由し、VMM(以下、「VMM」と「libkrun」は同じ意味で書いています)をプロキシーとすることで、外と通信できるようになります。
突然AF_*のような用語が出てきました。AFはアドレスファミリー(Address Family)を表しており、ネットワークアドレスの種類と思ってください。AF_INETはおなじみのIPv4での通信を表します。AF_VSOCKは、仮想環境においてゲストとホストの間でのソケット通信をするためのアドレスファミリーです。典型的な使用例としては、ゲストエージェント(例えばVMware Tools的なもの)とホストとの通信が挙げられます。VSOCKは、最近ではコンテナ環境などいろんな場所で使われています。
また、
- SOCK_DGRAM = UDP通信
- SOCK_STREAM = TCP通信
を意味する、と思ってください。
詳細
「AF_INETとAF_VSOCKの両方を透過的に処理できる」「AF_INETのソケット通信をAF_VSOCKに偽装」の実装についてもう少し踏み込んでみましょう。以下では、「ゲストOS内のクライアントプロセスが、外部のサーバーに対してconnect(2)で接続し、write(2)で何かデータを書く」というユースケースを想定します。
tsi_sock構造体
Linuxカーネルでは、アドレスファミリーごとに、*_sockという構造体を用意しています。まずはAF_TSIの通信を表現するためのsock構造体(struct tsi_sock)を見てみましょう。
struct tsi_sock { /* sk must be the first member. */ struct sock sk; struct socket *isocket; // inet通信用ソケット struct socket *vsocket; // vsock通信用ソケット struct socket *csocket; // VMM制御用ソケット(VMM内にinet-vsockプロキシーのエントリ追加依頼等で使用、これもvsock) unsigned int status; u32 svm_port; u32 svm_peer_port; struct sockaddr_in *bound_addr; struct sockaddr_in *sendto_addr; };
sock構造体の中にAF_INET通信用、AF_VSOCK通信用のsocket構造体を持つことで、場面に応じてそれぞれどちらの通信でも表現できるようになっていることがわかります。
connect(2)時の動き
外部のIPアドレスに接続する際に呼ばれる、connect(2)システムコールの処理を見てみましょう。libkrun仮想マシン内のプロセスがconnect(2)を呼び出すと、libkrunfwのカーネル内ではtsi_connect()がその処理を担当します。tsi_connect()はおおまかに次のような処理の流れになっています。
static int tsi_connect(struct socket *sock, struct sockaddr *addr, int addr_len, int flags) { ... tsk = tsi_sk(sock->sk); isocket = tsk->isocket; vsocket = tsk->vsocket; ... if (isocket) { ... err = isocket->ops->connect(isocket, addr, addr_len, flags & ~O_NONBLOCK); ... } if (vsocket) { ... if (!tsk->svm_port) { if (tsi_create_proxy(tsk, vsocket->type) != 0) { err = -EINVAL; goto release; } } ... err = tsi_control_sendmsg(tsk->csocket, TSI_CONNECT, (void *)&tc_req, sizeof(struct tsi_connect_req)); ... err = tsi_control_recvmsg(tsk->csocket, TSI_CONNECT, (void *)&tc_rsp, sizeof(struct tsi_connect_rsp)); ... err = kernel_connect(vsocket, (struct sockaddr *)&vm_addr, sizeof(struct sockaddr_vm), 0); ... tsk->status = S_VSOCK; } ... }
今考えているユースケースでは、ゲスト→ホスト→外部という通信を想定しており、ゲストからの直接のconnectはできないため(前回の記事で確認したように、libkrunはvirtio-netは使っておらず、ゲスト内ではループバックインターフェースしか持っていません)、if (isocket)
の節は飛ばして if (vsocket)
のif節に入ります。
この中では、おおまかに
- VMMに対してINET-VSOCKプロキシーをするためのエントリー作成する (tsi_create_proxy())
- VMM内のINET-VSOCKプロキシー経由で外部サイトにconnectする (tsi_control_sendmsg()でTSI_CONNECTする)
という処理をしています。
INET-VSOCKプロキシーは、libkrun上では VsockMuxer
および TcpProxy
という構造体で表現されています。VsockMuxerがプロキシー全体を管理しており、プロキシーの各エントリーがTcpProxy、という役割になっています。
- VsockMuxer構造体
pub struct VsockMuxer { cid: u64, host_port_map: Option<HashMap<u16, u16>>, queue: Option<Arc<Mutex<VirtQueue>>>, mem: Option<GuestMemoryMmap>, rxq: Arc<Mutex<MuxerRxQ>>, epoll: Epoll, interrupt_evt: EventFd, interrupt_status: Arc<AtomicUsize>, intc: Option<Arc<Mutex<Gic>>>, irq_line: Option<u32>, proxy_map: ProxyMap, reaper_sender: Option<Sender<u64>>, }
VsockMuxerには proxy_map
というフィールドがあります。ProxyMap型のこのフィールドは、中身はHashMapです。dstポートとsrcポートをビットシフトして連結した値をプロキシーエントリーの "ID" とし、これをキーとして指定するとHashMapからTcpProxyが取り出せる、という作りになっています。
- ProxyMap
pub type ProxyMap = Arc<RwLock<HashMap<u64, Mutex<Box<dyn Proxy>>>>>;
- TcpProxy構造体
pub struct TcpProxy { id: u64, cid: u64, parent_id: u64, local_port: u32, peer_port: u32, control_port: u32, fd: RawFd, pub status: ProxyStatus, mem: GuestMemoryMmap, queue: Arc<Mutex<VirtQueue>>, rxq: Arc<Mutex<MuxerRxQ>>, rx_cnt: Wrapping<u32>, tx_cnt: Wrapping<u32>, last_tx_cnt_sent: Wrapping<u32>, peer_buf_alloc: u32, peer_fwd_cnt: Wrapping<u32>, push_cnt: Wrapping<u32>, pending_accepts: u64, }
1.の処理(tsi_create_proxy())は、TSI_PROXY_CREATE
というメッセージを、制御用VSOCKソケット(csocket)を使ってVMMに対して送信し、この後connectするために使用するプロキシーエントリーの作成を依頼します。
このVSOCKメッセージを受けたVMMは、
- VsockMuxer.send_dgram_pkt() @src/devices/src/virtio/vsock/muxer.rs
- VsockMuxer.process_proxy_create() @src/devices/src/virtio/vsock/muxer.rs
という流れで処理が進み、TcpProxyのエントリーを作成後proxy_mapに格納します。
2.の処理では、TSI_CONNECT
というメッセージを、制御用VSOCKソケット(csocket)を使ってVMMに送信し、プロセスからのconnect(2)をlibkrunに代替してもらうよう依頼します。
このVSOCKメッセージを受けたVMMの中では、
- VsockMuxer.send_dgram_pkt() @src/devices/src/virtio/vsock/muxer.rs
- VsockMuxer.process_connect() @src/devices/src/virtio/vsock/muxer.rs
- TcpProxy.connect() @src/devices/src/virtio/vsock/tcp.rs
- nix::connect() @src/sys/socket/mod.rs
- libc::connect() @/src/unix/mod.rs
という流れで最終的にconnect(2)システムコールを呼び出します。
write(2)時の動き
Linuxカーネルにおいて、ソケットに対してwrite(2)した場合、socket_file_ops
の .write_iter()
メンバ関数が呼ばれます。
static const struct file_operations socket_file_ops = { .owner = THIS_MODULE, .llseek = no_llseek, .read_iter = sock_read_iter, .write_iter = sock_write_iter, .poll = sock_poll, .unlocked_ioctl = sock_ioctl, #ifdef CONFIG_COMPAT .compat_ioctl = compat_sock_ioctl, #endif .mmap = sock_mmap, .release = sock_close, .fasync = sock_fasync, .sendpage = sock_sendpage, .splice_write = generic_splice_sendpage, .splice_read = sock_splice_read, .show_fdinfo = sock_show_fdinfo, };
さらにその後、
sock_write_iter() → sock_sendmsg() → sock_sendmsg_nosec() → sock->ops->sendmsg()
という流れになり、最終的に struct proto_ops
のメンバ関数 sendmsg()
が呼び出されます。AF_TSIの場合は tsi_stream_sendmsg()
(TCPの場合) もしくは tsi_dgram_sendmsg()
(UDPの場合) が呼ばれることになります。ここではTCPを想定して tsi_stream_sendmsg()
を見ていきましょう。
static int tsi_stream_sendmsg(struct socket *sock, struct msghdr *msg, size_t len) { struct sock *sk = sock->sk; struct tsi_sock *tsk; struct socket *isocket; struct socket *vsocket; int err; lock_sock(sk); tsk = tsi_sk(sock->sk); isocket = tsk->isocket; vsocket = tsk->vsocket; pr_debug("%s: s=%p vs=%p is=%p st=%d\n", __func__, sock, vsocket, isocket, tsk->status); switch (tsk->status) { case S_HYBRID: err = -EINVAL; break; case S_INET: err = isocket->ops->sendmsg(isocket, msg, len); break; case S_VSOCK: err = vsocket->ops->sendmsg(vsocket, msg, len); pr_debug("%s: s=%p vs=%p is=%p st=%d exit\n", __func__, sock, vsocket, isocket, tsk->status); break; } release_sock(sk); return err; }
connect(2)時にtsk->statusに S_VSOCK
を入れているので、ここではvsocketを使って vsocket->ops->sendmsg()
でデータを送信していることがわかります。VSOCKでのsendmsg()では、最終的に VIRTIO_VSOCK_OP_RW
というメッセージをVMMに対して依頼します。
このVSOCKメッセージを受けたVMMでは、以下の流れで処理が進み、最終的にsend(2)を使ってファイルディスクリプタにデータを書き込みます。
- VsockMuxer.send_stream_pkt() @src/devices/src/virtio/vsock/muxer.rs
- VsockMuxer.process_stream_rw() @src/devices/src/virtio/vsock/muxer.rs
- TcpProxy.sendmsg() @src/devices/src/virtio/vsock/tcp.rs
- nix::send() @src/sys/socket/mod.rs
- libc::send() @src/unix/mod.rs
まとめ
というわけで、libkrunのゲスト内のプロセスから外部のIPアドレスに対してソケット通信をする様子を追いかけてみました。libkrunではvirtio-netを使用しないため、ゲストはループバックインターフェースしか持っていません。しかし、TSIの巧妙な仕組みによってINET通信をVSOCKに変換し、VMM内に持つINET-VSOCKプロキシーを介して、ゲスト↔VMMはVSOCKを、VMM↔外部はINETを使って通信する様子がわかりました。