Halo 2 博客附件存储解耦实战:基于 S3 协议对接 Cloudflare R2 对象存储与无损迁移

Halo 2 博客附件存储解耦实战:基于 S3 协议对接 Cloudflare R2 对象存储与无损迁移 在自建独立博客与内容管理系统(CMS)的过程中,服务器磁盘容量与带宽往往是最宝贵的资源。默认情况下,Halo 会将用户上传的文章图片、视频以及静态附件直接存放在宿主机的本地持久化目录(如 /op

Halo 2 博客附件存储解耦实战:基于 S3 协议对接 Cloudflare R2 对象存储与无损迁移

在自建独立博客与内容管理系统(CMS)的过程中,服务器磁盘容量与带宽往往是最宝贵的资源。默认情况下,Halo 会将用户上传的文章图片、视频以及静态附件直接存放在宿主机的本地持久化目录(如 /opt/halo/data)。随着博客文章的积累,本地附件会带来一系列运维痛点:

  1. 备份体积臃肿:数据库备份仅有数兆,而包含海量高分辨率配图的附件目录会迅速膨胀到数 GB,极大拖慢每日定时异地备份的传输速度。
  2. 服务器带宽瓶颈:国内或轻量海外 VPS 的上行带宽普遍受限(通常为 3Mbps ~ 10Mbps)。当多位访客同时打开包含多张配图的技术文章时,往往瞬间占满服务器带宽,导致页面加载卡顿甚至服务器响应超时。
  3. 数据迁移成本高:在更换服务器或升级容器时,数十 GB 的文件传输不仅耗时,还容易因网络抖动出现断流或校验异常。

针对上述问题,业界标准的解决方案是**“动静分离与附件解耦”**——将网站的核心服务与附件存储剥离。本文将基于生产实战,手把手演示如何利用 Halo 的 S3 插件无缝对接 Cloudflare R2 对象存储,并完成存量静态配图的平滑迁移。


一、方案选型与技术优势

为什么选择 Cloudflare R2?

目前主流的对象存储服务包括 AWS S3、阿里云 OSS、腾讯云 COS、又拍云等。对比之下,Cloudflare R2 对个人开发者和小型站点有着无可替代的几大核心优势:

特性指标 传统对象存储(AWS / 国内大厂) Cloudflare R2
免费存储额度 通常仅限新用户活动或无免费额度 每月 10 GB 永久免费存储空间
API 调用额度 超额即计费 每月 100 万次写请求(Class A)+ 1000 万次读请求(Class B) 免费
出站流量费(Egress) 昂贵(通常 0.5 ~ 0.8 元/GB,海外 AWS 更高) $0.00 / GB(完全免出站流量费)
全球 CDN 加速 需额外配置并支付 CDN 流量费 原生集成 Cloudflare Anycast 全球边缘缓存节点
协议兼容性 专有 API 或需额外适配 原生 100% 兼容 AWS S3 API

对于个人独立博客而言,10GB 的免费额度足以容纳数万张高清配图;更重要的是,免除出站流量费彻底消除了因爬虫扫描、恶意盗链或网站流量突增而引发的“天价账单”风险。


二、架构拓扑与交互流程

解耦后的静态资源交互拓扑如下:

[ Halo 博客后台 / 编辑器 ] 
       │
       ▼ (1. S3 协议上传: PUT / Object Key)
[ Cloudflare R2 Storage (存储桶: halo) ]
       │
       ▼ (2. 边缘缓存同步 / 回源拉取)
[ Cloudflare Anycast CDN 边缘缓存节点 ]
       ▲
       │ (3. 客户端 HTTP GET 加速分发)
[ 终端访客浏览器 ]
  • 写入阶段:当博主在后台上传附件或在 Markdown 编辑器中粘贴图片时,Halo 服务端通过 AWS S3 协议将文件直接流式写入 Cloudflare R2 存储桶。
  • 读取阶段:文章渲染出的图片链接直接指向 Cloudflare 提供的公开域名或绑定的自定义域名;访客请求直接由离其地理位置最近的 Cloudflare 边缘节点响应,完全不消耗源站 VPS 的任何 CPU、内存与上行带宽。

三、第一步:Cloudflare R2 资源准备

在进行 Halo 配置之前,需在 Cloudflare 控制台创建存储桶并生成对应的 S3 访问凭证。

1. 创建 R2 存储桶

  1. 登录 Cloudflare Dashboard,在左侧导航栏点击 R2 对象存储。
  2. 点击 “创建存储桶(Create bucket)”。
  3. 输入存储桶名称(例如 halo),位置建议选择“自动(Automatic)”,点击确认创建。

2. 生成 S3 兼容 API 令牌

R2 原生支持 S3 客户端接入,但需要专属的密钥对:

  1. 在 R2 概览页面的右侧,点击 “管理 R2 API 令牌(Manage R2 API Tokens)”。
  2. 点击 “创建 API 令牌”:
  3. 权限:选择 “对象读和写(Object Read & Write)”。
  4. TTL:根据需要选择永久或指定过期时间。
  5. 客户端 IP 限制:如源站 VPS 为固定公网 IP,可配置白名单进一步提升安全性;留空则不限 IP。
  6. 创建成功后,立即记录保存以下四项核心参数(界面关闭后 Secret 将无法再次查看):
  7. 访问密钥 ID(Access Key ID):例如 a5fa2477aea4ba55309462c2d1349462
  8. 机密访问密钥(Secret Access Key):例如 897f37652c8d1af8e94b...
  9. 管辖权地特定终结点(S3 Endpoint):格式形如 https://<ACCOUNT_ID>.r2.cloudflarestorage.com

3. 配置公共访问域(Public Access)

新创建的存储桶默认对公网私有。为了让博客访客能正常访问图片,需要开启公共访问:

  1. 进入该存储桶详情页 -> 设置(Settings) -> 公共访问(Public Access)。
  2. 方案 A(快速测试):开启 R2.dev 公共开发 URL(例如 https://pub-xxxxxx.r2.dev)。
  3. 方案 B(生产推荐):点击 自定义域(Custom Domains),绑定你已在 Cloudflare 解析的二级域名(如 assets.yourdomain.com),不仅 URL 更简洁,还能直接继承主域名的缓存规则与 WAF 防护。

四、第二步:Halo 插件选型与安装

Halo 2 采用了微内核与插件化架构,内置仅包含本地存储策略。对接 R2 需要安装官方维护的 S3 对象存储插件(plugin-s3)。

关键避坑点:Halo 核心版本与插件兼容性

Halo 2 处于高速迭代中,plugin-s3 插件对 Halo 底层核心版本有着严格的约束。若盲目安装 GitHub 上的 Latest 版本,会导致插件无法启动:

  • plugin-s3 v1.17.x ➡️ 要求 Halo >= 2.24.0
  • plugin-s3 v1.14.x ➡️ 要求 Halo >= 2.22.0
  • plugin-s3 v1.12.0 ➡️ 要求 Halo >= 2.20.0(适合 Halo 2.20.x 长期稳定版)

请根据你的 Halo 版本下载对应版本的 JAR 包(以 Halo 2.20 为例):

# 下载兼容当前 Halo 2.20 的 S3 插件
curl -sL "https://github.com/halo-dev/plugin-s3/releases/download/v1.12.0/plugin-s3-1.12.0.jar" \
  -o /opt/halo/data/plugins/plugin-s3-1.12.0.jar

chmod 644 /opt/halo/data/plugins/plugin-s3-1.12.0.jar

进入 Halo 管理后台 -> 应用市场 / 插件,或直接通过 Console API 安装并启用该插件。


五、第三步:配置 S3 存储策略

插件启用后,Halo 的存储策略模板中将新增 “S3 对象存储(s3os)”。

在 Halo 后台导航至 【附件】->【存储策略】->【新建存储策略】,选择 S3 对象存储,参考下表精确填入各项配置:

配置项 填写规范与示例 注意事项(踩坑高发区)
策略名称 Cloudflare R2 仅作为后台显示的策略标识
Bucket 桶名称 halo 必须与 Cloudflare 中创建的存储桶名完全一致
Endpoint 访问协议 HTTPS 默认选项
Endpoint <ACCOUNT_ID>.r2.cloudflarestorage.com ⚠️ 严禁带有 https:// 前缀,结尾不要带斜杠 /
Endpoint 访问风格 Virtual Hosted Style 或 Path Style R2 对两种风格均完美兼容,保持默认即可
Access Key ID a5fa2477aea4ba55309462c2d1349462 粘贴 R2 令牌页面的访问密钥
Access Key Secret 897f37652c8d1af8e... 粘贴 R2 令牌页面的机密访问密钥
Region(区域) auto ⚠️ 致命陷阱:必须填写全小写英文字母 auto!如果留空,SDK 默认使用 us-east-1,会直接导致 R2 抛出 400 校验错误
绑定域名(CDN 域名) pub-xxxxxx.r2.dev 或 assets.yourdomain.com 协议选 HTTPS,此处填纯域名(不要带 https://)
上传目录 留空 或 ${year}/${month} 留空则默认保存在桶根目录
上传重命名方式 保留原文件名 或 使用UUID 按个人偏好选择

点击保存后,插件会自动发起一次对象存储探测验证。验证通过即代表后端与 Cloudflare R2 通信完全通畅。

将 R2 设为全站默认存储策略

进入后台 【系统设置】->【基本设置】: - 文章附件策略:切换为 Cloudflare R2。 - 用户中心附件/头像策略:切换为 Cloudflare R2。

配置完成后,此后在 Halo 撰写文章时粘贴或上传的任何图片,都会直接写入 Cloudflare R2。


六、第四步:历史存量附件平滑迁移

如果你的博客此前已经运行了一段时间,文章中通常引用了存放在本地文件系统(如主题静态目录或默认本地策略)中的旧图片。我们需要完成以下两步实现全站无感切换:

1. 批量同步本地图片至 R2

你可以使用 Halo 的 REST API 进行同步,也可以直接利用通用的 S3 CLI 工具(如 aws-cli 或 rclone)将本地图片一键同步到 R2 存储桶中:

# 以 rclone 或 curl 配合 S3 API 为例,将本地图片推送到 R2
# 同步完成后在浏览器中验证外链:
curl -sI "https://pub-xxxxxx.r2.dev/jdcloud-turboacc-nss.jpg"
# 正常应返回 HTTP/1.1 200 OK

2. 文章 Markdown 图片链接批量替换

通过 Halo 的 OpenAPI,可以编写自动化脚本遍历已发布的文章,将旧的相对路径或本地路径自动批量替换为 R2 的加速链接:

import json, urllib.request, markdown

token = "YOUR_HALO_PAT_TOKEN"
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
post_id = "post-xgojwycd"

# 1. 读取文章当前元数据与 Raw Markdown
post_obj = json.loads(urllib.request.urlopen(
    urllib.request.Request(f"http://127.0.0.1:8090/apis/content.halo.run/v1alpha1/posts/{post_id}", headers=headers)
).read().decode())

content_obj = json.loads(urllib.request.urlopen(
    urllib.request.Request(f"http://127.0.0.1:8090/apis/api.console.halo.run/v1alpha1/posts/{post_id}/head-content", headers=headers)
).read().decode())

# 2. 执行路径替换
raw_md = content_obj.get("raw", "")
raw_md = raw_md.replace(
    "/themes/theme-Stack2/assets/images/",
    "https://pub-xxxxxx.r2.dev/"
)

# 3. 重新渲染 HTML 并提交更新
html_md = markdown.markdown(raw_md, extensions=['fenced_code', 'tables'])
payload = {
    "post": post_obj,
    "content": {"raw": raw_md, "content": html_md, "rawType": "markdown"}
}

put_req = urllib.request.Request(
    f"http://127.0.0.1:8090/apis/api.console.halo.run/v1alpha1/posts/{post_id}",
    data=json.dumps(payload).encode(),
    headers=headers,
    method="PUT"
)
urllib.request.urlopen(put_req)

# 4. 触发发布接口使快照生效 (Reconciliation)
pub_req = urllib.request.Request(
    f"http://127.0.0.1:8090/apis/api.console.halo.run/v1alpha1/posts/{post_id}/publish",
    data=b"{}",
    headers=headers,
    method="PUT"
)
urllib.request.urlopen(pub_req)
print("文章快照更新并重新发布成功!")

七、验证与性能测试

访问已发布的技术文章,打开浏览器开发者工具(F12)的 Network(网络) 标签页刷新页面:

  1. 资源地址校验:检查图片请求的 Host,已由原本的博客域名平滑切换为 pub-xxxxxx.r2.dev 或自定义图片域。
  2. 响应头检查: http HTTP/1.1 200 OK Content-Type: image/jpeg Server: cloudflare CF-RAY: 8...-SJC CF-Cache-Status: HIT 当 CF-Cache-Status 呈现 HIT 时,表明请求已被离访客最近的 Cloudflare CDN 边缘节点直接拦截并高速返回,完全免除了对云存储的读取计费,也彻底解放了源站服务器的宝贵带宽。

结语

通过为 Halo 2 接入 Cloudflare R2 对象存储,我们在享受 0 流量费、10GB 免费空间与全球边缘 CDN 加速 的同时,实现了博客架构与静态数据的彻底解耦。无论是日常的高清图文排版,还是未来的整站容器迁移与灾备恢复,都将变得更加轻盈、稳定和从容。

评论