zdzy

Back

你正在读的这篇文章,本身就是它所描述的部署链路的产物:我在本地写下这段 Markdown,git push 之后,GitHub Actions 构建出静态页面,上传到腾讯云 COS 私有桶,由 CDN 分发到你现在访问的域名。整个过程没有任何一步手工操作。

这篇文章完整记录这条链路的搭建方法与背后原理,包括我在排障中踩过的每一个坑——它们大多不是「搜索一下就能解决」的问题,而是需要理解系统内部机制才能定位的深水区。

0. 成果先看#

一句话架构:

git push → GitHub Actions(构建 + 验证)→ coscli 上传 COS → tccli 刷新 CDN 缓存 → 域名分发

组件职责月成本
GitHub 私有仓库源码托管、版本控制免费
GitHub Actions构建引擎、部署执行器免费(公开仓库无限,私有仓库有额度)
腾讯云 COS对象存储(私有桶)< 1 元(按存储量 + 请求计费)
腾讯云 CDN边缘分发、HTTPS、缓存~1 元(个人博客流量级)
域名 + 备案唯一的固定支出域名年付,备案免费

总计月成本 5 元以内,换来的是完全的数据主权:源码、构建过程、产物、分发全部自己可控。

1. 选型:从本质出发#

1.1 静态博客的本质#

先把问题还原到第一性原理:博客是什么?

剥掉所有动态特性,博客的核心是「内容 → 页面」的映射。内容是 Markdown,页面是 HTML,中间需要一个转换器。这个转换发生在什么时刻,决定了整个架构:

  • 运行时转换(WordPress、动态 SSR):每次请求都执行转换。需要常驻服务、数据库、进程守护。
  • 构建时转换(静态站点生成器):转换在发布时一次性完成,运行时只剩「读取文件 → 返回」。

对个人博客而言,内容更新的频率是「天」,读者访问的频率是「次/秒」。用运行时计算服务一个构建时就能解决的问题,是资源错配。所以选静态生成器,剩下的全部问题就是:生成的 HTML 放哪里、怎么分发

1.2 托管方案对比#

方案国内可达性备案要求控制权成本
Vercel / Netlify不稳定(部分线路被墙)无需低(平台绑定)免费额度内免费
Cloudflare Pages尚可无需免费
GitHub Pages一般无需免费
COS + CDN好(国内边缘节点)需要 ICP高(对象层可控)< 5 元/月

三个前提决定了我选 COS + CDN:

  1. 读者主要在国内,海外边缘节点的延迟和可达性不可控;
  2. 域名要备案才能绑国内 CDN,而备案本身要求接入商与云厂商绑定(备案接入商是腾讯云,域名就直接用腾讯云 CDN,避免跨平台排障);
  3. 我想要对象层控制权——能直接对产物文件做验证、签名请求、精细 ACL,而不是被平台 API 抽象遮住。

2. 博客搭建#

2.1 初始化#

# 基于 astro-theme-pure 主题初始化(详见主题官方文档)
npm create astro@latest -- --template cworld1/astro-theme-pure
npm install
npm run dev  # http://localhost:4321
bash

两个关键配置(astro.config.ts):

export default defineConfig({
  site: 'https://blog.zdzy.xyz',  // 站点绝对地址
  // output 默认即 'static'
})
ts

site 看似不起眼,实际影响一串下游行为:canonical URL、sitemap 生成、RSS 输出、OG 链接。域名变更时第一件事就是改它——我在部署时因为主域与子域切换,把这个值连带六处引用一起改掉。

2.2 主题配置的边界感#

astro-theme-pure 的个性化入口集中在 src/site.config.ts。我的原则是只动配置层,不动核心代码,这保证了后续跟随主题升级的成本最小化:

配置项我的值说明
title / author / description个性化全站元信息
footer.icp备案号 + 工信部链接国内站合规要求
social.github自己的 GitHub作者信息
waline.enablefalse见下方「依赖审计」

依赖审计是主题二开最容易忽略的一步。 这个主题默认集成了 Waline 评论系统,配置指向的是主题作者自己的演示服务器——意味着你不改配置,读者的评论数据会发到陌生人的服务器上。第三方默认配置必须在上线前逐项过一遍:要么关闭,要么指向自己部署的实例。

另一个类似问题:友链头像、示例文章配图默认引用外部图床。构建产物依赖外部资源 = 部署不可重复。我把所有引用资源下载到 public/ 本地化,构建从「依赖外部服务的拼装」变成「完全自包含的转换」。

3. 自动化部署设计#

这是整篇文章的核心。先看完整 workflow,再逐层拆解。

3.1 分阶段开关:解决「初始化期必红」问题#

一个现实矛盾:workflow 文件要随源码进仓库,但部署依赖的外部资源(云密钥、COS 桶、CDN 域名)还没准备好。如果 workflow 无条件执行,初始化阶段的每次 push 都会产生红色失败——噪音会淹没真实的信号。

解法是把「部署就绪」做成显式开关,用 GitHub Variables(非 Secrets)承载:

jobs:
  deploy:
    # 阶段开关 1:桶、密钥、Variables 都配好后设 DEPLOY_READY=true
    if: ${{ vars.DEPLOY_READY == 'true' }}
    steps:
      # ... 构建上传 ...
      # 阶段开关 2:CDN 域名上线、回源配置完成后设 CDN_LIVE=true
      - name: Purge CDN cache
        if: ${{ vars.CDN_LIVE == 'true' }}
yaml

两级开关把部署生命周期切成三个阶段:CI 骨架验证(两开关全关,run 显示 skipped,9 秒结束,无红色失败)→ 对象上传验证(DEPLOY_READY 开,产物真实落桶)→ 全链路(CDN_LIVE 开,缓存刷新上线)。

顺带说清 Secrets 与 Variables 的边界——很多教程混用,但语义完全不同:

SecretsVariables
内容可见性写入后不可读,日志中自动打码明文可读
用途凭据(API Key)配置(bucket 名、region、开关)
安全模型假设可能泄露,最小化暴露面公开无害

我的实践:TENCENT_SECRET_ID/KEY 进 Secrets;COS_BUCKETCOS_REGIONDEPLOY_READYCDN_LIVE 进 Variables。

3.2 完整 Workflow#

几个容易被文档坑掉的设计细节:

配置文件用 echo 逐行写,不用 heredoc。 GitHub Actions 的 run: | 块内所有行天然带基础缩进,而 heredoc 会把缩进原样写入文件——结果 YAML 顶层 key 带缩进、结束符 EOF 不在行首。产物是一个语法上就错误的配置文件,而且 cat > 本身退出码是 0,步骤照样绿。echo 逐行拼接虽然啰嗦,但每一行都明确可控。

coscli 配置文件路径是 ~/.cos.yaml,不是 ~/.coscli.yaml 写错路径的后果极具迷惑性:coscli 找不到配置,触发交互式初始化向导,在 CI 的非交互终端里所有输入都是空,最终生成一个空配置文件——endpoint 为空,DNS 解析 bucket.(空域名)失败。整条失败链路上没有任何一行日志提到「配置文件路径不对」。

tccli 凭据走环境变量。 TENCENTCLOUD_SECRET_ID / TENCENTCLOUD_SECRET_KEY(注意带下划线),优先级高于配置文件,密钥不落盘到 runner。

3.3 CI 假绿:最危险的一类失败#

我在这条链路上遇到的最阴险的问题:coscli sync 逐文件报错但整体退出码为 0。CI 显示绿色通过,实际上传了一个文件都没有。

这不是 coscli 独有的问题,而是所有「聚合器命令」的通病:命令内部循环处理子任务,子任务失败只记日志,不冒泡退出码。防御手段只有一个——不要信任聚合器的成功,信任你验证过的状态

- name: Verify upload
  run: |
    LIST=$(coscli ls -r cos://bucket/)
    COUNT=$(echo "$LIST" | grep -c .)
    echo "$LIST" | grep -q "index.html" || exit 1  # 关键对象存在性
    [ "$COUNT" -lt 100 ] && exit 1                  # 数量下限断言
yaml

验证步骤让「假绿」无处藏身:上传失败必然表现为 Verify 步骤红色失败,而红色失败是可定位的,静默失败不是。CI 的价值不在「绿」,在于失败时的信号质量。

4. 原理篇#

方法讲完了,下面是支撑这些方法(以及排障)的原理。

4.1 coscli sync 的增量对比#

sync 不是盲目全量上传。对每个本地文件,它先对远端同名对象发 HEAD 请求:

  • 远端不存在 → 上传;
  • 远端存在 → 对比大小与修改时间(或 ETag),一致则跳过,不一致则上传。

这就是为什么首次部署要 9 分钟(226 个对象全量上传),后续部署只要几十秒(绝大多数 HEAD 命中后跳过)。理解这个机制还有一个排障价值:如果你的 CAM 策略漏了 cos:HeadObject,sync 的对比环节会全部失败,行为退化为「每个文件都当不存在直接上传」——能跑通,但每次都是全量,且日志里会有大量 HEAD 权限错误。一个权限缺失不一定让流程挂掉,可能只是让它悄悄变慢,这类「降级成功」比直接失败更难察觉。

4.2 COS XML API 的请求签名#

所有对 COS 的请求都要带 Authorization 头。CI 里的 coscli、排障时的 curl、任何自定义脚本,走的都是同一套签名算法。理解它,你就能在没有 SDK 的环境里直接跟 COS 对话(我在排障权限问题时用 40 行 Python 标准库手写了签名,不装任何依赖)。

签名计算链:

KeyTime      = <起>;<止>                          # Unix 时间戳区间
SignKey      = HmacSHA1(SecretKey, KeyTime)       # 十六进制输出
HttpString   = <method小写>\n<uri>\n<params>\n<headers>\n
StringToSign = "sha1\n" + KeyTime + "\n" + SHA1(HttpString) + "\n"
Signature    = HmacSHA1(SignKey, StringToSign)

Authorization = q-sign-algorithm=sha1&q-ak=<SecretId>
              &q-sign-time=<KeyTime>&q-key-time=<KeyTime>
              &q-header-list=<参与签名的header键>&q-url-param-list=<参数键>
              &q-signature=<Signature>
text

两个实操要点:

  1. HTTP 方法大写发送,签名里小写。我用错大小写时返回 SignatureDoesNotMatch,改成小写后立刻变成 AccessDenied——同一个 403,两种完全不同的含义。
  2. 这个「两态对照」是排障利器:
响应含义结论
SignatureDoesNotMatch签名验证失败服务器还没看你的权限——先修签名
AccessDenied签名通过,CAM 拒绝密钥有效、身份已识别,问题在权限策略

我在验证子账号权限时,就是靠这个对照把「密钥问题」和「策略问题」干净地切开的。

4.3 CAM 权限模型与最小权限#

给 CI 的密钥应该是专用子账号,且只授予部署所需的最小权限。我的策略(已实测验证):

resource 的六段式语法拆解:

qcs : cos : ap-shanghai : uid/1256237186 : blog-1256237186 /*
 │    │        │              │                  │         └── 对象路径(* = 全部对象)
 │    │        │              │                  └── 桶名-APPID(对象级操作必须能匹配到对象,故需 /*)
 │    │        │              └── 主账号 APPID
 │    │        └── 地域
 │    └── 产品
 └── 腾讯云资源描述前缀
text

两个设计决策:不给 DeleteObject(sync 不带 --delete,桶内对象只增不删,误操作无法波及存量——代价是测试文件会残留);不给 PutObjectACL(CI 不需要改对象 ACL;CDN 访问私有桶走的是下一节讲的回源鉴权,与子账号无关)。

这里有个真实的教训:我最初拿到的策略把 resource 写成了 qcs::cos::uid/1256237186:${bucket-appid}/blog-1256237186——${bucket-appid} 是控制台模板里未替换的占位符,CAM 不做变量插值,它就是一段字面文本,匹配不到任何真实资源。结果是策略声称 allow 的全部操作实测 403。权限策略的「写了」和「生效了」是两回事,验证手段只有实测。

4.4 CDN 请求生命周期#

理解 CDN 最好的模型是把每个请求分成两段:

客户端 ──①──> CDN 边缘节点 ──②──> 回源源站(COS)
         <──────①'──────── <──────②'──
text

第 ① 段的决策:边缘节点查本地缓存。命中直接返回(X-Cache-Lookup: Hit,延迟 ~0.1s);未命中走第 ② 段回源拉取(Cache Miss)。URL 鉴权(防盗链)也发生在这段——开了它,裸 URL 在边缘就被拒,请求根本不会回源

第 ② 段的关键参数是「回源 Host」——它决定了 COS 如何解释这个请求,这里有一个语义分叉,是我踩过最深的一个坑:

回源 HostGET / 的语义效果
桶的默认域名<bucket>.cos.<region>.myqcloud.comListBucket(列桶 API)匿名/无权限 → 403
桶的静态网站域名<bucket>.cos-website.<region>.myqcloud.com映射到 index.html返回首页

同一个 URL,回源 Host 不同,COS 看到的是两个完全不同的 API 调用。我的博客一度出现诡异状态:/index.html 正常 200,裸域名 / 403——因为回源鉴权已生效(对象能拉到),但回源 Host 指向默认域名,/ 被当成列桶请求拒掉了。

修复路径:COS 桶 → 基础配置 → 静态网站(开启,索引文档 index.html,错误文档 404.html),再把 CDN 域名的源站切到静态网站源站。这个配置同时解决了三件事:/ 映射首页、目录 URL(/blog/)映射目录内 index、404 返回自定义错误页而不是 COS 的 XML。

4.5 私有桶 + CDN 的权限链#

桶是私有的(拒绝一切匿名访问),但读者访问博客不需要任何凭证——因为权限链在另一端:

CDN 边缘节点回源时,以「CDN 服务身份」访问 COS

COS 检查回源鉴权授权(COS 侧开关,开启时自动创建 CDN 服务角色)

授权范围 = 该桶的对象读取
text

三个开关必须同时正确,缺一不可,且每个开关配错都会产生特征不同的 403

配置错误403 特征定位方法
URL 鉴权(防盗链)开着Server: SLTX-Cache-Lookup: Return Directly空响应体响应头三件套,请求未回源
回源鉴权没开X-Cache-Lookup: Cache MissCOS 的 XML AccessDenied已回源,COS 拒绝匿名身份
回源 Host 用了默认域名对象路径 200,/ 返回 XML 403(ListBucket 语义)对象级路径测试对照

最后一张表就是我的「CDN 403 三层定位法」——排障时先抓响应头判断请求死在哪一段,再对症下药,避免在控制台里乱翻配置。

4.6 tccli 的参数方言#

CDN 缓存刷新用 tccli(腾讯云官方 CLI)。它和文档印象之间的偏差之多,值得一节单独记录:

尝试结果
tccli configure set --secretId xxx报错要位置参数:configure set secretId xxx
环境变量 TENCENTCLOUD_SECRETID无效——正确名是 TENCENTCLOUD_SECRET_ID(带下划线)
--Urls.0 https://... 数组索引无效——点号展开需显式 --cli-unfold-argument 开关
--cli-input-json '{"Urls":[...]}' 内联 JSON无效——强制 file:// 前缀只认文件

最终可靠组合:

echo '{"Urls":["https://blog.zdzy.xyz/"]}' > purge.json
tccli cdn PurgeUrlsCache --cli-input-json file://purge.json
bash

方法论上的教训:当工具连续两次行为与预期不符,停止试错,直接读源码pip download tccli --no-deps 解包 grep,两分钟定位全部四个问题)。API 之上的 CLI 封装层是方言高发区,源码是唯一没有歧义的文档。

5. 结语#

回看整条链路,方法论比工具链更值得记录:

  1. 分阶段开关:把部署拆成可独立验证的阶段,让每个阶段失败时都能给出干净的信号;
  2. 不信任聚合器的退出码:验证你关心的状态本身,而不是命令的返回值;
  3. 权限写完必须实测:策略文本和生效语义之间隔着模板变量、语法结构、隐藏默认值三层坑;
  4. 排障先分层:一个 403 背后是「边缘拒绝/回源被拒/源站语义」三种完全不同的故障,响应头特征先于一切猜测;
  5. CLI 行为存疑时读源码:文档描述的是意图,源码描述的是行为。

整套方案的月成本不超过 5 元,构建一次 2 分半,增量部署几十秒。这是静态博客该有的样子:复杂度一次性付清,之后只剩写字。

博客上线了,后面的路还长:评论区自建、图床、写作工作流……这些等做成之后再写续篇。

从零搭建:Astro 博客 + GitHub Actions + 腾讯云 COS/CDN 自动化部署全记录
https://blog.zdzy.xyz/blog/build-blog-astro-cos-cdn
Author Easton
Published at September 15, 2026