这是「AI 模型本地部署」系列的第二篇。上一篇结束时,你有了一个跑在 127.0.0.1:8000 的推理服务——稳定、开机自启,但只有本机能访问。这一篇讲怎么把它安全地暴露到外网。
会给出两条完整可操作的路线:Cloudflare 隧道(不开任何入站端口)和直连端口映射(自己的域名和证书)。两条路适用场景不同,也可以同时用——我自己就是两条都留着。
本地大模型部署系列(共 4 篇):① 选型与环境 → ② 对外暴露 → ③ 客户端接入 → ④ 知识库 RAG。本文是第 ② 篇。
先决定走哪条
| Cloudflare 隧道 | 直连端口映射 | |
|---|---|---|
| 需要公网 IP | 不需要 | 需要,且不能是 CGNAT |
| 需要开入站端口 | 不需要 | 需要路由器转发 |
| TLS 证书 | Cloudflare 边缘自动处理 | 自己签、自己续 |
| 延迟 | 取决于隧道落地节点 | 取决于客户端到你家的路径 |
| 抗攻击 | 有 Cloudflare 挡着 | 裸露,靠自己限流 |
| 配置复杂度 | 低 | 中 |
先做隧道。它不依赖任何网络条件,十分钟能通,可以作为兜底。等确认延迟是瓶颈了再加直连。
第一步:在前面加一层 nginx
两条路线都需要这一层,先做好。它负责三件推理服务做不了的事:限流、拦掉不该暴露的路径、把不同前缀分流到不同后端。
# 限流是防 Key 泄露后被滥用的,不是节流正常使用。
# 值要远高于真实负载 —— 翻译类客户端一页能瞬间发几十个请求。
limit_req_zone $binary_remote_addr zone=llm_api:10m rate=1200r/m;
limit_conn_zone $binary_remote_addr zone=llm_conn:10m;
server {
listen 127.0.0.1:8444; # 只听本地,由隧道或上层接管
server_name your-domain.example;
client_max_body_size 24m; # 多模态要传图
location /v1/ {
limit_req zone=llm_api burst=300 nodelay;
limit_conn llm_conn 64;
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Connection "";
# 流式输出是 SSE,必须关缓冲,否则整段回答会攒到最后一次性吐出来
proxy_buffering off;
proxy_cache off;
proxy_request_buffering off;
proxy_read_timeout 900s;
proxy_send_timeout 900s;
}
# 推理服务的 /metrics 里有 prompt 长度分布之类的信息,不要暴露出去
location = /metrics { return 404; }
location = /healthz { return 200 "ok\n"; }
location / { return 404; }
}
三个容易出错的点:
proxy_buffering off不能漏。漏了的表现是流式接口变成”等很久然后一次性出全文”,很像是模型慢,其实是 nginx 在攒。- 超时要给足。默认 60 秒,长回答会被掐断在半路。
nginx -t通过不等于生效,必须systemctl reload nginx。这个错误我犯过不止一次。
sudo cp your-site.conf /etc/nginx/sites-available/
sudo ln -sf /etc/nginx/sites-available/your-site.conf /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
路线 A:Cloudflare 隧道
原理是本机主动向 Cloudflare 建立一条出站长连接,外部请求从 Cloudflare 边缘走这条连接进来。不需要公网 IP,不需要开任何入站端口,也不需要自己管证书。
A.1 装 cloudflared
用官方 apt 源比从 GitHub 下稳定:
curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg \
| sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null
echo "deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared any main" \
| sudo tee /etc/apt/sources.list.d/cloudflared.list
sudo apt-get update && sudo apt-get install -y cloudflared
cloudflared --version
装完确认一下 which cloudflared 指向哪里。如果你之前手动下载过,/usr/local/bin 里的旧版本会盖住 apt 装的——我就被一个下载被截断的残留二进制坑过,表现是服务一启动就 SEGV,报错信息完全没有指向性。
A.2 建隧道
有两种凭证方式,安全性差别很大:
方式一:本机持有账号凭证(简单)
cloudflared tunnel login # 走浏览器授权
cloudflared tunnel create my-llm
cloudflared tunnel route dns my-llm llm.your-domain.example
缺点是 ~/.cloudflared/cert.pem 能创建/删除你账号下任意隧道并修改整个域名的 DNS。往一台对外提供服务的机器上放这种权限,值得犹豫一下。
方式二:只给单条隧道的凭证(推荐)
在一台已有凭证的机器上建好隧道和 DNS,只把该隧道的凭证文件传过去:
# 在已有 cert.pem 的机器上
cloudflared tunnel create my-llm
# 记下输出里的 UUID
# 绑 DNS —— 注意这两个参数
printf '' > /tmp/empty.yml
cloudflared tunnel --config /tmp/empty.yml route dns --overwrite-dns <UUID> llm.your-domain.example
--config 传一个空文件、并显式写 UUID,这两点都是必须的。否则 cloudflared 会从当前目录的 config.yml 里读 tunnel: 字段来决定绑哪条隧道——如果这台机器上已经跑着别的隧道,DNS 会被绑到错误的隧道上,而且它会正常返回成功。
然后把凭证传到目标机器(私钥不落中间文件):
cat ~/.cloudflared/<UUID>.json \
| ssh target-host 'mkdir -p ~/.cloudflared && cat > ~/.cloudflared/<UUID>.json && chmod 400 ~/.cloudflared/<UUID>.json'
A.3 写 ingress 配置
~/.cloudflared/config.yml:
tunnel: <UUID>
credentials-file: /home/youruser/.cloudflared/<UUID>.json
protocol: quic
retries: 5
grace-period: 30s
ingress:
- hostname: llm.your-domain.example
service: http://127.0.0.1:8444 # 指向上面那层 nginx
originRequest:
connectTimeout: 30s
keepAliveTimeout: 900s # 长回答别中途断开
httpHostHeader: llm.your-domain.example
- service: http_status:404 # 兜底
先校验再启动:
cloudflared tunnel --config ~/.cloudflared/config.yml ingress validate
cloudflared tunnel --config ~/.cloudflared/config.yml ingress rule https://llm.your-domain.example/v1/models
A.4 装成服务
[Unit]
Description=Cloudflare Tunnel - LLM gateway
After=network.target nginx.service
[Service]
Type=simple
User=youruser
# 用实际路径,别写死 —— apt 装的在 /usr/bin,手动装的在 /usr/local/bin
ExecStart=/usr/bin/cloudflared tunnel --config /home/youruser/.cloudflared/config.yml run
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload && sudo systemctl enable --now cloudflared-llm
sudo journalctl -u cloudflared-llm -n 20 --no-pager | grep "Registered tunnel connection"
看到 Registered tunnel connection 就通了。日志里偶尔出现 datagram manager encountered a failure 属于正常重连,只要后面跟着重新注册就没事。
A.5 DNS 设置
橙云代理要保持开启。隧道必须经过 Cloudflare 边缘,关掉代理(灰云)反而不通——这一点和 DDNS 的要求正好相反,容易记混。
A.6 验证
curl -sS -o /dev/null -w "HTTP %{http_code}\n" https://llm.your-domain.example/healthz
一个要知道的限制:Cloudflare 免费版单次请求上限 100 秒。生成很长的回答时,非流式请求可能在边缘被切断。长输出一律用 stream: true——流式下每个 chunk 都在刷新计时。
路线 B:直连端口映射
隧道方案的延迟取决于隧道落地在哪个节点。如果落地节点离你和你的客户端都很远,每个请求要绕一大圈。这时直连会快很多。
B.1 先确认三件事
# 1. 你的公网 IP 是什么,是不是 CGNAT
curl -4 -s https://1.1.1.1/cdn-cgi/trace | grep '^ip='
如果落在 100.64.0.0/10 就是运营商级 NAT,直连这条路走不通,只能用隧道。
# 2. 哪些端口能用(从一台外网机器探,本机探不准)
ssh some-vps 'for p in 80 443 8443 9443 9999; do
timeout 6 nc -z -w 5 你的公网IP $p 2>/dev/null && echo " $p 开" || echo " $p 关"
done'
一定要带一个”应该关闭”的对照端口(比如 9999)。如果连它都显示”开”,说明你的探测方法有问题,整组读数作废。
很多家庭宽带的 80/443 是被运营商过滤的,得用非标准端口。
# 3. 这台机器的默认网关是不是那台做 NAT 的路由器
ip route show default
这一条最容易被忽略,但它会导致一个极难排查的故障,下面单独讲。
B.2 DDNS
家庭宽带的公网 IP 会变,需要动态更新 DNS。用 ddns-go 之类的工具即可,配置要点:
- 只做 A 记录,别开 AAAA,除非你确认 IPv6 入站真的通。很多环境下 v6 有地址但打不进来。
- IPv6 检测别用”回显服务”。如果你的机器出站走了代理或隧道,回显服务看到的是出口地址,不是你家的地址——会发布一个完全错误的 AAAA,而且很难发现。
- DNS 记录设为灰云(DNS-only),橙云会让 DDNS 失去意义。
B.3 证书
80 端口被封的话 HTTP-01 验证走不通,只能用 DNS-01:
sudo certbot certonly --non-interactive --agree-tos \
--dns-cloudflare --dns-cloudflare-credentials /root/.secrets/cloudflare.ini \
--dns-cloudflare-propagation-seconds 30 \
-d llm-direct.your-domain.example
如果签证书的机器和跑服务的机器不是同一台(比如你不想把 DNS token 放到对外机器上),加一个续期钩子自动同步:
# /etc/letsencrypt/renewal/<domain>.conf 的 [renewalparams] 里加:
# renew_hook = /usr/local/bin/sync-cert.sh
# sync-cert.sh:root 读证书,以普通用户身份推送,私钥不落中间文件
SRC=/etc/letsencrypt/live/<domain>
ssh_peer() { sudo -u relayuser ssh -o BatchMode=yes target-host "$@"; }
ssh_peer "sudo tee /etc/llm/tls/fullchain.pem >/dev/null" < "$SRC/fullchain.pem"
ssh_peer "sudo tee /etc/llm/tls/privkey.pem >/dev/null && sudo chmod 600 /etc/llm/tls/privkey.pem" < "$SRC/privkey.pem"
ssh_peer "sudo nginx -t && sudo systemctl reload nginx"
B.4 对外的 nginx 站点
和前面那层不同:这个监听所有网卡、带 TLS、只开 API 路径:
server {
# http2 写在 listen 行上 —— 独立的 `http2 on;` 是 nginx 1.25.1 才有的,
# 老版本会直接 unknown directive 起不来
listen 9443 ssl http2;
server_name llm-direct.your-domain.example;
ssl_certificate /etc/llm/tls/fullchain.pem;
ssl_certificate_key /etc/llm/tls/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
# 这里故意不设 real_ip_header。
# 隧道那条路要靠 CF-Connecting-IP 还原访客 IP;这条路 $remote_addr
# 本来就是真实客户端,照抄过来的话谁伪造一个头就能绕过限流。
location /v1/ {
limit_req zone=llm_api burst=300 nodelay;
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Connection "";
proxy_buffering off;
proxy_request_buffering off;
proxy_read_timeout 900s;
}
location = /healthz { return 200 "ok\n"; }
location / { return 404; }
}
B.5 路由器端口转发
在路由器上加一条:外部端口 → 这台机器的内网 IP 和端口,协议 TCP。
B.6 如果端口转发配对了却还是连不上
这是直连方案里最容易卡住的地方,值得单独展开。
症状:所有配置都对,服务在监听,防火墙也关了,外部就是连不上。抓包会看到很诡异的一幕:
sudo tcpdump -i any -nn "tcp port 9443 and not host 127.0.0.1" -c 20
<客户端> > <本机>:9443 Flags [S] ← SYN 到了
<本机>:9443 > <客户端> Flags [S.] ← SYN-ACK 也发了
<客户端> > <本机>:9443 Flags [S] ← 客户端却一直重传
<客户端> > <本机>:9443 Flags [S]
包进来了、回包也发了,客户端就是收不到。原因是这台机器的默认网关不是做 NAT 的那台路由器——常见于为了走代理而把默认路由指向了局域网内另一台设备。回包从”另一条路”出去,路由器的 NAT 表里没有这条连接的记录,包被丢掉。
解法是用 conntrack 打标记 + 策略路由,让这类连接的回包原路返回:
IFACE=你的网卡 ROUTER=192.168.1.1 PORT=9443
MARK=0x9443 TABLE=100 LAN=192.168.1.0/24
ROUTER_MAC=xx:xx:xx:xx:xx:xx
# 1. 外网来的请求:源 IP 不在内网
iptables -t mangle -A PREROUTING -i "$IFACE" ! -s "$LAN" \
-p tcp --dport "$PORT" -m conntrack --ctstate NEW \
-j CONNMARK --set-mark "$MARK"
# 2. NAT 回环:内网设备用公网域名访问时源 IP 还是内网的,
# 但它是路由器 DNAT 进来的 —— 按源 MAC 认出来
iptables -t mangle -A PREROUTING -i "$IFACE" \
-m mac --mac-source "$ROUTER_MAC" \
-p tcp --dport "$PORT" -m conntrack --ctstate NEW \
-j CONNMARK --set-mark "$MARK"
# 3. 把标记恢复到该连接的后续包上(含本机生成的 SYN-ACK)
iptables -t mangle -A PREROUTING -i "$IFACE" -j CONNMARK --restore-mark
iptables -t mangle -A OUTPUT -j CONNMARK --restore-mark
# 4. 带标记的包查一张只指向路由器的独立路由表
ip route replace "$LAN" dev "$IFACE" scope link table "$TABLE"
ip route replace default via "$ROUTER" dev "$IFACE" table "$TABLE"
ip rule add fwmark "$MARK" lookup "$TABLE"
三个必须注意的细节:
--restore-mark属于CONNMARKtarget,不是connmarkmatch。写成-m connmark --restore-mark会直接报 unknown option。- 第 1 条规则的
! -s $LAN不能省。省掉的话,内网客户端直连内网 IP 时回包也被塞进那张只有默认路由的表,被丢到路由器再也回不来——内网直连会全部超时。 - 第 2 条规则不能省,否则家里的设备用公网域名访问会不通。加上之后,客户端只要一条 DNS/代理规则就能在家在外都用同一个地址。
写成 systemd 单元开机自启(Type=oneshot + RemainAfterExit=yes),别只在命令行跑一次——ip rule 和 iptables 重启就没了。
怎么选:一组实测数据
同一套系统、同一个请求,从两个位置测:
| 测量位置 | 隧道 | 直连 |
|---|---|---|
| 家里网络(客户端在国内) | 4.49s | 2.61s |
| 境外服务器 | 0.46s | 6.9s(偶发 40s 超时) |
结论完全相反。境外那台机器恰好在隧道落地的边缘节点旁边,所以隧道对它是”家门口”;而从境外直连家宽会经过国际链路,慢且不稳。
所以别问”哪个更快”,要问”我的客户端在哪“。两条都配好,按场景切换是最省心的。
测延迟时的三个陷阱
这些会让你得出完全错误的结论:
- 本机开着按域名分流的代理软件。它会把你的请求代理出去再绕回来。判断方法是看服务端 access log 里的来源 IP——如果那不是你本机的地址,这次测量作废。用
curl --interface <物理网卡>可以绕开。 - 用家里的机器测 NAT 回环。回环路径和真正的外网路径完全不同,尤其在并发下。
- 用一台开着代理的机器压并发。我试过 20 个并发只有 5 个成功,同一时刻从一台干净的服务器打同一个后端是 20/20——瓶颈在测试机不在服务端。
验证清单
# 一层一层往外排,哪层断了一目了然
curl -s 127.0.0.1:8000/health # 1. 推理服务
curl -s 127.0.0.1:8444/healthz # 2. 内层 nginx
curl -s https://llm.your-domain.example/healthz # 3. 隧道
curl -sk https://llm-direct.your-domain.example:9443/healthz # 4. 直连
# 鉴权三态都要测,而且要从外网测
curl -o /dev/null -w "%{http_code}\n" -X POST https://.../v1/chat/completions -d '{}' # 应 401
curl -o /dev/null -w "%{http_code}\n" -H 'Authorization: Bearer wrong' ... # 应 401
curl -o /dev/null -w "%{http_code}\n" -H "Authorization: Bearer $KEY" ... # 应 200
鉴权一定要从外网验,本机测不出问题。我在给自己的网关加转发层时写过一个洞:没有 Authorization 头时回退用服务端自己的 Key——结果那条路径成了公网上的无鉴权入口。本机测永远是通的,只有从外面打无 Key 请求才会暴露。
下一步
服务现在能从外网访问了。系列第三篇会讲怎么把它接进日常工具——浏览器翻译扩展、代码编辑器、以及自己写的客户端,包括一个能省掉 90% token 的技巧。