博客 CDN 迁移到 Cloudflare R2

又折腾博客了。

这次不是重写框架,也不是换主题, 而是把博客的静态资源从又拍云迁移到了 Cloudflare R2

之前我在《又折腾博客了》 里写过,从七牛云换到又拍云, 是因为又拍云联盟给的对象存储和 CDN 额度都挺香, 还顺手解决了 WebP 自适应的问题。

但几年过去,事情又变了。

一方面,域名 DNS 已经迁到了 Cloudflare。 另一方面,国内备案相关的东西我越来越懒得维护。 又拍云这套 CDN/云存储方案对备案域名还是有依赖, 所以继续放在那里,总感觉后面还要再折腾一次。

那不如现在折腾。

为什么是 R2

R2 最吸引我的地方其实不是“对象存储”,而是它跟 Cloudflare 的整合。

我本来就已经把 albertaz.com 托管到了 Cloudflare,静态资源继续走 cdn.albertaz.com,对外 URL 不变,历史文章里的图片也不用批量改。

迁移后大概就是:

Plain text
cdn.albertaz.com
  -> Cloudflare R2 custom domain
  -> albertaz-cdn bucket
  -> draw/
  -> img/

这次也只迁移真正用到的 draw/img/。 又拍云里的日志目录之类的东西,就没有必要搬家了。

R2 还有个好处是没有出口流量费。 虽然请求次数和存储仍然是账单项, 但对我这种自娱自乐的小站来说, 只要不要被人恶意刷,基本是一个挺省心的方案。

当然,“基本省心”和“完全不管”不是一回事, 所以后面还是做了几层防护。

怎么确认已经切到 Cloudflare

CDN 迁移最怕一种情况:你以为切完了,实际上请求还在旧服务上。

我主要看响应头:

Bash
curl -I https://cdn.albertaz.com/draw/avatar.jpg

如果能看到:

Plain text
server: cloudflare
cf-ray: ...
cf-cache-status: ...

基本就可以确认请求已经经过 Cloudflare。

再看 WebP:

Bash
curl -I https://cdn.albertaz.com/draw/avatar.webp

返回里如果有:

Plain text
content-type: image/webp
server: cloudflare

说明预生成的 WebP 也已经能从新的 CDN 域名访问。

最后我还会测一下 WAF:

Bash
curl -I 'https://cdn.albertaz.com/draw/avatar.jpg?x=1'

这个请求应该返回 403。因为带 query string 的图片请求很容易绕过缓存, 对图床来说没有什么价值,反而增加被刷的风险。

图片处理:不再依赖运行时参数

以前又拍云最方便的一点,就是图片处理可以写在 URL 里:

Plain text
!/format/webp
!small

比如 Markdown 里还是原图,但构建时自动追加 !/format/webp。 画图页面的封面图也会挂一个 !small 后缀,让又拍云在运行时处理缩略图。

好处是省事。

坏处也是省事:它把图片处理逻辑藏在了 CDN 服务里。

迁到 R2 后,我没有继续找一个新的运行时图片处理服务来替代它。 Cloudflare 有 Polish,也有 Images, 但对这个博客来说,我最后还是觉得本地预处理更合适。

现在图片源文件放在:

Plain text
.cdn-source/draw/
.cdn-source/img/

日常只需要:

Bash
pnpm cdn:dry-run
pnpm cdn:publish

cdn:dry-run 是预演,会告诉我哪些图片会生成 WebP,哪些文件会上传到 R2, 但不会真的写文件,也不会真的上传。

确认没问题后,再跑 cdn:publish。它会用 sharp 生成 WebP,再用 rclone copy 上传原图和 WebP。

这里故意用的是 copy,不是 sync。 我不希望一个手滑就把 R2 上的文件删掉。

如果在集中整理图片,也可以开:

Bash
pnpm cdn:watch

把图片丢进 .cdn-source/,它会自动发布。很朴素,但够用。

代码怎么兼容 WebP

文章里还是正常写原图:

Markdown
![example](https://cdn.albertaz.com/img/example.png)

构建时会把 CDN 上的 JPG/PNG 自动包成 <picture>

HTML, XML
<picture>
  <source srcset="https://cdn.albertaz.com/img/example.webp" type="image/webp">
  <img src="https://cdn.albertaz.com/img/example.png" alt="example">
</picture>

这样写文章的时候不用想 WebP,浏览器支持就加载 WebP,不支持就回退原图。

这也是我比较喜欢的状态:内容还是内容,优化交给构建流程。

防刷和预算控制

R2 没有出口流量费,但不代表可以完全裸奔。

最后我给 cdn.albertaz.com 做了几层限制:

第一层是 Cloudflare Hotlink Protection,用来挡常见外站盗链。

第二层是 WAF 自定义规则:

  • 只允许 GETHEAD
  • 只允许 /draw//img/
  • 拒绝 query string。
  • 只允许图片扩展名:.jpg.jpeg.png.gif.svg.webp

第三层是 Rate Limiting:

Plain text
100 requests / 10 seconds

范围只匹配 cdn.albertaz.com 下的 /draw//img/, 并排除 Cloudflare verified bots。 这个阈值没有设得特别激进, 主要是挡明显不正常的请求,不想误伤正常浏览。

第四层是缓存头:

Plain text
Cache-Control: public, max-age=31536000, immutable

我的图片文件名基本不会复用,所以长期缓存是适合的。 边缘缓存命中越多, R2 的读取次数也就越少。

为什么不用 GitHub Actions

中间我还想过做 GitHub Actions 自动化, 类似一些 S3 sync 方案:代码一推,CI 跑起来, 同步到对象存储。

但!

这套博客图片流程里,.cdn-source/ 是本地目录,不会提交到 Git 仓库。

如果 GitHub Actions 要生成 WebP, 就只能先从 R2 把源图拉下来,再处理,再传回 R2。 这听起来自动化了,但实际是在白白增加 R2 的列目录和读取操作。

所以最后还是撤掉了 GitHub Actions,改成本地发布:

Plain text
本地源图 -> 本地生成 WebP -> 上传 R2

老实说,这一点也不 fancy,但很符合我的使用方式。

PicGo 可以用吗

PicGo 这种图床工具当然很好用, 拖拽上传、自动复制链接, 体验很顺。

但它更适合临时分享图片,不太适合当这个博客的主流程。

因为如果图片直接从 PicGo 上传到 R2,本地 .cdn-source/ 就没有这张源图。 后面的 WebP、未来可能加的 AVIF、缩略图、manifest,也就都断开了。

所以我现在的取舍是:

  • 写博客用的图片:先放 .cdn-source/,再 pnpm cdn:publish
  • 临时分享用的图片:可以用 PicGo。
  • 如果 PicGo 上传的图片以后要写进博客,最好再补回 .cdn-source/

总结

这次迁移之后,博客的静态资源变成了:

Plain text
Cloudflare R2 负责存储
Cloudflare CDN/WAF 负责分发和防护
本地脚本负责图片处理和发布

从体验上看,其实没有“又拍云一行 URL 参数”那么省事。

但换来的好处是图片处理逻辑回到了项目里,迁移成本更低, 未来要加 AVIF、缩略图或者别的处理, 也不会被某个云服务的 URL 参数绑住。

以上,希望这版图床能活得更久一点。

CURRENTLY BUILDING

EveryCityMap

Style, share, and export custom maps for 500 cities. Choose a theme, edit the layers, and download a 1440px WebP without signing up.