The Dockerfile's builder stage already runs the unit suite, so the workflow deliberately has no separate test job -- a red test cannot produce an image. After pushing, the published artifact is smoke-tested by digest: `--version` covers a runtime stage missing a shared library, and `--check` against a throwaway credentials file covers the config baked into the image. Both were failure modes a green build would not have caught. The `--check` invocation is verified locally against docker/openvpngate.conf. `latest` follows the newest v* tag rather than the branch head; master head is published as `master`. Registry paths are lowercased explicitly rather than relying on metadata-action, since the same value is reused for the smoke test. GHCR_TOKEN / GHCR_USER / GHCR_IMAGE override the built-ins so this still works from a mirror or a Gitea/Forgejo runner, where the ambient token authenticates to the wrong registry. On github.com none of them need to be set. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
15 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 网关想要的那条。
不想在本机编译 openvpn3 的话,§9 说了怎么改成拉现成镜像。
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/openvpngate.conf本身是实测能解析的:拿它配一个临时凭据文件跑openvpngate -c docker/openvpngate.conf --check,输出config ok且socks5 0.0.0.0:1080 auth=required users=1——即 §3 那三处覆盖确实生效了。CI 里对镜像跑的 就是同一条命令;docker-compose.yml与.github/workflows/publish-image.yml的 YAML 结构解析通过。
没有验证
- 镜像构建本身:apt 装包、两个 clone、CMake 配置与编译、构建阶段跑测试;
HEALTHCHECK是否真的能命中/healthz;read_only: true下有没有哪个库偷偷要写别处(/tmp的 tmpfs 是按这个可能性预留的);create_host_path: false的报错行为;- 容器里
CapEff是否真是全零(按 Docker 语义应当如此,但没实跑)。
上面前两条会在 CI 第一次跑通时自动补上(§9 的 smoke 步骤),剩下三条只能在真机上验。
接手后按顺序跑一遍就能补齐:
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,就是它在容器里的样子。
9. CI 与 GHCR 发布
.github/workflows/publish-image.yml 构建镜像并推到 GitHub Container Registry。
它没有单独的测试 job,这是故意的:Dockerfile 的构建阶段最后一步就是跑完整单元测试
(OVG_RUN_TESTS=1),测试挂了镜像就构建不出来,也就发布不了。再加一个跑同一套测试的 job
只是把同样的编译做第二遍。
推完之后还有一步 smoke:按 digest(不是 tag)把刚发布的那个镜像拉回来,跑
--version,再挂一个临时凭据文件跑 --check。这两下覆盖的正是「构建绿了也不代表能跑」的
两件事——运行时阶段有没有漏装共享库,以及镜像里烤进去的那份配置能不能解析。
产出的 tag:
| 触发 | tag |
|---|---|
push 到 master |
master、sha-<短 hash> |
push tag v1.2.3 |
1.2.3、1.2、latest、sha-<短 hash> |
| pull request | 照常构建并跑测试,不推 |
latest 跟的是最新的 release tag,不是分支头。在打出第一个 v* 之前,要拉的是 master。
拉现成镜像而不是本地编译,把 docker-compose.yml 里的 build: 整块删掉,image: 改成:
image: ghcr.io/<owner>/<repo>:master
docker compose pull && docker compose up -d 即可。注意 docker/openvpngate.conf 和
docker/socks5.auth 仍然要从本仓库挂进去(§3),镜像里那份只是兜底默认值。
这个仓库的 remote 不是 GitHub
origin 指向 git.sfclub.cc。.github/workflows/ 只有在下面两种情况下会跑:
-
镜像到 github.com:什么都不用配。内置的
GITHUB_TOKEN配合 workflow 里的permissions: packages: write就能推 ghcr.io,包会挂在镜像仓库名下。 -
在 Gitea/Forgejo Actions 上跑:它们认这个 workflow 语法,但它们发的
GITHUB_TOKEN认证的是自己那个 registry,推不了 ghcr.io。需要在仓库里配:名字 类型 值 GHCR_TOKENsecret GitHub PAT,勾 write:packagesGHCR_USERvariable 那个 PAT 对应的 GitHub 用户名 GHCR_IMAGEvariable 目标镜像名,如 icybear/openvpngate。这里的仓库名和 GitHub 上想要的名字不一定一样三个都是「有就用、没有就退回内置值」,所以在 github.com 上不配也不会碍事。
首次推送后包默认是私有的。要让别人 docker pull 得先在 GitHub 的 Package settings 里改成
public,或者让对方 docker login ghcr.io。