ottijp blog

Next.jsをオリジンとしたAzure Front Doorでキャッシュを有効にすると net::ERR_HTTP2_PROTOROL_ERROR が発生する時の回避策

  • 2023-12-23

以下のようにNext.jsをオリジンとするAzure Front Doorのキャッシュを有効にした際,クライアントからのリクエストがHTTPプロトコルエラーになる問題が発生しました.

structure

問題発生時は以下のような状況でした.

  • クライアント(Chrome)の開発者コンソール(Networkタブ)において,特定のリクエストに(failed) net::ERR_HTTP2_PROTOROL_ERRORと表示されている.
  • Azure Front Doorのログ(AzureDiagnosticsテーブル)にはキャッシュステータス(cacheStatus_sカラム)が”MISS”と記録されている.
  • App Serviceのログ(AppServiceHTTPLogsテーブル)にはAzure Front Doorからアクセスがあり,206を返していることが記録されている.

どうやらRangeリクエストに対してNext.jsが不正な応答をしているのが問題のようだったので,回避策を考えました.

TL;DR

以下のミドルウェアをNext.jsのプロジェクトに追加します.

middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  const requestHeaders = new Headers(request.headers)
  requestHeaders.delete('range')
  return NextResponse.next({
    request: {
      headers: requestHeaders,
    },
  })
}

環境

  • mac OS: 13.5 (Ventura)
  • Next.js: 14.0.4
  • Apache: 2.4.58
  • curl: 8.1.2

Azure Front DoorからのRangeリクエストに対する応答

調べてみると,Azure Front Doorはキャッシュを有効にした際にオリジンに対してRangeリクエストを送ることがあり,これに対して正しく応答しなければ冒頭の問題が発生することがわかりました.

cf. HTTP2_PROTOCOL_ERROR with Azure CDN - Microsoft Q&A

cf. Azure Front Door でのキャッシュ | Microsoft Learn

Next.jsの応答の確認

Rangeリクエストを送った際,Next.jsがどのように応答するのか確認してみました.

まずはNext.jsアプリケーションを新規で作成し,テスト用のコンテンツとして辞書ファイル(/usr/share/dict/words)を配置しました.

Next.jsのインストールと実行
$ npx create-next-app@latest
Need to install the following packages:
create-next-app@14.0.4
Ok to proceed? (y) y
✔ What is your project named? … range-request-test
✔ Would you like to use TypeScript? … No / Yes
✔ Would you like to use ESLint? … No / Yes
✔ Would you like to use Tailwind CSS? … No / Yes
✔ Would you like to use `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to customize the default import alias (@/*)? … No / Yes
Creating a new Next.js app in /Users/otti/Downloads/range-request/next/range-request-test.

(中略)

$ cp /usr/share/dict/words public/words.txt

$ yarn build && yarn start
(中略)
   - Local:        http://localhost:3000

 ✓ Ready in 165ms

圧縮なし・ありでリクエストした時の応答はそれぞれ次のようになりました.

圧縮なし
$ curl -s -D - -o /dev/null http://localhost:3000/words.txt -r 0-10000
HTTP/1.1 206 Partial Content
Accept-Ranges: bytes
Cache-Control: public, max-age=0
Last-Modified: Fri, 22 Dec 2023 15:17:26 GMT
ETag: W/"260dbd-18c921a4f3f"
Content-Type: text/plain; charset=UTF-8
Content-Range: bytes 0-10000/2493885
Content-Length: 10001
Vary: Accept-Encoding
Date: Sat, 23 Dec 2023 02:37:52 GMT
Connection: keep-alive
Keep-Alive: timeout=5
圧縮あり
$ curl -s -D - -o /dev/null http://localhost:3000/words.txt -r 0-10000 --compressed
HTTP/1.1 206 Partial Content
Accept-Ranges: bytes
Cache-Control: public, max-age=0
Last-Modified: Fri, 22 Dec 2023 15:17:26 GMT
ETag: W/"260dbd-18c921a4f3f"
Content-Type: text/plain; charset=UTF-8
Content-Range: bytes 0-10000/2493885
Vary: Accept-Encoding
Content-Encoding: gzip
Date: Sat, 23 Dec 2023 02:37:49 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked

圧縮ありの場合にContent-Rangeが圧縮後のサイズになっておらず,これがAzure Front Doorできちんと解釈されないため,問題を発生させていると理解しました.

cf. Azure Front Door でのキャッシュ | Microsoft Learn

配信元が応答を圧縮する場合は、Content-Range ヘッダー値が圧縮された応答の実際の長さと一致していることを確認してください。

Apacheの場合の応答の確認

Apacheはどのように応答するのか試しに確認してみました.

Next.jsの場合と同様にテスト用のコンテンツとして辞書ファイル(/usr/share/dict/words)を配置し,deflateを有効にする設定を入れた上でDockerで動作させました.

$ cp /usr/share/dict/words words.txt

$ docker run --rm httpd:2.4.58 cat /usr/local/apache2/conf/httpd.conf > httpd.conf

$ sed -i '' 's|^#LoadModule deflate_module modules/mod_deflate.so$|LoadModule deflate_module modules/mod_deflate.so|' httpd.conf

$ cat <<EOS >> httpd.conf
# cf. https://jyn.jp/apache-setting-deflate/
<IfModule mod_deflate.c>
    DeflateCompressionLevel 1
    <IfModule mod_filter.c>
        FilterDeclare COMPRESS
        FilterProvider COMPRESS DEFLATE "%{CONTENT_TYPE} =~ m#^text/#i"
        FilterChain COMPRESS
        FilterProtocol COMPRESS DEFLATE change=yes;byteranges=no
    </IfModule>
</IfModule>
EOS

$ docker run --rm --name range-request-test -p 8080:80 -v "$PWD":/usr/local/apache2/htdocs/ -v "$PWD/httpd.conf":/usr/local/apache2/conf/httpd.conf httpd:2.4.58

圧縮なし・ありでリクエストした時の応答はそれぞれ次のようになりました.

圧縮なし
$ curl -s -D - -o /dev/null http://localhost:8080/words.txt -r 0-10000
HTTP/1.1 206 Partial Content
Date: Sat, 23 Dec 2023 02:45:37 GMT
Server: Apache/2.4.58 (Unix)
Last-Modified: Fri, 22 Dec 2023 15:07:47 GMT
Accept-Ranges: none
Vary: Accept-Encoding
Content-Range: bytes 0-10000/2493885
Content-Length: 10001
Content-Type: text/plain
圧縮あり
$ curl -s -D - -o /dev/null http://localhost:8080/words.txt -r 0-10000 --compressed
HTTP/1.1 200 OK
Date: Sat, 23 Dec 2023 02:45:42 GMT
Server: Apache/2.4.58 (Unix)
Last-Modified: Fri, 22 Dec 2023 15:07:47 GMT
Accept-Ranges: none
Vary: Accept-Encoding
Content-Encoding: gzip
Transfer-Encoding: chunked
Content-Type: text/plain

Next.jsとは異なり,圧縮ありの場合はRangeリクエストが無視された応答(200応答でContent-Rangeが存在しない)になりました.

Next.jsにおける回避策

以上の調査から,Next.jsにおいてもgzip圧縮時はRangeリクエストを無視し200応答とすることで回避策になりそうだと判断しました. ただし,私のケースでは非gzip圧縮時においてもRangeリクエストを無視して問題なかったので,Rangeリクエスト自体を無視するように実装しました.

Rangeリクエストを無視するには,以下のミドルウェアを作成しプロジェクトルート(もしくはsrcディレクトリ)に配置します.

middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  const requestHeaders = new Headers(request.headers)
  requestHeaders.delete('range')
  return NextResponse.next({
    request: {
      headers: requestHeaders,
    },
  })
}

このミドルウェアを追加した状態で,圧縮なし・ありでリクエストした時の応答はそれぞれ次のようになりました.

圧縮なし
$ curl -s -D - -o /dev/null http://localhost:3000/words.txt -r 0-10000
HTTP/1.1 200 OK
Accept-Ranges: bytes
Cache-Control: public, max-age=0
Last-Modified: Fri, 22 Dec 2023 15:17:26 GMT
ETag: W/"260dbd-18c921a4f3f"
Content-Type: text/plain; charset=UTF-8
Content-Length: 2493885
Vary: Accept-Encoding
Date: Sat, 23 Dec 2023 02:54:05 GMT
Connection: keep-alive
Keep-Alive: timeout=5
圧縮あり
$ curl -s -D - -o /dev/null http://localhost:3000/words.txt -r 0-10000 --compressed
HTTP/1.1 200 OK
Accept-Ranges: bytes
Cache-Control: public, max-age=0
Last-Modified: Fri, 22 Dec 2023 15:17:26 GMT
ETag: W/"260dbd-18c921a4f3f"
Content-Type: text/plain; charset=UTF-8
Vary: Accept-Encoding
Content-Encoding: gzip
Date: Sat, 23 Dec 2023 02:54:08 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked

Apacheの場合と同様に,圧縮ありの場合にRangeリクエストが無視された応答(200応答でContent-Rangeが存在しない)になりました.

この回避策を実装することで,Azure Front Doorからの応答が(failed) net::ERR_HTTP2_PROTOROL_ERRORにならず,正しく動作する(キャッシュも正しく行われる)ことを確認しました.

注意点

Next.js13(13.4.20-canary.13未満)にはprefetchリクエストに対してCache-Controlが付かないバグがあり,Azure Front Doorで誤ってキャッシュされてしまう問題が発生するので,CDNのオリジンとしてNext.jsを構成する場合はこちらにもご注意ください.

cf. Next.js missing cache-control header may lead to CDN caching empty reply · CVE-2023-46298 · GitHub Advisory Database

refs


ottijp
都内でアプリケーションエンジニアをしています
© 2024, ottijp