给大模型 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。这三个月里只要有一次没记住,就是一次全站中断。

90 天证书生命周期手动签发到期今天 · 剩 13 天⚠灰色区间是风险窗口:全靠有人记得。忘一次 = 一次全站中断。ACME约 2/3 处自动续期还有 30 天缓冲用于重试永远不会接近右端。续期失败还有一个月时间告警和补救。
两者的差别不是"自动比手动省事",而是失败模式完全不同: 手动方案的失败是静默的、突然的、全量的;ACME 方案即使续期失败,也还有约 30 天窗口留给告警和人工介入。

顺带说一个正在发生的趋势,它会让手动方案越来越站不住:证书有效期正在被行业强制缩短。 按 CA/Browser Forum 已通过的决议,TLS 证书最长有效期会分阶段从原来的一年多压到 200 天、100 天,最终到 47 天量级。手动续期在一年一次的年代还算可行,压到两个月一次就必然出事——这不是效率问题,是可靠性问题。

两者的实际差异

维度Caddynginx
证书管理内置 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?

GET /.well-known/acme-challenge/TOKEN 会命中哪个 location?❌ 没有专用 locationcertbotnginx location /try_files站点根目录/.well-known/…文件不在这里404签发失败,报错只说"验证不通过",不会告诉你是哪个 location 吃掉了请求。即使把 webroot 指对了,下一次部署静态站又会把 .well-known 目录清掉。✓ 加一个专用 location,指向独立目录certbotlocation/.well-known/acme-challenge/前缀更长/var/www/acme/.well-known/…200前缀 location 更长,优先命中;目录独立于任何应用的部署产物,部署脚本碰不到它。这个 location 必须在 80 端口的 server 块里 —— 别被 301 到 443 拦掉(见下)。
共享域名下这个坑有两重:验证路径可能落到错误的目录,而正确的目录又可能被部署脚本清空。 一个指向独立目录的专用 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 错误"的事故。

相关阅读