跳到内容
观澜涉川 · 札记EN

企业环境下的 Certbot 实战:用 Azure Key Vault 托管私钥签发 SSL 证书

· 约 19 分钟阅读

在单机场景里,用 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 生成和保存私钥的权力。

要做到这一点,需要把“生成密钥对”和“请求签名”两件事拆开:

  1. Key Vault 生成密钥对和 CSR。私钥创建在 Key Vault 内部,外部只拿到 CSR(Certificate Signing Request,证书签名请求,里面只有公钥和域名信息,没有私钥)。
  2. Certbot 拿着 CSR 去让 Let’s Encrypt 签名。Certbot 通过 DNS-01 挑战证明域名所有权,但签名的是我们给它的 CSR,所以它自始至终看不到私钥。
  3. 把签名后的证书链合并回 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 / IIStrue私钥落到该主机,需另行防护
只用 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 侧:这里才是真正要权衡的地方,有三档,宽松程度递减、运维成本递增:

  1. DNS Zone Contributor(最省事,也最宽):它能管理整个区的所有记录类型,等于把域名解析交了出去。签发身份一旦出问题,影响面最大。
  2. 只写 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 写权限,就可能在邮件安全上做手脚。

  1. 把 _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”就落不了地。思路是把账户存到运行器之外,每次运行再取回来:

  1. 一次性注册好账户,把 --config-dir/accounts/ 整个目录打包(tar + base64)。
  2. 把它存进 GitHub Actions secret,或者存进 Azure Key Vault。
  3. 每次 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 验证方式

Terminal window
pip install certbot

Certbot 自带的 --manual 模式,配合 --manual-auth-hook / --manual-cleanup-hook 就能完成 DNS-01 挑战,不需要额外的插件。本文用这种方式:验证逻辑拿 Azure CLI 自己写,不引入第三方依赖——在需要审计、追求依赖最小化的场合,这比省下来的那点代码更重要。

代价是两个 hook 要自己负责两件事:在 _acme-challenge 下增删 TXT 记录,以及等待 DNS 传播(--manual 不会替你等)。

如果不在意第三方依赖,也有现成的 certbot-dns-azure 插件,它内部同样是通过 Azure API 增删记录,并且会自己等待传播:

Terminal window
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:

Terminal window
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 能看出来;要么先排查,要么清掉再重来:

Terminal window
# 查看遗留的 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 全程看不到私钥:

Terminal window
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 证书:

Terminal window
az keyvault certificate pending merge \
--vault-name "${KEY_VAULT_NAME}" \
--name "${KEY_VAULT_CERT_NAME}" \
--file fullchain.pem

Key Vault 会把它和自己保管的私钥配对,完成证书创建。

校验

合并后要确认“库里这张证书”和“刚签发的结果”是同一张:用 az keyvault certificate show 取回指纹,和本地 openssl x509 -fingerprint -sha1 算出的对比,不一致就说明配错了。

Terminal window
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 命令:

Terminal window
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,删整条会把别的挑战值一起删掉。

Terminal window
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.app
if [ "${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 10
done
echo "ERROR: TXT 记录 ${RECORD_NAME}.${DNS_ZONE_NAME} 未生效" >&2
exit 1

cleanup 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 都会开启一个新的订单。如果合并失败、又反复重试,额度会很快被吃掉。所以正确的姿势是:

  1. 用 staging 环境把整条链路跑通。staging 的额度比生产高得多(相同标识符集每周约 30000 张),签出来的证书不受信任,只能用于测试。
  2. staging 的证书必须用一个带 -staging 后缀的独立证书名,并在流程里强制校验,否则一旦有人把它合并进生产证书,线上 TLS 立刻失效。
  3. 生产环境只在 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 的额度。

Terminal window
# 读出到期时间,再自行换算剩余天数
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 免费与自动化的同时,满足企业最基本的安全与审计要求。剩下的取舍——私钥到底能不能导出、证书落在哪里——取决于谁来消费它,这需要在动手之前就想清楚。

参考