Skip to main content

revm 中的 TunnelHostUnixToGuest 设计逻辑

·713 words·4 mins
IHEXON
Author
IHEXON
Do You Hear the People Sing ?

TunnelHostUnixToGuest 是一个Podman API 代理函数,它只做一件事:

把 host 上一个 Unix socket 收到的连接,通过 gvproxy tunnel 转发到 guest VM 里的 Podman API TCP 端口。

这篇文章记录几个关键的设计点:host/guest 边界、CLI 兼容性、网络后端隔离、连接生命周期,以及 Docker/Podman hijack stream 里的 half-close 语义。

问题背景
#

revm container mode 想提供一个轻量的容器 VM:host 上运行 docker / podman CLI,真正的容器环境跑在 libkrun 启动的 Linux guest 里。

从用户视角看,它应该像这样工作:

./dockerd --id dev

export DOCKER_HOST=unix://$HOME/.cache/revm/dev/socks/podman-api.sock
docker ps
docker run --rm alpine uname -a

这里的 dockerdrevm 的 CLI 入口,不是 Docker 官方的 daemon。VM 里真正响应容器 API 的是 Podman API service。Podman 提供 Docker-compatible API,因此 Docker CLI 可以通过这个 socket 工作。

这里有一个接口形态不匹配的问题:

Docker/Podman CLI 习惯连接 host 上的 Unix socket
Podman API 实际运行在 guest VM 里的 TCP 端口

TunnelHostUnixToGuest 就是在这个不匹配处做边界适配。

核心链路
#

container mode 下的代理链路可以概括为:

flowchart LR
    cli["docker / podman CLI"]
    hostSock["host podman-api.sock"]
    tunnelFn["TunnelHostUnixToGuest"]
    gvproxySock["host gvproxy socket"]
    gvproxy["gvproxy"]
    guestAPI["guest Podman API
192.168.127.2:port"] cli --> hostSock hostSock --> tunnelFn tunnelFn --> gvproxySock gvproxySock --> gvproxy gvproxy --> guestAPI

这条链路里有三个重要边界:

CLI 边界:host CLI 只看到 Unix socket
VM 边界:容器 API 实际在 guest 内
网络边界:host 进程不能直接假设 guest 网络如何实现

TunnelHostUnixToGuest 的价值在于把这三个边界收在一个很窄的地方处理。外部 CLI 不需要知道 VM,guest Podman API 不需要知道 host Unix socket,gvproxy 负责具体的 host/guest 网络穿透。

为什么 host 上要暴露 Unix socket
#

Docker CLI 和 Podman CLI 都天然支持 Unix socket:

export DOCKER_HOST=unix:///path/to/socket
export CONTAINER_HOST=unix:///path/to/socket

因此 revm 选择在 host 上暴露一个 podman-api.sock,而不是要求用户连接某个随机 TCP 端口。

这个选择有几个好处:

符合 Docker/Podman CLI 的默认使用模型
避免在 host 上开放额外 TCP 监听
socket 文件可以自然跟随 session 目录管理
权限可以用文件系统权限表达

也就是说,host Unix socket 是对用户友好的 API surface。它是 revm 对外承诺的接口,而不是内部实现细节。

为什么 guest 侧是 TCP
#

在 gvisor 网络模式下,guest 里的 Podman API 监听在一个 TCP 地址上。revm 里通常是:

192.168.127.2:<podman-api-port>

这不是偶然的。gvproxy/gvisor-tap-vsock 本来就是在 host 和 guest 之间提供虚拟网络能力。通过它转发到 guest TCP 端口,比尝试直接暴露 guest Unix socket 更符合这套网络模型。

所以最终形成了一个有意识的接口转换:

host Unix socket  ->  gvproxy tunnel  ->  guest TCP port

TunnelHostUnixToGuest 的名字也正好描述了这个方向:从 host 侧 Unix socket,到 guest 里的服务。

为什么中间需要 gvproxy tunnel
#

host 进程不能直接 Dial("tcp", "192.168.127.2:port") 并假设它一定能工作。guest 的地址存在于 gvisor-tap-vsock 管理的虚拟网络里,host/guest 通信需要通过 gvproxy 的控制 socket 建立 tunnel。

所以 TunnelHostUnixToGuest 的每条连接大致经历这些步骤:

flowchart TD
    accept["accept client connection"]
    dial["dial gvproxy socket"]
    request["request tunnel"]
    note["POST /tunnel?ip=guest&port=podman"]
    bridge["bridge two streams"]
    finish["finish on EOF or cancellation"]

    accept --> dial
    dial --> request
    request -.-> note
    request --> bridge
    bridge --> finish

这里的 request tunnel 对应的是 gvproxy 的 /tunnel 能力。revm 不需要理解 gvisor 网络栈细节,只需要告诉 gvproxy:

请把这条连接转发到 guest 的 192.168.127.2:<podman-api-port>

这体现了一个很重要的设计原则:revm 不越过 gvproxy 直接操作 guest 网络,而是把 guest 网络穿透交给网络后端。

TunnelHostUnixToGuest 的职责边界
#

我觉得这段代码最值得保留的设计点,是它的职责足够窄。

它做:

创建 host Unix listener
accept CLI 连接
为每条连接 dial gvproxy
通过 gvproxy 建立到 guest IP:port 的 tunnel
双向复制字节
处理 context cancellation
传播 half-close

它不做:

不解析 Docker API
不理解 Podman API
不关心容器命令是什么
不管理 guest Podman 生命周期
不决定网络模式如何实现

这很重要。因为 Docker API 里既有普通 HTTP 请求,也有 attach/exec 这种 hijacked stream。如果 proxy 层开始理解上层协议,很容易把一个简单的字节隧道写成半个 Docker daemon。

TunnelHostUnixToGuest 的设计哲学是:只做传输层适配,不进入应用层语义。

为什么不是一个通用 TCP proxy
#

它确实很像通用 proxy,但它不是完全通用的 TCP proxy。

它服务的是一个明确场景:

host Docker/Podman CLI
通过 Unix socket
访问 guest Podman API
并且需要支持 Docker hijack stream

因此它的设计不是“抽象到可以代理世界上一切 TCP 连接”,而是“足够通用地转发字节,同时准确保留 Docker/Podman CLI 需要的连接语义”。

这也是 CloseWrite 变得关键的原因。

CloseWrite 不是细节
#

在普通 request/response HTTP 里,很多人会把连接生命周期想得很简单:

请求发完
响应读完
连接关闭

但 Docker/Podman API 里有 hijacked stream。典型场景是:

docker attach
docker exec -i
docker run -it

这些场景里,连接是双向流:

stdin 方向:CLI -> guest process
stdout/stderr 方向:guest process -> CLI

这两个方向的生命周期不一定同时结束。一个非常典型的状态是:

stdin 已经 EOF
stdout/stderr 还要继续返回

如果 proxy 在 stdin EOF 时直接 Close() 整条连接,就会把 stdout/stderr 也切断。这会造成输出截断。

如果 proxy 完全不传播 stdin EOF,guest 里的进程可能永远不知道输入结束。例如:

echo hello | docker exec -i container cat

cat 需要看到 stdin EOF 才能退出。如果 EOF 停在 proxy 这里,guest 进程可能继续等输入。

正确语义是 half-close:

stdin EOF -> CloseWrite(tunnelConn)
stdout/stderr 方向继续保留

所以 CloseWrite 不是一个“小优化”,而是 Docker/Podman stream 能否正确结束的协议语义。

Docker CLI 也是这么想的
#

Docker CLI 的 hijack stream 逻辑也是这个模型。

它会同时启动 input 和 output copy。input copy 结束后,Docker CLI 会调用 hijacked response 的 CloseWrite(),向 daemon 表达:

我不会再写 stdin 了
但我还要继续读 stdout/stderr

之后,如果还有 output stream,Docker CLI 会继续等待 output 结束,而不是因为 input 结束就退出。

也就是说,Docker CLI 本身就依赖 half-close:

sequenceDiagram
    participant CLI as Docker CLI
    participant Proxy as revm proxy
    participant Guest as guest Podman API

    CLI->>Proxy: stdin bytes
    Proxy->>Guest: forward stdin bytes
    CLI-->>Proxy: stdin EOF / CloseWrite
    Proxy-->>Guest: CloseWrite tunnel side
    Guest->>Proxy: stdout / stderr continues
    Proxy->>CLI: forward output
    Guest-->>Proxy: output EOF
    Proxy-->>CLI: CloseWrite client side

因此 revm proxy 的责任不是隐藏 half-close,而是把 half-close 正确传过去。

设计总结
#

TunnelHostUnixToGuest 的设计可以压缩成一句话:

在不理解 Docker/Podman API 的前提下,把 host CLI 期望的 Unix socket 连接,可靠地映射成 guest Podman API 的 TCP stream。

它的几个核心取舍是:

对外暴露 Unix socket,因为 CLI 生态天然支持
对内使用 guest TCP,因为 gvisor/gvproxy 的网络模型适合这样转发
通过 gvproxy tunnel 穿过 host/guest 网络边界
只做字节转发,不解析 Docker API
保留 half-close,因为 Docker hijack stream 依赖它
用 context cancellation 统一回收连接资源
把不支持的网络组合挡在更早的配置阶段

所以这段代码的重点不是“怎么 copy 两个 conn”,而是边界设计:

flowchart TD
    api["host-facing API
Unix socket"] adapter["TunnelHostUnixToGuest
boundary adapter"] backend["network backend
gvproxy tunnel"] guest["guest service
Podman API TCP"] stream["stream semantics
half-close"] api --> adapter adapter --> backend backend --> guest stream -.-> adapter

只要这个边界保持干净,revm 的 container mode 就可以继续把复杂性压在内部,同时让外部 CLI 看到一个简单、熟悉的本地 socket。