Joifup Blog

Next.js × Notion API環境における画像表示の課題と本格対応(Webhook × GCS編)

2025-06-15

この記事では、Notion APIを使ったブログで発生した「画像が表示されなくなる問題」に対し、WebhookとGoogle Cloud Storageを活用した新たな解決策を提案し、画像の永続化を実現するアーキテクチャを構築した経緯について詳しく解説します。

Next.jsNotionGoogle CloudTerraform

はじめに

この記事では、Notion APIを使ったブログで発生する「画像が表示されなくなる問題」に対して、Webhook × Google Cloud Storage(GCS)で本格対応した話を書いていきます。

前回のISR編では暫定対応としてISRを導入しましたが、結局それでも画像が切れる問題は解消しきれませんでした。今回はその続きとして、Webhookで画像更新を検知してGCSに永続化するアーキテクチャを構築しています。

Notion APIで画像問題に困っている人、ISRで対応したけどまだ問題が残っている人向けです。

ISR暫定対応の何がダメだったか

前回の記事でISR(Incremental Static Regeneration)を導入して、定期的にページを再生成することで画像URLを更新する方式を採用しました。

でも、これには限界がありました。

問題1: アクセスがないと再生成されない

ISRは「アクセスがあったタイミングで、前回の生成から一定時間経過していたら再生成」という仕組みです。つまり、誰もアクセスしない記事は再生成されません。

うちのブログは正直アクセスが多くないので、古い記事は普通に画像が切れたままになることがありました。

問題2: 再生成のタイミングと画像期限のズレ

Notionの署名付きURLは1時間で切れます。ISRのrevalidate間隔を1時間以下にすれば理論上は大丈夫ですが、実際にはアクセスタイミング次第で切れることがあります。

問題3: そもそもの設計思想がズレている

ISRは「コンテンツの更新を反映する」ための仕組みであって、「外部リソースのURL有効期限を延命する」ためのものじゃないです。無理やり使っている感が否めませんでした。

結局、根本的に解決するには画像自体を自前で永続化するしかないという結論に至りました。

Webhook × GCS方式の設計

アーキテクチャ概要

考えた方式はこうです:

  1. Notionでページが更新されたらWebhookで通知を受ける
  2. 通知を受けたらNotionから画像をダウンロード
  3. Google Cloud Storageにアップロードして永続化
  4. ブログはGCSのURLを参照する

なぜWebhook + GCSを選んだか

Webhookを選んだ理由

  • Notionが公式でWebhookをサポートしている
  • ポーリングより効率的(更新があったときだけ処理が走る)
  • リアルタイム性が高い

GCSを選んだ理由

  • 仕事でGoogle Cloudを使っていて慣れていたため
  • Terraformで管理できる

処理フロー

画像アップロード(Notion更新時):

Notion更新
    ↓ Webhook
Cloud Run(Next.js API Route)
    ↓ 画像ダウンロード & アップロード
GCS

画像配信(読者アクセス時):

読者 → Cloud Run(HTML取得)
読者 → Cloud CDN(cdn.blog.joifup.com) → GCS(キャッシュになければ取得)

実装

1. TerraformでGCSバケット作成

まずGCSバケットをTerraformで作成しました。

resource "google_storage_bucket" "notion_assets" {
  name     = "joifup-blog-assets"
  location = var.location

  uniform_bucket_level_access = true
}

resource "google_storage_bucket_iam_binding" "public_access" {
  bucket = google_storage_bucket.notion_assets.name

  role    = "roles/storage.objectViewer"
  members = ["allUsers"]
}

uniform_bucket_level_access = true にして、バケットレベルでアクセス制御する形にしています。

2. Webhook受信エンドポイント(Next.js API Route)

NotionのWebhook通知を受けるエンドポイントをNext.js API Routeで作成しました。Cloud Runにデプロイして動かしています。

// app/api/webhooks/notion-images/route.ts
import { NextRequest, NextResponse } from "next/server";
import { uploadImage } from "@/lib/gcs/uploadImage";
import { getNotionPageWithBlocks } from "@/lib/notion";

export async function POST(req: NextRequest) {
    const body = await req.json();
    const pageId = body?.entity?.id;
    const eventType = body?.type;

    if (!pageId || !eventType) {
        return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
    }

    try {
        const { post, blocks } = await getNotionPageWithBlocks(pageId);
        const slug = post.slug;
        const coverUrl = post.coverImage;
        const published = post.published;

        if (!slug || !published) {
            console.log("Slug missing or post not published. Skipping.");
            return NextResponse.json({ ok: true });
        }

        // プロパティ更新時はカバー画像を処理
        if (eventType === "page.properties_updated" && coverUrl) {
            await uploadImage(coverUrl, `${slug}/cover.jpg`, { skipIfExists: false });
        }

        // コンテンツ更新時は本文中の画像を処理
        if (["page.created", "page.content_updated"].includes(eventType)) {
            for (const block of blocks) {
                if (block.type === "image" && block.image.type === "file") {
                    const url = block.image.file.url;
                    const blockId = block.id.replace(/-/g, "");
                    await uploadImage(url, `${slug}/${blockId}.jpg`, { skipIfExists: true });
                }
            }
        }

        return NextResponse.json({ status: "ok" });
    } catch (error) {
        console.error("[notion webhook error]", error);
        return NextResponse.json({ error: "Internal server error" }, { status: 500 });
    }
}

ポイントは、イベントタイプによって処理を分けているところです:

  • page.properties_updated: カバー画像の更新
  • page.created, page.content_updated: 本文中の画像

3. GCSアップロード処理

// lib/gcs/uploadImage.ts
import { Storage } from "@google-cloud/storage";

const storage = new Storage();
const bucket = storage.bucket(process.env.GCS_BUCKET_NAME!);

export async function uploadImage(
    url: string,
    destinationPath: string,
    options: { skipIfExists?: boolean } = {}
) {
    const file = bucket.file(destinationPath);

    // 既存ファイルがあればスキップ
    if (options.skipIfExists) {
        const [exists] = await file.exists();
        if (exists) {
            console.log(`Skipped: gs://${bucket.name}/${destinationPath}`);
            return;
        }
    }

    const res = await fetch(url);
    if (!res.ok) {
        throw new Error(`Failed to fetch image: ${res.status} ${res.statusText}`);
    }

    const buffer = Buffer.from(await res.arrayBuffer());
    const contentType = res.headers.get("content-type") ?? "application/octet-stream";

    await file.save(buffer, {
        contentType,
        metadata: {
            contentType: "image/jpeg",
            cacheControl: "public, max-age=31536000",
        },
        resumable: false,
    });

    console.log(`Uploaded: gs://${bucket.name}/${destinationPath}`);
}

skipIfExistsオプションで、既にアップロード済みの画像は再アップロードしないようにしています。本文中の画像は基本的に変更されない前提とし、無駄な処理を減らせます。

4. Cloud CDNで配信を高速化

GCSから直接配信するよりCDNを挟んだほうが速いので、Cloud CDNも追加しました。

# terraform/modules/cloud_cdn/main.tf
resource "google_compute_global_address" "cdn_ip" {
  project = var.project_id
  name    = "cdn-ip-address"
}

resource "google_compute_managed_ssl_certificate" "cdn_cert" {
  project = var.project_id
  name    = "cdn-ssl-certificate"
  managed {
    domains = [var.cdn_domain_name]
  }
}

resource "google_compute_backend_bucket" "image_backend_bucket" {
  project     = var.project_id
  name        = "image-backend-bucket"
  bucket_name = var.image_bucket_name
  enable_cdn  = true
}

resource "google_compute_url_map" "cdn_url_map" {
  project         = var.project_id
  name            = "cdn-url-map"
  default_service = google_compute_backend_bucket.image_backend_bucket.id
}

resource "google_compute_target_https_proxy" "cdn_proxy" {
  project          = var.project_id
  name             = "cdn-https-proxy"
  url_map          = google_compute_url_map.cdn_url_map.id
  ssl_certificates = [google_compute_managed_ssl_certificate.cdn_cert.id]
}

resource "google_compute_global_forwarding_rule" "cdn_forwarding_rule" {
  project      = var.project_id
  name         = "cdn-forwarding-rule"
  target       = google_compute_target_https_proxy.cdn_proxy.id
  ip_address   = google_compute_global_address.cdn_ip.address
  port_range   = "443"
  ip_protocol  = "TCP"
}

cdn.blog.joifup.com というドメインでCDN経由の配信を設定しています。

5. ブログ側の画像URL参照

ブログ側では、環境変数でCDNのURLを設定して参照するようにしました。

// lib/config.ts
export const IMAGE_BASE_URL =
  process.env.NEXT_PUBLIC_IMAGE_BASE_URL ||
  "<https://storage.googleapis.com/joifup-blog-assets>";
// 画像URLの組み立て
import { IMAGE_BASE_URL } from "@/lib/config";
const imageUrl = `${IMAGE_BASE_URL}/${slug}/${blockId}.jpg`;

本番環境では NEXT_PUBLIC_IMAGE_BASE_URL=https://cdn.blog.joifup.com を設定しています。

ハマったポイント

1. public: trueオプションがエラーになる

最初、GCSアップロード時にpublic: trueオプションを付けていたらエラーになりました。

// これがエラーになった
await file.save(buffer, {
    contentType,
    public: true,  // ← これ
    resumable: false,
});

原因は、バケットにuniform_bucket_level_access = trueを設定していたからです。Uniform bucket-level accessを有効にすると、個別のオブジェクトにACLを設定できなくなります。

バケットレベルでallUsersobjectViewer権限を付与しているので、public: trueは不要でした。削除して解決。

2. サービスアカウントの権限不足

Cloud RunからGCSにアップロードしようとしたら権限エラー。Cloud Runのサービスアカウントにroles/storage.objectAdminを追加する必要がありました。

module "cloudrun_sa" {
  source = "../../modules/service_account"
  roles = [
    "roles/run.admin",
    "roles/cloudsql.client",
    "roles/logging.logWriter",
    "roles/monitoring.metricWriter",
    "roles/storage.objectAdmin",  // ← これを追加
  ]
}

3. 下書き記事のWebhookも発火する

Webhookは公開・非公開関係なく発火します。最初はそのまま処理していましたが、下書き記事のたびに処理が走るのは無駄なので、publishedチェックを追加しました。

if (!slug || !published) {
    console.log("Slug missing or post not published. Skipping.");
    return NextResponse.json({ ok: true });
}

4. Webhookの発火頻度

これは想定通りでしたが、やっぱりWebhookは結構な頻度で発火します。画像のようなイベント項目のため、ちょっとした編集でも飛んできます。skipIfExistsで既存ファイルをスキップする処理を入れておいてよかったです。

結果と残った課題

良かった点

  • 画像が切れなくなりました(永続化されているので当然ですね)
  • Notionで画像を更新したら自動で反映されます
  • ISRのときのような「運任せ感」がなくなりました

残った課題

コストの問題

月額約2,800円くらいかかるようになりました。内訳としてはCloud Run、GCS、Cloud CDN、その他もろもろです。

正直、個人ブログにしてはちょっと高いです。もっとシンプルな構成でよかったかもしれません。

まとめ

  • ISRでの暫定対応には限界があり、画像の永続化が必要でした
  • Webhook × GCS × Cloud CDNで画像を自動永続化するアーキテクチャを構築しました
  • Uniform bucket-level accessとの競合、サービスアカウント権限など、細かいハマりポイントがありました
  • 動作はするようになりましたが、コスト面を考えるともっとシンプルな構成でよかったかも

個人ブログにしてはちょっとオーバーエンジニアリングだった気もしますが、Google Cloudの各サービスを組み合わせる良い経験にはなりました。

今後はもう少しシンプルな構成も検討してみたいと思います。