阿里云 ECS 自建博客 + HTTPS 证书"永久自动续期":实例 RAM 角色 + DNS-01,全程零密钥文件

写在前面:Let's Encrypt 的证书只有 90 天,到期必须续。手动续很烦,而通配符证书(*.example.com)只能用 DNS-01 验证,意味着每次续期都要去 DNS 服务商加一条 TXT 记录——更烦。
本文的方案:不给服务器保存任何长期 AccessKey,而是让 ECS 实例用自身绑定的 RAM 角色,在续期那一刻临时领取 1 小时有效的 STS 凭证,自动加删 TXT 记录。配好之后,证书自动续到服务器退役为止,你什么都不用做

本文所有示例用 example.com 占位,请全部替换成你自己的域名。

整体架构

浏览器
 ├─ https://example.com      → Typecho 博客(PHP-FPM + SQLite)
 └─ https://file.example.com → FileBrowser 网盘(可选,反代)
        │
        ▼
Nginx(443,证书 /etc/letsencrypt/live/example.com/)
        │
        ▼
acme.sh(每 6 小时检查一次,到期前自动续签)
        │ ① 从实例元数据领取角色临时凭证(STS,1 小时有效)
        ▼
阿里云 DNS API → 自动加/删 _acme-challenge TXT 记录(DNS-01 验证)
        │
        ▼
Let's Encrypt 签发 90 天新证书 → 自动拷贝到 nginx 路径 → reload → 生效

一、准备工作

  1. 域名:阿里云注册(或转入),完成实名认证。云解析 DNS 里添加记录(可以先加,后面要改)。
  2. ECS:购买 Ubuntu 22.04,2C2G + 2G swap 起步——实测 nginx + PHP + FileBrowser 全家桶只用 70~100MB 内存,绰绰有余。
  3. 备案:服务器在境内,对外提供网页服务需完成 ICP 备案(建议尽早提交,周期较长)。
  4. 登录方式:建议密钥对并关闭密码登录;本机(你的电脑)配好 SSH。

二、创建 RAM 角色并绑定到实例(全方案的核心)

目的:让实例自己能临时扮演一个有 DNS 权限的"身份",不需要任何一把长期钥匙。

  1. 控制台 → RAM 访问控制 → 角色 → 创建角色
  2. 可信实体类型:选"阿里云服务",云服务类型选"云服务器 ECS"
    ⚠️ 这是最容易选错的一步。选"云账号"是给跨账号授权用的,实例元数据不认;必须是"阿里云服务 → ECS"。生成后信任策略长这样:

    {
      "Statement": [{
        "Effect": "Allow",
        "Action": "sts:AssumeRole",
        "Principal": { "Service": ["ecs.aliyuncs.com"] }
      }]
    }
  3. 给角色授权:AliyunDNSFullAccess(最小够用——它只负责加删 _acme-challenge 的 TXT 记录)。
  4. 把角色授予实例:ECS 控制台 → 实例 → 更多 → 实例设置 → 授予/修改 RAM 角色 → 选择刚建的角色。
    ⚠️ 注意:一台实例同时只能绑定一个角色,绑新的会顶掉旧的。
  5. 在服务器上验证(该地址只有实例内部能访问):

    curl http://100.100.100.200/latest/meta-data/ram/security-credentials/
    # 应返回你的角色名

三、领取 STS 临时凭证

实例元数据服务会按角色发放临时凭证,约 1 小时有效、自动轮换、随时可再领:

# 领取凭证(返回 JSON:AccessKeyId 以 STS. 开头、AccessKeySecret、SecurityToken、Expiration)
curl http://100.100.100.200/latest/meta-data/ram/security-credentials/<你的角色名>

要点:凭证用完即弃,不要写进任何配置文件。历史上很多人(包括我)把它存进配置文件,结果签名时缺 SecurityToken 参数,报 MissingSecurityToken,排查半天——别踩。

四、安装基础软件

sudo apt update
sudo apt install -y nginx php php-fpm php-mbstring php-gd php-curl php-xml php-sqlite3 \
    unzip curl sqlite3
# 记下 php-fpm socket 路径,后面 nginx 配置要用
ls /var/run/php/php*-fpm.sock

五、DNS 解析

云解析添加三条 A 记录(值 = ECS 公网 IP):

主机记录类型TTL
@A<ECS公网IP>600
wwwA<ECS公网IP>600
fileA<ECS公网IP>600(用不到网盘可省)

六、部署 Typecho(博客程序)

cd /tmp
wget https://github.com/typecho/typecho/releases/latest/download/typecho.zip
unzip typecho.zip
sudo mkdir -p /var/www/blog
sudo cp -r /tmp/build/* /var/www/blog/
sudo chown -R www-data:www-data /var/www/blog
# 让当前用户加入 www-data 组,以后免 sudo 改博客文件(重登生效)
sudo usermod -aG www-data $USER

浏览器打开 https://example.com → 安装向导:

  • 数据库驱动选 SQLite(PDO)——零配置,全站数据就是一个文件,备份 = 拷走一个文件
  • 设置管理员账号密码
  • 装完立刻删除 /var/www/blog/install.php——安装页不需要登录,谁拿到 URL 都能重装,这是最常见的洞
  • 有备案号的话加在主题 footer.php(生效主题可能是自定义主题,路径相应调整)

七、HTTPS 入口(nginx)

证书就位之前,站点可以先以纯 HTTP 跑通;等第九节签出证书后,再在 nginx 站点配置里把 ssl_certificate / ssl_certificate_key 指向 /etc/letsencrypt/live/example.com/ 下对应的 fullchain.pemprivkey.pem,nginx -t 通过后 reload 即可上 HTTPS(80 端口统一 301 到 443)。PHP 走 fastcgi、改完先 nginx -t 这类要点按你熟悉的写法即可——server 块的具体写法不是本文重点,这里不展开。

八、(可选)FileBrowser 网盘

下载二进制到 ~/.local/bin/filebrowser,写 systemd 单元 /etc/systemd/system/filebrowser.service:

[Unit]
Description=File Browser
After=network.target
[Service]
ExecStart=/home/<用户名>/.local/bin/filebrowser --database /home/<用户名>/.filebrowser.db
Restart=on-failure
RestartSec=5
User=<用户名>
Group=<用户名>
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload && sudo systemctl enable --now filebrowser
# 初始化管理员(filebrowser 版本不同命令略有差异,以官方文档为准)
~/.local/bin/filebrowser users add <管理员> <密码> --perm.admin

只监听 127.0.0.1:18080,再配一个 nginx 反向代理(核心就一句 proxy_pass http://127.0.0.1:18080/;)提供 HTTPS,不要裸奔公网端口

九、证书申请与全自动续期(重点)

9.1 安装 acme.sh

curl https://get.acme.sh | sh

它会把 cron 和 shell 环境自动配好,客户端装在 ~/.acme.sh/

9.2 给 dns_ali 插件打 STS 补丁

acme.sh 的阿里云 DNS 插件(dns_ali)只支持永久 AccessKey,不支持 STS 临时凭证(签名时缺 SecurityToken 会报 MissingSecurityToken)。我们打一个 5 处的小补丁让它支持:

文件:~/.acme.sh/dnsapi/dns_ali.sh

修改 1:4 个查询构造器(_check_exist_query/_add_record_query/_delete_record_query/_describe_records_query)里,在 SignatureMethod 那行之前插入(位置必须保持参数字母序,因为阿里云按字母序参与签名;令牌值含 + = 等字符,必须 URL 编码,用插件自带的 _ali_urlencode_upper):

  if [ -n "$Ali_Security_Token" ]; then
    _ali_sts_token="$(printf "%s" "$Ali_Security_Token" | _ali_urlencode_upper)"
    query=$query'&SecurityToken='"$_ali_sts_token"
  fi

修改 2:_prepare_ali_credentials() 里,凭证保存改为"有令牌时不写回 account.conf"(防止临时凭证被持久化,埋下日后的坑):

  if [ -n "$Ali_Security_Token" ]; then
    _debug "STS mode: skip saving credentials to account.conf"
  else
    _saveaccountconf_mutable Ali_Key "$Ali_Key"
    _saveaccountconf_mutable Ali_Secret "$Ali_Secret"
  fi

验证语法:bash -n ~/.acme.sh/dnsapi/dns_ali.sh

9.3 包装脚本:领证 → 喂给 acme.sh

存为 ~/.ali-sts-acme.sh(角色名改成你自己的):

#!/bin/bash
# 从实例 RAM 角色领取临时 STS 凭证驱动 acme.sh。凭证不落盘、不打印。
set -euo pipefail
ROLE="${ALI_STS_ROLE:-<你的角色名>}"
META_URL="http://100.100.100.200/latest/meta-data/ram/security-credentials/${ROLE}"
json="$(curl -s --connect-timeout 3 -m 10 "$META_URL")" || { echo "STS 获取失败" >&2; exit 1; }
creds="$(python3 - "$json" <<'PY'
import json, sys
try: d = json.loads(sys.argv[1])
except Exception: sys.exit("STS 解析失败")
if d.get("Code") != "Success": sys.exit("STS 拒绝: {}".format(d.get("Message","")))
print(d["AccessKeyId"]); print(d["AccessKeySecret"]); print(d["SecurityToken"])
PY
)"
export Ali_Key="$(echo "$creds" | sed -n 1p)"
export Ali_Secret="$(echo "$creds" | sed -n 2p)"
export Ali_Security_Token="$(echo "$creds" | sed -n 3p)"
exec /home/<用户名>/.acme.sh/acme.sh "$@"
chmod +x ~/.ali-sts-acme.sh

9.4 首次签发

~/.ali-sts-acme.sh --issue -d 'example.com' -d '*.example.com' \
    --dns dns_ali --server letsencrypt --force

能看到它自动添加 TXT、验证、出证书。证书在 ~/.acme.sh/example.com_ecc/

9.5 登记安装路径(续签后自动部署到 nginx)

告诉 acme.sh:以后每次续签成功,把新证书拷到 nginx 读的路径并 reload。reload 必须能非交互执行——两种做法:

  • 给当前用户配 NOPASSWD sudo(简单粗暴),reloadcmd 用 sudo -n systemctl reload nginx
  • 或密码管道 echo '<密码>' | sudo -S systemctl reload nginx(注意:密码会以 base64 存在 acme.sh 的 conf 里,介意的用第一种)
~/.acme.sh/acme.sh --install-cert -d example.com \
  --key-file /etc/letsencrypt/live/example.com/privkey.pem \
  --fullchain-file /etc/letsencrypt/live/example.com/fullchain.pem \
  --reloadcmd "<上面的非交互 reload 命令>"

9.6 cron 走包装脚本 + 关闭自动升级

crontab -e
# 内容(每 6 小时检查一次,到期前才续;分钟数随意,不必照抄):
# 17 0,6,12,18 * * * /home/<用户名>/.ali-sts-acme.sh --cron --home "/home/<用户名>/.acme.sh" > /dev/null

关闭 acme.sh 自动升级(重要:自动升级会覆盖我们打的 dns_ali 补丁):

~/.acme.sh/acme.sh --upgrade --auto-upgrade 0

以后手动升级 acme.sh 时,记得重新打一遍 9.2 的补丁。

十、验证清单与排障

curl -sI https://example.com    # 200
# 服务中的证书必须是新签发的:
echo | openssl s_client -connect 127.0.0.1:443 -servername example.com 2>/dev/null \
  | openssl x509 -noout -enddate
# 强迫症体检:手动模拟一次完整续签
~/.ali-sts-acme.sh --renew -d example.com --force
# 成功标志:输出 Installing key to: … + Reload successful
症状原因处理
MissingSecurityToken补丁丢了(升级覆盖)/没走包装脚本重打补丁;一律用 ~/.ali-sts-acme.sh 调用
STS 获取失败角色没绑实例 / 角色名写错curl .../security-credentials/ 核对;看角色是否带 DNS 权限
证书文件是新的、网站还是旧的reload 没生效sudo systemctl reload nginx,检查 reloadcmd 能否非交互执行
acme.sh 打印 manual mode 警告后退出域名配置还是手动 DNS 模式用 9.4 的 --issue … --dns dns_ali 重签一次即转自动
到期没续上机器停机跨过窗口 / cron 被改手动 --renew --force 一次;acme.sh 支持 ARI,错过会尽快补

结语与安全提醒

  • 为什么不用永久 AccessKey:保存在服务器上的密钥等于一颗定时炸弹——泄露一次就要全网轮换。实例 RAM 角色 + 1 小时 STS 凭证随用随弃,泄露窗口趋近于零,这是本方案的核心价值。
  • 备份就三个文件:Typecho 的 SQLite 库、上传目录、FileBrowser 的库。拷走即全量备份。
  • 常规卫生:装完删 install.php;PHP 大版本 EOL 后记得升级;安全组只开 22/443。
  • 本文为经验分享,生产环境请结合自身情况评估;操作前建议给 nginx 配置等关键文件留一份带时间戳的备份。

标签: none

添加新评论