Mewz (WebAssembly x Unikernel) を libkrun で動かしてみた - 外部記憶装置 の詳細を記すシリーズ
- その1 libkrun を試す
- その2 libkrun の構造
- その3 Mewz 追加実装(Linux zeropage, kernel cmd params)
- その4 Mewz 追加実装(Virtio MMIO)
- その5 Mewz on libkrun してみた
- その6 Mewz 追加実装(virtio-console)
- その7 virtio-vsock について
- その8 Mewz 追加実装(virtio-vsock)
- その9 TSI の仕組み ← この記事
目次
TSI とは
Transparent Socket Impersonation (TSI) は、ゲストOS側の TCP/IP スタックに代わり virtio-vsock 経由で TCP/IP のソケットを実現する likbrun の機能である。 TSI が用いられる背景としては、他のコンテナと共存するためにホスト側のアドレスを利用したいという需要があるのではないかと思う。 ゲストが virtio-net を用いる場合は、passt によりいったん接続を終端してホスト側のアドレスを使う構造となっているが性能面において難があるため TSI が実装されたと考えられる。 詳細は以下の記事で解説されている。
同記事より、TSI の全体図を引用する。
ゲスト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 のパケロスが生じないものが概念としては類似している。
libkrun では、ポートに応じて制御通信の用途を切り替えている。
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) に対して送信する。
#[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
) を指定する。
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
)を制御通信経由で送信する。
#[repr(C)] pub struct TsiReleaseReq { pub peer_port: u32, pub local_port: u32, }
作成時と異なり、local_port
を指定する必要がある。
これは通信用 vsock のホスト側(libkrun) のローカルポートに対応する。
このようになっている理由としては、ゲスト側で accept
を行い接続を受け入れると、その都度新しい Proxy が作成されるためである。
新しく作られた接続に関する通信用の vsock の libkrun 側のローカルポートはランダムに生成され、それが Proxy の管理 id として用いられる。
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
)を送信する。
#[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
なソケットを用いて接続を試みる。
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 経由で返送する。
#[repr(C)] pub struct TsiConnectRsp { pub result: i32, }
ゲスト側はこのパケットの受信をもって接続処理が完了したとみなし、結果に応じて後続の処理を行う。
TSI_LISTEN
ゲスト側で listen
を行う場合、bind
するポートやそれを受け入れる通信用 vsock のポートを通知する必要がある。
ゲスト側は下記のパケット(TsiListenReq
)をlibkrun に対して送信する。
#[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)
を行う。
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
パケットを返送し完了通知を行う。
#[repr(C)] #[derive(Debug)] pub struct TsiListenRsp { pub result: i32, }
TSI_ACCEPT
accept
についてはこれまでと異なるフローとなっている。
まず、ゲスト側から libkrun に対して下記のパケットを送信し、新しい接続の有無を確認する。
#[repr(C)] #[derive(Debug)] pub struct TsiAcceptReq { pub peer_port: u32, pub flags: u32, }
libkrun 側では、pending となっている接続が存在する、ないしはノンブロックな場合はそれに応じて TsiAcceptRes
を返送する。
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 }
#[repr(C)] #[derive(Debug)] pub struct TsiAcceptRsp { pub result: i32, }
ここで、AF_INET 側の accept(2)
の処理について追いかける。
Listen を行うと、libkrun 側では Proxy に対応するワーカースレッドが立ち上がり、接続待機状態になる。
Proxy の process_event
では、epoll(7)
で通知されたイベントに応じて accept(2)
を行う。
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
を送信し接続処理を開始する。
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 をインクリメントする。
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; } }
#[repr(C)] #[derive(Debug)] pub struct TsiListenRsp { pub result: i32, }
機能としては AF_INET で受け入れた接続について通信用 vsock で先んじて接続を行い、accept については API として新規接続の有無の確認のみ行っていることになる。
TSI_GETNAME
getpeername(2)
に相当する機能を提供する。
ゲスト側はlibkrun に対して TsiGetnameReq
パケットを送信する。
#[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
に入れて返す。
#[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に対して送信する。
#[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 への実装を行う。