← 一覧へ戻る

Claude が書いた記事

Content-Range と Content-Length ── サイズのズレが Front Door で 503 を招くとき

Content-Range が `bytes 0-848/849` やのにボディが 879 バイトあったらどうなる? その矛盾から、Content-Range と Content-Length が「誰のサイズ」かを整理して、ズレが本番 Azure Front Door で 503 を招きうる筋まで辿った学習ログ。

俺の最初の疑問

きっかけは Content-Range ヘッダを調べてたこと。大きいファイルの一部分だけをダウンロードするとき、サーバーが「全体のここからここまでを返したで」と伝えるレスポンスヘッダ、というのは分かった。でも、ひとつ具体的なところで詰まった。

Content-Range: bytes 0-848/849 って宣言してるのに、実際のボディが 879 バイトあったらどうなるん?

さらにもう一つ。俺が実務で触ってる Azure Front Door は、こういう矛盾したレスポンスをどう扱うんや? この 2 つを潰した記録。

まず一言でいうと

Content-RangeContent-Length は、どっちもバイト数を言うてるのに「数えてる対象」が違う

  • Content-Range … 圧縮を解いた後の生データ (RFC 9110 では選択表現 = selected representation と呼ぶ) の、どの範囲かと全体サイズ。
  • Content-Length … いま回線を実際に流れてるメッセージボディのバイト数。

圧縮が無ければ、この 2 つは一致する。だから bytes 0-848/849 でボディが 879 バイトやったら、圧縮なしなら仕様違反 (サーバーのバグ)。そして、このズレは机上の話で終わらへん。前段に CDN を置くと、サイズの食い違いを含むレスポンスで Front Door が 503 を返す可能性がある。ここまで地続きや。

何と比べるとわかるか

まず混同しやすい RangeContent-Range は、向きが逆や。

ヘッダ向き意味
Rangeクライアント → サーバー「全体のここからここまでくれ」(リクエスト)
Content-Rangeサーバー → クライアント「全体のここからここまでを返した」(レスポンス)

クライアントが Range で一部を要求し、サーバーが応えるときのステータスは 200 やなくて 206 Partial Content。このとき Content-Length全体のサイズやなくて、今回返す部分のサイズになる。ここが一つ目の罠。

範囲リクエストの一往復。Range (クライアント→) と Content-Range (サーバー→) は向きが逆。ステータスは 206。そして Content-Length は全体 (10000) やなくて、今回返す部分 (2001) のサイズ ── ここを全体と思うとズレる。

何が問題なのか ── 3 つの値が指すもの

最初の疑問に戻る。Content-Range: bytes 0-848/849 でボディが 879 バイト。ここには別々のものを指す 3 つの数が出てくる。

  • Content-Range の範囲0-848 = 計 849 バイト (終了 − 開始 + 1)。全体も 849。
  • Content-Length … 本来ここに来るべき値。
  • 実際に届いたボディ … 879 バイト。

圧縮が無ければ、この 3 つは全部一致しなあかん。1 つでもズレたら、受け取り側はどれを信じるかで挙動が割れる。

  • Content-Length: 849 を信じて 849 バイトだけ読む → 残り 30 バイトをゴミ扱い → Keep-Alive で次のレスポンスの先頭と誤認して、次の通信が壊れる
  • サイズ不一致を「転送が途中で切れた」と判断してダウンロード失敗
  • 並列ダウンローダが範囲を無視して 879 バイトを書き込む → 後続チャンクとのオフセットがズレて最終ファイルが破損

要は、宣言したサイズと実ボディが食い違うと、どこかで必ず壊れる

gzip が入ると話が変わる ── レイヤーが割れる

ここが一番の山や。「圧縮されてるからボディが膨らんだのでは?」と思うかもしれん。実は、圧縮があるとサイズが食い違っても合法になる。理由は、2 つのヘッダが見てるレイヤーが違うから。

  • Content-Range が数えるのは 圧縮を解いた生データ (選択表現) の範囲。
  • Content-Length が数えるのは 圧縮した後に回線を流れる実ボディ

だから、生データ 849 バイトを gzip で 300 バイトに縮めて返すなら、こうなる ── そしてこれは RFC 9110 (HTTP Semantics) のとおりや。Content-Range は選択表現 (圧縮前) の範囲を、Content-Length はメッセージボディ (圧縮後) を指す、と定義が分かれてるから合法。

HTTP/1.1 206 Partial Content
Content-Encoding: gzip
Content-Range: bytes 0-848/849   ← 圧縮前 (生データ) の範囲を数える
Content-Length: 300              ← 圧縮後の、いま流れてる実ボディを数える

849 ≠ 300 やのに違反やない。別々のものを数えてるから。逆に、Content-Encoding が無いのにサイズが食い違ってたら、それは紛れもないバグ。判定はシンプルや。

状況判定
圧縮なし・範囲サイズ ≠ Content-Length仕様違反 (サーバーのバグ)
圧縮あり・範囲サイズ ≠ Content-Length正常 (見てる層が違うだけ)

図で見る

圧縮の有無で、Content-RangeContent-Lengthどこを測ってるかを並べるとこうなる。

Content-Range は「圧縮を解いた生データ (選択表現)」を、Content-Length は「いま回線を流れる実ボディ」を測る。圧縮なし (上) は全部 849 で一致。gzip あり (中) は層が割れるから 849 ≠ 300 でも合法。圧縮が無いのにズレてる (下) のだけが違反。

混乱しやすいポイント

① 「ボディのサイズ」は常に Content-Length が正

圧縮してようがしてまいが、テキストやろうが画像やろうが、回線を実際に流れるボディのバイト数 = Content-Length は 1 バイトもズレたらあかん。ズレると「途中で切れた」か「いつまでも終わらん」のどっちかになる。Content-Range の方は「圧縮を解いたら何バイトになるか」を指してるだけで、回線上のサイズやない。

② Content-Range の * は「不明」

Content-Range: bytes 200-1000/** は「全体サイズは今は不明」。逆に範囲を満たせないとき (要求がファイルサイズ超え等) は 416 Range Not SatisfiableContent-Range: bytes */5000 で「範囲は返せん、なお全体は 5000」と伝える。

③ 違反かどうかは Content-Encoding を見てから言う

「Content-Range と Content-Length が違う = 違反」と短絡したらあかん。先に Content-Encoding があるか見る。あれば層が違うだけで正常、無ければバグ。この一手間を飛ばすと誤判定する。

たとえ話

伝票と、実際の小包Content-Range は「中身を広げたら全部で 849 グラム、そのうち頭から 848 グラム目まで入れた」と書いた伝票Content-Length は「いま運送屋が運んでる小包の実重量」。真空パックで圧縮して送れば、伝票は 849 グラム (開けたときの重さ) でも、小包の実重量は 300 グラムでええ ── 測ってる対象が違うから矛盾やない。でも真空パックもしてへんのに伝票と実重量が食い違うなら、それは詰め間違いや。

本番ではこう出る ── Azure Front Door の 503

ここからが二つ目の疑問。このサイズ矛盾、CDN を前段に置くと現実の障害になりうる。Azure Front Door (以下 AFD) は、オリジンから一貫しないサイズのレスポンスを受けると、安全側に倒してクライアントへ 503 を返すことがある。どういうときにそうなるかは、AFD の中の オブジェクトチャンキング という仕組みを知ると見えてくる。

レンジリクエストとオブジェクトチャンキングは別もの

混ざりやすいけど、主体が違う。そして大事なのは、別概念やのに片方がもう片方の上に乗ってること ── チャンキングは「AFD 自身がオリジンにレンジリクエストを投げる」ことで実装されてる。

レンジリクエストオブジェクトチャンキング
主体クライアント (AFD も内部でこの役)AFD (内部)
正体HTTP 標準の機能AFD の配信最適化の仕組み
やること「一部だけくれ」と要求する大ファイルを 8 MB 単位で裏取り&キャッシュ
関係手段この手段を使って組み立てる

AFD はキャッシュ有効時、大きなファイルをオリジンから 8 MB のチャンク単位で取りに行く。このとき、クライアントが全体を要求してても、AFD 自身がファイルを 8 MB ずつに区切ってレンジリクエストをオリジンへ発行する (Range: bytes=0-8388607、次は 8388608-16777215…) ── ここで AFD は「オリジンに対するクライアント」として振る舞う (だからオリジンがレンジリクエストに対応してる必要がある)。届いたチャンクを即キャッシュしてユーザーに返しつつ、次のチャンクを並列で先読みする。ファイル全体が揃ってなくても、手元のチャンク単位でキャッシュとして機能する

ここで大事なのは、この 8 MB チャンクはあくまで AFD とオリジンの間の話で、クライアントには見えへんこと。クライアントから見えるのは 1 本のレスポンス ── 全体を要求したら 200 で全体のボディ、Range を出してたら 206 でその範囲。AFD は内部チャンクを繋いで 1 本のボディに仕立てて返す (しかも貯めてから送るんやなく、届いた順に流す)。だから AFD は最終的に 1 本の整合したレスポンスを組まなあかん。次の節の 503 は、ここで整合が組めなくなる話や。

オブジェクトチャンキング。AFD は大ファイルを 8 MB ずつオリジンから取り、届いたチャンクを順にキャッシュしながらユーザーへ流す (次のチャンクは並列で先読み)。Microsoft Learn の『ファイル全体を Front Door キャッシュにキャッシュする必要はない』は、この『チャンク単位で独立キャッシュ』のこと。

なぜサイズのズレで 503 が起きうるか

AFD がチャンクを繋ぎ合わせて配信してる最中に、オリジンが返す Content-Length一貫しなかったら、パズルが組めなくなる。Microsoft Learn が具体的に挙げてるのは RangeAccept-Encoding (圧縮要求) が同時に来たときや。同じ GET に対してオリジンが「ある時は圧縮後のサイズ、ある時は圧縮前のサイズ」と食い違う Content-Length を返すと、AFD は整合を取れず、データ破損やキャッシュ汚染を避けるために、503 を返すことがある

(注意: ドキュメントが明記してるのはこの「範囲リクエスト + 圧縮で Content-Length がブレる」ケース。「Content-RangeContent-Length の数字が一致しなければ必ず 503」とまでは書かれてへん。だからここは「503 になりうる」と読むのが正しい。)

503 が起きうる筋。Range + Accept-Encoding が同時に来て、オリジンがチャンクごとに食い違う Content-Length を返すと、AFD は『同じ GET には同じ Content-Length』の原則で整合を取れず、結合を諦めて 503 を返すことがある (Microsoft Learn が挙げてるケース)。前半で見た『サイズのズレ』が、本番ではこの形で表面化しうる。

どう直すか ── まずオリジンを直す

本筋はオリジンを直すこと。それができれば回避策は要らん。回避策は「オリジンをすぐ直せないとき」の逃げや。

  • 根本対処:オリジンを直す。 原因は「オリジンが同じ範囲リクエストに一貫しない Content-Length を返す」こと。Range と圧縮の処理を直して、実ボディと一致する Content-Length を毎回返すようにすれば、原因そのものが消える。これで終わり。
  • 回避策:オリジンをすぐ直せないとき。 原因は残したまま、矛盾の発生だけ止める当座しのぎ。
    • オリジンか AFD の圧縮を切る (Range と圧縮の競合をなくす)。
    • AFD のルールエンジンで範囲リクエストから Accept-Encoding を剥がす (オリジンに圧縮させない)。

回避策は症状を止めるだけで、オリジンのバグは残る。だから回避策で済ませず、直せるならオリジンを直す

出てきたヘッダの早見表

このページで出てきたヘッダを、向き (リクエストかレスポンスか) で分けてまとめる。RangeContent-RangeAccept-EncodingContent-Encoding は名前が似てるけど向きが逆や。

ヘッダ向きこのページでの役割
Rangeリクエスト (クライアント → サーバー)「全体のここからここまでくれ」と部分を要求する
Accept-Encodingリクエスト (クライアント → サーバー)「gzip 等の圧縮を受け取れる」と申告する。Range と同時に来ると 503 の火種になりうる
Content-Rangeレスポンス (サーバー → クライアント)「全体の何バイト目〜何バイト目を返したか」。数えるのは選択表現 (圧縮前)
Content-Lengthレスポンス (サーバー → クライアント)いま回線を流れる実ボディのバイト数 (圧縮後)。※リクエストにボディがある時はリクエストにも付く
Content-Encodingレスポンス (サーバー → クライアント)ボディにかけた圧縮 (gzip 等)。これの有無で「サイズ違い = 違反か」が決まる

(206 Partial Content416 Range Not Satisfiable503 Service Unavailable はヘッダやなくてステータスコード。レスポンスの 1 行目に乗る。)

次に答える、設計者の問い で、それがなんやねん?

で、この 503 が出たとき、圧縮をどこにやらせるかを選べるか?

  • オリジンで圧縮するか、エッジ (Front Door) に一任するか。 判断軸は「Range と圧縮を両立できるか」と「8 MB の境界」── AFD がエッジで即時圧縮する対象は 8 MB 未満のファイルだけ (公式の「圧縮のルール」: MIME 種・1 KB 超・8 MB 未満)。オリジンが Range を返しつつ圧縮もかけて Content-Length がブレるなら、オリジンでの圧縮をやめて圧縮の担当を Front Door (エッジ) 側に移すか、ルールエンジンで範囲リクエストから Accept-Encoding を剥がす。
  • 責任境界はどこか。 Content-Length と実ボディの整合を保証するのはオリジンの責任。それをチャンクに割って束ねるのが AFD の責任。503 を見たら「整合が崩れてるのはオリジン側」とまず切り分けられる。

これに答えられると強い ── 障害を「CDN のせい」で止めず、圧縮とキャッシュの責任境界を引いて、どっちを直すか即決できる側に立てる

深掘りメニュー 次におすすめのトピック

この記事の続きとして、次に掘るといいトピック。

  • キャッシュは実際どう保管されるか ── 8 MB チャンクはディスクにどう置かれ、ハッシュ名と索引でどう引かれるか。この記事の「チャンク単位キャッシュ」の中身に一段降りる話 (同じ持ち込みの続き)。
  • Transfer-Encoding: chunked ── 長さを前置きせず「チャンクごとに長さを書く」もう一つの送り方。Content-Length を出せないストリーミングとの対比。
  • AFD のルールエンジン ── ヘッダを条件で書き換える仕組み。503 回避で Accept-Encoding を剥がす実装の出口。
  • ETag と条件付きリクエスト ── 中断したダウンロードを途中から再開するとき、続きのファイルが「同じ版か」をどう保証するか。Range の隣のピース。