# 容器部署
这份文档存在的理由有两个,都不是「怎么打个包」:
1. **`cap_drop: ALL` 在这里是可以成立的。** 全项目最核心的约束是「不需要 root、不需要 tun
设备、不需要改路由表」(README 开头那句),在宿主机上这句话只能靠读代码相信;在容器里
它变成一条可以当场验证的断言——把所有 capability 丢掉、rootfs 只读、非 root uid,服务
照常工作。`docker-compose.yml` 里那几行安全选项不是装饰,是这个设计的验收条件。
2. **宿主机的默认配置在容器里有两处是错的**,而且都是「静默地错」:监听环回口会让发布的
端口连不到任何东西,相对路径的状态文件会写到一个随容器一起消失的层里。
先读哪一份:镜像与编排的**为什么**在这里,全部配置项的含义在
[etc/openvpngate.conf](../etc/openvpngate.conf),设计本身在
[ARCHITECTURE.md](ARCHITECTURE.md)。
---
## 1. 快速开始
```sh
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 <节点>` 才算真正可用。
验证:
```sh
curl -x socks5h://alice:口令@127.0.0.1:1080 https://ifconfig.me
```
`socks5h` 让 curl 把域名交给代理去解析(DNS 走隧道,不泄漏);`socks5` 是本地解析后只把
IP 交过来。两条路径都支持,但只有前者是你部署 VPN 网关想要的那条。
不想在本机编译 openvpn3 的话,[§9](#9-ci-与-ghcr-发布) 说了怎么改成拉现成镜像。
---
## 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_path`
`selector.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 里这几行是断言,也是测试:
```yaml
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
read_only: true
```
跑起来之后自己查:
```sh
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](FEASIBILITY.md) §2 算过。
**如果哪天某个改动让上面任何一行不得不放开,那个改动是错的。**
---
## 6. 日常运维
```sh
# 换了凭据 / 想立刻刷新节点列表;不断开任何在途连接
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 就没这个待遇,宿主机目录的属主说了算:
```sh
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 步骤),剩下三条只能在真机上验。
接手后按顺序跑一遍就能补齐:
```sh
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:` 改成:
```yaml
image: ghcr.io//:master
```
`docker compose pull && docker compose up -d` 即可。注意 `docker/openvpngate.conf` 和
`docker/socks5.auth` 仍然要从本仓库挂进去(§3),镜像里那份只是兜底默认值。
### 这个仓库的 remote 不是 GitHub
`origin` 指向 `git.sfclub.cc`。`.github/workflows/` 只有在下面两种情况下会跑:
1. **镜像到 github.com**:什么都不用配。内置的 `GITHUB_TOKEN` 配合 workflow 里的
`permissions: packages: write` 就能推 ghcr.io,包会挂在镜像仓库名下。
2. **在 Gitea/Forgejo Actions 上跑**:它们认这个 workflow 语法,但它们发的 `GITHUB_TOKEN`
认证的是**自己那个 registry**,推不了 ghcr.io。需要在仓库里配:
| 名字 | 类型 | 值 |
|---|---|---|
| `GHCR_TOKEN` | secret | GitHub PAT,勾 `write:packages` |
| `GHCR_USER` | variable | 那个 PAT 对应的 GitHub 用户名 |
| `GHCR_IMAGE` | variable | 目标镜像名,如 `icybear/openvpngate`。这里的仓库名和 GitHub 上想要的名字不一定一样 |
三个都是「有就用、没有就退回内置值」,所以在 github.com 上不配也不会碍事。
首次推送后包默认是私有的。要让别人 `docker pull` 得先在 GitHub 的 Package settings 里改成
public,或者让对方 `docker login ghcr.io`。