企业环境下的 Certbot 实战:用 Azure Key Vault 托管私钥签发 SSL 证书
在单机场景里,用 Certbot 申请 Let’s Encrypt 证书可以很省事:它自己生成私钥、完成域名验证、保存证书,几乎不需要人工介入。代价是它默认了一件事——运行 Certbot 的那台机器,就是最终使用这张证书的机器。
域名前面挂了 CDN 或别的反向代理时,还有一层额外的限制:HTTP-01 校验到不了运行 Certbot 的机器,只能改用 DNS-01,在域名的 DNS 里写一条 _acme-challenge 记录来证明所有权。DNS-01 顺带也更好拿通配符证书,只是要配上 DNS 服务商的插件或自己写的 hook,否则 --manual 会停下来等你手工加记录。
到了企业环境,前面那个“同一台机器”的默认就不成立了:CI/CD 的运行器、审计人员、甚至别的团队都可能接触到那台机器;私钥一旦落在构建机的磁盘上,就可能进入构建缓存、日志、镜像层或者备份,而它恰恰是整个 TLS 信任链里最不能泄露的东西。
本文给出一套可以在企业里落地的 Azure 方案:域名用 DNS-01 挑战验证,私钥由 Azure Key Vault 生成并保管,Certbot 只负责让 Let’s Encrypt 用这份私钥对应的 CSR 签出一张证书,签名完成后再把证书合并回 Key Vault——整个签发过程中,私钥从不离开 Key Vault。
为什么不干脆用云厂商的托管证书?因为托管证书的私钥拿不到手。以 Azure Front Door 为例,它的托管证书由 DigiCert 签发后存放在 Front Door 自己的 Key Vault 里,你既看不到私钥,也没法把它用到别处;一旦需要通配符,或者同一张证书要同时挂在 Front Door 和源站上,就只能自带证书(BYOC),而 BYOC 要求证书来自你自己的 Key Vault。AWS 的 ACM、GCP 的 Certificate Manager 也提供类似的托管证书,各有自带证书的做法,思路相通,但产品与限制不同,本文不展开。
目标与设计
密钥生成与签名分离
先说清楚一个容易混淆的点:我们不用的是 Certbot 生成的私钥,而不是“Certbot 创建的证书”。证书必须由权威 CA 签发,Certbot 在这里的角色是 ACME 客户端,负责完成域名验证并取回签名结果。真正要剥夺的是 Certbot 生成和保存私钥的权力。
要做到这一点,需要把“生成密钥对”和“请求签名”两件事拆开:
- Key Vault 生成密钥对和 CSR。私钥创建在 Key Vault 内部,外部只拿到 CSR(Certificate Signing Request,证书签名请求,里面只有公钥和域名信息,没有私钥)。
- Certbot 拿着 CSR 去让 Let’s Encrypt 签名。Certbot 通过 DNS-01 挑战证明域名所有权,但签名的是我们给它的 CSR,所以它自始至终看不到私钥。
- 把签名后的证书链合并回 Key Vault 并保存。Key Vault 把它和自己保管的那把私钥配对,完成证书创建并存进库中,供消费端引用。
这正是 Key Vault 的“外部签名证书”能力:只要证书策略里的签发者是 Unknown,Key Vault 就停在“待处理(pending)”状态,等我们把外部 CA 签好的证书合并进来。
三方角色
整条链路涉及三方:
- Azure Key Vault:生成并保管私钥,出具 CSR;收到签名证书后与库内私钥配对,把它保存为证书。
- 签发作业(CI/CD 或定时任务):运行 Certbot,只持有 CSR 和签名结果,读不到私钥。
- Let’s Encrypt:通过 DNS-01 挑战验证域名所有权,对 CSR 签名。
消费端与 exportable
“私钥不离开 Key Vault”这句话有一个前提,就是证书策略里的 exportable 必须为 false。这是整个方案里最容易踩的坑:
exportable: false时,私钥只是 Key Vault 里的一个密钥对象,即使把它对应的 secret 取出来,内容里也不包含私钥。这才是真正的“私钥不出库”。exportable: true时,任何拿到secrets/get权限的人都能把私钥(连同 PFX)导出走,所谓“不离开 Key Vault”就名存实亡了。
代价是:不可导出的私钥,任何需要私钥材料来完成 TLS 握手的消费端都用不上——Key Vault 把密钥锁在库里,服务拿不到密钥本身,也就没法在自己这边终止 TLS。所以 exportable: false 换来的是“密码学意义上的私钥不出库”,代价是适用面很窄:它基本只能用于把 Key Vault 当签名引擎的场景(比如 mTLS 客户端证书、代码签名),而不是普通的 HTTPS 服务端证书。
一个具体的例子:这张证书只给 Application Gateway 用。 很多人以为这样就能设成不可导出,其实不行——官方文档写得很明确:“Key Vault certificates must have an exportable private key in order for the Application Gateway to be able to use them.” Application Gateway 是用自己的托管标识,在运行时去 Key Vault 里 Get 证书的 secret,把私钥装进监听器,所以私钥材料确实从 Key Vault 交到了 Application Gateway 手上。App Service 从 Key Vault 导入证书、Front Door 通过 Get 取用证书,也都属于这一类——它们都需要私钥。
所以要分清两种“不离开 Key Vault”:
- 密码学意义:
exportable: false,私钥永远出不了库;代价是消费端只能用 Key Vault 做密钥操作,普通的 TLS 终止做不到。 - 运营意义:
exportable: true,但私钥由消费端(Application Gateway、Front Door、App Service)用托管标识在运行时直接从 Key Vault 取用,运维和签发流水线从不经手 PFX,也不把它落到别处。这才是大多数企业场景里真实可得的收益。
| 消费方式 | exportable | 说明 |
|---|---|---|
| TLS 终止类服务(Application Gateway、Front Door、App Service) | true | 需要私钥材料,由服务用托管标识从 Key Vault 取用 |
| 需要证书文件的主机 / 容器 / Nginx / IIS | true | 私钥落到该主机,需另行防护 |
| 只用 Key Vault 做签名 / 解密(如 mTLS 客户端证书) | false | 私钥真正不出库 |
| 合规要求密钥托管在 HSM | 用 Managed HSM | 密钥天然不可导出 |
要注意,签发环节和 exportable 无关:无论设成哪个值,CSR 都由 Key Vault 生成,私钥都不经过 Certbot 和签发流水线;exportable 只决定消费端能不能拿到私钥材料。即便设成 true,也比“在构建机上生成私钥”安全得多——私钥由 Key Vault 生成,签发作业只有证书权限、拿不到 secrets/get(见下文的最小权限设计),连它自己签出来的私钥都读不到。
准备工作
签发作业的身份
执行这套流程需要一个 Azure 身份。它至少要做三件事:在 Key Vault 里创建/合并证书、在 DNS 里增删 TXT 记录、以及(首次运行时)注册 ACME 账户。
推荐用 用户分配的托管标识(UAI),或 GitHub Actions / Azure DevOps 的 OIDC 联合凭据。两者都能避免在流水线里存放长期的服务主体密码:
- 运行在 Azure 计算资源上(VM、Container Apps、AKS):用 UAI,
az login --identity --username <client-id>。 - 运行在 GitHub Actions / Azure DevOps:用工作负载联合身份,
az login时使用 federated token,不落盘任何密码。
最小权限
认证和授权是两回事。这里的关键是给这个身份刚好够用的权限——但“刚好够用”不是把权限压到最小,而是在安全和运维成本之间取一个平衡点。下面按“够用”和“更严”两档来看。
Key Vault 侧:内置角色 Key Vault Certificates Officer 已经贴近需要——它的 data actions 只有 certificatecas/*、certificates/*、certificatecontacts/write,不包含 secrets/*,即便签发作业被攻破,它也读不到私钥的私密部分(读私钥要走 secret 接口,需要 secrets/get)。日常直接用它就是合理的:签发流程和“读取私钥”本来就是两套权限。
如果还想再收一点,可以自定义一个只保留证书读、创建、更新的角色——内置角色除了这些,还包含删除、清除、备份、管理联系人等:
{ "Name": "Key Vault Certificate Issuer", "Description": "仅供外部 CA 证书的创建与合并使用", "Actions": [], "DataActions": [ "Microsoft.KeyVault/vaults/certificates/read", "Microsoft.KeyVault/vaults/certificates/create/action", "Microsoft.KeyVault/vaults/certificates/update/action", "Microsoft.KeyVault/vaults/certificates/delete" ], "NotDataActions": [], "AssignableScopes": ["/subscriptions/<SUBSCRIPTION_ID>"]}DNS 侧:这里才是真正要权衡的地方,有三档,宽松程度递减、运维成本递增:
DNS Zone Contributor(最省事,也最宽):它能管理整个区的所有记录类型,等于把域名解析交了出去。签发身份一旦出问题,影响面最大。- 只写 TXT 的自定义角色(折中):DNS-01 只在一个名字下增删 TXT,所以可以只给
dnsZones/TXT/*:
{ "Name": "ACME DNS TXT Contributor", "Description": "仅供 ACME DNS-01 挑战增删 TXT 记录", "Actions": [ "Microsoft.Network/dnsZones/read", "Microsoft.Network/dnsZones/TXT/read", "Microsoft.Network/dnsZones/TXT/write", "Microsoft.Network/dnsZones/TXT/delete" ], "NotActions": [], "DataActions": [], "NotDataActions": [], "AssignableScopes": ["/subscriptions/<SUBSCRIPTION_ID>"]}但要注意 TXT 记录本身也很敏感:SPF、DMARC、DKIM、站点验证令牌都是 TXT。拿到主区的 TXT 写权限,就可能在邮件安全上做手脚。
- 把
_acme-challenge委派到独立的挑战区(最严):在主区里为每个要签发的域名放一条 CNAME,指向一个单独的挑战区,只给这个身份写那个区的权限。这样它连主区的记录都碰不到——这条思路也符合 Let’s Encrypt 官方“使用更细粒度的凭据、在独立服务器上做验证”的建议。
第 3 档的代价是每条 CNAME 只覆盖一个主机名:子域名越多,主区里的 CNAME 就越多。有两点可以让它划算起来:
- 改用通配符证书:签
*.example.com时,DNS-01 只用_acme-challenge.example.com一个名字,一条 CNAME 就够,与子域名的数量无关。 - 用 IaC 管理这些 CNAME:写进 Pulumi / Terraform,新增子域名时随资源一起创建,而不是手工维护。
所以它算不上过度设计(over-engineering),关键看取舍:子域名不多、或者能上通配符,第 3 档很划算;子域名成百上千、又只能逐个签发,第 2 档更实际。
别忘了 CAA 记录:如果域名的 DNS 里配置了 CAA,需要确认它允许 letsencrypt.org 签发,否则请求会被拒绝。
网络与审计
企业环境的 Key Vault 通常应该关闭公网访问,用 Private Endpoint 走 Azure 私有链接,只允许签发作业所在的网络访问。同时:
- 打开 软删除(soft-delete)与清除保护(purge protection),避免误删导致私钥永久丢失。
- 把 Key Vault 的诊断日志送到 Log Analytics,记录每一次证书的创建与合并,作为审计证据。
- 订阅 Event Grid 的
Microsoft.KeyVault.CertificateNearExpiry、Microsoft.KeyVault.CertificateExpired、Microsoft.KeyVault.CertificateNewVersionCreated事件,用它们做到期提醒和新版本通知。
ACME 账户
先澄清一个常见的误解:即使加上 --register-unsafely-without-email(也就是常说的“匿名”注册),Certbot 仍然会在 Let’s Encrypt 注册一个 ACME 账户——ACME 协议本身就要求有账户,不存在真正匿名的签发。这个参数只是不提供邮箱联系方式,代价是收不到到期和吊销通知。
账户对应的是一对密钥,默认放在 --config-dir/accounts/ 下,它是一份敏感凭据,能以你的账户身份申请和吊销证书。所以:
- 不要每次运行都重新注册一个账户——否则既浪费 Let’s Encrypt 的注册额度(每个 IP 每 3 小时最多 10 个新账户),又丢掉了账户连续性。
- 用一个持久化的
--config-dir保存账户,不要每次运行都删掉整个目录;生产环境里放在受控的持久卷上,并像对待密钥一样保护它。 - 邮箱是可选填的,但建议提供(
--email),这样能收到到期和吊销通知。
在 GitHub Actions 这类临时运行器上怎么办? GitHub 托管的 runner 是临时的,没有持久卷,“持久化 --config-dir”就落不了地。思路是把账户存到运行器之外,每次运行再取回来:
- 一次性注册好账户,把
--config-dir/accounts/整个目录打包(tar+base64)。 - 把它存进 GitHub Actions secret,或者存进 Azure Key Vault。
- 每次 job 开始时解压回
--config-dir,Certbot 就会复用它,不再注册新账户。
用 GitHub secret 最省事(账户目录只有几 KB,远小于 secret 的大小上限):
- name: Restore ACME account run: | mkdir -p "${STATE_DIR}/config" printf '%s' "${{ secrets.ACME_ACCOUNT }}" \ | base64 -d | tar xzf - -C "${STATE_DIR}/config"首次注册和导出可以放在一次性 job 或本地做:tar czf - -C "${STATE_DIR}/config" accounts | base64,再把输出写进 secret。
有一个细节:workflow 默认没有写 secret 的权限(GITHUB_TOKEN 不能改 secret),所以如果希望 CI 能自动把新账户存回去,用 Key Vault 更顺——但别破坏前面“签发身份读不到私钥”的设计:把这个 ACME 账户密钥放进独立的 vault,或给它按 secret 作用域的角色分配,只授予这一条 secret 的 get。
另外,不要用 actions/cache 存账户:cache 不是给敏感数据用的(GitHub 本身也不建议往里面放密钥),而且随时可能被清理。
工具与 DNS 验证方式
pip install certbotCertbot 自带的 --manual 模式,配合 --manual-auth-hook / --manual-cleanup-hook 就能完成 DNS-01 挑战,不需要额外的插件。本文用这种方式:验证逻辑拿 Azure CLI 自己写,不引入第三方依赖——在需要审计、追求依赖最小化的场合,这比省下来的那点代码更重要。
代价是两个 hook 要自己负责两件事:在 _acme-challenge 下增删 TXT 记录,以及等待 DNS 传播(--manual 不会替你等)。
如果不在意第三方依赖,也有现成的 certbot-dns-azure 插件,它内部同样是通过 Azure API 增删记录,并且会自己等待传播:
pip install certbot-dns-azure它支持托管标识、工作负载身份、CLI 凭据等多种认证方式,都不用把密码写进配置。两种方式二选一即可,下文用前者(manual hook)。
完整实操
下面用一个目标域名 app.example.com 走一遍。命令本身都不长,关键在每一步的意图;理解了流程,再按自己的环境把变量填好即可。
创建 pending 证书(CSR)
用一份证书策略在 Key Vault 里创建证书,签发者设为 Unknown,表示“由外部 CA 签名”。Key Vault 会生成密钥对,并返回一个 CSR——注意它是 base64 编码的 DER、没有 PEM 头,需要自己补上 -----BEGIN CERTIFICATE REQUEST----- / -----END CERTIFICATE REQUEST----- 再落成 .pem 文件。
策略里几个要点:
keyProperties.exportable:私钥是否可导出(前文讨论过的取舍);keyType/keySize:RSA或EC及其长度;reuseKey: false:每次续期生成新密钥,满足密钥轮换;subject与subjectAlternativeNames.dnsNames:把域名放进 SAN(现代 TLS 客户端只看 SAN,不看 CN);validityInMonths:只是信息性的,真正的有效期由 CA 决定。
一份最小的策略文件(policy.json)长这样,把域名和 exportable 按需要改即可:
{ "issuerParameters": { "name": "Unknown" }, "keyProperties": { "exportable": false, "keyType": "RSA", "keySize": 2048, "reuseKey": false }, "secretProperties": { "contentType": "application/x-pkcs12" }, "x509CertificateProperties": { "subject": "CN=app.example.com", "subjectAlternativeNames": { "dnsNames": ["app.example.com"] }, "keyUsage": ["digitalSignature", "keyEncipherment"], "validityInMonths": 3 }}用它创建证书并取回 CSR:
az keyvault certificate create \ --vault-name "${KEY_VAULT_NAME}" \ --name "${KEY_VAULT_CERT_NAME}" \ --policy @policy.json \ --query csr -o tsv如果这个证书名上还留着上次没走完的 pending 操作,az keyvault certificate pending show 能看出来;要么先排查,要么清掉再重来:
# 查看遗留的 pending 操作az keyvault certificate pending show \ --vault-name "${KEY_VAULT_NAME}" \ --name "${KEY_VAULT_CERT_NAME}"
# 排查后确认可以丢弃,再删除az keyvault certificate pending delete \ --vault-name "${KEY_VAULT_NAME}" \ --name "${KEY_VAULT_CERT_NAME}"用 Certbot 对 CSR 签名
把 CSR 交给 Certbot。域名来自 CSR,所以不要再传 -d;Certbot 全程看不到私钥:
certbot certonly \ --non-interactive --agree-tos \ --email "${ACME_EMAIL}" \ --staging \ --csr request.csr.pem \ --manual --preferred-challenges dns \ --manual-auth-hook ./azure-auth.sh \ --manual-cleanup-hook ./azure-cleanup.sh \ --cert-path cert.pem \ --chain-path chain.pem \ --fullchain-path fullchain.pem \ --config-dir ./letsencrypt/config \ --work-dir ./letsencrypt/work \ --logs-dir ./letsencrypt/logs上面的命令带 --staging,也就是 --test-cert 的别名:它切到 Let’s Encrypt 的测试环境,签发的证书不受信任、只用于跑通流程,也不会消耗生产额度。对应地,证书名要带 -staging 后缀(见「Staging 环境与速率限制」)。确认流程无误后,去掉 --staging 再签发生产证书。
不想提供邮箱就把它换成 --register-unsafely-without-email(原因见「ACME 账户」一节)。--config-dir 指向一个持久化目录来保存 ACME 账户;--manual-auth-hook / --manual-cleanup-hook 指到下面写的 DNS 脚本。
合并证书回 Key Vault
Certbot 产出的 fullchain.pem 顺序是“叶子在前、中间证书在后、不含根”。把它合并进上一步的 pending 证书:
az keyvault certificate pending merge \ --vault-name "${KEY_VAULT_NAME}" \ --name "${KEY_VAULT_CERT_NAME}" \ --file fullchain.pemKey Vault 会把它和自己保管的私钥配对,完成证书创建。
校验
合并后要确认“库里这张证书”和“刚签发的结果”是同一张:用 az keyvault certificate show 取回指纹,和本地 openssl x509 -fingerprint -sha1 算出的对比,不一致就说明配错了。
MERGED=$(az keyvault certificate show \ --vault-name "${KEY_VAULT_NAME}" --name "${KEY_VAULT_CERT_NAME}" \ --query x509ThumbprintHex -o tsv)LEAF=$(openssl x509 -in fullchain.pem -noout -fingerprint -sha1 | cut -d= -f2 | tr -d ':')[ "${MERGED^^}" = "${LEAF^^}" ] || { echo "指纹不一致"; exit 1; }DNS 验证 hook
--manual 模式下,Certbot 会为每个待验证的域名调用一次 auth hook、验证后再调用 cleanup hook,并通过环境变量 CERTBOT_DOMAIN(正在验证的域名)与 CERTBOT_VALIDATION(要写进 TXT 的值)传参。本文用两个小脚本承接——./azure-auth.sh 负责写入,./azure-cleanup.sh 负责清理。
./azure-auth.sh:验证前写入 TXT 并等待传播。 它先把域名换算成相对 DNS 区域的记录名(app.example.com → _acme-challenge.app,apex 则是 _acme-challenge),核心就是一条 az 命令:
az network dns record-set txt add-record \ --resource-group "${DNS_RESOURCE_GROUP}" --zone-name "${DNS_ZONE_NAME}" \ --record-set-name "${RECORD_NAME}" --value "${CERTBOT_VALIDATION}" -o none写完还要轮询公共递归 DNS 等待生效——--manual 不会替你等传播。
./azure-cleanup.sh:验证后只删自己写入的值。 用 --value 精确删除很重要:apex 和通配符(example.com 与 *.example.com)会共用同一个 _acme-challenge,删整条会把别的挑战值一起删掉。
az network dns record-set txt remove-record \ --resource-group "${DNS_RESOURCE_GROUP}" --zone-name "${DNS_ZONE_NAME}" \ --record-set-name "${RECORD_NAME}" --value "${CERTBOT_VALIDATION}" -o none两个脚本的完整写法(含环境变量校验、记录名换算、for 轮询等待)如下:
#!/usr/bin/env bash# azure-auth.sh —— 写入 _acme-challenge 的 TXT 并等待传播set -euo pipefail
: "${CERTBOT_DOMAIN:?Certbot 注入}": "${CERTBOT_VALIDATION:?Certbot 注入}": "${DNS_ZONE_NAME:?}": "${DNS_RESOURCE_GROUP:?}"
# 记录名相对区域:app.example.com -> _acme-challenge.appif [ "${CERTBOT_DOMAIN}" = "${DNS_ZONE_NAME}" ]; then RECORD_NAME="_acme-challenge"else RECORD_NAME="_acme-challenge.${CERTBOT_DOMAIN%.${DNS_ZONE_NAME}}"fi
az network dns record-set txt add-record \ --resource-group "${DNS_RESOURCE_GROUP}" --zone-name "${DNS_ZONE_NAME}" \ --record-set-name "${RECORD_NAME}" --value "${CERTBOT_VALIDATION}" -o none
# --manual 不会替你等传播,这里轮询公共递归 DNS(dig 来自 dnsutils / bind-utils)for _ in $(seq 1 30); do if dig +short TXT "${RECORD_NAME}.${DNS_ZONE_NAME}" @8.8.8.8 \ | grep -qF "${CERTBOT_VALIDATION}"; then exit 0 fi sleep 10doneecho "ERROR: TXT 记录 ${RECORD_NAME}.${DNS_ZONE_NAME} 未生效" >&2exit 1cleanup hook 只删自己写入的值:
#!/usr/bin/env bash# azure-cleanup.sh —— 只删除本次写入的那个 TXT 值set -euo pipefail
: "${CERTBOT_DOMAIN:?}": "${CERTBOT_VALIDATION:?}": "${DNS_ZONE_NAME:?}": "${DNS_RESOURCE_GROUP:?}"
if [ "${CERTBOT_DOMAIN}" = "${DNS_ZONE_NAME}" ]; then RECORD_NAME="_acme-challenge"else RECORD_NAME="_acme-challenge.${CERTBOT_DOMAIN%.${DNS_ZONE_NAME}}"fi
# apex 与通配符会共用同一个 _acme-challenge,删整条会误伤,所以只删自己写的值az network dns record-set txt remove-record \ --resource-group "${DNS_RESOURCE_GROUP}" --zone-name "${DNS_ZONE_NAME}" \ --record-set-name "${RECORD_NAME}" --value "${CERTBOT_VALIDATION}" -o none || true关于 cleanup 的一个坑:Certbot 会在验证之后调用 cleanup hook,但如果 certbot 进程在调用它之前就异常退出(崩溃、被 kill,或中途某一步报错),这个 hook 可能不会执行,TXT 记录就留在 DNS 里了。好在残留的 TXT 记录本身无害——下次验证会写入新值,Let’s Encrypt 只匹配本次的预期值。如果在意整洁,可以再挂一个定时任务巡检并清理陈旧的 _acme-challenge 记录。
工程细节
- staging 隔离:用 staging 时把证书名加上
-staging后缀并做强制校验,避免不受信任的测试证书被合并进生产证书的版本。 - 并发保护:用
flock对同一个证书加锁,避免两次运行同时操作。 - 幂等:签发前先检查有没有遗留的 pending 操作;auth hook 用“追加”而非“覆盖”的方式写 TXT。
- 自校验:合并后按第 4 步比对指纹,失败就报错,别让它悄悄过去。
运行顺序
先设置环境变量(目标域名、Key Vault 名称与证书名、DNS 区域与资源组),然后先打开 staging 跑一遍完整流程;确认无误后,再切回生产证书名跑一遍。另外,不要每次运行都重新注册 ACME 账户,账户的持久化见「ACME 账户」一节。
Staging 环境与速率限制
Let’s Encrypt 的证书签发有速率限制,调试阶段最容易撞上的几条是:
- 相同标识符集(exact set of identifiers):每 7 天最多 5 张。反复用同一组域名申请,第 6 次就会被拒。这是全局限制,所有账户的请求都计入,而且吊销证书并不会返还额度。
- 同一注册域名:每 7 天最多 50 张,同样是全局限制。
- 每账户新订单:每 3 小时最多 300 个。
这几条限制设计得很不友好的一点,恰好戳中本方案:每次调用 az keyvault certificate create 都会开启一个新的订单。如果合并失败、又反复重试,额度会很快被吃掉。所以正确的姿势是:
- 用 staging 环境把整条链路跑通。staging 的额度比生产高得多(相同标识符集每周约 30000 张),签出来的证书不受信任,只能用于测试。
- staging 的证书必须用一个带
-staging后缀的独立证书名,并在流程里强制校验,否则一旦有人把它合并进生产证书,线上 TLS 立刻失效。 - 生产环境只在 staging 演练通过、且确实到达续期窗口时才运行。
还有两个容易忽略的点:
--csr模式拿不到 ARI 豁免。 正常模式下,支持 ACME Renewal Info(ARI)的客户端续期可以豁免全部速率限制;但--csr只能配合certonly子命令使用,谈不上由 Certbot 管理的续期谱系,所以这份优惠用不上。这也是为什么续期必须自己调度、并且要珍惜额度。- 公信证书会进入证书透明度(CT)日志。 签发的域名会被公开记录,内部敏感主机名不适合用公信证书。
自动化续期
--csr 模式不能用 certbot renew,Key Vault 对非合作 CA(Let’s Encrypt 不是 Key Vault 的内置合作 CA)也不提供自动续期。所以必须自己写调度逻辑。
具体做法是“先判断、再签发”:先用 az keyvault certificate show 读出证书的到期时间,算出剩余天数;只有剩余低于阈值时才真正走一遍签发流程,否则直接跳过。这样既不会空跑,也避免无谓地消耗 Let’s Encrypt 的额度。
# 读出到期时间,再自行换算剩余天数az keyvault certificate show \ --vault-name "${KEY_VAULT_NAME}" \ --name "${KEY_VAULT_CERT_NAME}" \ --query attributes.expires -o tsv注意,--csr 模式下 Key Vault 策略里的 validityInMonths 只是信息性的,真正的有效期由 CA 决定。而且这个有效期正在变短:Let’s Encrypt 目前默认签发 90 天,但已明确会在 2028 年 2 月前缩短到 45 天;6 天有效期的短命证书(160 小时)也已可用(选用 ACME 的 shortlived 证书配置即可)。更大的背景是行业规则——CA/Browser Forum 的 SC-081 把公开 TLS 证书的最长有效期从 398 天逐步压到 200 天(2026-03-15)、100 天(2027-03-15)、47 天(2029-03-15)。变短的动因之一是安全:压缩误签与密钥泄露的有效窗口;但它同时“倒逼自动化”——有效期只有几十天时,任何手工续期都不可靠。
对本文方案来说,这意味着续期要比过去更频繁、更可靠:
- 续期必须做成定时任务并接入监控(cron、systemd timer、GitHub Actions 的
schedule、Azure DevOps 的 scheduled pipeline 都可以),每天或每周检查一次。 --csr模式拿不到 ARI 续期豁免(见上文),额度更经不起浪费,“只在到期窗口内签发”这个判断不能省。- 不要把阈值写死成
RENEW_BEFORE_DAYS=30,按有效期比例来设更稳,例如剩余不足 1/3 就续期;证书变短时窗口会自动跟着变短。
调度之外,还要做监控,否则“续期失败”可能直到证书过期才被发现:
- 续期任务失败要告警(流水线失败通知,或写入监控系统)。
- 订阅 Key Vault 的
CertificateNearExpiry/CertificateExpired事件作为第二道保险。 - 签发作业的日志(Certbot 的
--logs-dir)应被采集,用于审计与排障。
故障排查
合并后证书与私钥不匹配? 合并时的 fullchain.pem 必须来自同一个 CSR 对应的签名结果,且叶子证书在前、中间证书在后、不要带根证书。上文第 4 步的指纹校验就是为了拦住这种错配。
验证一直失败(DNS-01)? 确认 _acme-challenge.<域名> 的 TXT 记录确实被创建、并且能被公网递归 DNS 解析到。本文的 auth hook 会轮询公共递归 DNS 等待传播,但前提是签发作业能访问公共解析——如果所在网络启用了私有 DNS 区域,它可能查不到刚刚写入的记录。别忘了 CAA 记录。
卡在 pending 状态? 说明证书已创建但始终没有合并。脚本默认会直接报错而不是悄悄删掉,就是为了让这种异常可见。排查后确认可以丢弃时,再用 DELETE_PENDING=true 重跑。
撞上速率限制? 看错误信息里的 Retry-After,按提示时间等待即可,没有提前重置的办法。开发测试请改用 staging;生产环境要避免“每次部署都重新签发”,只按到期窗口续期。
安全清单
| 项目 | 做法 |
|---|---|
| 私钥保管 | 默认 exportable: false;TLS 终止类消费端需 true,由服务用托管标识取用;合规场景用 Managed HSM |
| 密钥轮换 | reuseKey: false,每次续期生成新密钥 |
| 签发身份 | UAI 或 OIDC 联合凭据,不用长期密钥 |
| Key Vault 权限 | 只给证书相关角色,不含 secrets/* |
| DNS 权限 | 自建只写 TXT 的角色,或用 CNAME 委派到独立挑战区 |
| 网络 | Private Endpoint + 关闭公网访问 |
| 数据保护 | 开启软删除与清除保护 |
| 审计监控 | 诊断日志入 Log Analytics,订阅证书事件 |
| ACME 账户 | 用持久化的 --config-dir 保存;邮箱可选但建议填 |
| 测试隔离 | staging 用 -staging 后缀的独立证书名 |
| 到期与续期 | 自带调度 + 失败告警,不依赖 Key Vault 自动续期 |
小结
企业环境里最值钱、也最容易在自动化流程中被忽视的东西,是私钥。把私钥的生成与保管交给 Azure Key Vault、让 Certbot 退回到“ACME 客户端”的本分,再用最小权限和 staging 演练把整条链路管起来,就能在保留 Let’s Encrypt 免费与自动化的同时,满足企业最基本的安全与审计要求。剩下的取舍——私钥到底能不能导出、证书落在哪里——取决于谁来消费它,这需要在动手之前就想清楚。
参考
- Let’s Encrypt Rate Limits
- Let’s Encrypt Staging Environment
- Challenge Types (DNS-01)
- Let’s Encrypt: Certificate Lifetime Rationale and Plans
- Let’s Encrypt: 6-day and IP Address Certificates are Generally Available
- CA/Browser Forum Ballot SC-081v3: Schedule of Reducing Validity
- Azure Key Vault: Certificate creation methods
- Azure Key Vault: About certificates
- Grant permission to applications to access an Azure key vault using Azure RBAC
- Application Gateway: Key Vault certificates
- Azure Front Door: End-to-end TLS
- certbot-dns-azure documentation
- Certbot User Guide