KernelRanch
返回文章列表

项目:用 R2 做一个不花流量费的图床

2026-11-10 #Cloudflare

图床的成本结构很怪:存储几乎不花钱,流量才是大头。一张 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-controlpublic, max-age=31536000, immutable边缘与浏览器都长期缓存
content-type上传时的 MIME避免浏览器猜类型
etagR2 对象自带的 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 APIhttps://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 权限,未尝试绕过。文中所有状态码为代码逻辑推导,不是实测结果。
  • 成本估算为按免费额度做的算术,未接入真实账单数据。