|
| 1 | +--- |
| 2 | +title: "revm 中的 TunnelHostUnixToGuest 设计逻辑" |
| 3 | +summary: "从设计角度记录 revm container mode 中 TunnelHostUnixToGuest 的位置:为什么需要 host Unix socket 到 guest TCP 的代理、为什么通过 gvproxy 建 tunnel,以及为什么 CloseWrite 是流式 Docker/Podman API 的关键语义。" |
| 4 | +description: "从设计角度记录 revm container mode 中 TunnelHostUnixToGuest 的位置:为什么需要 host Unix socket 到 guest TCP 的代理、为什么通过 gvproxy 建 tunnel,以及为什么 CloseWrite 是流式 Docker/Podman API 的关键语义。" |
| 5 | +date: 2026-05-13 |
| 6 | +draft: false |
| 7 | +categories: |
| 8 | + - "linuxvm" |
| 9 | +tags: |
| 10 | + - "revm" |
| 11 | + - "dockerd" |
| 12 | + - "gvproxy" |
| 13 | + - "podman" |
| 14 | + - "docker" |
| 15 | + - "network" |
| 16 | +--- |
| 17 | + |
| 18 | +`TunnelHostUnixToGuest` 是一个Podman API 代理函数,它只做一件事: |
| 19 | + |
| 20 | +把 host 上一个 Unix socket 收到的连接,通过 gvproxy tunnel 转发到 guest VM 里的 Podman API TCP 端口。 |
| 21 | + |
| 22 | +这篇文章记录几个关键的设计点:host/guest 边界、CLI 兼容性、网络后端隔离、连接生命周期,以及 Docker/Podman hijack stream 里的 half-close 语义。 |
| 23 | + |
| 24 | +<!--more--> |
| 25 | + |
| 26 | +## 问题背景 |
| 27 | + |
| 28 | +`revm` container mode 想提供一个轻量的容器 VM:host 上运行 `docker` / `podman` CLI,真正的容器环境跑在 libkrun 启动的 Linux guest 里。 |
| 29 | + |
| 30 | +从用户视角看,它应该像这样工作: |
| 31 | + |
| 32 | +```sh |
| 33 | +./dockerd --id dev |
| 34 | + |
| 35 | +export DOCKER_HOST=unix://$HOME/.cache/revm/dev/socks/podman-api.sock |
| 36 | +docker ps |
| 37 | +docker run --rm alpine uname -a |
| 38 | +``` |
| 39 | + |
| 40 | +这里的 `dockerd` 是 `revm` 的 CLI 入口,不是 Docker 官方的 daemon。VM 里真正响应容器 API 的是 Podman API service。Podman 提供 Docker-compatible API,因此 Docker CLI 可以通过这个 socket 工作。 |
| 41 | + |
| 42 | +这里有一个接口形态不匹配的问题: |
| 43 | + |
| 44 | +```text |
| 45 | +Docker/Podman CLI 习惯连接 host 上的 Unix socket |
| 46 | +Podman API 实际运行在 guest VM 里的 TCP 端口 |
| 47 | +``` |
| 48 | + |
| 49 | +`TunnelHostUnixToGuest` 就是在这个不匹配处做边界适配。 |
| 50 | + |
| 51 | +## 核心链路 |
| 52 | + |
| 53 | +container mode 下的代理链路可以概括为: |
| 54 | + |
| 55 | +{{< mermaid >}} |
| 56 | +flowchart LR |
| 57 | + cli["docker / podman CLI"] |
| 58 | + hostSock["host podman-api.sock"] |
| 59 | + tunnelFn["TunnelHostUnixToGuest"] |
| 60 | + gvproxySock["host gvproxy socket"] |
| 61 | + gvproxy["gvproxy"] |
| 62 | + guestAPI["guest Podman API<br/>192.168.127.2:port"] |
| 63 | + |
| 64 | + cli --> hostSock |
| 65 | + hostSock --> tunnelFn |
| 66 | + tunnelFn --> gvproxySock |
| 67 | + gvproxySock --> gvproxy |
| 68 | + gvproxy --> guestAPI |
| 69 | +{{< /mermaid >}} |
| 70 | + |
| 71 | +这条链路里有三个重要边界: |
| 72 | + |
| 73 | +```text |
| 74 | +CLI 边界:host CLI 只看到 Unix socket |
| 75 | +VM 边界:容器 API 实际在 guest 内 |
| 76 | +网络边界:host 进程不能直接假设 guest 网络如何实现 |
| 77 | +``` |
| 78 | + |
| 79 | +`TunnelHostUnixToGuest` 的价值在于把这三个边界收在一个很窄的地方处理。外部 CLI 不需要知道 VM,guest Podman API 不需要知道 host Unix socket,gvproxy 负责具体的 host/guest 网络穿透。 |
| 80 | + |
| 81 | +## 为什么 host 上要暴露 Unix socket |
| 82 | + |
| 83 | +Docker CLI 和 Podman CLI 都天然支持 Unix socket: |
| 84 | + |
| 85 | +```sh |
| 86 | +export DOCKER_HOST=unix:///path/to/socket |
| 87 | +export CONTAINER_HOST=unix:///path/to/socket |
| 88 | +``` |
| 89 | + |
| 90 | +因此 revm 选择在 host 上暴露一个 `podman-api.sock`,而不是要求用户连接某个随机 TCP 端口。 |
| 91 | + |
| 92 | +这个选择有几个好处: |
| 93 | + |
| 94 | +```text |
| 95 | +符合 Docker/Podman CLI 的默认使用模型 |
| 96 | +避免在 host 上开放额外 TCP 监听 |
| 97 | +socket 文件可以自然跟随 session 目录管理 |
| 98 | +权限可以用文件系统权限表达 |
| 99 | +``` |
| 100 | + |
| 101 | +也就是说,host Unix socket 是对用户友好的 API surface。它是 revm 对外承诺的接口,而不是内部实现细节。 |
| 102 | + |
| 103 | +## 为什么 guest 侧是 TCP |
| 104 | + |
| 105 | +在 gvisor 网络模式下,guest 里的 Podman API 监听在一个 TCP 地址上。revm 里通常是: |
| 106 | + |
| 107 | +```text |
| 108 | +192.168.127.2:<podman-api-port> |
| 109 | +``` |
| 110 | + |
| 111 | +这不是偶然的。gvproxy/gvisor-tap-vsock 本来就是在 host 和 guest 之间提供虚拟网络能力。通过它转发到 guest TCP 端口,比尝试直接暴露 guest Unix socket 更符合这套网络模型。 |
| 112 | + |
| 113 | +所以最终形成了一个有意识的接口转换: |
| 114 | + |
| 115 | +```text |
| 116 | +host Unix socket -> gvproxy tunnel -> guest TCP port |
| 117 | +``` |
| 118 | + |
| 119 | +`TunnelHostUnixToGuest` 的名字也正好描述了这个方向:从 host 侧 Unix socket,到 guest 里的服务。 |
| 120 | + |
| 121 | +## 为什么中间需要 gvproxy tunnel |
| 122 | + |
| 123 | +host 进程不能直接 `Dial("tcp", "192.168.127.2:port")` 并假设它一定能工作。guest 的地址存在于 gvisor-tap-vsock 管理的虚拟网络里,host/guest 通信需要通过 gvproxy 的控制 socket 建立 tunnel。 |
| 124 | + |
| 125 | +所以 `TunnelHostUnixToGuest` 的每条连接大致经历这些步骤: |
| 126 | + |
| 127 | +{{< mermaid >}} |
| 128 | +flowchart TD |
| 129 | + accept["accept client connection"] |
| 130 | + dial["dial gvproxy socket"] |
| 131 | + request["request tunnel"] |
| 132 | + note["POST /tunnel?ip=guest&port=podman"] |
| 133 | + bridge["bridge two streams"] |
| 134 | + finish["finish on EOF or cancellation"] |
| 135 | + |
| 136 | + accept --> dial |
| 137 | + dial --> request |
| 138 | + request -.-> note |
| 139 | + request --> bridge |
| 140 | + bridge --> finish |
| 141 | +{{< /mermaid >}} |
| 142 | + |
| 143 | +这里的 `request tunnel` 对应的是 gvproxy 的 `/tunnel` 能力。revm 不需要理解 gvisor 网络栈细节,只需要告诉 gvproxy: |
| 144 | + |
| 145 | +```text |
| 146 | +请把这条连接转发到 guest 的 192.168.127.2:<podman-api-port> |
| 147 | +``` |
| 148 | + |
| 149 | +这体现了一个很重要的设计原则:revm 不越过 gvproxy 直接操作 guest 网络,而是把 guest 网络穿透交给网络后端。 |
| 150 | + |
| 151 | +## TunnelHostUnixToGuest 的职责边界 |
| 152 | + |
| 153 | +我觉得这段代码最值得保留的设计点,是它的职责足够窄。 |
| 154 | + |
| 155 | +它做: |
| 156 | + |
| 157 | +```text |
| 158 | +创建 host Unix listener |
| 159 | +accept CLI 连接 |
| 160 | +为每条连接 dial gvproxy |
| 161 | +通过 gvproxy 建立到 guest IP:port 的 tunnel |
| 162 | +双向复制字节 |
| 163 | +处理 context cancellation |
| 164 | +传播 half-close |
| 165 | +``` |
| 166 | + |
| 167 | +它不做: |
| 168 | + |
| 169 | +```text |
| 170 | +不解析 Docker API |
| 171 | +不理解 Podman API |
| 172 | +不关心容器命令是什么 |
| 173 | +不管理 guest Podman 生命周期 |
| 174 | +不决定网络模式如何实现 |
| 175 | +``` |
| 176 | + |
| 177 | +这很重要。因为 Docker API 里既有普通 HTTP 请求,也有 attach/exec 这种 hijacked stream。如果 proxy 层开始理解上层协议,很容易把一个简单的字节隧道写成半个 Docker daemon。 |
| 178 | + |
| 179 | +`TunnelHostUnixToGuest` 的设计哲学是:只做传输层适配,不进入应用层语义。 |
| 180 | + |
| 181 | +## 为什么不是一个通用 TCP proxy |
| 182 | + |
| 183 | +它确实很像通用 proxy,但它不是完全通用的 TCP proxy。 |
| 184 | + |
| 185 | +它服务的是一个明确场景: |
| 186 | + |
| 187 | +```text |
| 188 | +host Docker/Podman CLI |
| 189 | +通过 Unix socket |
| 190 | +访问 guest Podman API |
| 191 | +并且需要支持 Docker hijack stream |
| 192 | +``` |
| 193 | + |
| 194 | +因此它的设计不是“抽象到可以代理世界上一切 TCP 连接”,而是“足够通用地转发字节,同时准确保留 Docker/Podman CLI 需要的连接语义”。 |
| 195 | + |
| 196 | +这也是 `CloseWrite` 变得关键的原因。 |
| 197 | + |
| 198 | +## CloseWrite 不是细节 |
| 199 | + |
| 200 | +在普通 request/response HTTP 里,很多人会把连接生命周期想得很简单: |
| 201 | + |
| 202 | +```text |
| 203 | +请求发完 |
| 204 | +响应读完 |
| 205 | +连接关闭 |
| 206 | +``` |
| 207 | + |
| 208 | +但 Docker/Podman API 里有 hijacked stream。典型场景是: |
| 209 | + |
| 210 | +```sh |
| 211 | +docker attach |
| 212 | +docker exec -i |
| 213 | +docker run -it |
| 214 | +``` |
| 215 | + |
| 216 | +这些场景里,连接是双向流: |
| 217 | + |
| 218 | +```text |
| 219 | +stdin 方向:CLI -> guest process |
| 220 | +stdout/stderr 方向:guest process -> CLI |
| 221 | +``` |
| 222 | + |
| 223 | +这两个方向的生命周期不一定同时结束。一个非常典型的状态是: |
| 224 | + |
| 225 | +```text |
| 226 | +stdin 已经 EOF |
| 227 | +stdout/stderr 还要继续返回 |
| 228 | +``` |
| 229 | + |
| 230 | +如果 proxy 在 stdin EOF 时直接 `Close()` 整条连接,就会把 stdout/stderr 也切断。这会造成输出截断。 |
| 231 | + |
| 232 | +如果 proxy 完全不传播 stdin EOF,guest 里的进程可能永远不知道输入结束。例如: |
| 233 | + |
| 234 | +```sh |
| 235 | +echo hello | docker exec -i container cat |
| 236 | +``` |
| 237 | + |
| 238 | +`cat` 需要看到 stdin EOF 才能退出。如果 EOF 停在 proxy 这里,guest 进程可能继续等输入。 |
| 239 | + |
| 240 | +正确语义是 half-close: |
| 241 | + |
| 242 | +```text |
| 243 | +stdin EOF -> CloseWrite(tunnelConn) |
| 244 | +stdout/stderr 方向继续保留 |
| 245 | +``` |
| 246 | + |
| 247 | +所以 `CloseWrite` 不是一个“小优化”,而是 Docker/Podman stream 能否正确结束的协议语义。 |
| 248 | + |
| 249 | +## Docker CLI 也是这么想的 |
| 250 | + |
| 251 | +Docker CLI 的 hijack stream 逻辑也是这个模型。 |
| 252 | + |
| 253 | +它会同时启动 input 和 output copy。input copy 结束后,Docker CLI 会调用 hijacked response 的 `CloseWrite()`,向 daemon 表达: |
| 254 | + |
| 255 | +```text |
| 256 | +我不会再写 stdin 了 |
| 257 | +但我还要继续读 stdout/stderr |
| 258 | +``` |
| 259 | + |
| 260 | +之后,如果还有 output stream,Docker CLI 会继续等待 output 结束,而不是因为 input 结束就退出。 |
| 261 | + |
| 262 | +也就是说,Docker CLI 本身就依赖 half-close: |
| 263 | + |
| 264 | +{{< mermaid >}} |
| 265 | +sequenceDiagram |
| 266 | + participant CLI as Docker CLI |
| 267 | + participant Proxy as revm proxy |
| 268 | + participant Guest as guest Podman API |
| 269 | + |
| 270 | + CLI->>Proxy: stdin bytes |
| 271 | + Proxy->>Guest: forward stdin bytes |
| 272 | + CLI-->>Proxy: stdin EOF / CloseWrite |
| 273 | + Proxy-->>Guest: CloseWrite tunnel side |
| 274 | + Guest->>Proxy: stdout / stderr continues |
| 275 | + Proxy->>CLI: forward output |
| 276 | + Guest-->>Proxy: output EOF |
| 277 | + Proxy-->>CLI: CloseWrite client side |
| 278 | +{{< /mermaid >}} |
| 279 | + |
| 280 | +因此 revm proxy 的责任不是隐藏 half-close,而是把 half-close 正确传过去。 |
| 281 | + |
| 282 | +## 设计总结 |
| 283 | + |
| 284 | +`TunnelHostUnixToGuest` 的设计可以压缩成一句话: |
| 285 | + |
| 286 | +```text |
| 287 | +在不理解 Docker/Podman API 的前提下,把 host CLI 期望的 Unix socket 连接,可靠地映射成 guest Podman API 的 TCP stream。 |
| 288 | +``` |
| 289 | + |
| 290 | +它的几个核心取舍是: |
| 291 | + |
| 292 | +```text |
| 293 | +对外暴露 Unix socket,因为 CLI 生态天然支持 |
| 294 | +对内使用 guest TCP,因为 gvisor/gvproxy 的网络模型适合这样转发 |
| 295 | +通过 gvproxy tunnel 穿过 host/guest 网络边界 |
| 296 | +只做字节转发,不解析 Docker API |
| 297 | +保留 half-close,因为 Docker hijack stream 依赖它 |
| 298 | +用 context cancellation 统一回收连接资源 |
| 299 | +把不支持的网络组合挡在更早的配置阶段 |
| 300 | +``` |
| 301 | + |
| 302 | +所以这段代码的重点不是“怎么 copy 两个 conn”,而是边界设计: |
| 303 | + |
| 304 | +{{< mermaid >}} |
| 305 | +flowchart TD |
| 306 | + api["host-facing API<br/>Unix socket"] |
| 307 | + adapter["TunnelHostUnixToGuest<br/>boundary adapter"] |
| 308 | + backend["network backend<br/>gvproxy tunnel"] |
| 309 | + guest["guest service<br/>Podman API TCP"] |
| 310 | + stream["stream semantics<br/>half-close"] |
| 311 | + |
| 312 | + api --> adapter |
| 313 | + adapter --> backend |
| 314 | + backend --> guest |
| 315 | + stream -.-> adapter |
| 316 | +{{< /mermaid >}} |
| 317 | + |
| 318 | +只要这个边界保持干净,revm 的 container mode 就可以继续把复杂性压在内部,同时让外部 CLI 看到一个简单、熟悉的本地 socket。 |
0 commit comments