博客 CDN 迁移到 Cloudflare R2
又折腾博客了。
这次不是重写框架,也不是换主题, 而是把博客的静态资源从又拍云迁移到了 Cloudflare R2。
之前我在《又折腾博客了》 里写过,从七牛云换到又拍云, 是因为又拍云联盟给的对象存储和 CDN 额度都挺香, 还顺手解决了 WebP 自适应的问题。
但几年过去,事情又变了。
一方面,域名 DNS 已经迁到了 Cloudflare。 另一方面,国内备案相关的东西我越来越懒得维护。 又拍云这套 CDN/云存储方案对备案域名还是有依赖, 所以继续放在那里,总感觉后面还要再折腾一次。
那不如现在折腾。
为什么是 R2
R2 最吸引我的地方其实不是“对象存储”,而是它跟 Cloudflare 的整合。
我本来就已经把 albertaz.com 托管到了 Cloudflare,静态资源继续走
cdn.albertaz.com,对外 URL 不变,历史文章里的图片也不用批量改。
迁移后大概就是:
cdn.albertaz.com
-> Cloudflare R2 custom domain
-> albertaz-cdn bucket
-> draw/
-> img/这次也只迁移真正用到的 draw/ 和 img/。
又拍云里的日志目录之类的东西,就没有必要搬家了。
R2 还有个好处是没有出口流量费。 虽然请求次数和存储仍然是账单项, 但对我这种自娱自乐的小站来说, 只要不要被人恶意刷,基本是一个挺省心的方案。
当然,“基本省心”和“完全不管”不是一回事, 所以后面还是做了几层防护。
怎么确认已经切到 Cloudflare
CDN 迁移最怕一种情况:你以为切完了,实际上请求还在旧服务上。
我主要看响应头:
curl -I https://cdn.albertaz.com/draw/avatar.jpg如果能看到:
server: cloudflare
cf-ray: ...
cf-cache-status: ...基本就可以确认请求已经经过 Cloudflare。
再看 WebP:
curl -I https://cdn.albertaz.com/draw/avatar.webp返回里如果有:
content-type: image/webp
server: cloudflare说明预生成的 WebP 也已经能从新的 CDN 域名访问。
最后我还会测一下 WAF:
curl -I 'https://cdn.albertaz.com/draw/avatar.jpg?x=1'这个请求应该返回 403。因为带 query string 的图片请求很容易绕过缓存,
对图床来说没有什么价值,反而增加被刷的风险。
图片处理:不再依赖运行时参数
以前又拍云最方便的一点,就是图片处理可以写在 URL 里:
!/format/webp
!small比如 Markdown 里还是原图,但构建时自动追加 !/format/webp。
画图页面的封面图也会挂一个
!small 后缀,让又拍云在运行时处理缩略图。
好处是省事。
坏处也是省事:它把图片处理逻辑藏在了 CDN 服务里。
迁到 R2 后,我没有继续找一个新的运行时图片处理服务来替代它。 Cloudflare 有 Polish,也有 Images, 但对这个博客来说,我最后还是觉得本地预处理更合适。
现在图片源文件放在:
.cdn-source/draw/
.cdn-source/img/日常只需要:
pnpm cdn:dry-run
pnpm cdn:publishcdn:dry-run 是预演,会告诉我哪些图片会生成 WebP,哪些文件会上传到 R2,
但不会真的写文件,也不会真的上传。
确认没问题后,再跑 cdn:publish。它会用 sharp 生成 WebP,再用 rclone copy
上传原图和 WebP。
这里故意用的是 copy,不是 sync。
我不希望一个手滑就把 R2 上的文件删掉。
如果在集中整理图片,也可以开:
pnpm cdn:watch把图片丢进 .cdn-source/,它会自动发布。很朴素,但够用。
代码怎么兼容 WebP
文章里还是正常写原图:
构建时会把 CDN 上的 JPG/PNG 自动包成 <picture>:
<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 自定义规则:
- 只允许
GET和HEAD。 - 只允许
/draw/和/img/。 - 拒绝 query string。
- 只允许图片扩展名:
.jpg、.jpeg、.png、.gif、.svg、.webp。
第三层是 Rate Limiting:
100 requests / 10 seconds范围只匹配 cdn.albertaz.com 下的 /draw/ 和 /img/,
并排除 Cloudflare verified bots。
这个阈值没有设得特别激进,
主要是挡明显不正常的请求,不想误伤正常浏览。
第四层是缓存头:
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,改成本地发布:
本地源图 -> 本地生成 WebP -> 上传 R2老实说,这一点也不 fancy,但很符合我的使用方式。
PicGo 可以用吗
PicGo 这种图床工具当然很好用, 拖拽上传、自动复制链接, 体验很顺。
但它更适合临时分享图片,不太适合当这个博客的主流程。
因为如果图片直接从 PicGo 上传到 R2,本地 .cdn-source/ 就没有这张源图。
后面的 WebP、未来可能加的 AVIF、缩略图、manifest,也就都断开了。
所以我现在的取舍是:
- 写博客用的图片:先放
.cdn-source/,再pnpm cdn:publish。 - 临时分享用的图片:可以用 PicGo。
- 如果 PicGo 上传的图片以后要写进博客,最好再补回
.cdn-source/。
总结
这次迁移之后,博客的静态资源变成了:
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.