Cloudfront + S3 + API GatewayでSPAとAPIを公開する時のTips

良くあるこのパターンですが、自分なりに辿り着いたプラクティスを紹介しておきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33070/b35f713b-1eb6-dcaa-59a3-01107fb26fef.png)


## 先に結論
- SPA: Cloudfront Functionsを使って拡張子付きのパスを/index.htmlに変更する
- API: Behaviorで/apiをAPI Gatewayに向ける

## 背景
今まで、Cloudfront + S3 でVueやAngular等のSPAアプリケーションを公開する場合、
存在しないパスにリクエストすることで、S3が403もしくは404を返す。
そのため、Cloudfront側でカスタムエラーページを作成し、/index.htmlにリダイレクトするような設定をしていた。

### よくある悩み１（SPAのパス問題）
本来キャッチすべき404があった場合でも気にせずリダイレクトされてしまう。
例えばCSSやJSのアップロードミスがあった場合は気づきにくい。

### よくある悩み２（Cloudfrontで特定のパスを振り分けたい問題）
/api のパスはAPI Gatewayに振り分けたい。
CORSの設定が不要となるので、セキュリティ的にもよさそうな方法。

## SPAのパス問題
`Cloudfront Functions`を使うと、エラーページを待たずして良い感じで`/users/xxx`のようなリクエストを `/index.html` に振り分けることができます。

- `Cloudfront Functions`とは、エッジでイベントを処理するコードをかけるものになります。
- 同じようなものに`lambda@edge`がありますが、違いについては[こちら](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/edge-functions.html
)をご参照ください。
- lambda@edgeから機能を限定して簡単な処理のみ行えるようになったものと理解すればよさそうです。


また、Cloudfront Functionsを手っ取り早く試したい方はこちらを試してみると良いです。
[Cloudfront Functions tutorial](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/functions-tutorial.html)

### terraformの例

#### Cloudfront Functionsの設定
このようにリソースを定義します。（AWS Providerの設定等、直接関係のない部分は省略しています）
パラメータの説明は[ドキュメント](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudfront_function)を見てください。

- `code` に実際の処理を書きます。ここでは別ファイルにして読み込んでます。
- `publish = true` にすることで、公開状態にすることができます。
```cloudfront_functions.tf
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)`
```cloudfront_functions/spa-redirect.js
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については[こちら](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/lambda-cloudfront-trigger-events.html)を参考にしてください。
※Cloudfront Functionsでは`viewer-request`と`viewer-response`にのみ対応しています。
```cloudfront.tf
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に置き換えられそう）

```cloudfront.tf 
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
    }

  }
...
}
```

以上、当たり前でしょ！と思う方もいるかもしれませんが、インフラ構成時に迷った時の参考になると嬉しいです。

参考：
[AWS CloudFront FunctionsでSPAのルーティング処理](https://qiita.com/seapolis/items/0afd49d24b12749d93ab)