kokh log

yumikokhの開発日記

SSGサイトのInstagram連携をCloudflareだけで完結させた

背景

Astro(SSG)で構築したコーポレートサイトに、Instagram の最新投稿を表示していました。ビルド時に Instagram Graph API から投稿を取得して、画像 URL を HTML に埋め込む仕組みです。

しばらくすると画像が表示されなくなりました。Instagram CDN の画像 URL には有効期限があり、数日で失効します。SSG はビルド時に URL を静的に焼き込むので、時間が経つと壊れたリンクだけが残ってしまいます。

再ビルドすれば一時的に直りますが、根本的な解決にはなりません。

さらに、Instagram Graph API のアクセストークンにも有効期限があります。長期トークンでも最大60日で失効するため、定期的にリフレッシュしないと API 自体が叩けなくなります。画像 URL の永続化に加えて、トークンの自動更新も必要でした。

トークンの取得

Instagram Graph API を利用するには、対象の Instagram アカウントと Meta Developer アプリの設定が必要です。

認証方式の選択

Instagram Graph API には2つの認証方式があります。

  • Instagram API with Facebook Login — Facebook ページとの連携が必要。ページ管理やインサイト等の高度な機能が使える
  • Instagram API with Instagram Login — Facebook ページ不要。投稿の取得やメディア管理など基本的な機能に対応

今回は投稿画像の取得だけが目的なので、Facebook ページ連携が不要な Instagram Login 方式を使いました。

Meta Developer アプリの設定

  1. Meta Developer Dashboard でアプリを作成
  2. アプリに「Instagram」プロダクトを追加
  3. 「Instagram ログインによる API 設定」画面で対象の Instagram アカウントを接続

短期トークンの生成

Meta Developer Dashboard → アプリ → 左サイドバー「Instagram」を開くと、接続済みの Instagram アカウントが一覧で表示されます。「トークンを生成」ボタンをクリックすると短期トークン(約1時間有効)が発行されます。

長期トークンへの交換

短期トークンのままでは使えないので、長期トークンに交換します。

curl "https://graph.instagram.com/access_token?\
grant_type=ig_exchange_token&\
client_secret={App Secret}&\
access_token={短期トークン}"

レスポンスの access_token が60日有効の長期トークンです。アクセストークンデバッガーで有効期限を確認できます。

動作確認

curl "https://graph.instagram.com/me/media?\
fields=id,caption,media_type&\
access_token={長期トークン}"

投稿一覧が返ってくれば、トークンは正しく取得できています。この長期トークンを Worker の初期トークンとして設定し、以降は cron で自動リフレッシュされます。

全体構成

最終的な構成はこのようになりました。

┌──────────────────────────────────────────────────┐
│  Astro SSG(Cloudflare Pages)                    │
│                                                    │
│  ビルド時:                                         │
│  1. Worker /feed → Instagram Graph API             │
│  2. 画像を R2 にアップロード(既存ならスキップ)      │
│  3. 不要画像を R2 から削除                          │
│  4. R2 の永続 URL で HTML に埋め込み                │
└──────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────┐
│  Cloudflare Worker(instagram-proxy)              │
│                                                    │
│  GET /feed   → KV キャッシュ or Instagram API      │
│  cron        → 月2回トークンリフレッシュ(KV保存)   │
└──────────────────────────────────────────────────┘

┌───────────────┐  ┌───────────────┐
│  Cloudflare   │  │  Cloudflare   │
│  R2           │  │  KV           │
│  画像保存     │  │  トークン/    │
│  (永続URL)  │  │  フィードキャッシュ│
└───────────────┘  └───────────────┘

使っている Cloudflare サービスは4つです。

  • Pages — サイトホスティング(SSG ビルド + デプロイ)
  • Workers — Instagram API プロキシ + トークン管理
  • R2 — 画像の永続ストレージ(S3 互換)
  • KV — アクセストークンとフィードのキャッシュ

R2 への画像キャッシュ

ビルド時に Instagram API から取得した画像を R2 にコピーして、HTML には R2 の URL を埋め込みます。

// r2.ts
import { HeadObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";

const s3 = new S3Client({
  region: "auto",
  endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
  credentials: { accessKeyId, secretAccessKey },
});

export async function uploadImageToR2(
  imageUrl: string,
  key: string,
): Promise<string | null> {
  // 既に R2 にあればスキップ
  try {
    await s3.send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
    return `${publicUrl}/${key}`;
  } catch {
    // 存在しない → アップロードに進む
  }

  const res = await fetch(imageUrl);
  if (!res.ok) return null;

  await s3.send(
    new PutObjectCommand({
      Bucket: bucket,
      Key: key,
      Body: Buffer.from(await res.arrayBuffer()),
      ContentType: res.headers.get("content-type") || "image/jpeg",
    }),
  );

  return `${publicUrl}/${key}`;
}

キーは instagram/{account}/{postId}.jpg の形式です。投稿 ID ベースなので、同じ画像を二重にアップロードすることはありません。

ビルドのたびに R2 には現在の投稿に対応する画像だけが残るよう、不要になった画像も削除しています。ListObjectsV2 で prefix 配下のキーを列挙して、現在のアクティブなキーセットに含まれないものを DeleteObjects で一括削除します。

Worker によるトークン自動リフレッシュ

Instagram の長期アクセストークンは60日で失効します。手動で更新し続けるのは現実的ではないので、Cloudflare Worker の cron trigger で自動化しました。

# wrangler.toml
[triggers]
crons = ["0 0 1,15 * *"]  # 毎月1日・15日 00:00 UTC

Worker の scheduled ハンドラがトークンリフレッシュ API を叩いて、新しいトークンを KV に保存します。

async function refreshToken(env: Env): Promise<void> {
  const token = await getToken(env);
  if (!token) return;

  const url = `https://graph.instagram.com/refresh_access_token?grant_type=ig_refresh_token&access_token=${token}`;
  const res = await fetch(url);
  if (!res.ok) return;

  const json = (await res.json()) as { access_token: string };
  await env.INSTAGRAM_KV.put("instagram_token:main", json.access_token);
}

初回のトークンだけは手動で wrangler secret put で設定します。以降は KV に保存されたトークンを cron が自動でリフレッシュし続けてくれます。

Worker の /feed エンドポイント

Worker は Instagram API のプロキシも兼ねています。GET /feed でフィードを取得して、結果を KV に6時間キャッシュします。Astro のビルド時はこのエンドポイントを叩くだけで済みます。

ビルドのたびに Instagram API を直接叩くとレートリミットが気になりますが、KV キャッシュを挟むことで API コール数を抑えられます。

Cloudflare の設定

コード以外に必要な Cloudflare 側の設定をまとめます。

R2 バケット

  1. Cloudflare Dashboard → 「R2 オブジェクトストレージ」→「バケットを作成」
  2. バケット名を入力して作成
  3. 作成したバケット →「設定」→「パブリック開発 URL」を有効化

有効化すると https://pub-{hash}.r2.dev 形式のパブリック URL が発行されます。これが R2_PUBLIC_URL になります。

R2 API トークン

  1. 「R2 オブジェクトストレージ」→ Overview ページ右側の「Account Details」→「API Tokens」横の「Manage」
  2. 「Create API Token」→ 権限を「Object Read & Write」、バケットを作成したものに限定して作成
  3. 表示される Access Key IDSecret Access Key を控える(この画面を閉じると再表示できません)

KV namespace

Worker のトークン・フィードキャッシュ用に KV namespace を作成します。

cd workers/instagram-proxy
npx wrangler kv namespace create INSTAGRAM_KV

出力される idwrangler.toml に設定します。

[[kv_namespaces]]
binding = "INSTAGRAM_KV"
id = "{発行された ID}"

Worker のデプロイ

# 初回トークンを secret として設定
npx wrangler secret put INSTAGRAM_INITIAL_TOKEN
# プロンプトが出るので長期トークンを貼り付け

# デプロイ
npx wrangler deploy

環境変数一覧

Astro ビルド(Cloudflare Pages)に必要な環境変数です。Cloudflare Dashboard → Pages → プロジェクト →「設定」→「環境変数」で設定します。

変数名 値の例 説明
INSTAGRAM_WORKER_URL https://xxx.workers.dev Worker のエンドポイント URL
R2_ACCOUNT_ID effd8f1e... Cloudflare アカウント ID(Dashboard URL から取得)
R2_ACCESS_KEY_ID d1c496... R2 API トークンの Access Key ID
R2_SECRET_ACCESS_KEY 4096a7... R2 API トークンの Secret Access Key
R2_BUCKET_NAME my-assets R2 バケット名
R2_PUBLIC_URL https://pub-{hash}.r2.dev R2 パブリック URL

おわりに

Cloudflare の無料枠だけで、Instagram 画像の永続化とトークン管理が完結しました。R2 は10GB/月まで無料、Workers は10万リクエスト/日まで無料なので、個人サイトやコーポレートサイト程度の規模なら費用はかかりません。

SSG + 外部画像 CDN の組み合わせでは、URL の有効期限に注意が必要です。Instagram に限らず、同様の問題が起きるサービスでは R2 / S3 への中間キャッシュが有効な手段になります。