LOGO 首页 OA教程 ERP教程 模切知识交流 PMS教程 CRM教程 技术文档 其他文档  
 
网站管理员

HTTPS 证书自动续期实践:部署、到期监控与续期失败告警

admin
2026年10月27日 0:57 本文热度 162

一、问题背景

证书过期是一个典型的"明知道会发生、但总是忘记处理"的故障。它的危害不需要多解释:浏览器直接拦截、API 调用方握手失败、移动端 App 大面积报错、支付回调丢失。生产环境上一次证书过期,影响面往往比一次机房故障还大,因为它同时打掉所有走 HTTPS 的入口。

更麻烦的是,证书故障的排查路径和常规故障不一样。服务器 CPU 正常、内存正常、进程还在、端口也通,重启服务毫无作用。唯一的证据在证书的 notAfter 字段里,而如果监控没有覆盖这个字段,你只能靠用户投诉知道出事了。

自动化续期本身不难,certbot 一条命令就能签发。难的是三件事:

  1. 续期必须能自动完成,而且失败时不能静默。很多团队配了定时任务,但没有验证它真的能工作,结果到期那天才发现续期脚本半年前就开始报错了。
  2. 续期后的证书必须真的被服务加载。证书文件更新了,但 Nginx 没 reload,线上用的还是旧证书。这个"更新成功但没生效"的情况非常常见。
  3. 到期监控要独立于续期流程。如果监控依赖于续期脚本自己上报,那么续期脚本挂了监控也挂了。到期检查必须是一条独立链路。

这篇文章按"目标 → 环境假设 → 配置前检查 → 实施 → 生效 → 验证 → 风险 → 回滚 → 维护"的顺序,把这三件事都做扎实。


二、适用场景

2.1 覆盖范围

  • 单机 Nginx / Apache 上的多域名证书管理。
  • 使用 Let’s Encrypt 免费证书的对外服务。
  • 使用商业 CA(阿里云、腾讯云、DigiCert 等)签发的证书,想实现自动部署和到期监控。
  • Kubernetes Ingress 场景下的证书自动签发(cert-manager)。
  • 内网服务使用自签证书或企业 CA 证书的定期轮换。

2.2 不覆盖的情况

  • 需要 EV 或 OV 类型证书的合规场景。这类证书的签发需要人工审核,能自动化的只有"部署"和"到期监控"环节。
  • 需要泛域名且 DNS 无法通过 API 操作的场景。这类需要手动配置 DNS 验证,或者只做提醒不做自动续期。
  • 客户端证书(双向 TLS)的证书管理。逻辑类似但对象不同,本文不展开。

2.3 典型的技术选型

方案适用特点
certbot + --webroot有 HTTP 服务监听 80 端口最简单,不需要停服务
certbot + --nginx / --apache由 Nginx/Apache 反向代理自动改配置,但会动你的配置文件
certbot + --dns-*泛域名、无公网 80 端口需要 DNS API 凭据
acme.sh各类场景轻量,DNS 支持广,不依赖 Python
cert-managerKubernetes声明式,与 Ingress 集成
云厂商证书服务云上业务自动签发部署,但绑定云平台

选型的关键判断:你的验证方式是什么。HTTP-01 验证需要 80 端口对公网开放且域名解析指向本机;DNS-01 验证需要在域名服务商有 API 权限,但能做泛域名且不需要 80 端口。


三、核心知识点

3.1 ACME 协议与验证方式

Let’s Encrypt 使用 ACME 协议。签发流程概括为:

   
1. 客户端生成密钥对和 CSR(证书签名请求)
2. 向 ACME 服务器请求挑战(challenge)
3. ACME 服务器返回一个 token 和验证方式
4. 客户端完成验证(放置文件 / 添加 DNS 记录)
5. ACME 服务器从外部验证
6. 验证通过后签发证书
7. 客户端下载并保存证书

三种验证方式:

方式原理要求适用
HTTP-01在 http://域名/.well-known/acme-challenge/<token> 放置文件80 端口公网可达,域名解析正确单域名、非泛域名
DNS-01添加 _acme-challenge.域名 的 TXT 记录DNS API 凭据泛域名、无 80 端口
TLS-ALPN-01在 443 端口提供特殊 TLS 握手443 端口可用不能停 443 的场景

关键点:HTTP-01 验证走的是 80 端口,与 HTTPS 无关。如果 80 端口被防火墙封了或重定向到了 HTTPS,HTTP-01 会失败。这是"续期突然失败"的常见原因之一——有人为了安全把 80 端口关掉了。

3.2 Let’s Encrypt 的续期窗口

Let’s Encrypt 签发的证书有效期是 90 天(这个策略由 CA 决定,具体以官方公布为准)。官方推荐的续期时机是剩余有效期小于 30 天时开始尝试。

certbot 的行为:

  • certbot renew 遍历所有已签发的证书。
  • 对每个证书检查剩余有效期。
  • 剩余超过 30 天的直接跳过,输出 Certificate not yet due for renewal。
  • 剩余不足 30 天的才真正续期。
  • 续期时会重新执行验证流程。

这个设计很重要:意味着你每天跑一次 certbot renew 是安全的,大多数时候它什么都不做。可以在一天中多次运行而不会消耗签发配额。

关于速率限制:Let’s Encrypt 有签发频率限制(如每个注册域名每周多少次)。具体数值会调整,实施前应查阅当前的官方文档。这决定了你不能靠"频繁重试"来解决续期失败,必须有退避和告警机制。

3.3 证书文件的结构

一个典型的证书目录:

   
/etc/letsencrypt/live/example.com/
├── cert.pem        -> ../../archive/example.com/cert1.pem
├── chain.pem       -> ../../archive/example.com/chain1.pem
├── fullchain.pem   -> ../../archive/example.com/fullchain1.pem
├── privkey.pem     -> ../../archive/example.com/privkey1.pem
└── README
文件内容用途
cert.pem服务器证书(叶子证书)单独使用较少
chain.pem中间证书单独使用较少
fullchain.pem服务器证书 + 中间证书Nginx 的 ssl_certificate 用这个
privkey.pem私钥ssl_certificate_key

live/ 目录下都是符号链接,指向 archive/ 里的带版本号的文件。这个设计的好处是续期时新增 cert2.pem 并更新链接,不覆盖旧文件。

关键影响:因为 live/ 下是符号链接,如果 Nginx 配置指向 live/ 路径,那么证书更新后 Nginx 需要 reload 才能读到新文件。但如果连接的是 archive/ 里的具体版本文件,更新后需要改配置。始终指向 live/ 路径。

另一个关键点:/etc/letsencrypt/ 目录权限是 0700,只有 root 能访问。如果 Nginx 不是以 root 启动,会读不到证书。需要检查 Nginx 的 master 进程权限(通常 master 是 root,worker 是 nobody,证书由 master 读取,所以没问题)。

3.4 续期后必须重载服务

这是最容易漏掉的一步。certbot renew 只更新磁盘上的文件,不会通知 Nginx 重新加载。

certbot 提供三种方式处理:

方式一:部署钩子(推荐)

   bash
# /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
#!/bin/bash
systemctl reload nginx

deploy 钩子在每次成功续期后执行,只有真的续期了才跑,不是每次都跑。这是最合适的位置。

方式二:--deploy-hook 参数

   bash
certbot renew --deploy-hook "systemctl reload nginx"

方式三:--post-hook(每次 renew 都跑,即使没续期)

不推荐,因为会导致每天无谓地 reload 服务。

验证钩子是否配置正确:

   bash
ls -la /etc/letsencrypt/renewal-hooks/deploy/
# 确认脚本存在且有执行权限

3.5 权限与运行用户

certbot 需要 root 权限的原因:

  • /etc/letsencrypt/ 目录权限是 0700。
  • 私钥 privkey.pem 权限是 0600。
  • 写入 --webroot 路径需要权限。

所以 certbot renew 通常由 root 的 cron 或 systemd timer 执行。

systemd timer 方式(现代发行版推荐):

   ini
# /etc/systemd/system/certbot-renew.timer
[Unit]
Description=Run certbot renew twice daily
Documentation=https://eff-certbot.readthedocs.io/

[Timer]
OnCalendar=*-*-* 00,12:17:00
RandomizedDelaySec=3600
Persistent=true

[Install]
WantedBy=timers.target
   ini
# /etc/systemd/system/certbot-renew.service
[Unit]
Description=Certbot renewal
Documentation=https://eff-certbot.readthedocs.io/

[Service]
Type=oneshot
ExecStart=/usr/bin/certbot renew --quiet

启用:

   bash
sudo systemctl daemon-reload
sudo systemctl enable --now certbot-renew.timer
sudo systemctl list-timers certbot-renew.timer

几个设计点:

  • OnCalendar=00,12:17:00 表示每天 0 点和 12 点执行,分钟数选 17 而不是 0,是为了错开整点的高峰。Let’s Encrypt 官方也建议加随机延迟。
  • RandomizedDelaySec=3600 给定时器加最多 1 小时的随机延迟,避免大量机器同时请求。
  • Persistent=true 让机器关机期间错过的任务在开机后补跑。
  • --quiet 减少输出,但不要用 --quiet 掩盖错误,失败信息仍需记录。

3.6 到期监控的独立链路

证书到期监控必须和续期流程解耦。设计原则:

  1. 独立采集:监控系统直接读取证书的 notAfter,不依赖续期脚本的返回值。
  2. 独立告警:即使续期脚本完全跑不起来,到期告警也应该触发。
  3. 多阈值:30 天、14 天、7 天、3 天分级告警,越接近到期越紧急。

三种实现方式:

方式实现优点缺点
黑盒探测监控系统定期连 443 端口读证书检测的是"实际对外提供的证书"需要外部探测点
文件检查读取本地证书文件简单检测不到"文件更新但未加载"
双探测两者都做能发现差异配置复杂

推荐双探测:黑盒探测发现"线上证书快过期了",文件检查发现"本地文件已更新但服务没重载"。两者配合能覆盖全部情况。

3.7 关键命令与输出字段

   bash
# 查看证书详情
openssl x509 -in /etc/letsencrypt/live/example.com/fullchain.pem -noout -dates -subject -issuer

输出:

   
notBefore=Sep 12 00:00:00 2026 GMT
notAfter=Dec 11 23:59:59 2026 GMT
subject=CN = example.com
issuer=C = US, O = Let's Encrypt, CN = R3
   bash
# 从远端读取证书(不校验证书有效性)
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
  | openssl x509 -noout -dates -subject

注意 -servername:SNI 参数。同一 IP 上有多个证书时,不带它可能拿到默认证书,导致误判。

   bash
# 剩余天数计算
EXPIRY=$(openssl x509 -in /etc/letsencrypt/live/example.com/fullchain.pem -noout -enddate | cut -d= -f2)
EXPIRY_EPOCH=$(date -d "${EXPIRY}" +%s)
NOW_EPOCH=$(date +%s)
echo $(( (EXPIRY_EPOCH - NOW_EPOCH) / 86400 ))

3.8 Nginx 中的证书配置要点

   nginx
server {
    listen 443 ssl;
    http2 on;
    server_name example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_ciphers         ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
    ssl_prefer_server_ciphers off;

    ssl_session_cache   shared:SSL:10m;
    ssl_session_timeout 1d;
    ssl_session_tickets off;

    ssl_stapling on;
    ssl_stapling_verify on;
    ssl_trusted_certificate /etc/letsencrypt/live/example.com/chain.pem;
}

关于 http2 on; 的版本差异:旧写法是 listen 443 ssl http2;。Nginx 1.25.1 起 http2 成为独立指令,listen ... http2 被标记为废弃。具体用哪种写法取决于 Nginx 版本,用 nginx -v 确认后按对应文档写。两种混用会报错。

关于 ssl_prefer_server_ciphers:在现代配置中通常设为 off,让客户端选择。设为 on 配合较旧的 cipher 列表反而可能降低兼容性。这是配置演进中的变化,老的教程里常见 on。

关于 OCSP Stapling:ssl_stapling on 依赖服务器能主动去 CA 获取 OCSP 响应。如果服务器无法访问 CA 的 OCSP 地址,staple 会失败,客户端可能因此报错。建议在开启前先验证服务器能访问 OCSP 地址,或者干脆不开(现代浏览器对 OCSP 的处理已趋于宽松)。

3.9 证书链完整性

证书链不完整是"某些客户端能访问、某些报错"的典型原因。常见于 Android 老版本、Java 客户端、部分命令行工具。

验证方式:

   bash
# 检查服务端返回的完整链
echo | openssl s_client -connect example.com:443 -servername example.com -showcerts 2>/dev/null \
  | grep -E "^ *[0-9]+ s:|^ *[0-9]+ i:"

预期输出包含至少两级:

   
 0 s:CN = example.com
   i:C = US, O = Let's Encrypt, CN = R3
 1 s:C = US, O = Let's Encrypt, CN = R3
   i:C = US, O = Internet Security Research Group, CN = ISRG Root X1

如果只有第 0 级,说明中间证书没发出去,客户端会报 unable to get local issuer certificate。

根因通常是 ssl_certificate 用了 cert.pem 而不是 fullchain.pem。


四、整体实施思路

4.1 实施闭环

   
目标:证书自动续期,到期前自动更新,失败及时告警
  │
  ├─ 配置前检查
  │   ├─ 域名解析是否指向本机
  │   ├─ 80 端口是否从公网可达
  │   ├─ 现有证书的剩余有效期
  │   └─ 服务是否支持 reload
  │
  ├─ 实施步骤
  │   ├─ 安装 certbot
  │   ├─ 首次签发(手动确认)
  │   ├─ 配置 Nginx 引用证书
  │   ├─ 配置 deploy 钩子
  │   ├─ 配置定时任务
  │   └─ 配置到期监控
  │
  ├─ 生效方式
  │   ├─ reload Nginx
  │   └─ 确认进程加载新证书
  │
  ├─ 验证方法
  │   ├─ 本地文件检查
  │   ├─ 外部黑盒验证
  │   ├─ 证书链完整性
  │   ├─ 续期演练(dry-run)
  │   └─ 模拟服务重载
  │
  ├─ 风险点
  │   ├─ 续期失败导致到期
  │   ├─ reload 失败导致服务中断
  │   └─ 配置错误导致验证失败
  │
  └─ 回滚方案
      ├─ 恢复旧证书
      ├─ 恢复旧配置
      └─ 手动签发应急

4.2 分阶段推进

阶段内容风险
一只做监控,不自动续期无
二手动签发,验证流程低
三自动续期 + Nginx reload中
四全自动 + 完整告警中

第一阶段只做监控很有价值:先搞清楚"现有证书什么时候到期、有多少张、都在哪",再动手自动化。很多团队跳过这步,结果自动化只覆盖了一部分证书,剩下的照样过期。

4.3 必须先收集的信息

   bash
# 1. 系统上所有证书文件
sudo find /etc /usr/local -name "*.pem" -o -name "*.crt" 2>/dev/null | head -50

# 2. Nginx 配置里引用的证书
sudo nginx -T 2>/dev/null | grep -E "ssl_certificate|server_name" | sort -u

# 3. Apache 配置里的证书
sudo apachectl -S 2>/dev/null
sudo grep -rE "SSLCertificateFile|SSLCertificateKeyFile" /etc/httpd/ /etc/apache2/ 2>/dev/null

# 4. 现有 certbot 管理的证书
sudo certbot certificates

# 5. 是否有其他 ACME 客户端
which acme.sh certbot 2>/dev/null
ls -la /root/.acme.sh/ 2>/dev/null

这组命令是只读的,可以放心执行。第 2 步的 nginx -T 会打印完整配置,其中可能包含敏感信息,输出不要随意分享。


五、实战步骤

5.0 配置前检查

目的:确认环境满足自动续期的前提条件,避免配置完才发现验证通不过。

检查一:域名解析

   bash
DOMAIN=example.com

# 本机的公网 IP(云环境通常需要从元数据或控制台获取)
curl -s --max-time 5 https://ifconfig.me || curl -s --max-time 5 http://ipv4.icanhazip.com

# 域名的解析结果
dig +short "${DOMAIN}" A
dig +short "www.${DOMAIN}" A

判断逻辑:

情况结论处理
解析结果 = 本机公网 IPHTTP-01 可行继续
解析到 CDN 或负载均衡HTTP-01 需要特殊处理用 DNS-01,或在 CDN 层放行验证路径
解析不到DNS 未配先配 DNS
解析到内网 IP公网无法验证用 DNS-01

检查二:80 端口可达性

   bash
# 本机监听情况
sudo ss -lntp | grep -E ':80 |:443 '

# 防火墙
sudo firewall-cmd --list-all 2>/dev/null || sudo iptables -L INPUT -n --line-numbers | head -20

# 云安全组需要在控制台确认(命令行无法直接查)
   bash
# 从外部验证(在另一台机器上执行)
curl -s -o /dev/null -w '%{http_code}\n' --max-time 5 "http://${DOMAIN}/.well-known/acme-challenge/test"

返回 404 是正常的(文件不存在),说明 80 端口可达。返回连接超时或拒绝,说明 80 端口不通。

重要判断:如果 80 端口有强制跳转到 HTTPS 的配置,验证路径必须排除在跳转之外,否则 ACME 服务器拿到的是 301 而不是验证内容。这是一个非常常见的失败原因。

   nginx
server {
    listen 80;
    server_name example.com;

    # 验证路径不跳转
    location /.well-known/acme-challenge/ {
        root /var/www/html;
        allow all;
    }

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

检查三:现有证书状态

   bash
sudo certbot certificates 2>/dev/null

# 或直接看文件
sudo find /etc/letsencrypt/live -name "fullchain.pem" -exec sh -c \
  'echo "=== $1 ==="; openssl x509 -in "$1" -noout -subject -enddate' _ {} \;

预期输出:

   
=== /etc/letsencrypt/live/example.com/fullchain.pem ===
subject=CN = example.com
notAfter=Dec 11 23:59:59 2026 GMT

检查四:服务是否支持 reload

   bash
systemctl show nginx -p CanReload
# CanReload=yes

如果 CanReload=no,不能自动 reload,必须 restart(有中断)。

检查五:时间同步

   bash
timedatectl status
# 关注 System clock synchronized: yes 和 NTP service: active

为什么重要:ACME 协议对时间敏感,时间偏差过大会导致请求被拒绝。证书的有效期判断也依赖本地时间。如果系统时间错了 5 天,剩余天数计算也会错。

下一步:五项检查都通过,进入 5.1。

5.1 步骤一:安装 certbot

目的:装好 ACME 客户端。

操作:

   bash
# RHEL/CentOS/openEuler/AlmaLinux/Rocky
sudo dnf install -y certbot python3-certbot-nginx
# 老版本用 yum
sudo yum install -y certbot python3-certbot-nginx

# Debian/Ubuntu
sudo apt update
sudo apt install -y certbot python3-certbot-nginx

# 用 snap(官方推荐的通用方式)
sudo snap install --classic certbot
sudo ln -s /snap/bin/certbot /usr/bin/certbot

验证:

   bash
certbot --version
# certbot 2.x.x

异常表现:

现象原因处理
command not found包名不对或没装成功检查发行版的包名,EPEL 源是否启用
No module named 'acme'依赖缺失或 Python 环境混乱重装 python3-certbot-nginx
版本过老(< 1.0)系统仓库没更新用 snap 或 pip 安装新版

关于 pip 安装:pip install certbot 在部分系统上会因为 Python 环境隔离(PEP 668)而失败,报 externally-managed-environment。生产环境建议用系统包管理器或 snap,不要混用 pip 和系统包。

风险提醒:不要用 pip install --break-system-packages 强行安装,这会破坏系统 Python 环境,可能影响其他依赖 Python 的系统工具。

下一步:确认版本后,做一次不写盘的 dry-run 验证。

5.2 步骤二:首次签发前的 dry-run

目的:在不消耗签发配额的前提下验证整个流程能走通。

操作:

   bash
DOMAIN=example.com
EMAIL=ops@example.com
WEBROOT=/var/www/html

# 确认 webroot 目录存在
sudo mkdir -p "${WEBROOT}/.well-known/acme-challenge"
sudo chown -R www-data:www-data "${WEBROOT}" 2>/dev/null || \
  sudo chown -R nginx:nginx "${WEBROOT}"

# dry-run 签发(不会真的签发,用 staging 环境)
sudo certbot certonly \
  --webroot \
  --webroot-path "${WEBROOT}" \
  -d "${DOMAIN}" \
  -d "www.${DOMAIN}" \
  --email "${EMAIL}" \
  --agree-tos \
  --no-eff-email \
  --dry-run

参数说明:

参数作用
certonly只签发证书,不修改 Web 服务器配置
--webroot使用 HTTP-01 验证,文件方式
--webroot-path验证文件的根目录
-d域名,可多次指定
--email到期提醒邮箱
--agree-tos同意条款
--no-eff-email不订阅 EFF 邮件
--dry-run用 staging 环境测试,不消耗正式配额

预期输出:

   
Saving debug log to /var/log/letsencrypt/letsencrypt.log
Plugins selected: Authenticator webroot, Installer None
Simulating a certificate request for example.com and www.example.com
Performing the following challenges:
http-01 challenge for example.com
http-01 challenge for www.example.com
Using the webroot path /var/www/html for all unmatched domains.
Waiting for verification...
Cleaning up challenges
The dry run was successful.

异常表现与处理:

错误信息原因处理
Could not find a virtual host域名没在 Web 服务器配置里检查 server_name
Timeout during connect80 端口不通检查安全组、防火墙
Invalid response ... 404webroot 路径不对核对 --webroot-path 与 Nginx 的 root 一致
Invalid response ... 30180 端口跳转到了 HTTPS给验证路径加例外
DNS problem: NXDOMAIN域名解析不到检查 DNS
too many certificates already issued触发速率限制用 --dry-run 测试,等限制重置

--dry-run 的边界:它在 staging 环境完整走一遍流程,包括验证和签发,只是不写入正式证书目录。所以它能验证:域名解析、80 端口可达、webroot 路径正确、ACME 通信正常。它不能验证:正式环境的速率限制、最终证书的信任链。

下一步:dry-run 成功后,正式签发。

5.3 步骤三:正式签发

目的:签发第一个正式证书。

操作:

   bash
sudo certbot certonly \
  --webroot \
  --webroot-path /var/www/html \
  -d example.com \
  -d www.example.com \
  --email ops@example.com \
  --agree-tos \
  --no-eff-email \
  --keep-until-expiring

--keep-until-expiring 的作用:如果证书已存在且未到期,直接跳过不动。这让命令可以重复执行而不会覆盖现有证书。

预期输出:

   
Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/example.com/fullchain.pem
Key is saved at:         /etc/letsencrypt/live/example.com/privkey.pem
This certificate expires on 2026-12-11.
These files will be updated when the certificate renews.

验证:

   bash
sudo ls -la /etc/letsencrypt/live/example.com/
sudo openssl x509 -in /etc/letsencrypt/live/example.com/fullchain.pem -noout -dates -subject -issuer

异常表现:

现象原因
报速率限制短期内签发太多,需要等待
证书目录已存在但报错用 --force-renewal 或删掉旧目录(谨慎)
只有 example.com 没有 www某个子域验证失败,看日志里具体哪个 challenge 失败

重要提醒:不要在没搞清原因时反复执行正式签发。每次失败都可能消耗配额,达到限制后会影响所有域名的签发。失败时先看 /var/log/letsencrypt/letsencrypt.log 里的具体错误。

   bash
sudo tail -100 /var/log/letsencrypt/letsencrypt.log

下一步:配置 Nginx 使用新证书。

5.4 步骤四:配置 Web 服务器引用证书

目的:让 Nginx 用新证书提供 HTTPS 服务。

操作:/etc/nginx/conf.d/example.com.conf:

   nginx
# HTTP 端口:保留 ACME 验证路径,其余跳转 HTTPS
server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;

    location /.well-known/acme-challenge/ {
        root /var/www/html;
        allow all;
        # 不记录验证请求的日志,避免噪声
        access_log off;
    }

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

# HTTPS 主站
server {
    listen 443 ssl;
    listen [::]:443 ssl;
    server_name example.com www.example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
    ssl_prefer_server_ciphers off;
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 1d;
    ssl_session_tickets off;

    add_header Strict-Transport-Security "max-age=31536000" always;

    access_log /var/log/nginx/example.com.access.log main;
    error_log  /var/log/nginx/example.com.error.log warn;

    root /var/www/example.com;
    index index.html;

    location / {
        try_files $uri $uri/ =404;
    }
}

配置说明:

  • listen 80 的 server 块必须存在且包含验证路径,否则 HTTP-01 无法完成。
  • ssl_certificate 用 fullchain.pem,不是 cert.pem。
  • ssl_certificate_key 用 privkey.pem。
  • HSTS 头一旦下发,浏览器会记住并强制 HTTPS。第一次上线时如果还没完全验证 HTTPS 正常,先不要加 HSTS,或者用较短的 max-age。这是一个容易造成"回不去"的配置:
   nginx
# 谨慎上线:先用短时间
add_header Strict-Transport-Security "max-age=300" always;

# 确认稳定后再改长
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

HSTS 的风险:一旦浏览器收到 max-age=31536000 的 HSTS 头,在一年内访问该域名都会强制走 HTTPS。如果 HTTPS 出问题(证书过期、配置错误),浏览器不会让你用 HTTP 绕过,也无法点击"继续访问"。清除 HSTS 缓存的唯一方式是浏览器设置里手动清除,对普通用户几乎不可能完成。

生效方式:

   bash
# 1. 语法检查(关键步骤,不要跳过)
sudo nginx -t

# 2. reload(不是 restart,reload 不断开现有连接)
sudo systemctl reload nginx

# 3. 确认 reload 成功
systemctl is-active nginx

异常表现:

现象原因处理
nginx: [emerg] cannot load certificate路径错误或权限不足检查路径,检查 Nginx master 是否 root
nginx: [emerg] PEM_read_bio_X509证书文件损坏重新签发
permission denied 读取 privkeyNginx 用户无权访问 /etc/letsencrypt/确认 master 进程是 root

注意:nginx -t 检查通过不代表 reload 一定成功。如果 reload 期间有并发问题,可能仍然失败。reload 后一定要 systemctl is-active nginx 确认。

下一步:配置自动续期的 deploy 钩子。

5.5 步骤五:配置续期后的自动重载

目的:证书更新后自动让 Nginx 加载新证书。

操作:

   bash
sudo mkdir -p /etc/letsencrypt/renewal-hooks/deploy

/etc/letsencrypt/renewal-hooks/deploy/10-reload-nginx.sh:

   bash
#!/bin/bash
# 用途:certbot 续期成功后重载 Web 服务器
# 该脚本由 certbot 在每次成功续期后自动执行
set -euo pipefail

LOG_FILE=/var/log/letsencrypt/deploy-hook.log
TIMESTAMP="$(date '+%Y-%m-%d %H:%M:%S')"

# certbot 会传入这些环境变量
RENEWED_DOMAINS="${RENEWED_DOMAINS:-unknown}"
RENEWED_LINEAGE="${RENEWED_LINEAGE:-unknown}"

echo "[${TIMESTAMP}] 续期成功 domain=${RENEWED_DOMAINS} lineage=${RENEWED_LINEAGE}" >> "${LOG_FILE}"

# 先做语法检查,不通过就不 reload
if ! /usr/sbin/nginx -t >> "${LOG_FILE}" 2>&1; then
  echo "[${TIMESTAMP}] nginx 配置检查失败,跳过 reload" >> "${LOG_FILE}"
  exit 1
fi

# reload 而不是 restart
if systemctl reload nginx; then
  echo "[${TIMESTAMP}] nginx reload 成功" >> "${LOG_FILE}"
else
  echo "[${TIMESTAMP}] nginx reload 失败" >> "${LOG_FILE}"
  exit 1
fi

# 验证新证书已生效
NEW_EXPIRY="$(openssl x509 -in "${RENEWED_LINEAGE}/fullchain.pem" -noout -enddate 2>/dev/null | cut -d= -f2)"
echo "[${TIMESTAMP}] 新证书到期时间: ${NEW_EXPIRY}" >> "${LOG_FILE}"

exit 0

授权:

   bash
sudo chmod 0755 /etc/letsencrypt/renewal-hooks/deploy/10-reload-nginx.sh
sudo chown root:root /etc/letsencrypt/renewal-hooks/deploy/10-reload-nginx.sh
sudo touch /var/log/letsencrypt/deploy-hook.log
sudo chmod 0600 /var/log/letsencrypt/deploy-hook.log

脚本设计的几个要点:

  1. set -euo pipefail:任何命令失败立即退出,不会带着错误继续执行。
  2. 变量加引号:"${RENEWED_LINEAGE}" 防止路径含空格时出错。
  3. 先 nginx -t 再 reload:配置有问题时宁可不 reload,也不要把服务搞坏。
  4. reload 而非 restart:reload 不断开现有连接。
  5. 写日志:certbot renew 的输出可能被 cron 邮件吞掉,独立日志更可靠。
  6. exit 1 非零退出:让 certbot 知道钩子失败了。

关于钩子的返回值:deploy 钩子返回非零会让 certbot renew 整体返回非零。这有用——你可以通过检查 certbot renew 的退出码来判断"续期 + 应用"是否完整成功。但要注意,证书已经续期成功,只是 reload 失败,这种情况下证书文件是新的,服务用的是旧的,需要人工介入。

测试钩子:

   bash
# 手动执行一次,验证脚本本身没问题
sudo RENEWED_DOMAINS=example.com \
     RENEWED_LINEAGE=/etc/letsencrypt/live/example.com \
     /etc/letsencrypt/renewal-hooks/deploy/10-reload-nginx.sh

cat /var/log/letsencrypt/deploy-hook.log

下一步:配置定时续期。

5.6 步骤六:配置定时续期

目的:让续期自动发生。

方式一:systemd timer(推荐)

   ini
# /etc/systemd/system/certbot-renew.service
[Unit]
Description=Certbot renewal
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/bin/certbot renew --quiet --no-random-sleep-on-renew
StandardOutput=journal
StandardError=journal

注意:--no-random-sleep-on-renew 与 timer 的 RandomizedDelaySec 功能重叠。如果 timer 已经加了随机延迟,这里就不需要再加;如果不加 --no-random-sleep-on-renew,certbot 自己也会休眠一段随机时间。两者选一,否则总延迟会叠加。

   ini
# /etc/systemd/system/certbot-renew.timer
[Unit]
Description=Run certbot renew twice daily
Documentation=https://eff-certbot.readthedocs.io/

[Timer]
OnCalendar=*-*-* 00,12:23:00
RandomizedDelaySec=1800
Persistent=true
AccuracySec=1min

[Install]
WantedBy=timers.target
   bash
sudo systemctl daemon-reload
sudo systemctl enable --now certbot-renew.timer
sudo systemctl list-timers certbot-renew.timer

预期输出:

   
NEXT                        LEFT       LAST                        PASSED    UNIT
Sun 2026-10-11 00:23:00 CST 9h left   Sat 2026-10-10 12:23:15 CST 2h ago    certbot-renew.timer

方式二:cron

   bash
# /etc/cron.d/certbot-renew
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
# 每天两次,分钟数错开整点
17 0,12 * * * root certbot renew --quiet

关于 cron 的坑:

  • cron 的环境变量很干净,PATH 可能不包含 certbot 的路径。必须在 cron 文件里显式设置 PATH。
  • 建议用 /usr/bin/certbot 绝对路径,而不是依赖 PATH。
  • cron 的输出会通过邮件发送,如果没配邮件会丢失。要显式重定向到日志:
   bash
17 0,12 * * * root /usr/bin/certbot renew --quiet >> /var/log/letsencrypt/renew.log 2>&1

两种方式的对比:

维度systemd timercron
随机延迟原生支持需要自己写
日志journalctl 统一需重定向
错过补偿Persistent=true不支持
依赖管理可声明 After=无
兼容性需要 systemd广泛

建议用 systemd timer,除非系统没有 systemd。

下一步:配置到期监控。

5.7 步骤七:配置到期监控

目的:建立一条独立于续期流程的证书到期检测链路。

方式一:脚本 + 定时任务 + 告警推送

/usr/local/bin/check-cert-expiry.sh:

   bash
#!/bin/bash
# 用途:检查本地证书文件的到期时间,输出告警
# 设计原则:独立于 certbot,即使 certbot 完全失效也能发现到期风险
set -uo pipefail

# 配置区
CERT_DIR="${CERT_DIR:-/etc/letsencrypt/live}"
WARN_DAYS_WARN="${WARN_DAYS_WARN:-21}"
WARN_DAYS_CRIT="${WARN_DAYS_CRIT:-7}"
STATE_DIR="${STATE_DIR:-/var/lib/cert-expiry}"
LOG_FILE="${LOG_FILE:-/var/log/cert-expiry.log}"
DRY_RUN="${DRY_RUN:-0}"

mkdir -p "${STATE_DIR}"

log() {
  echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "${LOG_FILE}"
}

get_fingerprint() {
  # 用序列号作为证书指纹,比文件 mtime 更可靠
  openssl x509 -in "$1" -noout -serial 2>/dev/null | cut -d= -f2
}

check_cert() {
  local cert_file="$1"
  local domain
  domain="$(basename "$(dirname "${cert_file}")")"

  if [[ ! -f "${cert_file}" ]]; then
    log "WARN ${domain}: 证书文件不存在 ${cert_file}"
    return 1
  fi

  local end_date
  end_date="$(openssl x509 -in "${cert_file}" -noout -enddate 2>/dev/null | cut -d= -f2)"
  if [[ -z "${end_date}" ]]; then
    log "ERROR ${domain}: 无法解析证书到期时间"
    return 1
  fi

  local end_epoch now_epoch days_left
  end_epoch="$(date -d "${end_date}" +%s 2>/dev/null)"
  if [[ -z "${end_epoch}" ]]; then
    log "ERROR ${domain}: 无法解析日期格式: ${end_date}"
    return 1
  fi
  now_epoch="$(date +%s)"
  days_left=$(( (end_epoch - now_epoch) / 86400 ))

  # 记录证书序列号,用于检测是否真的换新了
  local serial
  serial="$(get_fingerprint "${cert_file}")"
  local state_file="${STATE_DIR}/${domain}.serial"
  local old_serial=""
  [[ -f "${state_file}" ]] && old_serial="$(cat "${state_file}")"

  echo "${serial}" > "${state_file}"

  local status="OK"
  if [[ "${days_left}" -le "${WARN_DAYS_CRIT}" ]]; then
    status="CRITICAL"
  elif [[ "${days_left}" -le "${WARN_DAYS_WARN}" ]]; then
    status="WARNING"
  fi

  local changed="no"
  if [[ -n "${old_serial}" && "${old_serial}" != "${serial}" ]]; then
    changed="yes"
  fi

  # 输出一行结构化结果,便于日志分析
  printf '%s domain=%s days_left=%d expires=%s serial_changed=%s\n' \
    "${status}" "${domain}" "${days_left}" "${end_date}" "${changed}" \
    | tee -a "${LOG_FILE}"

  case "${status}" in
    CRITICAL) return 2 ;;
    WARNING)  return 1 ;;
    *)        return 0 ;;
  esac
}

main() {
  local worst_rc=0
  local cert_file
  local found=0

  # 遍历所有 lineage
  for dir in "${CERT_DIR}"/*/; do
    cert_file="${dir}fullchain.pem"
    [[ -f "${cert_file}" ]] || continue
    found=$((found + 1))
    check_cert "${cert_file}"
    local rc=$?
    [[ ${rc} -gt ${worst_rc} ]] && worst_rc="${rc}"
  done

  if [[ "${found}" -eq 0 ]]; then
    log "ERROR 未在 ${CERT_DIR} 下找到任何证书"
    return 3
  fi

  if [[ "${DRY_RUN}" == "1" ]]; then
    log "dry-run 结束,未发送告警"
    return 0
  fi

  if [[ "${worst_rc}" -ge 2 ]]; then
    log "存在 CRITICAL 级别的证书,应触发紧急告警"
    # 在这里接入告警通道,例如:
    # curl -s -X POST "${ALERT_WEBHOOK}" -H 'Content-Type: application/json' \
    #   -d "{\"msg\":\"证书即将到期,请立即处理\"}"
  elif [[ "${worst_rc}" -ge 1 ]]; then
    log "存在 WARNING 级别的证书,应触发普通告警"
  fi

  return "${worst_rc}"
}

main "$@"

脚本设计的几个要点:

  1. 用序列号而不是文件时间戳判断"是否更新"。文件可能在续期过程中被 touch,但内容没变。序列号是证书的唯一标识,变了才说明真的换了证书。
  2. DRY_RUN 支持:脚本默认是只读的,但 DRY_RUN 变量保留了扩展点,接入告警通道前可以先测。
  3. 日志包含结构化字段:domain=... days_left=... 的格式便于用 awk、grep 做后续分析。
  4. 退出码分级:0 正常、1 警告、2 严重、3 异常。定时任务可以根据退出码决定是否发告警。
  5. 不写死告警通道:Webhook 地址、Token 属于环境相关配置,通过环境变量或受控配置文件传入。

接入告警通道时的注意点:

   bash
# Token 不要写死在脚本里,用环境变量或权限受控的配置文件
# /etc/cert-monitor/env (权限 0600,属主 root)
ALERT_WEBHOOK="https://alert.example.com/api/v1/alerts"
ALERT_TOKEN_FILE="/etc/cert-monitor/token"
   bash
# 脚本中读取
# shellcheck source=/dev/null
[[ -f /etc/cert-monitor/env ]] && source /etc/cert-monitor/env
TOKEN="$(cat "${ALERT_TOKEN_FILE}" 2>/dev/null || echo '')"

定时任务:

   ini
# /etc/systemd/system/cert-expiry-check.service
[Unit]
Description=Check certificate expiry
After=network-online.target

[Service]
Type=oneshot
EnvironmentFile=-/etc/cert-monitor/env
ExecStart=/usr/local/bin/check-cert-expiry.sh
# 退出码 1 和 2 是"发现到期风险",属于正常告警,不是服务失败
SuccessExitStatus=0 1 2
   ini
# /etc/systemd/system/cert-expiry-check.timer
[Unit]
Description=Daily certificate expiry check

[Timer]
OnCalendar=*-*-* 08:41:00
RandomizedDelaySec=300
Persistent=true

[Install]
WantedBy=timers.target

SuccessExitStatus 的作用:如果不加,systemd 会把退出码 1、2 当作服务失败,在 systemctl status 里显示 failed,产生噪声。加上之后,只有真正的异常(3)才算失败。

方式二:Prometheus + blackbox_exporter

这是更规范的方案,能用统一的方式管理所有证书。

blackbox_exporter 的 blackbox.yml:

   yaml
modules:
  https_cert:
    prober: http
    timeout: 10s
    http:
      preferred_ip_protocol: ip4
      tls_config:
        insecure_skip_verify: true    # 只关心证书有效期,不校验信任链

重要说明:insecure_skip_verify: true 是必要的。如果不跳过校验,证书链有问题时探测会直接失败,拿不到 probe_ssl_earliest_cert_expiry 指标,你就不知道是"证书过期"还是"链不完整"。用 blackbox 探测到期时间时应该跳过校验,信任链的检查用另一个 module 单独做。

Prometheus 采集配置:

   yaml
scrape_configs:
  - job_name: 'blackbox-https'
    metrics_path: /probe
    params:
      module: [https_cert]
    static_configs:
      - targets:
          - https://example.com
          - https://www.example.com
          - https://api.example.com
    relabel_configs:
      - source_labels: [__address__]
        target_label: __param_target
      - source_labels: [__param_target]
        target_label: instance
      - target_label: __address__
        replacement: 127.0.0.1:9115    # blackbox_exporter 地址

告警规则:

   yaml
groups:
  - name: ssl-certificate
    rules:
      - alert: SSLCertExpiringSoon
        expr: |
          (probe_ssl_earliest_cert_expiry - time()) / 86400 < 21
          and
          (probe_ssl_earliest_cert_expiry - time()) / 86400 >= 7
        for: 1h
        labels:
          severity: warning
        annotations:
          summary: "证书将在 {{ $value | printf \"%.1f\" }} 天后过期"
          description: "目标 {{ $labels.instance }} 的证书剩余有效期不足 21 天"

      - alert: SSLCertExpiringCritical
        expr: |
          (probe_ssl_earliest_cert_expiry - time()) / 86400 < 7
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "证书将在 {{ $value | printf \"%.1f\" }} 天后过期"
          description: "目标 {{ $labels.instance }} 的证书即将过期,需立即处理"

      - alert: SSLCertExpired
        expr: |
          probe_ssl_earliest_cert_expiry - time() < 0
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "证书已过期"
          description: "目标 {{ $labels.instance }} 的证书已过期"

      - alert: SSLCertProbeFailed
        expr: up{job="blackbox-https"} == 0
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "证书探测失败"
          description: "无法探测 {{ $labels.instance }},可能是网络或服务问题"

阈值设计说明:

  • 21 天 作为 warning:Let’s Encrypt 在剩余 30 天时开始尝试续期,如果 9 天后还没成功,说明有问题。这个阈值给了一个观察窗口。
  • 7 天 作为 critical:留出人工介入的时间。
  • 阈值必须结合续期窗口调整。如果用的是 365 天的商业证书,人工续期流程需要提前一个月,那 warning 应该设在 45 天甚至 60 天。
  • 不要照搬别人的阈值。判断依据是"从告警到你实际完成处理需要多长时间",再留一倍余量。

probe_ssl_earliest_cert_expiry 指标:这是 blackbox_exporter 提供的指标,返回证书链中最早到期的那个证书的过期时间戳(Unix 秒)。由于是绝对时间戳,用 - time() 转换成剩余秒数。

注意:这个指标名以实际使用的 blackbox_exporter 版本为准。不同版本可能有差异,部署后先用 curl http://localhost:9115/probe?target=https://example.com&module=https_cert 确认实际输出的指标名。

方式三:文件检查 + 探测结果对比

这是最有价值的补充。把本地文件的有效期和线上提供的证书有效期放在一起比较:

   bash
# 本地文件
LOCAL_EXPIRY=$(openssl x509 -in /etc/letsencrypt/live/example.com/fullchain.pem -noout -enddate | cut -d= -f2)

# 线上实际提供的
REMOTE_EXPIRY=$(echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
  | openssl x509 -noout -enddate | cut -d= -f2)

echo "本地: ${LOCAL_EXPIRY}"
echo "线上: ${REMOTE_EXPIRY}"

两者不一致意味着什么:

情况含义处理
本地比线上新证书已续期但服务未 reload检查 deploy 钩子
线上比本地新服务加载的不是这个文件检查 Nginx 配置路径
两者都旧续期完全没工作检查定时任务和 certbot 日志

这是一个非常有用的检查,建议加入日常巡检。它能发现"续期成功但没生效"这个隐蔽问题——监控只看文件有效期的话,会显示一切正常,但线上实际用的是旧证书。

下一步:验证整套流程。

5.8 步骤八:续期演练

目的:在不等到期的情况下,验证续期流程真的能工作。

方式一:certbot renew --dry-run

   bash
sudo certbot renew --dry-run

预期输出:

   
Processing /etc/letsencrypt/renewal/example.com.conf
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Cert is due for renewal, auto-renewing...
Plugins selected: Authenticator webroot, Installer None
Simulating renewal of an existing certificate for example.com and www.example.com
Waiting for verification...
Cleaning up challenges
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Congratulations, all simulated renewals succeeded:
  /etc/letsencrypt/live/example.com/fullchain.pem (success)
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

关键注意点:renew --dry-run不会执行 deploy 钩子(因为证书实际上没更新)。所以这个测试验证的是"续期能成功",不验证"reload 能成功"。钩子需要单独测。

方式二:强制续期(有风险)

   bash
sudo certbot renew --force-renewal

风险提醒:--force-renewal 会无视剩余有效期直接续期。这有几个问题:

  1. 消耗签发配额。Let’s Encrypt 的速率限制是按注册域名算的,频繁强制续期会触发限制。
  2. 如果续期验证失败,旧证书可能被影响。虽然 certbot 设计上会保留旧证书,但反复失败会让状态变复杂。
  3. 多台机器同时执行会造成并发问题。

使用场景:只在测试环境验证完整流程(包括 deploy 钩子)时使用,而且要在确认配额充足的前提下。

方式三:手动触发 systemd 服务

   bash
# 触发续期(实际上因为未到期不会真的续期,只是验证服务能跑)
sudo systemctl start certbot-renew.service
sudo journalctl -u certbot-renew.service -n 50 --no-pager

验证日志里有正确的判断:

   
Certificate not yet due for renewal; no action taken.

看到这一行说明流程走通了,只是证书还没到期。这本身就是成功的信号。

方式四:测试 deploy 钩子

   bash
# 手动执行钩子
sudo RENEWED_DOMAINS=example.com \
     RENEWED_LINEAGE=/etc/letsencrypt/live/example.com \
     /etc/letsencrypt/renewal-hooks/deploy/10-reload-nginx.sh

# 检查日志
sudo tail -20 /var/log/letsencrypt/deploy-hook.log

# 确认 Nginx 确实 reload 了(看进程的重启时间)
systemctl show nginx -p ActiveEnterTimestamp

验证 Nginx 是否真的重载了:reload 不会改变主进程的 PID,但会改变 worker 进程。可以对比 worker 的启动时间:

   bash
# 对比前后
ps -eo pid,lstart,cmd | grep 'nginx: worker' | head

下一步:建立日常巡检。

5.9 步骤九:日常巡检与报表

目的:把证书状态纳入日常巡检,形成资产视图。

   bash
#!/bin/bash
# 用途:生成所有证书的状态报表
set -uo pipefail

OUTPUT="${1:-/tmp/cert-report-$(date +%Y%m%d).csv}"

{
  echo "domain,type,subject,issuer,not_before,not_after,days_left"
  for cert in $(sudo find /etc/letsencrypt/live -name "fullchain.pem" 2>/dev/null); do
    domain="$(basename "$(dirname "${cert}")")"
    subject="$(openssl x509 -in "${cert}" -noout -subject 2>/dev/null | sed 's/^subject=//')"
    issuer="$(openssl x509 -in "${cert}" -noout -issuer 2>/dev/null | sed 's/^issuer=//')"
    nb="$(openssl x509 -in "${cert}" -noout -startdate 2>/dev/null | cut -d= -f2)"
    na="$(openssl x509 -in "${cert}" -noout -enddate 2>/dev/null | cut -d= -f2)"
    dl=$(( ($(date -d "${na}" +%s) - $(date +%s)) / 86400 ))
    printf '%s,local,%s,%s,%s,%s,%d\n' \
      "${domain}" "${subject}" "${issuer}" "${nb}" "${na}" "${dl}"
  done

  # Nginx 配置中所有 server_name,检查是否有证书没覆盖
  sudo nginx -T 2>/dev/null | grep -E '^\s*server_name' | \
    sed 's/^\s*server_name\s*//;s/;$//' | tr ' ' '\n' | \
    grep -v '^$' | grep -v '_' | sort -u
} > "${OUTPUT}"

echo "报表已生成: ${OUTPUT}"
head -20 "${OUTPUT}"

这个报表能发现的问题:

  • 哪些证书剩余天数最少。
  • 是否有域名在 Nginx 配置里但没有任何证书覆盖(末尾那段输出可用于对比)。
  • 签发者是否符合预期(防止意外用了测试环境的证书)。

关于 CSV 中的逗号:subject 里可能包含逗号(如 CN = example.com, O = Example),这会让 CSV 列数错乱。稳妥做法是把逗号替换掉:

   bash
subject="$(openssl x509 -in "${cert}" -noout -subject 2>/dev/null | sed 's/^subject=//; s/,/;/g')"

六、常用命令速查

6.1 查看证书信息

   bash
CERT=/etc/letsencrypt/live/example.com/fullchain.pem

# 基本信息
openssl x509 -in "${CERT}" -noout -subject -issuer -dates

# 包含的域名
openssl x509 -in "${CERT}" -noout -ext subjectAltName

# 序列号
openssl x509 -in "${CERT}" -noout -serial

# 指纹
openssl x509 -in "${CERT}" -noout -fingerprint -sha256

# 完整内容
openssl x509 -in "${CERT}" -noout -text

# 剩余天数
END=$(openssl x509 -in "${CERT}" -noout -enddate | cut -d= -f2)
echo $(( ($(date -d "${END}" +%s) - $(date +%s)) / 86400 )) days

6.2 从远端读取证书

   bash
# 读取远端证书(跳过验证)
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
  | openssl x509 -noout -subject -dates

# 查看完整证书链
echo | openssl s_client -connect example.com:443 -servername example.com -showcerts 2>/dev/null \
  | grep -E "^ *[0-9]+ s:|^ *[0-9]+ i:"

# 检查证书链验证结果
echo | openssl s_client -connect example.com:443 -servername example.com 2>&1 \
  | grep -E "Verify return code|verify error"

# 检查 TLS 版本支持
for v in tls1_2 tls1_3; do
  printf '%-8s ' "${v}"
  echo | openssl s_client -connect example.com:443 -servername example.com \
    -"${v}" 2>/dev/null | grep -q "Cipher is" && echo "OK" || echo "FAIL"
done

# 用 curl 检查(更简单)
curl -vI https://example.com 2>&1 | grep -E "SSL certificate|expire date|subject:|issuer:"
curl -sI --max-time 5 https://example.com | head -1

-servername 的重要性:同一 IP 承载多个域名(SNI 场景)时,不指定 SNI 会拿到默认证书。排查时如果发现"证书不对",先确认有没有加 -servername。

6.3 certbot 命令

   bash
# 查看所有证书及到期时间
certbot certificates

# 续期所有证书
certbot renew

# 续期演练
certbot renew --dry-run

# 续期并指定钩子
certbot renew --deploy-hook "systemctl reload nginx"

# 强制续期(谨慎)
certbot renew --force-renewal

# 首次签发
certbot certonly --webroot --webroot-path /var/www/html -d example.com

# 泛域名(DNS 验证)
certbot certonly --manual --preferred-challenges dns -d "*.example.com"

# 吊销
certbot revoke --cert-path /etc/letsencrypt/live/example.com/cert.pem

# 删除证书
certbot delete --cert-name example.com

# 查看续期配置
cat /etc/letsencrypt/renewal/example.com.conf

certbot delete 的风险:会删除证书目录和续期配置,无法直接恢复,需要重新签发。删除前确认没有其他服务在用这个证书:

   bash
sudo nginx -T 2>/dev/null | grep -c "example.com"

6.4 日志位置

   bash
# certbot 主日志
sudo tail -100 /var/log/letsencrypt/letsencrypt.log

# 只看错误
sudo grep -iE "error|fail|traceback" /var/log/letsencrypt/letsencrypt.log | tail -30

# 续期钩子日志(自定义的)
sudo tail -50 /var/log/letsencrypt/deploy-hook.log

# systemd timer 日志
sudo journalctl -u certbot-renew.service --since "24 hours ago" --no-pager

# timer 状态
sudo systemctl list-timers certbot-renew.timer
sudo systemctl status certbot-renew.timer

# cron 日志
sudo grep -i certbot /var/log/cron
sudo grep -i CRON /var/log/syslog | grep -i certbot

关于日志轮转:/var/log/letsencrypt/letsencrypt.log 会持续增长。检查是否已有 logrotate 配置:

   bash
ls -la /etc/logrotate.d/ | grep -i certbot
cat /etc/logrotate.d/certbot 2>/dev/null

如果没有,需要补上:

   
/var/log/letsencrypt/*.log {
    weekly
    rotate 12
    compress
    delaycompress
    missingok
    notifempty
    create 0600 root root
}

6.5 Nginx 相关

   bash
# 配置语法检查
sudo nginx -t

# 打印完整配置(含 include)
sudo nginx -T

# 只看 ssl 相关
sudo nginx -T 2>/dev/null | grep -E "ssl_certificate|ssl_protocols|server_name"

# reload(不断连接)
sudo systemctl reload nginx

# restart(断连接)
sudo systemctl restart nginx

# 是否支持 reload
systemctl show nginx -p CanReload

# 查看 worker 进程启动时间(验证 reload 生效)
ps -eo pid,lstart,cmd | grep 'nginx: worker'

6.6 监控指标查询

   promql
# 剩余天数
(probe_ssl_earliest_cert_expiry - time()) / 86400

# 按目标排序,找出最紧急的
sort_desc(
  (probe_ssl_earliest_cert_expiry{job="blackbox-https"} - time()) / 86400
)

# 已经过期的目标
probe_ssl_earliest_cert_expiry - time() < 0

# 探测失败的目标
up{job="blackbox-https"} == 0

# 所有目标的证书剩余天数(用于大盘)
(probe_ssl_earliest_cert_expiry - time()) / 86400 < 30

七、配置示例

7.1 完整的 Nginx 多域名配置

   
/etc/nginx/conf.d/
├── example.com.conf
├── api.example.com.conf
└── acme-challenge.conf

/etc/nginx/conf.d/acme-challenge.conf(统一的验证入口):

   nginx
# 所有域名的 ACME 验证统一走这里
# 放在单独的 conf 文件中,便于统一管理
server {
    listen 80 default_server;
    listen [::]:80 default_server;
    server_name _;

    # 验证路径
    location ^~ /.well-known/acme-challenge/ {
        root /var/www/html;
        default_type "text/plain";
        allow all;
        access_log off;
    }

    # 其他请求返回 444(直接断开),避免暴露默认站
    location / {
        return 444;
    }
}

注意:default_server 只能在一个 server 块上设置。如果已有的配置里已经用了,需要合并。

return 444 的用意:Nginx 的 444 是特殊状态码,表示不返回任何响应直接关闭连接。对未知域名的访问用这个比返回 404 更干净,也不会暴露服务器信息。

风险:如果有健康检查或其他依赖默认站返回 200 的场景,改成 444 会破坏它们。改动前确认没有这类依赖。

/etc/nginx/conf.d/example.com.conf:

   nginx
# HTTP -> HTTPS 跳转(保留验证路径)
server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;

    location ^~ /.well-known/acme-challenge/ {
        root /var/www/html;
        allow all;
        access_log off;
    }

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

# HTTPS
server {
    listen 443 ssl;
    listen [::]:443 ssl;
    server_name example.com www.example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    # 协议与加密套件
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
    ssl_prefer_server_ciphers off;

    # 会话
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 1d;
    ssl_session_tickets off;

    # HSTS:首次上线先用短值
    add_header Strict-Transport-Security "max-age=300" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    access_log /var/log/nginx/example.com.access.log main;
    error_log  /var/log/nginx/example.com.error.log warn;

    root /var/www/example.com;
    index index.html;

    location / {
        try_files $uri $uri/ =404;
    }

    location /api/ {
        proxy_pass http://127.0.0.1:8080;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 60s;
        proxy_connect_timeout 5s;
    }
}

关于 http2 指令:上面的配置没有加 HTTP/2。如果 Nginx 版本是 1.25.1 及以上:

   nginx
listen 443 ssl;
http2 on;

如果是 1.25.1 以下:

   nginx
listen 443 ssl http2;

用 nginx -v 确认版本后再写,两种写法在错误的版本上会直接报配置错误。

7.2 泛域名的 DNS 验证配置

以 certbot 的 DNS 插件为例(不同 DNS 服务商的插件名不同):

   bash
# 凭据文件,权限必须 0600
# /etc/letsencrypt/dns-credentials.ini
sudo tee /etc/letsencrypt/dns-credentials.ini > /dev/null <<'EOF'
dns_example_api_key = REPLACE_WITH_ACTUAL_KEY
dns_example_api_secret = REPLACE_WITH_ACTUAL_SECRET
EOF

sudo chmod 0600 /etc/letsencrypt/dns-credentials.ini
sudo chown root:root /etc/letsencrypt/dns-credentials.ini

注意:上面用的是 heredoc 占位符,实际使用时必须替换为真实凭据。不要把真实凭据写进任何会提交到 Git 的文件。

   bash
sudo certbot certonly \
  --dns-example \
  --dns-example-credentials /etc/letsencrypt/dns-credentials.ini \
  -d "example.com" \
  -d "*.example.com" \
  --email ops@example.com \
  --agree-tos \
  --no-eff-email

插件名的说明:--dns-example 是占位写法。实际插件名取决于 DNS 服务商,例如 --dns-cloudflare、--dns-aliyun、--dns-route53 等。需要用 certbot plugins 查看已安装的插件,或 pip install certbot-dns-<provider> 安装。

DNS 验证的优势:

  • 支持泛域名。
  • 不需要 80 端口对外开放。
  • 不需要公网 DNS 解析到本机。

DNS 验证的挑战:

  • 需要 DNS API 凭据,凭据泄露意味着域名被劫持的风险。凭据必须严格管控,用最小权限的子账号,只授权特定的 zone。
  • DNS 记录的生效有延迟,插件需要等待传播。
  • 不同服务商的 API 限制不同,可能触发限流。

7.3 acme.sh 方案(不依赖 Python)

   bash
# 安装
curl https://get.acme.sh | sh -s email=ops@example.com

# 设置默认 CA(Let's Encrypt)
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt

# webroot 方式签发
~/.acme.sh/acme.sh --issue \
  -d example.com \
  -d www.example.com \
  -w /var/www/html

# 安装证书到指定位置(会自动配置续期)
~/.acme.sh/acme.sh --install-cert -d example.com \
  --key-file       /etc/nginx/ssl/example.com.key \
  --fullchain-file /etc/nginx/ssl/example.com.crt \
  --reloadcmd      "systemctl reload nginx"

# 查看已签发的证书
~/.acme.sh/acme.sh --list

acme.sh 的特点:

  • 纯 Shell 实现,不需要 Python。
  • 支持 100 多个 DNS 服务商的 API。
  • 安装证书时会自动创建续期配置,--reloadcmd 就是自动 reload 的钩子。
  • 自带 cron 任务,安装时自动配置。

使用 acme.sh 的注意点:

  • 证书默认存放在 ~/.acme.sh/ 下,用 --install-cert 复制到目标位置。不要让 Nginx 直接引用 ~/.acme.sh 下的文件,因为该目录结构可能变化。
  • 默认 CA 可能是 ZeroSSL 而不是 Let’s Encrypt,需要用 --set-default-ca 显式指定。
  • 升级时用 acme.sh --upgrade,升级后可能需要重新安装 cron 任务。

7.4 Kubernetes cert-manager

前提:集群已安装 cert-manager(安装方式见官方文档,本文不展开)。

ClusterIssuer(用 HTTP-01 验证):

   yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: ops@example.com
    privateKeySecretRef:
      name: letsencrypt-prod-account-key
    solvers:
      - http01:
          ingress:
            class: nginx

测试用 Issuer(staging 环境,有更高的速率限制):

   yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-staging
spec:
  acme:
    server: https://acme-staging-v02.api.letsencrypt.org/directory
    email: ops@example.com
    privateKeySecretRef:
      name: letsencrypt-staging-account-key
    solvers:
      - http01:
          ingress:
            class: nginx

Ingress 引用:

   yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: web-api
  namespace: prod
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
  ingressClassName: nginx
  tls:
    - hosts:
        - example.com
      secretName: example-com-tls     # cert-manager 会自动创建这个 Secret
  rules:
    - host: example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: web-api
                port:
                  number: 80

cert-manager 的排查命令:

   bash
# 查看证书状态
kubectl get certificate -n prod
kubectl describe certificate example-com -n prod

# 查看中间资源
kubectl get certificaterequest,order,challenge -n prod

# 查看 Secret
kubectl get secret example-com-tls -n prod
kubectl get secret example-com-tls -n prod -o jsonpath='{.data.tls\.crt}' | base64 -d | openssl x509 -noout -dates

# 查看 cert-manager 日志
kubectl -n cert-manager logs -l app.kubernetes.io/name=cert-manager --tail=200
kubectl -n cert-manager logs -l app.kubernetes.io/component=webhook --tail=100

Kubernetes 场景下的到期监控:

   promql
# cert-manager 提供的指标
certmanager_certificate_expiration_timestamp_seconds - time()

# 按证书排序
sort_desc(
  (certmanager_certificate_expiration_timestamp_seconds - time()) / 86400
)

# 证书未就绪的
certmanager_certificate_ready_status{condition="False"} == 1

指标名说明:以上指标名以实际 cert-manager 版本为准,不同版本可能调整。部署后用 kubectl -n cert-manager port-forward svc/cert-manager 9402 然后 curl localhost:9402/metrics | grep expir 确认实际指标名。

cert-manager 的注意事项:

  • HTTP-01 验证需要 Ingress Controller 能收到来自 Let’s Encrypt 的请求,即 Ingress 的 80 端口必须公网可达。
  • staging 环境的证书不被浏览器信任,只能用于验证流程,不要用到生产。
  • certmanager_certificate_ready_status 为 False 表示签发或续期失败,这是需要立即关注的告警。
  • cert-manager 默认在证书剩余 30 天时续期(可通过 spec.renewBefore 调整)。

八、日志与指标观察方法

8.1 certbot 日志分析

   bash
# 续期成功的记录
sudo grep -E "Congratulations|successfully|Renewing an existing" \
  /var/log/letsencrypt/letsencrypt.log | tail -20

# 未到期的记录(正常,说明流程在跑)
sudo grep -c "not yet due for renewal" /var/log/letsencrypt/letsencrypt.log

# 失败记录
sudo grep -E "Failed|Error|error" /var/log/letsencrypt/letsencrypt.log | tail -30

# 验证失败的具体原因
sudo grep -A5 -B5 "Invalid response\|Timeout during connect\|DNS problem" \
  /var/log/letsencrypt/letsencrypt.log

# 按日期分割查看
sudo grep "$(date +%Y-%m-%d)" /var/log/letsencrypt/letsencrypt.log | tail -50

判断标准:

日志内容含义是否需要处理
not yet due for renewal证书未到期,跳过否,这是正常状态
Congratulations, all renewals succeeded续期成功否
Failed to renew certificate续期失败是,立即排查
Invalid response from验证失败是,检查 webroot 或 DNS
Timeout during connect网络问题是,检查防火墙
Too many certificates already issued速率限制是,等待或减少重试

"平时一直在跑"的证据:not yet due for renewal 的记录应该每天都在增加。如果这个计数突然停止增长,说明定时任务挂了,需要检查 timer/cron。这是一个很有效的"续期流程是否还活着"的判断依据。

8.2 关键指标

指标来源含义异常判断
probe_ssl_earliest_cert_expiryblackbox_exporter证书最早到期时间戳与当前时间差 < 21 天告警
probe_successblackbox_exporter探测是否成功为 0 表示探测失败
up{job="blackbox-https"}Prometheus目标是否可采集为 0 表示 exporter 或网络问题
certmanager_certificate_expiration_timestamp_secondscert-managerK8s 证书到期时间同上
certmanager_certificate_ready_statuscert-manager证书是否就绪为 0 表示未就绪
probe_ssl_last_chain_expiry_timestamp_secondsblackbox_exporter链中最后一个证书的到期时间关注根证书到期

重要提醒:指标名以实际部署的 exporter 版本为准。blackbox_exporter 在不同版本中提供 probe_ssl_earliest_cert_expiry 和 probe_ssl_last_chain_expiry_timestamp_seconds 等指标,具体可用哪些需要用 curl 直接探测确认:

   bash
curl -s "http://localhost:9115/probe?target=https://example.com&module=https_cert" \
  | grep -E "^probe_ssl"

8.3 告警设计

分级告警:

   yaml
groups:
  - name: ssl-cert-lifecycle
    interval: 5m
    rules:
      - alert: SSLCertRenewalFailed
        # certbot 续期失败(需要通过文本文件或 pushgateway 上报)
        expr: cert_renewal_last_success_timestamp_seconds < time() - 7 * 86400
        for: 1h
        labels:
          severity: critical
        annotations:
          summary: "证书续期已 7 天未成功"
          description: "上一次成功续期在 {{ $value | humanizeDuration }} 前,检查 certbot 日志"

      - alert: SSLCertLocalNewerThanRemote
        # 本地证书比线上新,说明服务没重载
        expr: cert_local_expiry_timestamp_seconds - cert_remote_expiry_timestamp_seconds > 3600
        for: 30m
        labels:
          severity: critical
        annotations:
          summary: "证书已续期但服务未加载新证书"
          description: "{{ $labels.instance }} 本地证书比线上新,检查 deploy 钩子"

      - alert: SSLCertExpiringSoon
        expr: (probe_ssl_earliest_cert_expiry - time()) / 86400 < 21
        for: 6h
        labels:
          severity: warning
        annotations:
          summary: "证书 {{ $labels.instance }} 将在 {{ $value | printf \"%.0f\" }} 天后过期"

      - alert: SSLCertExpiringCritical
        expr: (probe_ssl_earliest_cert_expiry - time()) / 86400 < 7
        for: 1h
        labels:
          severity: critical
        annotations:
          summary: "证书 {{ $labels.instance }} 将在 {{ $value | printf \"%.0f\" }} 天后过期"

关于 cert_renewal_last_success_timestamp_seconds:这是一个假设的自定义指标。实现方式有几种:

  • 在续期钩子里把时间戳写入文件,用 node_exporter 的 textfile collector 暴露。
  • 用 Pushgateway 上报。

用 textfile collector 的实现:

   bash
# 在 deploy 钩子里追加
cat > /var/lib/node_exporter/textfile/cert_renewal.prom <<EOF
# HELP cert_renewal_last_success_timestamp_seconds 上次成功续期的时间戳
# TYPE cert_renewal_last_success_timestamp_seconds gauge
cert_renewal_last_success_timestamp_seconds $(date +%s)
EOF

注意:textfile collector 需要 node_exporter 以 --collector.textfile.directory=/var/lib/node_exporter/textfile 启动。写入用的临时文件必须先写再 mv(原子操作),否则 node_exporter 可能读到写了一半的内容:

   bash
TMP=$(mktemp)
cat > "${TMP}" <<EOF
# HELP cert_renewal_last_success_timestamp_seconds Last successful renewal
# TYPE cert_renewal_last_success_timestamp_seconds gauge
cert_renewal_last_success_timestamp_seconds $(date +%s)
EOF

mv "${TMP}" /var/lib/node_exporter/textfile/cert_renewal.prom

阈值必须结合实际情况调整:

  • 用 Let’s Encrypt(90 天有效,30 天开始续期)→ warning 21 天合理。
  • 用商业证书(1 年有效,人工续期流程需要提前 1 个月)→ warning 应设在 45-60 天。
  • 内网自签证书(有效期可能很长)→ 阈值要根据内部流程定。
  • 不要直接复制别人的阈值。判断依据是"从告警到你能完成处理需要多久",再乘以 2。

8.4 日志位置汇总

内容路径
certbot 主日志/var/log/letsencrypt/letsencrypt.log
续期钩子日志(示例)/var/log/letsencrypt/deploy-hook.log
到期检查日志(示例)/var/log/cert-expiry.log
Nginx 错误日志/var/log/nginx/error.log
Nginx 访问日志/var/log/nginx/access.log
systemd 单元日志journalctl -u certbot-renew.service
cron 日志/var/log/cron 或 /var/log/syslog

九、排查路径

9.1 证书已过期,服务不可用

紧急程度最高,先恢复服务再分析。

   
1. 确认现状
   echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
     | openssl x509 -noout -dates
   
2. 立即手动签发(这是应急手段)
   sudo certbot certonly --webroot --webroot-path /var/www/html \
     -d example.com -d www.example.com --force-renewal
   
   如果签发失败,走 9.2 的排查

3. 重载服务
   sudo nginx -t && sudo systemctl reload nginx

4. 验证
   curl -vI https://example.com 2>&1 | grep -E "expire date|SSL certificate"
   
5. 服务恢复后再分析为什么自动续期没工作

如果签发也失败:临时方案是使用一个未过期的备选证书(其他域名的证书,虽然会有域名不匹配警告),或者临时降级到 HTTP(视业务重要性)。降级到 HTTP 会暴露明文流量,只作为最后手段。

9.2 续期失败

   
1. 看 certbot 日志的具体错误
   sudo tail -200 /var/log/letsencrypt/letsencrypt.log

2. 按错误类型分流

   ├─ "Invalid response from http://.../.well-known/acme-challenge/..."
   │   ├─ 返回 404 → webroot 路径不对
   │   │   a. 确认 nginx 配置里的 root 路径
   │   │   b. 确认 --webroot-path 一致
   │   │   c. 手动创建测试文件验证可访问
   │   │      echo test > /var/www/html/.well-known/acme-challenge/test
   │   │      curl http://example.com/.well-known/acme-challenge/test
   │   ├─ 返回 301/302 → 80 端口跳转了
   │   │   → 给验证路径加例外,放在跳转规则之前
   │   └─ 返回 403 → 权限问题
   │       → 检查目录权限、SELinux
   │
   ├─ "Timeout during connect"
   │   ├─ 本机 80 端口没监听 → ss -lntp | grep :80
   │   ├─ 防火墙拦截 → firewall-cmd --list-all / iptables -L
   │   ├─ 云安全组未放行 → 控制台确认
   │   └─ DNS 解析错误 → dig +short example.com
   │
   ├─ "DNS problem: NXDOMAIN"
   │   └─ 域名解析不到,检查 DNS 记录
   │
   ├─ "Too many certificates already issued"
   │   └─ 触发速率限制
   │       a. 停止重试
   │       b. 用 --dry-run 测试
   │       c. 查阅当前速率限制,等待重置
   │
   ├─ "Client with the currently selected authenticator does not support any combination"
   │   └─ 通配符域名必须用 DNS 验证,不能 HTTP-01
   │
   └─ "Problem binding to port 80: Could not bind"
       └─ 80 被其他进程占用,改用 --webroot 或先停服务

手动验证 webroot 路径是否正确的完整流程:

   bash
# 1. 创建测试文件
sudo mkdir -p /var/www/html/.well-known/acme-challenge
echo "test-content-$(date +%s)" | sudo tee /var/www/html/.well-known/acme-challenge/testfile

# 2. 本地访问
curl -s http://127.0.0.1/.well-known/acme-challenge/testfile

# 3. 从外部访问(关键,本地能访问不代表外部能)
curl -s http://example.com/.well-known/acme-challenge/testfile

# 4. 检查返回的是不是重定向
curl -sI http://example.com/.well-known/acme-challenge/testfile | head -5

# 5. 清理
sudo rm -f /var/www/html/.well-known/acme-challenge/testfile

第 3 步是关键。本地 127.0.0.1 访问成功只说明 Nginx 配置对,不代表防火墙和 DNS 都对。ACME 服务器是从公网访问的,必须测公网路径。

9.3 续期成功但服务仍用旧证书

这是最隐蔽的问题,因为"续期成功"的日志看起来一切正常。

   
1. 对比本地文件和线上证书
   LOCAL=$(openssl x509 -in /etc/letsencrypt/live/example.com/fullchain.pem -noout -enddate)
   REMOTE=$(echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
     | openssl x509 -noout -enddate)
   echo "本地: ${LOCAL}"
   echo "线上: ${REMOTE}"

2. 如果本地更新、线上是旧的
   ├─ 检查 deploy 钩子是否存在
   │   ls -la /etc/letsencrypt/renewal-hooks/deploy/
   ├─ 检查钩子是否有执行权限
   │   ls -l /etc/letsencrypt/renewal-hooks/deploy/*.sh
   ├─ 检查钩子日志有没有报错
   │   cat /var/log/letsencrypt/deploy-hook.log
   ├─ 手动执行钩子验证
   │   sudo RENEWED_DOMAINS=example.com \
   │        RENEWED_LINEAGE=/etc/letsencrypt/live/example.com \
   │        /etc/letsencrypt/renewal-hooks/deploy/10-reload-nginx.sh
   └─ 检查 Nginx 的 reload 是否真的成功
       sudo nginx -t && sudo systemctl reload nginx
       systemctl is-active nginx

3. 如果重载后仍然用旧证书
   ├─ Nginx 配置指向了错误的文件路径
   │   sudo nginx -T 2>/dev/null | grep ssl_certificate
   ├─ 有多个 server 块占用同一域名,实际生效的是另一个
   │   sudo nginx -T 2>/dev/null | grep -A5 "server_name example.com"
   ├─ 前面有 CDN 或负载均衡,证书在那里终止
   │   dig +short example.com        # 看解析到哪
   │   curl -vI https://example.com 2>&1 | grep -i server
   └─ 有多个 Nginx 进程(多实例)
       ps -ef | grep nginx

CDN 场景的判断:如果 dig 解析到的 IP 不是你服务器的 IP,说明前面有 CDN 或负载均衡。此时证书是在 CDN 上终止的,你服务器上的证书更新了但用户看到的还是 CDN 的证书。这种情况需要在 CDN 控制台更新证书,服务器上的 certbot 意义有限。

   bash
# 判断是否有 CDN
curl -sI https://example.com | grep -iE "server|via|x-cache|x-served-by"

如果返回 Server: cloudflare、X-Cache: HIT 之类的头,说明有 CDN。

9.4 证书链不完整

   
1. 检查服务端返回的链
   echo | openssl s_client -connect example.com:443 -servername example.com -showcerts 2>/dev/null \
     | grep -E "^ *[0-9]+ s:|^ *[0-9]+ i:"

2. 只有 1 级 → 中间证书没发出去
   ├─ Nginx 配置用了 cert.pem 而不是 fullchain.pem
   │   sudo nginx -T 2>/dev/null | grep ssl_certificate
   └─ 修正后 reload

3. 验证修正效果
   echo | openssl s_client -connect example.com:443 -servername example.com 2>&1 \
     | grep "Verify return code"
   # 期望:Verify return code: 0 (ok)

4. 用在线工具交叉验证(可选)
   # 各种 SSL 检测服务的结论仅作参考,
   # 以本地 openssl 的输出为准

cert.pem 与 fullchain.pem 的区别:

   bash
# cert.pem 只有一级
openssl x509 -in /etc/letsencrypt/live/example.com/cert.pem -noout -subject
# subject=CN = example.com

# fullchain.pem 包含多级
grep -c "BEGIN CERTIFICATE" /etc/letsencrypt/live/example.com/fullchain.pem
# 2(或更多)

9.5 部分客户端报错

   
1. 确认是哪些客户端
   - 特定浏览器版本?
   - 特定操作系统(老版本 Android)?
   - 特定语言/框架(Java 客户端)?

2. 常见原因
   ├─ 证书链不完整 → 走 9.4
   ├─ 用了老旧的加密套件
   │   printf 'TLS1.1: '; echo | openssl s_client -connect example.com:443 -tls1_1 2>&1 | grep -c "Cipher is"
   ├─ 证书的 SAN 不包含访问的域名
   │   openssl x509 -in cert.pem -noout -ext subjectAltName
   └─ 客户端不支持 SNI(很老的客户端)
       → 配置默认证书

3. 检查 SAN 是否覆盖所有访问入口
   openssl x509 -in /etc/letsencrypt/live/example.com/fullchain.pem -noout -ext subjectAltName

注意:-ext subjectAltName 参数需要 OpenSSL 1.1.1 及以上。老版本用:

   bash
openssl x509 -in cert.pem -noout -text | grep -A1 "Subject Alternative Name"

十、风险提醒

10.1 高风险:让证书过期

这看起来是最基本的问题,但它是生产环境最常见的 HTTPS 故障。

影响范围:所有走该域名的 HTTPS 流量,包括 API、移动端、支付回调。浏览器会显示安全警告,大量客户端会直接拒绝连接。

预防措施:

  1. 监控必须覆盖所有域名,包括不常用的二级域名、内部 API 域名。
  2. 至少两个独立的告警通道(邮件 + 即时消息)。
  3. 告警要有明确的处理人和处理时限。
  4. 定期演练:每季度手动验证一次到期告警能不能触发。

关于监控覆盖的检查:

   bash
# 找出 Nginx 配置里所有的域名
sudo nginx -T 2>/dev/null | grep -E '^\s*server_name' | \
  sed 's/^\s*server_name\s*//;s/;$//' | tr ' ' '\n' | \
  grep -vE '^$|^_$' | sort -u

# 对比监控目标列表,找出遗漏

10.2 高风险:reload 失败导致服务中断

场景:deploy 钩子里的 nginx -t 通过了,但 reload 时因为其他原因失败。

为什么 reload 会失败:

  • 磁盘空间不足,无法写新的临时文件。
  • 端口被占用(有新的 listen 指令指向已占用的端口)。
  • 证书文件权限变化。
  • worker 进程数配置超过系统限制。

防护:

   bash
# 在 reload 前检查关键资源
df -h / /etc /var | awk 'NR==1 || $5+0 > 80'

# reload 后立即确认
if ! systemctl reload nginx; then
  echo "reload 失败" >&2
  systemctl status nginx --no-pager
  exit 1
fi

# 确认进程还在
sleep 1
if ! systemctl is-active --quiet nginx; then
  echo "nginx 未运行,需要回滚" >&2
  exit 2
fi

注意:systemctl reload 返回 0 不代表服务一定正常。某些情况下 reload 命令成功但服务实际上有问题。必须额外验证服务状态和端口监听。

关于 worker 进程数限制:如果 worker_processes 设得很大(比如 auto 在 128 核机器上是 128),reload 时会同时创建大量新进程,可能触发 nproc 限制。判断方式:

   bash
ulimit -u          # 当前用户的最大进程数
ulimit -H -u       # 硬限制
nproc              # CPU 核数

10.3 中风险:修改 Nginx 配置导致服务异常

   bash
sudo nginx -t
# nginx: [emerg] duplicate listen options for 0.0.0.0:443

执行前必须:

  1. 备份配置:
   bash
sudo cp -a /etc/nginx /etc/nginx.bak-$(date +%Y%m%d%H%M%S)
  1. nginx -t 必须在 reload 前执行。
  2. 变更窗口内操作,最好有人在旁边盯监控。

回滚方式:

   bash
# 从备份恢复
sudo rsync -a --delete /etc/nginx.bak-20261010143000/ /etc/nginx/
sudo nginx -t && sudo systemctl reload nginx

rsync --delete 的风险:会删除目标目录中不在源里的文件。必须确认备份目录的路径没有写错,从 /etc/nginx.bak-xxx/ 恢复到 /etc/nginx/ 是正确方向;反过来写会清空备份。

10.4 中风险:HSTS 配置错误

   nginx
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;

影响范围:一旦客户端收到这个头,在一年内访问该域名(包括所有子域)都会强制 HTTPS,无法通过浏览器界面绕过。

风险场景:

  • 某个子域没有配 HTTPS,加上 includeSubDomains 后该子域完全无法访问。
  • HTTPS 证书出问题,用户无法通过"继续访问"绕过。
  • preload 提交到浏览器预加载列表后,几乎不可能撤销(撤销流程需要数月)。

安全实施顺序:

   nginx
# 第一步:短时间测试
add_header Strict-Transport-Security "max-age=300" always;

# 第二步:确认所有子域都支持 HTTPS 后,延长
add_header Strict-Transport-Security "max-age=86400" always;

# 第三步:稳定运行一个月后,再加 includeSubDomains
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

# preload 只在完全确定后使用,且要理解撤销成本

回滚:HSTS 无法通过服务器端"撤销"。移除响应头后,已经收到过 HSTS 的客户端在 max-age 到期前仍会强制 HTTPS。唯一的缓解方式是等 max-age 到期。这就是为什么初始值要设短。

10.5 中风险:DNS API 凭据泄露

泛域名使用 DNS 验证时,需要在服务器上保存 DNS 服务商的 API 凭据。

凭据泄露的后果:攻击者可以修改你的 DNS 记录,把域名指向任意 IP,实现中间人攻击或完全劫持。

防护措施:

   bash
# 凭据文件权限必须是 0600
sudo chmod 0600 /etc/letsencrypt/dns-credentials.ini
sudo chown root:root /etc/letsencrypt/dns-credentials.ini

# 确认没有其他用户可读
sudo ls -la /etc/letsencrypt/dns-credentials.ini
# -rw------- 1 root root ...

更重要的措施:

  • 使用 DNS 服务商的子账号,只授权特定的 zone,且只授予 DNS 编辑权限(不要账户管理权限)。
  • 开启 DNS 变更的通知和审计日志。
  • 定期轮换凭据。
  • 不要把凭据放在会提交到 Git 的目录里。

检查是否误提交到 Git:

   bash
cd /path/to/repo
git log --all --full-history -- '*credentials*' '*dns*.ini' '*vault*'
grep -rE "api_key|api_secret|token" --include="*.yml" --include="*.ini" . 2>/dev/null | head

如果发现凭据曾提交到 Git,立即在服务商处轮换凭据,而不只是删除文件——Git 历史中仍然保留着。

10.6 低风险但需注意:certbot 的自动修改配置

使用 --nginx 或 --apache 插件时,certbot 会自动修改你的 Web 服务器配置:

  • 添加 ssl_certificate 指令。
  • 添加 80 到 443 的跳转。
  • 可能调整 server_name。

问题:如果你的配置是通过配置管理工具(Ansible、Puppet)下发的,certbot 的修改会在下一次配置下发时被覆盖,导致 HTTPS 配置丢失。

建议:

  • 用 certonly 而不是 --nginx,配置由你手动管理。
  • 如果用了 --nginx,把 certbot 修改后的配置提取出来,纳入配置管理。

检查 certbot 是否修改过配置:

   bash
sudo grep -rn "certbot\|acme" /etc/nginx/ 2>/dev/null
sudo ls -la /etc/nginx/conf.d/ | grep -i ssl

关于 # managed by Certbot 注释:certbot 修改的配置行后面会加这个注释。看到它就说明被改过。

10.7 敏感信息处理

证书相关的敏感信息包括:私钥、DNS API 凭据、ACME 账户密钥。

规则:

  • 私钥永远不离开服务器。privkey.pem 的权限是 0600,属主 root。
  • 不要把私钥复制到 Git、共享目录、聊天工具。
  • ACME 账户密钥(/etc/letsencrypt/accounts/)也应该保护,它关联着你的账户身份。
  • 备份证书时要加密。如果需要备份私钥,用 gpg 或 openssl enc 加密:
   bash
# 加密备份(会提示输入密码)
tar -czf - /etc/letsencrypt/live /etc/letsencrypt/archive \
  | openssl enc -aes-256-cbc -pbkdf2 -salt -out /backup/letsencrypt-$(date +%Y%m%d).tar.gz.enc

# 恢复
openssl enc -d -aes-256-cbc -pbkdf2 -in /backup/letsencrypt-20261010.tar.gz.enc | tar -xzf -

风险提醒:

  • openssl enc 的密码强度直接决定安全性,用强密码。
  • 某些旧版本 openssl enc 的默认 KDF 有弱点,-pbkdf2 参数能显著改善。
  • 加密文件的密码不要和加密文件放在同一台机器上。

检查私钥权限:

   bash
sudo find /etc/letsencrypt -name "privkey*.pem" -exec ls -la {} \;
# 应该是 -rw------- 1 root root

如果发现权限过宽,立即修正:

   bash
sudo chmod 600 /etc/letsencrypt/archive/*/privkey*.pem
sudo chmod 700 /etc/letsencrypt/live /etc/letsencrypt/archive

十一、验证方式

11.1 分层验证清单

   bash
DOMAIN=example.com
CERT_DIR=/etc/letsencrypt/live/${DOMAIN}

echo "=== 第 1 层:证书文件存在且可读 ==="
sudo test -r "${CERT_DIR}/fullchain.pem" && echo "OK" || echo "FAIL: fullchain.pem 不可读"
sudo test -r "${CERT_DIR}/privkey.pem" && echo "OK" || echo "FAIL: privkey.pem 不可读"

echo "=== 第 2 层:私钥权限正确 ==="
PERM=$(sudo stat -c '%a' "${CERT_DIR}/privkey.pem")
[[ "${PERM}" == "600" ]] && echo "OK (600)" || echo "WARN: 权限为 ${PERM},应为 600"

echo "=== 第 3 层:证书未过期 ==="
END=$(sudo openssl x509 -in "${CERT_DIR}/fullchain.pem" -noout -enddate | cut -d= -f2)
DAYS=$(( ($(date -d "${END}" +%s) - $(date +%s)) / 86400 ))
echo "剩余 ${DAYS} 天"
[[ ${DAYS} -gt 21 ]] && echo "OK" || echo "WARN: 剩余不足 21 天"

echo "=== 第 4 层:证书链完整 ==="
COUNT=$(sudo grep -c "BEGIN CERTIFICATE" "${CERT_DIR}/fullchain.pem")
[[ ${COUNT} -ge 2 ]] && echo "OK (${COUNT} 个证书)" || echo "FAIL: 链不完整"

echo "=== 第 5 层:私钥与证书匹配 ==="
CERT_MOD=$(sudo openssl x509 -in "${CERT_DIR}/fullchain.pem" -noout -modulus | openssl md5)
KEY_MOD=$(sudo openssl rsa -in "${CERT_DIR}/privkey.pem" -noout -modulus 2>/dev/null | openssl md5)
[[ "${CERT_MOD}" == "${KEY_MOD}" ]] && echo "OK" || echo "FAIL: 私钥与证书不匹配"

echo "=== 第 6 层:Nginx 配置引用正确 ==="
sudo nginx -T 2>/dev/null | grep -A1 "server_name ${DOMAIN}" | grep fullchain.pem \
  && echo "OK" || echo "WARN: 未找到 ${DOMAIN} 的证书引用"

echo "=== 第 7 层:Nginx 配置语法 ==="
sudo nginx -t 2>&1 | tail -1

echo "=== 第 8 层:服务运行中 ==="
sudo nginx -v 2>&1
systemctl is-active nginx

第 5 层的说明:openssl rsa -modulus 对 RSA 密钥有效。如果用的是 ECDSA 密钥,这个命令会失败。ECDSA 的检查方式:

   bash
# 从证书提取公钥
sudo openssl x509 -in "${CERT_DIR}/fullchain.pem" -noout -pubkey > /tmp/cert-pub.pem
# 从私钥提取公钥
sudo openssl pkey -in "${CERT_DIR}/privkey.pem" -pubout > /tmp/key-pub.pem
# 对比
diff /tmp/cert-pub.pem /tmp/key-pub.pem && echo "匹配" || echo "不匹配"
sudo rm -f /tmp/cert-pub.pem /tmp/key-pub.pem

清理临时文件的重要性:从私钥导出的公钥虽然本身不是秘密,但临时文件放错位置仍有风险。用完立即删除。注意上面命令里的 rm 是必要的,不要省略。

11.2 外部验证

注意:以下验证应该从集群外的机器执行,模拟真实用户视角。

   bash
DOMAIN=example.com

# 1. 基本 HTTPS 访问
curl -s -o /dev/null -w 'HTTP %{http_code}, TLS %{ssl_verify_result}, %{time_total}s\n' \
  --max-time 10 "https://${DOMAIN}/"

# ssl_verify_result 为 0 表示验证通过

# 2. 证书信息
echo | openssl s_client -connect "${DOMAIN}:443" -servername "${DOMAIN}" 2>/dev/null \
  | openssl x509 -noout -subject -issuer -dates

# 3. 链验证结果
echo | openssl s_client -connect "${DOMAIN}:443" -servername "${DOMAIN}" 2>&1 \
  | grep -E "Verify return code"

# 4. 链的层级
echo | openssl s_client -connect "${DOMAIN}:443" -servername "${DOMAIN}" -showcerts 2>/dev/null \
  | grep -c "BEGIN CERTIFICATE"

# 5. HTTP 跳转
curl -sI "http://${DOMAIN}/" | head -3

# 6. 验证路径可达(不影响业务)
curl -s -o /dev/null -w '%{http_code}\n' "http://${DOMAIN}/.well-known/acme-challenge/nonexistent"
# 期望 404,不是 301

第 6 步的期望值是 404。返回 404 说明请求到达了正确的 location 并且文件不存在;返回 301 说明跳转规则覆盖了验证路径,需要修改 Nginx 配置。

11.3 续期流程验证

   bash
# 1. 定时任务已启用
sudo systemctl list-timers certbot-renew.timer
sudo systemctl is-enabled certbot-renew.timer

# 2. 上次执行记录
sudo journalctl -u certbot-renew.service --since "7 days ago" --no-pager | tail -30

# 3. dry-run 通过
sudo certbot renew --dry-run 2>&1 | tail -20

# 4. deploy 钩子存在且可执行
sudo ls -la /etc/letsencrypt/renewal-hooks/deploy/

# 5. 钩子能正常执行
sudo RENEWED_DOMAINS=example.com \
     RENEWED_LINEAGE=/etc/letsencrypt/live/example.com \
     /etc/letsencrypt/renewal-hooks/deploy/10-reload-nginx.sh
echo "钩子退出码: $?"

# 6. reload 真的生效了
sudo nginx -t && sudo systemctl is-active nginx

# 7. 续期配置文件正确
sudo cat /etc/letsencrypt/renewal/example.com.conf

第 7 步要重点看的内容:

   ini
[renewalparams]
authenticator = webroot
webroot_path = /var/www/html,
account = xxxxxxxx
server = https://acme-v02.api.letsencrypt.org/directory

关键检查点:

  • webroot_path 是否与实际路径一致。
  • server 是正式环境还是 staging。如果不小心用了 staging 的地址,签发的证书不被信任。
  • account 是否存在。

11.4 端到端演练(推荐每季度一次)

目的:验证从告警到恢复的完整链路。

   bash
#!/bin/bash
# 用途:证书续期端到端演练
# 注意事项:这个脚本会在测试域名上实际操作,不要在生产域名上跑
set -euo pipefail

TEST_DOMAIN="${1:?用法: $0 <测试域名>}"
WEBROOT="${2:-/var/www/html}"

echo "=== 演练开始: $(date) ==="
echo "测试域名: ${TEST_DOMAIN}"

echo "--- 步骤 1: 检查当前证书 ---"
if sudo certbot certificates 2>/dev/null | grep -q "${TEST_DOMAIN}"; then
  sudo certbot certificates 2>/dev/null | grep -A3 "${TEST_DOMAIN}"
else
  echo "测试域名没有现有证书,将直接签发"
fi

echo "--- 步骤 2: dry-run 验证 ---"
if sudo certbot certonly --webroot --webroot-path "${WEBROOT}" \
     -d "${TEST_DOMAIN}" --dry-run --non-interactive 2>&1 | tail -5; then
  echo "[PASS] dry-run 成功"
else
  echo "[FAIL] dry-run 失败,检查日志"
  exit 1
fi

echo "--- 步骤 3: 检查 deploy 钩子 ---"
HOOKS=$(sudo ls /etc/letsencrypt/renewal-hooks/deploy/ 2>/dev/null | wc -l)
if [[ "${HOOKS}" -gt 0 ]]; then
  echo "[PASS] 找到 ${HOOKS} 个 deploy 钩子"
  sudo ls -la /etc/letsencrypt/renewal-hooks/deploy/
else
  echo "[FAIL] 没有 deploy 钩子,续期后不会自动重载服务"
  exit 1
fi

echo "--- 步骤 4: 检查定时任务 ---"
if systemctl is-enabled certbot-renew.timer >/dev/null 2>&1; then
  echo "[PASS] systemd timer 已启用"
  systemctl list-timers certbot-renew.timer --no-pager
elif crontab -l 2>/dev/null | grep -q certbot || sudo grep -rq certbot /etc/cron.d/ 2>/dev/null; then
  echo "[PASS] cron 任务存在"
else
  echo "[FAIL] 未找到定时任务"
  exit 1
fi

echo "--- 步骤 5: 验证监控覆盖 ---"
echo "请手动确认以下内容:"
echo "  [ ] 监控系统包含 ${TEST_DOMAIN} 的到期探测"
echo "  [ ] 告警通道可正常送达"
echo "  [ ] 告警有明确的处理人"

echo "=== 演练结束: $(date) ==="

这个演练脚本的价值:它把"证书管理是否真的配好了"这个模糊的问题,变成了一组可执行的检查。每季度跑一次,能及早发现配置漂移(有人改了配置、某个钩子被删了、定时任务被禁用了)。


十二、回滚方案

12.1 恢复旧证书

certbot 会在 /etc/letsencrypt/archive/ 下保留历史版本:

   bash
sudo ls -la /etc/letsencrypt/archive/example.com/
# cert1.pem  cert2.pem  chain1.pem  chain2.pem  fullchain1.pem  fullchain2.pem  privkey1.pem  privkey2.pem

恢复方式一:改回符号链接

   bash
cd /etc/letsencrypt/live/example.com
sudo ln -sf ../../archive/example.com/fullchain1.pem fullchain.pem
sudo ln -sf ../../archive/example.com/privkey1.pem privkey.pem
sudo ln -sf ../../archive/example.com/cert1.pem cert.pem
sudo ln -sf ../../archive/example.com/chain1.pem chain.pem

sudo nginx -t && sudo systemctl reload nginx

风险提醒:不要直接删除 live/ 下的符号链接重建,先确认 archive/ 下的目标文件存在:

   bash
sudo ls -la /etc/letsencrypt/archive/example.com/fullchain1.pem

关于版本号的判断:数字大的更新。fullchain2.pem 比 fullchain1.pem 新。要回滚就选数字小的。

恢复方式二:certbot 内置

certbot 没有直接的"回滚"命令。如果需要回到某个版本,只能用上面的符号链接方式。

12.2 恢复 Nginx 配置

   bash
# 从备份恢复
sudo rm -rf /etc/nginx.backup-current
sudo cp -a /etc/nginx /etc/nginx.backup-current    # 先备份当前状态
sudo rm -rf /etc/nginx
sudo cp -a /etc/nginx.bak-20261010143000 /etc/nginx

# 验证并重载
sudo nginx -t
sudo systemctl reload nginx || sudo systemctl restart nginx

风险提醒:

  • rm -rf /etc/nginx 是危险操作。执行前确认备份目录存在且内容完整:
   bash
sudo ls -la /etc/nginx.bak-20261010143000/
sudo test -f /etc/nginx.bak-20261010143000/nginx.conf && echo "备份完整" || echo "备份不完整,停止"
  • 更安全的方式是不删除原目录,用 rsync 同步:
   bash
# 先做 dry-run 看将要改变什么
sudo rsync -avn --delete /etc/nginx.bak-20261010143000/ /etc/nginx/

# 确认无误后执行
sudo rsync -av --delete /etc/nginx.bak-20261010143000/ /etc/nginx/

rsync --delete 的方向必须确认:源是备份目录,目标是当前目录。写反了会清空备份。

12.3 紧急签发

如果现有证书已过期且无法续期,而业务需要立即恢复:

   bash
# 方式一:用自签证书临时顶(会有信任警告,但至少能握手)
sudo openssl req -x509 -nodes -days 7 -newkey rsa:2048 \
  -keyout /etc/nginx/ssl/emergency.key \
  -out /etc/nginx/ssl/emergency.crt \
  -subj "/CN=example.com" \
  -addext "subjectAltName=DNS:example.com,DNS:www.example.com"

sudo chmod 600 /etc/nginx/ssl/emergency.key

# 修改 Nginx 配置指向临时证书
sudo nginx -t && sudo systemctl reload nginx

自签证书的后果:所有客户端都会看到证书不受信任的警告。对浏览器用户来说需要手动点击"继续",对 API 调用方来说是直接失败。这只是让人能看到页面,不是真正的恢复。

方式二:从其他环境借用证书

如果测试环境有该域名的有效证书(比如 staging 用了正式域名),可以临时复制过来。注意私钥的传输安全。

方式三:用其他 CA 快速签发

某些商业 CA 提供快速签发(几分钟到几小时),或者有 DV 证书的自动化接口。这是更正规的应急方案。

方式四:临时降级到 HTTP

   nginx
# 移除 80 到 443 的跳转,让 HTTP 可访问
server {
    listen 80;
    server_name example.com;
    # 注释掉跳转
    # return 301 https://$host$request_uri;
    root /var/www/example.com;
}

这是最后手段。HTTP 是明文传输,登录凭据、Token、业务数据全部暴露。只在内部系统、且必须明确告知相关方的前提下使用,用后立即恢复。

12.4 回滚决策表

情况处理紧急程度
证书已过期立即手动签发,签发失败走应急方案最高
证书 7 天内过期立即排查续期失败原因高
证书 21 天内过期排查续期问题,确认定时任务正常中
续期成功但服务未加载手动 reload,修 deploy 钩子高
证书链不完整修正为 fullchain.pem,reload高
HSTS 导致子域不可访问无法直接回滚,等待 max-age 到期高
Nginx reload 失败恢复配置备份,测试后重载高

十三、生产环境注意事项

13.1 证书资产清单

必须先有一份完整的清单,才谈得上管理。

字段说明
域名证书覆盖的所有域名,包括 SAN
用途哪个服务、哪个入口
签发方式Let’s Encrypt / 商业 CA / 内网 CA
有效期notBefore / notAfter
续期方式自动 / 手动
续期责任人谁负责处理续期失败
生效位置哪台服务器、哪个配置文件
前置依赖CDN、负载均衡、DNS
告警配置关联的告警规则

维护这份清单的成本不高,但能避免"这个域名是谁在用"这类低效沟通。

自动化生成清单的脚本:

   bash
#!/bin/bash
# 用途:从本地环境生成证书清单草稿
set -uo pipefail

echo "domain,expires_at,days_left,issuer,cert_path"
for cert in $(sudo find /etc/letsencrypt/live -name "fullchain.pem" 2>/dev/null); do
  domain="$(basename "$(dirname "${cert}")")"
  na="$(openssl x509 -in "${cert}" -noout -enddate 2>/dev/null | cut -d= -f2)"
  issuer="$(openssl x509 -in "${cert}" -noout -issuer 2>/dev/null | sed 's/^issuer=//; s/,/;/g')"
  dl=$(( ($(date -d "${na}" +%s) - $(date +%s)) / 86400 ))
  printf '%s,%s,%d,%s,%s\n' "${domain}" "${na}" "${dl}" "${issuer}" "${cert}"
done | sort -t, -k3 -n

13.2 告警设计原则

分级:

剩余天数级别通知方式响应时间
30 天信息日报无要求
21 天警告群消息当天确认
14 天严重群消息 + 电话2 小时内
7 天紧急电话 + 短信立即
已过期最紧急全通道立即

阈值必须结合业务基线。判断依据:

  • 续期窗口是多久(Let’s Encrypt 是 30 天)。
  • 从告警到实际处理完需要多久。
  • 失败后重试需要多久(受速率限制影响)。

如果续期窗口是 30 天,而从告警到处理平均需要 3 天,那 21 天告警就足够;如果处理链路涉及审批、跨团队协调,可能要提到 45 天。

告警内容必须包含足够信息:

   
标题:证书 7 天内到期 - example.com
内容:
  域名: example.com
  到期时间: 2026-10-17 15:30:00 UTC
  剩余天数: 6
  签发者: Let's Encrypt
  生效位置: server-01 (10.0.0.11), /etc/nginx/conf.d/example.com.conf
  上次续期尝试: 2026-10-10 12:23 (失败: Invalid response 404)
  处理手册: https://wiki.example.com/runbook/cert-renewal

"上次续期尝试"这一项特别重要,它直接给出排查方向。

13.3 多节点场景

如果同一个域名在多台服务器上提供服务(负载均衡后端):

方案一:每台机器独立续期

  • 每台机器都有自己的 certbot 和证书。
  • 优点:无单点。
  • 缺点:证书不完全一致(签发时间差),排查时容易混淆。

方案二:中心签发 + 分发

  • 一台机器负责签发,通过配置管理(Ansible)分发到其他机器。
  • 优点:证书一致,管理集中。
  • 缺点:分发链路是单点,需要监控。

方案三:共享存储

  • 证书放在 NFS 或对象存储,各机器挂载。
  • 优点:只有一份证书。
  • 缺点:存储是单点,且私钥跨网络传输增加风险。

Ansible 分发方案示例:

   yaml
- hosts: web_servers
  serial: 2
  tasks:
    - name: 同步证书文件
      ansible.builtin.copy:
        src: "/etc/letsencrypt/live/example.com/{{ item }}"
        dest: "/etc/nginx/ssl/example.com/{{ item }}"
        mode: "{{ '0600' if 'privkey' in item else '0644' }}"
        owner: root
        group: root
      loop:
        - fullchain.pem
        - privkey.pem
      notify: reload nginx

    - name: 校验证书与私钥匹配
      ansible.builtin.shell: |
        set -o pipefail
        c=$(openssl x509 -in /etc/nginx/ssl/example.com/fullchain.pem -noout -pubkey | md5sum)
        k=$(openssl pkey -in /etc/nginx/ssl/example.com/privkey.pem -pubout | md5sum)
        [ "$c" = "$k" ]
      args:
        executable: /bin/bash
      changed_when: false

    - name: 校验证书未过期
      ansible.builtin.shell: |
        set -o pipefail
        end=$(openssl x509 -in /etc/nginx/ssl/example.com/fullchain.pem -noout -enddate | cut -d= -f2)
        days=$(( ( $(date -d "$end" +%s) - $(date +%s) ) / 86400 ))
        [ "$days" -gt 7 ]
      args:
        executable: /bin/bash
      changed_when: false

  handlers:
    - name: reload nginx
      ansible.builtin.service:
        name: nginx
        state: reloaded

注意:ansible.builtin.copy 的 src 是从控制节点读取,所以证书需要先在控制节点上。不要把私钥长期存在控制节点上——用 delegate_to 从签发节点直接分发,或者用完立即清理。

13.4 多域名与泛域名的选择

场景建议
少量固定域名一张证书包含所有 SAN
大量子域(>20)泛域名 *.example.com
子域需要独立管理各自独立证书
有跨级子域(a.b.example.com)泛域名是单级的,需要额外证书

泛域名的风险:一张证书覆盖所有子域,私钥泄露会影响全部。而且泛域名必须用 DNS 验证,凭据管理是额外的风险点。

多 SAN 证书的风险:任何新增域名都要重新签发整张证书,改动面更大。

证书的 SAN 数量上限:CA 对单张证书的 SAN 数量有上限(通常是 100 个),实际使用中建议控制在 10 个以内,便于管理。

13.5 时间与系统依赖

  • 系统时间必须准。ACME 协议和证书有效期判断都依赖时间。用 chronyd 或 ntpd 保持同步。
  • 时区不影响证书(证书用 UTC),但影响日志解读。统一用 UTC 或者明确标注时区。
  • 系统的 CA 信任库要更新。ca-certificates 包太旧会导致某些根证书不被信任。
   bash
# 检查时间同步
timedatectl status
chronyc tracking 2>/dev/null | head -5

# 更新 CA 信任库
sudo dnf update -y ca-certificates    # RHEL 系
sudo apt update && sudo apt install -y ca-certificates   # Debian 系
sudo update-ca-trust                  # RHEL 系手动触发

关于 ca-certificates 的更新:这是少数几个"更新后需要 reload 服务"的系统包之一。证书库更新后,依赖它的服务(Nginx 代理到 HTTPS 后端、curl 等)需要重启才能读到新的信任库。

13.6 常见反模式

反模式一:只在签发证书的那台机器上配监控。

其他机器上的证书快过期了没人知道。监控必须覆盖所有生效位置。

反模式二:把 certbot renew 放在日志不落盘的地方。

   bash
# 错误:输出丢弃,失败无感知
17 0,12 * * * root /usr/bin/certbot renew --quiet >/dev/null 2>&1

正确做法是保留日志并检查退出码。

反模式三:只监控文件,不监控线上。

文件显示还有 80 天,但线上用的是没 reload 的旧证书,7 天后到期。两个都要监控。

反模式四:用测试环境的 CA 签发生产证书。

certbot 的 staging 环境签发的证书不被浏览器信任。检查 renewal/*.conf 里的 server 字段,确认是 acme-v02.api.letsencrypt.org 而不是 acme-staging-v02。

反模式五:证书续期后不验证就认为完成。

续期成功只说明文件更新了。必须验证服务真的加载了新证书(对比本地和线上的 notAfter)。

反模式六:所有域名用同一张证书的同一个验证方式。

某个域名无法用 HTTP-01(比如 80 端口被封),会导致整张证书续期失败。把验证方式有差异的域名拆成独立的证书。

反模式七:手动续期没有记录。

手动操作后不记录,下次不知道谁改了什么。所有手动操作都要留下时间、原因、结果。


十四、总结

HTTPS 证书自动续期这件事,技术方案本身不复杂,难的是把几个容易断的环节连成一条完整的链路。回顾整篇文章,关键就三件事:

第一,续期必须真的能自动跑完。 定时任务要启用,--webroot-path 路径要对,80 端口的验证路径不能被跳转规则覆盖,速率限制下不能靠反复重试。用 certbot renew --dry-run 定期验证,用 not yet due for renewal 的日志计数确认"流程还活着"。

第二,续期后的证书必须真的被加载。 deploy 钩子要在每次成功续期后执行 nginx -t && systemctl reload nginx。判断方法很直接:对比本地文件的 notAfter 和线上 openssl s_client 拿到的 notAfter。两者不一致就是没 reload。

第三,到期监控必须独立于续期流程。 让监控系统自己去读证书的到期时间,而不是依赖续期脚本上报。这样即使续期完全失效,告警依然会触发。多阈值分级(30/21/14/7 天),阈值高低取决于你从告警到处理完需要多久。

几条实践中得到的经验:

  • fullchain.pem 不是 cert.pem。用错会导致证书链不完整,部分客户端报错。这个错误在浏览器里看不出来,但在 Android 老版本、Java 客户端上会暴露。
  • HSTS 要慢慢来。先 max-age=300,稳定后再延长。includeSubDomains 和 preload 只在所有子域都确认支持 HTTPS 后才加。配错了几乎回不去。
  • 80 端口的验证路径不能跳转。这是"续期突然失败"最常见的原因,通常来自某次"安全加固"把 80 端口全部跳转到了 HTTPS。
  • --dry-run 不测 deploy 钩子。钩子要单独手动执行验证。很多人以为 dry-run 过了就没问题,实际上线时发现 reload 那步是坏的。
  • 私钥权限必须是 600。定期用 find ... -exec ls -la 检查一遍。顺便检查 DNS API 凭据的权限。
  • 多节点场景要覆盖所有节点。同一个域名在 5 台机器上,就有 5 份证书要管。
  • 泛域名用 DNS 验证意味着多一份凭据要管。用最小权限的子账号,定期轮换,且绝对不能进 Git。

最后一点关于运维习惯:证书管理最适合"无事可做"的状态——每天跑一次续期,大多数时候什么都不做,只有快到期时才真正动手。这种"什么都不做"恰恰是配置正确的标志。反过来,如果每天都看到续期的日志里有动作,说明续期窗口或定时频率设错了,需要检查。

把证书纳入常规巡检,每季度跑一次端到端演练,出问题时按排查路径走一遍。做到这些,证书过期这个"低级但致命"的故障,基本可以从你的故障清单里划掉了。


阅读原文:原文链接


该文章在 2026/10/27 11:39:06 编辑过
关键字查询
相关文章
正在查询...
点晴ERP是一款针对中小制造业的专业生产管理软件系统,系统成熟度和易用性得到了国内大量中小企业的青睐。
点晴PMS码头管理系统主要针对港口码头集装箱与散货日常运作、调度、堆场、车队、财务费用、相关报表等业务管理,结合码头的业务特点,围绕调度、堆场作业而开发的。集技术的先进性、管理的有效性于一体,是物流码头及其他港口类企业的高效ERP管理信息系统。
点晴WMS仓储管理系统提供了货物产品管理,销售管理,采购管理,仓储管理,仓库管理,保质期管理,货位管理,库位管理,生产管理,WMS管理系统,标签打印,条形码,二维码管理,批号管理软件。
点晴免费OA是一款软件和通用服务都免费,不限功能、不限时间、不限用户的免费OA协同办公管理系统。
Copyright 2010-2026 ClickSun All Rights Reserved  粤ICP备13012886号-9  粤公网安备44030602007207号