TL;DR
- 静的サイトを
aws s3 syncだけで配信した OG 画像には Cache-Control ヘッダーが付かない - X (Twitter) の image proxy はこれを「キャッシュ不可」と判定し、画像採用を保留する
- 結果、メタタグが完璧でも Tweet Composer に貼ると
summary_large_image指定なのに小カード(summary風)に「降格」表示される - 解決: deploy スクリプトに
aws s3 cp --recursive --metadata-directive REPLACE --cache-control "public, max-age=604800"を1行足すだけ - 反映直後から即時に大カード化した
なぜ書くか
「X で OGP が出ない」「Twitter Card が表示されない」系の記事は山ほどある。が、ほぼ全部が メタタグの書き方 の話で終わる。
- og:image を絶対URLで書け
- twitter:card は summary_large_image
- 画像は1200x630 / HTTPS / 5MB以下
この辺の項目は私のサイトでも全部クリアしていた。第三者OGP検証ツール(ogp.buta3.net)でも Summary (Large) で正しくレンダリングされる。Twitter Card Validator のログも Card loaded successfully / twitter:card = summary_large_image tag found。
それでも Tweet Composer に貼ると小カード化する。4時間ハマった末に見つけた原因が CDN/オリジンの Cache-Control ヘッダー欠落 だった。
WordPress + 標準ホスティングのユーザーはこれを踏まない。Apache/nginx のデフォルト設定で wp-content/uploads/ 配下に Cache-Control が自動で付くからだ。S3 + CloudFront を素で組んだ静的サイトだけがこの落とし穴を踏む。情報が出てこないのも納得で、ハマる人の母数自体が少ない。
同じ構成(静的サイト+S3+CloudFront)でハマっている人がもしいたら、ピンポイントで救えるはずなので残しておく。
やったこと(背景の最小限)
https://microapps.one/tool-forest/ という個人運営のブラウザ完結ツール集で、各ページに自動生成 OGP カード(1200x630 PNG)を出す仕組みを実装していた。
技術スタック:
- ビルド: 自前 build.js(EJS + Tailwind)
- OGP生成: satori + @resvg/resvg-js
- フォント: Noto Sans JP(静的TTF を
@expo-google-fonts/noto-sans-jpから取り出し) - 配信: S3 + CloudFront
- デプロイ:
aws s3 sync dist/ s3://microapps.one --delete
実装自体は数時間で動いた。デザインも問題ない。dist/og/tool-forest/image/color-variation.png が 1200x630 / 40KB / RGB で吐かれている。
問題はここから。
チェックリストは全部 ✓ なのにダメ
X の OGP トラブルシューティング記事の典型チェックリストを全部潰したが、どれも該当しない。
| 既存記事の典型チェック項目 | 自分のサイトの状態 |
|---|---|
og:image が絶対URL(https://...) |
✓ |
twitter:card = summary_large_image |
✓ |
og:image:width / height 指定 |
✓ 1200 / 630 |
twitter:title / description / image / image:alt |
✓ 全部出力 |
| 画像が HTTPS でアクセス可能 | ✓ HTTP/2 200 |
| 画像サイズ 1200x630 以上 | ✓ ちょうど 1200x630 |
| ファイルサイズ < 5MB | ✓ 約 40KB |
| Twitterbot UA で200 OK | ✓ curl -A "Twitterbot/1.0" で確認済 |
| robots.txt が Twitterbot 許可 | ✓ User-agent: * / Allow: /
|
| RGB(アルファ無しPNG) | ✓ sharp で flatten 済 |
| 同名メタタグの重複なし | ✓ |
| メタが JS 後付けでない(生 HTML に出力) | ✓ |
?v=N で新規URL化(キャッシュバスター) |
✓ |
それでも Tweet Composer に https://microapps.one/tool-forest/image/color-variation/?v=15 を貼ると、左に汎用プレースホルダーアイコンが入った小カードになる。summary_large_image を明示しているのに、X 側で summary 相当に降格している状態。
?v=16, ?v=17... と試しても結果は同じ。Card Validator も「Card loaded successfully」と返す。原因が掴めない。
第三者OGP検証ツールでは Summary (Large) 判定
切り分けのため第三者の OGP プレビューツールを通してみる。
ogp.buta3.net(OGP確認ツール)で同 URL を解析させたところ、以下のように出た:
- Open Graph protocol: 全項目正常認識
- Twitter カード:
summary_large_image認識 - Preview (Twitter) の Summary (Large) セクションでカードが正しく描画される
つまり メタタグは100%正しい。問題は X 側のレンダラ/キャッシュレイヤーで起きている。
ここまでで切り分けが済んだ。「メタの問題ではない」「画像配信の問題でもない(curl で 200 / image/png / 40KB を確認済)」。残るは X の image proxy が画像を採用するかしないかの判定。
比較で見えた違い
X の image proxy は何を見て「この画像を採用する/しない」を判定しているか。仮説を立てるため、瞬時に大カード化される他サイト(Qiita)と自分のサイトの 画像レスポンスヘッダー を比較した。
# 自分のサイト
$ curl -sSI -A "Twitterbot/1.0" https://microapps.one/og/tool-forest/image/color-variation.png
HTTP/2 200
content-type: image/png
content-length: 40065
date: Sat, 02 May 2026 03:03:37 GMT
last-modified: Sat, 02 May 2026 02:57:49 GMT
etag: "063fa57387f700b97f7e42a06d0e5968"
server: AmazonS3
x-cache: Hit from cloudfront
via: 1.1 ...cloudfront.net (CloudFront)
# Qiita(imgix 配信)
$ curl -sSI <Qiita記事のog:image>
HTTP/2 200
cache-control: public, max-age=31536000, immutable
content-type: image/png
...
違いは1点だけ。
自分のサイトには Cache-Control ヘッダーが完全に無い。
Last-Modified も ETag もある。Content-Type は image/png で正しい。x-cache: Hit from cloudfront でエッジキャッシュも効いている。それでも Cache-Control だけが欠落していた。
仮説と裏付け
X の image proxy(pbs.twimg.com 経由で画像をミラーリングするレイヤー)は、配信元の Cache-Control を見て「ミラーキャッシュ可能か」を判定する挙動が複数の個人開発者ブログで報告されている。公式ドキュメントには明記されていないが、以下と整合する:
- Qiita / Zenn / Twitter自身のCDN(
pbs.twimg.com) などは全てCache-Control: public, max-age=...を返す - 個人ブログ(Hugo on S3, Hexo on S3 等)で同症状を報告している記事が散発する
- WordPress 標準ホスティングはこれを踏まない(Apache/nginx の
mod_expiresやadd_header標準設定で自動付与) - 「ドメイン慣らし期間が必要」と言われている現象の正体の一部は、おそらくこれ
つまり、X 側の挙動はざっくり以下のように動いている (推測)。
Twitterbot がページHTMLをクロール → メタ取得(summary_large_image, og:image)
↓
Image proxy が og:image を別リクエストで取得
↓
レスポンスヘッダーをチェック
- Cache-Control が "public" 系で max-age 十分 → ミラー化、即時大カード採用
- Cache-Control が無い → "キャッシュポリシー不明" として保留扱い
→ Composer 側ではフォールバック描画(小カード風)
↓
時間が経つと別のヒューリスティックで救済される(ことがある)
解決
package.json の deploy スクリプトを1行修正するだけ。
{
"scripts": {
- "deploy": "npm run build && aws s3 sync dist/ s3://microapps.one --delete"
+ "deploy": "npm run build && aws s3 sync dist/ s3://microapps.one --delete && aws s3 cp dist/og/ s3://microapps.one/og/ --recursive --content-type image/png --cache-control \"public, max-age=604800\" --metadata-directive REPLACE"
}
}
ポイントは2つある。
1. aws s3 sync ではメタが上書きされない
aws s3 sync はオブジェクトの 内容ハッシュ を比較し、差分が無ければスキップする。Cache-Control のような メタデータだけ変更 したい場合、sync では既存オブジェクトに反映されない。man にも書いてある仕様だが盲点だった。
→ aws s3 cp --recursive --metadata-directive REPLACE で強制的に再アップロード+メタ上書きする。
2. max-age=604800 の意味
604800秒 = 7日。これは X のカードメタキャッシュ期間(最大7日)と同期させる発想。OGPカードは「ページのメタが変わった時に再生成」される運用なので、長めに取って問題ない。immutable を付けても良いが、生成側のロジックが変わると再生成が必要になるので immutableは付けない のが安全。
結果
deploy 後、CloudFront を /og/* で Invalidate して即確認:
$ curl -sSI -A "Twitterbot/1.0" https://microapps.one/og/tool-forest/image/color-variation.png
HTTP/2 200
content-type: image/png
content-length: 40065
cache-control: public, max-age=604800 # ← これが付いた
date: Sat, 02 May 2026 03:30:12 GMT
...
Tweet Composer に新しい ?v=N で URL を貼ると、投稿前の数秒以内に1200x630の大カードが描画された。それまで1時間以上「小カード」のままだった同じドメイン・同じ画像が、Cache-Control が付いただけで挙動が反転した。
決定的な変化はこの1個だけだった。状況証拠から見て Cache-Control 不在が原因で間違いない。
ハマりポイント補足
WordPress ユーザーが何故これに遭遇しないか
- エックスサーバー / ConoHa WING / SAKURA等の標準ホスティングは
wp-content/uploads/配下に デフォルトでCache-Control: max-age=...を返す Apache/nginx 設定 が入っている - WP プラグインのキャッシュ系(W3 Total Cache / WP Rocket)も media に長期キャッシュを自動設定
- 結果、Cache-Control が ほぼ意識されないまま OGP が動いている
- 静的サイト + S3 + CloudFront を素で組んだ場合だけが「ヘッダーが何も付かない」初期状態を踏む
?v=N クエリ戦略は X のどこに効いて、どこに効かないか
X 側のキャッシュは少なくとも2層に分かれている:
| キャッシュ層 | キー | 期間 |
?v=N で回避できるか |
|---|---|---|---|
| カードメタキャッシュ | ページURL | 最大7日 | できる(新規URLなので再フェッチが走る) |
| 画像 proxy ミラー | 画像URL | 不明 | できない(画像URLは変わっていない) |
つまり ?v=N をいくら振っても、画像 proxy 側のミラー判定には効かない。Cache-Control 欠落で「ミラー保留」になっている画像は、いくら新しいページURLから参照しても採用されないままだった。
Card Validator の "Card loaded successfully" は当てにならない
Twitter Card Validator のログで Card loaded successfully と出ても、それは メタタグの読取り成功 の意味で、Composer での描画成功 とは独立している。今回のケースでも Validator は最後まで全部緑だったが、Composer は小カード描画のままだった。
X 公式によれば Card Validator のプレビュー機能は 2022年8月に廃止 されており、現状で実プレビューを確認する唯一の手段は Tweet Composer に URL を貼って描画を見ること。投稿は不要、貼り付けただけでプレビューが出る。
切り分けに第三者OGPツールが効く
ogp.buta3.net のような第三者OGPプレビューツールは、メタタグを独自パーサで読んで疑似プレビューする。X 実機の挙動とは別物だが、メタタグ自体が正しいかの判定は信頼できる。
「ogp.buta3.net で Summary (Large) になるが、X で小カード」になった時点で、メタは無実、X 側の問題と確定できる。この切り分けに 30 秒で到達できる。
副産物として得た知見
ハマっている過程で集まった、関連する細かい知見メモ。
-
画像のアルファチャネル: satori + resvg-js で生成すると初期状態は RGBA。一部クローラはアルファ付きPNGで挙動が荒れる報告があるので sharp の
flatten()で RGB に変換しておくと安心(今回のケースでは決定打ではなかったが、副作用なし) - 画像レスポンスタイム: alexwlchan が実測した記事によると、Twitterbot は画像取得を 約8秒 で打ち切る挙動が観測されている。CDN なら 100ms 以内が普通なのでまず問題にならないが、オリジン直配信や cold start が発生する構成では要注意
-
Twitterbot User-Agent:
Twitterbot/1.0含み(公式IPレンジ非公開)。HTML と画像で別リクエスト
まとめ
X で summary_large_image を指定しているのに小カード化する場合の 最後に疑うべきポイント:
- メタタグ・画像URL・寸法・ファイルサイズは Qiita 等の既存記事のチェックリスト通り潰す
- それで治らなければ 第三者OGPツールでメタの正常性を切り分け
- メタが正常なら 画像配信側の Cache-Control を疑う
- 静的サイト + S3 構成なら、deploy スクリプトに
--cache-controlを1行足して反映確認
確認は curl 1 行で済む:
curl -sSI -A "Twitterbot/1.0" <og:image のURL> | grep -i cache-control
# 何も出なければ怪しい。"public, max-age=..." が出れば OK
動くもの
検証に使った実ページ(自動生成された 1200x630 OGP カードが付いている):
https://microapps.one/tool-forest/ — 個人運営のブラウザ完結ツール集。商品画像加工系を中心に20数個並んでいます。今回の記事の発端は、この各ページに自動生成OGPカードを付ける作業でした。
出てくれて嬉しいかぎり^^)o