
1. 为什么我会把 GitHub Actions、对象存储和 CDN 拼在一起先说个背景。去年我维护了好几个静态站点有的是项目文档站有的是个人博客还有一个是给团队内部用的工具集页面。最初图省事全塞在一台云服务器上Nginx 一配就完事。但后来发现几个很拧巴的问题服务器挂了网站就挂带宽峰值上不去而且每次发版都要手动 ssh 上去拉代码、构建、挪文件一遍遍重复时间全耗在机械操作上了。后来我开始研究静态资源托管市面上方案不少但真正让我决定用“GitHub Actions 火山引擎对象存储 CDN”这套组合其实是因为一个很现实的需求我希望整个发布流程能做到 push 代码后自动完成同时访问量上来时不会因为单机带宽而卡壳而且成本要控制在每个月几十块以内。这套链路解决的核心问题其实就三个构建自动化GitHub Actions 监听代码仓库的 push 或者 tag自动跑构建命令产出静态文件。托管稳定静态文件上传到对象存储存储本身自带多副本冗余不再依赖单台服务器的磁盘和进程。加速分发CDN 做全局缓存和就近回源把静态资源的访问延迟压下来同时也能扛住一定规模的突发流量。这套方案适合谁适合个人博客、文档站点、官网落地页、产品手册、活动页面这类“以静态资源为主、不需要服务端动态渲染”的场景。如果你用的是 VuePress、Docusaurus、Hugo、Hexo、Astro 这类静态站点生成器那基本就是完美匹配。如果还想要评论、搜索、表单这类功能可以再接第三方服务不影响整体的发布链路。我自己跑下来的体验是整个流程一旦配好后面几乎没有人工干预改文档、发新版本就是在本地改完 push 一下几分钟后线上就已经更新了。2. 火山引擎对象存储侧的准备一个 Bucket 决定了后面所有环节接下来的内容全部基于真实操作过程。先说对象存储因为这是整个部署链路的“落脚点”配置错了后面所有环节都会跟着错。访问火山引擎控制台找到“对象存储”产品入口创建一个 Bucket。这一步听起来简单但里面有几个选项会直接影响后续的自动化流程和 CDN 回源必须提前想清楚。2.1 创建 Bucket 时的访问权限选择创建 Bucket 时会让你选“读写权限”常见选项是“私有读写”和“公共读”。这里我强烈建议选私有读写。原因在于如果你开启了公共读那么任何人拿到你的 Bucket 域名就能直接访问资源虽然方便但一旦 CDN 配置错了回源鉴权流量绕过 CDN 直接打到存储上费用和安全性都不好控制。而且对象存储的默认域名一般带一串随机字符不好看也不适合直接对外使用。正确姿势是Bucket 保持私有读写外部访问全部走 CDNCDN 回源时通过鉴权方式读取私有 Bucket 内容。这样资源只能通过 CDN 访问存储本身不直接暴露在公网。创建时还有一个区域的选择。这块要注意对象存储的区域跟你后续 CDN 的加速区域是两码事。存储区域尽量选择离你目标用户近的如果主要访问者在大陆那就选大陆区域如果主要访问者在海外就选对应区域。但更精准的做法是主要靠 CDN 调度存储区域只要不过分偏远就行。2.2 访问密钥不建议直接写在仓库里GitHub Actions 要往对象存储上传文件肯定需要访问密钥。但你要是把 Access Key 和 Secret Key 直接写进仓库的.yml文件里那基本等于把家门钥匙贴在门口。正确做法是把密钥配在 GitHub 仓库的 Secrets 里。在仓库页面进入Settings - Secrets and variables - Actions然后添加两个 Secret比如VOLC_ACCESS_KEYVOLC_SECRET_KEY工作流文件里通过${{ secrets.VOLC_ACCESS_KEY }}这种方式引用。这样密钥既不会出现在代码仓库里也能保证 CI 运行时有权限调用。我见过有人图省事把密钥写在.env文件里然后传到公有仓库结果爬虫扫描到之后被刷了大量流量月底账单直接几百块。密钥管理这件事上踩过的坑不值得再踩一遍。2.3 静态网站托管与默认域名的关系这一步是很多新手容易忽略的。创建完 Bucket 之后需要你在 Bucket 的“基础设置”里开启“静态网站托管”功能。开启之后你才能设置默认首页比如index.html和 404 页面比如404.html。同时这里会生成一个静态网站访问域名格式类似bucketname.region.tos-cn.volces.com。这个域名是给 CDN 回源用的后续配置 CDN 时会用到。特别注意如果你构建出来的网站用了history 路由模式比如 Vue Router 的createWebHistory那么在刷新一个子页面路径比如/docs/guide时对象存储会去找docs/guide这个文件找不到就返回 404。解决办法有两类构建时把路由改成 hash 模式URL 带#丑一点但省事保留 history 模式但需要在存储侧设置“错误文档响应”为index.html也就是访问不到文件时回退到首页由前端路由接管。不过这样会牺牲真实 404 的语义需要在前端路由里做一个 NotFound 页面。这里没有标准答案按产品需求取舍。我的经验是如果是文档站用 history 模式体验更好如果是临时活动页hash 模式足够。3. GitHub Actions 工作流从 push 到对象存储的完整链路对象存储这边准备完毕接下来就是整个流水线的核心——GitHub Actions。这一节我会把实际可用的工作流文件拆开讲并解释每一段为什么这么写避免直接抄完却不知道在干什么。3.1 工作流文件的骨架在仓库根目录创建.github/workflows/deploy.yml一个最基础的可运行版本大概是这样的name: deploy-static-site on: push: branches: - main workflow_dispatch: jobs: build-and-deploy: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkoutv4 - name: Install pnpm uses: pnpm/action-setupv2 with: version: 8 - name: Set up Node uses: actions/setup-nodev4 with: node-version: 18 cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile - name: Build run: pnpm build - name: Deploy to Object Storage uses: volcengine/stack-storage-uploadv1 with: access_key: ${{ secrets.VOLC_ACCESS_KEY }} secret_key: ${{ secrets.VOLC_SECRET_KEY }} region: cn-beijing bucket: my-static-site local_path: dist remote_path: / delete_mirror: true这段配置里有两个地方值得多说几句。3.2 构建之前先确认构建产物目录不同静态站点生成器产出的目录名不一样VuePress 默认是distDocusaurus 默认是buildHugo 默认是publicVitePress 是dist。你需要先确认自己项目的产物目录然后把local_path改成对应的目录。这个错了我一开始也犯过构建成功后上传了个空目录页面访问全是 404。建议在 Build 步骤后面加一个查看产物的动作来验证- name: List build output run: ls -la dist这样一旦构建产物异常能在 Actions 日志里第一时间看到。3.3 上传逻辑同步而不是覆盖上传到对象存储时最怕的不是传不上去而是旧文件残留。比如网站首页引用了一个app.1a2b3c.css你改完代码重新构建后变成了app.4d5e6f.css但app.1a2b3c.css这个旧文件还在云端。短期看没毛病时间一长存储里堆满了历史版本文件体积越来越大而且某些旧资源如果恰好被外部引用了还会导致线上展示不一致。所以我上面的配置里用了delete_mirror: true。它的作用是先对比本地产物目录和远程存储目录删除掉那些远程有但本地已经没有的文件再上传新的文件。这一项建议始终开启保证云端目录和本地构建产物是一致的。不过要小心一个边界如果你在同一个 Bucket 里还存放了其他用途的文件比如用户上传的头像、附件那delete_mirror: true可能会误删这些文件。解决办法有两种把站点文件和用户文件拆到两个不同的 Bucket或者在上传时通过remote_path把站点文件放在特定前缀目录下比如remote_path: /site/然后设置delete_mirror只作用于这个前缀。标题相关的热词里提到了“头像啥的图片用服务器的 oss 对象存储”这正好对应了上面这个场景。如果你既要放静态网站文件又要放用户上传的图片个人建议无论如何把两者分目录或分 Bucket不然 CI 里的“同步”操作早晚会出问题。3.4 用缓存加速依赖安装actions/setup-node里我加了cache: pnpm意思是让 GitHub Actions 缓存 pnpm 的依赖目录。这样第一次跑的时候可能慢一点后面的构建流程通常能快 30 秒到一分钟尤其是依赖多的项目体感很明显。如果你用的是 yarn 或 npm对应的配置也不同npmcache: npmyarncache: yarn还有一个小技巧如果构建流程中不需要安装全部依赖可以考虑只在特定变更路径时触发构建。比如文档站点如果只是修改了docs目录下的 Markdown 文件那完全可以跳过依赖安装直接用缓存。但这里有个前提就是你得先构建过至少一次缓存里才有数据。举个例子on: push: paths: - docs/** - .github/workflows/deploy.yml这样只在文档内容或工作流本身变化时才触发部署减少无意义的构建次数。不过注意如果你同时修改了代码和文档这个配置只会匹配到docs/**如果构建还需要跑其他源文件就要把路径放宽。4. CDN 配置里那些不跑一遍不会知道的问题我在这块踩过的坑比前面所有步骤加起来都多。静态网站部署到对象存储只能算是“跑通了”配上 CDN 才是真正“好用”的开始但配置 CDN 的时候也是一堆细节等着你。4.1 回源域名与回源 Host 的区别这是新手最容易搞混的一组概念我单独拿出来讲。回源域名指 CDN 节点去请求源站时使用的 IP 或域名用来建立 TCP 连接并发送请求。回源 Host指 CDN 发送 HTTP 请求时HTTP 头中Host字段的值。源站服务器会根据这个字段来判断该请求属于哪个站点或 Bucket。对于对象存储这种源站你在配置 CDN 回源时需要把回源地址填写成对象 Bucket 的静态网站托管域名前面提到过的bucketname.region.tos-cn.volces.com同时回源 Host 也填这个域名。如果只回源到默认域名但回源 Host 留空或填错CDN 去对象存储请求时源站无法识别你的请求可能直接返回 403 或 404。我遇到过一次页面能打开但所有图片都挂掉的情况排查了半天最后发现就是回源 Host 填成了加速域名本身CDN 回源请求发给了自己形成循环。正确的配置逻辑是源站域名bucketname.region.tos-cn.volces.com 回源 Hostbucketname.region.tos-cn.volces.com 加速域名cdn.example.com4.2 缓存过期时间与刷新预热静态资源网站部署完访问发现内容没更新很多人第一反应是“CDN 缓存问题”然后手动刷新缓存。但这只是治标核心是要把缓存策略配置对。我在实际项目里的做法分成两级HTML 文件不缓存或短缓存index.html这类入口文件设置缓存过期时间为 0 或者 60 秒左右保证发布后能快速生效。静态资源长缓存CSS、JS、图片这类带哈希指纹的文件比如index-abc123.css设置缓存过期时间为 30 天甚至更长。因为文件名变了就相当于一个新文件CDN 自然会去回源拉取。在火山引擎 CDN 控制台可以针对不同路径设置缓存规则。比如路径前缀缓存有效期说明/0 秒默认入口不缓存*.html300 秒页面信息可容忍短暂延迟*.js30 天带哈希的 JS*.css30 天带哈希的 CSS*.png、*.jpg、*.svg30 天图片基本不变不过这里还有个坑如果你的静态站点生成器产出的 HTML 并没有带哈希也就是每次构建完文件名都一样那上面的“HTML 短缓存”策略依然会让用户拿到旧的页面。此时最稳妥的办法是发布时立刻在 CDN 控制台对.html文件做一次刷新。刷新操作在火山引擎 CDN 控制台的“刷新预热”里可以选择 URL 刷新或目录刷新。目录刷新的粒度比较大如果页面文件很多可以直接对整个站点目录做一次刷新反正静态站文件数量一般不多。4.3 HTTPS 证书与强制跳转接入 CDN 后通常我们会顺手把 HTTPS 配上。火山引擎支持在控制台绑定 SSL 证书也支持免费证书自动申请。我的建议是尽可能用免费证书一个域名一张证书足够省去维护私钥的麻烦。开启 HTTPS 后还有一个细节是否开启“强制 HTTPS 跳转”。如果开启了所有访问http://的请求都会 301 到https://。这个功能通常建议开启但要注意此前是否在站点代码里引用了http://协议的绝对路径资源。如果有强制跳转后浏览器会先发一个 HTTP 请求收到 301 后再跳 HTTPS多一次往返偶尔还会遇到部分老旧环境处理 301 不友好的情况。更规范的做法是在构建阶段就用相对路径或者协议相对路径//cdn.example.com/xxx.js但协议相对路径在某些场景下也有争议实际使用中我更多是直接把资源路径写成完整 HTTPS一劳永逸。5. 回滚、多环境与后续可扩展的做法基础链路通了之后你会开始思考一个问题如果这次发布的内容有问题怎么快速回到上一个版本这类问题其实可以在 CI 和存储层面提前设计好。5.1 用 Tag 触发的版本化部署我建议在 GitHub Actions 里同时监听main分支的 push 和v*版本的 tag。push 到main更新的是“开发版”打 tag 则生成一个带版本号的发布。所谓“版本化部署”本质上是把每一次构建产物对象存储的路径打上版本标签。比如你构建出了v1.2.3版本上传到对象存储的路径是/releases/v1.2.3/再通过某种方式把/releases/current指向这个版本。但在对象存储里做软链接会麻烦一些实际操作中比较简单的办法是更新main分支前的产物先上传到/releases/previous/当前构建产物上传到/根路径要回滚时手动或触发一个 Rollback 工作流把/releases/previous/的内容重新上传到根路径。不过静态站一般不涉及接口变更回滚频率很低所以大多数项目不需要把版本管理做得特别重。真正更实用的场景是“发布后立刻发现问题能 5 分钟回到上一个状态”。这时候最省事的方案就是在发布前把旧产物打包留存一份到对象存储其他目录甚至直接拉下来备份到 GitHub Releases 的附件里。虽然笨但管用。5.2 多环境测试/生产隔离如果团队协作你可能会想要一个“预览环境”。GitHub Actions 天然支持在 PR 阶段构建站点如果你用的是 GitHub Pages可以借助actions/deploy-pages发布预览。但如果你已经走了“对象存储 CDN”这套链路又想保留预览环境那么对象存储的目录前缀就是最简单的隔离手段。举个例子工作流里针对main分支上传到根路径/作为生产环境针对 PR 分支上传到/preview/pr-123/这样的路径。这样预览环境与生产环境共存于同一个 Bucket完全不需要额外开 CDN 域名。访问预览环境的 URL 就长这样https://cdn.example.com/preview/pr-123/。这里有个小坑如果静态构建工具默认使用了绝对路径/assets/xxx.js那预览环境在子路径下就会全部 404。解决方法是构建时指定 base 路径。比如 VitePress 的base配置项、VuePress 的base配置项、Astro 的base配置项都可以设为/preview/pr-123/。5.3 可扩展接入云监控与告警部署链路稳定运行后我们不能等到用户反馈“网站打不开”才知道出事了。比较建议在火山引擎这边开启 CDN 的监控和告警以下是几个实用的告警维度CDN 命中率低于某个阈值比如 85%说明缓存策略可能要优化。4XX/5XX 请求量异常增多说明回源或页面资源出了问题。回源失败率大于 0说明对象存储侧可能有异常。请求带宽持续超过设定值说明可能遭到恶意刷量或流量暴涨。告警通知渠道可以选择电话、短信、邮件、飞书等。我自己是接到了电话告警才去处理问题比看邮件要及时得多。6. 实测效果与省钱心得讲完配置链路最后聊聊这套方案跑下来到底值不值以及一些我自己总结的省钱和排查经验。6.1 性能实测数据我目前的一个项目文档站静态文件总大小大约 45MB有几百个页面和图片。配置 CDN 后全国范围内的平均首屏加载时间从原来的 1.2 秒降到了 400 毫秒左右。这里的主要提升来自 CDN 的边缘缓存和 HTTP/2、HTTP/3 的支持。如果你不确定自己的配置有没有生效可以在控制台看 CDN 的命中率。正常情况下静态文件命中率能到 90% 以上。如果命中率很低大概率是缓存过期时间设得太短或者请求 URL 里带了随机参数比如?v123导致 CDN 无法关联缓存。6.2 成本估算成本这块我实测下来非常低对象存储按量计费一个小型文档站每月存储费用基本在几毛钱到几块钱。CDN 流量不同计费方式差别较大。如果是个人项目流量不大每月几块钱足够了。如果访问量大建议使用流量包比按量付费便宜不少。GitHub Actions公共仓库免费私有仓库有免费额度一般小型项目用不满额度。整体算下来我这个小项目每个月成本不到 10 元如果就一个个人博客的话甚至可以说是“基本免费”这套方案的性价比比云服务器 Nginx 的架构高太多了。6.3 排查问题的顺序最后分享一下我遇到“网站打开异常”时的排查顺序按这个顺序来能少走不少弯路先看 CDN 命中率和回源状态码判断是缓存差异还是回源失败。再看对象存储的访问日志看有没有回源记录以及返回状态码。如果没有回源记录大概率 CDN 回源配置有问题重新检查回源域名、回源 Host 和鉴权配置。如果回源正常但页面提示 404检查构建产物是否上传成功目录路径是否正确。如果一切正常但用户还是看到旧内容那就是缓存问题检查缓存过期时间和是否该做刷新。这套排查逻辑帮我解决过不少诡异问题最典型的一次是用户反馈页面没更新但我访问 CDN 域名看到的是新内容后来发现是用户本机 DNS 缓存或者本地代理缓存只能让用户强刷或者等缓存过期。这类问题跟部署链路本身没关系但也值得心里有数。整条链路现在已经成为我这边发布静态站点的标准方案了新项目从零搭建到跑通整个 CI 流程大概只需要不到一个小时。如果你也正好在折腾静态站托管照着上面的配置一步步来应该可以少踩一大半我踩过的坑。