尚拙

一个分享技术、学习成长的个人博客网站

0%

多台服务器共享同一域名:如何实现 acme.sh 单一签发与证书分发

本文从一次多服务器证书过期故障出发,分析多台服务器各自运行 acme.sh 的风险,并给出以单台证书机统一签发、通过 rsync over SSH 分发证书并远程重载 Nginx 的落地方案。

背景

当一个域名(尤其是带泛解析的 *.example.com)同时指向多台服务器时,很多人的第一反应是:每台服务器都装一个 acme.sh,各自续期,各自安装。看起来简单直接,实际上埋下了不少隐患。

笔者最近就遇到了这个问题:两台服务器都托管 lovehistory.top 及其子域名,各自运行 acme.sh 自动续期。某天客户端突然报 CERTIFICATE_VERIFY_FAILED: certificate has expired,排查后发现是其中一台服务器的证书续期失败,但 Nginx 仍在提供旧证书。顺着这条线往下挖,发现”各自为政”的模式存在四个真实风险。

这篇文章记录问题分析和最终采用的单一签发 + 证书分发方案,适用于任何使用 acme.sh、多台服务器共享同一域名的场景。


一、为什么不能让每台服务器各自续期

风险 1:并行续期损坏配置文件

acme.sh 的续期过程会读写 ~/.acme.sh/<domain>/<domain>.conf。如果两台服务器的 cron 恰好同时触发,并行进程可能同时写同一个配置文件,导致文件损坏。虽然概率不高,但一旦发生,该服务器上这个域名的后续续期都会失败,而且报错往往很隐晦。

风险 2:Let’s Encrypt 的重复证书速率限制

Let’s Encrypt 对”完全相同的一组域名”有一个 Duplicate Certificate 限制:每 7 天最多 5 份。这个限制是全局的,不管你用了多少个 ACME 账户、多少台服务器,所有申请合并计算。

正常自动续期通常不受此限,但前提是续期请求来自签发上一份证书的同一个 ACME 账户。如果两台服务器的 acme.sh 使用不同账户,一台的”续期”在 Let’s Encrypt 看来可能是”新申请”,从而消耗配额。反复 --force 测试时尤其容易触发。

风险 3:DNS TXT 记录的竞争

使用 DNS-01 验证时,一个常见的担心是两台服务器同时写入 _acme-challenge 的 TXT 记录会互相覆盖。DNS 协议本身允许一个名称下存在多条 TXT 记录,验证时只要任意一条匹配即可,所以”各自添加、各自删除”理论上可以共存。

真正的风险在清理环节:如果某台服务器的脚本错误地删除了整个 _acme-challenge 名称下的所有记录,另一台正在验证的服务器就会失败。

风险 4(最隐蔽):证书不同步

这是”各自续期”模式最典型的后果。两台服务器各自生成不同的私钥和证书,独立续期,互不知晓。

可能出现的情况是:服务器 A 续期成功,服务器 B 因网络波动或 DNS 延迟续期失败,仍停留在旧证书上。当流量被负载均衡或 DNS 轮询分配到 B 时,用户就会看到证书错误。笔者遇到的正是这个问题——A 的证书已经更新,B 的还是过期的。


二、目标架构:单一签发者 + 证书分发

社区公认的最佳实践是 **”集中签发,统一分发”**:

┌─────────────────────────────────────────────┐
│ 证书机(服务器 A) │
│ acme.sh 唯一运行处 │
│ ├─ 持有 DNS API Token │
│ ├─ 完成 DNS-01 验证 │
│ ├─ 签发/续期证书 │
│ └─ --reloadcmd: 本地 reload + rsync 分发 │
└──────────────────┬──────────────────────────┘
│ rsync over SSH
▼
┌─────────────────────────────────────────────┐
│ 服务器 B(及更多) │
│ 不运行 acme.sh 续期 │
│ 只接收证书文件 + 被远程触发 reload │
└─────────────────────────────────────────────┘

优势很直接:

  • 只有一台机器持有 DNS API Token,暴露面更小;
  • 速率限制只需关注一台机器的行为;
  • 所有服务器上的证书始终一致;
  • 排查问题只看一台机器的日志。

三、实施步骤

以下以两台服务器为例,服务器 A 作为证书机,服务器 B 为分发目标。假设:

  • 域名:example.com + *.example.com
  • DNS 托管:阿里云(钩子 dns_ali)
  • Web 服务:Nginx
  • 证书路径:/etc/nginx/ssl/example.com/

第 1 步:摸清服务器 B 的现状

登录服务器 B,记录关键信息:

# acme.sh 的续期任务
crontab -l | grep acme

# Nginx 实际引用的证书路径
nginx -T 2>/dev/null | grep -E "server_name|ssl_certificate|ssl_certificate_key"

# 当前线上证书
echo | openssl s_client -servername example.com -connect example.com:443 2>/dev/null \
| openssl x509 -noout -dates -issuer

# 服务器 B 的 IP(后续 rsync 要用)
hostname -I

重点确认 B 的 Nginx 证书路径是否与 A 一致。如果不同,后续 rsync 目标路径要相应调整,或在 B 上做软链接。

第 2 步:停掉服务器 B 的 acme.sh 自动续期

注意:不要卸载 acme.sh,只删 cron 任务,保留已签发的证书文件,避免 Nginx 立刻报错。

# 备份 crontab
crontab -l > /root/crontab.backup.$(date +%F)

# 删除 acme.sh 的 cron 行,保留其他任务
crontab -l | grep -v 'acme.sh --cron' | crontab -

# 确认已删除
crontab -l | grep acme

如果 B 的 ~/.acme.sh/account.conf 里还存着 DNS API Token,建议一并移除,减少暴露面。

第 3 步:在服务器 A 上配置到 B 的 SSH 免密登录

# 生成专用密钥(如果还没有)
ssh-keygen -t ed25519 -N "" -f /root/.ssh/id_ed25519

# 推送公钥到 B
ssh-copy-id -i /root/.ssh/id_ed25519.pub root@<B_IP>

# 测试
ssh -i /root/.ssh/id_ed25519 root@<B_IP> "hostname && date"

能直接输出 B 的主机名和时间,说明免密成功。

第 4 步:在 B 上准备证书目录

# 在服务器 B 上执行
mkdir -p /etc/nginx/ssl/example.com

如果 B 的 Nginx 配置引用的路径不同,以 nginx -T 查到的实际路径为准。

第 5 步:在服务器 A 上重新安装证书,扩展 reloadcmd

把 --reloadcmd 从”只重载本地”扩展为”本地重载 + rsync 到 B + 远程校验并重载 B”:

acme.sh --install-cert -d example.com --ecc \
--key-file /etc/nginx/ssl/example.com/example.com.key \
--fullchain-file /etc/nginx/ssl/example.com/fullchain.cer \
--reloadcmd "systemctl reload nginx && \
rsync -az -e 'ssh -i /root/.ssh/id_ed25519' \
/etc/nginx/ssl/example.com/ \
root@<B_IP>:/etc/nginx/ssl/example.com/ && \
ssh -i /root/.ssh/id_ed25519 root@<B_IP> \
'nginx -t && systemctl reload nginx'"

这条命令在每次续期成功后会依次执行:

  1. 重载 A 的 Nginx;
  2. 把 A 的证书目录同步到 B;
  3. 在 B 上先 nginx -t 校验配置,通过后再重载 B 的 Nginx。

nginx -t && 不能省。如果 B 的配置因证书路径或权限有问题,nginx -t 会失败,reload 不会执行,避免 B 直接中断服务。

确认 --reloadcmd 已记录:

acme.sh --info -d example.com | grep -i reload

应输出:

Le_ReloadCmd=systemctl reload nginx && rsync ...

第 6 步:手动触发一次分发,验证 B 已更新

不用等续期,直接在 A 上跑一遍 reloadcmd 里的逻辑:

systemctl reload nginx && \
rsync -az -e 'ssh -i /root/.ssh/id_ed25519' \
/etc/nginx/ssl/example.com/ \
root@<B_IP>:/etc/nginx/ssl/example.com/ && \
ssh -i /root/.ssh/id_ed25519 root@<B_IP> \
'nginx -t && systemctl reload nginx'

然后在 B 上验证:

openssl x509 -in /etc/nginx/ssl/example.com/fullchain.cer -noout -dates -issuer

echo | openssl s_client -servername example.com -connect example.com:443 2>/dev/null \
| openssl x509 -noout -dates -issuer

期望 B 的证书与 A 完全一致(相同的 notAfter 和 issuer)。


四、验证端到端链路

建议在正式上线前强制续期一次,观察分发是否自动完成:

# 在服务器 A 上
acme.sh --renew -d example.com --force --ecc

然后看日志:

tail -50 /var/log/acme.sh.log

确认里面有 systemctl reload nginx、rsync、ssh 相关输出且无报错。

注意:--force 不要频繁使用。Let’s Encrypt 对”完全相同域名组”有每周 5 份重复证书的限制,验证一次即可,之后靠自动续期。


五、运维建议

1. 给 acme.sh 的 cron 加日志

默认 cron 输出被重定向到 /dev/null,续期失败时无日志可查。建议改成:

crontab -e
51 15 * * * "/root/.acme.sh"/acme.sh --cron --home "/root/.acme.sh" --log /var/log/acme.sh.log > /dev/null 2>&1

这样续期失败时,直接看 /var/log/acme.sh.log 就能定位。

2. 保留 B 上的旧证书备份

在第一次分发成功前,不要删除 B 上的旧证书文件。如果 rsync 因网络问题失败,B 的 Nginx 至少还能用旧证书启动,不会直接 502。

3. SSH 端口不是 22 时

如果 B 的 SSH 端口不是默认的 22,rsync 和 ssh 命令要加 -p <端口>,或写进 ~/.ssh/config:

Host server-b
HostName <B_IP>
Port 2222
IdentityFile /root/.ssh/id_ed25519

之后命令里直接用 root@server-b 即可。

4. CDN 场景要单独处理

如果域名前面还有 CDN(Cloudflare、阿里云 CDN 等),rsync 只解决源站之间的同步。CDN 边缘节点的证书需要在 CDN 控制台单独更新,或开启”回源 HTTPS 跟随源站证书”。

5. 定期检查证书一致性

可以写一个简单的巡检脚本,在多台服务器上分别执行,对比 notAfter 是否一致:

for host in <A_IP> <B_IP>; do
echo "=== $host ==="
ssh root@$host "echo | openssl s_client -servername example.com -connect example.com:443 2>/dev/null | openssl x509 -noout -dates -issuer"
done

如果有服务器日期不一致,说明分发链路断了,需要检查 A 的日志和 B 的 SSH 连通性。


六、做完后的状态

项目 服务器 A(证书机) 服务器 B(分发目标)
acme.sh 续期 ✅ 唯一运行 ❌ 已停 cron
证书来源 本地签发 从 A rsync 同步
自动重载 ✅ 本地 + 远程触发 B 由 A 远程触发
证书一致性 ✅ 始终一致 ✅ 始终一致
排查入口 /var/log/acme.sh.log 看 A 的日志

总结

多台服务器共享同一域名时,”各自为政”地运行 acme.sh 看似省事,实际会引入并行续期损坏配置、速率限制、DNS 记录竞争、证书不同步四类风险。其中”证书不同步”最隐蔽,往往在流量切换到某台续期失败的服务器时才暴露。

单一签发 + 证书分发的方案并不复杂:选定一台证书机,其余服务器停掉续期,通过 --reloadcmd 中的 rsync over SSH 把新证书推送到所有目标服务器并远程重载。整个改动集中在一条 acme.sh --install-cert 命令里,却能从根本上消除上述所有风险。

如果你也在多台服务器上跑着 acme.sh,建议花半小时按本文步骤切换一次。之后 90 天的续期周期里,你只需要看一台机器的日志。