CloudFrontのstale-if-errorはオリジンタイムアウト時にいつstaleを返すか検証してみた

CloudFrontのstale-if-errorはオリジンタイムアウト時にいつstaleを返すか検証してみた

CloudFrontの `stale-if-error` が、オリジンタイムアウト時にいつstaleキャッシュを返すのかを、再現可能な検証環境で確認しました。`stale-while-revalidate` では即時にstaleを返す一方、本検証のタイムアウト条件では `stale-if-error` はオリジン取得のタイムアウトを待ってからstaleを返す挙動を観測し、クライアントを待たせずに保護できる期間を整理します
2026.07.22

はじめに

DevelopersIOでは2023年にCloudFrontのオリジンへ stale-if-error を設定し、オリジン障害時でもエッジのキャッシュで応答を継続できるように備えていました。

https://dev.classmethod.jp/articles/developersio-cdn-cloudfront/

ところが2026年7月16日の障害の際、設定してあるはずの stale-if-error が期待通りに機能していないように見える事象がありました。

https://dev.classmethod.jp/articles/cloudfront-vpc-origin-incident-20260716-log-analysis/

設定は入っているのに、なぜユーザー体験としては保護されなかったのか。再現環境を作って実測しました。

検証の結果、stale-while-revalidate 期間中はstaleキャッシュが即時返却されクライアント影響ゼロですが、オリジンタイムアウト型障害では stale-if-error 期間に入ると OriginReadTimeout 秒の同期待ちが毎回発生します。

検証内容

検証環境

オリジンには意図的に30秒スリープするLambda Function URLを用意し、CloudFrontの OriginReadTimeout を切り替えることでタイムアウトを再現しました。stale-while-revalidatestale-if-error は2023年5月にCloudFrontのネイティブ機能として追加されたディレクティブです。

https://aws.amazon.com/about-aws/whats-new/2023/05/amazon-cloudfront-stale-while-revalidate-stale-if-error-cache-control-directives

項目
Edge POP NRT12-P9 (東京)
オリジン Lambda Function URL (python3.12)
Lambda応答時間 30秒 (sleep)
オリジンCache-Control max-age=90, stale-while-revalidate=90, stale-if-error=180
レスポンスヘッダーポリシー Cache-Control: no-store (Override=true, ビューア向け上書き)
キャッシュポリシー MinTTL=0, DefaultTTL=0, MaxTTL=3600
OriginReadTimeout フェーズ1: 60s / フェーズ2: 20s
ConnectionAttempts 1
Standard Logging v2 CloudWatch Logs (us-east-1, JSON format)
curlスクリプト --resolve でIP固定、10s間隔

レスポンスヘッダーポリシーの Cache-Control: no-store (Override=true) はビューア向け応答の上書きのみで、エッジキャッシュの制御には影響しません。

Lambdaハンドラのコードです。

def handler(event, context):
    time.sleep(30)
    now = datetime.now(timezone.utc).isoformat()
    body = json.dumps({"timestamp": now, "message": "OK from slow origin"})
    return {
        "statusCode": 200,
        "headers": {
            "Content-Type": "application/json",
            "Cache-Control": "max-age=90, stale-while-revalidate=90, stale-if-error=180"
        },
        "body": body
    }

curlの --resolve で名前解決先IPを固定し、AgeX-Cachex-amz-cf-pop ヘッダーとボディのタイムスタンプを記録しました。全リクエストで x-amz-cf-popNRT12-P9 であることを確認しています。

# CloudFront domainの名前解決先IPを固定
RESOLVED_IP=$(dig +short "$DOMAIN" | grep -E '^[0-9]' | head -1)

curl -s \
  --resolve "${DOMAIN}:443:${RESOLVED_IP}" \
  --max-time 65 \
  -w '%{http_code} %{time_total}' \
  -D "$TMPHEADERS" \
  -o "$TMPBODY" \
  "https://${DOMAIN}/"
CloudFormationテンプレート(検証環境構築用)
AWSTemplateFormatVersion: '2010-09-09'
Description: CloudFront stale-if-error verification with Lambda Function URL origin

Parameters:
  OriginReadTimeout:
    Type: Number
    Default: 60
    Description: CloudFront origin read timeout in seconds (Phase1=60, Phase2=20)
    MinValue: 1
    MaxValue: 60

Resources:
  LambdaExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub '${AWS::StackName}-lambda-role'
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole

  SlowOriginFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Sub '${AWS::StackName}-slow-origin'
      Runtime: python3.12
      Handler: index.handler
      Role: !GetAtt LambdaExecutionRole.Arn
      Timeout: 60
      Code:
        ZipFile: |
          import time
          import json
          from datetime import datetime, timezone

          def handler(event, context):
              time.sleep(30)
              now = datetime.now(timezone.utc).isoformat()
              body = json.dumps({"timestamp": now, "message": "OK from slow origin"})
              return {
                  "statusCode": 200,
                  "headers": {
                      "Content-Type": "application/json",
                      "Cache-Control": "max-age=90, stale-while-revalidate=90, stale-if-error=180"
                  },
                  "body": body
              }

  SlowOriginFunctionUrl:
    Type: AWS::Lambda::Url
    Properties:
      AuthType: NONE
      TargetFunctionArn: !GetAtt SlowOriginFunction.Arn

  SlowOriginFunctionUrlPermission:
    Type: AWS::Lambda::Permission
    Properties:
      FunctionName: !GetAtt SlowOriginFunction.Arn
      Action: lambda:InvokeFunctionUrl
      Principal: '*'
      FunctionUrlAuthType: NONE

  CachePolicy:
    Type: AWS::CloudFront::CachePolicy
    Properties:
      CachePolicyConfig:
        Name: !Sub '${AWS::StackName}-cache-policy'
        MinTTL: 0
        DefaultTTL: 0
        MaxTTL: 3600
        ParametersInCacheKeyAndForwardedToOrigin:
          CookiesConfig:
            CookieBehavior: none
          HeadersConfig:
            HeaderBehavior: none
          QueryStringsConfig:
            QueryStringBehavior: none
          EnableAcceptEncodingGzip: true
          EnableAcceptEncodingBrotli: true

  ResponseHeadersPolicy:
    Type: AWS::CloudFront::ResponseHeadersPolicy
    Properties:
      ResponseHeadersPolicyConfig:
        Name: !Sub '${AWS::StackName}-response-headers'
        CustomHeadersConfig:
          Items:
            - Header: Cache-Control
              Value: no-store
              Override: true

  Distribution:
    Type: AWS::CloudFront::Distribution
    Properties:
      DistributionConfig:
        Enabled: true
        Comment: !Sub '${AWS::StackName} - stale-if-error verification'
        DefaultCacheBehavior:
          TargetOriginId: lambda-origin
          ViewerProtocolPolicy: https-only
          CachePolicyId: !Ref CachePolicy
          ResponseHeadersPolicyId: !Ref ResponseHeadersPolicy
          Compress: true
        Origins:
          - Id: lambda-origin
            DomainName: !Select
              - 2
              - !Split ['/', !GetAtt SlowOriginFunctionUrl.FunctionUrl]
            CustomOriginConfig:
              HTTPSPort: 443
              OriginProtocolPolicy: https-only
              OriginReadTimeout: !Ref OriginReadTimeout
              OriginSSLProtocols:
                - TLSv1.2
            ConnectionAttempts: 1
            ConnectionTimeout: 10
        HttpVersion: http2and3
        PriceClass: PriceClass_200

Outputs:
  DistributionDomainName:
    Value: !GetAtt Distribution.DomainName
  DistributionId:
    Value: !Ref Distribution
  FunctionUrl:
    Value: !GetAtt SlowOriginFunctionUrl.FunctionUrl

フェーズ1: 正常時stale-while-revalidate確認

OriginReadTimeout=60s に設定し、Lambdaの30秒応答が正常に返る条件で stale-while-revalidate の動作を確認しました。

https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/Expiration.html#stale-while-revalidate

300秒間の計測結果です。初回Missはウォームアップのため除外しています。

フェーズ 期間 (Age) seq X-Cache 応答時間 動作
max-age有効期間 22-82 1-7 Hit from cloudfront 30ms キャッシュ即返し
stale-while-revalidate期間 92-112 8-10 Hit from cloudfront 30ms staleキャッシュ即返し + バックグラウンド再検証中
再検証完了後 - (新鮮) 11 Hit from cloudfront 37ms 新タイムスタンプに切替
2サイクル目 max-age 10-110 12-22 Hit from cloudfront 25-40ms 新キャッシュ
2サイクル目 再検証完了 - (新鮮) 23 Hit from cloudfront 31ms 再度リフレッシュ

max-age=90を超えてAge=92になってもX-Cacheは Hit from cloudfront のままで、応答時間も30msで変化しません。staleキャッシュを即座に返しつつバックグラウンドで再検証しているためで、fresh/staleの区別はボディのタイムスタンプで確認しました。

フェーズ2: タイムアウト時stale-if-error確認

OriginReadTimeout を20sに変更しました。Lambdaは30秒応答のため、全オリジンリクエストが20秒でタイムアウトして504になります。キャッシュを温めた状態から400秒間リクエストを送信し、stale-while-revalidate期間からstale-if-error期間、さらにstale超過後までの動作遷移を観測しました。フェーズの分類は、CloudFrontがレスポンス時に返す Age の値を基準にしています。

https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/Expiration.html#stale-if-error-only

フェーズ 期間 (Age) seq X-Cache 応答時間 HTTP 動作
max-age有効期間 16-86 1-8 Hit from cloudfront 23-41ms 200 キャッシュ即返し
stale-while-revalidate期間 96-177 9-17 Hit from cloudfront 25-44ms 200 staleキャッシュ即返し(バックグラウンド再検証タイムアウト中、クライアント影響なし)
stale-if-error期間(リクエスト開始時Age基準) 197-277 18-22 RefreshHit from cloudfront 11-20s 200 オリジン再検証20sタイムアウト後にstaleキャッシュ返却
stale-if-error超過 277超 23+ Error from cloudfront 20s(初回)/ 30ms(以降) 504 staleキャッシュ使用不可。504をクライアントに返す

stale-while-revalidate期間(seq 9-17)は25-44msで即返しされる一方、stale-if-error期間(seq 18-22)では応答時間が11-20秒に跳ね上がります。

seq 18の応答時間が約11秒であったのは、直前のバックグラウンド再検証が既に進行中のオリジン接続に合流し、残りのタイムアウト待ち時間だけで応答が返ったためと推測します。seq 19以降は毎回完全な20秒待ちが発生しています。

stale-if-error期間のX-Cacheヘッダーは RefreshHit from cloudfront です。stale-while-revalidate期間の Hit from cloudfront とは異なり、オリジンへの接続試行を経てstaleキャッシュを返した動作であることがヘッダーから判別できます。

stale-if-error超過後(Age が max-age 90 + stale-if-error 180 = 270 を超過)は、CloudFrontが504エラーをクライアントに返します。504レスポンスはErrorCachingMinTTL(デフォルト10秒)の期間キャッシュされるため、後続リクエストはオリジンへアクセスせず即座にキャッシュされた504を返しました(応答時間30ms)。

https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/Expiration.html#use-both-stale-directives

ログ突合(Standard Logging v2)

curlスクリプトで観測した結果をCloudFrontのStandard Logging v2(CloudWatch Logsへ出力)と突合しました。

確認項目 結果
x-edge-location一致 ✓ 全リクエスト NRT12-P9
x-edge-result-type vs X-Cache ✓ RefreshHit/Error 完全一致
time-taken vs curl応答時間 ✓ 20.011s ≒ curl 20.04s
origin-fbl (Error phase) 20.004s(OriginReadTimeout=20sと一致)
RefreshHit時のorigin-fbl -(タイムアウトで計測不能)

まとめ

今回の検証から、オリジンのタイムアウト障害が継続する状況では、クライアントに遅延を与えずに保護できる時間(ゼロインパクト保護時間)は max-age + stale-while-revalidate で決まります。stale-if-error 期間に入るとstaleは返るものの、毎リクエストで OriginReadTimeout 秒の同期待ちが発生するためです。

DevelopersIOの現時点のプライマリ設定と、各パスのゼロインパクト保護時間は次の通りです。

パス プライマリ設定 ゼロインパクト保護時間 (max-age + stale-while-revalidate)
/ トップ max-age=60, stale-while-revalidate=120, stale-if-error=900 180s (3分)
/articles/* max-age=300, stale-while-revalidate=450, stale-if-error=900 750s (12.5分)
/author/* /tags/* max-age=120, stale-while-revalidate=300, stale-if-error=600 420s (7分)

トップページのゼロインパクト保護時間を3分と短めにしているのは新規投稿の反映ラグを小さくする意図ですが、障害影響の緩和のため、stale-while-revalidate は延長する方向で検討したいと思います。

この記事をシェアする

AWSのお困り事はクラスメソッドへ

関連記事