把本地大模型安全暴露到外网:Cloudflare 隧道与端口映射两条路线
把本地大模型安全暴露到外网:Cloudflare 隧道与端口映射两条路线
站内搜索
直接问 AI

把本地大模型安全暴露到外网:Cloudflare 隧道与端口映射两条路线

这是「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 属于 CONNMARK target,不是 connmark match。写成 -m connmark --restore-mark 会直接报 unknown option。
  • 第 1 条规则的 ! -s $LAN 不能省。省掉的话,内网客户端直连内网 IP 时回包也被塞进那张只有默认路由的表,被丢到路由器再也回不来——内网直连会全部超时。
  • 第 2 条规则不能省,否则家里的设备用公网域名访问会不通。加上之后,客户端只要一条 DNS/代理规则就能在家在外都用同一个地址。

写成 systemd 单元开机自启(Type=oneshot + RemainAfterExit=yes),别只在命令行跑一次——ip ruleiptables 重启就没了。

怎么选:一组实测数据

同一套系统、同一个请求,从两个位置测:

测量位置 隧道 直连
家里网络(客户端在国内) 4.49s 2.61s
境外服务器 0.46s 6.9s(偶发 40s 超时)

结论完全相反。境外那台机器恰好在隧道落地的边缘节点旁边,所以隧道对它是”家门口”;而从境外直连家宽会经过国际链路,慢且不稳。

所以别问”哪个更快”,要问”我的客户端在哪“。两条都配好,按场景切换是最省心的。

测延迟时的三个陷阱

这些会让你得出完全错误的结论:

  1. 本机开着按域名分流的代理软件。它会把你的请求代理出去再绕回来。判断方法是看服务端 access log 里的来源 IP——如果那不是你本机的地址,这次测量作废。用 curl --interface <物理网卡> 可以绕开。
  2. 用家里的机器测 NAT 回环。回环路径和真正的外网路径完全不同,尤其在并发下。
  3. 用一台开着代理的机器压并发。我试过 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 的技巧。

发表回复

向下探索