阿里云域名七牛云对象存储证书自动更新

如果你的网站一部分跑在自己的服务器上,另一部分静态资源放在七牛云对象存储里,那 HTTPS 证书往往会变成两套事情:

  • 服务器上的证书要续签一次
  • 七牛绑定域名的证书还要再更新一次

一开始域名不多时,这件事似乎还能忍;但只要证书进入 90 天周期,你很快就会发现,真正麻烦的并不是“签发证书”,而是总得记得还有另一边也要更新

这篇文章要解决的就是这个问题:

  • 证书继续用免费的 Let’s Encrypt
  • 域名验证继续走阿里云 DNS
  • 服务器上的 Nginx 自动使用新证书
  • 七牛云对象存储绑定域名也自动切换到新证书

换句话说,就是把证书这件事收口成一条链路:

只维护一份配置,只续签一套证书,然后自动分发到所有需要它的地方。

文中所有域名、邮箱、AK/SK 都是示例,请替换成你自己的配置,不要公开真实信息。

这套方案是怎么工作的?

整套方案只依赖四样东西:

  • Certbot:负责申请和续签证书
  • 阿里云 DNS API:负责自动完成 DNS 验证
  • Deploy Hook:在续签成功后自动执行脚本
  • 七牛云 API:负责上传证书并切换对象存储绑定域名的 HTTPS 配置

整个过程可以概括成这样:

Certbot 续签证书
  -> 阿里云 DNS 自动验证
  -> 续签成功
  -> 触发 deploy hook
      -> reload Nginx
      -> 上传证书到七牛
      -> 更新七牛域名证书

这样做的好处也很直接:

  • 不再手工改 TXT
  • 不再手工替换 Nginx 证书
  • 不再手工去七牛后台上传证书
  • 整个流程变成“首次配置一次,以后自动运行”

适合什么场景?

假设你有这些域名:

  • example.com
  • www.example.com
  • file.example.com
  • cdn.example.com

推荐直接申请一张证书,包含:

  • example.com
  • *.example.com

这样一张证书就能同时覆盖:

  • example.com
  • www.example.com
  • file.example.com
  • cdn.example.com

这里有个容易忽略的点: *.example.com 不包含 example.com,所以根域名必须单独加上。

先看清楚这套方案有哪些文件

为了避免一上来就被代码淹没,先看清楚这套方案最终由哪些文件组成。

1)配置文件

/etc/ssl-automation/config.json

这是整套方案的中心。域名、邮箱、阿里云 AK/SK、七牛 AK/SK、七牛目标域名列表都放这里。

2)主脚本

/usr/local/bin/certbot_qiniu_sync.py

这个脚本负责两件事:

  • 生成阿里云 DNS 插件要用的凭据文件
  • 在 Certbot 续签成功后,把新证书同步到 Nginx 和七牛

3)Deploy Hook

/etc/letsencrypt/renewal-hooks/deploy/qiniu-sync.sh

这是 Certbot 的回调入口。续签成功后,Certbot 会自动执行它,而它再去调用 Python 主脚本。

4)自动续签 service

/etc/systemd/system/certbot-venv-renew.service

负责运行 certbot renew --quiet

5)自动续签 timer

/etc/systemd/system/certbot-venv-renew.timer

负责定时触发上面的 service。

第一步:先把所有变量放进配置文件

自动化能不能长期稳定,关键不是脚本写多复杂,而是不要把域名、邮箱、AK/SK 写死在代码里

先创建配置文件:

mkdir -p /etc/ssl-automation
vim /etc/ssl-automation/config.json

填入下面内容:

{
  "aliyun": {
    "access_key_id": "YOUR_ALIYUN_ACCESS_KEY_ID",
    "access_key_secret": "YOUR_ALIYUN_ACCESS_KEY_SECRET",
    "dns_propagation_seconds": 60
  },
  "certbot": {
    "email": "YOUR_NOTICE_EMAIL@example.com",
    "cert_name": "example.com",
    "domains": [
      "example.com",
      "*.example.com"
    ]
  },
  "nginx": {
    "enabled": true,
    "test_command": ["nginx", "-t"],
    "reload_command": ["systemctl", "reload", "nginx"]
  },
  "qiniu": {
    "enabled": true,
    "access_key": "YOUR_QINIU_ACCESS_KEY",
    "secret_key": "YOUR_QINIU_SECRET_KEY",
    "api_host": "https://api.qiniu.com",
    "cert_name_prefix": "example-com",
    "domains": [
      {
        "name": "file.example.com",
        "force_https": true,
        "http2_enable": true
      },
      {
        "name": "cdn.example.com",
        "force_https": true,
        "http2_enable": true
      }
    ]
  }
}

然后把权限收紧:

chmod 600 /etc/ssl-automation/config.json

这一步做完之后,后面的脚本就都只需要读这个文件。

第二步:安装 Certbot,但不要直接依赖系统自带版本

很多机器上的系统自带 Certbot 版本偏老,或者和系统 Python/OpenSSL 环境容易互相影响。为了避免后面踩坑,最省心的办法是直接用虚拟环境单独装一份。

python3 -m venv /opt/certbot-venv
/opt/certbot-venv/bin/pip install -U pip setuptools wheel
/opt/certbot-venv/bin/pip install certbot certbot-dns-aliyun

这里安装的两个东西分别负责:

  • certbot:申请和续签证书
  • certbot-dns-aliyun:通过阿里云 DNS API 自动完成 DNS 验证

也就是说,后面我们不再依赖“手工去阿里云控制台加 TXT 记录”。

第三步:把“续签后做什么”这件事交给脚本

真正让这套方案变得省心的,不是 Certbot 本身,而是续签成功之后自动做的那些动作

我们先放主脚本。

创建文件:

vim /usr/local/bin/certbot_qiniu_sync.py

写入下面内容:

#!/usr/bin/env python3
import base64
import datetime as dt
import hashlib
import hmac
import json
import os
import subprocess
import sys
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path


def load_config(path):
    with open(path, "r", encoding="utf-8") as f:
        return json.load(f)


def urlsafe_b64(data: bytes) -> str:
    return base64.urlsafe_b64encode(data).decode("utf-8")


def qiniu_sign(access_key, secret_key, signing_str: bytes) -> str:
    digest = hmac.new(secret_key.encode("utf-8"), signing_str, hashlib.sha1).digest()
    return f"Qiniu {access_key}:{urlsafe_b64(digest)}"


def qiniu_management_token(access_key, secret_key, method, url, body=b"", content_type="application/json"):
    parsed = urllib.parse.urlparse(url)
    path = parsed.path
    if parsed.query:
        path += "?" + parsed.query

    signing = f"{method.upper()} {path}\nHost: {parsed.netloc}"
    if content_type:
        signing += f"\nContent-Type: {content_type}"
    signing += "\n\n"

    signing_bytes = signing.encode("utf-8")
    if body and content_type != "application/octet-stream":
        signing_bytes += body

    return qiniu_sign(access_key, secret_key, signing_bytes)


def http_json(method, url, payload, access_key, secret_key):
    body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
    headers = {
        "Content-Type": "application/json",
        "Authorization": qiniu_management_token(access_key, secret_key, method, url, body),
    }
    req = urllib.request.Request(url, data=body, headers=headers, method=method.upper())

    try:
        with urllib.request.urlopen(req, timeout=60) as resp:
            raw = resp.read().decode("utf-8")
            return resp.status, json.loads(raw) if raw else {}
    except urllib.error.HTTPError as e:
        raw = e.read().decode("utf-8", errors="replace")
        raise RuntimeError(f"HTTP {e.code} {url} => {raw}") from e
    except urllib.error.URLError as e:
        raise RuntimeError(f"Request failed {url} => {e}") from e


def render_aliyun_credentials(cfg, output_path):
    aliyun = cfg["aliyun"]
    content = (
        f"dns_aliyun_access_key = {aliyun['access_key_id']}\n"
        f"dns_aliyun_access_key_secret = {aliyun['access_key_secret']}\n"
    )
    Path(output_path).write_text(content, encoding="utf-8")
    os.chmod(output_path, 0o600)


def reload_nginx(cfg):
    nginx = cfg.get("nginx", {})
    if not nginx.get("enabled", False):
        return
    if nginx.get("test_command"):
        subprocess.run(nginx["test_command"], check=True)
    if nginx.get("reload_command"):
        subprocess.run(nginx["reload_command"], check=True)


def upload_cert_to_qiniu(cfg, fullchain_pem, privkey_pem, common_name):
    q = cfg["qiniu"]
    ts = dt.datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")
    cert_name = f"{q.get('cert_name_prefix', 'cert')}-{ts}"

    payload = {
        "name": cert_name,
        "common_name": common_name,
        "pri": privkey_pem,
        "ca": fullchain_pem
    }

    url = q["api_host"].rstrip("/") + "/sslcert"
    _, data = http_json("POST", url, payload, q["access_key"], q["secret_key"])

    cert_id = data.get("certID") or data.get("certId") or data.get("certid")
    if not cert_id:
        raise RuntimeError(f"certID missing in response: {data}")

    return cert_id


def update_qiniu_domain_httpsconf(cfg, cert_id, domain_cfg):
    q = cfg["qiniu"]
    domain = urllib.parse.quote(domain_cfg["name"], safe="")
    payload = {
        "certId": cert_id,
        "forceHttps": bool(domain_cfg.get("force_https", True)),
        "http2Enable": bool(domain_cfg.get("http2_enable", True))
    }
    url = q["api_host"].rstrip("/") + f"/domain/{domain}/httpsconf"
    http_json("PUT", url, payload, q["access_key"], q["secret_key"])


def deploy(cfg):
    renewed_lineage = os.environ.get("RENEWED_LINEAGE")
    if not renewed_lineage:
        raise RuntimeError("RENEWED_LINEAGE is empty; run this from certbot deploy hook")

    cert_name = cfg["certbot"]["cert_name"]
    expected_lineage = f"/etc/letsencrypt/live/{cert_name}"

    if os.path.realpath(renewed_lineage) != os.path.realpath(expected_lineage):
        return

    fullchain = Path(renewed_lineage) / "fullchain.pem"
    privkey = Path(renewed_lineage) / "privkey.pem"

    fullchain_pem = fullchain.read_text(encoding="utf-8")
    privkey_pem = privkey.read_text(encoding="utf-8")

    reload_nginx(cfg)

    qiniu = cfg.get("qiniu", {})
    if qiniu.get("enabled", False):
        cert_id = upload_cert_to_qiniu(
            cfg,
            fullchain_pem,
            privkey_pem,
            cfg["certbot"]["domains"][0]
        )
        for domain_cfg in qiniu["domains"]:
            update_qiniu_domain_httpsconf(cfg, cert_id, domain_cfg)


def main():
    if len(sys.argv) < 3:
        sys.exit(2)

    action = sys.argv[1]
    cfg = load_config(sys.argv[2])

    if action == "render-aliyun-credentials":
        if len(sys.argv) != 4:
            sys.exit(2)
        render_aliyun_credentials(cfg, sys.argv[3])
    elif action == "deploy":
        deploy(cfg)
    else:
        sys.exit(2)


if __name__ == "__main__":
    main()

给它执行权限:

chmod 700 /usr/local/bin/certbot_qiniu_sync.py

这个脚本到底做了什么?

你可以把它理解成“部署管家”。它不会自己申请证书,它负责的是:

  • 从配置文件读取参数
  • 生成阿里云 DNS 插件要用的凭据文件
  • 在 Certbot 续签成功后:
    • 检查并 reload Nginx
    • 把新证书上传到七牛
    • 把七牛绑定域名切换到这张新证书

第四步:让 Certbot 在续签成功后自动调用这个脚本

现在主脚本有了,但还缺一个“触发器”。

Certbot 提供了一个很合适的机制:deploy hook。只要续签成功,它就会自动执行指定脚本。

创建文件:

mkdir -p /etc/letsencrypt/renewal-hooks/deploy
vim /etc/letsencrypt/renewal-hooks/deploy/qiniu-sync.sh

写入:

#!/usr/bin/env bash
set -euo pipefail

/usr/bin/python3 /usr/local/bin/certbot_qiniu_sync.py deploy /etc/ssl-automation/config.json >> /var/log/ssl-automation.log 2>&1

赋权:

chmod 700 /etc/letsencrypt/renewal-hooks/deploy/qiniu-sync.sh

这个脚本的作用

它本身不复杂,只是一个入口:

  • Certbot 成功续签
  • 自动执行这个 shell 脚本
  • shell 脚本再去调用 Python 主脚本

你也可以顺便记住日志位置:

/var/log/ssl-automation.log

以后想看自动更新有没有成功,主要看这个文件。

第五步:首次申请证书,并让 Nginx 用上它

现在自动化链路已经搭好了,接下来做第一次签发。

1)先生成阿里云 DNS 凭据文件

/usr/bin/python3 /usr/local/bin/certbot_qiniu_sync.py render-aliyun-credentials \
  /etc/ssl-automation/config.json \
  /etc/letsencrypt/aliyun.ini

chmod 600 /etc/letsencrypt/aliyun.ini

这一步的作用很简单:certbot-dns-aliyun 插件需要一个 INI 文件来读取阿里云 AK/SK,而我们不想手工维护两份配置,所以直接从 config.json 自动生成。

2)首次申请证书

这里也不要把域名手写死,直接从配置文件读:

python3 - <<'PY'
import json
import subprocess

cfg = json.load(open('/etc/ssl-automation/config.json', 'r', encoding='utf-8'))
certbot = cfg['certbot']
aliyun = cfg['aliyun']

cmd = [
    '/opt/certbot-venv/bin/certbot', 'certonly',
    '--non-interactive',
    '--agree-tos',
    '--email', certbot['email'],
    '--cert-name', certbot['cert_name'],
    '--authenticator', 'dns-aliyun',
    '--dns-aliyun-credentials', '/etc/letsencrypt/aliyun.ini',
    '--dns-aliyun-propagation-seconds', str(aliyun.get('dns_propagation_seconds', 60)),
]

for d in certbot['domains']:
    cmd.extend(['-d', d])

subprocess.run(cmd, check=True)
PY

这一步的作用是:

  • 向 Let’s Encrypt 申请证书
  • 用阿里云 DNS API 自动完成验证
  • 成功后把证书放到 /etc/letsencrypt/live/<cert_name>/

3)让 Nginx 使用这张证书

如果你本机有 Nginx,把证书路径改成:

ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;

这里的 example.com 应替换成你配置文件里的 cert_name

改完后执行:

nginx -t && systemctl reload nginx

这一步的意义在于:以后 Certbot 每次续签更新的都是这一路径,Nginx 只要 reload,读到的就是最新的证书文件。

第六步:把自动续签交给 systemd

到这里,单次签发和续签后的自动部署已经都有了,剩下最后一件事:让系统自己定时运行 certbot renew

1)创建 service

vim /etc/systemd/system/certbot-venv-renew.service

写入:

[Unit]
Description=Renew Let's Encrypt certificates with isolated Certbot
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
ExecStart=/opt/certbot-venv/bin/certbot renew --quiet

2)创建 timer

vim /etc/systemd/system/certbot-venv-renew.timer

写入:

[Unit]
Description=Twice daily renewal check for isolated Certbot

[Timer]
OnCalendar=*-*-* 03,15:17:00
RandomizedDelaySec=1800
Persistent=true

[Install]
WantedBy=timers.target

3)启用它

systemctl daemon-reload
systemctl enable --now certbot-venv-renew.timer

如果机器上已经有旧的 certbot.timer,建议停掉,避免混用:

systemctl disable --now certbot.timer

这个 timer 的作用

以后 systemd 会每天检查两次证书是否接近到期。真正接近到期时,才会触发续签;续签成功后,deploy hook 又会自动把新证书同步到 Nginx 和七牛云。

也就是说,后面真正自动发生的是:

  1. Certbot 发起续签
  2. 阿里云 DNS 自动完成验证
  3. 证书续签成功
  4. Deploy hook 被触发
  5. Nginx reload
  6. 七牛云对象存储域名证书自动更新

最后,日常其实只需要记住 3 个命令

查看当前证书:

/opt/certbot-venv/bin/certbot certificates

手动触发一次续签:

systemctl start certbot-venv-renew.service

查看自动化日志:

tail -100 /var/log/ssl-automation.log

总结

这套方案真正解决的问题,不是“怎么续签一次证书”,而是:

怎么把证书这件事彻底从手工操作里拿出来。

它的核心价值在于:

  • 证书来源统一
  • 验证流程自动化
  • Nginx 自动更新
  • 七牛自动更新
  • 后续维护成本非常低

如果你的域名同时用于:

  • 自己服务器上的站点
  • 七牛云对象存储的自定义域名

那么这是一种非常值得一次性配置好的方案。

¥赞赏