白嫖 Cloudflare:MinIO 换成 R2,静态站从 k3s 搬到 Workers,再守住 Free 额度
家里 k3s 上的 MinIO 下线了,对象存储换成 Cloudflare R2;博客这种纯静态站也从集群里的 nginx 搬到了 Workers。现在家里断网,博客和附件下载照样能打开,一分钱没花。
引言
上一篇 拆掉 WireGuard 跳板机 把对外入口换成了集群里的 cloudflared,之后所有 HTTP 服务都是这条路:
1 | Cloudflare → cloudflared(Pod)→ Traefik → Pod |
博客是一个 nginx 容器,附件下载是一个单实例 MinIO,数据落在家里的 RAID 上,cdn.gloomyer.com 也是走 tunnel 进来再交给 MinIO。
能用,但有个绕不过去的问题:家里一停电、一断网,博客和附件全挂。一个纯静态站,非要跟着家里那台机器同生共死,怎么想都不划算。
另一个是 MinIO 本身。这两年它一直在收紧社区版,管理界面阉割、安全公告也看得人心里发毛。单实例、没有高可用,还是个上游明显不想让你白用的东西,继续当核心依赖迟早要出事。
那就一起换掉。
选 R2 的理由很简单:
- S3 兼容,业务代码只用改 endpoint 和凭据。
- 免费额度够我用:10 GB 存储,出流量不收钱。我这边两个桶加起来不到 200 MB。
- 绑自定义域名后,公网下载直接从 Cloudflare 边缘出,不再经过家里。
MinIO → R2
桶和数据
原来 MinIO 有两个桶:
| bucket | 用途 |
|---|---|
outer |
公网匿名只读,博客附件之类 |
inner |
私有,只给服务端 SDK 和预签名 URL 用 |
R2 那边照着建两个同名的桶,后面代码里桶名都不用改。
数据量很小(outer 160 MB、inner 不到 2 MB),迁移就是停写、rclone copy、比对、切换。从停写到公网读切到 R2,前后七个小时左右,大部分时间花在了比对上。
rclone 多出来的 dir/ 占位
同步完一比对 key 集合,R2 的 inner 桶比 MinIO 多了 68 个对象。
全是 xxx/ 结尾的空对象。
原因是我同步时带了 --create-empty-src-dirs。MinIO 的”目录”是隐式的(只是 key 的前缀),rclone 却把它们当成真目录,在 R2 上给每个前缀都建了一个 dir/ 占位对象。
对业务没什么影响,但是 list 的时候会多出一堆奇怪的条目。后来写了个小脚本按 key 集合比对,多的删掉、少的补上,两边才对齐。
同步对象存储别加 --create-empty-src-dirs,除非你确实需要空目录。
S3 Secret 不是令牌本身
R2 的 S3 凭据是用 Cloudflare API 令牌换出来的:
- Access Key ID = 令牌的 id
- Secret Access Key = ?
我第一反应是填令牌的值(cfat_ 开头那一串)。结果一律 SignatureDoesNotMatch。
奇怪的是报的是签名不对,而不是 InvalidAccessKeyId,说明 Access Key 是认出来了的。换成 base64 也不行。
最后发现 Secret 是令牌值的 sha256 十六进制:
1 | import hashlib |
Cloudflare 只存令牌的哈希,R2 做 SigV4 校验用的也是这份哈希,所以你得自己算一遍。在面板上建 R2 令牌会直接把 Secret 显示给你,用 API 建的就只能自己算了。
顺带一提,令牌可以收窄到单个桶,资源要写成:
1 | com.cloudflare.edge.r2.bucket.<account_id>_default_<bucket_name> |
中间那个 _default_ 不能省,省了就是 403 Unauthorized to access requested resource。
自定义域名不认预签名
公网下载给 outer 桶绑了自定义域名 cdn.gloomyer.com,匿名 GET 没问题。
上传这边就不能照搬了。
上传走的是预签名的分块上传,服务端签好 URL,浏览器直接 PUT 给对象存储。MinIO 那会儿预签名是签给 cdn 域名的,经 tunnel、Traefik 转到 MinIO,一点问题没有。
R2 的自定义域名只管公开读,不接受预签名请求。预签名 URL 必须签给 S3 endpoint(https://<account_id>.r2.cloudflarestorage.com)。
所以服务端现在有两个配置:
endpoint/presign-endpoint:R2 的 S3 endpoint,SDK 访问和签名都用它public-base-url:https://cdn.gloomyer.com,只用来拼公开直链
另外浏览器直传要在桶上配 CORS(GET HEAD PUT,ExposeHeaders: ETag),不然分块上传拿不到每片的 ETag,complete 那一步就拼不起来。
旧链接一个都不能断
博客里的附件链接长这样:
1 | https://cdn.gloomyer.com/upload/xxx.zip |
但它在桶里的真实 key 是 gloomyer/upload/xxx.zip。以前是 Traefik 帮我加的前缀,现在 Traefik 不在这条路上了。
办法是在 zone 上加一条 Transform Rule(URL Rewrite),在请求到 R2 之前把路径改掉:
1 | 匹配:http.host eq "cdn.gloomyer.com" |
外面的 URL 一个字没变,搜索引擎也不会察觉。
刚绑完域名有个小插曲:大概一分半钟内访问一律 403,或者 error code: 1014。这时候证书和域名归属还在 pending,等状态变成 active 再验,别急着去怀疑 Rewrite 写错了。
R2 的分块上传有个怪癖
服务端有个定时任务,清理超过几天还没 complete 的分块上传。
迁到 R2 后发现一个怪事:ListMultipartUploads 列出来的 uploadId,和当初 CreateMultipartUpload 返回的 不是同一个字符串,但指的是同一个上传。
原来的逻辑是拿自己存下来的 uploadId 去列表里找,在 R2 上当然一个都找不到。
改成按 key 匹配,abort 的时候用列表里返回的那个 id,就好了。
R2 自己也会清理 7 天没完成的分块上传,所以我把自己的清理周期调到 6 天,跟它错开。
速度
家里到 R2 单流上传实测 3~4 MiB/s,20 MiB 一片串行传。
自用够了,但有一个地方得改:有个接口是同步把文件从对象存储读出来再返回的,而经过 Cloudflare 的单个请求大概 100 秒就会被掐断,按这个速度最多也就传两三百 MB。原来设计的 2 GiB 上限是不可能了,老老实实降到 128 MiB。
备份和删 MinIO
备份没怎么改。原来每天一个 CronJob 把 MinIO 打成 tarball,异地那台机器来拉;现在 init 容器换成 rclone copy r2:<bucket>,产物格式不变,异地那边一行没动。
切过去以后 MinIO 先缩到 0 副本留着回滚。
结果第二天想确认一下回滚能不能用,发现 quay.io/minio/minio 匿名拉取已经返回 401 了。真到要回滚那天,Pod 会直接卡在拉镜像上。只好先把镜像转存到自己的 Gitea registry,清单改成拉自家的。
这事让我更确定换掉它是对的。又观察了一天,日志里搜不到任何请求再打到 MinIO,就把 Deployment、PV、宿主机上的数据目录全删了,转存的镜像也不留了。
静态站搬到 Workers
对象存储搬走了,博客还在家里跑 nginx,也一起搬。
Cloudflare Pages 和 Workers 都能托管静态资源,我选的是 Workers 的静态资源(Static Assets),以后要加点脚本逻辑也方便。
构建还是 docker build,只是不推镜像了
原来 CI 是 docker build 出一个 nginx 镜像,推 registry,再 kubectl set image。
现在 Dockerfile 最后一段改成 FROM scratch,只装站点文件:
1 | FROM --platform=$BUILDPLATFORM node:26-alpine AS build |
CI 里用 -o 直接把这一段导出成目录,再 wrangler deploy:
1 | docker build --target site -o out . |
不推镜像、不碰集群,构建环境还是原来那套,CI runner 在哪台机器上都一样。
wrangler.jsonc 也就这么几行:
1 | { |
workers_dev: false 记得关。不然 xxx.workers.dev 上会有一份一模一样的站,搜索引擎会当成重复内容。
nginx 里的那些 rewrite 去哪了
这个博客从老站迁过来,nginx 里攒了一堆兜底规则:旧附件路径 301 到 CDN、日期错位的老文章链接 301 到新地址、删掉的 tag 页 301 回上一级。
这些搬进了 worker/index.js。关键是 没开 run_worker_first,脚本只在静态资源没命中时才执行:
1 | // 只在静态资源没命中时才执行,正常访问不消耗 Worker 请求额度 |
这点很重要,后面讲额度的时候再说。
http 跳 https、子域跳裸域这种,交给 zone 上的 Always Use HTTPS 和 Redirect Rule,在边缘就返回了,也不经过脚本。
有一个行为改不了:/about 补斜杠跳到 /about/,Workers 内置返回的是 307,原来 nginx 是 301。查了一圈没找到能改的配置,好在对收录没什么影响,就这样了。
绑自定义域名报 100117
第一次 wrangler deploy 绑 gloomyer.com 直接失败:
1 | Hostname 'gloomyer.com' already has externally managed DNS records [code: 100117] |
因为裸域上已经有一条 CNAME 指向 tunnel。Workers 要自己接管这个域名的 DNS,有记录就不让绑。
API 里有个 override_existing_dns_record: true,看名字正好是干这个的。试了,没用,一样报错。
最后只能先删掉那条 DNS 记录,马上再绑:
1 | # 先记下原记录的 id 和 content,回滚要用 |
中间断了几秒,可以接受。绑上以后,之后每次 deploy 都能认出域名已经是它的,不会再报。
只被通配记录 *.example.com 覆盖、本身没有单独记录的子域没这个问题,第一次 deploy 就能绑上。
单文件 25 MiB
Workers 静态资源单个文件上限 25 MiB,超了整个站发布失败。
我翻了一下,博客里最大的一个 GIF 是 23.5 MiB,好险。已经在仓库的写作规范里加了一条:大文件一律走 OSS,不打进站点。
Workers Free 的额度
搬完以后,Cloudflare 上的 Worker 就不止一个了。除了博客,我还把手机推送的中转服务、一个每分钟从外面探测家里是否在线的探针也写成了 Worker。
这时候就得认真算一下额度了。
Free 的规则
- 全账户每天 10 万次脚本请求,UTC 0 点重置;
- 单请求 CPU 10 ms;
- 超了直接报错(1027),不扣钱,但是账户下所有 Worker 一起停。
最后这条是重点。不是哪个 Worker 超了停哪个,是一起停。推送中转被人刷了,博客的 404 页、探针也跟着罢工。
命中静态资源的请求不算脚本请求。这就是上面为什么不开 run_worker_first:博客正常访问全是静态资源命中,一次额度都不占,只有 404 和老链接跳转才跑脚本。
探针是 cron 每分钟一次,一天 1440 次,CPU 不到 2 ms,可以忽略。
真正有风险的是 公网能直接触发脚本的路由,比如推送中转的那个接口。
升了 Paid,当天又降回来了
一开始想省事,直接升了 Workers Paid,$5 一个月,额度大得用不完。
升完冷静下来算了一下,发现 Paid 超额是 按量扣费,没有消费上限。被人刷一晚上,账单能刷成什么样不好说。
对一个个人站来说,我宁愿超额了停摆一天,也不想某天早上收到一张账单。当天又降回了 Free。
护栏
最后定了三条,以后新写 Worker 都照这个来:
1. wrangler.jsonc 里写 limits.cpu_ms
1 | "limits": { "cpu_ms": 50 } |
Free 版实际按 10 ms 执行,这项其实不生效,但写了也不报错。写着是防哪天又升 Paid 的时候裸奔,Paid 默认单请求 CPU 能跑 30 秒。
2. 公网能触发的路由,在 zone 上加 WAF 限速
比如同一个 IP 每 10 秒最多 100 次,超了直接在 WAF 层拦掉,根本不会执行 Worker,也就不占额度。
免费 zone 只能建 1 条 限速规则。所以有多个接口要限的时候,别新建,在原来那条的表达式里用 or 合并。
3. 脚本要轻
10 ms CPU 其实不多。大 JSON 解析、每次请求都重新签一遍 JWT 这种事别干,能缓存就缓存。推送中转里 APNs 的 JWT 我是签一次存 D1,50 分钟内复用。
纯静态站(脚本只在没命中时跑)就不用强制加限速了,但 cpu_ms 照样写上。
令牌策略满了
最后一个小坑。
做这些事要用到一堆权限:R2、Workers Scripts、D1、DNS、Transform Rules、WAF……我习惯往一个主令牌上加权限,加着加着报错了:单个 API 令牌最多 10 条策略。
别去拆主令牌,影响面太大。正确做法是用主令牌(它有 Account API Tokens: Edit)通过 API 另建一个只给某件事用的令牌,比如 CI 部署 Workers 一个、改 zone 规则一个、R2 绑域名一个。各管各的,泄露了影响也小。
现在的样子
1 | 浏览器 |
家里那台机器现在只跑真正需要它的东西。博客和附件跟它解绑了,以后家里停机重启,博客照常能打开。
账单还是 0。