关于 cloudflare-r2
cloudflare-r2 是面向使用 Cloudflare R2 对象存储和 Workers 的开发者的实用技能,介绍对象操作、上传模式、访问配置及运维注意事项,并涵盖 Wrangler 和兼容 S3 的预签名 URL。
主要功能
- 01R2 Workers 对象操作
- 02分段上传与预签名上传
- 03CORS 和元数据配置
- 04批量删除与范围读取
- 05Wrangler 设置指南
- 06安全与可靠性实践
适用场景
- 01上传大型或可续传文件
- 02通过预签名 URL 支持浏览器上传
- 03配置存储桶 CORS 和元数据
- 04处理存储错误与速率限制
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ jezweb/ claude-skills --skill cloudflare-r2安装后在 Claude Code 中输入/cloudflare-r2确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
README.md.claude-plugin/plugin.jsonreferences/common-patterns.mds3-compatibility.mdworkers-api.mdrules/cloudflare-r2.mdtemplates/r2-cors-config.jsonr2-multipart-upload.tsr2-presigned-urls.tsr2-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
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"
自定义域名的 CORS
在 R2 中使用自定义域名时,CORS 由两个层面处理:
- R2 存储桶 CORS:适用于所有访问方式(预签名 URL、直接 S3 访问)
- 转换规则 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 以内 |
| #7 | list() 缺少元数据 | httpMetadata 未定义 | 使用 include: ['httpMetadata', 'customMetadata'] 参数(问题 #10870) |
| #8 | CORS 格式混淆 | “Must contain 'rules' array” | 对 wrangler 使用带有 rules 包装项的 CLI 格式(问题 #10076) |
| #9 | API 令牌 403 错误 | “Failed to fetch - 403” | 对 wrangler 使用“Admin Read & Write”,而不是“Object Read & Write”(问题 #9235) |
| #10 | r2.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
远程 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 概览:https://developers.cloudflare.com/r2/
- 入门指南:https://developers.cloudflare.com/r2/get-started/
- Workers API:https://developers.cloudflare.com/r2/api/workers/workers-api-reference/
- 分段上传:https://developers.cloudflare.com/r2/api/workers/workers-multipart-usage/
- 预签名 URL:https://developers.cloudflare.com/r2/api/s3/presigned-urls/
- CORS 配置:https://developers.cloudflare.com/r2/buckets/cors/
- 公共存储桶:https://developers.cloudflare.com/r2/buckets/public-buckets/
准备好使用 R2 存储了! 🚀
最后验证时间:2026-01-20 | 技能版本:2.0.0 | 变更:通过社区调研新增 7 个已知问题(list() 元数据、CORS 格式混淆、API 令牌权限、r2.dev 速率限制、并发写入限制、预签名 URL 域名要求、平台中断时的重试模式)。增强了针对 5xx 错误的重试逻辑,新增开发最佳实践部分,并记录存储桶限制提高至 1M。