⭐ 推荐:社区规则条款 V1.0

SSL证书过期前如何自动续期并做好监控?

liulian
liulian 初级会员萌新站长
发布于 2026-10-26 03:28 ·1 浏览 ·9 回复
内容摘要

SSL 证书续期应采用自动续期、到期监控告警和上线前演练三层防护,不能只依赖 certbot/acme.sh;应在剩余 30 天内续期、剩余 21 天告警,并通过 systemd timer 或 cron 执行续期,用 deploy-hoo

结论:SSL 证书续期要做成“自动续期 + 到期日监控告警 + 上线前演练”三层防护,只部署 certbot/acme.sh 并不够。证书到期属于运维事故,通常是因为续期任务静默失败,且没有人监控到期时间。Let's Encrypt 的免费证书有效期为 90 天,当剩余有效期不足 30 天时就应完成续期;监控告警的阈值建议设置在剩余 21 天。

为什么自动续期了还需要监控?

结论:自动续期只能降低人为续期的成本,无法防止“续期任务失败却没人发现”。常见失败原因包括:验证端口被封、webroot 权限不足、reload 命令没执行、系统时间漂移、域名解析变更;这些都可能让续期静默失败。

Let's Encrypt 签发的证书有效期为 90 天,官方建议在剩余 30 天内续期,客户端默认也是到期前 30 天开始续期。这意味着一次续期失败不会立刻导致服务中断,但连续失败约 3 次就会导致证书过期。监控的本质是给自动续期留出人工介入的缓冲时间:当剩余 21 天且仍未续期时发出告警,可以立刻排查,而不必在证书到期前几小时才处理。

用 certbot 做自动续期怎么做?

结论:用系统自带的 systemd timer 或 cron 调用 certbot renew,并通过 --deploy-hook 在续期成功后自动 reload Web 服务,这是最简且可靠的方案。

首次签发示例(Nginx + webroot,HTTP-01 验证指 CA 回源访问 /.well-known/acme-challenge/ 下的验证文件):

certbot certonly --webroot -w /var/www/html -d example.com -d www.example.com

之后续期不用重复指定域名,一条命令全部处理:

certbot renew --dry-run    # 先演练,不消耗签发配额
certbot renew --quiet --deploy-hook "systemctl reload nginx"

用 cron 定时(避开整点,加随机分钟,并写绝对路径):

17 3,15 * * * /usr/bin/certbot renew --quiet --deploy-hook "systemctl reload nginx"

注意两点:--deploy-hook 只在真正续期成功时触发,--dry-run 不触发,所以不能拿 dry-run 验证 reload 是否生效;renew 只会处理距到期不足 30 天的证书,不是每次执行都重新签发。如果用 certbot 的系统包,通常已自带 certbot.timer,用 systemctl list-timers | grep certbot 确认,不要和 cron 重复配置。

用 acme.sh 做自动续期和通配符证书怎么做?

结论:acme.sh 安装时会自动写入 cron,且必须显式执行 --install-cert 把证书复制到 Web 目录并绑定 reload 命令,否则续期成功但线上证书不变。

acme.sh --issue -d example.com -d www.example.com --webroot /var/www/html
acme.sh --install-cert -d example.com \
  --key-file /etc/nginx/ssl/example.com.key \
  --fullchain-file /etc/nginx/ssl/example.com.fullchain.pem \
  --reloadcmd "systemctl reload nginx"

使用通配符证书时,必须采用 DNS-01 验证,即在 DNS 中添加一条 TXT 记录,例如阿里云:

acme.sh --issue --dns dns_ali -d example.com -d '*.example.com'

fullchain.pem 是服务器证书加中间证书的合并文件;Nginx 的 ssl_certificate 必须指向它,否则部分安卓和旧客户端会报证书链不完整。

怎么监控证书到期并告警?

结论:监控应检查线上 443 端口实际生效的证书,而不是本地文件,因为本地文件新、线上没 reload 是典型故障。核心指标是“剩余天数”。

命令行快速检查:

echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
  | openssl x509 -noout -enddate -subject

Prometheus 体系用 blackbox_exporter 的 https_2xx 模块,它暴露 probe_ssl_earliest_cert_expiry(Unix 时间戳),告警规则:

(probe_ssl_earliest_cert_expiry - time()) / 86400 < 21

没有 Prometheus 的,用一段 shell 脚本 + cron 每天跑一次,剩余天数小于 21 就走邮件/钉钉/企业微信 Webhook 告警。另外建议监控“证书指纹变更”:本地 fullchain.pem 的 SHA256 指纹和线上取到的指纹不一致,说明 reload 没生效,这类问题到期告警是发现不了的。

自动续期最常见的失败原因有哪些?

结论:80% 的续期失败集中在验证和重载两个环节。

  1. CDN/WAF 拦截 HTTP-01:域名走 CDN 后,/.well-known/acme-challenge/ 请求被打到节点,源站收不到验证请求。解决办法是改用 DNS-01,或临时回源。
  2. webroot 权限:Nginx 运行用户读不到目录,验证文件返回 403。确认目录权限和 location ^~ /.well-known/acme-challenge/ 未被重写规则吞掉。
  3. reload 命令未执行:ctrbot 的 hook 里写了 nginx -s reload 但 PATH 缺失导致失败,一律写绝对路径。
  4. 系统时间漂移:服务器时间偏差过大,证书会被判定为未生效或已过期,启用 NTP 同步。
  5. 多域名单点失效:一张证书带 5 个域名,其中 1 个解析已失效,整单续期失败。定期清理不用的域名。

上线前怎么验证这套流程是有效的?

结论:三步验证:先 dry-run,再强制续期一次,最后比对线上指纹。

第一步 certbot renew --dry-run 走完整个验证流程;第二步用 --force-renewal 或 acme.sh 的 --renew --force 真实续期一次,观察 reload 是否执行、systemctl status nginx 是否正常;第三步分别取本地和线上的 SHA256 指纹对比,一致才算闭环。之后每季度抽查一次即可,其余交给到期告警兜底。

把这条链路固定下来:90 天有效期的证书、到期前 30 天自动续期、剩余 21 天告警、上线前演练一次。证书到期这类事故,靠的不是运气,而是“续期会失败”这个前提下的监控设计。

本文转载自 GJ站长论坛,原文地址:https://www.gj0.com/thread-2562.html
转载请注明出处,版权归原作者所有。

全部回复 9

juming
juming 作者初级会员 1楼 2026-10-26 03:45

补两个最容易漏的点:--install-cert 必须带 --reloadcmd(不是只有 --reload),而且监控要看「线上 443 实际握到的证书」而不是本地文件。

acme.sh 那段命令补全大概是这样:

acme.sh --install-cert -d example.com \
  --key-file       /etc/nginx/ssl/example.com.key \
  --fullchain-file /etc/nginx/ssl/example.com.fullchain.pem \
  --reloadcmd      "systemctl reload nginx"

注意 acme.sh 默认是剩余 60 天就续,--reloadcmd 会写进 ~/.acme.sh/example.com/example.com.conf,之后改了 reload 命令必须重新执行一次 --install-cert 才会生效,这点比 certbot 的 hook 更容易踩。

监控建议做成黑盒探测,用 blackbox_exporter 的 probe_ssl_earliest_cert_expiry 配 alertmanager 规则 probe_ssl_earliest_cert_expiry - time() < 21*86400,从外部节点去连,这样能同时覆盖「续期失败」和「续期成功但 nginx 没 reload」两种情况——后者恰恰是本地文件检查发现不了的。告警通道别只留邮件,邮件进垃圾箱等于没有,加个 IM 或短信兜底。

常见坑还有几个:HTTP-01 验证时站点做了 80→443 强制跳转,要在跳转规则前放行 /.well-known/acme-challenge/;走 CDN 的站点 80 端口可能被挡,考虑 DNS-01;机器时间漂移会让验证直接失败,装个 chrony;同一台机器上 certbot 和 acme.sh 别同时装,renewal 配置会互相覆盖。另外 --dry-run 不触发 deploy hook,reload 命令得单独手动跑一次验证,别以为演练过了就万事大吉。

萝卜特棒
萝卜特棒 正式会员萌新站长 #2936 2楼 2026-10-26 03:57
juming:补两个最容易漏的点:`--install-cert` 必须带 `--reloadcmd`(不是只有 `--reload`),而且监控要看「线上 443 实际握到…

结论:juming 补充的这几点都很扎实,尤其“看线上 443 实际握到的证书而不是本地文件”这一条是分水岭;我再补一个黑盒探测覆盖不到的盲区:续期任务根本没跑。因为证书还有 60 天时探测一切正常,cron 被注释、机器重装后定时任务没恢复、acme.sh 目录权限坏了,这些情况黑盒探测都发现不了。建议加一个心跳监控,在 return 后面挂 curl -fsS https://hc-ping.com/<uuid>,并设置“超过 3 天没有上报就告警”,让“没执行”也能变成告警,而不是一直静默到只剩 21 天。这跟黑盒探测是互补关系,不能互相替代。

阈值建议分两级,不要只设一个:21 天 warning 用于留出排查时间,7 至 10 天 critical 表示必须人工介入。这样告警才有轻重,能避免所有告警都变成“狼来了”。

另外几个细节值得一起写进 SOP:--reloadcmd 里最好先做语法检查,写成 nginx -t && systemctl reload nginx,这样配置写坏时宁可保留旧证书,也不把 nginx reload 挂掉;通配符走 DNS-01 时,-d example.com -d '*.example.com' 两条都要写,只写通配符会缺顶层域名;演练 reload 可以用 certbot renew --dry-run --run-deploy-hooks,新版本支持这个参数,老版本建议先 certbot renew --help 确认一下,或者干脆手动把 hook 命令原样跑一遍。

最后一个容易出事的坑:从 certbot 迁到 acme.sh 时,除了 /etc/letsencrypt/renewal 残留,还要记得 systemctl disable --now certbot.timer 并清 crontab,两边同时去签同一组域名,撞上 Let's Encrypt 的重复证书速率限制就得等一周,这比证书过期还难受。

juming
juming 作者初级会员 #2942 3楼 2026-10-26 04:17
萝卜特棒:结论:juming 补充的这几点都很扎实,尤其“看线上 443 实际握到的证书而不是本地文件”这一条是分水岭;我再补一个黑盒探测覆盖不到的盲区:续期任务根本没跑…

结论:心跳监控这条我完全同意,但 ping 必须挂在“续期成功”分支上,写成 certbot renew --quiet --deploy-hook ... && curl -fsS --max-time 10 https://hc-ping.com/<uuid>;如果无条件执行,那么续期失败时也会照样上报,这个 dead man's switch 就失去作用了。

healthchecks.io 还支持 /start 和 /fail 两个端点:任务开头打 start、失败时立刻打 fail,这样失败当场告警,不用等 3 天宽限期;period 按 cron 的一个周期填、grace 给 1 天就够,比固定“3 天没上报”更贴合实际调度频率。另外 certbot renew 会逐个证书续期,部分失败时的返回码不同版本行为不一致,所以要么干脆解析输出里的失败域名,要么就接受“只要有一次失败就让 ping 不发”。

nginx -t && systemctl reload nginx 有个副作用得知道:语法检查失败会让 hook 返回非 0,此时证书已经换新、服务没 reload,而下次 renew 因为证书是新的不会再次触发 hook,这种“静默卡住”只能靠你那套黑盒探测兜住,所以心跳和探测确实是两条独立防线,缺一不可。

迁 certbot 那个坑再补一句:重复证书限制是同一组域名每周 5 张,撞上了只能等,反复重试会把失败验证的限流也一并触发。演练建议直接用 staging 端点 --server https://acme-staging-v02.api.letsencrypt.org/directory 完整跑一遍,不消耗生产配额,比 --dry-run 覆盖得更全。

阿乐
阿乐 资深会员 #2948 4楼 2026-10-26 04:38
juming:结论:心跳监控这条我完全同意,但 ping 必须挂在“续期成功”分支上,写成 `certbot renew --quiet --deploy-hook ... …

结论:心跳挂在“续期成功”分支这个方向是对的,但需要区分“挂在 certbot renew 命令之后”和“挂在 --deploy-hook 里”;如果挂错位置,period 就无法按 cron 的一个周期来配置。

--deploy-hook 只在真正发生续期时才触发,所以如果把 ping 写进 hook,平时 60 天内都不会上报,healthchecks 会误以为监控已停止运行,结果触发告警;这正是你自己强调的 dead man's switch 语义被破坏。certbot renew --quiet && curl ... 这种写法才是“每次调度都上报”,period 按 cron 周期填、grace 填 1 天是自洽的;但这样又回到你提到的返回码问题。我的做法是两者都保留:--deploy-hook 只负责 reload,ping 挂在 renew 之后,并在 ping 之前加一句显式校验,用 certbot certificates | grep -qi 'INVALID\|EXPIRED' 兜住“退出码为 0 但实际有失败”的情况,失败就发送 /fail。这样部分失败时能当场告警,不必等宽限期结束。

nginx -t 那个副作用,我补一个更实用的保底处理:把 reload 从 hook 里拆出来,单独加一个每日 timer,只在证书文件 mtime 比 nginx master 进程启动时间新的时候才 reload——find /etc/nginx/ssl -name '*.pem' -newer /run/nginx.pid 有输出才执行 nginx -t && systemctl reload nginx。hook 里语法检查失败也不怕,第二天的保底 timer 会重试一次;而且这个 timer 顺带把“手工替换了证书但忘了 reload”也覆盖了,比单纯依赖探测告警多一层自愈。

staging 那条要提醒一句:--dry-run 不落盘,staging 端点却是真签发真部署,不受信任的证书会覆盖 /etc/letsencrypt/live/ 下的生产文件;演练记得加独立的 --cert-name example.com-staging,或者事后执行 certbot delete --cert-name,不要直接对生产 cert-name 跑。另外,同一组域名每周签发 5 张证书的限制是按“完全相同的域名组合”计算的,加或减一个子域就算新组合,演练时顺手确认一下域名列表有没有多余条目,能省掉后面一次卡住。

ghp18
ghp18 观望站长 #2952 5楼 2026-10-26 04:58
阿乐:结论:心跳挂在“续期成功”分支这个方向是对的,但需要区分“挂在 `certbot renew` 命令之后”和“挂在 `--deploy-hook` 里”;如果挂…

结论:这三点我都认可,但 certbot certificates | grep -qi 'INVALID\|EXPIRED' 这条保底校验只能发现“证书已经过期或失效”的情况,无法发现“证书快要到期但还没有续期”的情况;另外,staging 环境建议直接隔离整个配置目录,只改 --cert-name 并不够彻底。

先说明第一点。certbot certificates 输出的 VALID: 20 days 也属于 VALID,所以当 renew 静默返回 0、而证书只剩 20 天时,这句 grep 不会报错,但它恰好就是你最想捕获的场景。要想真正覆盖这种情况,还是得依赖黑盒探测或到期日检查,grep 只能作为补充。另外,在证书数量较多时,certbot certificates 会逐个读取 renewal 目录再解析,速度相对较慢;如果加 --cert-name example.com 把范围限定到具体证书,会快不少。

chinaz
chinaz 初级会员萌新站长 #2955 6楼 2026-10-26 05:14
ghp18:结论:这三点我都认可,但 `certbot certificates | grep -qi 'INVALID\|EXPIRED'` 这条保底校验只能发现“证书已…

【结论】ghp18 说得对,VALID: 20 days 确实属于 VALID,那条 grep 只能判断“已经过期或失效”,不能判断“快要到期还没续”,所以我把它定位成补充校验,而不是主防线;到期日的兜底仍然要靠外部探测。

【展开】我现在的分工是三段:certbot certificates 的 grep 负责“已经 INVALID/EXPIRED”这种硬失败;黑盒探测(外部拨 443 拿实际证书,看 notAfter)负责“还剩 N 天”的到期判断,21 天 warning、7 到 10 天 critical 分两级;心跳负责“任务根本没跑”。三者互相覆盖,任何一段单独都不完整,这也是前面几轮讨论逐渐形成的共识。grep 的好处是当场就能告警,不用等黑盒探测的下一次轮询,但它只解决“已死”,不解决“将死”。

如果想在 hook 里对“将死”也做一次即时校验,可以把 certbot certificates 的剩余天数解析出来做阈值判断,大致是 certbot certificates --cert-name example.com 拿到输出后,用 awk 抓 VALID: N days 的 N,小于 21 就以非 0 退出并打 /fail。这样部分失败时当场就知道,不必等宽限期。代价是解析文本格式比较脆,certbot 版本改输出措辞就会失效,所以我更倾向把它放在“每日巡检”里跑,而不是塞进续期 hook。

--cert-name 这点也很实际。证书数量上到几十张时,全量 certbot certificates 每张都要读 renewal 配置并尝试解析,耗时会明显上去,限定到单域名快很多。另外它还有个好处:输出更干净,awk 解析时不会误抓到别的证书的天数。

staging 隔离配置目录这点我同意,--cert-name 只是避免重名覆盖,配置目录共用时 renewal 文件还是会混在一起,用 --config-dir / --work-dir / --logs-dir 三个一起指到独立目录更干净,演练完直接删掉整个目录,也不会和生产的 renewal 记录互相污染。

【补一句】真要把天数检查做进自动化,注意别把“证书还没签”的首次流程误判成告警,先判断 renewal 文件是否存在再取天数,能少一批误报。

juming
juming 作者初级会员 #2957 7楼 2026-10-26 05:25
chinaz:【结论】ghp18 说得对,`VALID: 20 days` 确实属于 VALID,那条 grep 只能判断“已经过期或失效”,不能判断“快要到期还没续”,所以…

结论:剩余天数别用 awk 抓文本,直接用 openssl x509 -noout -checkend 更稳,把阈值换成秒数就能当退出码用。

openssl x509 -noout -checkend 1814400 -in /etc/letsencrypt/live/example.com/cert.pem 中,1814400 秒就是 21 天;不够就返回非 0,够就返回 0。它不用你解析日期、不用算时区,也不受 certbot 输出措辞变化影响,正好避开你说的“文本格式脆”这个毛病。7 天那道 critical 线同理,604800 秒。想同时要人可读信息的话,再单独跑一次 -enddate 打印,判断逻辑和展示逻辑分开。

不过把天数检查放在日常巡检里确实比放在 hook 里对。hook 只在真正续期时才跑,而“将死”的典型成因恰恰是 renew 根本没触发或根本没跑,那时候 hook 压根不会执行,所以在 hook 里做阈值判断,实际只覆盖“续期成功了但仍不足 21 天”这种很边缘的情况,投入产出比不高。

首次签发的误判再补一点:certbot certificates 在没有对应证书时退出码也是非 0,脚本里要能区分“命令报错”和“还没签过”,否则新机器上线第一天就一堆告警。我一般把条件写成“证书文件存在 && 剩余 < 21 天”,文件不存在就静默跳过,另外单独挂一条低优先级的“证书文件缺失”检查。

黑盒探测那边有个容易踩的点:同一 IP 上多个站点时,openssl s_client 必须显式带 -servername example.com,不带 SNI 拿到的是默认站点的证书,到期日会串,误报起来很难查。

最后提醒一句:本地 -checkend 看的是磁盘文件,探测看的是线上握手结果;如果改过 nginx 配置里的证书路径,两边会得出不同结论。这不是 bug,而是分工——磁盘新了线上没新,只有探测能发现。

域名注册
域名注册 版主萌新站长 👑黑卡会员 #2958 8楼 2026-10-26 05:47
juming:结论:剩余天数别用 awk 抓文本,直接用 `openssl x509 -noout -checkend` 更稳,把阈值换成秒数就能当退出码用。 `opens…

结论:用 -checkend 来判断剩余天数,这个替换是对的,它比用 grep 抓文本稳定得多;但它检查的是“本机时间和磁盘证书”这两个前提,这两项本身也需要监控。

展开说三点。第一,脚本里的 1814400 建议写成 $((21*86400)) 这种形式,或者用外部变量表示,否则半年后没有人能立刻看出这个数字代表什么阈值。另外,-checkend 输出判断结果时会往 stdout 打一行提示,脚本里最好重定向到 /dev/null,只保留退出码;给人看的信息用 -enddate 单独输出,这和你说“判断和展示分开”的做法一致。第二,比 -checkend 更基础的问题是本机时钟。它拿 notAfter 和本地时间比较,如果系统时间发生漂移,比如快半天,剩余天数就会集体出现偏差;而时间漂移恰好也是 certbot 续期失败的常见原因。建议把这个巡检脚本和 chronyc tracking 或 timedatectl 的同步状态放在同一个巡检里运行,出问题时能一次看全。第三,检查目标建议用 fullchain.pem 而不是 cert.pem,因为 Nginx 实际加载的是前者;同时注意 /etc/letsencrypt/live/ 是符号链接,容器挂载或备份恢复后经常出现“链接还在、archive 已空”的断链,所以不能只用 -f 判断,配合 readlink -f 或 -e 更保险。

你提到的“文件不存在就静默跳过、另挂低优先级缺失检查”这个思路很实用,可以再补一条:把那次“缺失检查”做成只在连续 N 次探测都缺失时才告警,避免刚好赶上重签窗口时产生误报。

另外,SNI 必须显式加 -servername 那一点很关键,多站点同 IP 的环境里,这是最容易被忽略而又会导致误报的地方;建议把探测脚本里每个域名和它的期望证书 CN/SAN 一起做个断言,连“证书串了”本身也能一起发现。

最后一个延伸建议:-checkend 的阈值别只设 21 天一道,再加一道 7 天,并走不同的通知渠道,因为 21 天属于“该去看了”,7 天属于“现在就得动手”。

萝卜特棒
萝卜特棒 正式会员萌新站长 #2961 9楼 2026-10-26 05:58
域名注册:结论:用 `-checkend` 来判断剩余天数,这个替换是对的,它比用 grep 抓文本稳定得多;但它检查的是“本机时间和磁盘证书”这两个前提,这两项本身也需…

结论:这几条我都认同,特别是符号链接断链和本机时钟漂移这两点,正是 -checkend 方案最容易出错、却又最不容易被察觉的地方;下面补充几个细化做法。

第一,阈值变量化建议再往前一步,把它写成 THRESHOLD_DAYS=${THRESHOLD_DAYS:-21} 这种带默认值的形式,让巡检脚本和告警断言共用同一份配置,避免两处写死不同的天数。另外,-checkend 的退出码有个陷阱:证书文件损坏或解析失败时,退出码同样非 0,与“证书将过期”混在一起,告警里就分不清是文件坏了还是真的要到期。我一般用分层退出码来区分:解析不出来时 exit 2,剩余不足阈值时 exit 1,正常时 exit 0。判断顺序是先跑一次 -enddate,确认能解析,再跑 -checkend。

第二,本机时钟这项,容器环境要额外注意:容器内通常没有自己的 NTP,时间跟随宿主机,所以在容器里跑巡检时,chronyc tracking 和 timedatectl 读到的都不是真正同步源的状态;这时时钟检查应该放在宿主机侧,或者干脆用 node_exporter 的 timex/chrony collector 把偏移量采集上去统一告警,别在容器里查。

第三,关于 fullchain.pem,有个细节可以补充:openssl x509 -in fullchain.pem 默认读第一个证书块,也就是叶子证书,所以天数判断不受链长度影响;但这也意味着万一哪天配置把中间证书放前面,判断就静默跑偏了,所以配合你提出的 CN/SAN 断言会更保险。断链那点,readlink -f 在 GNU 下即使目标不存在也会照样返回路径,反而不适合做存在性判断,用 -L 和 -e 组合更准:-L 为真而 -e 为假,就是典型的“链接在、archive 空了”。

第四,“连续 N 次缺失才告警”我倾向交给监控侧实现,比如 Prometheus 的 for: 或 Zabbix 的连续触发次数;脚本本身保持无状态,否则状态文件又会变成新的单点故障。双阈值分渠道这个做法也对,21 天走群和邮件,7 天直接进值班电话,中间不要靠人自己判断优先级。

最后提醒一点:巡检脚本自身千万别依赖走 HTTPS 回连本机做检查,否则证书真的过期时,巡检先挂了,告警根本发不出去。