写在前面

我有一个域名 raysre.com,放在那儿很久没什么用。最近打算把它变成个人博客——要求很简单:纯静态、不维护服务器、更新成本接近零。

选了 Hugo(Go 写的静态站点生成器,hugo build 整个站点不到 200ms)+ PaperMod 主题(极简,不需要 Webpack/PostCSS 那一套)。托管在腾讯云 COS(对象存储),套一层 EdgeOne(腾讯云的边缘加速产品,对标 Cloudflare),通过 GitHub Actions 推送代码自动上线。

这条路踩了几个坑。COS 的「公有读私有写」和 EdgeOne 的「私有访问授权」组合在一起会产生一个让人摸不着头脑的 AccessDenied。调试过程花了不少时间翻文档,顺带把 EdgeOne 和 COS 里那些没用到但经常被问到的功能也一并理了。

这篇文章是从零到上线的完整记录,加上排查过程、配置解读、概念速查。没用到腾讯云功能的内容放进进阶参考,最后附 FAQ。


整体架构

各组件分工:

层级用什么负责
写作Markdown文章内容,存在 GitHub 仓库里
构建Hugo 0.164.0 + PaperModMarkdown → HTML/CSS/JS,hugo --minify 压缩输出
构建环境GitHub Actions每次 git push main 自动构建+上传
存储腾讯云 COS(上海,标准存储)存 HTML/CSS/JS/图片等静态文件
加速+安全腾讯云 EdgeOne边缘缓存、HTTPS 终结、DDoS 基础防护、www 跳转
DNS腾讯云 DNSPodraysre.com → EdgeOne CNAME

数据流是单向的:

开发者 git push → GitHub Actions → hugo build → public/ → Python SDK 上传 COS → CDN 刷新
用户访问 raysre.com → EdgeOne 边缘节点(缓存命中)→ 返回缓存 → 用户
                           |缓存未命中 → 回源 COS → 返回

这个架构对个人博客来说有几个好处:不需要买云服务器,不需要维护 Nginx,COS 存储一个月几毛钱,EdgeOne 免费套餐 10GB/月流量够用。


基础概念速查

配置过程中反复出现的几个概念,放在这里免得后面看不懂。

概念一句话你博客里的角色
COS 存储桶腾讯云的对象存储,类似 AWS S3存 HTML 等静态文件
静态网站托管COS 的一个功能,让桶像 Web 服务器那样响应 HTTP 请求开了,但 EdgeOne 回源不用它
默认域名COS 给每个桶分配的公网访问域名(xxx.cos.ap-shanghai.myqcloud.com2024 年 1 月后新建的桶不能用它预览网页(官方限制)
静态网站端点另一个域名(xxx.cos-website.ap-shanghai.myqcloud.com),专门用于静态网站访问开了,确认能用 HTTP 访问到页面
EdgeOne 加速域名你自定义的域名接入 EdgeOne 后分配 CNAME(raysre.com.eo.dnse2.comDNS CNAME 指到这儿
回源CDN 节点缓存未命中时,向源站拉取文件EdgeOne → COS
回源 HOST 头CDN 回源请求中带的 Host 头,决定源站识别哪个站点配置错误导致 AccessDenied 的关键字段
CNAMEDNS 里把域名指向另一个域名的记录raysre.com CNAME → raysre.com.eo.dnse2.com

关键理解:COS 有两套域名体系。

域名类型格式用在哪
默认域名(API 端点)bucket.cos.region.myqcloud.comSDK 上传文件、API 操作
静态网站端点bucket.cos-website.region.myqcloud.com浏览器直接访问静态网站

EdgeOne 如果用「对象存储源站」模式回源到默认域名,走的不是静态网站逻辑。你用浏览器访问 https://raysre.com/index.html,EdgeOne 回源时相当于向 COS 的 API 端点发起 GET 请求,这时候如果桶是私有读,会直接返回 AccessDenied——这就是后面排查过程中掉进去的坑。


Step 1:Hugo 环境搭建

Hugo 用的是 v0.164.0 extended 版 + PaperMod 主题。注意 PaperMod 是 submodule,克隆仓库时带 --recurse-submodules

# 新建站点
hugo new site raysre.com --format yaml
cd raysre.com

# PaperMod 主题
git submodule add https://github.com/adityatelange/hugo-PaperMod.git themes/PaperMod

hugo.toml 核心配置(60 行,去掉了默认配置的冗余项):

baseURL = 'https://raysre.com/'   # 生产环境域名,决定所有绝对链接
locale = 'zh-cn'
title = 'DevOps工作笔记'
theme = 'PaperMod'
[params]
  description = '工作与生活随记'
  author = '阿端'
  ShowToc = true                  # 显示文章目录
  ShowBreadCrumbs = true          # 面包屑导航
  ShowReadingTime = true
  ShowWordCount = true
[markup.tableOfContents]
  startLevel = 2                  # 目录只显示 h2/h3
  endLevel = 3

构建命令:

hugo --minify           # 构建并压缩,输出到 public/
hugo server -D          # 本地预览,--watch 默认开启

备案展示——PaperMod 默认的 footer.html 没有备案号。在 layouts/partials/footer.html 里加了:

<div style="text-align:center;font-size:12px;color:#888;margin-top:10px">
  <a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">
    沪ICP备2026021650号-1
  </a>
  <span style="margin:0 8px"></span>
  <a href="http://www.beian.gov.cn/portal/registerSystemInfo?recordcode=31011502406637"
     target="_blank" rel="noopener">
    <img src="/images/beian.png" style="display:inline;height:14px;vertical-align:middle" alt="">
    沪公网安备 31011502406637 号
  </a>
</div>

公安备案图标 beian.pngstatic/images/ 下,构建时会原样复制到 public/images/


Step 2:COS 存储桶配置

腾讯云 COS 控制台操作清单:

配置项设置原因
地域上海(ap-shanghai)和 GitHub Actions runner 延迟低,和 EdgeOne 节点同地域回源走内网
访问权限公有读私有写最早设置;后面排查 AccessDenied 时临时改过,最终改回私有读写+EdgeOne 回源鉴权
静态网站托管开启,默认首页 index.htmlHugo 生成的就是 index.html
自定义源站域名不配置COS 的自定义域名和 EdgeOne 是两条路,用了 EdgeOne 就没必要在 COS 里再配
防盗链Referer 白名单:空 Referer + raysre.com防止其他网站直接盗链 COS 默认域名

关于访问权限的最终方案:

桶设为私有读写,在 EdgeOne 里开启私有访问授权。EdgeOne 回源 COS 时会带上内部鉴权签名,COS 认这个签名放行。用户在浏览器里直接打 COS 默认域名会返回 403。

这样做的好处:COS 源站不暴露公网入口,只有 EdgeOne 能回源取文件。


Step 3:GitHub Actions 自动部署

deploy.yml 总长 145 行,分 5 个步骤:

步骤做什么耗时参考
Checkout拉仓库 + PaperMod submodule3-5s
Setup Hugo下载 Hugo 0.164.0 extended 二进制2-3s
Buildhugo --minify<1s
Deploy to COSPython SDK 遍历 public/ 上传,带 Content-Type10-20s
Purge CDN Cache调 CDN API 刷新首页缓存1-2s

Setup Hugo 步骤:从 GitHub Releases 直接下载,不用 peaceiris/actions-hugo 这种第三方 Action——少依赖就是少隐患。指定版本 0.164.0 防止某天自动升版导致构建行为变化。

Deploy 步骤逐段拆解

# 第一段:MIME 类型映射表
MIME = {
    '.html': 'text/html; charset=utf-8',
    '.css':  'text/css; charset=utf-8',
    '.js':   'application/javascript; charset=utf-8',
    '.svg':  'image/svg+xml',
    # ... 16 种常见格式
}

不设 Content-Type 的话,COS 默认给所有文件 application/octet-stream。浏览器拿到不认识就当成下载,CSS/JS 不生效,页面直接崩。所以每个文件上传时必须带正确的 MIME。

# 第二段:遍历上传
local_dir = 'public'
uploaded = 0
for root, dirs, files in os.walk(local_dir):
    for f in files:
        local_path = os.path.join(root, f)
        cos_key = local_path[len(local_dir):].lstrip('/')
        ext = os.path.splitext(f)[1].lower()
        content_type = MIME.get(ext, 'application/octet-stream')
        with open(local_path, 'rb') as fp:
            client.put_object(
                Bucket='raysre-1377355589',
                Key=cos_key,
                Body=fp,
                ContentType=content_type
            )
        uploaded += 1

os.walk() 递归遍历 public/ 下所有文件,cos_key 去掉了 public/ 前缀。比如 public/posts/hello/index.html 上传后 COS Key 就是 posts/hello/index.html,直接和 URL 路径对应。

为什么用 Python SDK 而不是 coscmd

方案问题
coscmd CLIGitHub Actions 默认不带,需要 apt-get 装;配置文件要文本替换密钥,麻烦
cos-python-sdk-v5pip install 一行搞定,密钥走环境变量,代码即配置

Purge CDN 步骤:调腾讯云 CDN API PurgeUrlsCache,签 V3 签名算法(TC3-HMAC-SHA256),刷新首页和 www 两个 URL。Hugo 新构建的文件名含 hash(如 main.abc123.css),自然绕过旧缓存——所以不需要全站刷新,只刷首页让用户拿到新文件引用就够了。

Secrets 配置TENCENT_SECRET_IDTENCENT_SECRET_KEY 必须放 GitHub Repository secrets(不是 Environment secrets)——走 Environment 的话 deploy.yml 里 ${{ secrets.XXX }} 拿不到值。这个区分是排查早期构建失败时发现的。


Step 4:EdgeOne 配置 & AccessDenied 排查

第一版配置(翻车)

配置项
加速域名raysre.com
源站类型对象存储源站
源站地址默认域名(raysre-1377355589.cos.ap-shanghai.myqcloud.com
私有访问授权开启
回源 HOST 头使用源站域名

git push 后 Actions Run #9 成功上传 30+ 个文件但浏览器访问 https://raysre.com/ 返回 AccessDenied XML:

<Error>
  <Code>AccessDenied</Code>
  <Message>Access Denied.</Message>
  <Resource>/</Resource>
  <RequestId>...</RequestId>
</Error>

而直接访问 COS 静态网站端点 http://raysre-1377355589.cos-website.ap-shanghai.myqcloud.com/ 能正常显示。

定位过程

做了几个测试:

测试结果结论
curl -sv https://raysre.com/403 AccessDeniedEdgeOne 代理层面就返回了 403
curl -sI http://cos-website域名/200 + HTMLCOS 静态网站本身正常
curl -s https://默认域名/index.html200 + 完整 HTML默认域名公网可访问(当时桶是公有读)

根因:EdgeOne「私有访问授权」开启时,会构造一个鉴权签名放在回源请求中。但这个签名是针对 COS API 端点的——不是针对静态网站端点的。而用「对象存储源站」模式回源的地址恰恰好是 API 端点地址(默认域名)。COS 收到带签名的请求后发现签名和请求不匹配:API 端点期望的签名格式和静态网站的授权逻辑不一样。

换句话说:公有读桶 + 私有访问授权 = 冲突。公有读桶不需要鉴权就能访问,开了私有访问授权反而让 EdgeOne 构造了一个多此一举的签名,COS 校验失败直接拒。

修复版本(正确配置)

配置项旧值新值
COS 桶权限公有读私有读写
EdgeOne 私有访问授权开启保持开启
EdgeOne 回源 HOST 头源站域名使用源站域名(不变)

修复逻辑:先让 EdgeOne 去 COS 完成授权绑定,等 COS 桶策略里出现一条允许 EdgeOne 服务访问的策略后,再把桶改为私有读写。这样 EdgeOne 回源带签名,COS 认这个签名放行。外面直接打 COS 默认域名因为没有签名,直接 403。

附加规则

URL 重写(解决 / 不自动返回 index.html):

匹配条件操作目标
URL Path 等于 /URL 重写/index.html

这一步是关键。EdgeOne 回源到 COS API 端点时 GET / 不等于 GET /index.html,不像 Nginx 那样有 try_files。不加这个规则首页永远 403。

www 301 重定向

配置
DNS添加 CNAME 记录 www.raysre.comraysre.com.eo.dnse2.com
EdgeOne 域名添加加速域名 www.raysre.com,源站和 raysre.com 相同
EdgeOne 规则引擎匹配 HOST = www.raysre.com → 访问 URL 重定向 301 → https://raysre.com${uri}

${uri} 是 EdgeOne 内置变量,保留原始路径。比如 www.raysre.com/posts/xxxraysre.com/posts/xxx,不会全部丟到首页。


Step 5:DNS 切换

DNSPod 控制台里把 raysre.comwww.raysre.com 的 A 记录删掉(如果之前指向了服务器 IP),改成 CNAME:

记录类型主机记录记录值TTL
CNAME@raysre.com.eo.dnse2.com600
CNAMEwwwraysre.com.eo.dnse2.com600

TTL 设 600 秒(10 分钟),初次调试时改配置能更快生效。稳定运行后可以调回 3600。


成本估算

以我这个站的实际用量(文章 3 篇、访问量可以忽略):

计费项月费(估算)
COS 存储(标准存储,<1GB)¥0.10
COS 请求费(几千次/月)¥0.01
CDN 流量(<10GB/月)免费套餐覆盖
CDN 请求费免费套餐覆盖
DNS 解析免费
总计≈ ¥0.11/月

对个人博客来说几乎免费。唯一需要留意的:CDN 免费套餐每月 10GB 国内流量,如果哪天文章被大量访问超额,按量计费部分 ¥0.21/GB——设置好「用量封顶」防止意外。


进阶参考:没用到的腾讯云功能

下面这些是 EdgeOne 和 COS 提供的功能,个人博客场景用不到,但如果你要从静态博客拓展到更复杂的场景,可以参考。

EdgeOne 四层代理(L4 Proxy)

用 TCP/UDP 协议加速,适用场景是游戏加速、IoT 设备通信、实时音视频——不走 HTTP。静态博客只用七层(HTTP/HTTPS)。

EdgeOne 边缘函数(Edge Functions)

在 EdgeOne 边缘节点上跑 JavaScript 代码,类似 Cloudflare Workers。可以做的事:自定义鉴权、A/B 分流、响应头注入、内部重定向。比规则引擎灵活,但需要写代码。如果你要实现「特定 IP 段跳转到维护页」这种逻辑,用边缘函数比规则引擎方便。

回源限频

限制 EdgeOne 向源站发起的请求频率。作用是高并发时保护源站不被击穿。文档里典型的参数是「同一 URL 每秒最多回源 1 次」。个人博客流量小,可以不开。

EdgeOne 用量封顶

设置流量 / 带宽 / QPS 上限,达到后自动限速或返回提示页。建议设一下——免费套餐有额度限制,防止某个月被刷流量产生天价账单。

COS 回源设置

当请求的对象在 COS 桶里找不到时,自动去一个预设地址拉取内容。用于数据热迁移场景(如从旧服务器逐步搬到 COS)。不用于静态博客——所有文件由 GitHub Actions 上传,不会出现 404。

COS 生命周期管理

自动把对象从标准存储切换到低频 / 归档 / 深度归档,或到期后自动删除。如果你的桶存了大量日志需要定期清理,这是一个有用的功能。

COS 智能分层存储

自动在「高频」和「低频」两个访问层之间迁移对象,不需要手动配生命周期。访问模式不可预测时比生命周期省心。

数据万象 CI(图片/音视频处理)

和 COS 绑定的数据处理服务,提供实时图片裁剪、水印、格式转换(比如 ?imageMogr2/thumbnail/200x),以及视频转码、截帧等能力。如果你以后需要在文章里自动生成缩略图、加水印,数据万象是实现方案之一。

COS 标签管理

给对象打标签(如 env=prod, category=blog),方便分类、成本核算和批量操作。


FAQ

Q:为什么 hugo.toml 不用 yaml 格式?

hugo new site --format yaml 生成的就是 TOML(这个参数有点误导,实际上 Hugo 默认生成 TOML)。TOML 比 YAML 更不容易出现缩进错误,而且 Hugo 文档里的示例代码基本是 TOML,复制粘贴改起来方便。

Q:deploy.yml 在 GitHub Actions 里报 ModuleNotFoundError: No module named 'qcloud_cos' 怎么处理?

- run: pip install cos-python-sdk-v5

加在 Deploy 步骤最前面。注意每次 step 的 shell 环境可能不同,pip install 放在同一个 run: 块里,不要跨 step。

Q:如何验证 COS 静态网站端点正常工作?

curl -sI http://raysre-1377355589.cos-website.ap-shanghai.myqcloud.com/index.html

预期返回 200 OK + Content-Type: text/html。如果不是 200,去 COS 控制台检查静态网站托管是否开启、默认首页是否设了 index.html

Q:HTTPS 证书怎么搞?

EdgeOne 免费提供 DV SSL 证书,接入域名后自动申请,不需要额外操作。如果 www 也需要扫码 HTTPS,申请时勾选多域名即可。

Q:PaperMod 怎么加备案号?

layouts/partials/footer.html 里覆盖默认模板。PaperMod 的默认 footer 位于 themes/PaperMod/layouts/partials/footer.html,把内容复制过来,底部追加备案信息。Hugo 发现项目 layouts/ 下同名文件会优先用你的,不会动主题原文件。

Q:文章更新后旧页面能看到吗?

能看到。Hugo 构建时 CSS/JS 文件名含内容 hash,新版本文件名不同。旧文件还在 COS 上,EdgeOne 缓存里旧的 HTML 引用旧 JS——所以 deploy 后加了一步 CDN 刷新。

Q:COS 默认域名能被直接访问吗?

取决于桶权限。如果你设了私有读写+EdgeOne 私有访问授权,直接打 COS 默认域名会 403。顺手加 Referer 白名单可以多做一层防护。

Q:为啥不用 Cloudflare Pages / Vercel?

Cloudflare Pages 免费 500 次/月构建,Vercel 免费 6000 分钟/月。两家托管在国内的访问稳定性时好时坏——不是产品不行,是物理距离在那儿。腾讯云 COS + EdgeOne 面向国内用户,延迟 <50ms,在线率有 SLA。如果你的受众主要在海外,那 Cloudflare Pages 反倒更合适。

Q:文章多了以后构建和上传会变慢吗?

Hugo 官方的基准测试数据:5000 篇文章的站点,hugo build 在 1 秒内完成。瓶颈在上传:os.walk() 顺序串行,文件数到几千时可以考虑用 ThreadPoolExecutor 并行化上传。目前几十个文件串行够用。


写在最后

整个过程踩的最大坑是 EdgeOne 的「私有访问授权」和「公有读桶」的组合——文档里散落在不同页面,没有明确的「桶公有读时不要开私有访问授权」的警告。这篇记录把配置关系理成表格,方便以后查。

不用管服务器、不用打安全补丁、不用半夜被告警吵醒——COS 存文件,EdgeOne 扛流量,GitHub Actions 管发布。代价是没法跑动态内容(评论、登录、后台)。如果你需要这些,WordPress + 云服务器或者 Hugo + Serverless 评论更适合。

如果你也在搭类似的个人博客,上面的 deploy.yml 可以直接拿走用,改个 bucket 名字就行。