
最近排查了一个很典型的证书续期问题Certbot 日志里显示续期成功进程退出码是 0Nginx 的 reload 命令也执行了但线上站点响应的还是旧证书。更麻烦的是这类问题不一定每次续期都出现有的域名正常有的域名悄悄失效直到浏览器弹证书警告才发现。很多人在这一步就急着看 Nginx 参数、改 reload 方式其实核心问题只有一句话exit 0只代表命令正常退出不代表你的 Nginx 真的把新证书加载进去了。下面我把这个场景按排查顺序完整拆一遍先讲清楚退出码和 Certbot 的成功判定再分析 reload 没生效的常见原因最后给出一套带自检的 deploy hook。1. 先弄清楚 exit 0 到底证明不了什么1.1 exit 0 只是“命令正常退出”在 Linux 里退出码 0 通常表示进程按照自己的逻辑跑完了并且没有主动对外报告错误。这里的“跑完”离“目标达成”还有很大距离。举两个最直接的例子nginx -s reload这条命令退出码为 0只说明 nginx 可执行文件成功读取了 pid 文件并成功把 HUP 信号交给了内核。它不保证 master 进程接受了新配置不保证新 worker 真的创建了也不保证客户端下一次握手会拿到新证书。再比如certbot renew退出码为 0只说明 Certbot 整个续期流程走完了检查证书、申请新证书、写入文件、执行该执行的 hook。它默认不会去验证“Web 服务器现在是否真的在用新证书”。换句话说这个退出码是一个很弱的信号。1.2 Certbot 眼里这次续期“成功”了Certbot 判断续期是否成功看的是自身流程而不是外部服务状态。常见模式是 webroot 或 DNS 验证拿到新证书后写入/etc/letsencrypt/live/域名/然后结束。如果你没有安装 nginx 插件也没有配置 deploy hookCertbot 根本不知道也不关心 Nginx 是否存在。它写完文件退出码就是 0。即使你配置了 deploy hookCertbot 也只会检查这个 hook 命令的退出码。只要 hook 返回 0Certbot 就认为整条链路正常。如果你的 hook 写的是nginx -s reload而这个 reload 因为 pid 文件过期、进程命名空间不对、或者压根没有 nginx 在运行命令返回了 0但实际什么都没做你看到的还是那个熟悉的exit 0。所以第一件事是放下“退出码 0 等于成功”的直觉。它只代表“流程结束”不代表“结果生效”。2. reload 了却没生效大概率是这四种情况2.1 场景一证书文件写了但根本没通知 Nginx这是最常见的一种。检查一下ls -l /etc/letsencrypt/renewal-hooks/deploy/如果目录是空的说明续期后没有任何部署动作。Nginx 的证书是在解析配置、启动 worker 时读入内存的。不 reload、不 restart新证书即使躺在磁盘上也永远不会被使用。很多人以为“Certbot 续期成功 Nginx 自动更新了证书”这个默认其实不成立。只有在用 certbot 的 nginx 插件安装证书时才可能自动 reload或者你手动配置过 hook。用 webroot 方式续期必须自己处理 reload。2.2 场景二reload 信号发给了错误的进程nginx -s reload的机制是读取 pid 文件往对应进程发送 HUP 信号。这里有两个隐蔽坑。第一pid 文件过期。比如系统重启过、Nginx 被升级重启过但 pid 文件还是旧的。此时 pid 文件里的数字可能被系统分给了别的进程。如果你的权限足够kill(pid, HUP)会成功命令退出码是 0但真正接收信号的是另一个无关进程。Nginx 没有任何感知也不会写任何日志。第二机器上存在多个 Nginx 实例。有的系统用发行版自带的 Nginx又手动编译了一份 Nginx。两份 Nginx 的 pid 文件路径不同、监听端口不同。Certbot 的 hook 调用了其中一个但流量实际由另一个实例承载。reload 结果自然和线上表现对不上。2.3 场景三Nginx 在容器里Certbot 在容器外这个在现代部署里非常常见。Certbot 跑在宿主机上Nginx 跑在容器里证书目录通过 volume 挂载共享。此时如果 deploy hook 写的是nginx -s reload宿主机上往往没有 nginx 进程。命令要么报错导致 Certbot 返回非 0要么因为各种原因返回 0 但什么都没发生。正确的做法是在容器内部执行 reloaddocker exec nginx容器名 nginx -t docker exec nginx容器名 nginx -s reload如果用的是 docker compose还要先生成或查一下容器名。只要 hook 执行的目标进程和真正监听 443 的进程不是同一个这个问题就会反复出现。2.4 场景四reload 正常但配置里压根不是这份证书还有一种更隐蔽的情况reload 完全成功ps也看到新 worker 起来了但线上还是旧证书。原因通常是配置文件里的证书路径不是 Lets Encrypt 管理的目录。比如你在某个 server 块里手写了ssl_certificate /etc/nginx/ssl/old_site.crt; ssl_certificate_key /etc/nginx/ssl/old_site.key;而 Certbot 更新的是/etc/letsencrypt/live/example.com/fullchain.pem。两边完全没有交集reload 再多次Nginx 读的还是那份旧文件。判断这类问题直接看nginx -T输出的实际路径就行。3. 先做验证再谈修复3.1 用 openssl 直接看线上证书不要只看浏览器浏览器缓存和连接复用会干扰判断。直接在命令行做一次真实的 TLS 握手openssl s_client -connect example.com:443 -servername example.com /dev/null 2/dev/null \ | openssl x509 -noout -subject -issuer -dates -serial -fingerprint -sha256-servername指定 SNI应对多域名共用 IP 的情况。输出里的notBefore和notAfter能帮你快速判断线上证书是什么时候签发的。3.2 对比磁盘证书与线上证书的指纹先看磁盘上的证书指纹openssl x509 -in /etc/letsencrypt/live/example.com/cert.pem -noout -fingerprint -sha256再对比 3.1 拿到的线上指纹。如果不一致说明 Nginx 当前加载的不是这份 cert.pem。要么没有 reload要么配置指向了别的文件。同时检查一下软链接是否指到了新文件readlink -f /etc/letsencrypt/live/example.com/cert.pem ls -l --time-stylelong-iso /etc/letsencrypt/live/example.com/Certbot 续期会生成新的 archive 文件并把 live 目录下的软链接切到新文件。如果软链接还指向旧版本说明续期本身可能也没完成。3.3 看进程状态和 Nginx 实际配置ps -eo pid,lstart,cmd | grep nginx cat /run/nginx.pid nginx -T 2/dev/null | grep -E ssl_certificate|pid重点看两个信息worker 进程的启动时间。reload 成功的话新 worker 的启动时间应该晚于你执行 reload 的时刻。nginx -T输出的ssl_certificate路径。确认它是不是指向/etc/letsencrypt/live/。这里有个细节不要只看 master 进程。master 一直活着不代表 reload 成功过。worker 的 lstart 时间更能代表配置是否重新加载过。3.4 看 Certbot 自己的续期记录certbot certificates sudo tail -n 200 /var/log/letsencrypt/letsencrypt.log ls -l /etc/letsencrypt/renewal-hooks/deploy/在日志里找 “Deploying certificate” 和 hook 相关的输出。如果整个日志里根本没有部署相关的行那问题基本就是没有配置 deploy hook或者 hook 没被执行。4. 完整排查链路按这个顺序查别跳步4.1 先看现象判断是哪一类不同现象对应不同根因不要一上来就改配置。先分类现象最可能原因优先验证点证书日期完全没变化没有 reload或配置没引用新证书deploy hook、ssl_certificate 路径证书日期变了但本地浏览器旧浏览器缓存、长连接未断开openssl 独立验证部分用户旧部分用户新多实例中只有部分 reload逐个实例 ps、逐个指纹对比reload 命令返回 0但线上没动reload 目标错误pid 文件、容器上下文4.2 再确认进程、PID 文件和 reload 方式先回答这几个问题监听 443 的进程是哪一个是宿主机 nginx 还是容器里的 nginx这个进程对应的 pid 文件在哪里路径和nginx -s reload读的路径一致吗你用的是nginx -s reload还是systemctl reload nginx还是docker exec ... nginx -s reloadreload 命令执行时实际使用的是哪个 nginx 二进制如果系统里有多个 nginx 二进制优先用which -a nginx和nginx -V确认版本和编译路径。生产环境常遇到的问题是手动编译的 nginx 和 apt 安装的 nginx 共存pid 文件完全不同。4.3 然后看 Certbot 的 hook 配置和日志检查续期配置里有没有 deploy_hookgrep -r deploy /etc/letsencrypt/renewal/*.conf再跑一次演练续期观察 hook 是否真的执行certbot renew --dry-run如果 dry-run 输出里看不到部署动作或者 hook 被跳过那问题就在 Certbot 这一侧。注意 dry-run 不会真的签发新证书但会执行验证和部署流程用于验证链路已经足够。4.4 最后检查证书路径与 server 块对每个监听 443 的 server 块单独确认证书路径nginx -T 2/dev/null | grep -E server_name|ssl_certificate|ssl_certificate_key把它们列出来和/etc/letsencrypt/live/域名/下面的文件对比。常见的坑是只改了一个 server 块另一个同域名的 server 块还在用旧路径。特别是上游 Nginx 反代多套服务时一个域名可能出现在多个 server 块里漏一个就会“部分生效”。5. 把 reload 变成一件可验证的事5.1 配置一个带自检的 deploy hook既然退出码不可靠那就让 hook 自己验证结果。思路是reload 完成后立刻从线上握手拿一次证书指纹和磁盘上的 cert.pem 指纹对比。一致才算成功。下面是一个可直接改造的脚本示例#!/usr/bin/env bash set -euo pipefail DOMAINexample.com CERT_FILE/etc/letsencrypt/live/${DOMAIN}/cert.pem LOG_FILE/var/log/letsencrypt/nginx-reload.log log() { echo $(date %Y-%m-%d %H:%M:%S) $* ${LOG_FILE} } disk_fp() { openssl x509 -in ${CERT_FILE} -noout -fingerprint -sha256 2/dev/null | cut -d -f2 } served_fp() { openssl s_client -connect ${DOMAIN}:443 -servername ${DOMAIN} /dev/null 2/dev/null \ | openssl x509 -noout -fingerprint -sha256 2/dev/null | cut -d -f2 } EXPECTED$(disk_fp) log expected fingerprint: ${EXPECTED} if ! systemctl reload nginx; then log systemctl reload nginx failed exit 1 fi sleep 2 ACTUAL$(served_fp) if [ ${EXPECTED} ${ACTUAL} ]; then log check ok after reload exit 0 fi log fingerprint mismatch after reload, trying restart systemctl restart nginx sleep 3 ACTUAL$(served_fp) if [ ${EXPECTED} ${ACTUAL} ]; then log check ok after restart exit 0 fi log check failed, served fingerprint: ${ACTUAL} exit 1把这个脚本放到/usr/local/bin/reload-nginx.sh加执行权限chmod x /usr/local/bin/reload-nginx.sh再放到 Certbot 的 deploy hook 目录ln -s /usr/local/bin/reload-nginx.sh /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.shCertbot 在续期成功后会自动执行/etc/letsencrypt/renewal-hooks/deploy/下所有可执行脚本。这些脚本会按文件名字母顺序执行所以命名要清晰比如01-reload-nginx.sh。5.2 容器环境里的正确 reload 写法如果 Nginx 在容器里hook 脚本里的 reload 部分要改成在容器内执行docker exec nginx nginx -t docker exec nginx nginx -s reload用 compose 时可以先拿到容器名docker compose ps -q nginx脚本里的指纹校验逻辑不用变但要注意如果域名前面还有 CDN、四层负载均衡或云厂商的证书服务线上握手拿到的可能是外层证书不是回源服务器的证书。这种情况下指纹对比会一直失败。解决方案有两种让脚本直接连回源地址比如内网 IP 或回源 Host 直连 443。去掉指纹校验只保留 reload 和日志记录。5.3 用 dry-run 和强制续期测试整个链路配置完 hook 后先跑一遍certbot renew --dry-rundry-run 会走完整的申请和部署流程但不会真的替换线上证书适合确认 hook 本身没语法错误、能正常执行。如果 dry-run 通过还想验证新证书上线效果可以对单个证书做一次强制续期certbot renew --cert-name example.com --force-renewal注意强制续期会真正生成一张新证书并部署。生产环境做完后要立刻用 3.1 的 openssl 命令确认线上指纹已经更新。不要天天跑强制续期频繁签发新证书意义不大还可能触碰签发频率限制。5.4 长期兜底指纹巡检和日志告警光靠续期时校验还不够因为续期可能因为很多原因根本没触发。比如 Certbot 定时任务挂了、系统时间不对、证书还没到续期窗口但文件被误删。更稳妥的做法是加一个独立巡检。用 systemd timer 或 crontab 每天跑一次对比磁盘 cert.pem 指纹和线上握手指纹不一致就写日志并告警。巡检脚本和 deploy hook 的区别在于它不关心续期有没有发生只关心“线上证书和磁盘证书是否一致”。这是一个更贴近用户体验的检查。巡检脚本可以复用上面的disk_fp和served_fp函数只是把动作从“reload”改成“告警”。告警方式可以是邮件、企业微信机器人、钉钉机器人、自定义 webhook取决于你们团队的监控体系。核心是让问题在浏览器报错之前被看到。6. 几个容易误判的细节提前打个预防针6.1 老 worker 还在不代表 reload 失败Nginx 的 reload 是优雅的master 重新加载配置后启动新 worker旧 worker 会继续处理已有的长连接等连接结束后自己退出。所以在 reload 后的一小段时间里ps里同时存在新旧 worker 是正常的。判断标准不是“有没有老 worker”而是“新 worker 是否出现、旧 worker 是否在逐渐消失”。如果几分钟后老 worker 还赖着不走再怀疑长连接被卡住或者 worker 进程异常。不要一看到两个 worker 就手动 restart反而可能打断正在处理的请求。6.2 reload 成功不代表配置检查通过nginx -s reload这个命令和信号发送是同步的但配置检查是在 master 进程里异步做的。如果配置有语法错误master 会拒绝应用新配置继续用旧配置运行并把错误写进 error.log。这时候发送信号的命令可能已经返回 0 了。所以生产环境要养成习惯reload 之前先跑nginx -t。在 deploy hook 里也应该先nginx -t再 reload。配置检查通过才能保证 reload 是有意义的。6.3 不要只信系统状态要信线上握手结果systemd 显示 activenginx 进程活着Certbot 返回 0这些都不能证明用户拿到的证书是正确的。唯一可靠的验证方式是模拟一个普通客户端从网络侧发起 TLS 握手看它拿到的证书是什么。这也是为什么我在处理证书问题时第一步永远是openssl s_client -connect example.com:443 -servername example.com /dev/null 2/dev/null \ | openssl x509 -noout -dates -fingerprint -sha256机器内部怎么看都可能是“正常”的但握手结果不会骗人。这个问题踩过一次之后我现在的处理流程变成固定的三步先确认线上指纹再确认 hook 是否执行最后确认 reload 目标进程是否正确看退出码反而放在最后。exit 0只能说明流程走完了真正的证书有效性要到网络侧握手那里去看。