2026-06-23
今日主题
- DevContainer 配置架构
- DevContainer 端口映射机制
- DevContainer 端口转发底层机制
- IPv4/IPv6 双栈与端口共存
- macOS 双栈 socket 端口表行为(待验证)
新增认知
DevContainer 配置架构
-
devcontainer.json 是唯一入口:VS Code 只读这一个文件,
按其中指定的路径去拉 docker-compose.yml 和 Dockerfile。除此之外它不知道任何文件的存在。
docker-compose.yml 定义"启动哪些服务",Dockerfile 定义"镜像里装什么",
devcontainer.json 定义"怎么用这个容器做开发"。 -
docker-compose.yml 不是必须的:单容器模式只需要 devcontainer.json + Dockerfile,
用 "build": { "dockerfile": "Dockerfile" } 直接指向镜像构建文件。
只有需要多服务编排(如应用 + 数据库 + 缓存)时才引入 compose。harness/codex 就是单容器模式的实例。 -
devcontainer 配置由编辑器实现,非 Docker 原生能力:
forwardPorts、customizations、postCreateCommand、remoteUser、remoteEnv 等字段全是 VS Code Dev Containers 扩展(或 JetBrains Gateway)在容器跑起来后通过 docker exec 注入 VS Code Server 再逐一执行的。
Docker 只负责容器的 build/run/卷挂载/网络,编辑器的扩展负责"开发体验"层面。
DevContainer 端口映射机制
-
两套端口映射机制并存:Docker ports(如 "5432:5432")是容器启动时就生效的原生映射,
用于 VS Code 不会 exec 进去的辅助容器(如 db、adminer);VS Code forwardPorts 通过编辑器与容器的连接隧道转发,
不依赖 Docker -p 参数,用于开发容器本身。
devcontainer.json 里的 forwardPorts 也顺手声明 compose 映射出来的端口,
方便开发者在 VS Code 界面里看到并一键打开浏览器。 -
command: sleep infinity 的必要性:devcontainer 需要一个一直活着的容器作为载体——
VS Code 不会启动容器里定义的业务进程,而是通过 docker exec 把 VS Code Server 注入进去。如果容器没有前台进程直接退出,
devcontainer 就无法启动。
DevContainer 端口转发底层机制
- bind+listen 而非拦截:
VS Code devcontainer 的端口转发本质是 VS Code 客户端在宿主机上执行 socket() → bind(127.0.0.1:8080) → listen(),
成为该端口的合法持有者。当浏览器访问 localhost:8080 时,操作系统的 TCP 栈查端口表发现 8080 的持有者是 VS Code,
于是将连接交给它。这与任何网络代理(Nginx、HAProxy)的原理完全一致,不需要内核模块、iptables 或 root 权限。
VS Code 的独特之处仅在于它的"后端"不在网络上,而是复用 docker exec 管道——将 accept() 收到的数据编码成内部协议帧,
通过 docker exec 的 stdin/stdout 发给容器内的 VS Code Server,
由后者解码还原后向容器内目标端口发起真正的 TCP 连接。本质上是一个应用层隧道代理,与 SSH 隧道(ssh -L)同思路。
IPv4/IPv6 双栈与端口共存
-
IPv4 和 IPv6 端口表是独立的:两个进程可以同时 listen 同一个端口号(如 8080),
只要它们分别绑定在 IPv4 和 IPv6 协议族上。操作系统内核的端口表按协议族分开维护,不同协议族的同名端口互不冲突,
就像两个 namespace 下的同名变量。
这就是为什么 lsof 输出中 Code Helper(IPv4)和 ___go_bui(IPv6)能同时占用 8080。 -
macOS 双栈 bind 冲突时静默降级:macOS 默认 net.inet6.ip6.v6only=0,
即 IPv6 通配 socket 默认是双栈的,会同时接收 IPv4 和 IPv6 连接。但当该 socket 尝试 bind 时发现 IPv4 端口已被占用,
内核不会报 EADDRINUSE,而是静默将 socket 降级为 IPv6-only——IPv4 半边绑定失败但不报错。
这是 BSD 系(含 macOS)的默认行为,与 Linux 不同。前提是 IPv4 socket 先于 IPv6 双栈 socket 完成 bind。
macOS 双栈 socket 端口表行为(待验证)
- macOS 双栈 IPv6 socket 可能不占 IPv4 端口表:
Go 先启动 net.Listen("tcp", ":8080") 创建双栈 IPv6 socket(IPV6_V6ONLY=0),
随后 Code Helper 再 bind(AF_INET, 127.0.0.1:8080) 成功不报错。
推测原因是 macOS 内核的双栈实现与 Linux 不同——双栈 IPv6 socket 不直接占据 IPv4 端口表,
而是通过内核 IP 层地址映射(IPv4 → ::ffff:127.0.0.1)将 IPv4 流量投递到双栈 socket。
IPv4 端口表在双栈 socket 存在时仍可被独立 bind。当后到的 IPv4 socket 占用 IPv4 端口表后,IPv4 流量优先匹配该精确条目,
不再走映射路径。这与 Linux 默认行为(net.ipv6.bindv6only=0 时双栈 socket 直接占 IPv4 端口,
后 bind 报 EADDRINUSE)不同。待找内核资料或 Linux 对比实验验证。