Nginx 证书自动化与 ACME:certbot、DNS-01 通配符与自动续期

讲解 Nginx 证书自动化的完整链路,涵盖 ACME 协议与 HTTP-01/DNS-01 挑战选择、certbot 与 acme.sh 的配置方式、通配符证书申请、自动续期与原子 reload、以及证书过期监控与故障排查。

证书管理最容易被低估的部分不是「怎么签发」,而是「怎么保证永远不过期」。手工申请的证书在一年后到期,而负责续期的人可能早已离职或转岗;即便是 90 天有效期的 Let’s Encrypt 证书,如果续期脚本因为一次目录权限变更而静默失败,也会在三个月后引发全站告警。本文把证书管理拆成「申请、续期、加载、监控」四个环节,逐段给出可落地的自动化方案。

一句话总结: 证书自动化的目标不是省一次手工操作,而是让「证书过期」这个故障类型从系统里彻底消失,包括它的监控与告警。

1. 证书生命周期管理的痛点

一句话总结: 证书问题的根源是「低频操作 + 高影响后果 + 责任人漂移」,因此必须完全自动化并配套监控,而不是依赖流程与提醒。

手工管理证书会周期性地产生同一类事故:

痛点一:续期遗忘
  证书有效期 90 天或 1 年,跨越两个季度,交接时最易丢失

痛点二:续期成功但未加载
  文件已更新,Nginx 未 reload,仍在用旧证书直到过期

痛点三:加载了错误的证书链
  缺少中间证书,部分客户端(尤其是 Android 与 Java)握手失败

痛点四:多域名分散管理
  每个域名一套流程,规模上去后无法审计谁在管哪张证书

这四类问题的共同解法是「把证书当作由代码生成的产物」:申请与续期由脚本完成,加载由原子替换加 reload 完成,正确性由自动化断言验证,状态由监控覆盖。

2. ACME 协议与挑战类型

一句话总结: ACME 是自动化签发证书的标准协议,通过 HTTP-01 或 DNS-01 证明域名控制权,前者简单后者支持通配符。

2.1 ACME 交互流程

一句话总结: 客户端先注册账户并申请订单,CA 下发挑战,客户端完成验证后 CA 签发证书,全流程无需人工介入。

客户端                          ACME 服务端(CA)
  |  1. 生成/复用账户密钥            |
  |  2. 新建订单(含域名列表) ------>|
  |  <------- 返回授权与挑战列表       |
  |  3. 按挑战类型布置验证材料        |
  |     HTTP-01: 写入 /.well-known/acme-challenge/<token>
  |     DNS-01 : 添加 _acme-challenge.<domain> TXT 记录
  |  4. 通知 CA 开始验证 ------------->|
  |  <------- CA 回源校验通过          |
  |  5. 提交 CSR,请求签发 ----------->|
  |  <------- 返回证书链与有效期        |

整个流程的关键约束是验证时刻必须在材料就绪之后,因此 DNS-01 需要等待 DNS 传播(TTL 决定),HTTP-01 需要确保 80 端口可从公网访问。

从工程角度看,ACME 值得关注的还有两个细节:一是账户密钥,它标识了你的客户端身份,重装环境时应备份 account.key,否则每次重建都要重新注册;二是吊销与替换,私钥泄露时需要调用吊销接口并立即换发,这部分通常不在自动化脚本的覆盖范围内,需要单独准备应急流程。

2.2 HTTP-01 与 DNS-01 的选择

一句话总结: HTTP-01 配置简单但只能签发单域名且要求 80 端口开放,DNS-01 支持通配符与内网主机但需要 DNS API 权限。

HTTP-01
  优点:无需 DNS 权限,配置简单,验证快
  缺点:不支持通配符;要求域名解析到本机且 80 端口可达
  适用:单域名、公网可直接访问的 Web 服务

DNS-01
  优点:支持 *.example.com 通配符;不要求服务公网可达
  缺点:需要 DNS 服务商的 API 凭据,传播延迟不可控
  适用:通配符证书、内网服务、无法开放 80 端口的场景

选择的关键判据是「是否需要通配符」与「主机是否可从公网访问」。若两者都不需要,HTTP-01 是最省事的;只要涉及通配符,就必须走 DNS-01。

3. certbot 与 acme.sh 实战

一句话总结: certbot 适合标准 Web 服务器场景且与 Nginx 集成度高,acme.sh 更轻量、DNS API 支持更广,适合容器与多域名批量场景。

3.1 certbot 的 webroot 与 standalone

一句话总结: webroot 模式复用现有 Nginx 的 80 端口与站点目录,standalone 模式临时占用 80 端口,前者更适合生产环境。

# webroot 模式:把挑战文件写到 Nginx 的静态目录
certbot certonly --webroot \
  --webroot-path /var/www/certbot \
  -d example.com -d www.example.com \
  --email ops@example.com --agree-tos --no-eff-email

# 对应的 Nginx 配置:必须暴露 /.well-known/acme-challenge/
server {
    listen 80;
    server_name example.com www.example.com;

    # ACME 挑战目录,必须可公开访问且不重定向到 HTTPS
    location ^~ /.well-known/acme-challenge/ {
        root /var/www/certbot;
        default_type "text/plain";
        allow all;
    }

    # 其余流量全部跳转 HTTPS
    location / {
        return 301 https://$host$request_uri;
    }
}

这里最容易出错的是把 80 端口的全部流量无条件 301 到 HTTPS:ACME 服务端只访问 HTTP,一旦被重定向到 HTTPS 再跳到别处,验证就会失败。因此 /.well-known/acme-challenge/ 必须用 location ^~ 精确前缀匹配,排在重定向规则之前。standalone 模式则用 --standalone 让 certbot 自己起一个临时服务,代价是必须先停掉 Nginx 或确保 80 端口空闲。

3.2 acme.sh 的 DNS-01 通配符

一句话总结: acme.sh 通过环境变量传入 DNS API 凭据,自动添加并清理 TXT 记录,一条命令即可签发通配符证书。

# 安装(推荐用 git 方式,便于升级)
git clone https://github.com/acmesh-official/acme.sh.git
cd acme.sh && ./acme.sh --install -m ops@example.com

# 配置 DNS 服务商凭据(以 Cloudflare 为例,写到账户配置中)
export CF_Token="your-api-token"
export CF_Account_ID="your-account-id"

# 签发通配符证书(DNS-01)
acme.sh --issue \
  --dns dns_cf \
  -d example.com -d '*.example.com' \
  --keylength ec-256 \
  --server letsencrypt

# 安装证书到 Nginx 目录,并指定续期后的 reload 命令
acme.sh --install-cert -d example.com --ecc \
  --key-file       /etc/nginx/certs/example.com.key \
  --fullchain-file /etc/nginx/certs/example.com.fullchain.pem \
  --reloadcmd      "nginx -t && systemctl reload nginx"

--install-cert 是 acme.sh 设计中最关键的一步:它把「证书文件位置」与「续期后要执行的命令」持久化到 acme.sh 的配置中,之后每次自动续期都会自动复制文件并执行 reload。少了这一步,续期虽然成功但 Nginx 仍在用旧文件。--ecc 表示使用 ECDSA 密钥,需要与 --keylength ec-256 配套。

4. 自动续期与安全 reload

一句话总结: 续期由定时任务触发,但真正决定可用性的是「reload 的原子性与时机」——必须确保 Nginx 读到的是完整的新证书。

4.1 续期触发与钩子

一句话总结: certbot 的 systemd timer 与 acme.sh 的 cron 都会在证书剩余 30 天左右时自动续期,钩子负责 reload。

# certbot 的 systemd timer(安装包默认已启用)
systemctl list-timers | grep certbot
systemctl status certbot.timer

# 手动演练续期(不真正签发,用于验证钩子)
certbot renew --dry-run

# acme.sh 安装时自动写入的 cron(每日检查)
crontab -l | grep acme.sh
# 17 0 * * * "/root/.acme.sh"/acme.sh --cron --home "/root/.acme.sh" > /dev/null

certbot 的续期判断逻辑是「剩余有效期小于 30 天则续期」,因此一天多次运行不会造成重复签发。acme.sh 的 --cron 同理。真正需要验证的是钩子:--dry-run 会走完整个流程但不消耗签发配额,是上线前必做的演练。

# certbot 的 deploy hook:证书更新后校验并 reload
cat > /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
nginx -t || { echo "配置校验失败,跳过 reload"; exit 1; }
systemctl reload nginx
echo "证书已更新并 reload: $(date -Is)"
EOF
chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh

4.2 reload 的原子性

一句话总结: 证书文件写入必须用「临时文件 + 原子重命名」,否则 reload 可能读到写了一半的文件导致启动失败。

#!/usr/bin/env bash
# 安全替换证书文件:先写临时文件,再原子 mv
set -euo pipefail

SRC_DIR="/etc/letsencrypt/live/example.com"
DST_DIR="/etc/nginx/certs"
TS=$(date +%Y%m%d%H%M%S)

install -m 0644 "$SRC_DIR/fullchain.pem" "$DST_DIR/.fullchain.pem.$TS"
install -m 0600 "$SRC_DIR/privkey.pem"   "$DST_DIR/.privkey.pem.$TS"

# mv 在同一文件系统内是原子操作,Nginx 不会读到半截文件
mv "$DST_DIR/.fullchain.pem.$TS" "$DST_DIR/example.com.fullchain.pem"
mv "$DST_DIR/.privkey.pem.$TS"   "$DST_DIR/example.com.key"

nginx -t && systemctl reload nginx

Nginx 的 reload 是「启动新 worker 后优雅关闭旧 worker」,新 worker 在启动时读取证书文件。若此时文件正在被写入,会读到不完整内容并导致新 worker 启动失败——旧 worker 仍能服务,但新配置不会生效。原子重命名消除了这个竞态窗口。另外注意权限:私钥必须是 0600 且属主为 Nginx 运行用户可读。

5. 证书过期监控

一句话总结: 自动化不等于不需要监控,必须有一个独立于续期链路的检查,从外部观察实际生效的证书还剩多少天。

#!/usr/bin/env bash
# 从外部探测实际生效的证书有效期,独立于续期脚本
set -euo pipefail

HOSTS=(example.com www.example.com api.example.com)
WARN_DAYS=21

for h in "${HOSTS[@]}"; do
  end=$(echo | openssl s_client -servername "$h" -connect "$h:443" 2>/dev/null \
        | openssl x509 -noout -enddate | cut -d= -f2)
  end_ts=$(date -d "$end" +%s)
  now_ts=$(date +%s)
  days=$(( (end_ts - now_ts) / 86400 ))
  if [ "$days" -lt "$WARN_DAYS" ]; then
    echo "CRITICAL: $h 证书仅剩 ${days} 天"
  else
    echo "OK: $h 证书剩余 ${days} 天"
  fi
done

这段脚本的关键点是从外部探测而不是读本地文件:它验证的是「实际握手时使用的证书」,能同时发现「文件已更新但未 reload」「负载均衡上另一台机器未更新」这两类本地检查看不到的问题。把结果推送到监控系统并设置 21 天告警阈值,留出足够的人工介入时间。

若已有 Prometheus 体系,更省事的做法是用 blackbox exporter 的 https 探针,它原生输出证书过期时间:

# blackbox exporter 配置片段
modules:
  https_cert:
    prober: http
    timeout: 5s
    http:
      preferred_ip_protocol: ip4
      tls_config:
        insecure_skip_verify: false
# Prometheus 抓取配置:对每个域名做证书探测
scrape_configs:
  - job_name: blackbox_tls
    metrics_path: /probe
    params:
      module: [https_cert]
    static_configs:
      - targets:
          - https://example.com
          - https://www.example.com
    relabel_configs:
      - source_labels: [__address__]
        target_label: __param_target
      - source_labels: [__param_target]
        target_label: instance
      - target_label: __address__
        replacement: blackbox-exporter:9115

probe_ssl_earliest_cert_expiry 指标直接给出最早到期时间戳,告警规则写成 probe_ssl_earliest_cert_expiry - time() < 21 * 86400 即可。这种做法的优势是探测与业务监控走同一套告警通道,避免出现「证书监控在另一套系统里没人看」的割裂。

6. 多域名与大规模证书管理

一句话总结: 证书数量上去之后,要用「一张通配符证书 + 集中式目录 + 统一续期任务」替代逐域名管理。

策略一:通配符收敛
  用 *.example.com 覆盖全部子域,把 N 张证书收敛为 1 张
  代价:任一子域泄露私钥影响全部;需要 DNS-01 与 DNS 权限

策略二:按业务分组合并
  把同一业务的多域名合并到一张 SAN 证书(上限 100 个域名)
  代价:任一域名验证失败会拖累整张证书的续期

策略三:集中目录 + 统一命名
  /etc/nginx/certs/<primary-domain>/{fullchain.pem,privkey.pem}
  便于脚本批量扫描与统一权限管理

对于几百张证书的场景,还需考虑 ACME 服务端的速率限制(Let’s Encrypt 对同一注册域名每周 50 张、完全相同的域名集合每周 5 张),批量签发要加退避与队列。此外应把证书清单纳入配置管理,用脚本定期比对「磁盘上有的证书」与「配置里引用的证书」,清理孤儿文件。

#!/usr/bin/env bash
# 批量签发与续期:从清单文件逐条处理,失败不中断整体
set -uo pipefail

DOMAINS_FILE="/etc/nginx/certs/domains.txt"   # 每行一个主域名
FAILED=0

while read -r domain; do
  [ -z "$domain" ] && continue
  if ! acme.sh --issue --dns dns_cf -d "$domain" -d "*.$domain" \
       --keylength ec-256 --server letsencrypt; then
    echo "签发失败: $domain" >&2
    FAILED=$((FAILED + 1))
    continue
  fi
  acme.sh --install-cert -d "$domain" --ecc \
    --key-file       "/etc/nginx/certs/$domain/privkey.pem" \
    --fullchain-file "/etc/nginx/certs/$domain/fullchain.pem" \
    --reloadcmd      "nginx -t && systemctl reload nginx"
  sleep 5   # 控制节奏,避免触发速率限制
done < "$DOMAINS_FILE"

[ "$FAILED" -eq 0 ] || { echo "有 $FAILED 个域名处理失败" >&2; exit 1; }

注意 --reloadcmd 放在循环内会让每个域名续期后都 reload 一次,规模大时应改为「全部处理完再统一 reload」,把 reload 命令从 install-cert 中移出,在循环结束后执行一次。这样既能减少 reload 次数,也避免了 reload 与证书写入交错带来的竞态。

7. 常见故障与排错

一句话总结: 证书类故障集中在验证失败、链不完整、reload 未生效三类,各自有明确的排查入口。

# 排查一:验证失败(HTTP-01)
curl -v http://example.com/.well-known/acme-challenge/test-token
# 若返回 301/404,说明 location 被重定向规则覆盖或路径写错

# 排查二:证书链不完整(部分客户端握手失败)
openssl s_client -connect example.com:443 -servername example.com -showcerts
# 检查返回的证书链是否包含中间证书(应为 2~3 张)

# 排查三:reload 未生效
nginx -T | grep ssl_certificate
openssl s_client -connect 127.0.0.1:443 -servername example.com 2>/dev/null \
  | openssl x509 -noout -dates
# 对比本地文件的有效期与实际生效的有效期,不一致即未 reload

# 排查四:私钥权限问题
ls -l /etc/nginx/certs/example.com.key
# 应为 0600;若属主不对,reload 后新 worker 无法读取

还有一类隐蔽问题:Nginx 在 http 层配置了全局 ssl_certificate,而某个 server 块未覆盖,导致该站点用了错误的证书。用 nginx -T 逐个 server 块核对证书路径是唯一可靠的检查方式。

8. 总结

环节要点
核心痛点低频操作、高影响后果、责任人漂移,必须全自动化
挑战类型HTTP-01 简单不支持通配符,DNS-01 支持通配符需 DNS 权限
certbotwebroot 复用 80 端口,挑战目录必须优先于重定向规则
acme.shinstall-cert 持久化文件位置与 reload 命令,是续期生效的关键
续期触发systemd timer 或 cron 每日检查,剩余 30 天内自动续期
原子替换临时文件加原子 mv,避免 reload 读到半截证书
过期监控从外部探测实际生效证书,阈值 21 天并独立于续期链路
规模管理通配符收敛、按业务分组合并、集中目录与统一命名

证书自动化的验收标准很直接:在没有人记得证书存在的情况下,服务依然能持续提供正确的 TLS 握手。做到这一点需要把申请、续期、替换、reload、监控五个环节全部脚本化,并且用 --dry-run 与外部探测定期验证链路本身是否还活着。配置与证书都稳定之后,还有一类流量会持续制造麻烦——大文件上传,它对缓冲区、临时文件与超时的要求与普通请求完全不同,这是下一篇的主题。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「nginx」更多文章

  1. Nginx 在服务网格中的角色:边车代理、mTLS 与 Envoy 取舍
  2. Nginx 大文件上传与请求体处理:缓冲、临时文件与断点续传
  3. Nginx 配置测试与 CI 流水线:从 nginx -t 到灰度校验