项目:用 R2 做一个不花流量费的图床
图床的成本结构很怪:存储几乎不花钱,流量才是大头。一张 200 KB 的图被看一万次就是 2 GB 出站,放在按流量计费的对象存储上,账单会跟着访问量走。R2 把出站定成免费,这一条就决定了它适合做图床。做完这个项目你会得到:一个带上传口令的图片入口、一套能扛住重复访问的缓存策略、以及一份能算清楚的成本表。
账号权限说明在前头:这次使用的 API Token 没有 R2 权限,wrangler r2 bucket create 会被拒绝,所以这篇的上传、读取、缓存行为都没有实机跑通,代码依据官方文档与 Workers 运行时 API 写。基础概念在 60-workers-r2-image-hosting.md 里过了一遍,这篇讲工程化的部分。图片本身的压缩与格式选择见 73-image-optimization.md。
1. 三种对外暴露方式
| 方式 | 怎么配 | 适合 | 代价 |
|---|---|---|---|
| r2.dev 开发域名 | 控制台一键开启 | 自测、内部用 | 有限速,不适合公开站点 |
| 自定义域绑定 | 域名接进 Cloudflare 后绑 img.example.com | 公开图床 | 需要 Zone 权限 |
| Worker 代理 | env.BUCKET.get(key) 再返回 | 需要鉴权、改写、防盗链 | 每次读消耗一次 Worker 请求 |
个人图床最省事的是第二种:R2 桶绑自定义域后,Cloudflare 直接在前面做缓存,Cache-Control 生效,回源次数可控。需要"只有登录用户能看图"时才用第三种。
2. 上传接口
上传走 Worker,用共享口令挡住陌生人。口令放 secret,不进代码:
npx wrangler secret put UPLOAD_TOKEN
function keyFromName(name) {
const ext = (name.split(".").pop() || "bin").toLowerCase().replace(/[^a-z0-9]/g, "");
const rand = crypto.randomUUID().replace(/-/g, "").slice(0, 16);
return `${new Date().toISOString().slice(0, 7)}/${rand}.${ext}`;
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
const auth = request.headers.get("authorization") || "";
if (auth !== `Bearer ${env.UPLOAD_TOKEN}`) {
return new Response("unauthorized", { status: 401 });
}
if (request.method === "PUT" && url.pathname === "/upload") {
const name = url.searchParams.get("name") || "image.png";
const type = request.headers.get("content-type") || "application/octet-stream";
if (!type.startsWith("image/")) {
return Response.json({ error: "image only" }, { status: 415 });
}
const body = await request.arrayBuffer();
if (body.byteLength > 10 * 1024 * 1024) {
return Response.json({ error: "max 10MB" }, { status: 413 });
}
const key = keyFromName(name);
await env.BUCKET.put(key, body, {
httpMetadata: { contentType: type, cacheControl: "public, max-age=31536000, immutable" }
});
return Response.json({ key, url: `https://img.example.com/${key}` }, { status: 201 });
}
if (request.method === "GET") {
const obj = await env.BUCKET.get(decodeURIComponent(url.pathname.slice(1)));
if (!obj) return new Response("Not Found", { status: 404 });
return new Response(obj.body, {
headers: {
"content-type": obj.httpMetadata?.contentType || "application/octet-stream",
"cache-control": "public, max-age=31536000, immutable",
etag: obj.httpEtag
}
});
}
return new Response("Not Found", { status: 404 });
}
};
关键在 key 的生成方式:按年月分目录、文件名用随机串、扩展名从原始名里抠。这样同一张图重复上传不会互相覆盖,也不会因为文件名可猜测被人遍历。
3. 缓存头怎么定
图片是不可变内容,一旦写入就不再改,所以给一年加 immutable 是安全的。改图就换 key,别去原地覆盖——覆盖后旧 URL 的缓存还在,用户会看到两个版本。
| 头 | 值 | 作用 |
|---|---|---|
cache-control | public, max-age=31536000, immutable | 边缘与浏览器都长期缓存 |
content-type | 上传时的 MIME | 避免浏览器猜类型 |
etag | R2 对象自带的 httpEtag | 条件请求返回 304 |
immutable 的意思是这个 URL 的内容永不变化,浏览器连条件请求都省了。对图床来说这是最划算的一行配置。
4. 成本怎么算
R2 免费额度(每月):存储 10 GB、Class A 操作 100 万次、Class B 操作 1000 万次、出站流量免费。超出后标准存储 $0.015/GB-月,Class A $4.50/百万次,Class B $0.36/百万次。
一个每天 500 次图片请求的个人站,一个月约 1.5 万次读,离 1000 万差三个数量级。假设图床存了 5000 张平均 300 KB 的图,占用 1.5 GB,也在 10 GB 内。也就是说这个量级下账单是 0,唯一在动的是 Workers 的请求额度——如果用 Worker 代理图片,每次读都算一次 Worker 请求,10 万/天的额度反而先见底。
超出免费额度之后账单也不会失控。按 20 GB 存储、每月 500 万次读估算:存储超出 10 GB,费用 10 × $0.015 = $0.15;读操作落在 1000 万次免费额度内,为 0;出站流量不计费。合计每月不到两毛美元。额度换算的方法见 56-free-tier-limits-cheatsheet.md。
算成本时容易漏掉的是操作分类。put 是 Class A,get 是 Class B,list 也是 Class A。翻目录、批量检查这类操作很吃 Class A 额度,别把它放进每次请求的路径里。
5. 常见问题
能不能直接传视频?能,但别。单对象没有 25 MB 那种 Pages 的限制,可视频动辄几百 MB,存储涨得比图片快得多,而且播放需要 Range 请求,Worker 代理要自己处理 Range 头。图床就只放图。
防盗链怎么做?在 Worker 里检查 Referer,或者给图片 URL 加签名参数(key + 过期时间 + HMAC)。单纯靠 Referer 只能挡住最粗的抓取,脚本直接请求时这个头可以随便伪造,所以签名才是真正管用的那一层。
同一张图传两次会得到两个 key,占两份存储。要省空间就按内容哈希去重:上传前先算 SHA-256,用哈希当前缀查一次,命中就直接返回已有 URL。代价是每次上传多一次读操作,收益是重复图片不会越堆越多。
要不要开图片变换?能按 width/quality 参数动态出图,代价是按变换次数计费,且每个尺寸都算一次唯一变换。访问量不大时,上传时就生成两三个固定尺寸更省,也更容易被边缘缓存住。
小结
图床的关键选择不是存储引擎,是流量计价方式。R2 出站免费这一点,让"图被疯狂转发"不再是账单风险,这也是它相比传统对象存储最适合个人站的地方。免费额度 10 GB / 100 万写 / 1000 万读,对一个日均几百访问的站点来说是压倒性的富余。
真正的工程细节集中在两处:上传鉴权(别让桶变成公开网盘)和缓存头(让 99% 的读不打到 R2)。这篇没能在账号里跑通,因为 Token 缺 R2 权限;代码与额度依据官方文档,实际部署时以上传一张图、再 curl -I 看 cf-cache-status 作为第一个验证点。后续的额度监控见 102-cost-control-quotas.md。
数据来源与检索时间
检索时间 2026-11-10。
| 来源 | URL | 对应数据 |
|---|---|---|
| R2 定价 | https://developers.cloudflare.com/r2/pricing/ | 免费 10 GB-月、100 万 Class A、1000 万 Class B、出站免费;超出 $0.015/GB-月、$4.50/百万 Class A、$0.36/百万 Class B |
| R2 Workers API | https://developers.cloudflare.com/r2/api/workers/workers-api-reference/ | put / get / httpMetadata / httpEtag 用法 |
| R2 公开访问 | https://developers.cloudflare.com/r2/buckets/public-buckets/ | r2.dev 开发域名与自定义域的区别 |
核验说明
- 文档核对:R2 免费额度与单价、Workers API 的方法签名来自上述页面(2026-11-10 抓取成功)。
- 未做:
wrangler r2 bucket create与图片上传、读取、cf-cache-status实测——当前 Token 无 R2 权限,未尝试绕过。文中所有状态码为代码逻辑推导,不是实测结果。 - 成本估算为按免费额度做的算术,未接入真实账单数据。