Docker 中的 OpenVPN:最小客户端所需的运行时契约
一个经过审核的镜像、一份受保护的配置、TUN 访问与 NET_ADMIN。容器得到什么、不该得到什么,以及如何证明隧道可用。
把 VPN 客户端放进容器,你得到的是一个内部带隧道的命名空间,而不是一台把全部流量都送进 VPN 的主机。这是两个不同的项目,把它们混为一谈,正是容器意外获得特权的来由。本手册只讲前者:一个经审核的镜像、一份受保护的配置,以及一个被限制在自身网络命名空间内的客户端进程。
从别人已经批准过的产物开始。以下步骤假设镜像中包含 openvpn 可执行文件,以及一份完整的客户端配置,其证书和密钥材料已在挂载内解析完毕。这些步骤不构建软件、不签发凭据,也不覆盖无 root 运行时和原生 Windows 容器,因为这些情况都会改变答案。
容器得到什么,得不到什么
把配置及其引用的全部内容放在权限受限的本地目录中,以只读方式挂载。配置里的相对路径必须能在该挂载内解析;当证书位于配置上一级目录、而容器根本看不到它时,人们常常忽略这个细节。
services:
vpn:
image: ${VPN_CLIENT_IMAGE:?set the reviewed image reference}
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun:/dev/net/tun
volumes:
- ./vpn:/vpn:ro
working_dir: /vpn
entrypoint: ["openvpn"]
command: ["--config", "/vpn/client.ovpn"]
该文件只授予三样东西,不要求其他任何东西。如果部署策略要求,镜像引用应按 digest 固定;配置应当是经审核的那份,而不是有人直接在主机上改过的副本。
这里的只读意味着容器无法修改主机上的文件;并不意味着容器读不到你挂载的私钥。这正是挂载的意义所在,也正因如此,文件权限、属主以及密钥如何落到主机上仍然重要。绝不要在命令行上传递密码,也不要把它烤进镜像层,因为两者都会残留在他处,事后无法清理。
能力、entrypoint 以及两者之间的陷阱
需要两项设置,它们各司其职。NET_ADMIN 允许在容器的命名空间内操作接口和路由。/dev/net/tun 设备条目则允许进程打开隧道设备本身。只给其中一项会产生明确的失败,这很方便:缺少 TUN 设备会在打开时失败,而缺少能力则会在客户端配置接口时失败。
两者都不能成为 privileged: true 的理由。特权容器会获得全部能力外加设备访问权限,这远超该工作负载所需。运行时特权与能力参考 说明了这一授权能延伸多远。如果某个 chart 或博客文章要求这样做,先检查需求是否其实是缺少设备路径或缺少能力。
覆盖 entrypoint 才是真正会咬人的部分。直接调用 openvpn 对普通镜像是正确的,但对任何在产品镜像里由启动脚本先准备防火墙规则、解析器文件或凭据再执行守护进程的情况都是错误的。覆盖该脚本会跳过准备步骤,让你去调试一个在半配置命名空间里跑起来的隧道。
让客户端在前台运行,并让它成为容器的主进程。转为守护进程的进程会立即退出,容器陷入重启循环,而你需要的日志分散在一次次看起来完全相同的重启之间。
从命名空间内部验证,而不是从主机验证
启动栈,在日志中确认客户端进入已连接状态,然后检查真正持有该隧道的命名空间。
docker compose up -d
docker compose logs --tail 40 vpn
docker compose exec vpn ip -br addr
docker compose exec vpn ip route
地址列表应显示一个带有池内地址的隧道接口,路由表应显示指向它的推送目标。如果接口不存在,说明客户端从未走到创建它的那一步。如果接口存在但没有任何路由使用它,该看的地方就是 push 或路由选项。
然后从应使用该隧道的命名空间测试你关心的目的地,用应用而不是只发 ping。名称解析是另一类故障,值得单独测试:推送的 DNS 地址不会自行更新容器解析器,而忽略 push 的独立客户端会通过镜像所配置的方式解析名称。
断开状态也是契约的一部分
重连行为需要单独检查,因为重启策略只能证明进程会重启,并不能说明中间这段时间流量会怎样。停掉隧道,尝试目标目的地,观察连接是被拒绝、挂起,还是悄悄通过容器的普通路由成功。最后一种才是要紧的,而它在健康日志里完全看不出来。
如果工作负载绝不允许走普通路径,就用防火墙规则和命名空间内的就绪信号显式地设计它。kill switch 是你自己编写并测试的策略,而不是把客户端放进容器就继承来的属性。
最后,记住边界:这些路由存在于容器的命名空间里,并不替代主机的路由表。Docker 的转发、地址转换与防火墙规则仍然是外层路径的一部分,而发布的端口并不是 VPN 接口。