acme.sh 自动更新阿里云 CDN 与 DCDN 证书:多账号部署与排错

太阳作者太阳
原创内容采用 CC-4.0 协议发布,转载请注明出处
acme.sh证书CDN

这次故障最容易误判的地方,是把“证书续签成功”和“线上证书已经更新”当成同一件事。acme.sh 负责签发和续签,CDN、DCDN 是否拿到新证书,还要看部署钩子有没有正确登记并执行。

下面先整理一套可复现的配置,再记录这次故障的判断依据和后续维护方法。

签发与部署

我的场景是使用阿里云 DNS 完成域名验证,为主域名和通配符域名签发同一张证书,再把证书部署到多个阿里云账号下的 CDN 和 DCDN。

安装 acme.sh

安装时设置联系邮箱,并通过 --log--log-level 2 启用详细日志。没有给 --log 指定路径时,日志默认写入 $HOME/.acme.sh/acme.sh.log

curl https://get.acme.sh | sh -s \
  email=OWNER@example.com \
  --log \
  --log-level 2

acme.sh --set-default-ca --server letsencrypt

通过阿里云 DNS 签发通配符证书

先在当前终端设置具有 DNS 解析权限的阿里云 AccessKey:

export Ali_Key="YOUR_ALIYUN_ACCESS_KEY_ID"
export Ali_Secret="YOUR_ALIYUN_ACCESS_KEY_SECRET"

然后使用 dns_ali 完成 DNS 验证,同时签发主域名和通配符域名证书:

acme.sh --issue --dns dns_ali \
  -d example.com \
  -d '*.example.com'

部署到同一阿里云账号

通过环境变量指定使用这张证书的 CDN、DCDN 域名:

export DEPLOY_ALI_CDN_DOMAIN="cdn.example.com api.example.com"
export DEPLOY_ALI_DCDN_DOMAIN="dcdn-api.example.com dcdn-ws.example.com"

部署时在同一条命令中指定两个钩子,让 acme.sh 同时保存 ali_cdnali_dcdn

acme.sh --deploy -d example.com \
  --deploy-hook ali_cdn \
  --deploy-hook ali_dcdn

这个写法只适用于 CDN 和 DCDN 属于同一个阿里云账号的情况。两个钩子在一次执行中共用同一组 Ali_KeyAli_Secret;acme.sh 会保存部署域名、凭据和钩子配置,后续续签成功时继续执行这两个钩子。

部署到多个阿里云账号

如果 CDN、DCDN 分属不同的阿里云账号,就不能只把内置的 ali_cdnali_dcdn 放进同一条命令。阿里云 DNS 和两个部署钩子共用全局的 Ali_KeyAli_Secret,普通的多钩子命令不能为每个目标单独绑定账号。

acme.sh 3.1.2 之后提供了 multideploy,可以为多个服务编排部署环境。不过阿里云钩子的凭据还会由 dns_ali 写入全局 account.conf。为了让账号切换和 DNS 凭据恢复都能直接检查,我在这个场景里仍使用一个很小的自定义钩子。

手动部署时,可以为每条命令临时传入对应账号的凭据和域名:

Ali_Key="ACCOUNT_A_ACCESS_KEY_ID" \
Ali_Secret="ACCOUNT_A_ACCESS_KEY_SECRET" \
DEPLOY_ALI_CDN_DOMAIN="cdn-a.example.com" \
acme.sh --deploy -d example.com --deploy-hook ali_cdn

Ali_Key="ACCOUNT_B_ACCESS_KEY_ID" \
Ali_Secret="ACCOUNT_B_ACCESS_KEY_SECRET" \
DEPLOY_ALI_DCDN_DOMAIN="dcdn-b.example.com" \
acme.sh --deploy -d example.com --deploy-hook ali_dcdn

这种方式可以完成当次部署,但每次 --deploy 都会覆盖 Le_DeployHook,最后只保留最后一次使用的钩子,不能直接作为自动续签方案。

自动部署时,把所有账号操作收口到一个自定义部署钩子,例如 ali_multi_account

  • 只把 ali_multi_account 保存到 Le_DeployHook
  • 钩子从权限为 0600 的私有配置文件读取各账号凭据,不把凭据写进文章或 crontab。
  • 钩子按账号依次设置 Ali_KeyAli_Secret 和目标域名,再调用对应的 CDN 或 DCDN 部署逻辑。
  • 全部部署结束后,把 acme.sh 全局保存的阿里云凭据恢复为 DNS 验证账号,避免下次续签时无法修改 DNS 记录。

下面的示例包含两个 CDN 账号和一个 DCDN 账号。先创建私有配置目录与 $HOME/.acme.sh/private/aliyun-accounts.conf,再按实际情况填写凭据和域名:

mkdir -p "$HOME/.acme.sh/private"
chmod 700 "$HOME/.acme.sh/private"
# DNS 验证账号
ALI_DNS_KEY="DNS_ACCOUNT_ACCESS_KEY_ID"
ALI_DNS_SECRET="DNS_ACCOUNT_ACCESS_KEY_SECRET"

# 第一个 CDN 账号
ALI_CDN_A_KEY="CDN_ACCOUNT_A_ACCESS_KEY_ID"
ALI_CDN_A_SECRET="CDN_ACCOUNT_A_ACCESS_KEY_SECRET"
ALI_CDN_A_DOMAINS="cdn-a.example.com api-a.example.com"

# 第二个 CDN 账号
ALI_CDN_B_KEY="CDN_ACCOUNT_B_ACCESS_KEY_ID"
ALI_CDN_B_SECRET="CDN_ACCOUNT_B_ACCESS_KEY_SECRET"
ALI_CDN_B_DOMAINS="cdn-b.example.com"

# DCDN 账号
ALI_DCDN_KEY="DCDN_ACCOUNT_ACCESS_KEY_ID"
ALI_DCDN_SECRET="DCDN_ACCOUNT_ACCESS_KEY_SECRET"
ALI_DCDN_DOMAINS="dcdn-api.example.com dcdn-ws.example.com"

配置文件会被钩子作为 shell 文件加载,只能由当前用户修改:

chmod 600 "$HOME/.acme.sh/private/aliyun-accounts.conf"

然后创建 $HOME/.acme.sh/deploy/ali_multi_account.sh。如果账号数量不同,配置项、完整性检查和部署调用要一起增删:

#!/usr/bin/env sh

ali_multi_account_deploy() {
  _config_file="${ALI_MULTI_ACCOUNT_CONFIG:-$HOME/.acme.sh/private/aliyun-accounts.conf}"

  if [ ! -r "$_config_file" ]; then
    _err "Aliyun account config is not readable: $_config_file"
    return 1
  fi

  . "$_config_file"

  if [ -z "$ALI_DNS_KEY" ] || [ -z "$ALI_DNS_SECRET" ] ||
    [ -z "$ALI_CDN_A_KEY" ] || [ -z "$ALI_CDN_A_SECRET" ] || [ -z "$ALI_CDN_A_DOMAINS" ] ||
    [ -z "$ALI_CDN_B_KEY" ] || [ -z "$ALI_CDN_B_SECRET" ] || [ -z "$ALI_CDN_B_DOMAINS" ] ||
    [ -z "$ALI_DCDN_KEY" ] || [ -z "$ALI_DCDN_SECRET" ] || [ -z "$ALI_DCDN_DOMAINS" ]; then
    _err "Aliyun multi-account config is incomplete"
    return 1
  fi

  _ali_cdn_hook="$(_findHook "$1" "$_SUB_FOLDER_DEPLOY" "ali_cdn")"
  _ali_dcdn_hook="$(_findHook "$1" "$_SUB_FOLDER_DEPLOY" "ali_dcdn")"

  if [ ! -r "$_ali_cdn_hook" ] || [ ! -r "$_ali_dcdn_hook" ]; then
    _err "Built-in Aliyun deploy hooks were not found"
    return 1
  fi

  . "$_ali_cdn_hook"
  . "$_ali_dcdn_hook"

  _deploy_result=0

  Ali_Key="$ALI_CDN_A_KEY"
  Ali_Secret="$ALI_CDN_A_SECRET"
  DEPLOY_ALI_CDN_DOMAIN="$ALI_CDN_A_DOMAINS"
  export Ali_Key Ali_Secret DEPLOY_ALI_CDN_DOMAIN
  ali_cdn_deploy "$@" || _deploy_result=1

  Ali_Key="$ALI_CDN_B_KEY"
  Ali_Secret="$ALI_CDN_B_SECRET"
  DEPLOY_ALI_CDN_DOMAIN="$ALI_CDN_B_DOMAINS"
  export Ali_Key Ali_Secret DEPLOY_ALI_CDN_DOMAIN
  ali_cdn_deploy "$@" || _deploy_result=1

  Ali_Key="$ALI_DCDN_KEY"
  Ali_Secret="$ALI_DCDN_SECRET"
  DEPLOY_ALI_DCDN_DOMAIN="$ALI_DCDN_DOMAINS"
  export Ali_Key Ali_Secret DEPLOY_ALI_DCDN_DOMAIN
  ali_dcdn_deploy "$@" || _deploy_result=1

  # 内置阿里云钩子会覆盖全局凭据,结束前恢复 DNS 验证账号。
  Ali_Key="$ALI_DNS_KEY"
  Ali_Secret="$ALI_DNS_SECRET"
  _saveaccountconf_mutable Ali_Key "$Ali_Key"
  _saveaccountconf_mutable Ali_Secret "$Ali_Secret"

  unset Ali_Key Ali_Secret DEPLOY_ALI_CDN_DOMAIN DEPLOY_ALI_DCDN_DOMAIN
  return "$_deploy_result"
}

限制钩子文件权限:

chmod 700 "$HOME/.acme.sh/deploy/ali_multi_account.sh"

自定义钩子准备好后,只登记它:

acme.sh --deploy -d example.com --deploy-hook ali_multi_account

检查自动续期配置

查看 acme.sh 管理的证书和系统中的定时任务:

acme.sh --list
crontab -l

确认这张证书保存了符合当前场景的部署钩子:

grep '^Le_DeployHook=' "$HOME/.acme.sh/example.com_ecc/example.com.conf"

同一账号下同时使用 CDN 和 DCDN 时,结果应包含 ali_cdnali_dcdn;部署到多个账号时,结果应只包含 ali_multi_account。不要只检查证书是否续签,还要确认部署钩子与当前拓扑一致。

启用续期日志

acme.sh 默认创建的 cron 命令会把标准输出重定向到 /dev/null。如果没有另外启用文件日志,后续只能知道任务是否运行过,无法查看完整的续签和部署过程。

在当前用户下执行一次下面的命令,立即检查所有证书,并把日志路径和日志级别保存到 account.conf

"$HOME/.acme.sh/acme.sh" --cron \
  --home "$HOME/.acme.sh" \
  --log "$HOME/.acme.sh/acme.sh.log" \
  --log-level 2

这条命令可能执行已经到期的续签和部署钩子;尚未到续签时间的证书会被跳过。日志配置保存后,原有 cron 命令也会继续写入同一个日志文件,不需要手工修改 crontab。

执行后检查配置和日志文件:

grep -E '^(LOG_FILE|LOG_LEVEL)=' "$HOME/.acme.sh/account.conf"
ls -lh "$HOME/.acme.sh/acme.sh.log"
tail -n 100 "$HOME/.acme.sh/acme.sh.log"

需要观察下一次定时任务时,可以持续查看日志:

tail -f "$HOME/.acme.sh/acme.sh.log"

问题排查

我发现一个由阿里云 DCDN 加速的业务域名证书已经过期。设备上有 acme.sh 定时任务,本地通配符证书也仍然有效。问题不在签发,而在部署。

当时能确认的事实有这些:

  • crontab 每天执行一次 acme.sh;
  • 本地通配符证书已按时续签,仍在有效期内;
  • 对应的证书配置当时只保存了 Le_DeployHook='ali_cdn,'
  • 使用 ali_dcdn 的业务域名因此没有收到续签后的新证书。
  • 我手动执行 ali_dcdn 部署后,线上证书才与本地证书恢复一致。

原因很直接:多次单独执行 acme.sh --deploy 时,后一次命令会覆盖证书配置中的 Le_DeployHook,不会在原有值后面追加。分别执行下面两条命令,最终只会留下 ali_dcdn

acme.sh --deploy -d example.com --deploy-hook ali_cdn
acme.sh --deploy -d example.com --deploy-hook ali_dcdn

同账号场景可以在一条命令里登记两个钩子:

acme.sh --deploy -d example.com \
  --deploy-hook ali_cdn \
  --deploy-hook ali_dcdn

这次故障涉及多个阿里云账号,所以只登记前文的自定义钩子:

acme.sh --deploy -d example.com --deploy-hook ali_multi_account

在自定义钩子完成前,按账号分别执行内置钩子只能用于恢复当次线上证书,下一次续签仍可能漏掉目标账号。

故障发生时,这台设备没有为 acme.sh 配置独立日志文件,cron 又把标准输出重定向到 /dev/null,所以拿不到当时的完整执行日志。最后只能用 crontab、证书文件时间、Le_DeployHook、线上证书有效期和指纹交叉判断。事后补上文件日志,主要是为了下次能够区分“没有续签”“续签成功但没有部署”和“部署调用失败”。

日常维护

检查证书和定时任务

定期查看 acme.sh 版本、已管理证书和 crontab:

"$HOME/.acme.sh/acme.sh" --version
"$HOME/.acme.sh/acme.sh" --list
crontab -l

查看本地证书的有效期、序列号和指纹:

openssl x509 \
  -in "$HOME/.acme.sh/example.com_ecc/fullchain.cer" \
  -noout -subject -issuer -dates -serial -fingerprint -sha256

查看线上域名实际提供的证书:

timeout 10 openssl s_client \
  -connect cdn-a.example.com:443 \
  -servername cdn-a.example.com </dev/null 2>/dev/null |
  openssl x509 -noout -subject -issuer -dates -serial -fingerprint -sha256

本地和线上的序列号、SHA-256 指纹应保持一致。多账号部署时,需要逐个检查所有 CDN、DCDN 域名,不能只验证其中一个。

查看日志和部署结果

查看最近 200 行日志:

tail -n 200 "$HOME/.acme.sh/acme.sh.log"

筛选续签、部署和错误信息:

grep -Eni 'renew|deploy|error|failed|invalid' \
  "$HOME/.acme.sh/acme.sh.log"

需要立即执行一次与 cron 相同的检查时运行:

"$HOME/.acme.sh/acme.sh" --cron \
  --home "$HOME/.acme.sh" \
  --log "$HOME/.acme.sh/acme.sh.log" \
  --log-level 2

这不是只读命令。已经到续签时间的证书会被续签,随后执行已登记的部署钩子。运行结束后,不要只看日志里的 renew success,还要检查部署结果和线上证书指纹。

修改多账号配置或钩子

账号凭据或目标域名变化时,修改私有配置文件;部署流程变化时,修改自定义钩子:

${EDITOR:-vi} "$HOME/.acme.sh/private/aliyun-accounts.conf"
${EDITOR:-vi} "$HOME/.acme.sh/deploy/ali_multi_account.sh"

修改后重新限制权限,并检查两个文件的 shell 语法:

chmod 700 "$HOME/.acme.sh/private"
chmod 600 "$HOME/.acme.sh/private/aliyun-accounts.conf"
chmod 700 "$HOME/.acme.sh/deploy/ali_multi_account.sh"
sh -n "$HOME/.acme.sh/private/aliyun-accounts.conf"
sh -n "$HOME/.acme.sh/deploy/ali_multi_account.sh"

确认语法无误后,手动部署一次并检查日志和线上证书:

"$HOME/.acme.sh/acme.sh" --deploy \
  -d example.com \
  --deploy-hook ali_multi_account

tail -n 200 "$HOME/.acme.sh/acme.sh.log"

更新 acme.sh

这里使用了自定义部署钩子,我选择手动更新 acme.sh,不让 cron 自动升级。更新前先确认私有配置和钩子存在,而且语法正确:

test -r "$HOME/.acme.sh/private/aliyun-accounts.conf"
test -r "$HOME/.acme.sh/deploy/ali_multi_account.sh"
sh -n "$HOME/.acme.sh/deploy/ali_multi_account.sh"

手动更新到最新版,同时关闭以后由 cron 自动升级:

"$HOME/.acme.sh/acme.sh" --upgrade --auto-upgrade 0

更新后再次检查版本、自定义钩子和日志:

"$HOME/.acme.sh/acme.sh" --version
test -r "$HOME/.acme.sh/deploy/ali_multi_account.sh"
sh -n "$HOME/.acme.sh/deploy/ali_multi_account.sh"
tail -n 200 "$HOME/.acme.sh/acme.sh.log"

如果希望恢复自动更新,可以执行:

"$HOME/.acme.sh/acme.sh" --upgrade --auto-upgrade 1

自动更新会在 cron 续签检查开始时执行。使用自定义钩子时,更新后仍要确认钩子文件存在,并留意日志中的兼容性错误。

参考资料