mcpField
C
cloudflare-r2

cloudflare-r2

github.com/jezweb云与 DevOps1.1k462 次安装3 周前更新
代码仓库

关于 cloudflare-r2

cloudflare-r2 是面向使用 Cloudflare R2 对象存储和 Workers 的开发者的实用技能,介绍对象操作、上传模式、访问配置及运维注意事项,并涵盖 Wrangler 和兼容 S3 的预签名 URL。

主要功能

  1. 01R2 Workers 对象操作
  2. 02分段上传与预签名上传
  3. 03CORS 和元数据配置
  4. 04批量删除与范围读取
  5. 05Wrangler 设置指南
  6. 06安全与可靠性实践

适用场景

  1. 01上传大型或可续传文件
  2. 02通过预签名 URL 支持浏览器上传
  3. 03配置存储桶 CORS 和元数据
  4. 04处理存储错误与速率限制

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/jezweb/claude-skills --skill cloudflare-r2

安装后在 Claude Code 中输入/cloudflare-r2确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

README.md
.claude-plugin/
plugin.json
references/
common-patterns.md
s3-compatibility.md
workers-api.md
rules/
cloudflare-r2.md
templates/
r2-cors-config.json
r2-multipart-upload.ts
r2-presigned-urls.ts
r2-simple-upload.ts

详细介绍

Cloudflare R2 对象存储

状态:已可用于生产环境 ✅ 最近更新:2026-01-20 依赖项:cloudflare-worker-base(用于 Worker 设置) 最新版本:[email protected],@cloudflare/[email protected],[email protected]

近期更新(2025 年):

  • 2025 年 9 月:R2 SQL 开放测试版(Apache Iceberg 的无服务器查询引擎),Pipelines 正式发布(实时流摄取),远程绑定正式发布(本地开发连接到已部署的 R2)
  • 2025 年 5 月:控制面板重新设计(支持深层链接,集中管理存储桶设置),Super Slurper 速度提升 5 倍(基于 Workers/Queues/Durable Objects 重建)
  • 2025 年 4 月:R2 Data Catalog 开放测试版(托管式 Apache Iceberg 目录),事件通知开放测试版(每个 Queue 每秒 5,000 条消息)
  • 2025 年:存储桶限额提高(最高 100 万个),CRC-64/NVME 校验和,使用客户密钥的服务器端加密,低频访问存储类别(测试版),大洋洲区域,S3 API 增强(sha256/sha1 校验和、ListParts、条件式 CopyObject)

快速入门(5 分钟)

# 1. Create bucket
npx wrangler r2 bucket create my-bucket

# 2. Add binding to wrangler.jsonc
# {
#   "r2_buckets": [{
#     "binding": "MY_BUCKET",
#     "bucket_name": "my-bucket",
#     "preview_bucket_name": "my-bucket-preview"  // Optional: separate dev/prod
#   }]
# }

# 3. Upload/download from Worker
type Bindings = { MY_BUCKET: R2Bucket };

// Upload
await env.MY_BUCKET.put('file.txt', data, {
  httpMetadata: { contentType: 'text/plain' }
});

// Download
const object = await env.MY_BUCKET.get('file.txt');
if (!object) return c.json({ error: 'Not found' }, 404);

return new Response(object.body, {
  headers: {
    'Content-Type': object.httpMetadata?.contentType || 'application/octet-stream',
    'ETag': object.httpEtag,
  },
});

# 4. Deploy
npx wrangler deploy

R2 Workers API

核心方法
// put() - Upload objects
await env.MY_BUCKET.put('file.txt', data, {
  httpMetadata: {
    contentType: 'text/plain',
    cacheControl: 'public, max-age=3600',
  },
  customMetadata: { userId: '123' },
  md5: await crypto.subtle.digest('MD5', data),  // Checksum verification
});

// Conditional upload (prevent overwrites)
const object = await env.MY_BUCKET.put('file.txt', data, {
  onlyIf: { uploadedBefore: new Date('2020-01-01') }
});
if (!object) return c.json({ error: 'File already exists' }, 409);

// get() - Download objects
const object = await env.MY_BUCKET.get('file.txt');
if (!object) return c.json({ error: 'Not found' }, 404);

const text = await object.text();           // As string
const json = await object.json();           // As JSON
const buffer = await object.arrayBuffer();  // As ArrayBuffer

// Range requests (partial downloads)
const partial = await env.MY_BUCKET.get('video.mp4', {
  range: { offset: 0, length: 1024 * 1024 }  // First 1MB
});

// head() - Get metadata only (no body download)
const object = await env.MY_BUCKET.head('file.txt');
console.log(object.size, object.etag, object.customMetadata);

// delete() - Delete objects
await env.MY_BUCKET.delete('file.txt');  // Single delete (idempotent)
await env.MY_BUCKET.delete(['file1.txt', 'file2.txt']);  // Bulk delete (max 1000)

// list() - List objects
const listed = await env.MY_BUCKET.list({
  prefix: 'images/',  // Filter by prefix
  limit: 100,
  cursor: cursor,     // Pagination
  delimiter: '/',     // Folder-like listing
  include: ['httpMetadata', 'customMetadata'],  // IMPORTANT: Opt-in for metadata
});

for (const object of listed.objects) {
  console.log(`${object.key}: ${object.size} bytes`);
  console.log(object.httpMetadata?.contentType);  // Now populated with include parameter
  console.log(object.customMetadata);             // Now populated with include parameter
}

分段上传

适用于大于 100MB 的文件或可续传的上传。适用场景:大文件、浏览器上传、需要并行处理。

// 1. Create multipart upload
const multipart = await env.MY_BUCKET.createMultipartUpload('large-file.zip', {
  httpMetadata: { contentType: 'application/zip' }
});

// 2. Upload parts (5MB-100MB each, max 10,000 parts)
const multipart = env.MY_BUCKET.resumeMultipartUpload(key, uploadId);
const part1 = await multipart.uploadPart(1, chunk1);
const part2 = await multipart.uploadPart(2, chunk2);

// 3. Complete upload
const object = await multipart.complete([
  { partNumber: 1, etag: part1.etag },
  { partNumber: 2, etag: part2.etag },
]);

// 4. Abort if needed
await multipart.abort();

限制:每个分段为 5MB-100MB,每次上传最多 10,000 个分段。文件小于 5MB 时不要使用(开销较大)。


预签名 URL

允许客户端直接向 R2 上传或从 R2 下载(绕过 Worker)。使用 aws4fetch 库。

import { AwsClient } from 'aws4fetch';

const r2Client = new AwsClient({
  accessKeyId: env.R2_ACCESS_KEY_ID,
  secretAccessKey: env.R2_SECRET_ACCESS_KEY,
});

const url = new URL(
  `https://${bucketName}.${accountId}.r2.cloudflarestorage.com/${filename}`
);
url.searchParams.set('X-Amz-Expires', '3600');  // 1 hour expiry

const signed = await r2Client.sign(
  new Request(url, { method: 'PUT' }),  // or 'GET' for downloads
  { aws: { signQuery: true } }
);

// Client uploads directly to R2
await fetch(signed.url, { method: 'PUT', body: file });

关键安全提示:

  • ❌ 绝不要在客户端代码中暴露 R2 访问密钥
  • ✅ 始终在服务端生成预签名 URL
  • ✅ 始终设置过期时间(通常为 1-24 小时)
  • ✅ 始终在生成 URL 前进行身份验证
  • ✅ 考虑将范围限定到用户文件夹:users/${userId}/${filename}
预签名 URL 的域名要求

关键提示:预签名 URL 只能通过 S3 API 域名使用,不能使用自定义域名。

// ❌ WRONG - Presigned URLs don't work with custom domains
const url = new URL(`https://cdn.example.com/${filename}`);
const signed = await r2Client.sign(
  new Request(url, { method: 'PUT' }),
  { aws: { signQuery: true } }
);
// This URL will fail - presigning requires S3 domain

// ✅ CORRECT - Use R2 storage domain for presigned URLs
const url = new URL(
  `https://${accountId}.r2.cloudflarestorage.com/${filename}`
);
const signed = await r2Client.sign(
  new Request(url, { method: 'PUT' }),
  { aws: { signQuery: true } }
);

// Pattern: Upload via presigned S3 URL, serve via custom domain
async function generateUploadUrl(filename: string) {
  const uploadUrl = new URL(
    `https://${accountId}.r2.cloudflarestorage.com/${filename}`
  );
  const signed = await r2Client.sign(
    new Request(uploadUrl, { method: 'PUT' }),
    { aws: { signQuery: true } }
  );

  return {
    uploadUrl: signed.url,  // For client upload (S3 domain)
    publicUrl: `https://cdn.example.com/${filename}`  // For serving (custom domain)
  };
}

来源:社区知识

Wrangler 的 API 令牌要求

⚠️ Wrangler CLI 需要“Admin Read & Write”权限,而非“Object Read & Write”。

为 Wrangler 操作创建 API 令牌时:

  • ✅ 使用:R2 → Admin Read & Write
  • ❌ 不要使用:R2 → Object Read & Write(会导致 403 Forbidden 错误)

原因:“Object Read & Write”仅用于直接访问 S3 API。Wrangler 在执行存储桶操作时需要管理员级权限。

# With wrong permissions:
export CLOUDFLARE_API_TOKEN="token_with_object_readwrite"
wrangler r2 object put my-bucket/file.txt --file=./file.txt --remote
# ✘ [ERROR] Failed to fetch - 403: Forbidden

# With correct permissions (Admin Read & Write):
wrangler r2 object put my-bucket/file.txt --file=./file.txt --remote
# ✔ Success

来源:GitHub Issue #9235


CORS 配置

在通过浏览器访问前,请在存储桶设置中配置 CORS(Dashboard → R2 → Bucket → Settings → CORS Policy)。

Dashboard 格式与 CLI 格式

⚠️ Wrangler CLI 和 Dashboard UI 使用不同的 CORS 格式。这经常会造成混淆。

Dashboard 格式(仅在 UI 中有效):

[{
  "AllowedOrigins": ["https://example.com"],
  "AllowedMethods": ["GET", "PUT"],
  "AllowedHeaders": ["*"],
  "ExposeHeaders": ["ETag"],
  "MaxAgeSeconds": 3600
}]

CLI 格式(wrangler r2 bucket cors 所需):

{
  "rules": [{
    "allowed": {
      "origins": ["https://www.example.com"],
      "methods": ["GET", "PUT"],
      "headers": ["Content-Type", "Authorization"]
    },
    "exposeHeaders": ["ETag", "Content-Length"],
    "maxAgeSeconds": 8640
  }]
}
# Using CLI format
wrangler r2 bucket cors set my-bucket --file cors-config.json

# Error if using Dashboard format:
# "The CORS configuration file must contain a 'rules' array"

来源:GitHub Issue #10076

自定义域名的 CORS

在 R2 中使用自定义域名时,CORS 由两个层面处理:

  1. R2 存储桶 CORS:适用于所有访问方式(预签名 URL、直接 S3 访问)
  2. 转换规则 CORS:通过自定义域名的 Cloudflare Cache 设置添加额外的 CORS 标头
// Bucket CORS (set via dashboard or wrangler)
{
  "rules": [{
    "allowed": {
      "origins": ["https://app.example.com"],
      "methods": ["GET", "PUT"],
      "headers": ["Content-Type"]
    },
    "maxAgeSeconds": 3600
  }]
}

// Additional CORS via Transform Rules (Dashboard → Rules → Transform Rules)
// Modify Response Header: Access-Control-Allow-Origin: https://app.example.com

// Order of CORS evaluation:
// 1. R2 bucket CORS (if presigned URL or direct R2 access)
// 2. Transform Rules CORS (if via custom domain)

来源:社区知识

对于预签名 URL:CORS 由 R2 直接处理(在存储桶上配置,而不是在 Worker 上配置)。


HTTP 元数据与自定义元数据

// HTTP metadata (standard headers)
await env.MY_BUCKET.put('file.pdf', data, {
  httpMetadata: {
    contentType: 'application/pdf',
    cacheControl: 'public, max-age=31536000, immutable',
    contentDisposition: 'attachment; filename="report.pdf"',
    contentEncoding: 'gzip',
  },
  customMetadata: {
    userId: '12345',
    version: '1.0',
  }  // Max 2KB total, keys/values must be strings
});

// Read metadata
const object = await env.MY_BUCKET.head('file.pdf');
console.log(object.httpMetadata, object.customMetadata);

错误处理

常见的 R2 错误
try {
  await env.MY_BUCKET.put(key, data);
} catch (error: any) {
  const message = error.message;

  if (message.includes('R2_ERROR')) {
    // Generic R2 error
  } else if (message.includes('exceeded')) {
    // Quota exceeded
  } else if (message.includes('precondition')) {
    // Conditional operation failed
  } else if (message.includes('multipart')) {
    // Multipart upload error
  }

  console.error('R2 Error:', message);
  return c.json({ error: 'Storage operation failed' }, 500);
}
重试逻辑

由于运行问题,R2 在 2025 年第一季度发生了两次重大故障(2 月 6 日:59 分钟,3 月 21 日:1 小时 7 分钟)。请为平台错误实现带指数退避的稳健重试逻辑。

async function r2WithRetry<T>(
  operation: () => Promise<T>,
  maxRetries = 5
): Promise<T> {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      return await operation();
    } catch (error: any) {
      const message = error.message;

      // Retry on transient errors and platform issues
      const is5xxError =
        message.includes('500') ||
        message.includes('502') ||
        message.includes('503') ||
        message.includes('504');

      const isRetryable =
        is5xxError ||
        message.includes('network') ||
        message.includes('timeout') ||
        message.includes('temporarily unavailable');

      if (!isRetryable || attempt === maxRetries - 1) {
        throw error;
      }

      // Exponential backoff (longer for platform errors)
      // 5xx errors: 1s, 2s, 4s, 8s, 16s (up to 31s total)
      // Other errors: 1s, 2s, 4s, 5s, 5s (up to 17s total)
      const delay = is5xxError
        ? Math.min(1000 * Math.pow(2, attempt), 16000)
        : Math.min(1000 * Math.pow(2, attempt), 5000);

      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }

  throw new Error('Max retries exceeded');
}

// Usage
const object = await r2WithRetry(() =>
  env.MY_BUCKET.get('important-file.txt')
);

平台可靠性:R2 总体上很可靠,但 2025 年第一季度的故障表明,生产应用必须实现重试逻辑。所有 5xx 错误都应采用指数退避进行重试。

来源:


性能优化

// Batch delete (up to 1000 keys)
await env.MY_BUCKET.delete(['file1.txt', 'file2.txt', 'file3.txt']);

// Range requests for large files
const partial = await env.MY_BUCKET.get('video.mp4', {
  range: { offset: 0, length: 10 * 1024 * 1024 }  // First 10MB
});

// Cache headers for immutable assets
await env.MY_BUCKET.put('static/app.abc123.js', jsData, {
  httpMetadata: { cacheControl: 'public, max-age=31536000, immutable' }
});

// Checksums for data integrity
const md5Hash = await crypto.subtle.digest('MD5', fileData);
await env.MY_BUCKET.put('important.dat', fileData, { md5: md5Hash });
并发写入速率限制

⚠️ 以高频率并发写入同一个对象键会触发 HTTP 429 速率限制。

// ❌ BAD: Multiple Workers writing to same key rapidly
async function logToSharedFile(env: Env, logEntry: string) {
  const existing = await env.LOGS.get('global-log.txt');
  const content = (await existing?.text()) || '';
  await env.LOGS.put('global-log.txt', content + logEntry);
  // High write frequency to same key = 429 errors
}

// ✅ GOOD: Shard by timestamp or ID (distribute writes)
async function logWithSharding(env: Env, logEntry: string) {
  const timestamp = Date.now();
  const shard = Math.floor(timestamp / 60000); // 1-minute shards

  await env.LOGS.put(`logs/${shard}.txt`, logEntry, {
    customMetadata: { timestamp: timestamp.toString() }
  });
  // Different keys = no rate limiting
}

// ✅ ALTERNATIVE: Use Durable Objects for append operations
// Durable Objects can handle high-frequency updates to same state

// ✅ ALTERNATIVE: Use Queues + batch processing
// Buffer writes and batch them with unique keys

来源:R2 限制文档

R2.dev 域名速率限制

🚨 严重警告:{bucket}.{account}.r2.cloudflarestorage.com(r2.dev)域名不适用于生产环境。

r2.dev 的限制:

  • ❌ 速率限制不固定(起始值约为每秒数百个请求)
  • ❌ 带宽节流
  • ❌ 不提供 SLA 或性能保证
  • ❌ 高负载时会收到 429 Too Many Requests 错误

生产环境请始终使用自定义域名

// ❌ NOT for production - r2.dev endpoint
const publicUrl = `https://${bucketName}.${accountId}.r2.cloudflarestorage.com/${key}`;
// This will be rate limited in production

// ✅ Production: Custom domain
const productionUrl = `https://cdn.example.com/${key}`;

// Setup custom domain:
// 1. Dashboard → R2 → Bucket → Settings → Custom Domains
// 2. Add your domain (e.g., cdn.example.com)
// 3. Benefits:
//    - No rate limiting beyond account limits
//    - Cloudflare Cache support
//    - Custom cache rules via Workers
//    - Full CDN features

r2.dev 仅用于测试和开发。生产环境必须使用自定义域名。

来源:R2 限制文档


最佳实践摘要

务必执行:

  • 为所有上传设置 contentType
  • 删除多个对象时使用批量删除(最多 1000 个)
  • 为静态资源设置缓存标头
  • 大型客户端上传使用预签名 URL(仅限 S3 域名)
  • 文件大于 100MB 时使用分段上传
  • 浏览器上传前设置 CORS(wrangler 使用 CLI 格式)
  • 为预签名 URL 设置过期时间(1–24 小时)
  • 只需要元数据时使用 head()
  • 使用条件操作防止覆盖
  • 生产环境使用自定义域名(绝不使用 r2.dev)
  • 将写入分散到多个键,以避免速率限制
  • 使用 list() 的 include 参数获取元数据
  • 为 5xx 错误实现带指数退避的重试逻辑

切勿执行:

  • 切勿在客户端代码中公开 R2 访问密钥
  • 切勿省略 contentType(文件将以二进制形式下载)
  • 切勿循环删除(请使用批量删除)
  • 切勿省略浏览器上传所需的 CORS 设置
  • 小文件(<5MB)切勿使用分段上传
  • 单次调用切勿删除超过 1000 个键
  • 切勿省略预签名 URL 的过期时间(存在安全风险)
  • 切勿在生产环境使用 r2.dev 域名(会受到速率限制)
  • 切勿将预签名 URL 与自定义域名搭配使用(请使用 S3 域名)
  • 切勿以高频率写入同一个键(会导致 429 错误)
  • 切勿将“Object Read & Write”令牌用于 wrangler(请使用“Admin Read & Write”)
多租户架构

每个账户的存储桶上限已提高至 100 万个,因此现在大型应用可以为每个租户单独创建存储桶。

// Option 1: Per-tenant buckets (now scalable to 1M tenants)
const bucketName = `tenant-${tenantId}`;
const bucket = env[bucketName]; // Dynamic binding

// Option 2: Key prefixing (still preferred for most use cases)
await env.MY_BUCKET.put(`tenants/${tenantId}/file.txt`, data);

// Choose based on:
// - Per-tenant buckets: Strong isolation, separate billing/quotas
// - Key prefixing: Simpler, fewer resources, easier to manage

来源:R2 限制文档


已避免的已知问题

此技能可避免 13 个已记录的问题:

问题编号问题错误预防措施
#1浏览器中的 CORS 错误浏览器无法上传/下载在存储桶设置中配置 CORS,并使用正确的 CLI 格式
#2文件下载为二进制数据缺少 content-type上传时始终设置 httpMetadata.contentType
#3预签名 URL 过期时间URL 永不过期始终设置 X-Amz-Expires(1–24 小时)
#4分段上传限制分段超出限制将分段大小保持在 5MB–100MB,且最多 10,000 个分段
#5批量删除限制超过 1000 个键时失败将删除操作拆分为每批 1000 个
#6自定义元数据超出容量超过 2KB 限制将自定义元数据控制在 2KB 以内
#7list() 缺少元数据httpMetadata 未定义使用 include: ['httpMetadata', 'customMetadata'] 参数(问题 #10870)
#8CORS 格式混淆“Must contain 'rules' array”对 wrangler 使用带有 rules 包装项的 CLI 格式(问题 #10076)
#9API 令牌 403 错误“Failed to fetch - 403”对 wrangler 使用“Admin Read & Write”,而不是“Object Read & Write”(问题 #9235)
#10r2.dev 速率限制生产环境中出现 HTTP 429使用自定义域名,生产环境中切勿使用 r2.dev(R2 限制)
#11并发写入导致 429 错误频繁写入同一个键将写入分散到不同的键上(R2 限制)
#12预签名 URL 域名错误预签名 URL 失效仅使用 S3 域名,不要使用自定义域名(社区)
#13平台中断中断期间出现 5xx 错误使用指数退避实现重试逻辑(2 月 6 日、3 月 21 日)

开发最佳实践

清理本地 R2 存储

⚠️ 本地 R2 的 DELETE 操作不会清理 blob 文件。使用 wrangler dev 时,已删除的对象仍会留在 .wrangler/state/v3/r2/{bucket-name}/blobs/ 中,导致本地存储空间不断增长。

# Symptom: .wrangler/state grows large during development
du -sh .wrangler/state/v3/r2/

# Fix: Manually cleanup local R2 storage
rm -rf .wrangler/state/v3/r2/

# Alternative: Use remote R2 for development
wrangler dev --remote

来源:GitHub 问题 #10795

远程 R2 访问问题

⚠️ 使用 --remote 进行本地开发时,.get() 操作可能不稳定。有些用户报告称,即使 put() 正常工作,get() 仍会返回 undefined。

# If experiencing issues with remote R2 in local dev:

# Option 1: Use local buckets instead (recommended)
wrangler dev  # No --remote flag

# Option 2: Deploy to preview environment for testing
wrangler deploy --env preview

# Option 3: Add retry logic if must use --remote
async function safeGet(bucket: R2Bucket, key: string) {
  for (let i = 0; i < 3; i++) {
    const obj = await bucket.get(key);
    if (obj && obj.body) return obj;
    await new Promise(r => setTimeout(r, 1000));
  }
  throw new Error('Failed to get object after retries');
}

来源:GitHub 问题 #8868(来自社区)


Wrangler 命令参考

# Bucket management
wrangler r2 bucket create <BUCKET_NAME>
wrangler r2 bucket list
wrangler r2 bucket delete <BUCKET_NAME>

# Object management
wrangler r2 object put <BUCKET_NAME>/<KEY> --file=<FILE_PATH>
wrangler r2 object get <BUCKET_NAME>/<KEY> --file=<OUTPUT_PATH>
wrangler r2 object delete <BUCKET_NAME>/<KEY>

# List objects
wrangler r2 object list <BUCKET_NAME>
wrangler r2 object list <BUCKET_NAME> --prefix="folder/"

官方文档


准备好使用 R2 存储了! 🚀

最后验证时间:2026-01-20 | 技能版本:2.0.0 | 变更:通过社区调研新增 7 个已知问题(list() 元数据、CORS 格式混淆、API 令牌权限、r2.dev 速率限制、并发写入限制、预签名 URL 域名要求、平台中断时的重试模式)。增强了针对 5xx 错误的重试逻辑,新增开发最佳实践部分,并记录存储桶限制提高至 1M。

评论

支持 Markdown