CloudFrontのstale-if-errorはオリジンタイムアウト時にいつstaleを返すか検証してみた
はじめに
DevelopersIOでは2023年にCloudFrontのオリジンへ stale-if-error を設定し、オリジン障害時でもエッジのキャッシュで応答を継続できるように備えていました。
ところが2026年7月16日の障害の際、設定してあるはずの stale-if-error が期待通りに機能していないように見える事象がありました。
設定は入っているのに、なぜユーザー体験としては保護されなかったのか。再現環境を作って実測しました。
検証の結果、stale-while-revalidate 期間中はstaleキャッシュが即時返却されクライアント影響ゼロですが、オリジンタイムアウト型障害では stale-if-error 期間に入ると OriginReadTimeout 秒の同期待ちが毎回発生します。
検証内容
検証環境
オリジンには意図的に30秒スリープするLambda Function URLを用意し、CloudFrontの OriginReadTimeout を切り替えることでタイムアウトを再現しました。stale-while-revalidate と stale-if-error は2023年5月にCloudFrontのネイティブ機能として追加されたディレクティブです。
| 項目 | 値 |
|---|---|
| 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を固定し、Age、X-Cache、x-amz-cf-pop ヘッダーとボディのタイムスタンプを記録しました。全リクエストで x-amz-cf-pop が NRT12-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 の動作を確認しました。
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 の値を基準にしています。
| フェーズ | 期間 (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)。
ログ突合(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 は延長する方向で検討したいと思います。







