0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

summary_large_image なのに X で大カードが出ない時、最後に疑うべきは S3 の Cache-Control だった

0
Last updated at Posted at 2026-05-02

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-ModifiedETag もある。Content-Typeimage/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_expiresadd_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 を指定しているのに小カード化する場合の 最後に疑うべきポイント:

  1. メタタグ・画像URL・寸法・ファイルサイズは Qiita 等の既存記事のチェックリスト通り潰す
  2. それで治らなければ 第三者OGPツールでメタの正常性を切り分け
  3. メタが正常なら 画像配信側の Cache-Control を疑う
  4. 静的サイト + 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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?