把博客图片搬出 git 仓库:R2 图床落地记录

Read in English

AI 问答 · DeepSeek

把博客图片搬出 git 仓库:R2 图床落地记录

  • cloudflare
  • r2
  • ci

这个博客跑在 Cloudflare Workers 上,文章是 Markdown,部署是 push 到 main 后 CI 自动 wrangler deploy。有一件事一直没定下来:图片放哪。

最常见的做法是把图片和文章一起提交进仓库(public/assets/<slug>/)。能用,但隐性成本不低:

  1. 仓库体积只增不减。 压得再好,几十篇文章积累下来也是几十上百 MB,clone 变慢,git 历史里的旧图永远删不掉。
  2. 图片和文字的发布节奏被绑死。 想先传图、看看效果、再改文字?必须整体 commit。
  3. 缓存头不归你管。 Worker 分发静态文件时,缓存策略要么全站一致,要么写代码特判。

所以这次直接把图片挪到 R2——Cloudflare 的对象存储,免费层 10 GB 存储、自定义域名分发不收流量费,对一个个人博客绰绰有余。

架构:两条互不干扰的线

两条独立链路:部署线与图片线

关键取舍是两条线彻底解耦:

  • 部署线只管代码和文字,每次 push 自动跑;
  • 图片线只在写作时手动跑一次,上传后拿到 URL 贴进 Markdown,之后永远不需要再碰。

图片 URL 直接指向 R2 的公开自定义域名 img.awmthink.com,不经过 Worker。这意味着图片请求零 Worker 调用量、零 cookie,Cloudflare 边缘直接命中缓存。

链路搭建

R2 需要先在 Dashboard 开通一次(绑支付方式激活免费层),之后的 bucket 和上传都可以走 CLI:

# 建 bucket
npx wrangler r2 bucket create assets

# Dashboard 里给 bucket 绑自定义域名 img.awmthink.com,
# Cloudflare 自动建 CNAME 和证书,几分钟生效

本机先 npx wrangler login 做一次 OAuth 授权,之后上传就不需要任何长期凭据。

上传脚本

上传是高频动作,包了一个零依赖的小脚本,核心就是调 wrangler r2 object put:

const key = `${slug}/${basename(file)}`;
const result = spawnSync('npx', [
  'wrangler', 'r2', 'object', 'put', `assets/${key}`,
  '--file', file,
  '--content-type', MIME[ext],
  // 图片 URL 会永久留在 Markdown 里,按"内容不变"假设缓存一年;
  // 改图必须换文件名,重传同名不会刷新边缘缓存
  '--cache-control', 'public, max-age=31536000, immutable',
  '--remote',
], { stdio: 'inherit' });

写完文章配图时只需要:

npm run upload ~/Desktop/cover.webp blog-images-on-r2
# ✅ https://img.awmthink.com/blog-images-on-r2/cover.webp

脚本里顺手做了两道闸:扩展名白名单,以及 2MB 大小上限——把“提交前压缩”从事后约定变成事前强制。

CI 部署不碰 R2

CI(GitHub Actions)只需要两个 secret:CLOUDFLARE_API_TOKEN 和 CLOUDFLARE_ACCOUNT_ID。deploy job 挂在 check 后面,只有 main 的 push 会触发:

deploy:
  needs: check
  if: github.event_name == 'push' && github.ref == 'refs/heads/main'
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v5
    - uses: actions/setup-node@v5
      with: { node-version: '22', cache: npm }
    - run: npm ci
    - run: npm run deploy
      env:
        CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
        CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

R2 的凭据不进 CI:构建期不碰图片,部署期不需要 R2 权限。原则很简单——CI 只拿它真正用到的凭据,用不到的变量加进去纯粹是扩大泄露面。

踩坑:帐户令牌不能用用户端点验证

这次落地过程中浪费最久的一个坑。背景:Cloudflare 现在有两类 API 令牌——用户令牌(cfut_ 前缀,跟人走)和帐户令牌(cfat_ 前缀,跟账户走,官方推荐自动化场景用)。

我的 token 是 cfat_ 帐户令牌,但验证时用了用户令牌的端点:

# ✗ 用户令牌端点,对帐户令牌永远返回 Invalid API Token
curl .../user/tokens/verify -H "Authorization: Bearer cfat_xxx"

# ✓ 帐户令牌要用帐户端点
curl .../accounts/<account_id>/tokens/verify -H "Authorization: Bearer cfat_xxx"

结果就是:token 明明有效,却被我误判成无效,反复重建、回滚,来回折腾好几轮。教训是验证工具和被验证对象必须是同一套体系——错误端点返回的“无效”不是证据,只是答非所问。

小结

  • 图片进 R2,仓库只留文字,clone 体积和缓存策略都干净了
  • 上传一条命令(本机 OAuth,零长期凭据),分发走 img.awmthink.com(零 Worker 调用)
  • CI 与 R2 完全解耦,secrets 最小化
  • 改图永远换文件名,旧的 immutable 缓存不用管

评论

评论公开可见,以 GitHub 身份署名。