在 Nginx + Certbot 的 HTTPS 维护场景里,最常见也最容易误判的一个问题就是:Certbot 续期命令执行完毕,退出码是 0,表面看没有任何错误;但当你用 OpenSSL 检查 443 端口时,看到证书仍然停留在上个月的有效期范围。这个问题的麻烦之处在于“看起来成功”和“实际生效”之间隔着一层没有直接暴露的环节,很多人会反复执行续期命令,结果都是 exited 0,问题却始终没有消失。在正式排查之前,先说明一个前提:如果你刚刚运行了certbot renew,并且退出码是 0,不要急着确认证书已更新。退出码 0 只代表 Certbot 自己的执行流程没有报错,并不代表浏览器或客户端握手的最终结果已经变好。本文按“退出码含义 -> 证书文件状态 -> 443 端口内容 -> 服务加载行为 -> 代理与缓存”的顺序把整条链路理清,最后给出可以直接照做的排查命令和确认清单。
1. 先拆开“Certbot exited 0”:退出码 0 不等于证书已经生效
1.1 Certbot 的退出码描述的是“流程执行”,不是“证书一定更新”
很多运维同学看到exited 0的第一反应是“成功了”。在 systemd timer 或 crontab 的日志里,Certbot exited 0确实说明 Certbot 进程执行完整个流程后正常退出,没有抛异常。但“正常退出”只能说明本轮续期逻辑按预期走完了,不能说明“证书被换成了新证书”。
Certbot 的renew行为可以分成两种情况:
- 证书确实到期或即将到期,Certbot 发起续期,成功拿到新证书,然后执行 deploy hook。
- 证书距离到期还有较长时间,Certbot 检查到期时间后直接跳过,日志里出现
Certificate not yet due for renewal,此时同样返回退出码 0。
所以退出码 0 是一个“流程状态码”,而不是“证书更新状态码”。你需要通过certbot certificates或openssl x509去确认证书是否真的变了。
1.2 先看 certbot certificates,确认 Certbot 自己认为证书是什么状态
在打开 OpenSSL 查 443 端口之前,先确认 Certbot 当前记录的证书状态:
sudo certbot certificates这条命令会输出 Certbot 管理的所有证书,关键字段包括:
Certificate Name:证书配置名称,通常是域名。Domains:包含哪些域名。Expiry Date:证书过期时间。Certificate Path:当前 live 目录下的证书路径。
如果你看到Expiry Date仍然对应上个月签发的证书,那说明 Certbot 这轮续期根本没有成功,或者根本没有触发续期。如果Expiry Date已经变成未来新日期,但openssl s_client检查 443 端口仍是旧证书,问题就转移到“服务没有读取新证书”这一层。
这里要注意:certbot certificates输出的证书路径通常指向/etc/letsencrypt/live/<域名>/fullchain.pem,这是 Certbot 通过 symlink 指向 archive 目录的入口。只要 Certbot 认为证书已经更新,这个路径下的 fullchain.pem 会跟着指向新的 archive 文件。
1.3 记住 live 目录下的四份文件分别是什么
/etc/letsencrypt/live/<域名>/目录下通常有四个文件:
/etc/letsencrypt/live/example.com/ ├── cert.pem ├── chain.pem ├── fullchain.pem └── privkey.pem它们之间的关系是:
cert.pem:只包含站点证书,不含中间证书。chain.pem:只包含中间证书链。fullchain.pem:站点证书 + 中间证书链,Nginx 的ssl_certificate一般用它。privkey.pem:私钥,Nginx 的ssl_certificate_key用它。
排查时一定要确认服务配置里用的是fullchain.pem还是cert.pem。如果配置写的是cert.pem,在部分客户端上会出现证书链不完整的问题,同时也会让你在openssl s_client输出里看到与预期不同的颁发链。虽然这不会直接导致“443 显示旧证书”,但它会影响你对证书的比对判断。
注意:不要只验证
/etc/letsencrypt/live/下文件时间对不对,还要确认 Nginx 配置真正指向的是哪一个文件。文件时间只能说明文件被替换过,不能说明服务已经读进来。
2. OpenSSL 看到旧证书之前,先弄清楚你在检查哪一层的证书
2.1 三个容易混淆的对象:磁盘文件、服务内存、客户端握手
排查“443 端口仍显示旧证书”时,容易把下面三个对象混在一起:
- 磁盘上的证书文件,例如
/etc/letsencrypt/live/example.com/fullchain.pem。 - 正在运行的服务(通常是 Nginx)加载到内存中的证书。
- 客户端通过 TCP 443 端口做 TLS 握手时拿到的证书。
Certbot 续期只负责更新第 1 项。第 2 项需要 Web 服务重载或重启才会更新。第 3 项是客户端真实得到的证书,也是 OpenSSL 命令能看到的证书。你要查的问题出在第 3 项,但原因可能在 1、2 之间断开了。
如果第 1 项已经是新证书,第 3 项还是旧证书,说明第 2 项没有跟上。如果第 1 项还是旧证书,问题出在 Certbot 续期本身,和第 3 项没有关系。
2.2 用 openssl s_client 抓取 443 端口当前真实证书
检查客户端实际拿到的证书,不需要重新下载 OpenSSL,直接使用系统自带的openssl命令即可:
echo | openssl s_client -connect 127.0.0.1:443 -servername example.com 2>/dev/null | openssl x509 -noout -subject -dates -issuer解释一下参数:
s_client:openssl 提供的 TLS 客户端工具,用于向指定地址发起握手。-connect 127.0.0.1:443:连接本机 443 端口,也可以换成公网 IP 或域名。-servername example.com:通过 SNI 指定访问的域名。如果 443 端口上配置了多个证书,这个参数会直接决定你拿到哪份证书。| openssl x509 -noout -subject -dates:从握手结果中提取证书的主体、生效时间和过期时间。
输出大致是这样:
subject=CN = example.com notBefore=Jan 5 00:00:00 2025 GMT notAfter=Apr 5 00:00:00 2025 GMT issuer=C = US, O = Let's Encrypt, CN = R11如果你当前已经处于 3 月,而notBefore还是 1 月 5 日,说明端口呈现的不是 3 月续期后的证书。如果notAfter距当前不足 30 天,也说明旧证书仍在线。
同时检查磁盘上当前 live 目录的证书时间:
sudo openssl x509 -enddate -subject -noout -in /etc/letsencrypt/live/example.com/fullchain.pem如果这条命令输出的notAfter是新日期,而openssl s_client输出的notAfter是旧日期,就可以确定:磁盘已经更新,服务没有读取。
2.3 检查系统时间:证书时间线对比的前提
证书的notBefore和notAfter都是绝对时间。判断“上个月”和“新证书”必须依赖当前系统时间。如果服务器时间慢了几天或快了几天,很容易误导判断。
检查系统时间:
date timedatectl如果发现系统时间与真实时间偏差过大,先同步时间,再重新做证书时间对比。时间同步本身也可能导致 Certbot 对“是否到期”的判断发生偏差,但这种情况在正常使用 NTP 同步的服务器上很少见。
注意:openssl 命令读证书本地时间,读的是服务器系统时间;浏览器读到的证书有效期,则是客户端系统时间和证书时间的对比。同一份证书在两边显示可能不同,但证书本身的
notBefore、notAfter字段是确定的,不会因为系统时间改变而改变。
3. 根因一:证书文件已经更新,但 Nginx 没有重新读取
3.1 Nginx 只在启动或 reload 时读取证书文件,这是最常见的根因
很多人对 Certbot 续期的理解是:证书文件被替换后,Nginx 会自动感知。实际上 Nginx 不会自动重新读取证书。Nginx worker 进程在启动或执行 reload 时,才会重新加载ssl_certificate和ssl_certificate_key指向的文件。
用 openssl 命令直接看磁盘文件,看到的是新证书;用openssl s_client连接 443 端口,看到的是旧证书。这种差异就是典型的“文件更新了,但服务没有 reload”。
3.2 对比服务中的证书时间和磁盘上的证书时间
把第 2 节的两条命令放在一起执行:
echo | openssl s_client -connect 127.0.0.1:443 -servername example.com 2>/dev/null | openssl x509 -noout -enddate sudo openssl x509 -enddate -noout -in /etc/letsencrypt/live/example.com/fullchain.pem如果第一条输出的是旧时间,第二条输出的是新时间,说明 Nginx 还持有旧证书。此时执行 reload:
sudo systemctl reload nginxreload 完成后,再次执行第一条命令。如果输出已经变成新证书的时间,说明问题就是服务没有重新读取证书文件。
3.3 reload 与 restart 的选择,以及 deploy-hook 的作用
Nginx 的reload是平滑重载:master 进程重新读取配置,启动新的 worker,旧的 worker 处理完当前连接后退出。这个过程不会断开正在进行的请求,所以 Certbot 的自动化续期一般推荐使用 reload。
restart会强制停止再启动,虽然也能读取新证书,但会造成连接中断,不适合生产环境自动执行。
Certbot 在续期成功后是否会执行 reload,取决于两个条件:
- 使用的插件是否自带了 reload 逻辑。
- 是否配置了 deploy hook。
certbot renew如果使用--nginx插件,部分版本会在续期成功后自动 reload Nginx;如果使用--webroot或--certonly,则不会自动 reload。deploy hook 是一个更通用的方式,在续期成功且安装新证书后执行:
sudo mkdir -p /etc/letsencrypt/renewal-hooks/deploy sudo tee /etc/letsencrypt/renewal-hooks/deploy/nginx-reload > /dev/null <<'EOF' #!/bin/bash systemctl reload nginx EOF sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/nginx-reload脚本里的systemctl reload nginx会在 Certbot 成功续期后触发。如果 reload 失败,需要确认脚本权限、systemd 服务名称和 Nginx 配置是否正确。
如果你发现 Certbot 已经配置了 deploy hook,但 443 端口仍是旧证书,先手动执行一遍 hook 脚本,再检查脚本是否真的被调用:
sudo bash -x /etc/letsencrypt/renewal-hooks/deploy/nginx-reload3.4 现象、检查和修复速查
| 项目 | 典型现象 | 检查方式 | 处理建议 |
|---|---|---|---|
| 磁盘证书 | fullchain.pem 时间已经是新时间 | 用 openssl x509 读文件 | 说明续期已完成,不需要再次续期 |
| 端口证书 | openssl s_client 输出仍是旧时间 | 连接 443 端口看握手结果 | 执行 reload 或重启 TLS 终止服务 |
| deploy hook | 日志显示 hook 未执行或执行失败 | 查看 letsencrypt.log 和 hook 脚本权限 | 手动执行 hook 脚本,修复脚本错误 |
| 多 worker / 多实例 | 部分 IP 是新证书,部分 IP 是旧证书 | 从不同网络或机器检查 443 | 检查每一台机器上的证书分发和 reload |
4. 根因二:Certbot 没有续期,只是按规则跳过了
4.1 日志中的 not due for renewal 不是错误
Certbot exited 0还有一种非常常见的情况:Certbot 检查完证书到期时间后,认为证书还不需要续期,直接跳过。此时日志会出现类似下面这样的内容:
Certificate not yet due for renewal No renewals were attempted.这种日志不代表异常。Let's Encrypt 证书有效期是 90 天,Certbot 默认会在证书剩余时间不足 30 天时触发续期。如果你的域名证书上个月刚签发,现在运行certbot renew,大概率就是返回 0 但没有任何替换动作。
如果你误以为“退出码 0 就是续期成功”,可能根本不会去看日志,然后花很长时间排查一个本来就没有发生的问题。
4.2 看清 renew 日志里到底发生了什么
查看 Certbot 日志,确认本轮执行是否有真正的续期动作:
sudo tail -100 /var/log/letsencrypt/letsencrypt.log关注几个关键字:
Skipped:证书未到期,跳过。Renewing an existing certificate:正在续期。Successfully received certificate:成功拿到新证书。Running deploy-hook command:正在执行部署钩子。Error:出现错误。
如果日志里是Skipped,那exited 0只是“顺利跳过”的意思。此时不需要改 Nginx,也不需要 reload,因为根本没有新的证书文件产生。
4.3 强制续期只能在明确需要时使用,生产环境要克制
如果确实需要立即换新证书,比如怀疑旧证书私钥泄露,或证书内容有问题,可以强制续期:
sudo certbot renew --force-renewal--force-renewal会让 Certbot 无视剩余时间要求,重新申请证书。但这里要非常克制:频繁向 Let's Encrypt 发起续期请求,可能触发频率限制;生产环境也不要把它写进 cron 定时任务里。
--force-renewal执行完成后,再运行certbot certificates确认Expiry Date已经变化,然后手动 reload Nginx 或确认 deploy hook 已经执行。这里经常出现第三个坑:强制续期确实产生了新证书,但没有配置 deploy hook,Nginx 还是旧内存证书。这又回到第 3 节的问题。
| 日志关键字 | 含义 | 下一步动作 |
|---|---|---|
| Skipped | 证书未到期,不续期 | 不需要 reload,保留现有服务 |
| Renewing an existing certificate | 进入续期流程 | 等待续期完成,关注是否成功 |
| Successfully received certificate | 新证书已写入 live 目录 | 确认 deploy hook 是否执行 reload |
| Running deploy-hook command | 续期后正在执行部署钩子 | 检查 hook 输出和最终端口证书 |
5. 根因三:443 端口背后不是你想的那份服务配置
5.1 导出 Nginx 真实配置,找到所有 server 块
如果磁盘证书已经更新,reload 也已经执行,但 443 端口仍是旧证书,下一个要怀疑的是:443 端口走的根本不是你以为的那个 Nginx server 块。
先导出 Nginx 当前生效的全部配置:
sudo nginx -T 2>/dev/null | grep -E "server_name|ssl_certificate|ssl_certificate_key|listen"nginx -T会把所有配置解析后的内容输出,包含 include 进来的所有文件。listen 443 ssl;表示该 server 块监听 443,ssl_certificate表示它实际使用的证书路径。
如果配置里出现了多个ssl_certificate指向不同路径,要确认访问example.com时命中哪个 server 块。域名匹配顺序是:精确匹配 > 通配符前缀匹配 > 正则匹配 > 默认 server。如果你配置了一个默认 server 或正则 server,客户端访问可能落到旧证书所在的 server 块。
如果想看到某个域名最终命中了哪个证书,可以在配置里临时关闭默认 server 的监听,或直接通过 SNI 抓包确认。更简单的做法是:
echo | openssl s_client -connect 127.0.0.1:443 -servername example.com 2>/dev/null | openssl x509 -noout -subject如果 subject 不是CN = example.com,说明命中了别的证书,大概率是默认 server 配置。
5.2 live 目录 symlink 被手动改过,导致新证书没有接上
Certbot 会自动把/etc/letsencrypt/live/<域名>/下的文件用 symlink 指向/etc/letsencrypt/archive/<域名>/下最新的版本。如果之前有人手动把fullchain.pem换成普通文件,或手动改过 symlink 指向,Certbot 续期后可能不会自动更新这个链接。
检查 live 目录里的文件类型:
ls -la /etc/letsencrypt/live/example.com/正常情况是:
fullchain.pem -> ../../archive/example.com/fullchain1.pem privkey.pem -> ../../archive/example.com/privkey1.pem如果fullchain.pem是普通文件,续期时 Certbot 可能不会覆盖它。此时需要重新建立 symlink:
sudo ln -sf /etc/letsencrypt/archive/example.com/fullchain1.pem /etc/letsencrypt/live/example.com/fullchain.pem sudo ln -sf /etc/letsencrypt/archive/example.com/privkey1.pem /etc/letsencrypt/live/example.com/privkey.pem sudo systemctl reload nginx注意,archive目录下的文件名会随着续期次数增加,例如fullchain1.pem、fullchain2.pem。调整 symlink 前要确认当前最新的 archive 文件编号。
5.3 如果不是 Nginx 在监听 443,先定位 TLS 终止点
有时候 443 端口根本不是 Nginx 在监听。可能是 haproxy、Traefik、Apache、Java 网关,或者是 Docker 容器里的另一套服务。
先确认监听进程:
sudo ss -ltnp | grep ':443'输出里会出现 PID 和进程名。如果监听进程不是 Nginx,比如是haproxy或java,那么 reload Nginx 没有意义。你需要 reload 或重启真正持有 TLS 证书的服务进程。
如果本机 443 端口没有输出,说明流量可能经过四层负载均衡,在更前面的设备上终止了 TLS。这时用本机 IP 检查 443 端口没有意义,应该用对外的域名、负载均衡地址或真实客户端网络来检查。
5.4 多实例、负载均衡和证书分发场景下,端口背后可能不是本机
如果环境里有多个 Nginx 实例,证书可能只在其中一台机器上执行了续期。其他机器的证书还是旧文件。这就是为什么从不同网络检查同一个域名,有时会得到不同的证书时间。
排查时不要只看当前机器。建议把检查命令放到负载均衡后面的每一台后端机器上执行,或者直接访问域名,对比多次握手结果。证书分发场景下,还要检查分发任务是否执行成功,比如 ansible、rsync、对象存储同步等。
| 检查对象 | 命令 | 判断标准 |
|---|---|---|
| 本机 443 监听进程 | sudo ss -ltnp | grep ':443' | 确认 TLS 终止点是否在本机 |
| 本机 Nginx 全量配置 | sudo nginx -T 2>/dev/null | grep -E "server_name|ssl_certificate" | 确认 server 块和证书路径 |
| live 目录 symlink | ls -la /etc/letsencrypt/live/example.com/ | 确认指向 archive 最新文件 |
| 多台后端机器 | 在每台机器上执行端口证书检查 | 确认每台机器都拿到新证书 |
| 负载均衡或公网地址 | 从外部机器用域名检查 443 | 确认客户端视角的最终结果 |
6. 根因四:OCSP stapling、代理缓存与部署钩子的隐性影响
6.1 OCSP stapling 可能让客户端拿到旧签名时间
Nginx 开启了ssl_stapling on;后,会向 OCSP 服务器查询证书状态,并把查询结果缓存。续期后如果没有 reload,Nginx 可能继续发送旧的 OCSP staple 响应。客户端看到的是旧证书,或者至少是旧证书对应的状态信息,看起来就像“443 端口还是旧证书”。
检查是否开启了 OCSP stapling:
sudo nginx -T 2>/dev/null | grep -E "ssl_stapling|ssl_trusted_certificate"如果配置里有ssl_stapling on;,确认ssl_trusted_certificate是否指向正确的 chain 文件。正确配置 OCSP stapling 需要完整的证书链,否则 stapling 不生效,Nginx 只会向客户端发送空 staple。
在 openssl 客户端里显式请求 stapling 响应:
echo | openssl s_client -connect 127.0.0.1:443 -servername example.com -status 2>/dev/null | grep -A 10 "OCSP response"如果OCSP Response Status: successful对应的时间还是旧的,说明 Nginx 还在使用旧 staple。reload Nginx 或关闭 stapling 后再重新开启,可以刷新这个状态。
6.2 deploy-hook 执行失败或未触发,证书没有 reload
有些环境里 Certbot 是自动化续期的,但续期后没有触发任何 reload,因为 deploy hook 目录为空,或者 hook 脚本写错了服务名。比如脚本里写的是systemctl restart nginx,但实际服务名是openresty、nginx2或容器名,reload 执行后仍然失败。
查看上次续期后的日志,确认有没有执行 hook:
sudo grep -iE "deploy-hook|renewal-hooks" /var/log/letsencrypt/letsencrypt.log | tail -50如果日志里根本没有Running deploy-hook command,说明 Certbot 没有触发 hook。最常见原因是续期实际没有发生,或 hook 没有配置到 renewal 配置中。
Certbot 支持三种 hook 目录:
| 目录 | 触发时机 |
|---|---|
/etc/letsencrypt/renewal-hooks/pre/ | 续期开始前 |
/etc/letsencrypt/renewal-hooks/deploy/ | 成功续期并安装新证书后 |
/etc/letsencrypt/renewal-hooks/post/ | 每次续期尝试结束后 |
deploy hook 脚本必须可执行,否则 Certbot 会跳过或报错。检查权限:
sudo ls -l /etc/letsencrypt/renewal-hooks/deploy/脚本需要包含 shebang,并且权限至少是755。
6.3 其它 TLS 终止层和应用层缓存不能忽略
如果 443 端口后面不是 Nginx,而是 Java 网关、haproxy、Tomcat、Jetty 或自定义 Go 程序,那么“reload 服务”这个动作需要换成各自对应的方式:
- haproxy:需要
systemctl reload haproxy,且 haproxy 2.x 对证书加载和 reload 的处理与 Nginx 不同。 - Java 应用:证书路径可能配置在
server.ssl.certificate,必须重启应用或动态刷新密钥库。 - 自定义 Go 程序:需要代码实现了
tls.LoadX509KeyPair且支持热更新,否则只能重启进程。 - Docker/Nginx 容器:需要重新创建容器,或进入容器 reload 后,确认挂载的证书文件路径是否与宿主机一致。
这类问题最典型的特征是:本地磁盘证书是新的,本机没有任何 Nginx,443 端口也正常,但客户端就是看到旧证书。原因就是 TLS 终止点在应用层,应用层并没有重新读取磁盘证书。
注意:浏览器缓存不会导致
openssl s_client看到旧证书,因为 openssl 每次执行都建立新的 TLS 连接。如果 openssl 看到旧证书,说明服务端确实在提供旧证书,而不是客户端缓存问题。
7. 从现象到根因的四层排查链路
7.1 第一层:对比文件证书和端口证书
按下面的顺序执行,先确定“磁盘新不新”和“端口新不新”的差异。
# 1. 查看当前 Certbot 记录的证书信息 sudo certbot certificates # 2. 查看磁盘 live 目录中证书的过期时间 sudo openssl x509 -enddate -noout -in /etc/letsencrypt/live/example.com/fullchain.pem # 3. 查看 443 端口当前握手得到的证书过期时间 echo | openssl s_client -connect 127.0.0.1:443 -servername example.com 2>/dev/null | openssl x509 -noout -enddate根据结果分支:
| 磁盘证书 | 端口证书 | 初步判断 |
|---|---|---|
| 新 | 新 | 证书已经生效,不需要继续排查 |
| 新 | 旧 | 服务没有重新读取证书,向下走 7.2 |
| 旧 | 旧 | Certbot 可能没有续期,回到第 4 节 |
| 旧 | 新 | 很少见,但可能是手动替换过证书文件或配置指向了其他路径 |
7.2 第二层:根据差异选方向,缩小到“没续期”还是“没加载”
如果磁盘证书是旧的,先看 Certbot 日志:
sudo tail -100 /var/log/letsencrypt/letsencrypt.log关注有没有Skipped、Renewing、Successfully received certificate。如果之前根本没有触发过续期,运行一次前台续期:
sudo certbot renew --force-renewal --dry-run--dry-run不会真正覆盖线上证书,只会验证续期流程能否走通。确认流程没问题后,再根据实际情况考虑是否使用--force-renewal。
如果磁盘证书是新的,端口证书是旧的,直接进入服务加载层。检查监听进程和 reload:
sudo ss -ltnp | grep ':443' sudo systemctl reload nginxreload 后立刻重新检查端口证书。
7.3 第三层:检查监听进程、配置文件和 hook
确认监听进程是不是 Nginx:
sudo ss -ltnp | grep ':443'如果不是 Nginx,去 reload 对应的服务。如果是 Nginx,再看配置:
sudo nginx -T 2>/dev/null | grep -E "server_name|ssl_certificate|listen"确认example.com命中的 server 块里,ssl_certificate指向的路径确实是/etc/letsencrypt/live/example.com/fullchain.pem。
然后检查 deploy hook 是否生效:
sudo ls -l /etc/letsencrypt/renewal-hooks/deploy/ sudo tail -50 /var/log/letsencrypt/letsencrypt.log如果 hook 脚本存在但一直没有执行,先手动执行,再观察日志。
7.4 第四层:确认客户端视角和监控告警
本机排查完成后,还要从外部客户端视角确认:
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null | openssl x509 -noout -dates如果本机是新证书,外部域名仍是旧证书,需要怀疑负载均衡、CDN 节点缓存证书、或证书没有分发到多台后端机器。生产环境里不要只信一次检查结果,可以多试几个网络节点。
如果你在管理一批证书,最好配置一个证书剩余时间监控脚本或 Prometheus exporter,以notAfter减去当前时间的结果作为告警指标。证书续期是否成功,最终应该由监控系统把关,而不是靠人肉检查日志。
8. 生产环境如何把“续期成功”变成“真正生效”
8.1 学习环境复现和临时修复的做法
学习环境想复现这个问题,最快的方法是:
- 准备一台带域名的测试服务器,安装 Certbot 和 Nginx。
- 先用 webroot 或 HTTP-01 方式签发一张证书。
- 手动修改
/etc/letsencrypt/live/example.com/fullchain.pem和privkey.pem,或者用certbot renew --force-renewal生成新证书。 - 不执行 reload,直接运行:
echo | openssl s_client -connect 127.0.0.1:443 -servername example.com 2>/dev/null | openssl x509 -noout -dates此时你会看到端口上的证书时间还没变,这就是“证书文件更新,但服务没有 reload”的现场。然后在另一台机器上观察certbot certificates和/var/log/letsencrypt/letsencrypt.log,可以直观理解退出码 0 和证书生效之间的区别。
临时修复手段:
- 如果确认证书文件已经更新,执行
sudo systemctl reload nginx。 - 如果确认 Certbot 只是跳过,可以执行
sudo certbot renew --force-renewal。 - 如果 live 目录 symlink 坏了,重建 symlink 后再 reload。
- 如果 443 端口背后不是 Nginx,reload 对应进程。
8.2 用 deploy-hook 固定 reload 动作,避免手工遗漏
生产环境里不要依赖手工 reload。推荐把 deploy hook 配置好,让 Certbot 在每次成功续期后自动完成 reload。
脚本示例:
#!/bin/bash if [ -n "$RENEWED_DOMAINS" ]; then systemctl reload nginx fi脚本放在/etc/letsencrypt/renewal-hooks/deploy/nginx-reload,并赋予执行权限:
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/nginx-reload如果使用 systemd timer 或 cron 自动续期,需要确认定时任务执行用户有权限执行 reload。通常用 root 运行 Certbot 续期任务,deploy hook 也能以 root 身份执行。
如果环境里用的是 Apache,把脚本里的 reload 命令换成:
systemctl reload apache2 # 或 systemctl reload httpd8.3 三个值得反复记住的坑
第一个坑:把exited 0当成了“证书已经更新成功”。实际判断标准应该是openssl s_client握手得到的notAfter,而不是 Certbot 的退出码。
第二个坑:强制续期后不重载 Nginx。certbot renew --force-renewal会生成新证书文件,但如果没配 deploy hook,端口可能仍服务旧证书。看到Successfully received certificate后还要确认 reload 是否发生。
第三个坑:在负载均衡或多实例环境里只检查本机。如果证书没有同步到其他机器,只有部分入口是新证书,客户端从不同网络访问会得到不同结果。续期、分发、reload 三步必须按机器逐一确认。
8.4 每次续期后可以照做的检查清单
| 检查项 | 命令或方式 | 确认结果 |
|---|---|---|
| Certbot 记录状态 | sudo certbot certificates | Expiry Date 为新日期 |
| 磁盘证书时间 | sudo openssl x509 -enddate -noout -in /etc/letsencrypt/live/example.com/fullchain.pem | 与期待的新时间一致 |
| 端口证书时间 | echo | openssl s_client -connect 127.0.0.1:443 -servername example.com 2>/dev/null | openssl x509 -noout -dates | 与磁盘证书时间一致 |
| 监听进程 | sudo ss -ltnp | grep ':443' | 确认 TLS 终止点 |
| reload 动作 | sudo systemctl reload nginx或 deploy hook | 命令退出码为 0,且端口证书时间变化 |
| 日志 | sudo tail -50 /var/log/letsencrypt/letsencrypt.log | 没有 Error,续期或跳过原因清晰 |
| 外部视角 | 从其他网络执行 openssl s_client | 公网入口的新证书时间一致 |
| 监控 | 证书剩余天数告警 | 告警已恢复或即将到期时间正确 |
这套清单可以贴在每次证书续期排障的文档里。下次再遇到 “Certbot exited 0,OpenSSL 检查 443 还是旧证书” 的问题,不要再重复执行续期命令,先跑一遍清单,确定是 Certbot 没续期、Nginx 没 reload、配置指错路径,还是端口背后根本不是 Nginx。找到差异点之后,针对那一个环节做处理,问题就能在十分钟内定位。