Next.jsでStatic GenerationしたサイトをAWSでホスティングする
Next.jsでStatic Generation(SG)したWebサイトをVercelにデプロイすると、スムーズにホスティングできます。
AWS上にホスティングする際は、いくつか方法がありますが、比較検討したかったため、Next.jsでSGしたWebサイトをホスティングする方法の考慮すべき点と実現方法をまとめました。
また、これは株式会社FOLIOの2021年アドベントカレンダーの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になります。 具体的には、
-
https://example.com
にアクセス - ページ内のリンクから
https://example.com/about
に遷移 - ブラウザのページリロードを実行
のようなことをすると、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管理)ができないため、選択肢から外しました。