2026-10-24
证书别再靠人记日子:Let's Encrypt 自动续期实战
站点的证书是当初手动签的,签发方 TrustAsia,到期日 2027 年 1 月 20 日。现在它工作得很好,问题在于「到期那天会不会有人记得」。纯靠人肉记日期的证书,大概率会在某个没人看的日子悄悄过期,然后浏览器开始弹红字。这篇记录把它换成 Let's Encrypt 自动续期的完整过程,中间有一次真实失败,也有一层自动兜底把故障拦在了线上之外。
一、先确认起点,别凭印象
证书信息不要看控制台,直接跟线上握手一次最准:
echo | openssl s_client -connect eeuse.com:443 -servername eeuse.com 2>/dev/null \
| openssl x509 -noout -issuer -dates
返回的是 issuer=C = US, O = TrustAsia Technologies, Inc., CN = TrustAsia DV TLS RSA CA 2024,notAfter=Jan 20 02:xx:xx 2027 GMT。配置里两处证书路径都指向 /etc/nginx/ssl/eeuse.com/,是当初手动放进去的文件。
结论很清楚:证书能用,但没有自动续期机制,到期前必须有人手动做一遍。
二、为什么不用 certbot 的 nginx 插件
certbot --nginx 一条命令就能搞定,但代价是它会自己往配置里写东西。这个站的配置是手写维护的,每一行都清楚为什么在,不希望被工具悄悄改掉——尤其当它改出来的结构跟原有风格不一致时,下次自己改配置会多一层困惑。
所以选 webroot 模式:certbot 只往一个固定目录里写校验文件,怎么响应这个路径由我自己的 Nginx 配置决定,两边职责清晰。
三、第一步:给 80 端口单独开一条通道
原本 80 端口的 server 块极其简单,就一行跳转:
server {
listen 80;
server_name eeuse.com www.eeuse.com;
return 301 https://$host$request_uri;
}
这里就藏着第一个坑。server 级 return 在 rewrite 阶段执行,优先级高于所有 location。也就是说,不管 certbot 往 /.well-known/acme-challenge/ 下写了什么,请求一进来就被 301 到 https,CA 拿到的响应体重定向,而不是校验文件的内容,校验必然失败。而且失败信息往往是「返回了 301」,容易误判成文件没写对。
正确做法是把 return 收进 location /,让校验路径先被命中:
server {
listen 80;
server_name eeuse.com www.eeuse.com;
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
default_type "text/plain";
}
location / {
return 301 https://$host$request_uri;
}
}
^~ 表示命中这个前缀后不再尝试正则匹配,避免被后面的规则抢走。root 指向专用目录,跟站点根分开,续期文件不会混进公开目录里。
建目录并放个测试文件验证:
mkdir -p /var/www/certbot/.well-known/acme-challenge
echo ok > /var/www/certbot/.well-known/acme-challenge/testfile
然后两条命令都要对,缺一条都不算配好:
curl -s http://eeuse.com/.well-known/acme-challenge/testfile # 应返回 ok
curl -s -o /dev/null -w "%{http_code}\n" http://eeuse.com/ # 应为 301
第一条说明校验文件能被读到,第二条说明普通访问仍然跳 https,没有把 80 端口变成明文站。
四、第二步:先演练,再正式签发
Ubuntu 仓库里的版本已经够用,装上就是 certbot 2.9.0:
apt-get install -y certbot
certbot --version
正式签发前先跑一次演练,这一步不消耗配额也不会写证书:
certbot certonly --webroot -w /var/www/certbot \
-d eeuse.com -d www.eeuse.com \
--agree-tos --register-unsafely-without-email \
--deploy-hook "systemctl reload nginx" \
--dry-run
演练通过。到这里很容易觉得「稳了」,但接下来发生的事说明了为什么不能这么想。
五、第一次正式签发失败了
去掉 --dry-run 再跑,两个域名全部失败,报错是:
Detail: 82.156.84.4: Fetching http://eeuse.com/.well-known/acme-challenge/xxx:
Timeout during connect (likely firewall problem)
超时,不是配置错、不是文件没写对、也不是防火墙没放行——80 端口本地和外网都能正常访问,校验文件刚也验证过。真正的原因是:正式签发时 CA 会从多个节点做二次校验,其中包含境外节点,而境外回源国内服务器的 80 端口这条链路本身不稳定,偶发超时。
同理,www 子域也一起失败,因为两个域名在同一次申请里,任一失败整单失败。
六、失败之后,站点为什么没挂
这是整个流程里最值得单独说的一段。我没有先签发再手动改配置,而是把「签发 → 切路径 → 校验 → 重载」写成一个脚本,固定顺序执行:
- 调用 certbot 签发
- 把配置里的两处证书路径指向
/etc/letsencrypt/live/eeuse.com/ nginx -t检查- 通过才
systemctl reload nginx,不通过就把备份配置拷回去
关键在于第 3 步:nginx -t 除了查语法,还会检查 ssl_certificate 指向的文件是否真实存在。签发失败时 /etc/letsencrypt/live/eeuse.com/ 目录里根本没有 fullchain.pem,于是 nginx -t 直接报错,reload 不会执行,脚本把带时间戳的备份配置还原回去。
最终结果是:站点 code=200,握手看到的仍是原来那张 TrustAsia 证书。故障被完整限制在脚本内部,线上没有任何一秒见到坏配置。如果当时图省事直接手工改路径再 reload,站点会立刻起不来,而且是在我只改了两行、以为「就两行不会错」的心理状态下。
七、重试一次,就成功了
原样再执行一次同样的命令,这次返回 Successfully received certificate:
Certificate is saved at: /etc/letsencrypt/live/eeuse.com/fullchain.pem
Key is saved at: /etc/letsencrypt/live/eeuse.com/privkey.pem
Expires: 2027-01-22
同一个命令、同一份配置,一次超时一次成功,印证了前面的判断:问题在链路,不在配置。这种情况不需要改任何东西,重试就是正确做法。
八、第三步:把证书路径切过去
签发成功只代表文件有了,Nginx 还在用老路径。改这两行:
ssl_certificate /etc/letsencrypt/live/eeuse.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/eeuse.com/privkey.pem;
备份、nginx -t、reload,然后必须从外部握手验证:
echo | openssl s_client -connect eeuse.com:443 -servername eeuse.com 2>/dev/null \
| openssl x509 -noout -issuer -dates
返回 issuer=C = US, O = Let's Encrypt, CN = YE1、notAfter=Jan 22 02:23:44 2027 GMT。到这一步才算真的换掉了。
为什么要用 openssl s_client 而不是只看配置文件?因为「配置改了但忘了 reload」是最常见的收尾失误——文件内容对,线上跑的还是旧进程里的旧配置,只有从外部握手才看得见真相。
九、把续期链路逐项确认
证书换好只是第一步,真正要确认的是「90 天后它会不会自己续上」。逐项查:
systemctl is-enabled certbot.timer # enabled
systemctl is-active certbot.timer # active
再看续期配置里挂的钩子:
grep -n "renew_hook\|authenticator" /etc/letsencrypt/renewal/eeuse.com.conf
输出里应该有 authenticator = webroot 和 renew_hook = systemctl reload nginx。后者是必须的:续期只是换了磁盘上的文件,不 reload Nginx 就还在用旧证书,等于白续。
/etc/letsencrypt/renewal-hooks/deploy/ 是 total 0,看起来像没配钩子。实际是因为 --deploy-hook 的内容被写进了 renewal/eeuse.com.conf,而不是往 hooks 目录里放脚本。想确认钩子在不在,去 grep 那个 conf,别看目录。最后清掉之前放的测试文件,避免校验目录里留着无关内容:
rm -f /var/www/certbot/.well-known/acme-challenge/testfile
十、还剩一个不确定项
证书是 90 天有效,理论上提前 30 天开始续,正常情况下不会出问题。但要诚实说一点:既然正式签发都被跨境链路拖超时过一次,自动续期同样可能撞上同样的问题。所以 certbot renew --dry-run 我是在后台跑的,没有当场等结果——这类演练一旦卡在跨境校验上,耗时会远超预期。
更稳的方案是改用 DNS 方式校验(--preferred-challenges dns,配合 DNS 服务商的接口自动加记录)。DNS 校验不需要 CA 回源你的 80 端口,绕开了整条跨境链路,代价是要配一次 API 凭证。如果后面发现续期不稳,这是第一顺位要改的地方。
小结
- 80 端口有
return 301时,要把它收进location /,否则 ACME 校验路径也被跳走 webroot模式不碰你的配置,适合手写维护的 Nginx--dry-run成功不等于正式签发成功,两者校验节点不同- 把「签发 → 切路径 →
nginx -t→ reload」做成带回滚的脚本,签发失败时站点毫发无伤 - 换证书后必须用
openssl s_client从外部确认,配置文件对不等于线上生效 renew_hook一定要有,否则续期后 Nginx 还在用旧证书- 钩子配在
renewal/*.conf里,renewal-hooks/deploy/空着是正常的
本篇阅读 — 次