フロントエンドとバックエンドを同一ドメインで構成したとき、CloudFrontのカスタムエラーページ機能に気をつけたい

# はじめに
フロントエンドはReactなどのフレームワークを使い、バックエンドはAPI化するというアーキテクチャはよく見ると思います。基本的にフロントエンドとバックエンドを別のドメインで構成するように設計されるかもしれませんが、やむを得ず同一のドメインで構成することもあると思います。同一ドメインでCloudFrontを使ったときにカスタムエラーページ設定でバックエンドのエラー応答をCloudFrontで上書きしてちょっとハマったので気をつけたいポイントとして残しておきます。

# 構成
今回は簡単に以下の構成で説明してみます。
![overview.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3717446/80877578-b3c6-0e5c-02ce-fbc6adff23b0.png)
検証目的なので`www.hoge.com`部分はCloudFrontがネイティブに提供するFQDNを使います。

# 検証してみた
[こちら](https://github.com/dmorita7474/sample/tree/b4e502a00e2ac84aaee19b89580df3b4716c19ec/cloudfront-custom-error)のコードで検証していきます。
今回は以下の順番で進めます。
1. [検証環境のデプロイ](#1-検証環境のデプロイ)
1. [バックエンドAPIの応答を200から404に書き換え](#2-バックエンドapiの応答を200から404に書き換え)
1. [CloudFrontのカスタムエラーページの追加](#3-cloudfrontのカスタムエラーページの追加)
1. [CloudFront Functionsによるエラーレスポンスのカスタマイズ](#4-cloudfront-functionsによるエラーレスポンスのカスタマイズ)
1. [Lambda@Edgeによるエラーレスポンスのカスタマイズ](#5-Lambda@Edgeによるエラーレスポンスのカスタマイズ)
1. [【おまけ】 CloudFront Functionsによるメンテナンスページの表示](#6-おまけ-cloudfront-functionsによるメンテナンスページの表示)


## 1. 検証環境のデプロイ
対象のリポジトリをCloneした後、フロントエンドアプリのビルドを行います。※この手順ではDocker上でビルドします。
```zsh
cd cloudfront-custom-error/files/frontend-app
docker run -it --rm -v $PWD:/app -w /app node:alpine3.21 npm run build 
```

続いてterraformを実行します。※AWSのクレデンシャルの設定は省略します。
```zsh
cd ../..
terraform init
terraform apply
```
ルートパスにアクセスすると以下のようなフロントエンドの画面が返されるようにしています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3717446/ff3473cc-4491-7ec5-7e5f-aa278d04e7ef.png)

Submitボタンを押すとバックエンドに通信が走り、レスポンスBody内のMessageが以下のように応答結果が表示されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3717446/3d26c88b-d716-d091-5846-1b0a859638a1.png)

## 2. バックエンドAPIの応答を200から404に書き換え
バックエンドから404を返すように設定した場合、以下のような表示になります。尚、今回のサンプルではAPI GatewayのMockを使って固定で返すようにしています。terraformのvariable `backend_status_code` を404に設定して`terraform apply`することで返却するステータスコードを変更します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3717446/91df86f3-fb5a-886c-caf3-97f4dfe62b81.png)
変更後アクセスすると404応答が返され、レスポンスBody内のMessageが表示されました。

**処理イメージ**
ここまでの処理イメージは以下の通りです。

```mermaid
sequenceDiagram
　　Browser ->>+ CloudFront: GET /
    CloudFront ->>+ S3: GET /index.html
    S3-->>-CloudFront: html
    CloudFront-->>-Browser:html
    Browser ->>+ CloudFront: GET(fetch) /api/hello
    CloudFront ->> APIGW: GET /api/hello
    alt 200
      APIGW-->>CloudFront: json({"message": "hello"})
      CloudFront-->>Browser: json({"message": "hello"})
    else 404
      APIGW-->>CloudFront: json({"message": "Not Found"})
      CloudFront-->>-Browser: json({"message": "Not Found"})
    end
```



## 3. CloudFrontのカスタムエラーページの追加
よく使われるであろうCloudFrontの機能のカスタムエラーページで404エラーの設定をしてみます。terraformのmain.pyに記載している以下のコメントアウトを解除して再度`terraform apply`します。※CloudFrontの更新なので少し時間がかかります。
```terraform
  custom_error_response = [
    {
      error_code         = 404
      response_code      = 404
      response_page_path = "/404.html"
    },
    {
      error_code         = 503
      response_code      = 503
      response_page_path = "/error500.html"
    }
  ]
```

更新完了後再度Submitボタンを押下すると以下のような画面表示になりました。フロントエンドがAPIからJSONで返されることを期待した作りとしているため、HTMLが返ってきたことによって例外が発生してしまっています。フロントエンドとバックエンドAPIが同一のドメインであるが故にCloudFrontのカスタムページの仕組みで応答が変わってしまってしまいました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3717446/ff527b8b-c7af-3281-59e1-4486a0373108.png)

フロントエンド上に存在しないリソース（`{cloudfrontのFQDN}/sample`）にアクセスしてみると以下のような画面が表示されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3717446/e624e49c-485c-af2f-679b-9003459b218e.png)

**処理イメージ**
ここまでの処理イメージは以下の通りです。404のレスポンス部分でAPIGWから返ってくるjsonをCloudFrontがhtmlに変えてBrowserに返してしまいます。

```mermaid
sequenceDiagram
    alt 200
　　   Browser ->>+ CloudFront: GET /
      CloudFront ->>+ S3: GET /index.html
      S3-->>-CloudFront: html
      CloudFront-->>-Browser:html
      Browser ->>+ CloudFront: GET(fetch) /api/hello
      CloudFront ->> APIGW: GET /api/hello
      alt 200
        APIGW-->>CloudFront: json({"message": "hello"})
        CloudFront-->>Browser: json({"message": "hello"})
      else 404
        APIGW-->>CloudFront: json({"message": "Not Found"})
        CloudFront ->>+ S3: GET /404.html
        S3-->>-CloudFront: html
        CloudFront-->>-Browser: html(custom error page)
      end
    else 404
      Browser ->>+ CloudFront: GET /sample
      CloudFront ->>+ S3: GET /sample/index.html
      S3-->>-CloudFront: xml
      CloudFront ->>+ S3: GET /404.html
      S3-->>-CloudFront: html
      CloudFront-->>-Browser:html
    end
```

## 4. CloudFront Functionsによるエラーレスポンスのカスタマイズ
APIからの応答はJSONのまま、フロントエンドのエラーはhtmlで返したいので他の方法を考えてみます。
まずはCloudFrontのリクエスト・レスポンスをカスタムする方法として簡単に実装できるCloudFront Functionsを使ってみます。

またこちらをコメントアウトして
```terraform
  #custom_error_response = [
  #  {
  #    error_code         = 404
  #    response_code      = 404
  #    response_page_path = "/error404.html"
  #  },
  #  {
  #    error_code         = 503
  #    response_code      = 503
  #    response_page_path = "/error500.html"
  #  }
  #]
```
以下のコメントアウト・インを行い、`terraform apply`を実行します。
**before**
```terraform
    function_association = {
      # 通常用
      "viewer-request" = {
        function_arn = aws_cloudfront_function.main.arn
      }
      #"viewer-response" = {
      #  function_arn = aws_cloudfront_function.viewer_response.arn
      #}
      # エラーページ用
      #"viewer-request" = {
      #  function_arn = aws_cloudfront_function.maintenance_request.arn
      #}
      #"viewer-response" = {
      #  function_arn = aws_cloudfront_function.maintenance_response.arn
      #}
      #"viewer-response" = {
      #  function_arn = aws_cloudfront_function.error_custom_response.arn
      #}
    }
```
**after**
```terraform
    function_association = {
      # 通常用
      "viewer-request" = {
        function_arn = aws_cloudfront_function.main.arn
      }
      #"viewer-response" = {
      #  function_arn = aws_cloudfront_function.viewer_response.arn
      #}
      # エラーページ用
      #"viewer-request" = {
      #  function_arn = aws_cloudfront_function.maintenance_request.arn
      #}
      #"viewer-response" = {
      #  function_arn = aws_cloudfront_function.maintenance_response.arn
      #}
      "viewer-response" = {
        function_arn = aws_cloudfront_function.error_custom_response.arn
      }
    }
```

`viewer-response`に定義したfunctionではレスポンスコードを判定して固定のhtmlを返すスクリプトを組んでいます。これが上手く動けば「ページがみつかりません。」というメッセージが表示されます。設定し、改めて先ほど404エラーになったページへアクセスすると以下のような味気ない画面が返されました。
![スクリーンショット 2024-12-17 15.21.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3717446/66430570-832f-4ed7-6915-3e8217e26391.png)
[こちら](https://dev.classmethod.jp/articles/http-status-response-generation-cloudfront-functions/?t)の記事の通り残念ながらCloudFront Functionsはステータスコードが400以上の時発動しないようでレスポンスボディの書き換えはできないので、フロントエンドの404等標準的なエラーページ表示には使えなさそうです。

**処理イメージ**
今回やろうとしたことは以下の通りです。※フロントエンド部分のみ抜粋。
```mermaid
sequenceDiagram
  Browser ->>+ CloudFront: GET /sample
  CloudFront ->> S3: GET /sample
  S3-->>CloudFront: xml
  CloudFront-->>-Browser: html(Custom with CloudFront Functions)
```

## 5. Lambda@Edgeによるエラーレスポンスのカスタマイズ
次にCloudFront Functionsよりも少し設定が面倒な分、凝ったことができるLambda@Edgeを試してみます。
まず、先程の変更を元に戻しつつ、Lambda@Edgeを紐づける設定も併せて行います。変更後、`terraform apply`を実行してください。
**before**
```terraform
    function_association = {
      # 通常用
      "viewer-request" = {
        function_arn = aws_cloudfront_function.main.arn
      }
      #"viewer-response" = {
      #  function_arn = aws_cloudfront_function.viewer_response.arn
      #}
      # エラーページ用
      #"viewer-request" = {
      #  function_arn = aws_cloudfront_function.maintenance_request.arn
      #}
      #"viewer-response" = {
      #  function_arn = aws_cloudfront_function.maintenance_response.arn
      #}
      "viewer-response" = {
        function_arn = aws_cloudfront_function.error_custom_response.arn
      }
    }
    #lambda_function_association = {
    #  "origin-response" = {
    #    lambda_arn = module.lambda_at_edge.lambda_function_qualified_arn
    #  }
    #}
```
**after**
```terraform
    function_association = {
      # 通常用
      "viewer-request" = {
        function_arn = aws_cloudfront_function.main.arn
      }
      #"viewer-response" = {
      #  function_arn = aws_cloudfront_function.viewer_response.arn
      #}
      # エラーページ用
      #"viewer-request" = {
      #  function_arn = aws_cloudfront_function.maintenance_request.arn
      #}
      #"viewer-response" = {
      #  function_arn = aws_cloudfront_function.maintenance_response.arn
      #}
      #"viewer-response" = {
      #  function_arn = aws_cloudfront_function.error_custom_response.arn
      #}
    }
    lambda_function_association = {
      "origin-response" = {
        lambda_arn = module.lambda_at_edge.lambda_function_qualified_arn
      }
    }
```
反映後、改めて`{cloudfrontのFQDN}/sample`にアクセスしてみると想定どおり「ページがみつかりません。」というページが表示されました。（味気ない表示であることはスルーでお願いします。）Lambda@Edgeであればレスポンスをカスタマイズできることが確認できました。これを使えばCloudFrontのカスタムエラーページの代わりにフロントエンドのエラーレスポンスを設定しつつ、バックエンドAPIのエラーを上書きしてしまうのを回避できそうです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3717446/bb65c6a6-379a-1339-adea-7226def6b58c.png)

**処理イメージ**
今回行ったことは以下の通りです。※フロントエンド部分のみ抜粋。
```mermaid
sequenceDiagram
  Browser ->>+ CloudFront: GET /sample
  CloudFront ->> S3: GET /sample
  S3-->>CloudFront: xml->html(Custom with Lambda@Edge)
  CloudFront-->>-Browser: html
```

## 6. 【おまけ】 CloudFront Functionsによるメンテナンスページの表示
フロントエンドにパスルーティングされるところでリクエスト・レスポンスを書き換えてエラーページを出すように変えてみます。想定されるケースとしてはフロントエンドを閉鎖するメンテナンスページを表示したい時などです。


以下のコメントアウト・インを行います。

**before**
```terraform
    function_association = {
      # 通常用
      "viewer-request" = {
        function_arn = aws_cloudfront_function.main.arn
      }
      #"viewer-response" = {
      #  function_arn = aws_cloudfront_function.viewer_response.arn
      #}
      # エラーページ用
      #"viewer-request" = {
      #  function_arn = aws_cloudfront_function.maintenance_request.arn
      #}
      #"viewer-response" = {
      #  function_arn = aws_cloudfront_function.maintenance_response.arn
      #}
      #"viewer-response" = {
      #  function_arn = aws_cloudfront_function.error_custom_response.arn
      #}
    }
    lambda_function_association = {
      "origin-response" = {
        lambda_arn = module.lambda_at_edge.lambda_function_qualified_arn
      }
    }
```

**after**
```terraform
    function_association = {
      # 通常用
      "viewer-request" = {
        function_arn = aws_cloudfront_function.main.arn
      }
      #"viewer-response" = {
      #  function_arn = aws_cloudfront_function.viewer_response.arn
      #}
      # エラーページ用
      "viewer-request" = {
        function_arn = aws_cloudfront_function.maintenance_request.arn
      }
      "viewer-response" = {
        function_arn = aws_cloudfront_function.maintenance_response.arn
      }
      #"viewer-response" = {
      #  function_arn = aws_cloudfront_function.error_custom_response.arn
      #}
    }
    #lambda_function_association = {
    #  "origin-response" = {
    #    lambda_arn = module.lambda_at_edge.lambda_function_qualified_arn
    #  }
    #}
```

リロードすると以下の画面になりました。CloudFront Functionsでviewer-requestとviewer-responseをカスタムすることでメンテナンス画面表示は実現できそうです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3717446/7b92a811-cfee-3194-9978-4cbc0bbee570.png)

**処理イメージ**
```mermaid
sequenceDiagram
  Browser ->>+ CloudFront: GET / -> /error500.html(Custom with CloudFront Functions)
  CloudFront ->> S3: GET /error500.html
  S3-->>CloudFront: html
  CloudFront-->>-Browser: html status200 -> 503(Custom with CloudFront Functions)
```

一通り検証が完了しました。料金節約の為、最後に今回作った環境を`terraform destroy`で削除して完了です。


# 所感
今回のようにフロントエンドをCSR、バックエンドをAPIで構成するケースは多いかと思いますが、エラーページの制御がツラいので、できればCORSなどの設定等面倒はあれど、別のFQDNで構成するのが良さそうだと思いました。

最後まで読んでいただきありがとうございました。