一、自动续期是怎么工作的,为什么它会"总失败"

自动续期不是魔法,它是一个被定时器唤醒的脚本。定时器到点触发脚本,脚本向证书签发机构发起一次新的签发请求,签发机构验证你对域名的控制权,通过后下发新证书,脚本用新证书替换服务器上的旧文件,然后重载 Web 服务。整个链路有五个环节,任何一环断了,续期都会失败。
这五个环节依次是:调度器触发、环境变量注入、验证方式通过、证书文件写成功、服务重载成功。多数人排查时只盯着"证书到底有没有签出来",但实际上"签发成功但替换失败"和"压根没签发出来"是两个完全不同的问题。前者的日志里能看到 "Congratulations! Your certificate has been saved",后者则是一串 DNS 解析失败或者连接超时。
先看一个完整的续期日志长什么样,才能知道问题在哪一环:
# certbot 的手动续期试跑,--dry-run 不会真的签发,但会走完整个流程
sudo certbot renew --dry-run --verbose
输出里会逐段打印每个域名的验证过程。看到 "Performing the following challenges" 之后出错,说明问题在验证环节;看到 "Renewal configuration file ... is missing" 之类,说明续期配置文件本身丢了。
一个必须先弄清的概念:证书有效期是 90 天,但续期并不会在第 60 天自动发生。certbot 在续期时检查当前证书的剩余有效期,只有当剩余时间少于 30 天时才真正发起签发;你手动改 cron 频率到每小时一次,它依然只会在剩余不足 30 天时才干活。所以"证书过期了"通常意味着续期环节连续失败了很多轮,而不是某一次的意外。
还有一个容易被误判的现象叫"续期成功但没换证书"。certbot 的流程是先签发新证书到临时路径,再原子替换符号链接,最后执行重载。签发成功和替换成功是两件事,中间任何一步被打断(磁盘满、权限不足、脚本被 kill)都会留下"日志说成功、文件没变"的状态。所以日志里看到成功的字样之后,一定要去核对证书的序列号,这是第九节要讲的内容。
除了 certbot,续期方案还有 acme.sh、lego、 dehydrated、win-acme 等其他实现。它们的配置方式完全不同:acme.sh 用 shell 脚本管理,通过 ~/.acme.sh/acme.sh --cron 触发,续期信息存在 .acme.sh 目录的 JSON 里;lego 是 Go 写的单二进制,配置全部走命令行参数和 environment 文件;dehydrated 用一个 shell 脚本加一个配置文件。无论用哪一个,判断续期是否成功的逻辑都是通用的:看证书的到期日期有没有延后、看线上服务用的序列号有没有变。
选型上有一个实际影响:如果你用的是宝塔面板、1Panel 这类图形化面板,它们的证书管理功能背后往往自带一套续期逻辑,可能和手工装的 certbot 冲突。同一个域名被两个工具同时管着,就会出现"面板说续期了、certbot 说没有配置"这类互相甩锅的情况。选定一套工具,把另一套彻底停掉,是最省事的做法。
二、cron和systemd timer:两种调度方式选哪个

两种调度方式在技术上都能工作,差别在可观测性和环境管理上。
cron 的写法最常见:
# 每天两次,凌晨和中午各一次,错开时间避免撞上签发机构的维护窗口
0 3,15 * * * /usr/bin/certbot renew --quiet
cron 的优势是几乎所有 VPS 教程都用它,出问题能搜到解决方案。劣势是环境变量极其贫瘠——cron 的默认环境只有 PATH 和 HOME,没有 SHELL,没有自定义的 PATH。这就是为什么大量续期失败的报错长得像这样:
/bin/sh: 1: certbot: not found
certbot 装在 /usr/bin/certbot 时相对路径没问题,但装在 /snap/bin 或者通过 Python 虚拟环境安装时,cron 找不到它。解决办法是全部用绝对路径:
0 3,15 * * * /usr/bin/certbot renew --quiet --deploy-hook "/usr/bin/systemctl reload nginx"
systemd timer 的写法更现代,而且能保证服务重启后任务不会被漏掉:
# /etc/systemd/system/certbot-renew.service
[Unit]
Description=Certbot renewal
After=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/bin/certbot renew --quiet
[Install]
WantedBy=multi-user.target
# /etc/systemd/system/certbot-renew.timer
[Unit]
Description=Run certbot renewal twice daily
[Timer]
OnCalendar=*-*-* 03,15:00:00
RandomizedDelaySec=3600
Persistent=true
[Install]
WantedBy=timers.target
Persistent=true 是关键,它表示如果机器在这个时间点处于关机状态,开机后 systemd 会补跑一次。cron 没有这个能力,关机时错过的任务就永远错过了。
RandomizedDelaySec=3600 也不该忽略。签发机构会对单个账户的签发频率做限制,所有人在整点同时发起请求容易触发限流,随机延迟 0 到 60 分钟可以把这个尖峰摊平。
启用后要确认定时器真的在跑:
systemctl enable --now certbot-renew.timer
systemctl list-timers certbot-renew.timer
systemctl status certbot-renew.timer
如果列表里没有下次触发时间,说明 timer 没被正确加载,最常见的原因是 ExecStart 里的路径不存在,或者 service 文件里的 After= 依赖了一个不存在的单元。
还有两个与调度相关的实战细节。其一是 cron 的时区问题。服务器的 cron 按系统时区执行,容器里通常是 UTC,而你在日志里看到的时间和浏览器里的时间差八小时。这个错位经常让人误判"任务今天没跑"。确认时区:
timedatectl
crontab -l
其二是脚本自身的执行环境。续期脚本如果调用了其他自写的脚本,那些脚本依赖的环境变量在 cron 里同样缺失。解决办法是在脚本开头显式设置,而不是指望继承:
#!/bin/bash
set -e
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
export HOME="/root"
exec /usr/bin/certbot renew --quiet
export HOME 这一行尤其重要。certbot 依赖 HOME 决定配置文件的位置,cron 默认给的是 /root 但某些精简环境会留空,导致 certbot 找不到已有的配置而在别处新建一套空配置,表现就是"每次续期都重新申请一张新证书"。
三、验证方式冲突:HTTP和DNS同时开着导致的失败

这是续期失败里最隐蔽的一种。certbot 支持多种验证方式,同时启用多种时,域名匹配会被拆分:完全匹配某个验证方式配置的域名走那一种,匹配泛域名的走另一种。拆分的判定依据是域名匹配哪个配置块,不是你的直觉。
典型症状是:单个域名续期成功,泛域名 *.example.com 续期失败。日志里通常是:
Some challenges have failed.
- Domain: example.com
Type: http-01
Detail: The key is not being authorized by the server
另一个典型症状是反向代理后面的站点验证失败。certbot 验证 HTTP-01 时会在 /.well-known/acme-challenge/ 目录下放一个验证文件,然后请求你的域名。如果你的 Nginx 把所有请求都反向代理到了应用层,或者应用层对不存在的路径返回 404 而不是 200,验证就过不了。
解决方案是在 Nginx 里显式放行这个路径:
location ^~ /.well-known/acme-challenge/ {
root /var/www/html;
default_type "text/plain";
allow all;
}
这个 location 必须放在 location / 之前,否则会被更通用的规则抢先匹配。
验证一下这个 location 是否真的生效:
# 手工放一个测试文件,然后从公网访问
echo "acme-test-ok" | sudo tee /var/www/html/.well-known/acme-challenge/testfile
curl -s http://你的域名/.well-known/acme-challenge/testfile
返回 "acme-test-ok" 才说明放行成功。如果返回 404 或你的应用页面,说明规则没生效。这一步应该在申请证书之前做,能省掉一轮失败的等待。
DNS-01 验证是另一条路,它不依赖 HTTP 访问,适合泛域名和后端服务不暴露 80 端口的场景。它的前提是你能用 API 修改 DNS 记录。常见坑有三个。第一个是 API 权限不足,certbot 创建了 TXT 记录但没有等它生效就开始验证。第二个是 DNS 传播延迟,TTL 设成 600 秒的话验证必然失败,必须把这条 TXT 记录的 TTL 设成 60 秒以下。第三个是凭据过期,DNS 服务商的 API Token 有有效期,过期后 certbot 会报认证失败。
验证方式的组合建议:主域名用 HTTP-01(最简单,续期不需要任何第三方凭据),泛域名用 DNS-01(必须用),两者共存但不要让同一个域名落进两个配置块。
如果两种方式都配了,可以用这个命令看 certbot 最终给每个域名分配了什么验证方式:
sudo certbot certificates
输出里每个 Certificate 行后面会跟着 "Domains: ... " 和对应的验证方式标识,能直接看出拆分结果。
四、权限问题:文件锁、属主和目录权限三种坑

续期脚本需要写证书目录,通常是 /etc/letsencrypt/live/。这块的权限问题有三种表现。
第一种,目录属主不对。certbot 是以 root 运行的定时任务,但如果你曾经手工用普通用户跑过一次 certbot,生成的目录会变成那个用户的属主,之后定时任务就写不进去了:
ls -ld /etc/letsencrypt/live /etc/letsencrypt/archive /etc/letsencrypt/renewal
sudo chown -R root:root /etc/letsencrypt
sudo chmod 755 /etc/letsencrypt
第二种,文件锁没释放。certbot 在续期时会创建 /.well-known 之外的锁文件,如果上一次续期被强制中断,锁会残留,导致后续所有续期都报 "Another instance of Certbot is already running":
# 检查并清理残留的锁
sudo find /var/lib/letsencrypt /etc/letsencrypt -name ".lock" -print
sudo rm -f /var/lib/letsencrypt/.lock
第三种,Web 服务读不到新证书。certbot 默认把私钥权限设成 600,属主是 root。而 Nginx 以 www-data 运行,它读不了 root 私钥。续期后新证书换了权限,就出现"续期成功了但网站报 502 或者 SSL 错误"的诡异情况。解决办法是加一个 deploy hook 修正权限:
#!/bin/bash
# /etc/letsencrypt/renewal-hooks/deploy/fix-perms.sh
/usr/bin/install -m 0444 -o root -g root /etc/letsencrypt/live/你的域名/fullchain.pem /etc/ssl/certs/你的域名.crt
/usr/bin/install -m 0440 -o root -g www-data /etc/letsencrypt/live/你的域名/privkey.pem /etc/ssl/private/你的域名.key
/usr/bin/systemctl reload nginx
关键点是这个 hook 必须在每次续期后执行,否则手工续期成功一次、后续定时续期失败的情况会重复出现。certbot 的 hook 目录分两类,renewal-hooks/deploy/ 里的脚本在任何一次签发成功后都会跑,renewal-hooks/pre/ 里的在每次尝试时都会跑。
写 hook 时有两个细节要小心。其一是 hook 脚本必须有可执行权限且有正确的 shebang,否则 certbot 会静默跳过它,你的权限修正就从来没发生过:
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/fix-perms.sh
sudo head -1 /etc/letsencrypt/renewal-hooks/deploy/fix-perms.sh
其二是 hook 的退出码。hook 脚本返回非零会让 certbot 报 "deploy-hook command ... returned non-zero exit status",即使续期本身是成功的。所以脚本里要做错误处理,避免因为一次 install 失败就中断后续的重载步骤:
#!/bin/bash
set -u
install -m 0444 -o root -g root /etc/letsencrypt/live/你的域名/fullchain.pem /etc/ssl/certs/你的域名.crt || echo "cert copy failed"
install -m 0440 -o root -g www-data /etc/letsencrypt/live/你的域名/privkey.pem /etc/ssl/private/你的域名.key || echo "key copy failed"
if nginx -t; then
systemctl reload nginx
else
echo "nginx config test failed, not reloading"
fi
exit 0
这段脚本做了三件事:拷贝时失败只记日志不中断;重载前先用 nginx -t 检查配置语法,语法有问题就不重载;无论前面发生什么最后都 exit 0。加 nginx -t 这一步尤其重要,它能防止一份写坏的证书配置被灌进 Nginx 导致整站挂掉。
另外还有一个与权限相关的隐蔽问题:某些 VPS 的系统有 AppArmor 或者 SELinux 之类的强制访问控制,即使文件权限看起来正确,服务进程也可能被内核拒绝读取。这种情况的表现是 Nginx 日志里出现 Permission denied 但 ls -l 看权限没问题。检查方法:
getenforce 2>/dev/null || aa-status 2>/dev/null
dmesg | grep -i denied | tail -20
如果确认是强制访问控制拦截,正确的做法是给 Nginx 配置读证书目录的规则,而不是去 chmod 放宽权限。
五、频率限制和重试窗口:为什么"多试几次"会坏事

Let's Encrypt 对签发有明确的频率限制,核心规则是:同一组域名每 50 个证书每周最多签发 5 次,同一账户每周最多签发 50 次,单个账户每 3 小时最多签发 5 次。超出限制会返回 too many certificates already issued 或者 rate limit exceeded 错误,并且这个限制要等窗口重置。
这就是很多人"越急越容易搞砸"的原因。发现证书快过期,脚本每 5 分钟跑一次,连着跑 12 次就触发了每小时 5 次的限制,接下来 3 小时所有续期请求都被拒绝,证书反而彻底过期了。
更隐蔽的是 DNS-01 的沙箱环境。Let's Encrypt 提供了一个测试端点,用它签发的证书只有 7 天有效期,可以随便试:
# 用沙箱环境测试你的续期配置,不消耗正式环境的签发额度
sudo certbot renew --dry-run --server https://acme-staging-v02.api.letsencrypt.org/directory
这条命令应该在每次改完配置后都跑一遍,确认流程通了再去跑正式的。沙箱签发的证书不会被浏览器信任,这是正常现象。
另外要注意续期的"重试窗口"逻辑。certbot 默认只在剩余有效期少于 30 天时才发起签发,所以理论上你有 60 天的窗口来发现问题。但如果你把 cron 设成每天只跑一次,且恰好在服务器重启后补跑失败,间隔实际上会被拉长。稳妥的做法是每天跑两次,并且把监控告警配成"剩余天数少于 21 天就告警",这样你有 9 天的处理时间,而不是等到浏览器报错。
对自签证书转正式证书的场景,还有一条容易忽略的规则:同一域名从自签换成正式签发会消耗一次签发配额,所以测试期尽量用 --dry-run 而不是真的申请。
还要注意频率限制是可以主动查询的,不用靠猜。Let's Encrypt 提供了速率限制的诊断端点,它会告诉你当前各个维度的配额用了多少:
curl -s https://acme-v02.api.letsencrypt.org/directory
拿到目录地址后查对应端点,返回的 JSON 里会列出 "ratelimits" 数组,每一项有 "name"(如 "new-orders-per-account")、"limit"、"remaining"、"reset" 四个字段。remaining 是剩余可签发次数,reset 是窗口重置时间。改配置前先查一次,能避免在最糟糕的时候撞墙。
六、续期失败告警怎么配:在浏览器报警之前收到通知

这是最该做但最常被跳过的一步。证书过期时,浏览器会先报警,但那时候你可能什么都改不了了。正确的做法是监控证书剩余天数,在剩 21 天时主动告警。
第一层监控,检查有效期:
#!/bin/bash
# /opt/cert-monitor/check-cert.sh
DOMAIN="你的域名"
ENDPOINT="https://你的域名/"
DAYS_LEFT=$(echo | openssl s_client -servername "$DOMAIN" -connect "$DOMAIN:443" 2>/dev/null \
| openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)
EXPIRY=$(date -d "$DAYS_LEFT" +%s 2>/dev/null)
NOW=$(date +%s)
REMAIN=$(( (EXPIRY - NOW) / 86400 ))
if [ "$REMAIN" -lt 21 ]; then
echo "证书 $DOMAIN 只剩 $REMAIN 天到期,请立即处理" | mail -s "SSL证书即将过期" [email protected]
curl -s -X POST "你的告警webhook地址" -d "text=证书 $DOMAIN 剩余 $REMAIN 天"
fi
echo "剩余 $REMAIN 天"
第二层监控,检查续期任务本身是否在跑。这比检查证书更早发现问题,因为任务停了证书还能撑 30 天:
#!/bin/bash
# 检查最近一次续期日志的时间,超过 48 小时说明任务没跑
LAST=$(stat -c %Y /var/log/letsencrypt/letsencrypt.log 2>/dev/null || echo 0)
AGE=$(( ($(date +%s) - LAST) / 3600 ))
if [ "$AGE" -gt 48 ]; then
echo "certbot 续期任务已 $AGE 小时未运行" | mail -s "续期任务异常" [email protected]
fi
这两层要配合:第一层告诉你"快过期了",第二层告诉你"任务本身挂了",两者指向完全不同的排查方向。
第三层是验证环节的自检。定期用沙箱跑一次 dry-run,把它做成监控项:
0 4 * * 0 /usr/bin/certbot renew --dry-run --quiet --server https://acme-staging-v02.api.letsencrypt.org/directory || mail -s "续期自检失败" [email protected]
每周跑一次沙箱自检,通过说明配置和凭据都还正常,不通过说明该修了。
告警通道建议至少配两条。一条是邮件,因为它最通用,任何时候都能看到。另一条是即时通讯的 webhook,因为邮件可能进垃圾箱,而证书过期这件事你希望五分钟内知道。如果你的团队用值班制度,告警还要带上明确的行动指令和回滚方案链接,而不是只发一句"证书过期了"。
还有一层最容易被忽略的监控是"服务健康"本身。证书到期只是结果,前面的过程也可能出问题:续期任务跑失败了、Nginx 重载之后没起来、证书文件被误删。这些都可以用外部探测发现,比等证书到期再处理快得多:
# 每 5 分钟从外部探测一次
*/5 * * * * curl -s -o /dev/null -w '%{http_code}' https://你的域名/ | grep -q 200 || /usr/local/bin/alert.sh "站点 HTTPS 不可用"
外部探测比服务器内部自检更可靠,因为它能发现"服务器活着但服务死了"这种内部监控看不出来的情况。很多团队在服务器上装一堆监控,服务器整个宕机时告警发不出来,恰好是最需要告警的时候。
三层监控加上外部探测,构成一个完整的续期安全网:沙箱自检每周一次发现配置问题,任务日志检查发现调度问题,证书有效期检查发现时间问题,外部探测发现服务问题。四个问题里有三个会在证书真正过期前很久就暴露出来。
告警还要有一条"静默"规则:同一个告警在同一个小时内不要重复发十次。可以用一个标记文件实现:
#!/bin/bash
FLAG=/tmp/cert-alert-flag
if [ -f "$FLAG" ] && [ $(($(date +%s) - $(stat -c %Y $FLAG))) -lt 3600 ]; then
exit 0
fi
touch $FLAG
# 发送告警的逻辑
告警疲劳是监控系统失效的最常见原因。一条每小时发二十条的告警,最终会被人加进忽略列表,那它和没有告警是一回事。
七、换证书服务商时哪些地方会踩坑

从 Let's Encrypt 迁到 ZeroSSL 或其他签发机构,坑比想象中多。
第一个坑是 ACME 账号没有邮箱。Let's Encrypt 的注册接口接受不带邮箱的请求,其他机构大多要求邮箱,因为证书到期前要发通知。迁到这类机构时如果没填邮箱,你会拿不到任何到期提醒,只能靠自己的监控。注册时补上邮箱:
sudo certbot register --email [email protected]
第二个坑是 EAB 凭据。ZeroSSL 等机构要求 EAB(外部账户绑定)凭据,格式是一串 key ID 和 HMAC 密钥。凭据过期或者填错时的报错很模糊,就是签发失败。填法:
sudo certbot certonly --server https://acme.zerossl.com/v2/DV90 \
--eab-kid 你的key_id --eab-hmac-key 你的hmac密钥 \
-d 你的域名 --agree-tos -m [email protected]
第三个坑是 DNS 验证凭据的格式变化。Cloudflare 的 Global API Key 和 API Token 换成了不同的环境变量名,certbot 的 DNS 插件在不同版本里读取的变量名也不一样。换了服务商后 DNS-01 验证失败,八成是这里的变量名或权限范围不对。
第四个坑是速率限制规则不同。不同机构的限制窗口和阈值不一样,你不能把在 Let's Encrypt 上的测试习惯直接套过去。
第五个坑是证书文件路径。不同签发机构在 certbot 里的目录名可能不同,Nginx 里的 ssl_certificate 路径要跟着改。而且改路径之后,续期时的 deploy hook 也必须同步改,否则手工续期一次成功、后续定时续期因为路径不对而失败。
第六个坑是证书链文件的构成。fullchain 文件是服务器证书加上中间证书的拼接,链文件不完整会让一部分客户端(尤其是老版本 Android 和部分国产浏览器)报证书错误,而 curl 和现代浏览器可能完全正常。验证链是否完整:
openssl s_client -showcerts -connect 你的域名:443 </dev/null 2>/dev/null | grep -c "BEGIN CERTIFICATE"
返回的数字应当是 2 或 3(服务器证书加一到两个中间证书)。如果只有 1,说明你用的是只含服务器证书的 cert.pem 而不是 fullchain.pem,需要改 Nginx 配置指向 fullchain 文件。
第七个坑是证书的私钥类型。Let's Encrypt 签发的是 RSA 2048 或 ECDSA P-256 两种私钥。某些老旧客户端对 ECDSA 支持不好,如果你的用户里有大量老设备,应该确认签发的是 RSA 而不是 ECDSA。查看当前类型:
openssl x509 -in /etc/letsencrypt/live/你的域名/fullchain.pem -noout -text | grep -E "Public Key Algorithm|Signature Algorithm"
换成 RSA 的做法是在申请时加 --key-type rsa 参数。注意不同签发机构的默认值不一样,Let's Encrypt 新申请默认倾向 ECDSA,所以如果你迁机构后发现某些客户端开始报错,很可能就是这里变了。
第八个坑是证书的吊销状态没有同步。签发机构会维护证书吊销列表(CRL),某些客户端会检查。如果你用的是旧式证书并且曾经私钥泄露过,即使换了新证书,旧证书的吊销状态也需要时间同步到各个分发点。这不影响新证书的正常使用,但排查问题时容易误判。
八、常见误区:五个看起来合理但会出事做法

第一个误区,认为"证书是自动续期的,所以不用管它"。自动续期只是有人替你跑脚本,脚本失败时不会有任何提示。你必须配告警,否则你的"自动"只是"自动地失败"。
第二个误区,把 cron 设成每 5 分钟跑一次以确保及时。这个做法会撞上签发机构的频率限制,反而把续期彻底堵死。每天两次是安全值。
第三个误区,只在证书过期后才手工跑一次 certbot renew,然后发现签发成功但网站还是报错。原因是私钥权限或重载没做,需要 deploy hook 一次性解决。
第四个误区,把 certbot 的续期配置放在家目录而不是 /etc/letsencrypt。certbot 找配置是按当前用户的 home 目录去找的。root 跑的配置在 /etc/letsencrypt,普通用户跑的在 ~/.config/letsencrypt,两套配置互不相通。用 sudo certbot renew 和 certbot renew 会操作完全不同的证书。
第五个误区,认为续期会保留旧的证书文件。certbot 的续期会替换 /etc/letsencrypt/live/ 下的符号链接,指向 archive/ 里新的实际文件。如果你的运维脚本里有硬编码的证书路径引用,或者有别的服务单独读 archive/ 里的文件,那些地方会在续期后指向过期证书。
# 检查当前实际生效的是哪个证书文件
readlink -f /etc/letsencrypt/live/你的域名/fullchain.pem
openssl x509 -in /etc/letsencrypt/live/你的域名/fullchain.pem -noout -dates
第六个误区,用 --force-renewal 当成日常操作。这个参数会无视剩余有效期强行重新签发一张新证书,每用一次就消耗一次签发配额。一周用几次,你的账户就撞上频率限制了。只有在证书确实需要立即更换时(比如私钥泄露)才用它。
第七个误区,只在服务器上检查证书,不从公网检查。服务器本地读的是 /etc/letsencrypt/live/ 里的文件,而对外提供服务的是 Nginx 加载到内存里的那一张。这两者不一致的情况比你想象的多——文件换了但没重载、路径配的不是同一个文件、CDN 用的是另一张证书。第九节的第三项检查专门解决这个。
第八个误区,把"certbot renew 没报错"当成续期成功。这个命令在剩余有效期大于 30 天时会输出 "no renewals attempted" 并且退出码为 0,也就是成功退出。退出码为 0 只说明没有错误,不代表证书被更新了。
九、验证续期成功的四个数字

跑完一次续期,下面四项要检查,任何一项不达标都不能算成功。
第一项,证书的到期日期确实延后了。执行 openssl x509 -enddate,确认新的到期日期是 90 天后,而不是原来的日期。很多人的脚本跑完了,日志也说成功,但证书根本没换。
第二项,文件内容确实变了。对比新旧证书的序列号:
openssl x509 -in /etc/letsencrypt/live/你的域名/fullchain.pem -noout -serial
第三项,线上服务用的就是这张证书。从公网连上去查,而不是查本地文件:
echo | openssl s_client -servername 你的域名 -connect 你的域名:443 2>/dev/null \
| openssl x509 -noout -serial -dates
本地文件和线上证书的序列号必须一致。不一致说明 Web 服务还在用旧证书,通常是重载没生效。
第四项,浏览器和 API 客户端都不报错。用 curl 完整走一遍 HTTPS 握手:
curl -sI https://你的域名/ | head -3
curl -s -o /dev/null -w '%{http_code} %{ssl_verify_result}\n' https://你的域名/
ssl_verify_result 必须是 0,表示证书链验证通过。
把这四项做成一个脚本,接到你的部署流程里,续期之后自动跑一遍:
#!/bin/bash
# /opt/cert-verify/verify.sh
DOMAIN="你的域名"
SERIAL=$(openssl x509 -in /etc/letsencrypt/live/$DOMAIN/fullchain.pem -noout -serial 2>/dev/null)
LIVE_SERIAL=$(echo | openssl s_client -servername $DOMAIN -connect $DOMAIN:443 2>/dev/null \
| openssl x509 -noout -serial 2>/dev/null)
DAYS=$(echo | openssl s_client -servername $DOMAIN -connect $DOMAIN:443 2>/dev/null \
| openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)
REM=$(( ( $(date -d "$DAYS" +%s) - $(date +%s) ) / 86400 ))
echo "本地序列号: $SERIAL"
echo "线上序列号: $LIVE_SERIAL"
echo "剩余天数: $REM"
[ "$SERIAL" = "$LIVE_SERIAL" ] && [ "$REM" -gt 60 ] && echo "PASS" || echo "FAIL"
这个脚本的判断条件是:本地与线上序列号一致,且剩余天数大于 60 天。两个条件同时满足才算续期真正生效。如果序列号不一致,说明重载没做;如果剩余天数没超过 60 天,说明续期根本没触发。
十、续期方案怎么落到具体配置上

前面讲的是原理,这里给一份可以直接抄的配置模板,包含调度、权限修正、监控三部分。
调度部分,用 systemd timer(推荐,机器重启不漏跑):
[Unit]
Description=Certbot renewal
After=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/bin/certbot renew --quiet --agree-tos
如果你的系统还在用 cron,这是等效的写法:
0 3,15 * * * /usr/bin/certbot renew --quiet --agree-tos
权限修正 hook:
#!/bin/bash
set -u
install -m 0444 -o root -g root /etc/letsencrypt/live/你的域名/fullchain.pem /etc/ssl/certs/你的域名.crt || echo "cert copy failed"
install -m 0440 -o root -g www-data /etc/letsencrypt/live/你的域名/privkey.pem /etc/ssl/private/你的域名.key || echo "key copy failed"
if nginx -t; then systemctl reload nginx; else echo "nginx config invalid, skip reload"; fi
exit 0
监控部分,每天跑一次并把结果推到告警通道:
0 4 * * * /opt/cert-verify/verify.sh | grep -q PASS || /usr/local/bin/alert.sh "证书续期校验未通过"
三部分配齐之后,续期这件事就从"偶尔想起来看一眼"变成了"系统自己在管"。判断这套配置是否到位,看三个数字:连续观察两周,如果 certbot certificates 显示的到期日期每次都在往后推 90 天,说明续期链路通畅;如果沙箱自检每周都是 PASS,说明配置和凭据有效;如果 verify 脚本每天都是 PASS,说明线上服务用的确实是最新证书。
至于服务器本身怎么选,续期这件事对机器的要求很低,真正吃资源的是 Web 服务本身和你的业务逻辑。一个跑静态站或者反向代理的小站,2GB 内存的机型就够;跑 PHP 的 WordPress 站,内存是主要瓶颈,PHP-FPM 每个进程要 30 到 50MB,几个并发就能把 2GB 吃满;跑数据库加应用的多组件站点,内存要往 8GB 以上走。选型时把业务组件的数量和每块的内存需求算清楚,比看总的 vCPU 核数更有意义。
按这个思路,几个可参考的档位:小型站点选 LAX-STARTER,2GB 内存、40GB 磁盘、4.88 TB 流量,折合每月 16.67 美元;中等 WordPress 站选 LAX-MEDIUM,8GB 内存、160GB 磁盘、19.53 TB 流量,折合每月 66.67 美元;需要大内存跑数据库的选云途 HK.DS.S,128GB 内存、6TB 磁盘、39.06 TB 流量,800 元每月;纯做反向代理和静态分发、流量吃紧的选 LAX-LARGE,16GB 内存、320GB 磁盘、39.06 TB 流量,折合每月 133.33 美元。价格与配置以商家页面当日显示为准。
按你的情况,下一步该看哪家
上面讲的是判断标准,但多数人最后还是想问一句「那我该买谁」。按你的实际情况对照下面几条,直接去看对应的机型页比继续比价更快:
- 只想搭个站、预算优先:先看价格最低那档够不够用,别为用不上的配置付钱。看 iwebfusion 当前机型与价格
- 主要访客在国内、在意打开速度:先确认回程线路,再谈配置。看 racknerd 当前机型与价格
- 只是开发测试、临时用:按小时或月付,用完就删,别买年付。看 cloudcone 当前机型与价格
价格与库存以商家页面当日显示为准,下单前建议先确认退款窗口与是否支持按月付费。
十一、常见问题

certbot提示找不到命令怎么办? 说明 cron 环境里没有 certbot 的路径。用 which certbot 找到真实路径写进 cron 命令,或者改用 systemd timer 并在 service 文件里写绝对路径。同时确认 crontab -e 里没有多余的前导空格和换行格式错误。
续期日志里提示DNS解析失败,但域名能正常访问? HTTP-01 验证要求公网能直接访问 80 端口。你的域名可能解析正常,但 80 端口被防火墙关了,或者被 CDN 的 HTTPS 重定向规则拦了。用 curl http://你的域名/.well-known/acme-challenge/test 测试一下能不能拿到响应。
续期成功了但网站提示SSL错误? 几乎都是私钥权限问题。certbot 默认把私钥设为 600 root 属主,Nginx 的运行用户读不了。执行 deploy hook 修正权限,或者手动 chmod 640 并把属组改成 www-data。
能不能同时给多个域名签一张证书? 可以,用多个 -d 参数。但要注意签发机构的限制:一张证书里的域名数量有上限,而且所有域名都必须通过同一种验证方式的检查,混用 HTTP 和 DNS 会被拆分到两张证书上。
泛域名证书续期需要什么? 必须用 DNS-01 验证,因为 HTTP-01 无法验证 *.example.com。前提是 DNS 服务商的 API 可用,并且 API 凭据权限足够创建 TXT 记录。
我的网站有CDN,证书应该放在哪里? 两边都要。CDN 节点上放一份用于终结外部连接,源站上放一份用于 CDN 回源。只在源站放证书而 CDN 不放,CDN 会用自己的证书,你的证书根本不会生效。
certbot renew提示"no renewals attempted"是什么意思? 表示当前证书剩余有效期大于 30 天,certbot 判断还没到续期时间,所以没做任何事。这是正常状态,不是错误。想强制测试用 --dry-run。
续期会不会影响我的网站? 正常情况下不会,certbot 只是在更新文件并触发重载,Nginx 重载是平滑的,不会断开已有连接。但如果重载脚本本身有问题,导致 Nginx 起不来,网站会立刻挂掉。所以 deploy hook 里的重载命令要确保失败时不会导致服务不可用。

喜欢
高兴
鬼脸
呵呵
无聊
伤心