R2 与图床:对象存储的上传、取回与公开访问
读完这一篇,你能写出一个最小的图床 Worker:PUT 上传图片到 R2,GET /i/<key> 取回并带上正确的 content-type、etag、长缓存头,list 能按前缀列出对象,删除后立刻查不到。代码部分我在本机 wrangler dev 的 R2 模拟环境里完整跑通了,输出见第 4 节。
先说清边界:这个账号的 token 只有 Workers 和 KV 的写权限,没有 R2 权限。所以创建真实存储桶、绑定自定义域名、验证线上访问这几步没有执行,只按官方文档写步骤。第 6 节和「核验说明」里会把哪些是跑的、哪些是查的分开。
1. R2 在三个存储里的位置
| KV | R2 | D1 | |
|---|---|---|---|
| 数据形状 | key -> 小 value | key -> 任意二进制对象 | 关系表 |
| 单条上限 | 25 MiB | 单个对象 5 TiB(分片上传) | 行级,库 10 GB 量级 |
| 一致性 | 最终一致 | 强一致 | 强一致 |
| 典型用途 | 配置、白名单 | 图片、附件、备份 | 业务数据 |
| 计费特点 | 按读写次数 | 存储 + 操作次数,出网不单独计费 | 按行读写 |
图片这类"写一次、读很多次、体积大"的东西放 R2,比塞进 KV 便宜也更合适。KV 的定位见 59-workers-kv-tutorial.md,结构化数据看 61-workers-d1-database.md,三者的额度边界在 56-free-tier-limits-cheatsheet.md。
2. 建桶(本次未执行)
有权限时是两条命令:
wrangler r2 bucket create cf60-images
wrangler r2 bucket list
也可以在控制台 R2 面板里点创建。桶名全局唯一性只在账号内要求,命名用 项目-用途 最省事。
公开访问有两种方式:给桶绑一个自定义域名(推荐,能走 Cloudflare 缓存),或者开 r2.dev 的公共子域(仅测试用,有速率限制且不适合生产)。绑自定义域需要域名已接入并且有对应权限,前置条件见 49-add-domain-to-cloudflare.md 和 55-workers-routes-custom-domain.md。
如果走 Worker 代理(本篇的做法),就不需要给桶开公网访问,所有请求都经过 Worker,鉴权、防盗链、图片处理都能加在中间。
3. 绑定与代码
name = "cf60-r2-demo"
main = "src/index.js"
compatibility_date = "2026-10-08"
[[r2_buckets]]
binding = "BUCKET"
bucket_name = "cf60-images"
本地模拟不需要真实桶存在,wrangler dev 会在 .wrangler/state 下自己造一个。线上部署前桶得先建好,否则 deploy 会报绑定找不到。
上传用 put,body 可以直接给 request.body 这个流,不用先读进内存:
if (request.method === "PUT" && p.startsWith("/up/")) {
const key = decodeURIComponent(p.slice(4));
const ct = request.headers.get("content-type") || "application/octet-stream";
const obj = await bucket.put(key, request.body, {
httpMetadata: { contentType: ct },
customMetadata: { uploadedBy: "cf60-demo" }
});
return Response.json({ key: obj.key, size: obj.size, etag: obj.etag });
}
取回时把 httpMetadata 写回响应头,这一步用 writeHttpMetadata:
const obj = await bucket.get(key);
if (!obj) return new Response("Not Found", { status: 404 });
const headers = new Headers();
obj.writeHttpMetadata(headers);
headers.set("etag", obj.etag);
headers.set("cache-control", "public, max-age=31536000, immutable");
return new Response(obj.body, { headers });
obj.body 是流,new Response(obj.body) 直接边读边发,几十 MB 的图也不会把内存顶满。
4. 本地实测输出
wrangler dev --port 8789 --local 起服务后,用一个 68 字节的 1x1 PNG 走完整流程:
| 操作 | 结果 |
|---|---|
PUT /up/img/2026/11/dot.png | 200,{"key":"img/2026/11/dot.png","size":68,"etag":"9ffa98aecdfb9232b21fc7887e53c00b","uploaded":"2026-11-10T06:49:41.375Z"} |
GET /i/img/2026/11/dot.png | 200,68 字节,content-type: image/png,etag 同上,cache-control: public, max-age=31536000, immutable |
GET /i/nope.png | 404,Not Found |
GET /list?prefix=img/ | {"truncated":false,"objects":[{"key":"img/2026/11/dot.png","size":68}]} |
DELETE /i/img/2026/11/dot.png | {"deleted":"img/2026/11/dot.png"} |
删除后 GET /list | {"truncated":false,"objects":[]} |
etag 值得单独说一句:这次拿到的 9ffa98aecdfb9232b21fc7887e53c00b 正好等于该文件的 MD5。R2 对单次上传的对象用内容 MD5 作为 etag,所以它可以当内容指纹用——同一张图重复上传,etag 不变,可以直接用来做去重。分片上传的 etag 不是纯 MD5,别把这个结论推广到所有对象。
删除后 list 立刻为空,没有 KV 那种滞后。R2 是强一致的,写完就能读到最新状态。
5. 图床要注意的四件事
key 的设计决定后面能不能批量管理。用 img/2026/11/<hash>.png 这种带日期和哈希的路径,好处是 list({ prefix: "img/2026/11/" }) 能按月清理,重复上传同一张图会落到同一个 key(如果哈希取自内容)。
content-type 一定在写入时带上。R2 不会猜类型,put 时不传 httpMetadata.contentType,取回来就是 application/octet-stream,浏览器会变成下载而不是显示。上面的代码从请求头取,够用;更严谨的做法是按扩展名映射一张白名单表。
cache-control 要长。图片内容不变,key 里带哈希的话可以直接 immutable,让浏览器和 Cloudflare 边缘都长期缓存。这一步对回源次数的影响比任何代码优化都大,缓存规则的细节见 74-cache-rules.md。
上传入口要鉴权。上面为了演示把 PUT 完全放开了,线上 anything 都能往你的桶里塞东西。至少加一个 authorization 头比对,或者用 Turnstile 挡一道,见 81-turnstile.md。
6. 常见问题
问:wrangler dev 里能上传,部署到线上后 put 报绑定错误? 答:线上要求 bucket_name 对应的桶真实存在,本地模拟不检查。先 wrangler r2 bucket list 确认桶在,再核对 wrangler.toml 里的 bucket_name 拼写。另外本地和线上的对象是两套存储,本地传的图线上读不到。
问:图片地址要不要直接暴露 R2 域名? 答:看需求。直接暴露省一次 Worker 调用,但拿不到鉴权、防盗链、动态缩放。走 Worker 代理每次多一次请求,但可以在中间加 Referer 校验、按 UA 返回不同尺寸。图床项目通常选后者,成本是每次读都消耗一次 Worker 请求额度。
问:大文件上传要注意什么? 答:Worker 请求体有大小上限(免费档 100 MB),超过就得用 R2 的分片上传(multipart upload)接口,由客户端直传 R2 而不是经过 Worker。这条我只按文档记录,没有实测。
小结
R2 的用法和 KV 一样简单:put、get、head、delete、list,区别在于 value 是任意二进制、强一致、容量大到可以放视频。图床的最小实现在本篇第 3 节,四十行代码,本地已经跑通上传、取回、列表、删除全流程。
真正决定图床好不好用的是三件小事:key 里带内容和日期以便批量管理、写入时固定 content-type、响应头给足 cache-control。这三件都不写也不会报错,只会在某天发现"图片全是下载"或者"回源流量莫名其妙很高"。完整的图床项目形态见 98-project-image-host-r2.md。
数据来源与检索时间
检索时间 2026-11-10。第 4 节全部输出为本机 workerd 模拟环境的真实响应,非线上 R2。
| 来源 | URL | 对应数据 |
|---|---|---|
| R2 入门 | https://developers.cloudflare.com/r2/get-started/ | 建桶命令、绑定配置、公开访问方式(未抓取) |
| R2 Workers API | https://developers.cloudflare.com/r2/api/workers/workers-api-reference/ | put / get / head / list 与 writeHttpMetadata(未抓取) |
| R2 定价 | https://developers.cloudflare.com/r2/pricing/ | 免费额度与出网不单独计费(未抓取) |
核验说明
- 账号无 R2 权限,本次没有创建任何真实存储桶,没有部署 Worker,也没有验证线上
r2.dev或自定义域访问。第 2 节与第 5 节的公开访问部分仅依据文档描述。 - 真做了:本机
_cf60src临时项目,wrangler dev --local启动 workerd 的 R2 模拟环境,用 68 字节 PNG 完成PUT /up/...、GET /i/...、GET /i/nope.png、GET /list、DELETE、再GET /list六步,输出原样记录在第 4 节表格。 etag等于文件 MD5 这一点经过核对:Get-FileHash -Algorithm MD5得到9ffa98aecdfb9232b21fc7887e53c00b,与put返回的etag一致。- 官方 R2 文档三页都没有抓取(本篇的抓取额度已在前几篇用完),建桶命令、分片上传、公开域名配置的说法来自既有文档记忆,未逐条比对原文,读者以控制台和
wrangler r2 --help的实际输出为准。 - 未做:没有测大文件与分片上传,没有测
head的响应,没有测缓存命中率,没有测 Worker 请求体上限附近的边界值。