Next.jsでStatic Generation(SG)したWebサイトをVercelにデプロイすると、スムーズにホスティングできます。

AWS上にホスティングする際は、いくつか方法がありますが、比較検討したかったため、Next.jsでSGしたWebサイトをホスティングする方法の考慮すべき点と実現方法をまとめました。

また、これは株式会社FOLIO2021年アドベントカレンダーの9日目の投稿でもあります。

AWSでホスティングする方法の候補

今回はNext.jsでSGしたWebサイトには独自ドメインでアクセスすることを想定しています。 これを実現するには大きく以下の2つの方法が考えられます。

  • 方法1: CloudFrontのDefault Root Objectとオリジンアクセスアイデンティティ(OAI)を使用してS3バケットにアクセス
  • 方法2: S3バケットの静的Webサイトホスティングを使用

「CloudFrontのDefault Root Objectとオリジンアクセスアイデンティティ(OAI)を使用してS3バケットにアクセス」について

この方法は一般的な静的Webサイトのホスティングで使われています。 Default Root ObjectとOAIを作成することで、S3バケットをプライベートモードにしつつWebサイトをホスティングできます。

また、CloudFrontを経由することで、コンテンツをキャッシュできます。

「CloudFrontのDefault Root Objectとオリジンアクセスアイデンティティ(OAI)を使用してS3バケットにアクセス」の問題点

Next.jsでSGしたWebサイトをこの方法でホスティングすると、サイト内でページ遷移した後、ページをリロードしすると404になります。 具体的には、

  1. https://example.comにアクセス
  2. ページ内のリンクからhttps://example.com/aboutに遷移
  3. ブラウザのページリロードを実行

のようなことをすると、404となってしまいます。

これは、/aboutというパスでアクセスされると、text/html/aboutファイルを表示しようとするためです。

Next.jsのデフォルトの設定の場合、next exportすると

.
├── _next
├── about.html
└── index.html

のようなファイルが生成されます。そのため、404となってしまいます。

/about/index.htmlを作成することも検討しましたが、Default Root Objectの仕様上、サブディレクトリのindex.htmlが表示できません。

「CloudFrontのDefault Root Objectとオリジンアクセスアイデンティティ(OAI)を使用してS3バケットにアクセス」の解決策 - デプロイ時に.html拡張子を削除する方法

S3バケットへのデプロイ時に、index.html以外のHTMLファイル名から拡張子を削除し、対象のファイルのメタデータをtext/htmlに変更することでこの問題を解決できます。

uploading_directory="アップロードするルートディレクトリパス"
find "${uploading_directory}" -type f | while read file_path;do
  key="${file_path#${uploading_directory}}"
  if [[ "${file_path}" =~ \.html$ ]] && [[ "${file_path}" != "index.html" ]] && [[ ! "${file_path}" =~ /index\.html$ ]];then
    key="${key%.html}"
    aws s3api put-object \
      --bucket "バケット名" \
      --key "${key}" \
      --body "${file_path}" \
      --content-type "text/html"
  else
    aws s3api put-object \
      --bucket "バケット名" \
      --key "${key}" \
      --body "${file_path}"
  fi
done

このスクリプトはindex.html以外のHTMLファイルの拡張子を取り除いて、メタデータをtext/htmlにしてアップロードしています。 アップロードが直列に実行されてしまっている部分は改善が必要ですが、要件は達成できます。

ただ、この方法は必要のないファイルが残り続けてしまうため、本来なら

aws s3 sync --exact-timestamps --delete "アップロードするルートディレクトリ名" "s3://バケット名/"

のように必要ないファイルは削除したいです。

しかし、一般的なOS上では「aboutファイル」と「aboutディレクトリ」は区別できません。 そのため、aws s3 syncコマンド以外の方法を検討する必要があり、このようなスクリプト例を提示しました。

「CloudFrontのDefault Root Objectとオリジンアクセスアイデンティティ(OAI)を使用してS3バケットにアクセス」の解決策 - Lambda@Edgeを使用する方法

Lambda@Edgeを使用し、/aboutにアクセスしてきたら、.htmlを付与してabout.htmlにアクセスするように変更します。

import { Handler } from "aws-lambda";

type Event = {
  Records: {
    cf: {
      request: {
        uri: string;
      };
    };
  }[];
};

type EdgeHander = Handler<Event, any>;

export const handler: EdgeHander = async (event) => {
  const { request } = event.Records[0].cf;

  // トップページのリクエストは加工せずにそのままリクエストを通す
  if (request.uri === "/" || request.uri === "") {
    return request;
  }

  const page = request.uri.split("/").pop();

  if (page === "") {
    // 末尾がスラッシュの場合は、スラッシュなしのページにリダイレクト
    return {
      status: "302",
      statusDescription: "Found",
      headers: {
        location: [
          {
            key: "Location",
            value: request.uri.replace(/\/+$/, ""),
          },
        ],
      },
    };
  }

  if (!page.match(/\.html$/)) {
    // .html拡張子がついてなければ.html拡張子をつける
    request.uri = `${request.uri}.html`;
  }

  return request;
};

上記のようなLambdaを動かすことで、問題を解決できます。

「S3バケットの静的Webサイトホスティングを使用」について

Next.jsにはtrailingSlashオプションがあり、これを有効にしてnext exportすることで、

.
├── _next
├── about
│   └── index.html
└── index.html

のようなファイルが生成されます。 S3バケットの静的Webサイトホスティングは、サブディレクトリにアクセスするとindex.htmlを表示する挙動をするため、/aboutにアクセスすると404にはなりません。

「S3バケットの静的Webサイトホスティングを使用」の問題点

S3バケットに直接アクセスしてしまうと、Webサイトのキャッシュが効きません。 そのため、一般的にはCloudFrontなどのCDNを挟みます。

また、静的Webサイトホスティングを有効化している場合、S3バケットはパブリック公開されているため、CDNを経由しないアクセスも可能です。 しかしその場合、想定外の費用が発生することになります。

「S3バケットの静的Webサイトホスティングを使用」の解決策 - バケットポリシーでRefererの値を参照する方法

S3バケットのバケットポリシーを利用すると、アクセス元のRefererによってアクセスを制限できます。 CloudFrontにはオリジンへのリクエスト時に独自のRefererを設定できるため、お互いにしか知り得ない値を設定しておくことで、余計なアクセスをなくすことができます。

以下はterraformで設定する場合の例です。

resource "aws_s3_bucket" "web" {
  bucket = "nextjs-web"
  acl = "public-read"

  versioning {
    enabled = false
  }

  server_side_encryption_configuration {
    rule {
      apply_server_side_encryption_by_default {
        sse_algorithm = "AES256"
      }
    }
  }

  website {
    index_document = "index.html"
  }
}

resource "random_id" "restriction_header_value" {
  # 推測不可能な値にしたかったため、ある程度長い値にしている
  byte_length = 50
}

data "aws_iam_policy_document" "web_bucket" {
  statement {
    effect = "Allow"
    actions = [
      "s3:GetObject"
    ]

    principals {
      type        = "AWS"
      identifiers = ["*"]
    }

    resources = [
      "${aws_s3_bucket.web.arn}/*"
    ]

    # Refererに特定の値がないとアクセスできないように制限
    condition {
      test     = "StringLike"
      values   = [random_id.restriction_header_value.b64_url]
      variable = "aws:Referer"
    }
  }
}

resource "aws_s3_bucket_public_access_block" "web_bucket" {
  bucket                  = aws_s3_bucket.web.id
  block_public_acls       = true
  block_public_policy     = false
  ignore_public_acls      = true
  restrict_public_buckets = false
}

resource "aws_s3_bucket_policy" "web_bucket" {
  /**
   * 「aws_s3_bucket_public_access_block」と「aws_s3_bucket_policy」を同時に作成または削除すると
   * 'A conflicting conditional operation is currently in progress against this resource'
   * と言われるため、同時には作成しないようにしている
   */
  depends_on = [
    aws_s3_bucket_public_access_block.web_bucket
  ]
  bucket = aws_s3_bucket.web.id
  policy = data.aws_iam_policy_document.web_bucket.json
}

resource "aws_cloudfront_distribution" "web" {
  aliases = [
    "<ドメイン名>"
  ]
  enabled         = true
  is_ipv6_enabled = true
  price_class = "PriceClass_100"
  default_root_object = "index.html"

  origin {
    domain_name = aws_s3_bucket.web.website_endpoint
    origin_id   = aws_s3_bucket.web.id

    custom_origin_config {
      http_port              = 80
      https_port             = 443
      origin_protocol_policy = "http-only"
      origin_ssl_protocols = ["TLSv1.2"]
    }

    # バケットポリシーのRefererの制限を突破するためにカスタムヘッダーを設定
    custom_header {
      name  = "Referer"
      value = random_id.restriction_header_value.b64_url
    }
  }

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }

  default_cache_behavior {
    ...
  }

  viewer_certificate {
    ...
  }
}

各解決策の懸念点について

各解決策にはそれぞれ以下のような懸念点がありました。

  • 「CloudFrontのDefault Root Objectとオリジンアクセスアイデンティティ(OAI)を使用してS3バケットにアクセス」の解決策 - デプロイ時に.html拡張子を削除する方法
    • デプロイスクリプトを作り込まなければいけない
  • 「CloudFrontのDefault Root Objectとオリジンアクセスアイデンティティ(OAI)を使用してS3バケットにアクセス」の解決策 - Lambda@Edgeを使用する方法
    • 管理するリソースが増える
    • 呼び出し回数の無料の上限を超えると、Lambdaの費用が発生する
  • 「S3バケットの静的Webサイトホスティングを使用」の解決策 - バケットポリシーでRefererの値を参照する方法
    • Refererに設定した値が分かれば、直接S3バケットの静的Webサイトホスティングにアクセスできる

終わりに

今回挙げた解決策の中で、コスト増加リスク、およびメンテナンスコストが低いと個人的に考えているのは、「S3バケットの静的Webサイトホスティングを使用」の解決策 - バケットポリシーでRefererの値を参照する方法です。

どの選択肢も、若干の懸念点はある状態にはなってしまったため、もしより良い方法をご存じの方はご教示ください。

また、今回挙げた方法以外にも、AWS Amplifyを使用する方法があります。 しかし、AmplifyはAWSリソースをコード管理(Terraform管理)ができないため、選択肢から外しました。