给模型反代加了一个子域名

· 网安 · #服务器, #命令, #折腾

我的 VPS 是一台 2 核 2G 的香港小机器,上面同时跑着博客(Docker Compose 一整套:nginx、后端、MySQL、Redis、MinIO……)和一个模型反代服务。之前这个反代只有我自己在用,访问方式是开一条 SSH 隧道。

这次是想让几个朋友也能用上,但我不想把服务器整个敞开。最后的做法是加一个子域名,用博客现有的 nginx 反代过去,两个容器之间走 Docker 内部网络。下面记一下改之前是什么样子、我是怎么考虑的,以及实际敲过的命令。

文中的域名统一写成 example.com / api.example.com,服务器地址写成 <服务器IP>,密钥一律省略。

改之前是什么样子

最早我用的是 CLIProxyAPI(容器名 cpamp-private-cpamp-1、cpaplus-private-cpaplus-1),通过本机 ~/.ssh/config 里的 cpamp 主机别名开隧道,把服务器的 8317 端口映射到本地。后来这两个容器停掉了,docker ps -a 能看到它们已经 Exited 六周,服务器上 8317 也早就没有监听,但本机的 SSH 配置还留着:

Host cpaplus cpamp
    HostName <服务器IP>
    User blog
    LocalForward 127.0.0.1:8317 127.0.0.1:8317
    LocalForward 127.0.0.1:18317 127.0.0.1:18317
    SessionType none
    ...

这段配置里有个坑:SessionType none 表示“只建隧道,不开会话”,所以 ssh cpamp '某条命令' 根本不会执行命令,只会挂在那里。我第一次排查时就被它卡住,最后加上 -o SessionType=default -o ClearAllForwardings=yes 才在服务器上跑起命令来。服务已经不在了,这段配置留着也没用,就顺手删了,删之前先备份:

cp ~/.ssh/config ~/.ssh/config.bak-20261024
# 删除 Host cpaplus cpamp 那一整段后检查语法
ssh -G anti >/dev/null && echo "config parses ok"

现在提供模型的是 antigravity-manager(镜像 lbjlaq/antigravity-manager:v4.9.8),旁边还有一个 mihomo 容器专门负责出站代理:

docker ps --format "{{.Names}}\t{{.Image}}\t{{.Ports}}"
# antigravity-manager          lbjlaq/antigravity-manager:v4.9.8   127.0.0.1:8045->8045/tcp
# antigravity-manager-mihomo   metacubex/mihomo                     127.0.0.1:9090->9090/tcp

端口是 127.0.0.1:8045,只绑在服务器的回环地址上,所以从外网访问 <服务器IP>:8045 只会超时,安全组放不放行都一样。它的 /v1/models 需要 Bearer Key,不带 key 时返回 401。用容器里配置的 key 查了一下,当时一共登记了 43 个模型:Claude Opus / Sonnet 4.6 和 5.5(分 low / medium / high 三档)、Gemini 3.x~3.8 Flash、3.1 Pro、Gemini 4 Argon,还有 GPT-OSS 120B 和几个补全用的小模型。

我自己访问靠的是 ~/.ssh/config 里的 anti:

Host anti
    LocalForward 127.0.0.1:8045 127.0.0.1:8045   # 模型 API + 管理后台
    LocalForward 127.0.0.1:9090 127.0.0.1:9090   # mihomo 控制面板
    IdentitiesOnly yes
    Compression yes
    IPQoS none
    ControlMaster auto
    ControlPath ~/.ssh/control-%C
    ControlPersist no
    ExitOnForwardFailure yes
    ServerAliveInterval 15
    ServerAliveCountMax 3
    SessionType none
    ForkAfterAuthentication yes

ssh anti 一敲,隧道就转到后台,本地 http://127.0.0.1:8045/v1 就能用,我的 Hermes agent 也是这样连的。对我一个人来说这样足够安全,问题是别人用不了:想用就得有服务器的 SSH 权限,这个不可能给朋友。它也不太稳定,电脑一睡眠或者断网,隧道就断了,要手动再开一次。

我想要什么

我希望朋友在任何地方都能用一个普通的 HTTPS 地址调用模型,最好还能限速、能区分每个人。同时有几样东西不能动。antigravity-manager 的根路径就是 Web 管理页,能看到账号池、改配置,只靠一个密码挡着,绝对不能放到公网。安全组我也不想新增规则,8045 继续不对外。博客更不能受影响,nginx 是它的入口,改坏了整个站都会挂。

最省事的做法是在安全组放行 8045,再把容器端口改成 0.0.0.0。可这样走的是明文 HTTP,key 和对话内容都在裸奔;管理后台会跟着一起暴露;非标准端口也很快会被扫到。所以我换了个思路:博客本来就有一个在跑的 nginx,给它加一个子域名做 HTTPS 反代,只放行 API 路径。

两个容器怎么连起来

麻烦在于,博客的 nginx 和 antigravity-manager 属于两个不同的 Compose 项目。博客的容器都在 aurora_default 网络里,antigravity 和 mihomo 在 antigravity-manager_egress 网络里。不同的 bridge 网络之间是隔离的,nginx 原本根本连不到 antigravity。

办法还是用 Docker 自己的网络机制。一个容器可以同时加入多个网络,就像一台电脑插两张网卡,所以我新建了一个只有它俩在的网络,把两个容器都接进去。Docker 自带内部 DNS(127.0.0.11),同一网络里的容器可以用名字互相访问,给 antigravity 起个别名 antigravity,nginx 就能访问 http://antigravity:8045。这个网络加了 --internal,只用于容器之间互通,不能借它出网,antigravity 访问上游仍然走原来的 egress 网络和 mihomo。

                 ┌──────── antigravity-gateway(--internal)────────┐
外网 ─443─> nginx ────────── http://antigravity:8045 ──────────> antigravity-manager ──egress──> mihomo ──> 上游
 (同时在 aurora_default,                                      (同时在 egress,
  连 backend / minio)                                            连 mihomo)

这样宿主机上的 127.0.0.1:8045 绑定不用动,nginx 走容器网络,不经过宿主机端口,我的 SSH 隧道也照常能用。

中间我还纠结过 antigravity-manager 设置里的“允许局域网访问”(allow_lan_access),一度以为它是个安全开关。其实它决定的是程序监听在哪个地址:开就是 0.0.0.0,关就是 127.0.0.1。在 Docker 里,“局域网”指的是容器自己的虚拟网卡。不管是 nginx 从内部网络连过来,还是 SSH 隧道经由 Docker 端口映射进来,流量都是从容器的网卡进入的。按这个开关的作用,关掉以后程序只认容器内部的 127.0.0.1,nginx 和 SSH 隧道都会连不上(我没有真的关掉试过)。所以在 Docker 部署下它得开着,真正挡住外网的是 Docker 的 127.0.0.1 端口绑定和安全组。

动手

先建内部网络,把两个容器接进去。这三条立即生效,不用重启任何容器:

docker network create --internal antigravity-gateway
docker network connect --alias antigravity antigravity-gateway antigravity-manager
docker network connect antigravity-gateway aurora-nginx-1

接上以后从 nginx 容器里试一下能不能连到 antigravity:

docker exec aurora-nginx-1 wget -qO- -T 5 http://antigravity:8045/health
# {"status":"ok","version":"4.9.8"}

docker exec aurora-nginx-1 wget -qO- -T 5 http://antigravity:8045/v1/models
# wget: server returned error: HTTP/1.1 401 Unauthorized   ← 没带 key,符合预期

不过 docker network connect 只对当前容器有效,容器一旦被 docker compose up 重建,连接就没了,所以两边的 Compose 文件也要声明这个网络。改之前先备份:

cp /opt/antigravity-manager/compose.yaml /opt/antigravity-manager/compose.yaml.bak-$(date +%Y%m%d-%H%M%S)
cp ~/aurora/docker-compose.yml ~/aurora/docker-compose.yml.bak-$(date +%Y%m%d-%H%M%S)

antigravity 这边改的是 /opt/antigravity-manager/compose.yaml:

services:
  antigravity-manager:
    # ...
    networks:
      egress: {}
      gateway:
        aliases:
          - antigravity

networks:
  egress:
    driver: bridge
  gateway:
    name: antigravity-gateway
    external: true

博客这边改的是 ~/aurora/docker-compose.yml:

services:
  nginx:
    networks:
      - default
      - office-private
      - antigravity-gateway

networks:
  office-private:
    external: true
    name: oarc-office-private
  # nginx -> antigravity-manager (api.example.com), internal only
  antigravity-gateway:
    external: true
    name: antigravity-gateway

改完用 docker compose config -q 校验语法,两边都通过。这里我没有马上执行 docker compose up -d,因为前面已经让连接生效了,没必要为此重建博客的 nginx,下次正常重建时,Compose 会按新配置接上网络。

接下来是 nginx。先备份配置:

cd ~/aurora/nginx && cp nginx.conf nginx.conf.bak-$(date +%Y%m%d-%H%M%S)

在 http 块开头加限速区:

# api.example.com (antigravity-manager) per-IP limits
limit_req_zone $binary_remote_addr zone=llm_api_requests:1m rate=5r/s;
limit_conn_zone $binary_remote_addr zone=llm_api_connections:1m;

再加两个 server 块:

# ============ 模型 API api.example.com → antigravity-manager ============
server {
    listen 80;
    server_name api.example.com;
    location /.well-known/acme-challenge/ { root /var/www/certbot; }
    location / { return 301 https://$host$request_uri; }
}

server {
    listen 443 ssl;
    server_name api.example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    # Docker DNS, resolved per request so nginx still starts when antigravity is down
    resolver 127.0.0.11 valid=30s ipv6=off;
    set $antigravity_upstream antigravity:8045;

    # Only whitelisted model API endpoints are public; the web admin stays behind the SSH tunnel
    location / { return 404; }

    location = /health {
        proxy_pass http://$antigravity_upstream;
    }

    # Whitelist: only the API endpoints in use; anything else falls through to 404
    location ~ ^/v1/(models|chat/completions|images/generations|messages|messages/count_tokens)$ {
        client_max_body_size 50m;
        limit_req zone=llm_api_requests burst=20 nodelay;
        limit_conn llm_api_connections 10;
        limit_req_status 429;
        limit_conn_status 429;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_buffering off;
        proxy_request_buffering off;
        proxy_read_timeout 600s;
        proxy_send_timeout 600s;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_pass http://$antigravity_upstream;
    }
}

这段配置有几处是特意这么写的。location / { return 404; } 让默认请求全部被拒绝,只有 /health 和白名单里的 5 个接口会转给 antigravity:OpenAI 格式的 /v1/models、/v1/chat/completions、/v1/images/generations,以及 Anthropic 格式的 /v1/messages、/v1/messages/count_tokens。管理后台的页面和接口都不在名单里,外网访问一律 404。

白名单是后来收紧的。一开始我放行的是整个 /v1/ 和 /v1beta/,结果浏览器打开 https://api.example.com/v1/ 时,页面标题显示的是 Antigravity Tools。原因是 antigravity 对不认识的路径会返回管理后台首页的 HTML,/v1/abc、/v1/api/accounts 也一样。这个页面其实跑不起来:它的 JS 和 CSS 用相对路径引用,请求到的还是同一份 HTML,真正的后台接口在 /api/ 下,nginx 早就拦了,拿不到数据。但别人能看出我跑的是什么软件。所以我翻了请求日志,看实际用到哪些接口:/v1/chat/completions 1521 次,/v1/models 18 次,/v1/messages 5 次,/v1/images/generations 1 次,/v1beta 一次都没有,于是只放行这些,再加上 Claude Code 会用到的 count_tokens。以后有人要用 /v1/responses 或 Gemini 的 /v1beta,把路径加进这行正则再重载就行。

upstream 用的是 resolver 加变量的写法。如果直接写 proxy_pass http://antigravity:8045;,nginx 会在启动时解析这个名字,万一那一刻 antigravity 没在跑,nginx 就起不来,整个博客跟着挂。改成变量后,nginx 每次请求才去 Docker DNS 解析,antigravity 挂了只影响 API。

proxy_buffering off 和 600 秒超时是给模型回复准备的,回复是流式的,也可能很长,关掉缓冲才能逐字返回,超时也要给够。X-Real-IP / X-Forwarded-For 把用户真实 IP 传给 antigravity,后台日志和按 IP 绑定 token 的功能才有意义。限速设的是每个 IP 每秒 5 个请求、瞬间最多再多 20 个、同时最多 10 个连接,超出返回 429。

写完检查语法,再平滑重载,不会中断现有连接:

docker exec aurora-nginx-1 nginx -t
docker exec aurora-nginx-1 nginx -s reload

这时 DNS 还没加,可以先在服务器上用 --resolve 把域名指向本机来测试:

curl -sk --resolve api.example.com:443:127.0.0.1 -o /dev/null -w "%{http_code}\n" https://api.example.com/
# 404
curl -sk --resolve api.example.com:443:127.0.0.1 -o /dev/null -w "%{http_code}\n" https://api.example.com/v1/models
# 401

然后去域名服务商的解析面板加一条 A 记录,主机记录填 api,记录值填 <服务器IP>。刚加完时公共 DNS 可能还没生效,可以直接查权威 DNS,确认记录已经在了:

dig +short A api.example.com @<你的权威DNS服务器>

最后是证书。博客原本就有一张 Let's Encrypt SAN 证书,覆盖 4 个域名,由 certbot 容器每 12 小时检查续期。这次用 --expand 把新子域名加进同一张证书,以后续期会自动带上它。先在 Let's Encrypt 的测试环境演练一遍:

cd ~/aurora
docker compose run --rm --entrypoint certbot certbot certonly \
  --webroot -w /var/www/certbot --cert-name example.com --expand --non-interactive \
  -d example.com -d www.example.com -d admin.example.com -d img.example.com -d api.example.com \
  --dry-run
# The dry run was successful.

演练通过后,去掉 --dry-run 正式签发,然后重载 nginx:

docker compose run --rm --entrypoint certbot certbot certonly \
  --webroot -w /var/www/certbot --cert-name example.com --expand --non-interactive \
  -d example.com -d www.example.com -d admin.example.com -d img.example.com -d api.example.com

docker exec aurora-nginx-1 nginx -t && docker exec aurora-nginx-1 nginx -s reload

注意 -d 要把原有的所有域名都写上,否则新证书会把没写的那几个丢掉。

整个过程安全组一条都没改。用户访问 API 走的是入方向 TCP 443,证书验证和跳转 HTTPS 用的是入方向 TCP 80,这两条规则博客上线时就有了。8045 始终不放行,出方向保持默认,也用不到 UDP。

测一下

从本机走公网测了一轮。http:// 会 301 跳到 HTTPS;/、/index.html、/api/accounts 这些管理后台的路径都是 404;/health 返回 200;/v1/models 不带 key 是 401,带上 key 是 200。用 OpenAI 格式流式调用 Gemini 能逐段返回,用 Anthropic 格式调用 Claude 也正常。直连 <服务器IP>:8045 还是连不上,8045 仍然不对外,博客首页也照常返回 200。收紧成白名单之后又测了一遍:/v1/、/v1/abc、/v1beta/、/v1/completions、/v1/responses 都是 404,名单里的 5 个接口不带 key 都是 401,带上 key 后调用正常。

限速是用同一个 IP 瞬间发 40 个请求来测的,结果 24 个 401、16 个 429,说明生效了:

for i in $(seq 40); do
  curl -s -o /dev/null -w "%{http_code}\n" https://api.example.com/v1/models &
done | sort | uniq -c; wait

测的时候顺便踩了个 zsh 的坑:bash 里把多个参数存进一个字符串变量,$R 会被自动拆开;zsh 默认不拆,curl 会把整串当成一个参数,报 option ... is unknown。zsh 里要写成数组:

R=(--resolve api.example.com:443:<服务器IP>)
curl $R https://api.example.com/health

还有一次以为它没反应:直接 curl https://api.example.com/v1/models 什么都不输出,加上 -i 才看到返回的是 401 Unauthorized,响应体是空的。带上 key 就好了:

curl https://api.example.com/v1/models -H "Authorization: Bearer <你的key>"

我也顺手比了一下 SSH 隧道和域名的速度。/health 测 10 次取中位数,隧道是 0.44 秒,域名是 0.93 秒,多出的约 0.5 秒主要是每次新建 HTTPS 连接要做 TLS 握手,而隧道是早就建好的长连接。真正调模型时,短回答各测了 3 次,隧道是 3.23、1.35、2.03 秒,域名是 2.09、2.82、1.93 秒。上游本身的波动就有 1~2 秒,两条路看不出稳定的快慢。

两种方式的区别其实不在速度上。隧道要有私钥,得一直开着,断网或睡眠后要手动重连,但它能访问全部内容,包括管理后台,也没有任何限制。域名只要能上网、有 key 就能用,不依赖本机进程,但只开放 API,还受 nginx 的限速、超时和请求体大小限制。所以我打算两种并存:Hermes 这类日常工具走域名,管理后台继续走 SSH 隧道。

给朋友用之前

瓶颈在上游额度,不在服务器。模型是在上游算的,我的服务器只负责转发。从日志看,单个请求耗时中位数约 6 秒,输入 token 中位数约 6 万(agent 每次都带完整上下文),输出却只有几十个 token。只有我一个人用时,就已经偶尔会碰到上游的 Resource has been exhausted。要支撑更多人,得往账号池里加账号,调 nginx 没用。所以碰到 429 时要先分清是谁返回的:antigravity 日志里有这条请求,说明是上游额度不够;日志里根本没有,说明是 nginx 限速拦下的。前者要加账号,后者才是调 nginx。

用户 token 也不能限额。antigravity-manager 的用户 token 可以设启用状态、有效期、最多绑定几个 IP 和禁用时段,但没有额度和速率限制,某个人用得太猛,只能停用他的 token。目前真正限速的只有 nginx 那一层。max_ips 也别设太小,朋友手机加电脑、开着会变的 VPN,很容易把 IP 名额用完,然后被挡在门外。

服务器这边,2G 内存同时跑博客和反代,swap 已经在用了,并发真上来,大概会先卡在内存上。日志倒不会撑爆磁盘:按目前的设置,antigravity 只保留 24 小时的请求内容,日志总量上限 1GB,这点比我原先担心的好。

收尾

这次弄完,有几件事我算是想明白了。端口绑在 127.0.0.1 还是 0.0.0.0,比安全组更早决定一个服务能不能被外网访问。Docker 跨项目通信不需要暴露端口,一个 --internal 网络加一个别名就够了。nginx 反代的 upstream 用变量加 resolver,一个后端挂了不会把整个入口拖死。API 可以开放给朋友,管理后台照样关在 SSH 隧道后面,两件事并不冲突。

最后留一张改动清单,方便以后回滚:

文件 / 对象 改动 备份
~/.ssh/config(本机) 删除 cpamp / cpaplus ~/.ssh/config.bak-20261024
Docker 网络 新建 antigravity-gateway(--internal) 回滚:docker network disconnect 两个容器后 docker network rm antigravity-gateway
/opt/antigravity-manager/compose.yaml 加入 gateway 网络和别名 同目录 .bak-<时间>
~/aurora/docker-compose.yml nginx 加入 gateway 网络 同目录 .bak-<时间>
~/aurora/nginx/nginx.conf 限速区、api 子域名的 server 块,之后收紧为接口白名单 同目录 .bak-<时间>
Let's Encrypt 证书 扩展为 5 个域名 续期配置自动更新

← 全部文章