certbot 退出码是 0但用 openssl s_client 连服务器的 443 端口检查看到的还是上个月签发的旧证书。这是 SSL 证书维护里最容易误判的场景之一certbot 告诉你续期成功线上服务却没有真正使用新证书。很多人第一反应是再执行一次 certbot renew结果日志显示证书未到期直接跳过问题照旧。这个问题的排查链路其实不复杂。先厘清 exit 0 在 certbot 工作流里到底代表什么再按链路检查证书文件时间、Web 服务器配置、进程重载、前置代理这几个环节最后配置好续期后自动生效的 hook。这篇文章适合自己维护 Nginx、Apache、Caddy或者用 certbot 做证书自动化续期的运维、后端开发和个人站长。1. 先弄清楚 exit 0 到底证明了什么1.1 退出码只说明 certbot 自己的流程走完了certbot 退出码是 0代表这一轮任务正常结束但“正常结束”包含好几种情况证书成功续期并写入了新文件证书还没到期certbot 判断不需要更新直接跳过DNS 验证或 HTTP 验证通过但没有触发后续服务重载也就是说退出码 0 不等于“新证书已经在线生效”。它只能说明 certbot 这个程序本身没有报错。真正要看的是 certbot 的详细日志和证书文件的最后修改时间。如果 certbot 检查后认为证书还有效它根本不会向证书颁发机构发起新的申请证书文件不会变线上当然还是旧证书。更常见的坑是证书文件已经更新了但后续环节没有跟上。certbot 负责把新证书写到磁盘至于 Web 服务器是否读取新文件、读取后是否把新证书加载到内存它默认不负责。除非你配置了 deploy hook否则这一环完全靠手动或外部任务。1.2 用 openssl s_client 看线上证书而不是只看 certbot 输出不要直接相信 certbot 的显示结果。先执行这条命令看真实连接 443 端口时服务端会把哪张证书发给客户端openssl s_client -connect your-domain.com:443 -servername your-domain.com /dev/null 2/dev/null | openssl x509 -noout -dates -subject这条命令模拟一次 TLS 握手然后提取服务端返回证书的签发日期、到期日期和主题。如果这里显示的到期时间还是上个月那个说明线上服务确实在使用旧证书如果显示的是新证书说明问题可能出在检测入口或者客户端缓存上。注意-servername参数。多域名站点如果没有它服务端可能返回默认站点的证书这会让你误判成“证书没更新”。如果服务不在 443 端口把-connect里的端口改成实际监听端口即可命令逻辑不变。1.3 把磁盘文件时间和线上证书时间分开对比磁盘上的证书文件和线上实际使用的证书是两个不同层面的东西。排查时先看磁盘文件sudo ls -l /etc/letsencrypt/live/your-domain.com/再看线上证书sudo openssl s_client -connect 127.0.0.1:443 -servername your-domain.com /dev/null 2/dev/null | openssl x509 -noout -dates对比两者文件时间是新的线上时间是旧的 → 问题出在服务读取或加载环节文件时间也是旧的 → certbot 根本没更新这张证书问题出在续期申请环节这一步能直接砍掉一半排查方向。很多人一上来就改 Nginx 配置其实证书文件压根没变过改配置没有意义。2. 旧证书还挂在 443 端口常见原因就这几个2.1 证书文件更新了但服务进程没有重载这是最常见的原因。Nginx 在启动时读取证书文件。运行中的进程不会因为磁盘文件被替换就自动重新读取证书Apache 同理。certbot 更新文件后如果没有执行systemctl reload nginx没有触发 deploy hook进程内存里仍然是旧证书。验证方法很简单sudo systemctl status nginx --no-pager | head -20对比 nginx 的启动或重载时间与证书文件修改时间。如果服务启动时间远早于证书文件修改时间中间又没有 reload基本可以锁定是这个问题。2.2 配置里引用的证书路径不是 certbot 管理的目录certbot 默认把证书写到/etc/letsencrypt/live/域名/目录下。但有些人在 Web 服务器配置里写的是自定义路径比如/etc/ssl/certs/、/data/cert/或者拷贝出来的一份旧证书。certbot 只会更新它自己管理的目录不会去动自定义路径里的文件。配置指向自定义路径的话文件当然不会变。排查时看有效配置sudo nginx -T 2/dev/null | grep -E ssl_certificate |ssl_certificate_key 或者看指定站点的配置片段sudo nginx -T 2/dev/null | grep -A2 server_name your-domain.com对比配置里的路径和 certbot live 目录是否一致。不一致的话把配置改为 certbot 管理的路径或者在 certbot 里配置自定义部署方式并确认更新逻辑。2.3 多站点 SNI 匹配导致访问时拿到默认站点的旧证书如果你的 Nginx 上有多个 server 块访问指定域名时返回哪个证书取决于 SNI 匹配和 server_name 匹配的优先级。常见的情况是你更新了 A 域名的证书但访问 A 域名时命中的是默认 server 块而该块引用的是 B 域名的证书。还有另一种情况listen 443 ssl的 server 块里写了证书同时同端口还有一个兜底的默认 server实际命中的顺序和预期不一致。对策是先检查当前连接实际返回的证书而不是只看配置文件里看起来应该生效的那个证书。2.4 443 端口前面还有代理、负载均衡或云网关很多所谓“证书没更新”的场景443 端口根本不是本地 Web 服务器直接监听的。常见的几种组合Nginx 前面还有 HAProxy、Traefik、OpenResty域名解析到 CDNCDN 回源到服务器云厂商的负载均衡实例上单独挂载了证书这种情况下客户端连接的是最外层节点的 443 端口。即使本地服务器上的证书已经更新外层节点没有换证书你看到的仍然是旧证书。判断方法是从外网执行openssl s_client看证书信息再从服务器本机执行同样的命令。如果外网显示旧证书、本机显示新证书问题就在中间那一层。2.5 live 软链接和文件权限被改坏LetsEncrypt 的 live 目录下通常是指向 archive 目录的软链接。如果之前手动替换过文件、复制过文件、改过权限或者有人把软链接拆成了真实文件certbot 更新时可能只更新 archive 目录live 目录的链接没有指向新版本。检查软链接sudo readlink -f /etc/letsencrypt/live/your-domain.com/fullchain.pem sudo ls -l /etc/letsencrypt/archive/your-domain.com/如果 live 目录下显示的是普通文件而不是软链接或者链接指向的 archive 文件不是最新时间修正链接后再 reload 服务。3. 从现象到根因按这个顺序排查以下排查顺序是固定的不要跳步。每一步都能排除一批可能。3.1 第一步检查证书文件本身的时间和有效期先看 certbot 管理的证书文件sudo openssl x509 -in /etc/letsencrypt/live/your-domain.com/fullchain.pem -noout -dates -subject再看文件修改时间sudo ls -l --time-stylelong-iso /etc/letsencrypt/live/your-domain.com/如果这里显示的到期时间是上个月说明 certbot 实际没有续期成功。这时候要查 certbot 日志不要急着改 Web 服务器配置。如果文件时间已经是新的继续往下走。3.2 第二步检查 Web 服务器配置引用了哪个文件列出实际加载的证书配置sudo nginx -T 2/dev/null | grep -E ssl_certificate |ssl_certificate_key Apache 用这个sudo apachectl -S 2/dev/null | grep -E SSL|证书对比结果和 certbot live 目录是否一致。判断标准见下表检测对象命令示例判断标准证书文件到期时间openssl x509 -in ... -noout -dates时间是上个月说明 certbot 未更新线上证书到期时间openssl s_client -connect ...时间是上个月说明服务未生效配置文件引用路径nginx -T看是否指向 live 目录服务启动/重载时间systemctl status nginx对比证书文件修改时间3.3 第三步reload 服务后重新验证确认配置路径无误后执行重载sudo nginx -t sudo systemctl reload nginx先校验配置再重载这一步很有必要。如果证书文件有问题nginx -t会直接报错不会影响线上服务。nginx -t通过后重新执行最开始的那条openssl s_client命令。如果显示的是新证书说明根因就是服务没有重载。3.4 第四步本机验证和外网验证对比如果本机验证已经显示新证书但用自己的域名从外部访问还是旧证书就要检查是不是有中间层。本机验证sudo openssl s_client -connect 127.0.0.1:443 -servername your-domain.com /dev/null 2/dev/null | openssl x509 -noout -dates外网验证openssl s_client -connect your-domain.com:443 -servername your-domain.com /dev/null 2/dev/null | openssl x509 -noout -dates两个结果不一致说明 443 端口前面还有代理、CDN 或负载均衡。需要到对应节点去更新证书而不是在本地 Web 服务器上继续折腾。3.5 第五步查 certbot 日志和系统时间certbot 日志默认在sudo tail -100 /var/log/letsencrypt/letsencrypt.log重点看有没有类似Certificate not yet due for renewal的记录。如果有说明 certbot 判断证书未到期根本没有发起续期申请。这时候不是服务配置的问题而是续期策略或证书剩余有效期的问题。顺便检查系统时间date服务器时间严重偏离会影响 TLS 校验也可能导致证书验证判断异常。时间偏差不大时一般不是根因但建议作为最后一项健康检查。4. 续期后自动生效别把 reload 交给手动4.1 配置 deploy hook续期成功后自动 reloadcertbot 支持在续期成功后执行自定义命令这就是 deploy hook。可以在命令行直接指定sudo certbot renew --deploy-hook systemctl reload nginx也可以写进配置文件/etc/letsencrypt/cli.inideploy-hook systemctl reload nginx这样每次续期成功certbot 会自动执行重载命令新证书立即生效不需要手动干预。需要理解的一点deploy hook 只在证书真正被更新时执行。如果 certbot 判断证书未到期它不会执行 hook。这不是 hook 失效而是行为设计如此。4.2 确认 hook 是否真的执行过看日志sudo tail -50 /var/log/letsencrypt/letsencrypt.log | grep -E Running deploy-hook|deploy hook正常会看到Running deploy-hook command和命令执行的输出。如果是容器环境里跑 certbothook 里的路径要注意。比如sudo certbot renew --deploy-hook /usr/sbin/nginx -s reload容器内执行的命令和宿主机上的服务不是同一个上下文需要确认挂载目录和命令路径。hook 返回非 0 时certbot 会在日志里记录失败。4.3 reload 和 restart 怎么选普通证书续期reload 足够。reload 会重新读取配置并平滑替换 worker 进程不会中断现有连接。restart 适合配置变更、模块加载异常、排查内存中旧状态的情况。生产环境一般先 reload确认无报错后再考虑 restart。有的老版本 Nginx 在 reload 后如果证书文件权限不对会直接报错。所以 deploy hook 里建议写成nginx -t systemctl reload nginx先校验再重载。避免证书文件有问题时把服务弄挂。4.4 把证书检测加进监控别等用户报障自动化续期不是终点。建议写一个简单的检测脚本每天定时检查线上证书到期时间#!/bin/bash domainyour-domain.com expire$(echo | openssl s_client -connect ${domain}:443 -servername ${domain} 2/dev/null | openssl x509 -noout -enddate | cut -d -f2) echo 当前线上证书到期时间: ${expire}放到 crontab 里每天执行一次0 6 * * * /usr/local/bin/check_cert.sh /var/log/cert_check.log 21检测对象应该用线上 443 端口返回的证书而不是磁盘文件。因为线上证书才是用户真正会拿到的东西。如果到期时间少于 10 天但 certbot 日志显示正常那就说明某个环境环节断了需要人工介入。5. 不同环境里的变体和坑5.1 旧版 OpenSSL 和系统依赖差异排查命令里的openssl s_client在不同系统上输出格式有差异。CentOS 7.6 这类系统自带 OpenSSL 1.0.2输出格式和 3.x 不完全一样但证书的 dates、subject、serial 字段都有不影响排查。低版本 OpenSSL 对-servername的支持也正常。如果机器上 OpenSSL 依赖被升级过比如为了某个软件重新编译了 OpenSSL系统里可能有多个 openssl 可执行文件which -a openssl openssl version系统默认 openssl 和你手动安装的 openssl 可能不是同一个。手写命令时建议用绝对路径或者在 PATH 里明确指定。否则可能在 A 版本下测试实际服务使用的是 B 版本造成误判。另外提一句排查证书问题时一般不需要为了验证功能重新编译 OpenSSL。如果有软件编译时报please install the appropriate openssl developer package那是构建依赖问题和证书续期是否生效没有直接关系。5.2 证书权限与 SELinuxNginx 读取证书文件时对文件权限和 SELinux 上下文有要求。如果证书文件被覆盖后权限变了或者 SELinux 阻止进程读取新文件服务会报错或者继续使用旧证书。查看 Nginx 错误日志sudo tail -50 /var/log/nginx/error.log常见的报错有两种permission denied无法读取证书证书文件是 600 权限而 Nginx worker 进程的用户无法读取修复方式是设置证书目录为 755证书文件为 644或者参考发行版默认生成的权限。SELinux 环境下需要确认上下文类型主要是httpd_sys_content_t或cert_t具体以你的系统策略为准。5.3 多域名、通配符证书的续期特殊性如果有多个域名每个域名对应独立的证书文件live 目录下会按证书名称分目录。证书目录名通常是-d参数里的第一个域名。通配符证书使用 DNS 验证时续期不会自动完成。因为 DNS 记录需要手动配置或通过 DNS 服务商 API 更新。如果 DNS 记录没有更新续期会失败问题会体现在 certbot 日志里而不是 Web 服务器配置里。另外如果你的站点同时使用www.domain.com和domain.com证书可能只覆盖其中一个域名。用 openssl 验证时要使用实际访问的域名做 servername否则可能拿到默认证书误以为部署有问题。5.4 代理层和负载均衡的证书更新位置如果 443 端口被 HAProxy 或负载均衡监听证书文件的位置可能在/etc/haproxy/certs目录也可能在云控制台。此时需要更新对应位置的证书并重启或重载代理服务。典型判断方法在前面提过服务器本机验证是新证书从域名解析的 IP 访问是旧证书说明中间有一个节点还在使用旧证书。先定位这个节点再决定是更新 HAProxy 证书还是去云控制台上传新证书。不要只顾着在 Web 服务器里做 reload。6. 留给自己的固定排查清单6.1 按顺序排查别跳步把这次踩过的坑整理成固定排查顺序能省不少时间先看 certbot 日志确认它到底有没有申请新证书再看 live 目录证书文件时间确认文件是否更换用nginx -T或apachectl -S看配置文件引用路径reload 服务重新用 openssl s_client 验证本机正常但外网旧证书检查前置代理、CDN 和负载均衡权限报错时检查文件权限和 SELinux 上下文最后检查系统时间、openssl 版本和服务日志很多看起来像是 certbot 没有生效的问题其实都出在服务是否重载、配置是否指向正确路径、443 端口是不是本地 Nginx 在监听这三个环节上。6.2 两条检测命令建议沉淀成脚本我更建议把证书检测直接写进监控。每天跑一次线上证书检测和一次磁盘文件检测两条数据一对比certbot 有没有更新、服务有没有生效一眼就能看出来。# 线上证书 echo | openssl s_client -connect your-domain.com:443 -servername your-domain.com 2/dev/null | openssl x509 -noout -dates # 磁盘证书 sudo openssl x509 -in /etc/letsencrypt/live/your-domain.com/fullchain.pem -noout -dates比等用户反馈“网页证书过期”再排查要舒服得多。最后留一个经验如果你在容器里跑 certbot宿主机上的 Web 服务未必能读取容器生成的证书文件。要注意挂载目录和权限不能只改 certbot 配置文件就完事。这个坑碰到一次后面就会记得先把“容器进程能不能读到这个文件”放在最前面确认。