给大模型 API 中转选反向代理,网上的对比文章大多在比性能、比配置语法、比模块生态。这些差异对一个 API 网关来说其实都不重要——真正的分水岭是证书生命周期由谁管理。
原因很简单:对普通网站,证书过期是"用户看到一个可怕的警告,但能点继续";对 API 网关,证书过期是所有客户端硬失败的全站中断。Python 的 httpx、requests、OpenAI SDK 全都会直接抛 SSLError,没有"点继续"这个选项。而且报错文本是 certificate verify failed,第一眼看着像客户端环境问题,会把人引到完全错误的方向排查。
一张手动证书的真实时间线
我手上一台机器用的是 nginx 加商业 DV 证书(TrustAsia 签发),实测有效期:
notBefore = Jun 18 08:00:00 2026 GMT
notAfter = Sep 16 07:59:59 2026 GMT
正好 90 天。查证书那天是 9 月 3 日,距到期 13 天。
这台机器上跑着 API 服务和一个静态站,证书是手工申请、手工安装的。它现在没坏,问题是它的存续完全依赖"有人记得"——记得到期日、记得去续、记得装上去、记得 reload。这三个月里只要有一次没记住,就是一次全站中断。
顺带说一个正在发生的趋势,它会让手动方案越来越站不住:证书有效期正在被行业强制缩短。 按 CA/Browser Forum 已通过的决议,TLS 证书最长有效期会分阶段从原来的一年多压到 200 天、100 天,最终到 47 天量级。手动续期在一年一次的年代还算可行,压到两个月一次就必然出事——这不是效率问题,是可靠性问题。
两者的实际差异
| 维度 | Caddy | nginx |
|---|---|---|
| 证书管理 | 内置 ACME,默认自动申请与续期 | 本身不管,需要 certbot + 定时器 |
| 反代能力 | 够用 | 够用 |
| 流式 / SSE | 对 text/event-stream 默认刷新 | 需显式关 proxy_buffering 和 gzip |
| 复杂 rewrite / 正则路由 | 能做,但表达力弱一些 | 更强,且久经考验 |
| 模块生态 | 较小 | 大得多 |
| 配置语法 | Caddyfile,简洁 | 熟悉的人多 |
| 内存占用 | 略高(Go 运行时) | 略低 |
| 出网要求 | 需要能访问 ACME 目录 | 无 |
看这张表要抓重点:除了第一行和最后一行,其余差异对一个 API 网关都不构成决策依据。 反代能力两边都够,性能差异在 LLM 场景里被上游生成耗时完全淹没——你的瓶颈是模型在想,不是代理在转发。
Caddy 的代价也要说清,别只看好处:
- 需要出网访问 ACME 目录。 内网隔离环境、或出网被严格管控的机器上,自动签发根本走不通,这时 Caddy 的核心优势直接消失。
on_demand_tls是个陷阱。 配错了会给任意 Host 头签发证书,等于把签发配额敞开给别人刷。要用就必须配ask端点做白名单校验。- 复杂路由场景 nginx 更稳。 如果你已经有一大堆 nginx 的
location规则和正则,重写成 Caddyfile 的风险可能高于收益。
不换 nginx 的话,certbot 要配对
大多数人的现实是 nginx 配置已经很复杂、不想动。那就把证书自动化补上,同样能解决问题:
# 首次签发。--deploy-hook 是关键,否则证书换了但 nginx 还在用内存里的旧的
certbot certonly \
--webroot -w /var/www/acme \
-d ambertu.com -d www.ambertu.com \
--deploy-hook "systemctl reload nginx"
# 确认自动续期的定时器真的在跑 —— 这一步很多人跳过
systemctl list-timers | grep certbot
# 演练一次,别等到真到期才发现签发链路是坏的
certbot renew --dry-run
三个容易漏的点:
--deploy-hook不能省。 证书文件换了,但 nginx 不会自己重新读——不 reload 就等于没续。systemctl list-timers必须确认。 certbot 的自动续期靠 systemd timer(或 cron),装了 certbot 不代表定时器启用了。--dry-run要定期跑。 签发链路可能因为 DNS 变更、防火墙调整、challenge 路径被占而悄悄失效,--dry-run是唯一能提前发现的手段。
一个共享域名下的真实坑:ACME 验证被吞掉
这个坑值得单独讲,因为它只在"一个域名下住着多个应用"时出现,而这种架构相当常见。
HTTP-01 验证要求 http://your-domain/.well-known/acme-challenge/<token> 能被访问到。问题是:这个路径会命中哪个 location?
location 同时解决两个问题。正确的配法:
# 80 端口的 server 块里 —— 顺序很重要
server {
listen 80;
server_name ambertu.com www.ambertu.com;
# ACME 验证必须放在 301 之前,否则请求会被跳转走
location /.well-known/acme-challenge/ {
root /var/www/acme;
}
location / {
return 301 https://$host$request_uri;
}
}
顺序是关键。 如果 80 端口的 server 块里只有一句 return 301 https://...,那么 ACME 的验证请求会被 301 走。Let’s Encrypt 会跟随跳转到 443,所以这种情况有时也能成——但如果 443 上的 location / 又用 try_files ... =404 兜底,请求最终还是 404。加一个显式的前缀 location 才是确定的做法,不依赖跳转链路碰巧走对。
再说一遍那个连带的坑:验证目录要放在任何应用的部署产物之外。 如果你把 webroot 指向静态站的根目录,而部署脚本用的是 rsync --delete 式的镜像同步,.well-known 会在下一次发版时被删掉——证书在三个月后续期失败,而当时没有人会想起两个月前改过部署脚本。
混合方案
如果 nginx 配置太复杂不想重写,但又想要自动证书,可以让 Caddy 只做 TLS 终止:
外网 → Caddy :443(自动证书) → nginx :8080(全部现有路由逻辑) → 各应用
Caddyfile 简单到几行:
ambertu.com, www.ambertu.com {
reverse_proxy 127.0.0.1:8080 {
flush_interval -1 # 保住 SSE 流式
header_up X-Real-IP {remote_host}
}
}
代价是多一跳(本机回环,延迟可忽略),以及 nginx 那边要改成监听 8080 且信任来自本机的 X-Real-IP——这一步别搞错,否则所有客户端 IP 都会变成 127.0.0.1,按 IP 的限流会一起误伤。
无论用哪个:把到期日变成告警
这是这篇文章最该带走的一条。再好的自动化也可能静默失效,所以到期监控是独立于方案选择的必需品:
#!/bin/sh
# /usr/local/bin/cert-expiry-check.sh
DOMAIN="${1:-ambertu.com}"
THRESHOLD=20
END=$(echo | openssl s_client -connect "$DOMAIN:443" -servername "$DOMAIN" 2>/dev/null \
| openssl x509 -noout -enddate | cut -d= -f2)
[ -z "$END" ] && { echo "无法获取证书:$DOMAIN"; exit 2; }
DAYS=$(( ( $(date -d "$END" +%s) - $(date +%s) ) / 86400 ))
echo "$DOMAIN 证书剩余 $DAYS 天(到期 $END)"
[ "$DAYS" -lt "$THRESHOLD" ] && { echo "低于 $THRESHOLD 天,需要处理"; exit 1; }
exit 0
两点设计考虑:
- 从外部探测,而不是读本地证书文件。 读文件只能证明"磁盘上那份还有效",探端口才能证明"nginx 实际吐出去的那份还有效"。两者不一致正是"续了但忘了 reload"这个经典故障的样子。
- 阈值设 20 天而不是 3 天。 要留出的不是"续期需要的时间",而是"发现问题、排查、找人、处理"的时间。
配个定时任务每天跑一次,非零退出就告警。这十几行东西能挡住的,是那种"周末证书过期、周一早上所有客户端集体报 SSL 错误"的事故。
相关阅读
- 大模型 API 中转的超时与重试:一次 504 可能让你双倍计费 —— 另一类会让全部请求一起失败的配置问题
- 反代把大模型 API 的 SSE 流式响应缓冲了:五个缓冲点与三层排查法 —— 换成 Caddy 后 SSE 的行为差异
- API 中转站的限流为什么不能信 X-Forwarded-For —— 混合方案里
X-Real-IP那一跳为什么必须配对