寝たきりLate Night

Next.jsの静的サイトをCloudFront + S3にデプロイしたらリロードで404になった

目次を開く

 

このブログが完成して、意気揚々とデプロイしたときの話です。

トップページからリンクをクリックして記事を開く。問題なし。 レイアウトも崩れてない。よしよし。

念のためリロードしてみると、404ページ

・・・え?

慌てて開発サーバで確認したところ、404 にはならず。・・・もしかしてインフラの問題ってこと?面倒なことになったな~~~と思いながら調査をはじめました。

何が起きていたんだ

このブログは Next.js (App Router) で構築しており、output: 'export' で静的 HTML を生成して S3 にアップロード、CloudFront 経由で配信しています。

症状をまとめるとこうです。

操作 結果
トップページを開く ✅ 正常
リンクをクリックして記事に遷移 ✅ 正常
記事ページをリロード ❌ 404
記事のURLを(curlコマンドなどで)直接開く ❌ 404

つまり、クライアントサイドナビゲーションでは問題ないのに、サーバーへのリクエストが発生すると404になる

なぜ初回遷移は成功したんだ

Next.js の <Link> によるページ遷移は、ブラウザが実際に HTTP リクエストを送りません。JavaScript が DOM を書き換えるだけです。

つまり S3 上にファイルがあろうがなかろうが関係ないのです。

一方、リロードや直接 URL アクセスではブラウザが CloudFront に HTTP リクエストを送るので、S3 上のファイルパスと URL が一致しないと 404 になります。(厳密には 403 を 404 ページにリダイレクトしている)

原因

Next.js の output: 'export' は、デフォルトで各ページを .html ファイルとして出力します。

out/
├── index.html
├── about.html
├── posts/
│   ├── my-article.html
│   └── another-post.html
└── tags/
    ├── Next.js.html
    └── Python.html

CloudFront + S3 (OAC) の構成では、リクエストされた URL パスがそのまま S3 のキーとして使われます。

リクエスト: /posts/my-article
S3キー検索: posts/my-article    ← 存在しない!
実際のキー: posts/my-article.html  ← こっちはある

S3 の静的ウェブサイトホスティングを使えば index.html の自動解決ができますが、OAC(Origin Access Control)とは併用できません。OAC を使う場合、S3 は REST API としてアクセスされるため、キーの完全一致が求められます。

つまり、URLとS3キーがズレているのが原因でした。

どうやって解決したんだ

2 つの変更を組み合わせました。

1. trailingSlash: true を有効にする

// next.config.ts
const nextConfig: NextConfig = {
  output: "export",
  trailingSlash: true,
};

これにより、出力構造が変わります。

out/
├── index.html
├── about/
│   └── index.html
├── posts/
│   └── my-article/
│       └── index.html
└── tags/
    └── Next.js/
        └── index.html

各ページが ディレクトリ/index.html として出力されるようになりました。

2. CloudFront Function でURLをリライトする

S3 にリクエストが届く前に、URL の末尾に /index.html を付けます。

var STATIC_EXT = /\.(ico|png|jpg|jpeg|gif|svg|webp|woff|woff2|ttf|eot|otf|xml|txt|pdf|html|css|json|map)$/i;

function handler(event) {
  var uri = event.request.uri;
  if (uri.endsWith('/')) {
    event.request.uri = uri + 'index.html';
  } else if (!STATIC_EXT.test(uri)) {
    // 静的アセット拡張子以外(ドット入りスラグ含む)は index.html にリライト
    event.request.uri = uri + '/index.html';
  }
  return event.request;
}

Terraform に追加

CloudFront Function は Terraform で以下のように定義しました。

resource "aws_cloudfront_function" "url_rewrite" {
  name    = "${var.function_name_prefix}-url-rewrite"
  runtime = "cloudfront-js-2.0"
  publish = true
  code    = <<-EOT
    var STATIC_EXT = /\.(ico|png|jpg|jpeg|gif|svg|webp|woff|woff2|ttf|eot|otf|xml|txt|pdf|html|css|json|map)$/i;
    function handler(event) {
      var uri = event.request.uri;
      if (uri.endsWith('/')) {
        event.request.uri = uri + 'index.html';
      } else if (!STATIC_EXT.test(uri)) {
        // 静的アセット拡張子以外(ドット入りスラグ含む)は index.html にリライト
        event.request.uri = uri + '/index.html';
      }
      return event.request;
    }
  EOT
}

default_cache_behaviorfunction_association を追加して紐付けます。

default_cache_behavior {
  # ...省略...

  function_association {
    event_type   = "viewer-request"
    function_arn = aws_cloudfront_function.url_rewrite.arn
  }
}

まとめ

Next.js + CloudFront + S3 の構成は定番ですが、OAC を使う場合は URL リライトがほぼ必須です。 全然知らなかったのですが、割と常識らしいです。

勉強不足を痛感しますね。