使用 acme.sh 实现 Let's Encrypt 泛域名证书自动续期与 Nginx 无缝重载

fC9HMFWxs.jpg

acme.sh 自动续期命令执行脚本。

场景介绍

目前我自己的博客是运行在阿里的 ecs 上的,为了网站支持 https 那么网站的 ssl 证书是必不可少的。但是,收费证书每年的费用不菲,而作用仅仅只是支持 https,由于个人网站没有那么高的需求,且对认证形式也没有更高的等级要求。所以只需要个人免费证书就可以。免费证书申请,可以通过Lets Encrypt、阿里云、京东云、七牛云等等。每个都支持免费和收费证书。

我的博客的证书就一直是通过阿里的免费证书来手动申请的。免费证书目前只支持三个月(90天),每到时间就需要在申请一次更换证书。一次两次还好,这是第三次了,就比较麻烦了。每次都得手动申请,然后提交更新,重启服务。所以,为了更加省事,省时,省力。还是自己写个自动更近脚本。这样基本就一劳永逸了。

查了下,能够通过程序或者命令更新的程序,确实已经有了不少,对比后我选择的 acme.sh。这个程序相当于将续期的主要功能已经全部完成了,只要安装上,然后配置好,他就会自动在证书快到期的时候去申请证书,然后更新证书。

由于阿里、京东云等,对每个账号申请免费证书有限制,对需要比较多的二级域名的朋友来说,这个就不太友好。当然可以通过开多个账号(只要不嫌麻烦)来解决。另一种就是通过 lets encrypt 来申请免费证书。这个证书申请限制相对来说非常宽泛,对个人站点来说,应该完全够用。具体的限制政策可以参见官方文档:

https://letsencrypt.org/zh-cn/docs/rate-limits/。

自动续期脚本

注意,此脚本只需要执行一次!!!,acme 会支持自动续期(通过 cron 来实现)。部分需要调整的配置,可以根据需要调整一下。如果环境不一样,或者后续执行命令不一样,可以自己调整下脚本。

bash 复制
#!/bin/bash

# ==============================
# Let's Encrypt 泛域名证书申请脚本,置于虚拟主机环境
# 域名: liujie.xin + *.liujie.xin
# 作者: king.2oo8@163.com
# ==============================

set -e  # 遇错退出

# 主域名
DOMAIN="liujie.xin"
# 证书生成目录
CERT_PATH="/data/ssl/${DOMAIN}"
# 通知邮箱
EMAIL="king.2oo8@163.com"
# nginx 容器名称
POD_NAME="swoft-app-nginx"
# docker-compose.yml 文件路径
COMPOSE_FILE="/home/liujie/swoft-app/swoft-app/docker-compose.yml"

# 检查 acme.sh 是否安装
if [ ! -f ~/.acme.sh/acme.sh ]; then
  echo "错误: acme.sh 未安装,请先运行:"
  echo "curl https://get.acme.sh | sh -s email=${EMAIL}"
  exit 1
fi

if [ -d "${CERT_PATH}" ]; then
  echo "目录 ${CERT_PATH} 已存在,跳过创建"
else
  mkdir -p "${CERT_PATH}"
  chmod 700 "${CERT_PATH}"
fi

# 设置阿里云 DNS API 凭据(建议从安全位置读取)
# 密钥 /root/.aliyun-keys(权限 600)
if [ -f /root/.aliyun-keys ]; then
  source /root/.aliyun-keys
else
  echo "错误: 请先创建 /root/.aliyun-keys 文件,内容为:"
  echo "Ali_Key='LTAI5tXXXXXXXXXXXXXX'"
  echo "Ali_Secret='XXXXXXXXXXXXXXXXXXX'"
  exit 1
fi

# 导出环境变量给 acme.sh
export Ali_Key
export Ali_Secret

NEED_ISSUE=false
if [ ! -f "${CERT_PATH}/fullchain.pem" ]; then
  echo "证书不存在,需要申请。"
  NEED_ISSUE=true
elif ! openssl x509 -in "${CERT_PATH}/fullchain.pem" -noout -checkend 2592000 >/dev/null 2>&1; then
  # 2592000 = 30天(单位:秒)
  echo "证书将在30天内过期,需要续期。"
  NEED_ISSUE=true
else
  echo "证书有效且剩余有效期超过30天,跳过申请。"
fi

# 仅当需要时申请
if [ "$NEED_ISSUE" = true ]; then
  # 申请证书(包含根域 + 泛域)
  echo "正在申请证书: ${DOMAIN} + *.${DOMAIN} ..."
  ~/.acme.sh/acme.sh --issue \
    --dns dns_ali \
    --server letsencrypt \
    -d "${DOMAIN}" \
    -d "*.${DOMAIN}" \
    --log-level 2
fi

echo "正在安装/更新证书到 ${CERT_PATH} ..."
# 兼容 docker-compose(有横杠)和 docker compose(无横杠)
if command -v docker-compose >/dev/null 2>&1; then
  RELOAD_CMD="docker-compose -f \"${COMPOSE_FILE}\" kill -s HUP \"${POD_NAME}\" || echo 'Nginx 重载失败(容器可能未运行),但证书已更新。'"
else
  RELOAD_CMD="docker compose -f \"${COMPOSE_FILE}\" kill -s HUP \"${POD_NAME}\" || echo 'Nginx 重载失败(容器可能未运行),但证书已更新。'"
fi

# 安装证书到指定目录,并设置重载命令
~/.acme.sh/acme.sh --install-cert \
  -d "${DOMAIN}" \
  --key-file       "${CERT_PATH}/privkey.pem" \
  --fullchain-file "${CERT_PATH}/fullchain.pem" \
  --reloadcmd "${RELOAD_CMD}" \
  --days 30

echo "证书自动续期已配置完成!证书路径: ${CERT_PATH}"

自动续期脚本执行

使用前,需要在 /root 目录 .aliyun-keys 中配置对应阿里云的 AccessKey ID 和 AccessKey Secret,这个主要是用来让 acme 自动通过 dns 来在指定的域名中添加验证 txt 记录的。注意,这个授权尽量新增一个账号,只给这个账号授权 AliyunDNSFullAccess(管理云解析(DNS)的权限)即可,以保证安全。

注意千万不要用主账号的 AccessKey ID 和 AccessKey Secret,一旦泄露会导致账号高风险。

默认为ZeroSSL

开始我的脚本使用的默认的 CA 申请服务(ZeroSSL),但是这个服务,对泛域名证书验证相对严格,可能需要24小时人工审核。所以第一次脚本直接返回失败了。

bash 复制
[root@iZ2ze8594zfczf83jgbrnlZ ~]# bash setup-ssl.sh 
目录 /data/ssl/liujie.xin 已存在,跳过创建
证书将在30天内过期,需要续期。
正在申请证书: liujie.xin + *.liujie.xin ...
[Wed Nov 19 16:02:17 CST 2025] Using CA: https://acme.zerossl.com/v2/DV90
[Wed Nov 19 16:02:17 CST 2025] Creating domain key
[Wed Nov 19 16:02:17 CST 2025] The domain key is here: /root/.acme.sh/liujie.xin_ecc/liujie.xin.key
[Wed Nov 19 16:02:17 CST 2025] Multi domain='DNS:liujie.xin,DNS:*.liujie.xin'
[Wed Nov 19 16:02:24 CST 2025] Getting webroot for domain='liujie.xin'
[Wed Nov 19 16:02:24 CST 2025] Getting webroot for domain='*.liujie.xin'
[Wed Nov 19 16:02:25 CST 2025] Adding TXT value: k5XTExEaqu4boyF7EGC2v896w5Oq27osf88qUD8dnuI for domain: _acme-challenge.liujie.xin
[Wed Nov 19 16:02:26 CST 2025] The TXT record has been successfully added.
[Wed Nov 19 16:02:26 CST 2025] Adding TXT value: IvPZr71p3rWB7vZljXiyL72LM0f0NTWDbWHAc0vgc-g for domain: _acme-challenge.liujie.xin
[Wed Nov 19 16:02:28 CST 2025] The TXT record has been successfully added.
[Wed Nov 19 16:02:28 CST 2025] Let's check each DNS record now. Sleeping for 20 seconds first.
[Wed Nov 19 16:02:49 CST 2025] You can use '--dnssleep' to disable public dns checks.
[Wed Nov 19 16:02:49 CST 2025] See: https://github.com/acmesh-official/acme.sh/wiki/dnscheck
[Wed Nov 19 16:02:49 CST 2025] Checking liujie.xin for _acme-challenge.liujie.xin
[Wed Nov 19 16:02:50 CST 2025] Please refer to https://curl.haxx.se/libcurl/c/libcurl-errors.html for error code: 35
[Wed Nov 19 16:02:57 CST 2025] Please refer to https://curl.haxx.se/libcurl/c/libcurl-errors.html for error code: 7
[Wed Nov 19 16:02:58 CST 2025] Success for domain liujie.xin '_acme-challenge.liujie.xin'.
[Wed Nov 19 16:02:58 CST 2025] Checking liujie.xin for _acme-challenge.liujie.xin
[Wed Nov 19 16:02:59 CST 2025] Success for domain liujie.xin '_acme-challenge.liujie.xin'.
[Wed Nov 19 16:02:59 CST 2025] All checks succeeded
[Wed Nov 19 16:02:59 CST 2025] Verifying: liujie.xin
[Wed Nov 19 16:03:01 CST 2025] Processing. The CA is processing your order, please wait. (1/30)
[Wed Nov 19 16:03:05 CST 2025] The retryafter=86400 value is too large (> 600), will not retry anymore.
[Wed Nov 19 16:03:05 CST 2025] Removing DNS records.
[Wed Nov 19 16:03:05 CST 2025] Removing txt: k5XTExEaqu4boyF7EGC2v896w5Oq27osf88qUD8dnuI for domain: _acme-challenge.liujie.xin
[Wed Nov 19 16:03:07 CST 2025] Successfully removed
[Wed Nov 19 16:03:07 CST 2025] Removing txt: IvPZr71p3rWB7vZljXiyL72LM0f0NTWDbWHAc0vgc-g for domain: _acme-challenge.liujie.xin
[Wed Nov 19 16:03:09 CST 2025] Successfully removed
[Wed Nov 19 16:03:09 CST 2025] Please add '--debug' or '--log' to see more information.
[Wed Nov 19 16:03:09 CST 2025] See: https://github.com/acmesh-official/acme.sh/wiki/How-to-debug-acme.sh

这是 ZeroSSL 的 ACME 服务器返回了一个超长的重试等待时间(86400 秒 = 24 小时),而 acme.sh 默认最多只等 600 秒(10 分钟),于是直接放弃了。

原因如下:

  • ZeroSSL 账号未完成邮箱验证
  • 新注册的 ZeroSSL 账户处于“受限”状态
  • 短时间内频繁请求(触发风控)
  • ZeroSSL 免费账户限制(尤其是泛域名证书)

这时需要将ssl验证服务切换到 Let's Encrypt。(默认为ZeroSSL)。上边代码我已经更新,将 server指向了 letsencrypt,所以你应该不会碰到这个问题了。

bash 复制
~/.acme.sh/acme.sh --issue \
    --dns dns_ali \
    --server letsencrypt \
    -d "${DOMAIN}" \
    -d "*.${DOMAIN}" \
    --log-level 2

letsencrypt 域名申请

修改为 letsencrypt 证书服务后,直接成功了!

bash 复制
[root@iZ2ze8594zfczf83jgbrnlZ ~]# bash setup-ssl.sh 
目录 /data/ssl/liujie.xin 已存在,跳过创建
证书将在30天内过期,需要续期。
正在申请证书: liujie.xin + *.liujie.xin ...
[Wed Nov 19 16:08:33 CST 2025] Using CA: https://acme-v02.api.letsencrypt.org/directory
[Wed Nov 19 16:08:33 CST 2025] Account key creation OK.
[Wed Nov 19 16:08:33 CST 2025] Registering account: https://acme-v02.api.letsencrypt.org/directory
[Wed Nov 19 16:08:36 CST 2025] Registered
[Wed Nov 19 16:08:36 CST 2025] ACCOUNT_THUMBPRINT='08eAu0E1qXtWZGLJpsP2hmUCbC_AiekAPa9hqCl51zo'
[Wed Nov 19 16:08:36 CST 2025] Creating domain key
[Wed Nov 19 16:08:36 CST 2025] The domain key is here: /root/.acme.sh/liujie.xin_ecc/liujie.xin.key
[Wed Nov 19 16:08:36 CST 2025] Multi domain='DNS:liujie.xin,DNS:*.liujie.xin'
[Wed Nov 19 16:08:42 CST 2025] Getting webroot for domain='liujie.xin'
[Wed Nov 19 16:08:42 CST 2025] Getting webroot for domain='*.liujie.xin'
[Wed Nov 19 16:08:42 CST 2025] Adding TXT value: 9gLcAg3aM9LAZsEcjvY0ctoBn9Wq3X5SN3rsnMGTH4w for domain: _acme-challenge.liujie.xin
[Wed Nov 19 16:08:44 CST 2025] The TXT record has been successfully added.
[Wed Nov 19 16:08:44 CST 2025] Adding TXT value: W2N2V7GxmM3LFSs8pEOMKF2C_lHjX9XDf_nVeMxRBPs for domain: _acme-challenge.liujie.xin
[Wed Nov 19 16:08:45 CST 2025] The TXT record has been successfully added.
[Wed Nov 19 16:08:45 CST 2025] Let's check each DNS record now. Sleeping for 20 seconds first.
[Wed Nov 19 16:09:06 CST 2025] You can use '--dnssleep' to disable public dns checks.
[Wed Nov 19 16:09:06 CST 2025] See: https://github.com/acmesh-official/acme.sh/wiki/dnscheck
[Wed Nov 19 16:09:06 CST 2025] Checking liujie.xin for _acme-challenge.liujie.xin
[Wed Nov 19 16:09:07 CST 2025] Please refer to https://curl.haxx.se/libcurl/c/libcurl-errors.html for error code: 35
[Wed Nov 19 16:09:14 CST 2025] Please refer to https://curl.haxx.se/libcurl/c/libcurl-errors.html for error code: 7
[Wed Nov 19 16:09:15 CST 2025] Success for domain liujie.xin '_acme-challenge.liujie.xin'.
[Wed Nov 19 16:09:15 CST 2025] Checking liujie.xin for _acme-challenge.liujie.xin
[Wed Nov 19 16:09:15 CST 2025] Not valid yet, let's wait for 10 seconds then check the next one.
[Wed Nov 19 16:09:32 CST 2025] Let's wait for 10 seconds and check again.
[Wed Nov 19 16:09:43 CST 2025] You can use '--dnssleep' to disable public dns checks.
[Wed Nov 19 16:09:43 CST 2025] See: https://github.com/acmesh-official/acme.sh/wiki/dnscheck
[Wed Nov 19 16:09:43 CST 2025] Checking liujie.xin for _acme-challenge.liujie.xin
[Wed Nov 19 16:09:43 CST 2025] Already succeeded, continuing.
[Wed Nov 19 16:09:43 CST 2025] Checking liujie.xin for _acme-challenge.liujie.xin
[Wed Nov 19 16:09:43 CST 2025] Not valid yet, let's wait for 10 seconds then check the next one.
[Wed Nov 19 16:10:00 CST 2025] Let's wait for 10 seconds and check again.
[Wed Nov 19 16:10:11 CST 2025] You can use '--dnssleep' to disable public dns checks.
[Wed Nov 19 16:10:11 CST 2025] See: https://github.com/acmesh-official/acme.sh/wiki/dnscheck
[Wed Nov 19 16:10:12 CST 2025] Checking liujie.xin for _acme-challenge.liujie.xin
[Wed Nov 19 16:10:12 CST 2025] Already succeeded, continuing.
[Wed Nov 19 16:10:12 CST 2025] Checking liujie.xin for _acme-challenge.liujie.xin
[Wed Nov 19 16:10:12 CST 2025] Success for domain liujie.xin '_acme-challenge.liujie.xin'.
[Wed Nov 19 16:10:12 CST 2025] All checks succeeded
[Wed Nov 19 16:10:12 CST 2025] Verifying: liujie.xin
[Wed Nov 19 16:10:13 CST 2025] Pending. The CA is processing your order, please wait. (1/30)
[Wed Nov 19 16:10:18 CST 2025] Pending. The CA is processing your order, please wait. (2/30)
[Wed Nov 19 16:10:22 CST 2025] Pending. The CA is processing your order, please wait. (3/30)
[Wed Nov 19 16:10:26 CST 2025] Pending. The CA is processing your order, please wait. (4/30)
[Wed Nov 19 16:10:33 CST 2025] Success
[Wed Nov 19 16:10:33 CST 2025] Verifying: *.liujie.xin
[Wed Nov 19 16:10:35 CST 2025] Pending. The CA is processing your order, please wait. (1/30)
[Wed Nov 19 16:10:39 CST 2025] Success
[Wed Nov 19 16:10:39 CST 2025] Removing DNS records.
[Wed Nov 19 16:10:39 CST 2025] Removing txt: 9gLcAg3aM9LAZsEcjvY0ctoBn9Wq3X5SN3rsnMGTH4w for domain: _acme-challenge.liujie.xin
[Wed Nov 19 16:10:41 CST 2025] Successfully removed
[Wed Nov 19 16:10:41 CST 2025] Removing txt: W2N2V7GxmM3LFSs8pEOMKF2C_lHjX9XDf_nVeMxRBPs for domain: _acme-challenge.liujie.xin
[Wed Nov 19 16:10:43 CST 2025] Successfully removed
[Wed Nov 19 16:10:43 CST 2025] Verification finished, beginning signing.
[Wed Nov 19 16:10:43 CST 2025] Let's finalize the order.
[Wed Nov 19 16:10:43 CST 2025] Le_OrderFinalize='https://acme-v02.api.letsencrypt.org/acme/finalize/2811185806/450231901346'
[Wed Nov 19 16:10:45 CST 2025] Downloading cert.
[Wed Nov 19 16:10:45 CST 2025] Le_LinkCert='https://acme-v02.api.letsencrypt.org/acme/cert/05d15bbae199f17000c7b01d83af4ea27ca9'
[Wed Nov 19 16:10:46 CST 2025] Cert success.
-----BEGIN CERTIFICATE-----
这里涉及证书内容,打个马赛克。
-----END CERTIFICATE-----
[Wed Nov 19 16:10:46 CST 2025] Your cert is in: /root/.acme.sh/liujie.xin_ecc/liujie.xin.cer
[Wed Nov 19 16:10:46 CST 2025] Your cert key is in: /root/.acme.sh/liujie.xin_ecc/liujie.xin.key
[Wed Nov 19 16:10:46 CST 2025] The intermediate CA cert is in: /root/.acme.sh/liujie.xin_ecc/ca.cer
[Wed Nov 19 16:10:46 CST 2025] And the full-chain cert is in: /root/.acme.sh/liujie.xin_ecc/fullchain.cer
正在安装/更新证书到 /data/ssl/liujie.xin ...
[Wed Nov 19 16:10:46 CST 2025] The domain 'liujie.xin' seems to already have an ECC cert, let's use it.
[Wed Nov 19 16:10:46 CST 2025] Installing key to: /data/ssl/liujie.xin/privkey.pem
[Wed Nov 19 16:10:46 CST 2025] Installing full chain to: /data/ssl/liujie.xin/fullchain.pem
[Wed Nov 19 16:10:46 CST 2025] Running reload cmd: docker compose -f "/home/liujie/swoft-app/swoft-app/docker-compose.yml" kill -s HUP "nginx" || echo 'Nginx 重载失败(容器可能未运行),但证书已更新。'
stat /home/liujie/swoft-app/swoft-app/docker-compose.yml: no such file or directory
Nginx 重载失败(容器可能未运行),但证书已更新。
[Wed Nov 19 16:10:47 CST 2025] Reload successful
证书自动续期已配置完成!证书路径: /data/ssl/liujie.xin

这下证书是完成了,但是查看网站的连接,证书还是之前的,说明服务没有成功。 然后尝试手动重启一下服务,确保第一次成功,可以直接执行证书更新成功之后的命令:

bash 复制
docker compose -f /home/liujie/swoft-app/docker-compose.yml kill -s HUP nginx

结果又发现问题了,路径写错了,导致无法加载 docker-compose.yml 文件。额,这时候就要注意,必须在做两件事:

  1. 手动重启(reload)nginx 服务,让服务重新加载证书,确认证书是否能够正常识别
bash 复制
# 给 nginx 容器发送 SIGHUP 信号(Hang Up),此信号,会让 nginx reload(平滑重载)
docker compose -f /home/liujie/swoft-app/docker-compose.yml kill -s HUP nginx
  1. 将之前设置证书每次更新成功后要执行的命令,给更新为对的命令
bash 复制
# 重新执行下这个安装证书,同时设置更新后执行命令的语句,让正确的命令覆盖掉之前有错误的命令
[root@iZ2ze8594zfczf83jgbrnlZ ~]#/root/.acme.sh/acme.sh --install-cert \
  -d liujie.xin \
  --key-file       /data/ssl/liujie.xin/privkey.pem \
  --fullchain-file /data/ssl/liujie.xin/fullchain.pem \
  --reloadcmd      "docker compose -f /home/liujie/swoft-app/docker-compose.yml kill -s HUP nginx"
# 更新完正确命令后,检查下配置文件是否对了
[root@iZ2ze8594zfczf83jgbrnlZ ~]# grep Le_ReloadCmd ~/.acme.sh/liujie.xin_ecc/liujie.xin.conf
Le_ReloadCmd='__ACME_BASE64__START_ZG9ja2VyIGNvbXBvc2UgLWYgL2hvbWUvbGl1amllL3N3b2Z0LWFwcC9kb2NrZXItY29tcG9zZS55bWwga2lsbCAtcyBIVVAgbmdpbng=__ACME_BASE64__END_'
# 将上一步 __ACME_BASE64__START_{}__ACME_BASE64__END_中间的base64编码还原查看命令是否正确
[root@iZ2ze8594zfczf83jgbrnlZ ~]# echo "ZG9ja2VyIGNvbXBvc2UgLWYgL2hvbWUvbGl1amllL3N3b2Z0LWFwcC9kb2NrZXItY29tcG9zZS55bWwga2lsbCAtcyBIVVAgbmdpbng=" | base64 -d
docker compose -f /home/liujie/swoft-app/docker-compose.yml kill -s HUP nginx
[root@iZ2ze8594zfczf83jgbrnlZ ~]#

检查无误后,等到下次就可以验证自动续期任务是否能够正常完成了。