Let’s Encrypt 证书过期后,如何无停机恢复 Nginx + WordPress HTTPS


结论先行

证书过期不代表必须停掉 Nginx,更不需要重启 WordPress 或 PHP-FPM。最稳妥的恢复路径是:保持现有服务运行,通过 Webroot 模式完成 HTTP-01 验证,签发一张新的 ECDSA P-256 单域名证书,再由 acme.sh --install-cert 将证书复制到 Nginx 正在使用的生产路径,最后执行“先检查、后平滑重载”。

整个过程中,80、443 端口持续由 Nginx 提供服务。新证书部署前,服务进程并没有停止,但浏览器仍会因旧证书过期而阻断访问;新证书部署后,Nginx 会启动新 worker,并让旧 worker 处理完已有请求后退出。这是一种不额外制造停机时间的恢复方式:Nginx 控制信号说明

环境与故障症状

这次处理的是常见的 Linux + Nginx + PHP-FPM + WordPress 环境。网站原先使用面板管理的通配符证书,但证书过期后,浏览器提示证书日期无效,严格模式下的 curl 也直接失败。

先不要急着签发,确认当前服务器实际对外发送的证书:

curl -Iv https://blog.example.com/

openssl s_client \
  -connect blog.example.com:443 \
  -servername blog.example.com </dev/null 2>/dev/null |
openssl x509 -noout -subject -issuer -dates -ext subjectAltName

再检查 Nginx 到底引用了哪组证书文件,以及 acme.sh 是否认识本站:

sudo /path/to/nginx/sbin/nginx -T 2>/dev/null |
grep -nE 'server_name|ssl_certificate'

/path/to/acme-home/acme.sh \
  --home /path/to/acme-home --list

crontab -l

最终确认,直接原因虽然是证书过期,但真正的运维根因不是“已经续签却忘了 reload”,而是原面板的续签 wrapper 和计划任务都已缺失,现有 acme.sh 的续期列表中也没有这个域名。换句话说,整条自动续签链路实际上已经不存在。

先备份,再动生产文件

至少备份站点 Nginx 配置、旧证书与私钥、acme.sh 工作目录及计划任务。备份目录应限制访问权限,尤其不能公开其中的私钥和 ACME 账户材料。

STAMP=$(date +%Y%m%d-%H%M%S)
BACKUP=/path/to/backup/tls-$STAMP

sudo install -d -m 700 "$BACKUP"
sudo cp -a /path/to/nginx/conf/site.conf "$BACKUP/"
sudo cp -a /path/to/nginx/ssl "$BACKUP/"
sudo cp -a /path/to/acme-home "$BACKUP/"

sudo /path/to/nginx/sbin/nginx -T 2>&1 |
sudo tee "$BACKUP/nginx-T.txt" >/dev/null

crontab -l 2>/dev/null |
sudo tee "$BACKUP/crontab.txt" >/dev/null

不要删除旧证书,也不要先重启 Nginx。只要旧 worker 仍在运行,服务进程就还在;证书错误和服务宕机是两件不同的事。

打通 HTTP-01

本次只恢复网站实际使用的 blog.example.com,不再重新申请通配符证书。因为 Let’s Encrypt 的 HTTP-01 只能通过 80 端口验证普通域名,不能签发通配符证书;需要通配符时应改用可自动化的 DNS-01。

在 80 端口的 server 块中,为挑战目录保留明确入口:

server {
    listen 80;
    listen [::]:80;
    server_name blog.example.com;

    location ^~ /.well-known/acme-challenge/ {
        root /path/to/site;
        default_type text/plain;
        try_files $uri =404;
    }

    location / {
        return 301 https://$host$request_uri;
    }
}

如果原配置在 server 级别直接写了 return 301,应将重定向移入 location /,避免挑战文件还没匹配就被提前跳转。

测试后再签发:

sudo mkdir -p /path/to/site/.well-known/acme-challenge
printf 'acme-ok\n' |
sudo tee /path/to/site/.well-known/acme-challenge/probe >/dev/null

sudo /path/to/nginx/sbin/nginx -t &&
sudo /path/to/nginx/sbin/nginx -s reload

test "$(curl -fsS \
  http://blog.example.com/.well-known/acme-challenge/probe)" = "acme-ok"

sudo rm /path/to/site/.well-known/acme-challenge/probe

还要同时检查域名的 A、AAAA 记录。若存在错误的 IPv6 记录,Let’s Encrypt 可能从 IPv6 命中另一台机器,导致验证失败。

签发并安装证书

显式指定 Let’s Encrypt、Webroot 和 ECDSA P-256:

ACME=/path/to/acme-home/acme.sh

sudo "$ACME" --home /path/to/acme-home \
  --issue \
  --server letsencrypt \
  -d blog.example.com \
  -w /path/to/site \
  --keylength ec-256

签发成功不等于 Nginx 已经在使用新证书。按照 acme.sh 官方说明,不要让 Nginx 直接引用 acme.sh 内部目录,而应使用 --install-cert 写入稳定的生产路径:

sudo "$ACME" --home /path/to/acme-home \
  --install-cert \
  -d blog.example.com \
  --ecc \
  --key-file /path/to/nginx/ssl/blog.example.com.key \
  --fullchain-file /path/to/nginx/ssl/blog.example.com.fullchain.pem \
  --reloadcmd "/path/to/nginx/sbin/nginx -t && /path/to/nginx/sbin/nginx -s reload"

目标路径应与 Nginx 的 ssl_certificate_keyssl_certificate 完全一致。reloadcmd 使用 && 很关键:只有配置检查通过,才允许平滑重载;同时该命令会被保存,后续自动续期成功后也会再次执行。

严格验证,而不是“能打开就算好”

先检查 Nginx,再核对线上实际发送的证书:

sudo /path/to/nginx/sbin/nginx -t

openssl s_client \
  -connect blog.example.com:443 \
  -servername blog.example.com </dev/null 2>/dev/null |
openssl x509 -noout -issuer -dates -ext subjectAltName

随后用不带 -k 的严格 curl 回归首页、robots、站点地图和 WordPress REST API:

for p in / /robots.txt /wp-sitemap.xml /wp-json/; do
  curl --fail --silent --show-error --location \
    -o /dev/null \
    -w "$p -> %{http_code}\n" \
    "https://blog.example.com$p"
done

sudo "$ACME" --home /path/to/acme-home --list

验收标准是:首页最终返回 200;证书 SAN 包含目标域名;签发者、notBeforenotAfter 正常;Nginx 配置通过;acme.sh --list 中能看到该域名及下一次续期时间。

回滚方案

如果安装后检查异常,只恢复本次改动的配置和证书文件,不要覆盖整个 Nginx 目录:

sudo cp -a "$BACKUP/site.conf" /path/to/nginx/conf/site.conf
sudo cp -a "$BACKUP/ssl/blog.example.com.key" \
  /path/to/nginx/ssl/blog.example.com.key
sudo cp -a "$BACKUP/ssl/blog.example.com.fullchain.pem" \
  /path/to/nginx/ssl/blog.example.com.fullchain.pem

sudo /path/to/nginx/sbin/nginx -t &&
sudo /path/to/nginx/sbin/nginx -s reload

旧证书本身已经过期,回滚只能用于恢复错误配置或错误文件权限,不是最终修复。若签发阶段失败且尚未部署,则无需回滚生产服务,修正 HTTP-01 后重试即可。

补齐自动续期

计划任务必须由安装和管理这套 acme.sh 的同一账号执行,并使用同一个 --home

17 3 * * * /path/to/acme-home/acme.sh --cron --home /path/to/acme-home >> /path/to/log/acme-renew.log 2>&1

手工执行一次普通检查,确认命令、权限和工作目录没有问题:

sudo "$ACME" --home /path/to/acme-home --cron
sudo "$ACME" --home /path/to/acme-home --list

此外应增加证书到期监控,不能把“有 cron”当作“续期一定成功”。

常见坑

  • HTTP-01 必须能从公网访问 80 端口,安全组、主机防火墙、CDN 和错误的 AAAA 记录都会造成失败。
  • HTTP-01 不能签发通配符证书;只恢复一个站点时,单域名证书通常更简单。
  • Webroot 必须是 Nginx 实际映射的站点根目录,不一定等于 WordPress 项目所在的上级目录。
  • ECDSA 证书安装时不要漏掉 --ecc,否则可能从错误的证书目录读取文件。
  • ssl_certificate 应指向 fullchain,ssl_certificate_key 应指向对应私钥,两者必须成对。
  • 不要直接引用 acme.sh 内部证书文件,也不要只签发、不执行 --install-cert
  • cron 环境的 PATH 很短,reloadcmd 最好使用 Nginx 可执行文件的绝对路径。
  • 不要让面板和 acme.sh 同时写同一组生产证书文件;应明确唯一的续签责任方。
  • curl -k 会跳过证书校验,只适合诊断,不能作为恢复成功的证据。