你正在读的这篇文章,本身就是它所描述的部署链路的产物:我在本地写下这段 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:
- 读者主要在国内,海外边缘节点的延迟和可达性不可控;
- 域名要备案才能绑国内 CDN,而备案本身要求接入商与云厂商绑定(备案接入商是腾讯云,域名就直接用腾讯云 CDN,避免跨平台排障);
- 我想要对象层控制权——能直接对产物文件做验证、签名请求、精细 ACL,而不是被平台 API 抽象遮住。
2. 博客搭建#
2.1 初始化#
# 基于 astro-theme-pure 主题初始化(详见主题官方文档)
npm create astro@latest -- --template cworld1/astro-theme-pure
npm install
npm run dev # http://localhost:4321bash两个关键配置(astro.config.ts):
export default defineConfig({
site: 'https://blog.zdzy.xyz', // 站点绝对地址
// output 默认即 'static'
})tssite 看似不起眼,实际影响一串下游行为:canonical URL、sitemap 生成、RSS 输出、OG 链接。域名变更时第一件事就是改它——我在部署时因为主域与子域切换,把这个值连带六处引用一起改掉。
2.2 主题配置的边界感#
astro-theme-pure 的个性化入口集中在 src/site.config.ts。我的原则是只动配置层,不动核心代码,这保证了后续跟随主题升级的成本最小化:
| 配置项 | 我的值 | 说明 |
|---|---|---|
title / author / description | 个性化 | 全站元信息 |
footer.icp | 备案号 + 工信部链接 | 国内站合规要求 |
social.github | 自己的 GitHub | 作者信息 |
waline.enable | false | 见下方「依赖审计」 |
依赖审计是主题二开最容易忽略的一步。 这个主题默认集成了 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 的边界——很多教程混用,但语义完全不同:
| Secrets | Variables | |
|---|---|---|
| 内容可见性 | 写入后不可读,日志中自动打码 | 明文可读 |
| 用途 | 凭据(API Key) | 配置(bucket 名、region、开关) |
| 安全模型 | 假设可能泄露,最小化暴露面 | 公开无害 |
我的实践:TENCENT_SECRET_ID/KEY 进 Secrets;COS_BUCKET、COS_REGION、DEPLOY_READY、CDN_LIVE 进 Variables。
3.2 完整 Workflow#
name: deploy
on:
push:
branches: [main]
workflow_dispatch: # 支持手动触发
jobs:
deploy:
runs-on: ubuntu-latest
if: ${{ vars.DEPLOY_READY == 'true' }}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
- name: Build
run: |
npm ci
npm run build
- name: Install coscli
run: |
curl -fsSL -o coscli https://cosbrowser.cloud.tencent.com/software/coscli/coscli-linux
sudo install -m 755 coscli /usr/local/bin/coscli
- name: Configure coscli
env:
SECRET_ID: ${{ secrets.TENCENT_SECRET_ID }}
SECRET_KEY: ${{ secrets.TENCENT_SECRET_KEY }}
COS_BUCKET: ${{ vars.COS_BUCKET }}
COS_REGION: ${{ vars.COS_REGION }}
run: |
{
echo "cos:"
echo " base:"
echo " secretid: ${SECRET_ID}"
echo " secretkey: ${SECRET_KEY}"
echo " buckets:"
echo " - name: ${COS_BUCKET}"
echo " alias: blog"
echo " region: ${COS_REGION}"
} > "$HOME/.cos.yaml"
- name: Upload dist to COS
run: coscli sync dist/ cos://${{ vars.COS_BUCKET }}/ -r
# 关键:显式验证,防 CI 假绿(原因见 3.3)
- name: Verify upload
run: |
set -e
LIST=$(coscli ls -r cos://${{ vars.COS_BUCKET }}/)
COUNT=$(echo "$LIST" | grep -c .)
echo "uploaded objects: $COUNT"
echo "$LIST" | grep -q "index.html" || { echo "::error::index.html missing in bucket"; exit 1; }
echo "$LIST" | grep -q "404.html" || { echo "::error::404.html missing in bucket"; exit 1; }
if [ "$COUNT" -lt 100 ]; then echo "::error::too few objects ($COUNT < 100)"; exit 1; fi
echo "upload verified: $COUNT objects, index.html + 404.html present"
- name: Purge CDN cache
if: ${{ vars.CDN_LIVE == 'true' }}
env:
TENCENTCLOUD_SECRET_ID: ${{ secrets.TENCENT_SECRET_ID }}
TENCENTCLOUD_SECRET_KEY: ${{ secrets.TENCENT_SECRET_KEY }}
run: |
pip install tccli
echo '{"Urls":["https://blog.zdzy.xyz/"]}' > purge.json
tccli cdn PurgeUrlsCache --cli-input-json file://purge.jsonyaml几个容易被文档坑掉的设计细节:
配置文件用 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两个实操要点:
- HTTP 方法大写发送,签名里小写。我用错大小写时返回
SignatureDoesNotMatch,改成小写后立刻变成AccessDenied——同一个 403,两种完全不同的含义。 - 这个「两态对照」是排障利器:
| 响应 | 含义 | 结论 |
|---|---|---|
SignatureDoesNotMatch | 签名验证失败 | 服务器还没看你的权限——先修签名 |
AccessDenied | 签名通过,CAM 拒绝 | 密钥有效、身份已识别,问题在权限策略 |
我在验证子账号权限时,就是靠这个对照把「密钥问题」和「策略问题」干净地切开的。
4.3 CAM 权限模型与最小权限#
给 CI 的密钥应该是专用子账号,且只授予部署所需的最小权限。我的策略(已实测验证):
{
"version": "2.0",
"statement": [
{
"effect": "allow",
"action": [
"cos:GetBucket",
"cos:HeadObject",
"cos:GetObject",
"cos:PutObject"
],
"resource": [
"qcs::cos:ap-shanghai:uid/1256237186:blog-1256237186/*"
]
},
{
"effect": "allow",
"action": ["cdn:PurgeUrlsCache"],
"resource": ["*"]
}
]
}jsonresource 的六段式语法拆解:
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 如何解释这个请求,这里有一个语义分叉,是我踩过最深的一个坑:
| 回源 Host | GET / 的语义 | 效果 |
|---|---|---|
桶的默认域名(<bucket>.cos.<region>.myqcloud.com) | ListBucket(列桶 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: SLT,X-Cache-Lookup: Return Directly,空响应体 | 响应头三件套,请求未回源 |
| 回源鉴权没开 | X-Cache-Lookup: Cache Miss,COS 的 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.jsonbash方法论上的教训:当工具连续两次行为与预期不符,停止试错,直接读源码(pip download tccli --no-deps 解包 grep,两分钟定位全部四个问题)。API 之上的 CLI 封装层是方言高发区,源码是唯一没有歧义的文档。
5. 结语#
回看整条链路,方法论比工具链更值得记录:
- 分阶段开关:把部署拆成可独立验证的阶段,让每个阶段失败时都能给出干净的信号;
- 不信任聚合器的退出码:验证你关心的状态本身,而不是命令的返回值;
- 权限写完必须实测:策略文本和生效语义之间隔着模板变量、语法结构、隐藏默认值三层坑;
- 排障先分层:一个 403 背后是「边缘拒绝/回源被拒/源站语义」三种完全不同的故障,响应头特征先于一切猜测;
- CLI 行为存疑时读源码:文档描述的是意图,源码描述的是行为。
整套方案的月成本不超过 5 元,构建一次 2 分半,增量部署几十秒。这是静态博客该有的样子:复杂度一次性付清,之后只剩写字。
博客上线了,后面的路还长:评论区自建、图床、写作工作流……这些等做成之后再写续篇。