AWS S3 + CloudFront でキャッシュが更新されない・X-Cache: Miss from cloudfront が続く時の原因と対処法【Cache-Control / Invalidation】

公開日:2026-05-10/カテゴリ:AWS・CloudFront・インフラ/AnchorUp 技術ブログ

S3を更新したのにサイトが古いまま表示される

AWS S3 + CloudFront でウェブサイトをホスティングしていると、こんな状況に遭遇することがあります。

この問題は「どのレイヤーのキャッシュが原因か」を特定しないと解決できません。CloudFront のキャッシュ・ブラウザキャッシュ・S3 の設定ミスがそれぞれ絡み合うため、対処法を知らないと無駄に時間を使います。本記事では原因ごとに対処法を体系的に解説します。X-Cache: Miss from cloudfront の意味と、Miss が続く原因を知りたい方は次の次の章「X-Cache: Miss from cloudfront とは?」から読んでください。

仕組みの前提:CloudFront はどうキャッシュ時間(TTL)を決めるか

結論: TTL は「キャッシュポリシーの最小・デフォルト・最大 TTL」と「オリジン(S3)が返す Cache-Control / Expires」の組み合わせで決まります。 オリジンの max-age が優先されますが、最小 TTL〜最大 TTL の範囲に収められます。AWS 公式ドキュメント(Manage how long content stays in the cache)の表を整理すると次のとおりです。

オリジン(S3)のヘッダー 最小 TTL = 0 のとき 最小 TTL > 0 のとき
Cache-Control: max-age あり max-age と最大 TTL の小さい方 max-age を最小〜最大 TTL の範囲に収めた値
s-maxage と max-age の両方 CloudFront は s-maxage、ブラウザは max-age を使う 同左(s-maxage を最小〜最大 TTL に収める)
Expires のみ Expires の日時か最大 TTL の早い方 Expires を最小〜最大 TTL に収める
ヘッダーなし デフォルト TTL 最小 TTL とデフォルト TTL の大きい方
no-cache / no-store / private ヘッダーに従う(キャッシュしない) 最小 TTL の間はキャッシュする

キャッシュポリシーを使わない旧来の設定(レガシーキャッシュ設定)や、マネージドポリシー CachingOptimized のデフォルト TTL は 24 時間(86400 秒) です。max-age と Expires の両方がある場合は max-age だけが使われます。

これを知らずに「S3 を更新したから即座に反映されるはず」と思っていると、毎回ハマります。

X-Cache: Miss from cloudfront とは?毎回 Miss になる(Hit にならない)のはなぜか

結論: X-Cache: Miss from cloudfront は「リクエストを受けたエッジにキャッシュが無かったので、オリジン(S3 など)から取得して返した」という意味で、エラーではありません。 初回アクセスや TTL が切れた直後の Miss は正常です。同じ URL に続けてアクセスしても Miss が続く場合、原因はほぼ次の 3 つのどれかです。

  1. キャッシュポリシーが CachingDisabled など、TTL が 0 になっている
  2. オリジンが Cache-Control: no-cache / no-store / private / max-age=0 を返していて、最小 TTL が 0
  3. クエリ文字列・Cookie・リクエストヘッダーがキャッシュキーに入っていて、リクエストごとに別のキャッシュになっている

X-Cache ヘッダーの値はそれぞれ何を意味するのか

CloudFront はレスポンスに X-Cache ヘッダーを付けます。値の意味は、アクセスログの x-edge-result-type 列の定義(AWS 公式ドキュメント Standard logging reference)と同じです。

X-Cache の値 意味 対応が必要か
Hit from cloudfront エッジのキャッシュから返した 不要(キャッシュが効いている)
RefreshHit from cloudfront キャッシュはあったが期限切れだったので、オリジンに最新か問い合わせてから返した 基本は不要
Miss from cloudfront キャッシュに無かったので、オリジンから取得して返した 2 回目以降も続くなら確認
Error from cloudfront 4xx / 5xx のエラーを返した 必要(オリジンや権限を確認)

アクセスログではほかに LimitExceeded、CapacityExceeded、Redirect(HTTP→HTTPS のリダイレクト)なども記録されます。x-edge-detailed-result-type 列には、Origin Shield から返した時の OriginShieldHit も出ます。

まず確認:同じ URL に続けてアクセスしても Miss が続くか

1 回目が Miss なのは当然なので、同じ URL に 2〜3 回続けて GET し、2 回目以降に Hit になるかを見ます。curl -I は HEAD リクエストなので、実際のアクセスと同じ GET で確認するのが確実です。

URL=https://d1234abcdef.cloudfront.net/assets/main.abc123.js
for i in 1 2 3; do
  curl -s -o /dev/null -D - "$URL" \
    | grep -iE '^(x-cache|age|cache-control|x-amz-cf-pop):'
  echo '---'
done

正常なら、2 回目から Hit になり Age(キャッシュされてからの秒数)が増えていきます。

cache-control: public, max-age=31536000, immutable
x-cache: Miss from cloudfront
x-amz-cf-pop: NRT57-P2
---
cache-control: public, max-age=31536000, immutable
x-cache: Hit from cloudfront
x-amz-cf-pop: NRT57-P2
age: 1
---

3 回とも Miss なら、次の早見表で原因を絞り込みます。x-amz-cf-pop(応答したエッジの識別子)が毎回違う場合は、エッジが変わっただけの可能性があります。

Miss が続く原因の早見表

確認結果 原因 対処
何回アクセスしても Miss(すべてのファイル) ビヘイビアのキャッシュポリシーが CachingDisabled(最小・デフォルト・最大 TTL がすべて 0) 対策1
cache-control が no-cache / no-store / private / max-age=0 オリジンがキャッシュ禁止を指示し、最小 TTL 0 のためそれに従っている 対策2
URL の ?v=… や ?utm_… が変わると Miss クエリ文字列がキャッシュキーに入っている 対策3
Cookie 付き(ログイン後など)だと Miss Cookie がキャッシュキーに入っている(旧設定の Forward Cookies: All など) 対策3
ブラウザや端末によって Miss User-Agent などのヘッダーがキャッシュキーに入っている 対策3
x-amz-cf-pop が変わった時だけ Miss キャッシュはエッジごとに別 正常(減らすなら対策4)
アクセスの少ないファイルだけ時々 Miss 期限前でも、あまり要求されないオブジェクトは追い出される(evict) 正常
POST / PUT などのリクエスト CloudFront がキャッシュするのは GET / HEAD(設定すれば OPTIONS)だけ 仕様
ブラウザの DevTools でだけ Miss が続く ブラウザに保存された初回レスポンス(Miss)を見ている curl で確認する

対策1:キャッシュポリシーが CachingDisabled になっていないか確認する

S3 の静的ファイルなのに全部 Miss になる場合、まずビヘイビアに付いているキャッシュポリシーを確認します。

# 各ビヘイビアのキャッシュポリシー ID を確認
aws cloudfront get-distribution-config --id EXXXXXXXXXX \
  --query 'DistributionConfig.{default:DefaultCacheBehavior.CachePolicyId,others:CacheBehaviors.Items[].{path:PathPattern,policy:CachePolicyId}}'

# ポリシーの TTL を確認
aws cloudfront get-cache-policy --id <ポリシーID> \
  --query 'CachePolicy.CachePolicyConfig.{min:MinTTL,default:DefaultTTL,max:MaxTTL}'

よく使うマネージドポリシーの ID と TTL は次のとおりです(AWS 公式ドキュメント Use managed cache policies より)。

ポリシー名 ID 最小 / デフォルト / 最大 TTL キャッシュキー
CachingDisabled 4135ea2d-6df8-44a3-9df3-4b5a84be39ad 0 / 0 / 0 秒 なし(キャッシュしない)
CachingOptimized 658327ea-f89d-4fab-a63d-7e88639e58f6 1 秒 / 24 時間 / 365 日 正規化した Accept-Encoding のみ

CachingDisabled なら、何度アクセスしても Miss になるのが正しい動作です。API やログイン後の画面用のビヘイビアに付けたポリシーが、デフォルトビヘイビア(*)にも付いていた、というのがよくある原因です。静的ファイルのパス(/assets/* など)には CachingOptimized を付けます。

CachePolicyId が null の場合はレガシーキャッシュ設定です。ビヘイビアの MinTTL / DefaultTTL / MaxTTL と ForwardedValues を確認してください。

対策2:S3 オブジェクトの Cache-Control を直す

最小 TTL が 0 のポリシーでは、オリジンが no-cache などを返すと CloudFront はキャッシュしません。

aws s3api head-object --bucket your-bucket-name --key assets/main.abc123.js \
  --query '{CacheControl:CacheControl,ContentType:ContentType}'

既にアップロード済みのファイルの Cache-Control だけを書き換えるには、同じキーにコピーし直します。--metadata-directive REPLACE を付けるとメタデータが置き換わるので、Content-Type も一緒に指定し直してください。

aws s3 cp s3://your-bucket/assets/ s3://your-bucket/assets/ --recursive \
  --metadata-directive REPLACE \
  --cache-control "public, max-age=31536000, immutable" \
  --exclude "*" --include "*.js" --content-type "application/javascript"

ただし、index.html だけが Miss になるのは設計どおりです。後述の原因2のとおり、SPA の index.html は更新をすぐ反映させるために no-cache にするのが定石だからです。なお CachingOptimized は最小 TTL が 1 秒なので、no-cache の index.html でも 1 秒以内に続けてアクセスすると Hit になることがあります。

対策3:キャッシュキーからクエリ文字列・Cookie・ヘッダーを外す

キャッシュキーに入れた値は、値の組み合わせごとに別々にキャッシュされます。AWS 公式ドキュメント(Increase the proportion of requests that are served directly from the CloudFront caches)の例では、2 種類の Cookie にそれぞれ 3 通りの値があると、1 つのオブジェクトにつき最大 9 回オリジンへリクエストが飛びます。クエリ文字列も、parameter1=A と parameter1=a のような大文字小文字の違いや、パラメータの順番の違いだけで別のキャッシュになります。

広告の utm_source などが付いた URL でアクセスされるページは、クエリ文字列をすべてキャッシュキーに入れていると、パラメータの数だけ Miss になります。

対策4:エッジが変わるたびの Miss は Origin Shield で減らす

CloudFront のキャッシュはエッジロケーションごとに別々なので、別のエッジ(x-amz-cf-pop が違う)に初めて届いたリクエストは Miss になります。これは正常な動作です。オリジンへのリクエストを減らしたい場合は、Origin Shield を有効にすると、すべてのエッジとリージョナルエッジキャッシュが 1 か所のキャッシュ層を経由してオリジンに取りに行くようになります。この場合、ログの x-edge-detailed-result-type が OriginShieldHit になります。Origin Shield は追加料金がかかるので、オリジンの負荷が問題になっている時に検討してください。

原因1:CloudFront のデフォルト TTL が 24 時間のまま

もっともよくある原因です。CloudFront はデフォルトで 24 時間(86400 秒) オリジンのレスポンスをキャッシュします。Cache-Control ヘッダーを S3 に設定していない場合、このデフォルト値が使われます。

確認方法

# レスポンスヘッダーでキャッシュ状態を確認
curl -I https://d1234abcdef.cloudfront.net/index.html

レスポンスの X-Cache ヘッダーで状態がわかります。

X-Cache: Hit from cloudfront   ← CloudFront キャッシュから返却
X-Cache: Miss from cloudfront  ← S3(オリジン)から取得

Age ヘッダーはキャッシュされてからの経過秒数です。「古いファイルが出る」時は Hit で Age が大きいはずです。反対に Miss ばかりでキャッシュが効かない場合は、前の章「X-Cache: Miss from cloudfront とは?」の早見表を確認してください。

対処法:キャッシュポリシーの TTL を調整する

CloudFront コンソール → ディストリビューション → ビヘイビア → 編集 → キャッシュポリシー で設定します。

開発環境では TTL を短くし、本番でも静的アセット以外は短めにするのが基本です。

# AWS CLI でキャッシュポリシーを確認
aws cloudfront list-cache-policies --type custom

原因2:S3 オブジェクトに Cache-Control ヘッダーが設定されていない

S3 にアップロードしたファイルのメタデータに Cache-Control が設定されていないと、CloudFront は独自の判断(デフォルト TTL)でキャッシュします。

確認方法

# S3 オブジェクトのメタデータを確認
aws s3api head-object --bucket your-bucket-name --key index.html

出力に ContentType や CacheControl が含まれます。CacheControl が表示されていない場合は未設定です。

対処法:ファイルの種類に応じた Cache-Control を設定する

ファイルの役割によって最適な Cache-Control は異なります。

SPA の index.html(エントリーポイント)はキャッシュしない:

aws s3 cp dist/index.html s3://your-bucket/index.html \
  --cache-control "no-cache, no-store, must-revalidate" \
  --content-type "text/html"

ハッシュ付き静的アセット(main.abc123.js など)は長期キャッシュ OK:

aws s3 sync dist/assets/ s3://your-bucket/assets/ \
  --cache-control "public, max-age=31536000, immutable" \
  --exclude "*.html"

Vite や webpack はビルド時にファイル名にハッシュを付与するため、内容が変わればファイル名も変わります。そのため古いキャッシュが読まれることはなく、長期キャッシュが安全です。

GitHub Actions での自動デプロイ設定例:

- name: Deploy to S3
  run: |
    # index.html はキャッシュしない
    aws s3 cp dist/index.html s3://${{ secrets.S3_BUCKET }}/index.html \
      --cache-control "no-cache, no-store, must-revalidate" \
      --content-type "text/html"

    # ハッシュ付きアセットは長期キャッシュ
    aws s3 sync dist/assets/ s3://${{ secrets.S3_BUCKET }}/assets/ \
      --cache-control "public, max-age=31536000, immutable" \
      --delete

AWS CDK での設定例:

import * as s3deploy from 'aws-cdk-lib/aws-s3-deployment'

// index.html:キャッシュなし
new s3deploy.BucketDeployment(this, 'DeployHtml', {
  sources: [s3deploy.Source.asset('./dist', { exclude: ['assets/**'] })],
  destinationBucket: bucket,
  distribution,
  distributionPaths: ['/*'],
  cacheControl: [
    s3deploy.CacheControl.fromString('no-cache, no-store, must-revalidate'),
  ],
})

// ハッシュ付きアセット:長期キャッシュ
new s3deploy.BucketDeployment(this, 'DeployAssets', {
  sources: [s3deploy.Source.asset('./dist/assets')],
  destinationBucket: bucket,
  destinationKeyPrefix: 'assets',
  cacheControl: [
    s3deploy.CacheControl.fromString('public, max-age=31536000, immutable'),
  ],
})

原因3:CloudFront Invalidation の使い方が間違っている

キャッシュを手動でクリアするには Invalidation(キャッシュ無効化)を使いますが、落とし穴があります。

よくある間違い①:パスの先頭スラッシュを忘れる

# ❌ 間違い:スラッシュなし → 対象にヒットしない
aws cloudfront create-invalidation \
  --distribution-id EXXXXXXXXXX \
  --paths "index.html"

# ✅ 正しい:先頭にスラッシュが必須
aws cloudfront create-invalidation \
  --distribution-id EXXXXXXXXXX \
  --paths "/index.html"

# ✅ 全キャッシュをクリアする場合
aws cloudfront create-invalidation \
  --distribution-id EXXXXXXXXXX \
  --paths "/*"

よくある間違い②:完了を待たずにアクセス確認する

Invalidation は 完了まで 1〜5 分 かかります。コマンド実行直後にアクセスしても、まだ古いキャッシュが返ってくることがあります。

# Invalidation の状態を確認
aws cloudfront get-invalidation \
  --distribution-id EXXXXXXXXXX \
  --id IXXXXXXXXXXXXXXXXXXXXXXXXXX

# "Status": "Completed" になるまで待つ
# "Status": "InProgress" はまだ処理中

CI/CD での自動 Invalidation

デプロイのたびに Invalidation を自動実行するのがベストプラクティスです。

- name: Invalidate CloudFront cache
  run: |
    INVALIDATION_ID=$(aws cloudfront create-invalidation \
      --distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} \
      --paths "/*" \
      --query 'Invalidation.Id' \
      --output text)

    echo "Waiting for invalidation $INVALIDATION_ID to complete..."
    aws cloudfront wait invalidation-completed \
      --distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} \
      --id "$INVALIDATION_ID"

    echo "Invalidation completed."

コスト注意: Invalidation は月 1,000 パス分まで無料、それ以降は有料です。/* は1パスとしてカウントされるため、毎回全キャッシュをクリアしても費用はほぼかかりません。

原因4:ブラウザキャッシュと CloudFront キャッシュを混同している

「CloudFront のキャッシュはクリアした」と思っていても、実はブラウザキャッシュが効いているケースがあります。

確認方法

Chrome DevTools(F12)→ Network タブ → 「キャッシュを無効化」にチェックしてリロード。

または、シークレットモード(Ctrl+Shift+N)でアクセスしてみてください。シークレットモードではブラウザキャッシュが使われません。

レスポンスヘッダーでも判断できます。

# CloudFront がキャッシュを返している(HIT)
X-Cache: Hit from cloudfront
Age: 3600

# S3 から直接取得(MISS)
X-Cache: Miss from cloudfront
Age: 0

ブラウザキャッシュが原因の場合は、CloudFront の設定ではなく Cache-Control: no-cache ヘッダーで制御します。

原因5:S3 バケットポリシーと OAC の設定ミス

まれに、S3 のアクセス制御の問題で CloudFront が最新ファイルを取得できず、古いキャッシュを返し続けることがあります。

CloudFront の OAC(Origin Access Control)を使用している場合、S3 バケットポリシーが正しくないと CloudFront が 403 エラーを受け取ります。403 は、オリジンが Cache-Control: max-age(または s-maxage)を付けて返した場合にキャッシュされます(詳しくは原因6)。

S3 バケットポリシーの正しい設定(OAC 使用時)

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowCloudFrontServicePrincipal",
      "Effect": "Allow",
      "Principal": {
        "Service": "cloudfront.amazonaws.com"
      },
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::your-bucket-name/*",
      "Condition": {
        "StringEquals": {
          "AWS:SourceArn": "arn:aws:cloudfront::123456789012:distribution/EXXXXXXXXXX"
        }
      }
    }
  ]
}

AWS:SourceArn にはディストリビューションの ARN を正確に指定してください(アカウント ID とディストリビューション ID は環境に合わせて変更)。

確認方法

# バケットポリシーを確認
aws s3api get-bucket-policy --bucket your-bucket-name

# CloudFront がオリジンから正常に取得できているか確認
# エラーページが返っていないかチェック
curl -v https://d1234abcdef.cloudfront.net/index.html 2>&1 | grep "< HTTP"

原因6:エラーレスポンスのキャッシュ(Error Caching TTL)

CloudFront は 4xx・5xx のエラーレスポンスもキャッシュします。キャッシュされる時間は「エラーキャッシュ最小 TTL(デフォルト 10 秒)」と「オリジンがエラーと一緒に返した Cache-Control: max-age / s-maxage」の長い方です(AWS 公式ドキュメント How CloudFront processes HTTP 4xx and 5xx status codes より)。

ステータス キャッシュされる条件
404, 414, 500, 501, 502, 503, 504 常にキャッシュされる
400, 403, 405, 412, 415 オリジンが max-age / s-maxage を付けた時だけ

S3 の設定ミスで一度 404 などが返ったあと、設定を直しても最低 10 秒、エラーキャッシュ最小 TTL を長くしていればその間は古いエラーレスポンスが返り続けます。なお CachingDisabled ポリシーのビヘイビアでは、エラーもキャッシュされません。

確認・対処法

CloudFront コンソール → エラーページ → カスタムエラーレスポンスを設定し、エラーキャッシュ TTL を短くするか 0 にします。

# AWS CLI でエラーキャッシュの設定を確認(distribution config を取得)
aws cloudfront get-distribution-config --id EXXXXXXXXXX \
  --query 'DistributionConfig.CustomErrorResponses'

まとめ:キャッシュが更新されない時のチェックリスト

確認項目 確認方法
CloudFront キャッシュ状態 curl -I URL で X-Cache を確認
Miss が続く(Hit にならない) キャッシュポリシーの TTL・オリジンの Cache-Control・キャッシュキー(クエリ/Cookie/ヘッダー)
S3 の Cache-Control ヘッダー aws s3api head-object
Invalidation の完了 コンソールまたは aws cloudfront get-invalidation
ブラウザキャッシュ DevTools で「キャッシュを無効化」してリロード
S3 バケットポリシー aws s3api get-bucket-policy
エラーレスポンスのキャッシュ CloudFront コンソールのエラーページ設定

推奨 Cache-Control 設定まとめ

ファイル種別 Cache-Control 理由
index.html no-cache, no-store, must-revalidate 常に最新を取得させる
ハッシュ付き JS/CSS public, max-age=31536000, immutable 内容変化でファイル名も変わるため安全
ハッシュなし JS/CSS no-cache または短い TTL 変更が反映されないリスクを避ける
画像・フォント public, max-age=86400 更新頻度に合わせて調整

CloudFront のキャッシュ問題は「どのレイヤーのキャッシュか」を特定することが解決への近道です。curl -I でヘッダーを確認し、X-Cache と Age を見れば CloudFront が原因かブラウザが原因か切り分けられます。上記のチェックリストを順に確認すれば、ほとんどのケースで原因を特定できます。

技術ブログの記事一覧へ