Cloudflare R2 × Workers × Koyebで低コストなログアーカイブ基盤を設計・実装する実録

contents 10
  1. 01 R2とオブジェクトストレージについて
  2. 02 要件と前提
  3. 03 アーキテクチャ:役割分担と保存ルール
  4. 04 実装:Workersとシッパー
  5. 05 運用:分割方針とコスト試算
  6. 06 将来拡張:Workers Logpush の統合(保留)
  7. 07 セキュリティと可観測性
  8. 08 トレードオフと採用しなかった案
  9. 09 まとめ
  10. 10 参考文献

R2とオブジェクトストレージについて

R2とは

Cloudflare R2は、ファイルをクラウド上に保存するオブジェクトストレージサービスです。写真、動画、ログファイルなど、あらゆる種類のデータを格納できます。従来のファイルシステムとは異なり、フォルダ階層ではなく「バケット」という入れ物の中に「オブジェクト」として保存します。

補足(まずは直感で理解するために)

  • バケット = 名前付きの「大きな箱」です。案件ごと・環境ごと(dev/beta/prod など)に箱を分けます。
  • オブジェクト = 1ファイルに相当します。箱の中で「キー(名前)」を付けて保存します。
  • キー = パスのような文字列です。logs/2025/09/14/app/app-2025-09-14T10.jsonl.gz のように、/ を含めて「擬似フォルダ」風に整理できます(実体はフラットで、フォルダはメタ情報にすぎません)。
  • 操作 = 置く(PUT)、取り出す(GET)、一覧(LIST)が基本です。上書きは「同じキー名で置き直す」動作になります。

よくある疑問への短い答え

  • ローカルのネットワークドライブのように「マウントして使う」ものではありません(HTTP API で操作します)。
  • 「ディレクトリの作成」は不要です。オブジェクトを置くと、そのキーのプレフィックスが結果的に「フォルダらしく」見えます。
  • 信頼性はクラウド側で多重化されます。自前で RAID を設計する発想は不要です(ただし削除・上書きは人為ミスになり得るため、ライフサイクルルールやバージョニングでガードします)。

Cloudflare R2 概要

S3との関係

R2はAmazon S3と互換性があります。つまり、S3用に作られたツールやライブラリがそのまま使えることが多いです。APIの仕様も似ているため、既存のS3コードをほとんど変更せずにR2に移行できます。

S3互換とは何か

  • 多くの SDK/CLI は「S3 API(Signature v4)」で話します。R2はこの“言語”を理解するため、エンドポイント(https://<accountid>.r2.cloudflarestorage.com など)と認証情報を差し替えるだけで動きます。
  • 例:既存のバックアップツール(rclone, aws-sdk, minio client など)に R2 のエンドポイントを設定すると、そのまま PUT/GET/LIST が使えます。
  • 細部の差異(例:一部 API のサポート状況やオプション名)はあります。重要なのは「基本操作は同じ」で、「データ転送料が無料」という料金面の大きな違いがあることです。

用語のミニ辞典

  • バケット: データを入れる最上位の入れ物。名前は(通常)アカウント内で一意です。
  • オブジェクト: 実体データ(1ファイル分)。メタデータ(Content-Type 等)を付けられます。
  • キー(オブジェクトキー): オブジェクトの一意な名前。/ を含めて疑似階層にできます。
  • プレフィックス: キーの共通の先頭部分。logs/2025/ のように、範囲指定やライフサイクル管理で便利です。
  • ライフサイクルルール: 「90日で自動削除」など、保存期間や削除方針を自動化する仕組みです。

オブジェクトストレージってなんだったかな、と考えながら、調べながら、まったく覚えていなかったので勉強し直していました。

料金体系

R2の大きな特徴はデータ転送料が無料であることです。通常のクラウドストレージでは、データをダウンロードするたびに課金されますが、R2では一切かかりません。

項目Forever Free(無料枠)有料料金
ストレージ10 GB / 月$0.015 / GB
クラスA操作(書き込み・削除など)100万回 / 月$4.50 / 100万回
クラスB操作(読み込み・一覧など)1000万回 / 月$0.36 / 100万回
データ転送料無料無料

(注:2025-09-14時点の情報)

補足(エグレスについて)

  • ここでの「データ転送料無料」は、一般に課金対象となる外向きデータ転送(egress)料金が 0 USD であることを指します。費用は主に「保管容量」と「リクエスト数(Class A/B)」で決まり、Workers の実行や他サービス側の従量は別途です。

無料枠だけでも個人プロジェクトには十分な容量です。

要件と前提

前提として、ホストしているアプリケーションは Discord Bot を想定しています。Bot は標準出力/標準エラーに運用ログ(情報/警告/エラーなど)を出力する設計であり、本稿の仕組みはそのログをサイズ/時間でローテーションし、Cloudflare Workers を経由して R2 に保存する用途を対象にしています。

本稿では、Koyeb の標準出力ログを定期的に収集・圧縮し、Cloudflare Workers の受信エンドポイントを経由して R2 に保存する設計と実装の記録をまとめます。目的は二つです。第一に、Koyeb のローカルディスク消費を抑えつつ、低コストで長期保管すること。第二に、将来必要になったときに Workers 側のコンソールログ(Logpush)を同一バケットへ無停止で統合できる拡張性を確保することです。なお、今回の段階では Logpush は有効化せず、必要になった際の手順を別ドキュメントに記しました(意思決定の先送りによるリスク低減)。

補足:Koyeb にはログ閲覧や Log Exporter による外部送出の機能がありますが、本構成では独自に収集・保管する方針です。

要件整理は以下のとおりです。

  • コスト最小。SaaS 連携は避け、管理対象は最小。
  • 機密情報をハードコードしない。Koyebには R2 の鍵を置かず、Bearer(API_KEY)のみ。
  • オブジェクト数の制御。過剰分割を避け、検索性と信頼性のバランスをとる。
  • 落ちても耐える。送信失敗時はスプール保持。再起動時に最終チャンクをフラッシュ。

アーキテクチャ:役割分担と保存ルール

ここでは、各コンポーネントの役割分担と、R2へ保存する際のパス(キー)の付け方を簡潔に定めます。設計は単純です。Koyeb コンテナ内でアプリ実行用のラッパーを走らせ、標準出力/標準エラーをファイルに tee します。一定条件(サイズしきい値または最大経過時間)に達したら gzip に圧縮し、Workers の PUT /api/logs/:source に送信します。Workers は R2Bucket.put() でオブジェクトを保存するだけの薄い中継です。

図解(全体フロー)

ログ収集フロー: Koyeb App → Shipper → Worker API → R2
  • 保存キーのルール(R2)
    • logs/{source}/{YYYY}/{MM}/{DD}/{host}/{service}/{YYYY-MM-DDTHH}-{uuid}.jsonl.gz
    • sourcekoyeb、将来の Worker 側は worker(Logpush)でプレフィックス分離します。
  • セキュリティ
    • Koyeb → Worker は Bearer 認証(API_KEY)。R2 の鍵は配布しません。
    • Body は gzip 強制。Content-Encoding: gzip を検査します。
  • 構成ファイル
    • Workers 側: [[r2_buckets]] LOGS_BUCKET を dev/beta/stable でバインド。
    • Koyeb 側: .dev.vars/.beta.vars/.stable.vars に送信ポリシーとロギング方針を一元化。

最小コード例(Workers 受信)

// workers/src/ingest.ts(最小例)
export interface Env { LOGS_BUCKET: R2Bucket }
export default {
  async fetch(req: Request, env: Env): Promise<Response> {
    const url = new URL(req.url);
    const source = url.pathname.split('/').pop() || 'unknown';
    const host = (url.searchParams.get('host') || 'unknown').replace(/[^a-zA-Z0-9_.-]/g, '_');
    const service = (url.searchParams.get('service') || source).replace(/[^a-zA-Z0-9_.-]/g, '_');

    // 認証(例)
    const auth = req.headers.get('authorization') || '';
    if (!auth.startsWith('Bearer ')) return new Response('Unauthorized', { status: 401 });

    // gzip 前提・サイズ上限(例: 10MB)
    if (!(req.headers.get('content-encoding') || '').toLowerCase().includes('gzip')) {
      return new Response('gzip required', { status: 415 });
    }
    const len = Number(req.headers.get('content-length') || '0');
    if (len > 10 * 1024 * 1024) return new Response('payload too large', { status: 413 });

    const now = new Date();
    const yyyy = now.getUTCFullYear();
    const mm = String(now.getUTCMonth() + 1).padStart(2, '0');
    const dd = String(now.getUTCDate()).padStart(2, '0');
    const hh = String(now.getUTCHours()).padStart(2, '0');
    const id = crypto.randomUUID();
    const key = `logs/${source}/${yyyy}/${mm}/${dd}/${host}/${service}/${yyyy}-${mm}-${dd}T${hh}-${id}.jsonl.gz`;

    await env.LOGS_BUCKET.put(key, req.body as ReadableStream, {
      httpMetadata: { contentType: 'application/x-ndjson', contentEncoding: 'gzip' }
    });
    return new Response(JSON.stringify({ ok: true, key }), { headers: { 'content-type': 'application/json' } });
  }
} satisfies ExportedHandler<Env>;

実装:Workersとシッパー

1) Workers 受信エンドポイント(抜粋)

PUT /api/logs/:source に対し、クエリで hostservice を受け取り、ボディをそのまま R2 にストリーミング保存します。Content-Encoding: gzip を前提にし、httpMetadata で型とエンコーディングを保持します。

// workers/src/handlers/logsIngest.ts
export const logsIngestHandler = new Hono<{ Bindings: { LOGS_BUCKET: R2Bucket } }>()
  .put('/:source', async (c) => {
    const source = c.req.param('source') || 'unknown';
    const url = new URL(c.req.url);
    const host = (url.searchParams.get('host') || 'unknown').replace(/[^a-zA-Z0-9_.-]/g, '_');
    const service = (url.searchParams.get('service') || source).replace(/[^a-zA-Z0-9_.-]/g, '_');
    const enc = c.req.header('content-encoding') || '';
    if (!enc.toLowerCase().includes('gzip')) return c.json({ success:false, error:'gzip required' }, 415);

    const now = new Date();
    const yyyy = now.getUTCFullYear();
    const mm = String(now.getUTCMonth() + 1).padStart(2, '0');
    const dd = String(now.getUTCDate()).padStart(2, '0');
    const hh = String(now.getUTCHours()).padStart(2, '0');
    const id = crypto.randomUUID?.() ?? Math.random().toString(36).slice(2);
    const key = `logs/${source}/${yyyy}/${mm}/${dd}/${host}/${service}/${yyyy}-${mm}-${dd}T${hh}-${id}.jsonl.gz`;
    await c.env.LOGS_BUCKET.put(key, c.req.body as ReadableStream, {
      httpMetadata: { contentType: 'application/x-ndjson', contentEncoding: 'gzip' }
    });
    return c.json({ success: true, key });
  });

2) Koyeb 用ラッパー(shipping ポリシー)

app/bin/run-with-shipper.sh は、アプリを起動しつつ標準出力をファイルに tee し、以下の条件で送信します。

  • 判定周期: INTERVAL 秒(推奨 60)
  • サイズしきい値: MIN_BYTES バイト(推奨 256KB)
  • 最大経過時間: MAX_AGE 秒(推奨 3600 = 1時間)

実行例(Run command):

bash -lc 'INTERVAL=60 MAX_AGE=3600 MIN_BYTES=262144 \
  CONSOLE_FILTER_REGEX="WARN|ERROR|CRITICAL" \
  APP_CMD="python3 -u python.py" \
  SERVICE_TAG="service-name" \
  bash ./bin/run-with-shipper.sh'

CONSOLE_FILTER_REGEX は Koyeb のコンソール表示を抑制するための正規表現です。ファイル側には INFO まで含めた全件を保存します。Python 側では LOG_LEVEL=INFOLOG_LEVEL_DISCORD=WARNING とし、外部ライブラリの冗長な出力を抑えています。

運用:分割方針とコスト試算

分割は「検索性」「信頼性」「コスト」の三者の均衡です。10分間隔は小粒で快適ですが、オブジェクト数が増えます。今回は検索単位を 1 時間とし、静かな時間でも 1 本、賑やかな時間帯は 256KB 到達で適度に分割されます。これらは推奨と書いたものの、適当な基準です。

  • PUT 課金の目安
    • 1時間間隔なら 24/日 → 約 720/月(1ホスト×1サービス)
    • Class A(PUT)100万あたり数ドルのため誤差レベル
  • ストレージ
    • 1本あたり平均 1MB なら月 0.7GB 程度。30日ライフサイクルなら微少
  • ライフサイクル
    • logs/koyeb/ と将来の logs/worker/ に 30 日削除ルールの適用を推奨

将来拡張:Workers Logpush の統合(保留)

Workers のコンソールログは Logpush を使うのが筋です。今回はコスト理由で見送り、必要時に以下の手順で logs/worker/ へ出力する方針としました。

  1. ダッシュボード → Workers & Pages → Logs → Logpush → Add destination
  2. Destination: R2、Bucket: 環境別バケット、Prefix: logs/worker/、Compression: gzip
  3. 有効化。R2 のライフサイクルを 30 日で設定

運用を始めてからでも無停止で追加可能です。

セキュリティと可観測性

  • 認証は Bearer(API_KEY)のみを Koyeb
  • HTTP ヘッダー(Authorization 等)は shipper からログ出力しません。
  • 重大イベントは Workers のハンドラ各所で ADMIN_WEBHOOK_URL へ通知(Discord)。未捕捉例外の全量通知はスパム化リスクがあるため段階導入とします。

トレードオフと採用しなかった案

  • Log Exporter(Koyeb公式)
    • リアルタイム性と可観測性は優秀。しかし外部基盤連携のコストが目的に比して過剰。(なんとなくログに関してはKoyebを信用しきれない)
  • S3 直送(Koyeb→R2)
    • 実装は簡素だが、Koyeb へ鍵を配る必要がありポリシーに反する。Worker 経由が安全。
  • 高機能ログシッパー(Fluent Bit / Vector)
    • 再送・加工は強力です。ただし依存が増えるため、今回の規模には過剰と判断しました。

まとめ

本構成は、低コスト・低運用負荷で「とりあえず保存する」ことに焦点を当て、将来の拡張(Logpush 統合)にも道を残す実務的な折衷です。分割条件(時間/サイズ)とロギングレベルの二軸を調整し、検索性と費用のバランスを取り続けることが肝要です。最初は控えめに、必要になった時にだけ仕組みを厚くする、という姿勢で行いました。

Cloudflare Workers の有料プランは魅力的ですが、現時点の用途では Logpush と KV 以外を十分に活用できていないため、本稿では無料枠の範囲で構成しました。

参考文献

share Twitter Facebook