最終更新:

Cloudflare R2ストレージ実践ガイド: S3互換オブジェクトストレージの活用


Cloudflare R2ストレージ実践ガイド: S3互換オブジェクトストレージの活用

Cloudflare R2は、S3互換のオブジェクトストレージサービスで、エグレス料金が無料という革新的な特徴を持っています。この記事では、R2の基本から実践的な活用方法まで完全解説します。

Cloudflare R2とは

R2は、Cloudflareが提供するオブジェクトストレージサービスです。AWS S3と互換性があるため、既存のS3用コードをほぼそのまま使用できます。

R2の主な特徴

  • エグレス料金無料: データ転送料金がかからない
  • S3互換API: 既存のS3ツールやSDKが使える
  • グローバル配信: Cloudflareのエッジネットワークを活用
  • Workers統合: Cloudflare Workersから高速アクセス可能
  • 低コスト: ストレージ料金が月$0.015/GB

R2バケットの作成と基本操作

Wranglerを使った管理

# Wranglerのインストール
npm install -g wrangler

# Cloudflareにログイン
wrangler login

# R2バケットの作成
wrangler r2 bucket create my-bucket

# バケット一覧の確認
wrangler r2 bucket list

# ファイルのアップロード
wrangler r2 object put my-bucket/test.txt --file ./test.txt

# ファイルの取得
wrangler r2 object get my-bucket/test.txt --file ./downloaded.txt

# ファイルの削除
wrangler r2 object delete my-bucket/test.txt

# バケットの削除
wrangler r2 bucket delete my-bucket

S3 SDKを使ったR2操作

AWS SDK for JavaScriptの設定

// r2-client.ts
import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand, ListObjectsV2Command } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

export class R2Storage {
  private client: S3Client;
  private bucket: string;

  constructor(accountId: string, accessKeyId: string, secretAccessKey: string, bucket: string) {
    this.client = new S3Client({
      region: 'auto',
      endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
      credentials: {
        accessKeyId,
        secretAccessKey,
      },
    });
    this.bucket = bucket;
  }

  // ファイルのアップロード
  async upload(key: string, body: Buffer | Uint8Array | string, contentType?: string): Promise<void> {
    const command = new PutObjectCommand({
      Bucket: this.bucket,
      Key: key,
      Body: body,
      ContentType: contentType,
    });

    await this.client.send(command);
  }

  // ファイルの取得
  async get(key: string): Promise<Uint8Array> {
    const command = new GetObjectCommand({
      Bucket: this.bucket,
      Key: key,
    });

    const response = await this.client.send(command);
    const stream = response.Body as ReadableStream;
    const reader = stream.getReader();
    const chunks: Uint8Array[] = [];

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      chunks.push(value);
    }

    const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0);
    const result = new Uint8Array(totalLength);
    let offset = 0;
    for (const chunk of chunks) {
      result.set(chunk, offset);
      offset += chunk.length;
    }

    return result;
  }

  // ファイルの削除
  async delete(key: string): Promise<void> {
    const command = new DeleteObjectCommand({
      Bucket: this.bucket,
      Key: key,
    });

    await this.client.send(command);
  }

  // ファイル一覧の取得
  async list(prefix?: string, maxKeys: number = 1000): Promise<string[]> {
    const command = new ListObjectsV2Command({
      Bucket: this.bucket,
      Prefix: prefix,
      MaxKeys: maxKeys,
    });

    const response = await this.client.send(command);
    return response.Contents?.map(obj => obj.Key!) || [];
  }

  // 署名付きURLの生成(一時的なアクセス権限)
  async getSignedUrl(key: string, expiresIn: number = 3600): Promise<string> {
    const command = new GetObjectCommand({
      Bucket: this.bucket,
      Key: key,
    });

    return await getSignedUrl(this.client, command, { expiresIn });
  }
}

// 使用例
const r2 = new R2Storage(
  process.env.CLOUDFLARE_ACCOUNT_ID!,
  process.env.R2_ACCESS_KEY_ID!,
  process.env.R2_SECRET_ACCESS_KEY!,
  'my-bucket'
);

// アップロード
await r2.upload('images/cat.jpg', imageBuffer, 'image/jpeg');

// ダウンロード
const data = await r2.get('images/cat.jpg');

// 一覧取得
const files = await r2.list('images/');
console.log(files); // ['images/cat.jpg', 'images/dog.jpg', ...]

// 署名付きURL生成
const url = await r2.getSignedUrl('images/cat.jpg', 3600);
console.log(url); // https://...

Cloudflare Workersとの統合

Cloudflare WorkersからR2バケットに直接アクセスすることで、超高速なストレージ操作が可能です。

Workers統合の設定

# wrangler.toml
name = "my-worker"
main = "src/index.ts"
compatibility_date = "2024-01-01"

[[r2_buckets]]
binding = "MY_BUCKET"
bucket_name = "my-bucket"

Workersでのファイル操作

// src/index.ts
export interface Env {
  MY_BUCKET: R2Bucket;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    const key = url.pathname.slice(1); // "/path/to/file" -> "path/to/file"

    // GET: ファイルの取得
    if (request.method === 'GET') {
      const object = await env.MY_BUCKET.get(key);

      if (!object) {
        return new Response('Not Found', { status: 404 });
      }

      const headers = new Headers();
      object.writeHttpMetadata(headers);
      headers.set('etag', object.httpEtag);

      return new Response(object.body, {
        headers,
      });
    }

    // PUT: ファイルのアップロード
    if (request.method === 'PUT') {
      await env.MY_BUCKET.put(key, request.body, {
        httpMetadata: {
          contentType: request.headers.get('content-type') || 'application/octet-stream',
        },
      });

      return new Response('Uploaded', { status: 201 });
    }

    // DELETE: ファイルの削除
    if (request.method === 'DELETE') {
      await env.MY_BUCKET.delete(key);
      return new Response('Deleted', { status: 204 });
    }

    // HEAD: メタデータの取得
    if (request.method === 'HEAD') {
      const object = await env.MY_BUCKET.head(key);

      if (!object) {
        return new Response('Not Found', { status: 404 });
      }

      const headers = new Headers();
      object.writeHttpMetadata(headers);
      headers.set('etag', object.httpEtag);

      return new Response(null, { headers });
    }

    return new Response('Method Not Allowed', { status: 405 });
  },
};

画像リサイズWorker

// src/image-resize.ts
export interface Env {
  IMAGES: R2Bucket;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    const key = url.pathname.slice(1);
    const width = url.searchParams.get('w');
    const height = url.searchParams.get('h');

    // 元画像を取得
    const object = await env.IMAGES.get(key);
    if (!object) {
      return new Response('Not Found', { status: 404 });
    }

    // リサイズパラメータがない場合は元画像を返す
    if (!width && !height) {
      return new Response(object.body, {
        headers: {
          'content-type': object.httpMetadata?.contentType || 'application/octet-stream',
          'cache-control': 'public, max-age=31536000',
        },
      });
    }

    // Cloudflare Imagesを使ってリサイズ
    const resizeOptions: RequestInit = {
      cf: {
        image: {
          width: width ? parseInt(width) : undefined,
          height: height ? parseInt(height) : undefined,
          fit: 'scale-down',
          quality: 85,
        },
      },
    };

    // R2から取得した画像をCloudflare Imagesでリサイズ
    const imageResponse = new Response(object.body);
    return fetch(imageResponse.url, resizeOptions);
  },
};

パブリックアクセスの設定

カスタムドメインでの公開

// src/public-bucket.ts
export interface Env {
  PUBLIC_BUCKET: R2Bucket;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    const key = url.pathname.slice(1);

    // キャッシュヘッダーの確認
    const cacheKey = new Request(url.toString(), request);
    const cache = caches.default;
    let response = await cache.match(cacheKey);

    if (response) {
      return response;
    }

    // R2からオブジェクトを取得
    const object = await env.PUBLIC_BUCKET.get(key);

    if (!object) {
      return new Response('Not Found', { status: 404 });
    }

    // レスポンスヘッダーの設定
    const headers = new Headers();
    object.writeHttpMetadata(headers);
    headers.set('etag', object.httpEtag);
    headers.set('cache-control', 'public, max-age=31536000');
    headers.set('access-control-allow-origin', '*');

    response = new Response(object.body, { headers });

    // エッジキャッシュに保存
    await cache.put(cacheKey, response.clone());

    return response;
  },
};

ファイルアップロードAPI

マルチパートアップロード対応

// src/upload-api.ts
export interface Env {
  UPLOADS: R2Bucket;
  AUTH_TOKEN: string;
}

// 認証ミドルウェア
function authenticate(request: Request, env: Env): boolean {
  const token = request.headers.get('authorization')?.replace('Bearer ', '');
  return token === env.AUTH_TOKEN;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    // 認証チェック
    if (!authenticate(request, env)) {
      return new Response('Unauthorized', { status: 401 });
    }

    const url = new URL(request.url);

    // シングルファイルアップロード
    if (request.method === 'POST' && url.pathname === '/upload') {
      const formData = await request.formData();
      const file = formData.get('file') as File;

      if (!file) {
        return new Response('No file provided', { status: 400 });
      }

      // ファイル名の生成(UUIDを使用)
      const fileExt = file.name.split('.').pop();
      const key = `${crypto.randomUUID()}.${fileExt}`;

      // R2にアップロード
      await env.UPLOADS.put(key, file.stream(), {
        httpMetadata: {
          contentType: file.type,
        },
        customMetadata: {
          originalName: file.name,
          uploadedAt: new Date().toISOString(),
        },
      });

      return Response.json({
        success: true,
        key,
        url: `https://your-domain.com/${key}`,
      });
    }

    // マルチパートアップロード開始
    if (request.method === 'POST' && url.pathname === '/multipart/start') {
      const { fileName, fileType } = await request.json();
      const key = `${crypto.randomUUID()}.${fileName.split('.').pop()}`;

      const multipartUpload = await env.UPLOADS.createMultipartUpload(key, {
        httpMetadata: {
          contentType: fileType,
        },
      });

      return Response.json({
        uploadId: multipartUpload.uploadId,
        key,
      });
    }

    // マルチパートアップロード - パートのアップロード
    if (request.method === 'PUT' && url.pathname.startsWith('/multipart/part')) {
      const { key, uploadId, partNumber } = await request.json();
      const body = await request.arrayBuffer();

      const multipartUpload = env.UPLOADS.resumeMultipartUpload(key, uploadId);
      const part = await multipartUpload.uploadPart(partNumber, body);

      return Response.json({
        etag: part.etag,
      });
    }

    // マルチパートアップロード完了
    if (request.method === 'POST' && url.pathname === '/multipart/complete') {
      const { key, uploadId, parts } = await request.json();

      const multipartUpload = env.UPLOADS.resumeMultipartUpload(key, uploadId);
      await multipartUpload.complete(parts);

      return Response.json({
        success: true,
        url: `https://your-domain.com/${key}`,
      });
    }

    return new Response('Not Found', { status: 404 });
  },
};

コスト最適化とベストプラクティス

1. ライフサイクルポリシーの実装

// 古いファイルの自動削除
export interface Env {
  TEMP_BUCKET: R2Bucket;
}

export default {
  async scheduled(event: ScheduledEvent, env: Env): Promise<void> {
    const nowMs = Date.now();
    const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000;

    // すべてのオブジェクトをリスト
    let cursor: string | undefined;
    do {
      const listed = await env.TEMP_BUCKET.list({ cursor });

      for (const object of listed.objects) {
        const uploadedMs = object.uploaded.getTime();
        if (nowMs - uploadedMs > thirtyDaysMs) {
          await env.TEMP_BUCKET.delete(object.key);
          console.log(`Deleted old file: ${object.key}`);
        }
      }

      cursor = listed.truncated ? listed.cursor : undefined;
    } while (cursor);
  },
};

2. キャッシュ戦略

// エッジキャッシュを活用した配信
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    const cacheKey = new Request(url.toString(), request);
    const cache = caches.default;

    // キャッシュから取得を試みる
    let response = await cache.match(cacheKey);
    if (response) {
      return response;
    }

    // R2から取得
    const object = await env.BUCKET.get(url.pathname.slice(1));
    if (!object) {
      return new Response('Not Found', { status: 404 });
    }

    // 適切なキャッシュヘッダーを設定
    const headers = new Headers();
    object.writeHttpMetadata(headers);
    headers.set('cache-control', 'public, max-age=86400'); // 24時間
    headers.set('cdn-cache-control', 'max-age=31536000'); // 1年

    response = new Response(object.body, { headers });

    // エッジにキャッシュ
    await cache.put(cacheKey, response.clone());

    return response;
  },
};

まとめ

Cloudflare R2は、エグレス料金無料という革新的な特徴を持つオブジェクトストレージサービスです。主な利点は以下の通りです。

  • コスト削減: データ転送料金が無料で、大量配信に最適
  • S3互換: 既存のツールやコードをそのまま使用可能
  • 高速アクセス: Workers統合で超低レイテンシーを実現
  • グローバル配信: Cloudflareのエッジネットワークを活用

静的アセット、メディアファイル、バックアップなど、あらゆるデータストレージニーズに対応できます。