一、问题背景
证书过期是一个典型的"明知道会发生、但总是忘记处理"的故障。它的危害不需要多解释:浏览器直接拦截、API 调用方握手失败、移动端 App 大面积报错、支付回调丢失。生产环境上一次证书过期,影响面往往比一次机房故障还大,因为它同时打掉所有走 HTTPS 的入口。
更麻烦的是,证书故障的排查路径和常规故障不一样。服务器 CPU 正常、内存正常、进程还在、端口也通,重启服务毫无作用。唯一的证据在证书的 notAfter 字段里,而如果监控没有覆盖这个字段,你只能靠用户投诉知道出事了。
自动化续期本身不难,certbot 一条命令就能签发。难的是三件事:
- 续期必须能自动完成,而且失败时不能静默。很多团队配了定时任务,但没有验证它真的能工作,结果到期那天才发现续期脚本半年前就开始报错了。
- 续期后的证书必须真的被服务加载。证书文件更新了,但 Nginx 没 reload,线上用的还是旧证书。这个"更新成功但没生效"的情况非常常见。
- 到期监控要独立于续期流程。如果监控依赖于续期脚本自己上报,那么续期脚本挂了监控也挂了。到期检查必须是一条独立链路。
这篇文章按"目标 → 环境假设 → 配置前检查 → 实施 → 生效 → 验证 → 风险 → 回滚 → 维护"的顺序,把这三件事都做扎实。
二、适用场景
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-manager | Kubernetes | 声明式,与 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 提供三种方式处理:
方式一:部署钩子(推荐)
# /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
#!/bin/bash
systemctl reload nginx
deploy 钩子在每次成功续期后执行,只有真的续期了才跑,不是每次都跑。这是最合适的位置。
方式二:--deploy-hook 参数
certbot renew --deploy-hook "systemctl reload nginx"
方式三:--post-hook(每次 renew 都跑,即使没续期)
不推荐,因为会导致每天无谓地 reload 服务。
验证钩子是否配置正确:
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 方式(现代发行版推荐):
# /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
# /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
启用:
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 到期监控的独立链路
证书到期监控必须和续期流程解耦。设计原则:
- 独立采集:监控系统直接读取证书的
notAfter,不依赖续期脚本的返回值。 - 独立告警:即使续期脚本完全跑不起来,到期告警也应该触发。
- 多阈值:30 天、14 天、7 天、3 天分级告警,越接近到期越紧急。
三种实现方式:
| 方式 | 实现 | 优点 | 缺点 |
|---|
| 黑盒探测 | 监控系统定期连 443 端口读证书 | 检测的是"实际对外提供的证书" | 需要外部探测点 |
| 文件检查 | 读取本地证书文件 | 简单 | 检测不到"文件更新但未加载" |
| 双探测 | 两者都做 | 能发现差异 | 配置复杂 |
推荐双探测:黑盒探测发现"线上证书快过期了",文件检查发现"本地文件已更新但服务没重载"。两者配合能覆盖全部情况。
3.7 关键命令与输出字段
# 查看证书详情
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
# 从远端读取证书(不校验证书有效性)
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
| openssl x509 -noout -dates -subject
注意 -servername:SNI 参数。同一 IP 上有多个证书时,不带它可能拿到默认证书,导致误判。
# 剩余天数计算
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 中的证书配置要点
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 客户端、部分命令行工具。
验证方式:
# 检查服务端返回的完整链
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 必须先收集的信息
# 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 配置前检查
目的:确认环境满足自动续期的前提条件,避免配置完才发现验证通不过。
检查一:域名解析
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
判断逻辑:
| 情况 | 结论 | 处理 |
|---|
| 解析结果 = 本机公网 IP | HTTP-01 可行 | 继续 |
| 解析到 CDN 或负载均衡 | HTTP-01 需要特殊处理 | 用 DNS-01,或在 CDN 层放行验证路径 |
| 解析不到 | DNS 未配 | 先配 DNS |
| 解析到内网 IP | 公网无法验证 | 用 DNS-01 |
检查二:80 端口可达性
# 本机监听情况
sudo ss -lntp | grep -E ':80 |:443 '
# 防火墙
sudo firewall-cmd --list-all 2>/dev/null || sudo iptables -L INPUT -n --line-numbers | head -20
# 云安全组需要在控制台确认(命令行无法直接查)
# 从外部验证(在另一台机器上执行)
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 而不是验证内容。这是一个非常常见的失败原因。
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;
}
}
检查三:现有证书状态
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
systemctl show nginx -p CanReload
# CanReload=yes
如果 CanReload=no,不能自动 reload,必须 restart(有中断)。
检查五:时间同步
timedatectl status
# 关注 System clock synchronized: yes 和 NTP service: active
为什么重要:ACME 协议对时间敏感,时间偏差过大会导致请求被拒绝。证书的有效期判断也依赖本地时间。如果系统时间错了 5 天,剩余天数计算也会错。
下一步:五项检查都通过,进入 5.1。
5.1 步骤一:安装 certbot
目的:装好 ACME 客户端。
操作:
# 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
验证:
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
目的:在不消耗签发配额的前提下验证整个流程能走通。
操作:
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 connect | 80 端口不通 | 检查安全组、防火墙 |
Invalid response ... 404 | webroot 路径不对 | 核对 --webroot-path 与 Nginx 的 root 一致 |
Invalid response ... 301 | 80 端口跳转到了 HTTPS | 给验证路径加例外 |
DNS problem: NXDOMAIN | 域名解析不到 | 检查 DNS |
too many certificates already issued | 触发速率限制 | 用 --dry-run 测试,等限制重置 |
--dry-run 的边界:它在 staging 环境完整走一遍流程,包括验证和签发,只是不写入正式证书目录。所以它能验证:域名解析、80 端口可达、webroot 路径正确、ACME 通信正常。它不能验证:正式环境的速率限制、最终证书的信任链。
下一步:dry-run 成功后,正式签发。
5.3 步骤三:正式签发
目的:签发第一个正式证书。
操作:
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.
验证:
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 里的具体错误。
sudo tail -100 /var/log/letsencrypt/letsencrypt.log
下一步:配置 Nginx 使用新证书。
5.4 步骤四:配置 Web 服务器引用证书
目的:让 Nginx 用新证书提供 HTTPS 服务。
操作:/etc/nginx/conf.d/example.com.conf:
# 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。这是一个容易造成"回不去"的配置:
# 谨慎上线:先用短时间
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 缓存的唯一方式是浏览器设置里手动清除,对普通用户几乎不可能完成。
生效方式:
# 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 读取 privkey | Nginx 用户无权访问 /etc/letsencrypt/ | 确认 master 进程是 root |
注意:nginx -t 检查通过不代表 reload 一定成功。如果 reload 期间有并发问题,可能仍然失败。reload 后一定要 systemctl is-active nginx 确认。
下一步:配置自动续期的 deploy 钩子。
5.5 步骤五:配置续期后的自动重载
目的:证书更新后自动让 Nginx 加载新证书。
操作:
sudo mkdir -p /etc/letsencrypt/renewal-hooks/deploy
/etc/letsencrypt/renewal-hooks/deploy/10-reload-nginx.sh:
#!/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
授权:
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
脚本设计的几个要点:
set -euo pipefail:任何命令失败立即退出,不会带着错误继续执行。- 变量加引号:
"${RENEWED_LINEAGE}" 防止路径含空格时出错。 - 先
nginx -t 再 reload:配置有问题时宁可不 reload,也不要把服务搞坏。 - reload 而非 restart:reload 不断开现有连接。
- 写日志:
certbot renew 的输出可能被 cron 邮件吞掉,独立日志更可靠。 exit 1 非零退出:让 certbot 知道钩子失败了。
关于钩子的返回值:deploy 钩子返回非零会让 certbot renew 整体返回非零。这有用——你可以通过检查 certbot renew 的退出码来判断"续期 + 应用"是否完整成功。但要注意,证书已经续期成功,只是 reload 失败,这种情况下证书文件是新的,服务用的是旧的,需要人工介入。
测试钩子:
# 手动执行一次,验证脚本本身没问题
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(推荐)
# /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 自己也会休眠一段随机时间。两者选一,否则总延迟会叠加。
# /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
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
# /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 的输出会通过邮件发送,如果没配邮件会丢失。要显式重定向到日志:
17 0,12 * * * root /usr/bin/certbot renew --quiet >> /var/log/letsencrypt/renew.log 2>&1
两种方式的对比:
| 维度 | systemd timer | cron |
|---|
| 随机延迟 | 原生支持 | 需要自己写 |
| 日志 | journalctl 统一 | 需重定向 |
| 错过补偿 | Persistent=true | 不支持 |
| 依赖管理 | 可声明 After= | 无 |
| 兼容性 | 需要 systemd | 广泛 |
建议用 systemd timer,除非系统没有 systemd。
下一步:配置到期监控。
5.7 步骤七:配置到期监控
目的:建立一条独立于续期流程的证书到期检测链路。
方式一:脚本 + 定时任务 + 告警推送
/usr/local/bin/check-cert-expiry.sh:
#!/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 "$@"
脚本设计的几个要点:
- 用序列号而不是文件时间戳判断"是否更新"。文件可能在续期过程中被 touch,但内容没变。序列号是证书的唯一标识,变了才说明真的换了证书。
DRY_RUN 支持:脚本默认是只读的,但 DRY_RUN 变量保留了扩展点,接入告警通道前可以先测。- 日志包含结构化字段:
domain=... days_left=... 的格式便于用 awk、grep 做后续分析。 - 退出码分级:0 正常、1 警告、2 严重、3 异常。定时任务可以根据退出码决定是否发告警。
- 不写死告警通道:Webhook 地址、Token 属于环境相关配置,通过环境变量或受控配置文件传入。
接入告警通道时的注意点:
# Token 不要写死在脚本里,用环境变量或权限受控的配置文件
# /etc/cert-monitor/env (权限 0600,属主 root)
ALERT_WEBHOOK="https://alert.example.com/api/v1/alerts"
ALERT_TOKEN_FILE="/etc/cert-monitor/token"
# 脚本中读取
# shellcheck source=/dev/null
[[ -f /etc/cert-monitor/env ]] && source /etc/cert-monitor/env
TOKEN="$(cat "${ALERT_TOKEN_FILE}" 2>/dev/null || echo '')"
定时任务:
# /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
# /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:
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 采集配置:
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 地址
告警规则:
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 确认实际输出的指标名。
方式三:文件检查 + 探测结果对比
这是最有价值的补充。把本地文件的有效期和线上提供的证书有效期放在一起比较:
# 本地文件
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
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 能成功"。钩子需要单独测。
方式二:强制续期(有风险)
sudo certbot renew --force-renewal
风险提醒:--force-renewal 会无视剩余有效期直接续期。这有几个问题:
- 消耗签发配额。Let’s Encrypt 的速率限制是按注册域名算的,频繁强制续期会触发限制。
- 如果续期验证失败,旧证书可能被影响。虽然 certbot 设计上会保留旧证书,但反复失败会让状态变复杂。
- 多台机器同时执行会造成并发问题。
使用场景:只在测试环境验证完整流程(包括 deploy 钩子)时使用,而且要在确认配额充足的前提下。
方式三:手动触发 systemd 服务
# 触发续期(实际上因为未到期不会真的续期,只是验证服务能跑)
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 钩子
# 手动执行钩子
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 的启动时间:
# 对比前后
ps -eo pid,lstart,cmd | grep 'nginx: worker' | head
下一步:建立日常巡检。
5.9 步骤九:日常巡检与报表
目的:把证书状态纳入日常巡检,形成资产视图。
#!/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 列数错乱。稳妥做法是把逗号替换掉:
subject="$(openssl x509 -in "${cert}" -noout -subject 2>/dev/null | sed 's/^subject=//; s/,/;/g')"
六、常用命令速查
6.1 查看证书信息
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 从远端读取证书
# 读取远端证书(跳过验证)
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 命令
# 查看所有证书及到期时间
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 的风险:会删除证书目录和续期配置,无法直接恢复,需要重新签发。删除前确认没有其他服务在用这个证书:
sudo nginx -T 2>/dev/null | grep -c "example.com"
6.4 日志位置
# 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 配置:
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 相关
# 配置语法检查
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 监控指标查询
# 剩余天数
(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(统一的验证入口):
# 所有域名的 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:
# 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 及以上:
如果是 1.25.1 以下:
用 nginx -v 确认版本后再写,两种写法在错误的版本上会直接报配置错误。
7.2 泛域名的 DNS 验证配置
以 certbot 的 DNS 插件为例(不同 DNS 服务商的插件名不同):
# 凭据文件,权限必须 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 的文件。
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)
# 安装
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 验证):
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 环境,有更高的速率限制):
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 引用:
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 的排查命令:
# 查看证书状态
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 场景下的到期监控:
# 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 日志分析
# 续期成功的记录
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_expiry | blackbox_exporter | 证书最早到期时间戳 | 与当前时间差 < 21 天告警 |
probe_success | blackbox_exporter | 探测是否成功 | 为 0 表示探测失败 |
up{job="blackbox-https"} | Prometheus | 目标是否可采集 | 为 0 表示 exporter 或网络问题 |
certmanager_certificate_expiration_timestamp_seconds | cert-manager | K8s 证书到期时间 | 同上 |
certmanager_certificate_ready_status | cert-manager | 证书是否就绪 | 为 0 表示未就绪 |
probe_ssl_last_chain_expiry_timestamp_seconds | blackbox_exporter | 链中最后一个证书的到期时间 | 关注根证书到期 |
重要提醒:指标名以实际部署的 exporter 版本为准。blackbox_exporter 在不同版本中提供 probe_ssl_earliest_cert_expiry 和 probe_ssl_last_chain_expiry_timestamp_seconds 等指标,具体可用哪些需要用 curl 直接探测确认:
curl -s "http://localhost:9115/probe?target=https://example.com&module=https_cert" \
| grep -E "^probe_ssl"
8.3 告警设计
分级告警:
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 的实现:
# 在 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 可能读到写了一半的内容:
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 路径是否正确的完整流程:
# 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 意义有限。
# 判断是否有 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 的区别:
# 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 及以上。老版本用:
openssl x509 -in cert.pem -noout -text | grep -A1 "Subject Alternative Name"
十、风险提醒
10.1 高风险:让证书过期
这看起来是最基本的问题,但它是生产环境最常见的 HTTPS 故障。
影响范围:所有走该域名的 HTTPS 流量,包括 API、移动端、支付回调。浏览器会显示安全警告,大量客户端会直接拒绝连接。
预防措施:
- 监控必须覆盖所有域名,包括不常用的二级域名、内部 API 域名。
- 至少两个独立的告警通道(邮件 + 即时消息)。
- 告警要有明确的处理人和处理时限。
- 定期演练:每季度手动验证一次到期告警能不能触发。
关于监控覆盖的检查:
# 找出 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 进程数配置超过系统限制。
防护:
# 在 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 限制。判断方式:
ulimit -u # 当前用户的最大进程数
ulimit -H -u # 硬限制
nproc # CPU 核数
10.3 中风险:修改 Nginx 配置导致服务异常
sudo nginx -t
# nginx: [emerg] duplicate listen options for 0.0.0.0:443
执行前必须:
- 备份配置:
sudo cp -a /etc/nginx /etc/nginx.bak-$(date +%Y%m%d%H%M%S)
nginx -t 必须在 reload 前执行。- 变更窗口内操作,最好有人在旁边盯监控。
回滚方式:
# 从备份恢复
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 配置错误
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
影响范围:一旦客户端收到这个头,在一年内访问该域名(包括所有子域)都会强制 HTTPS,无法通过浏览器界面绕过。
风险场景:
- 某个子域没有配 HTTPS,加上
includeSubDomains 后该子域完全无法访问。 - HTTPS 证书出问题,用户无法通过"继续访问"绕过。
preload 提交到浏览器预加载列表后,几乎不可能撤销(撤销流程需要数月)。
安全实施顺序:
# 第一步:短时间测试
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,实现中间人攻击或完全劫持。
防护措施:
# 凭据文件权限必须是 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:
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 是否修改过配置:
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 加密:
# 加密备份(会提示输入密码)
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 参数能显著改善。 - 加密文件的密码不要和加密文件放在同一台机器上。
检查私钥权限:
sudo find /etc/letsencrypt -name "privkey*.pem" -exec ls -la {} \;
# 应该是 -rw------- 1 root root
如果发现权限过宽,立即修正:
sudo chmod 600 /etc/letsencrypt/archive/*/privkey*.pem
sudo chmod 700 /etc/letsencrypt/live /etc/letsencrypt/archive
十一、验证方式
11.1 分层验证清单
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 的检查方式:
# 从证书提取公钥
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 外部验证
注意:以下验证应该从集群外的机器执行,模拟真实用户视角。
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 续期流程验证
# 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 步要重点看的内容:
[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 端到端演练(推荐每季度一次)
目的:验证从告警到恢复的完整链路。
#!/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/ 下保留历史版本:
sudo ls -la /etc/letsencrypt/archive/example.com/
# cert1.pem cert2.pem chain1.pem chain2.pem fullchain1.pem fullchain2.pem privkey1.pem privkey2.pem
恢复方式一:改回符号链接
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/ 下的目标文件存在:
sudo ls -la /etc/letsencrypt/archive/example.com/fullchain1.pem
关于版本号的判断:数字大的更新。fullchain2.pem 比 fullchain1.pem 新。要回滚就选数字小的。
恢复方式二:certbot 内置
certbot 没有直接的"回滚"命令。如果需要回到某个版本,只能用上面的符号链接方式。
12.2 恢复 Nginx 配置
# 从备份恢复
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 是危险操作。执行前确认备份目录存在且内容完整:
sudo ls -la /etc/nginx.bak-20261010143000/
sudo test -f /etc/nginx.bak-20261010143000/nginx.conf && echo "备份完整" || echo "备份不完整,停止"
- 更安全的方式是不删除原目录,用
rsync 同步:
# 先做 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 紧急签发
如果现有证书已过期且无法续期,而业务需要立即恢复:
# 方式一:用自签证书临时顶(会有信任警告,但至少能握手)
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
# 移除 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 |
| 告警配置 | 关联的告警规则 |
维护这份清单的成本不高,但能避免"这个域名是谁在用"这类低效沟通。
自动化生成清单的脚本:
#!/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 分发方案示例:
- 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 包太旧会导致某些根证书不被信任。
# 检查时间同步
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 放在日志不落盘的地方。
# 错误:输出丢弃,失败无感知
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 编辑过