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这里的 dockerd 是 revm 的 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 portTunnelHostUnixToGuest 的名字也正好描述了这个方向:从 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 catcat 需要看到 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。

