良くあるこのパターンですが、自分なりに辿り着いたプラクティスを紹介しておきます。
先に結論
- SPA: Cloudfront Functionsを使って拡張子付きのパスを/index.htmlに変更する
- API: Behaviorで/apiをAPI Gatewayに向ける
背景
今まで、Cloudfront + S3 でVueやAngular等のSPAアプリケーションを公開する場合、
存在しないパスにリクエストすることで、S3が403もしくは404を返す。
そのため、Cloudfront側でカスタムエラーページを作成し、/index.htmlにリダイレクトするような設定をしていた。
よくある悩み1(SPAのパス問題)
本来キャッチすべき404があった場合でも気にせずリダイレクトされてしまう。
例えばCSSやJSのアップロードミスがあった場合は気づきにくい。
よくある悩み2(Cloudfrontで特定のパスを振り分けたい問題)
/api のパスはAPI Gatewayに振り分けたい。
CORSの設定が不要となるので、セキュリティ的にもよさそうな方法。
SPAのパス問題
Cloudfront Functions
を使うと、エラーページを待たずして良い感じで/users/xxx
のようなリクエストを /index.html
に振り分けることができます。
-
Cloudfront Functions
とは、エッジでイベントを処理するコードをかけるものになります。 - 同じようなものに
lambda@edge
がありますが、違いについてはこちらをご参照ください。 - lambda@edgeから機能を限定して簡単な処理のみ行えるようになったものと理解すればよさそうです。
また、Cloudfront Functionsを手っ取り早く試したい方はこちらを試してみると良いです。
Cloudfront Functions tutorial
terraformの例
Cloudfront Functionsの設定
このようにリソースを定義します。(AWS Providerの設定等、直接関係のない部分は省略しています)
パラメータの説明はドキュメントを見てください。
-
code
に実際の処理を書きます。ここでは別ファイルにして読み込んでます。 -
publish = true
にすることで、公開状態にすることができます。
resource "aws_cloudfront_function" "spa_redirect" {
name = "${var.product_name}-${var.env}-spa-redirect"
runtime = "cloudfront-js-1.0"
comment = "${var.product_name}-${var.env}-spa-redirect"
publish = true
code = file("cloudfront_functions/spa-redirect.js")
}
関数部分
かなり古い書き方をしていますが、なぜかECMAScript 5.1 準拠
である必要があります。
これについては、ぜひ改善していただけるとありがたいですね。
- 拡張子の有無はドットの有無で判断しています。
if(request.uri.indexOf(".") === -1)
var index = '/index.html';
function handler(event) {
var request = event.request;
// 拡張子が見つからない場合(実ファイルではないアクセス)
if(request.uri.indexOf(".") === -1) {
request.uri = index;
}
return request;
}
Cloudfront FunctionsをCloudfrontに適応する設定
長々と書いていますが、注目するのは下の方のfunction_association
だけです。
- behavior毎に設定することができます!
- event_type は リクエスト時なので
viewer-request
と書いておきます。 - event_typeについてはこちらを参考にしてください。
※Cloudfront Functionsではviewer-request
とviewer-response
にのみ対応しています。
resource "aws_cloudfront_distribution" "front" {
origin {
domain_name = aws_s3_bucket.front.bucket_regional_domain_name
origin_id = local.s3_origin_id
s3_origin_config {
origin_access_identity = aws_cloudfront_origin_access_identity.origin.cloudfront_access_identity_path
}
}
enabled = true
is_ipv6_enabled = true
comment = "${var.product_name}-${var.env}"
default_root_object = "index.html"
aliases = ["test.${data.terraform_remote_state.network.outputs.domain}"]
default_cache_behavior {
allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
cached_methods = ["GET", "HEAD"]
target_origin_id = local.s3_origin_id
forwarded_values {
query_string = true
cookies {
forward = "none"
}
}
function_association {
event_type = "viewer-request"
function_arn = aws_cloudfront_function.spa_redirect.arn
}
viewer_protocol_policy = "allow-all"
min_ttl = 0
default_ttl = 0
max_ttl = 0
}
...省略
}
/apiを分けたい
前述したとおり、 cloudfrontのbehaviorで振り分ければOKです
serverless frameworkやCDKを使用してAPI Gatewayを作成する人も多いと思いますが、
Cloudformationから値を取得すると便利です。
ポイント
-
origin
のdomain_name
にcloudformationからエンドポイントとステージを取得しています -
behavior
のpath_pattern
にapi/*
を指定しています - APIGatewayへのアクセスに
/api
は不要なので、lambda@edgeで除去しています(ここもcloudfront functionsに置き換えられそう)
resource "aws_cloudfront_distribution" "front" {
...
origin {
custom_origin_config {
http_port = "80"
https_port = "443"
origin_protocol_policy = "https-only"
origin_ssl_protocols = ["TLSv1.2"]
}
# ServiceEndpointから xxx.execute-api.ap-northeast-1.amazonaws.com と v1 を抜き出す
domain_name = split("/", data.aws_cloudformation_stack.api_test.outputs["ServiceEndpoint"])[2]
origin_id = var.product_name
}
...
ordered_cache_behavior {
path_pattern = "api/*"
allowed_methods = ["HEAD", "DELETE", "POST", "GET", "OPTIONS", "PUT", "PATCH"]
cached_methods = ["GET", "HEAD"]
target_origin_id = var.product_name
forwarded_values {
headers = ["Authorization"]
query_string = true
cookies {
forward = "none"
}
}
min_ttl = 0
default_ttl = 10
max_ttl = 10
viewer_protocol_policy = "https-only"
lambda_function_association {
event_type = "origin-request"
lambda_arn = aws_lambda_function.redirect_trim_context.qualified_arn
}
}
...
}
以上、当たり前でしょ!と思う方もいるかもしれませんが、インフラ構成時に迷った時の参考になると嬉しいです。