841-biborokuWebフロントエンドの備忘録

microCMSのimgixで自動生成していたOGP画像をCloudflare WorkersとR2×Cloudinaryに変えてみた

今までこの備忘録ブログのOGP画像は、microCMSで使えるimgixのURLパラメータを使って画像に記事タイトルのレイヤーを重ねて色を設定した画像を連携していました。

OGP画像を確認したところ記事タイトルが長くパラメータが肥大化してしまい、うまく表示されないケースがあったことに最近気づきました😭

そこでこれを機に前から実装してみたかったCloudflare Workers + R2で画像を生成・保存する仕組みを作ることにしました。画像の生成はCloudinaryを使います。

imgixを使った画像加工についてまとめた記事は「📝microCMS×imgixでOGPを自動生成する」をご覧ください。

💡この記事で実現できること

  • OGP自動生成
    • 記事URLにアクセスがあった際、microCMSからタイトルやカテゴリ色を動的に取得し、Cloudinaryで画像を合成。
  • Cloudinaryでの画像加工
    • 日本語Noto Sans JPフォントを利用。パラメータの組み合わせで複数レイヤーと効果を使用した加工。
  • コストパフォーマンスと高速配信
    • 一度生成した画像はCloudflare R2にキャッシュ。
      2回目以降は画像生成をスキップし、R2から即座にレスポンスを返すことでCloudinaryの無料枠を節約。
  • microCMS連動のキャッシュ自動削除
    • microCMSで記事を更新・削除した際、Webhook(カスタム通知)を飛ばしてR2上の古いキャッシュ画像を自動で削除
      常に最新の情報を反映。

OGP生成・配信のフロー

  1. OGP画像URLへアクセス(SNSでのシェアや直接アクセス)
  2. Cloudflare Workersが起動
  3. R2キャッシュの確認
    • キャッシュあり: R2から画像を返して終了(爆速&低コスト)
    • キャッシュなし: 次のステップへ
  4. microCMSから記事情報を取得(タイトル、カテゴリ色、IDなど)
  5. Cloudinaryで画像を動的生成
    • R2にある素材画像(ベース画像・1px画像)とフォントを組み合わせて合成
  6. 生成画像をR2に保存(次回のアクセスのためにキャッシュ)
  7. 画像をレスポンスとして返却

キャッシュ削除のフロー(Webhook)

  • microCMSでの記事更新・削除を検知
  • Workersの削除専用エンドポイントを実行
  • HMAC署名検証でリクエストの正当性を確認
  • R2上の該当キャッシュ画像を削除(これで次回のアクセス時に新デザインで再生成される)

まずはCloudinary+Cloudflare R2とCloudflare Imagesの比較

はじめはCloudflareでまとめようと画像の加工にCloudflare Imagesを使おうと思いました。

しかしCloudflare Imagesはリサイズや圧縮には優れていますがレイヤーの重ね合わせや、動的なテキスト合成の自由度が低かったため運用の柔軟性とコストを考え、今回はCloudinary+R2を採用することにしました。

比較項目

Cloudinary+Cloudflare R2

Cloudflare Images

無料枠

最強。
Cloudinary(25クレジット)
+R2(10GB)+Workers(10万件リクエスト/日)

月5,000回まで。
超えると有料。

加工自由度

非常に高い。
文字入れやレイヤー加工が自由自在。

基本的なリサイズ・圧縮がメイン。

構成

複雑(Workersのコード管理が必要)

シンプル(スイッチ一つで完結)

1. Cloudflare R2の準備

生成した画像のアップロード先のバケットを作成します。

  1. Cloudflareダッシュボードの「ストレージとデータベース>R2オブジェクトストレージ>概要」を開き「バケットを作成」をクリック。
  2. 名前を入力(例:blog-ogp-images)して作成。

2. WorkersとR2の紐付け(バインディング)

2-1. Workersの作成

  1. コンピューティングとAI
  2. Workers & Pages
  3. アプリケーションを作成する
  4. Hello Worldを開始する
  5. Worker nameを入力(例:ogp-generator
  6. (一旦)デプロイ

※後ほどコードを書き換えます。

2-2. バインディングの設定

  1. 作成したWorkersを開く
  2. バインディングを開く
  3. バインディングを追加
  4. R2 バケットを選択しバインディングを追加
  5. Workersで使うR2バケット名の変数名(例:OGP_R2_BUCKET)を入力し、「1. Cloudflare R2の準備」で作成したバケットを選択。
  6. デプロイ

2-3. ランタイムの設定

  1. 設定>ランタイム>互換性フラグ
  2. nodejs_compatを追加

3. Workersのコードを編集(Cloudinaryとの連携)

今回はCloudinaryを画像加工エンジンとして使い、その結果をR2にキャッシュさせる仕組みにしました。

export default {
  async fetch(request, env) {
    // 1. 環境変数のバリデーション
    const validationError = validateEnvironment(env);
    if (validationError) return validationError;

    const SITE_URL = env.SITE_URL;
    const url = new URL(request.url);

    // ---------------------------------------------------------
    // A. Webhookによるキャッシュ削除ロジック
    // ---------------------------------------------------------
    // microCMSなどのWebhook通知を受け取ってR2上の画像を削除します
    if (url.pathname.endsWith("/★delete-cache-endpoint")) {
      let targetId = url.searchParams.get('id'); 

      if (request.method === "POST") {
        const bodyText = await request.text();

        // Webhook署名検証 (Secretが設定されている場合)
        if (env.MICROCMS_WEBHOOK_SECRET) {
          const signature = request.headers.get('X-Microcms-Signature');
          const expected = await createSignature(bodyText, env.MICROCMS_WEBHOOK_SECRET);
          if (signature !== expected) {
            return new Response("Unauthorized", { status: 401 });
          }
        }

        try {
          const body = JSON.parse(bodyText);
          // microCMSのJSON構造からID(slugなど)を抽出
          targetId = body.contents?.new?.publishValue?.★field_name
            || body.contents?.old?.publishValue?.★field_name
            || targetId;
        } catch (e) {
          console.log("JSON parse failed. Using query parameter.");
        }
      }

      if (!targetId) return new Response("ID missing", { status: 400 });

      try {
        const R2_KEY = `${targetId}.png`;
        await env.OGP_R2_BUCKET.delete(R2_KEY);
        return new Response(`Deleted: ${R2_KEY}`, { status: 200 });
      } catch (e) {
        return new Response(`Delete failed: ${e.message}`, { status: 500 });
      }
    }

    // ---------------------------------------------------------
    // B. 画像取得・生成ロジック
    // ---------------------------------------------------------
    const filename = url.pathname.split('/').pop()?.replace('.png', '');

    // 不正なアクセスやルートへのアクセスはブログトップへリダイレクト
    if (!filename || filename === "" || filename === "favicon.ico") {
      return Response.redirect(SITE_URL, 301);
    }

    const R2_KEY = `${filename}.png`;

    // 素材画像(背景や1px画像)はそのままR2から返却する
    if (filename === "★base_image_name" || filename === "★dot_image_name") {
      const material = await env.OGP_R2_BUCKET.get(R2_KEY);
      if (material) {
        return new Response(material.body, { headers: { 'Content-Type': 'image/png' } });
      }
      return new Response('Material Not Found', { status: 404 });
    }

    try {
      // 2. R2キャッシュチェック
      const cached = await env.OGP_R2_BUCKET.get(R2_KEY);
      if (cached) {
        return new Response(cached.body, { headers: { 'Content-Type': 'image/png' } });
      }

      // 3. データソース(microCMS等)から記事情報を取得
      const apiRes = await fetch(
        `https://${env.MICROCMS_SERVICE_ID}.microcms.io/api/v1/★endpoint?filters=★field_name[equals]${filename}`,
        { headers: { 'X-MICROCMS-API-KEY': env.MICROCMS_API_KEY} }
      );
      const data = await apiRes.json();

      if (!data.contents || data.contents.length === 0) {
        return Response.redirect(SITE_URL, 302);
      }

      const { title, category } = data.contents[0];
      const themeColor = (category?.[0]?.imgColor || '3d50f5').replace('#', '');

      // 4. Cloudinary URL構築
      const cloudinaryUrl = buildCloudinaryUrl({
        cloudName: env.CLOUDINARY_NAME,
        title,
        themeColor,
        workerDomain: url.host
      });

      // 5. Cloudinaryから画像を取得
      const imageRes = await fetch(cloudinaryUrl);
      if (!imageRes.ok) throw new Error('Cloudinary Error');

      const imageBuffer = await imageRes.arrayBuffer();

      // 6. 次回のためにR2にキャッシュ保存
      await env.OGP_R2_BUCKET.put(R2_KEY, imageBuffer, {
        httpMetadata: { contentType: 'image/png' },
      });

      return new Response(imageBuffer, { headers: { 'Content-Type': 'image/png' } });

    } catch (e) {
      console.error(e.message);
      return Response.redirect(SITE_URL, 302);
    }
  }
};

/**
 * 署名作成 (HMAC-SHA256)
 */
async function createSignature(body, secret) {
  const encoder = new TextEncoder();
  const key = await crypto.subtle.importKey(
    "raw", encoder.encode(secret),
    { name: "HMAC", hash: "SHA-256" },
    false, ["sign"]
  );
  const signed = await crypto.subtle.sign("HMAC", key, encoder.encode(body));
  return Array.from(new Uint8Array(signed))
    .map(b => b.toString(16).padStart(2, "0"))
    .join("");
}

/**
 * 環境変数のチェック
 */
function validateEnvironment(env) {
  const vars = ['OGP_R2_BUCKET', 'API_SERVICE_ID', 'API_KEY', 'CLOUDINARY_NAME', 'SITE_URL'];
  for (const v of vars) {
    if (!env[v]) return new Response(`Missing env var: ${v}`, { status: 500 });
  }
  return null;
}

/**
 * Cloudinaryの変換URLを生成
 */
function buildCloudinaryUrl({ cloudName, title, themeColor, workerDomain }) {
  const baseImageUrl = `https://${workerDomain}/★base_image_name.png`;
  const BASE_IMAGE_B64 = safeBase64(baseImageUrl);
  const encodedTitle = encodeURIComponent(title).replace(/,/g, '%252C');
  const ONE_PIXEL_URL = `https://${workerDomain}/★dot_image_name.png`;
  
  return [
    // Cloudinaryの画像生成URL
  ].join('/');
}

/*
 * Cloudinary用のURLセーフなBase64エンコード
 */
function safeBase64(str) {
  return Buffer.from(str)      // 文字列をBufferに変換
    .toString('base64')        // Base64文字列へ
    .replace(/\+/g, '-')       // URLセーフ化(+ を -)
    .replace(/\//g, '_')       // URLセーフ化(/ を _)
    .replace(/=+$/, '');       // パディング削除
}

4. microCMSのWebhookでキャッシュを自動削除

記事を更新したのにOGPが古いままという事を防ぐため、microCMSのWebhook機能を使って、記事公開・更新時にR2のキャッシュを自動で削除する設定を行います。

4-1. microCMS側の設定

  1. microCMSの管理画面で「API設定」>「Webhook」> 「追加」をクリック
  2. 「カスタム通知」を選択
  3. 以下の項目を入力
    • Webhookの名前
    • URLの入力(Workersの実行エンドポイント)
    • 通知タイミング(実行したいタイミングにチェック、今回はコンテンツの公開と削除にチェック)
    • シークレット(認証用)

4-2. Workersの環境変数を設定

Workers側で、先ほど決めた「シークレット」を環境変数MICROCMS_WEBHOOK_SIGNATUREで登録します。

😞試行錯誤したポイント

👿R2バケットが「ない」と怒られる

Workersにいくつか環境変数を読み込んでいる箇所があり、R2のバケット名も環境変数env.OGP_R2_BUCKETで設定していたところ、バケット名がない・空だ!とエラーがでて進まない...

WorkersでR2を扱う際、バケット名は「環境変数」として登録するのではなく、「バインディング」という設定が必要でした。これをしないとenv.OGP_R2_BUCKETが空(undefined)になり、エラーで進めなくなります。

👿Cloudinaryでカスタムフォントが適用されない

Cloudinaryで「Noto Sans JP」などのデフォルトで指定ができない日本語フォントを使うには、単にMedia Libraryにアップロードするだけでは不十分でした。

使用できるようにするには、管理画面のSettings > Upload > Upload presetsで設定が必要です。

Delivery Typeを「Authenticated」、Access Controlを「Public」設定したプリセットでアップロードしたアセットを使わないと、Workers内のCloudinary画像生成URLで呼び出すことができません。

CloudinaryのURL構築で l_text:★YourFontNameを使う際、フォント名にはAuthenticatedでアップロードした際のPublic IDを指定する必要があります。

📝参考

👿imgixの多重ブレンドをCloudinaryで再現する

imgix(microCMS標準)で作り込んでいたレイヤー加工にも苦戦しました。

もともと使っていたimgixのクエリは、単に文字を載せるだけでなく、背景色と素材画像を高度に合成していました。

🖼️imgixの生成URL

`https://images.microcms-assets.io/assets/.../ファイル名.png?
  w=1200&h=630
  &bg=${color}                // 1. 背景色の指定
  &blend-mode=multiply        // 2. 乗算で背景と合成
  &monochrome=95${color}      // 3. ベース画像を単色化
  &blend64=${base64url(text)} // 4. 文字を乗せる
  &fm=png`

🖼️Cloudinaryの生成URL

return [
  `https://res.cloudinary.com/${cloudName}/image/fetch`,
  `w_1200,h_630,c_fill,b_rgb:${themeColor},e_colorize:100,q_auto`, // 塗り
  `l_fetch:${BASE_IMAGE_B64},w_1200,h_630,c_fill,e_replace_color:${themeColor}:1:979C9F,fl_layer_apply`,  // ベース画像+色置き換え
  `co_rgb:${themeColor},l_text:★YourFontName_50_bold:${encodedTitle},w_1000,c_fit,fl_layer_apply,g_west,x_100`,  // タイトル
  `f_png`,
  ONE_PIXEL_URL  // 1px画像
].join('/');

ロゴ色を一番下の塗りとタイトルの色に合わせるため、ベース画像の中のロゴ色を置き換える必要がありました。

はじめはimgixのときと同じようにグレースケール・乗算プロパティで再現しようと思いましたが、imgixとCloudinaryでは処理が違うようで再現できませんでした。

e_replace_color:${themeColor}:1:979C9F

そこで使用している色が#979C9Fの単色ということもあり、しきい値を1に設定して${themeColor}に置き換えることに成功しました🌟

画像の圧縮

はじめは圧縮設定をしていませんでしたがq_autoを設定することで、画像サイズが22KB→9.59KBまで圧縮されました。

👿キャッシュ削除が動かない?

カスタムドメイン経由のWebhookだと削除が動かない現象がありました。

おそらくCloudflareのWAFなどがmicroCMSからのリクエストをブロックしていた可能性があります。Workersの初期ドメイン(*.workers.dev)を通知先にすることで解決しました。

最後にデバッガーでOGPチェック

WorkersのプレビューやOGP画像のURLにアクセスすると問題なく生成されることを確認できました。

普段使うチェッカーやデバッガーでも確認すると、生成されるものとされないものが...チェッカーツールでは初回生成時の処理時間によるタイムアウトや、Workers内でのリダイレクト処理が影響しているのかもしれません。

👏自動生成される

  • X(旧Twitter)のポスト
  • Facebookシェアデバッガー
  • microCMSのリッチエディタ内の埋め込み

自動生成されない

  • OGP画像確認|ラッコツールズ
  • Chrome拡張ツール:Alt & Meta Viewer

まとめ

設定の罠にハマり、何度もURLを書き換えた苦労はありましたが、その分、既存のツールでは届かない「かゆいところに手が届く」自分専用の機能を作ることができました。

OGPのURLを短縮しつつ、Cloudflare R2やCloudinaryといった初めて使用するサービスを組み合わせることができ良い経験となりました🙌

シェア