Multi-stage Dockerfile on debian:trixie-slim. openvpn3 and lwIP are cloned at pinned refs and handed to CMake through OVG_OPENVPN3_DIR/OVG_LWIP_DIR rather than left to FetchContent, whose GIT_TAG master would make the same Dockerfile build a different VPN client each week. The unit suite runs in the builder stage. docker/openvpngate.conf overrides only the keys whose host default is wrong inside a container -- loopback listen addresses, which make a published port reach nothing, and relative state paths, which put the node failure history on a layer that gets thrown away. Everything else stays absent and takes the compiled-in default so the file cannot drift from the code. The compose example drops every capability, runs read-only as uid 10001 and publishes both ports to host loopback: the admin endpoint has no auth and includes POST /switch. That configuration is the design constraint of this project (no root, no tun device) turned into something testable. docs/DOCKER.md 8 records what was checked against the source and what was not: this sandbox has no docker daemon, so neither the image build nor the compose file has actually been run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 KiB
容器部署
这份文档存在的理由有两个,都不是「怎么打个包」:
cap_drop: ALL在这里是可以成立的。 全项目最核心的约束是「不需要 root、不需要 tun 设备、不需要改路由表」(README 开头那句),在宿主机上这句话只能靠读代码相信;在容器里 它变成一条可以当场验证的断言——把所有 capability 丢掉、rootfs 只读、非 root uid,服务 照常工作。docker-compose.yml里那几行安全选项不是装饰,是这个设计的验收条件。- 宿主机的默认配置在容器里有两处是错的,而且都是「静默地错」:监听环回口会让发布的 端口连不到任何东西,相对路径的状态文件会写到一个随容器一起消失的层里。
先读哪一份:镜像与编排的为什么在这里,全部配置项的含义在 etc/openvpngate.conf,设计本身在 ARCHITECTURE.md。
1. 快速开始
cp docker/socks5.auth.example docker/socks5.auth
$EDITOR docker/socks5.auth # 换掉 alice:changeme
docker compose up -d --build
docker compose logs -f
冷启动要花几十秒,这是正常的:抓 1.3 MB 的节点目录 → 对候选节点做真实 TCP 握手计时 →
和地球另一端一台志愿者跑的服务器完成 OpenVPN 握手。镜像里 HEALTHCHECK 的
--start-period=90s 就是按这个量级给的。日志里出现 egress ready on <节点> 才算真正可用。
验证:
curl -x socks5h://alice:口令@127.0.0.1:1080 https://ifconfig.me
socks5h 让 curl 把域名交给代理去解析(DNS 走隧道,不泄漏);socks5 是本地解析后只把
IP 交过来。两条路径都支持,但只有前者是你部署 VPN 网关想要的那条。
2. 镜像里有什么
| 路径 | 内容 |
|---|---|
/usr/local/bin/openvpngate |
主程序,以 uid/gid 10001 运行,ENTRYPOINT |
/usr/local/bin/ovg_tunnel_smoke |
只建一条隧道、ping、退出的诊断工具。接手一台新机器后第一个该跑的东西:它能把「镜像坏了」和「这个网络到不了 VPNGate」分开 |
/etc/openvpngate/openvpngate.conf |
容器默认配置,compose 会挂载覆盖它 |
/var/lib/openvpngate |
唯一需要可写的目录:节点缓存 + 节点历史。工作目录也是这里 |
构建参数:
--build-arg |
默认 | 说明 |
|---|---|---|
DEBIAN_TAG |
trixie-slim |
改它要同时改运行时包名:bookworm 是 libssl3/libfmt9,trixie 是 libssl3t64/libfmt10 |
OPENVPN3_REF |
1512c166… |
openvpn3 的固定 commit |
LWIP_REF |
STABLE-2_2_1_RELEASE |
lwIP 的 tag |
OVG_WITH_TUNNEL |
ON |
OFF 只编 direct 出口,不拉 openvpn3/lwIP,几秒编完;此时二进制拒绝以 tunnel 模式启动 |
OVG_RUN_TESTS |
1 |
在构建阶段跑完整单元测试。构建机没有环回网络时关掉(十几个测试要 bind 127.0.0.1) |
BUILD_JOBS |
$(nproc) |
编译并发度 |
为什么要钉住 openvpn3 的 commit
cmake/Dependencies.cmake 里 openvpn3 的 FetchContent 用的是 GIT_TAG master。放着不管的
话,同一个 Dockerfile、同一个本仓库 commit,下周构建出来的是一个不同的 VPN 客户端——这在
本地开发里只是有点烦,在镜像里等于没有可复现构建。所以 Dockerfile 自己按固定 ref clone,
再通过 OVG_OPENVPN3_DIR / OVG_LWIP_DIR 交给 CMake,用的是它本来就有的那个开关。
默认那个 commit 就是本仓库开发和测试时用的那个。升级它是个需要主动做的决定,做完请重跑 测试套件。
3. 容器里必须改的三处配置
docker/openvpngate.conf 只写了这三类键,其余全部留空走编译内置默认值——这样它不会像
「复制一份完整配置再改几行」那样慢慢和代码脱节。
| 键 | 宿主机默认 | 容器里 | 为什么 |
|---|---|---|---|
socks5.listen_address |
127.0.0.1 |
0.0.0.0 |
环回口在容器里的意思是「只有本容器能连」,-p 发布出去的端口会连到一个没人监听的地址。安全性改由发布规则提供:compose 把它发布到宿主机环回口 |
admin.listen_address |
127.0.0.1 |
0.0.0.0 |
同上。但这个接口没有认证,见 §4 |
vpngate.cache_pathselector.history_path |
var/…(相对) |
/var/lib/…(绝对) |
相对路径按工作目录解析,写进容器可写层就随容器一起没了。节点历史尤其不该丢:它是「哪些节点已经坑过你」的记录,丢掉就会顶着一个好看的 API 分数再走进同一个坑 |
socks5.auth_file |
未设置 | /etc/openvpngate/socks5.auth |
凭据不进镜像层。挂载进来,SIGHUP 热重载 |
凭据文件那个 Docker 陷阱
bind mount 的源文件不存在时,Docker 的默认行为是在宿主机上建一个同名目录。之后:
- 配置文件被挂成目录 → 服务读到空配置,用内置默认值启动,监听容器内环回口,谁也连不上;
- 认证文件被挂成目录 → 解析出零条凭据,启动时打印
warning: socks5.require_auth is on but no credentials are configured; every login will be refused, 然后拒绝所有人。
所以 docker-compose.yml 里这两个挂载用的是长语法加 create_host_path: false:宁可在
up 的时候直接失败。而如果 auth_file 指向的路径完全不存在,进程会在启动时以
config: cannot open auth file: … 退出——这是好事,是快速失败。
4. 端口与暴露面
| 端口 | 内容 | 建议 |
|---|---|---|
| 1080 | SOCKS5,需要用户名/口令 | 发布到宿主机环回口,或者放进一个只有客户端在的 docker 网络 |
| 9080 | 管理 HTTP | 没有任何认证,而且包含 POST /switch |
把 9080 发布到 0.0.0.0 意味着:能访问到它的人可以读到你在线会话的列表,也可以随时强制
你的网关换节点(换节点会掐掉所有已经传过字节的连接)。这不是理论风险,是一个 HTTP POST。
不想要它的话,三处一起改:docker/openvpngate.conf 里 admin.enabled = false、命令行加
--no-admin、并把 Dockerfile 里的 HEALTHCHECK 去掉(它探的就是 /healthz)。
5. 权限:验证那句「不需要 root」
compose 里这几行是断言,也是测试:
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
read_only: true
跑起来之后自己查:
docker compose exec openvpngate cat /proc/self/status | grep -E 'Cap(Eff|Prm)|^Uid'
# Uid: 10001 10001 10001 10001
# CapPrm: 0000000000000000
# CapEff: 0000000000000000
docker compose exec openvpngate ls /dev/net/tun # No such file or directory
零 capability、非 root、没有 tun 设备,SOCKS5 照常出流量。对照绝大多数 VPN 客户端容器
需要的 --cap-add NET_ADMIN --device /dev/net/tun——省掉它们的代价是进程内自带了一个
用户态 TCP/IP 栈,这笔账 FEASIBILITY.md §2 算过。
如果哪天某个改动让上面任何一行不得不放开,那个改动是错的。
6. 日常运维
# 换了凭据 / 想立刻刷新节点列表;不断开任何在途连接
docker compose kill -s HUP openvpngate
# 手动换节点(被拒绝时返回具体原因,不是一句 false)
curl -s -XPOST 127.0.0.1:9080/switch
# 为什么选了这个节点
curl -s 127.0.0.1:9080/nodes | jq
curl -s 127.0.0.1:9080/status | jq
退出语义。 docker stop 发 SIGTERM:停止 accept → 排空 → 拆隧道 → 退出,正常在一秒内
完成。宽限期给到 30s 不是因为它慢,而是因为超时之后 Docker 发的是 SIGKILL,而那有可能落在
写节点历史的中间。容器里手动再发一次 SIGTERM 会立即退出(第二次信号的语义就是「别排空了」)。
healthcheck 是存活探针,不是就绪探针。 /healthz 只要管理服务在监听就回 200,它不表示
隧道是通的;隧道状态在 /status。特别地:不要在它上面接「unhealthy 就重启容器」的
监工。节点变差时服务自己会换节点,重启会把排空、在线会话、以及刚刚学到的「这个节点不行」
一起扔掉——恰好是在它已经在正确处理问题的时候。
日志走 stderr,由 compose 的 json-file driver 收,配了 10 MB × 5 的轮转。一个繁忙的网关 每条会话至少一行 info,不轮转就是在慢慢填满宿主机磁盘。
7. 卷与文件属主
状态目录用的是命名卷 ovg-state。空的命名卷会继承镜像里该目录的属主(Dockerfile 里
install -d -o ovg -g ovg),所以开箱即用。
换成 bind mount 就没这个待遇,宿主机目录的属主说了算:
mkdir -p ./state && sudo chown 10001:10001 ./state
uid 写死成 10001 而不是让发行版随便分配,就是为了这条命令里能有一个提前知道的数字。
read_only: true 之下,容器里唯一可写的是这个卷和 /tmp(16 MB tmpfs)。目前没有已知的
东西需要 /tmp,它在那里是为了让某个决定往 /tmp 写东西的库在写的时候就报错,而不是在
连接的时候才莫名其妙失败。
8. 这份编排验证到了什么、没验证到什么
延续 README §6 的规矩:没跑过就是没跑过。
开发这套文件的沙箱里没有 docker daemon,所以 docker build 和 docker compose up
一次都没有真正执行过。下面把「照着源码核对过的」和「没跑过的」分开列。
已核对(对着本仓库源码或在本机实测)
- 构建依赖与运行时共享库:
ldd实测二进制只依赖liblz4.so.1 / libfmt.so.10 / libssl.so.3 / libcrypto.so.3加 libc 三件套,对应的 trixie 包名逐个apt-cache policy查过; - 产物路径
build/src/openvpngate、build/src/ovg_tunnel_smoke、build/tests/ovg_tests; OVG_OPENVPN3_DIR/OVG_LWIP_DIR确实能跳过 FetchContent(cmake/Dependencies.cmake), 钉住的两个 ref 就是本机构建通过的那两个;- 配置键名、相对路径默认值、
auth_file缺失时的失败方式(config: cannot open auth file)、 空凭据时的警告文案,全部来自src/common/config.cpp与src/app/main.cpp; - CLI 参数与信号语义来自
src/app/main.cpp的 usage 与App::begin_shutdown; - 预哈希凭据格式是实测的:用文档里那条
openssl rand -hex 16+sha256sum生成一条sha256$salt$hash,起 direct 模式代理,正确口令拿到 200,错误口令和不存在的用户都被auth rejected挡下; - 管理路由清单来自
src/app/admin_server.cpp; docker-compose.yml的 YAML 结构解析通过(挂载目标、profiles、tmpfs、healthcheck 覆盖)。
没有验证
- 镜像构建本身:apt 装包、两个 clone、CMake 配置与编译、构建阶段跑测试;
HEALTHCHECK是否真的能命中/healthz;read_only: true下有没有哪个库偷偷要写别处(/tmp的 tmpfs 是按这个可能性预留的);create_host_path: false的报错行为;- 容器里
CapEff是否真是全零(按 Docker 语义应当如此,但没实跑)。
接手后按顺序跑一遍就能补齐:
docker compose build # 构建 + 构建阶段的单元测试
docker compose run --rm --entrypoint ovg_tunnel_smoke openvpngate # 数据面
docker compose up -d && docker compose ps # 看 healthy
curl -x socks5h://用户:口令@127.0.0.1:1080 https://ifconfig.me # 出口 IP 应是节点 IP
docker compose exec openvpngate cat /proc/self/status | grep CapEff # 应为全零
docker compose stop # 应在一秒内退出,不是等满 30s
最后一条尤其值得看:本项目已经被「优雅退出日志打得漂漂亮亮然后永远不退出」这类 bug 咬过
一次(README §5 末尾那段),docker stop 卡满宽限期然后被 SIGKILL,就是它在容器里的样子。