OGP画像 Worker + R2キャッシュ 実装完了
OGP画像はビルド時に生成しない。アクセス時にオンデマンド生成し、R2にキャッシュする。
1. ユーザーがTwitterでブログURLをシェア
2. Twitterクローラーが og:image URL(Worker経由)にアクセス
3. Worker が画像を生成 → R2に保存 → 返却
4. 次回以降はR2から即座に返却(キャッシュHIT)
これにより、ビルド時の画像生成が不要になり、ビルド時間が大幅に短縮される。
- ビルド時間の増加: 記事数に比例してビルド時間が増加する
- 471記事 → 総ビルド時間7分(うちOGP生成に約3分)
- 1000記事 → 推定14分
- 無駄な再生成: 変更のない記事のOGP画像も毎回再生成される
- ビルド時間を記事数に関係なく一定にする(4分以下)
- 変更があった記事のOGPのみ再生成
- 新記事は再ビルド不要で即OGP利用可能
Nuxt (静的ビルド)
└─ nuxt-og-image
└─ ビルド時に全記事のOGP画像をプリレンダリング(471枚 → 約3分)
Nuxt (静的ビルド) OG Worker
│ │
│ og:image URL を Worker に向ける │
└────────────────────────────────────►│
│
▼
┌─────────────────┐
│ R2にキャッシュ? │
└────────┬────────┘
│
┌──────────────┴──────────────┐
│ │
▼ ▼
[HIT] R2から返す [MISS] 生成→R2保存→返す
import { ImageResponse, loadGoogleFont } from 'workers-og'
interface Env {
OG_IMAGES: R2Bucket
OG_SECRET: string
}
// 正規化(Nuxt側と同じロジック)
function normalizeText(text: string): string {
return text.trim().replace(/\s+/g, ' ')
}
function normalizePath(path: string): string {
return path.replace(/^\/+/, '')
}
// 署名検証
async function verifySignature(
params: { path: string; version: string; title: string; author: string },
signature: string,
secret: string
): Promise<boolean> {
const encoder = new TextEncoder()
const payload = [
normalizePath(params.path),
params.version,
normalizeText(params.title),
normalizeText(params.author)
].join('|')
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
)
const sig = await crypto.subtle.sign('HMAC', key, encoder.encode(payload))
const expected = Array.from(new Uint8Array(sig))
.map(b => b.toString(16).padStart(2, '0'))
.join('')
.slice(0, 16)
return expected === signature
}
// サニタイズ
function sanitize(text: string, maxLen: number): string {
const escaped = text
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
return escaped.length > maxLen ? escaped.slice(0, maxLen - 3) + '...' : escaped
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url)
// 既存: 日本語クイズOGP
if (url.pathname === '/og/japanese-quiz') {
return handleJapaneseQuizImage(url)
}
if (url.pathname === '/share/japanese-quiz') {
return handleSharePage(url)
}
// 新規: ブログ記事OGP
if (url.pathname.startsWith('/og/blog/')) {
return handleBlogOgImage(url, env)
}
return new Response('Not Found', { status: 404 })
},
}
async function handleBlogOgImage(url: URL, env: Env): Promise<Response> {
const match = url.pathname.match(/^\/og\/blog\/(.+)\.png$/)
if (!match) {
return new Response('Invalid path', { status: 400 })
}
const articlePath = match[1]
const signature = url.searchParams.get('sig')
const version = url.searchParams.get('v') || 'v1'
const title = url.searchParams.get('title') || articlePath
const author = url.searchParams.get('author') || 'Kei Komatsu'
// 署名検証
if (!signature || !(await verifySignature({ path: articlePath, version, title, author }, signature, env.OG_SECRET))) {
return new Response('Forbidden', { status: 403 })
}
// R2キャッシュ確認
const cacheKey = `blog/${articlePath}/${version}.png`
const cached = await env.OG_IMAGES.get(cacheKey)
if (cached) {
return new Response(cached.body, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=31536000, immutable',
'X-OG-Cache': 'HIT'
}
})
}
// 生成
const safeTitle = sanitize(title, 80)
const safeAuthor = sanitize(author, 30)
const imageResponse = await generateBlogOgImage(safeTitle, safeAuthor)
const imageBuffer = await imageResponse.arrayBuffer()
// R2保存
await env.OG_IMAGES.put(cacheKey, imageBuffer, {
httpMetadata: { contentType: 'image/png' }
})
return new Response(imageBuffer, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=31536000, immutable',
'X-OG-Cache': 'MISS'
}
})
}
async function generateBlogOgImage(title: string, authorName: string): Promise<ImageResponse> {
const fontData = await loadGoogleFont({
family: 'Noto Sans JP',
weight: 700,
text: title + authorName + 'Eurekapu.com'
})
const html = `
<div style="display: flex; width: 1200px; height: 630px; justify-content: center; align-items: center; padding: 40px; background: linear-gradient(135deg, #ff0080 0%, #0066ff 100%); font-family: Noto Sans JP, sans-serif;">
<div style="display: flex; flex-direction: column; justify-content: space-between; width: 1100px; height: 530px; background: white; border-radius: 24px; padding: 50px 60px; box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);">
<div style="display: flex; flex: 1; align-items: center; justify-content: center; font-size: 52px; font-weight: 700; color: #1a1a1a; line-height: 1.4; text-align: center;">
${title}
</div>
<div style="display: flex; justify-content: space-between; align-items: center;">
<div style="display: flex; font-size: 24px; color: #444; font-weight: 500;">
${authorName}
</div>
<div style="display: flex; font-size: 24px; color: #888; font-weight: 500;">
Eurekapu.com
</div>
</div>
</div>
</div>
`
return new ImageResponse(html, {
width: 1200,
height: 630,
fonts: [{ name: 'Noto Sans JP', data: fontData, weight: 700, style: 'normal' }]
})
}
name = "og-image-worker"
main = "src/index.ts"
compatibility_date = "2024-12-01"
compatibility_flags = ["nodejs_compat"]
workers_dev = true
routes = [
{ pattern = "log.eurekapu.com/share/*", zone_name = "eurekapu.com" },
{ pattern = "log.eurekapu.com/og/blog/*", zone_name = "eurekapu.com" }
]
[[r2_buckets]]
binding = "OG_IMAGES"
bucket_name = "og-images"
# OG_SECRET は wrangler secret put で設定
import { createHmac } from 'crypto'
interface OgSignatureParams {
path: string
version: string
title: string
author: string
}
function normalizeText(text: string): string {
return text.trim().replace(/\s+/g, ' ')
}
function normalizePath(path: string): string {
return path.replace(/^\/+/, '')
}
export function generateOgSignature(params: OgSignatureParams): string {
const secret = process.env.OG_SECRET
if (!secret) {
throw new Error('OG_SECRET is not set. OGP signing is disabled.')
}
const payload = [
normalizePath(params.path),
params.version,
normalizeText(params.title),
normalizeText(params.author)
].join('|')
return createHmac('sha256', secret).update(payload).digest('hex').slice(0, 16)
}
import { generateOgSignature } from '~/server/utils/og-signature'
const authorName = 'Kei Komatsu'
const signature = generateOgSignature({
path,
version: updatedAt,
title,
author: authorName
})
const ogImageUrl = `https://log.eurekapu.com/og/blog${path}.png?v=${updatedAt}&title=${encodeURIComponent(title)}&author=${encodeURIComponent(authorName)}&sig=${signature}`
useHead({
meta: [
{ property: 'og:image', content: ogImageUrl },
{ property: 'og:image:width', content: '1200' },
{ property: 'og:image:height', content: '630' },
{ name: 'twitter:card', content: 'summary_large_image' },
{ name: 'twitter:image', content: ogImageUrl },
]
})
- R2バケット作成(
wrangler r2 bucket create og-images) - wrangler.toml にR2バインディングとルート追加
-
wrangler secret put OG_SECRET でシークレット設定 - Worker実装(ブログOGP生成 + R2キャッシュ + 署名検証)
- ローカルテスト(
wrangler dev --remote) - Workerデプロイ
- Nuxt側に
OG_SECRET 環境変数を設定(.env) -
app/composables/useOgSignature.ts 作成(サーバーユーティリティではなくcomposableに変更) - nuxt-og-imageは維持(ブログ以外の44ページで使用)
-
[...slug].vue のog:image URL変更(useAsyncDataで非同期生成) - ビルドテスト → 2分12秒で成功
- 本番デプロイ・動作確認
- GitHub Secretsに
OG_SECRET を設定(CI用)
| 項目 | Before | After | 改善率 |
|---|
| ビルド時間 | 7分(471記事) | 2分12秒 | 68%削減 |
| 新記事のOGP | 再ビルド必要 | 即座に利用可能 | - |
| 記事更新時 | 全OGP再生成 | 該当記事のみ再生成 | - |
- Client build: 37.3秒
- Server build: 24.7秒
- Prerender (1226 routes): 56.3秒
- nuxt-og-imageはブログ記事以外の44ページで引き続き使用
- ブログ記事(
[...slug].vue)のみWorker経由でOGP生成
| 指摘 | 重要度 | 対応内容 |
|---|
| クライアント遷移時OG上書き | 中 | useAsyncDataにserver: trueオプション追加。クライアント側での再実行を抑止 |
| 非ASCIIパス未エンコード | 低 | パスをencodeURIComponentでエンコード |
| settings.local.json | 低 | コミットから除外 |
| 指摘 | 重要度 | 理由 |
|---|
| フォント毎回取得 | 中 | R2キャッシュがあるため、フォント取得は記事ごとに初回のみ。2回目以降はR2から画像を返すためgenerateBlogOgImage()自体が呼ばれない |
| ポイント | 内容 |
|---|
| キャッシュキー | blog/${articlePath}/${version}.png - バージョンを含めて自動無効化 |
| 署名対象 | path|v|title|author - 全パラメータを署名してURL改ざん防止 |
| 正規化 | パス: 先頭スラッシュ除去 / テキスト: trim + 連続空白→単一スペース |
| サニタイズ | HTMLエスケープ + 長さ制限(title: 80文字, author: 30文字) |
| OGメタタグ | og:image と twitter:image 両方を明示的に設定 |
| 運用前提 | コンテンツ変更時は updatedAt を必ず更新(CI検知推奨) |
- R2コスト: 無料枠10GB/月。OGP画像1枚約50KB、1000記事で約50MB。十分収まる。
- 初回遅延: キャッシュなし初回は1-2秒かかる。SNSクローラーには影響なし。
- フォント: 初回にGoogleフォント読み込みでコールドスタート遅延あり。