SPAのメンテナンスページをCloudFront Functionsを使って実現する

## 概要
複数のフロントエンド × 単一のバックエンドを持つSPA (Single Page Application)のメンテナンスモードを実装しました。
インフラとしてはAWSを使っています。
今回は諸々の事情でWAFにてメンテナンスレスポンスを返すのではなく、一段踏み込んだCloudFont Functions（以降CF2）でのメンテナンスレスポンス返却を行っています。

### 全体図
[![Image from Gyazo](https://i.gyazo.com/d00e65f71a04a6d30f08c908f9bb164f.png)](https://gyazo.com/d00e65f71a04a6d30f08c908f9bb164f)

## 要件
- 複数のフロントエンドドメインがあるがそれら全てにおいて、メンテナンスモード中はユーザーにメンテナンス画面を表示したい
- 特定のIPからはメンテナンス中でも通常通り触れるようにしたい

## フロント実装手順
### 各々フロントエンドにてメンテナンスページとrouterを準備
ここらへんは好きなように準備
```vue:maintenance.vue
<template>
  <div class="maintenance__page">
    <div class="maintenance__page--wrap">
      <h2>ただいまメンテナンス中です</h2>
      <div class="announce__area">
        <h3>【メンテナンス予定期間】</h3>
        <p class="period__text">{{ start }} ～ {{ end }}頃(予定)</p>
        <p>※作業状況により、時間が多少前後する場合がございます。</p>
      </div>
      <p>ご利用の皆様にはご迷惑をおかけし、大変申し訳ございません。</p>
    </div>
  </div>
</template>

<script lang="ts" setup>
import axios from 'axios'
import { onMounted } from 'vue'
const start = $router.currentRoute.query.start?.toString()
const end = $router.currentRoute.query.end?.toString()

onMounted(async () => {
  try {
    await axios.get(`/healthcheck`)
    // リロード時にメンテナンスが終了していたら(/healthcheckで503エラーが起きなかったら)TOPに飛ばす
    $router.push(`/`)
  } catch (e) {
    // メンテナンス中は503が返ってくる ここは握りつぶしてOK
  }
})
</script>

```
```typescript:router.ts
import Maintenance from './pages/maintenance.vue'
const routes = [
    {
      path: '/maintenance',
      name: 'maintenance',
      component: Maintenance,
      props: (route) => ({ start: route.query.start, end: route.query.end }),
      meta: { isPublic: true }, // 未認証状態でもこのページはアクセスできるようにしている
    },
]
```
### axiosを全体で503エラーの発生をインターセプト
`axios.interceptors`を使用する
プロジェクト全体の`axios`使用部分で503エラーを検知することで、バックエンドから503のレスポンスが帰ってきたらメンテナンスページに移行させる
```typescript:main.ts
/**
 * 503エラーの発生を全体でインターセプトすることでメンテナンスページに移行させる
 */
axios.interceptors.response.use(
  (response) => {
    // 通常のレスポンスはそのまま次のハンドラに
    return response
  },
  (error) => {
    // エラーレスポンスがない場合はそのまま次のハンドラに
      if (!error.response) return Promise.reject(error)
    
      const start = error.response.data?.maintenanceStart
      const end = error.response.data?.maintenanceEnd
      if (error.response.status === 503 && start && end) {
        // 503エラーで maintenanceStart と maintenanceEnd が存在した場合、メンテナンスページにリダイレクト
        $core.$router.push({
          '/maintenance',
          query: {
            start,
            end,
          },
        })
      }
      // それ以外のエラーはそのまま次のハンドラに渡す
      return Promise.reject(error)
  },
)
```

これでバックエンドから503のエラーレスポンスが返ってきたらメンテナンスページへ移行できるようになる。

## バックエンド実装手順
WAFのルールでも503レスポンスを返却することはできるが諸事情(後述)によりCF2を採用する。

:::note info
CF2では
- `request`オブジェクトを返却すればオリジンにそのままアクセスを流すことができる
- `response`オブジェクトを返却すればオリジンにアクセスさせることなく生成したレスポンスをアクセス元に返却することができる
:::

### CF2コードを準備
- CloudFront > 関数 > 関数を作成
- 名前：適当に
- Runtime：cloudfront-js-2.0
```javascript
function handler(event) {
    // メンテナンスモード時はここを手動で動かす
    const isMaintenance = false
    const maintenanceStart = "2024年7月22日 9:00"
    const maintenanceEnd = "2024年7月22日 12:00"
    // 手動で動かすのここまで

    const request = event.request

    if (!isMaintenance) {
        // メンテナンスモード中以外はそのままリクエストをオリジンに通す
        return request
    }

    const response = {
        statusCode: 503,
        statusDescription: 'Service Unavailable',
        headers: {
            'content-type': { value: 'application/json' },
            'access-control-allow-credentials': { value: 'true' },
        },
        body: JSON.stringify({
            "maintenanceStart": maintenanceStart,
            "maintenanceEnd": maintenanceEnd,
            "message": "システムメンテナンス中です。"
        })
    }

    // メンテナンスモード中もオリジンへのアクセスを許可するIP群
    const allowedIPs = [
        'XXX.XXX.XX.XX',
        'YY.YYY.YY.YY',
        'Z.ZZZ.ZZZ.ZZZ'
    ]

    // 許可オリジン フロントエンドのオリジン
    const allowedOrigins = [
        'https://front.a.com',
        'https://front.b.com'
    ]

    const clientIP = event.viewer.ip
    const origin = request.headers.origin ? request.headers.origin.value : ''

    if (allowedIPs.includes(clientIP) || request.method === 'OPTIONS') {
        // OPTIONS か 許可IP の場合は通す
        return request
    } else if (allowedOrigins.includes(origin)) {
        // レスポンスヘッダーに許可されたオリジンを設定することでCORS違反を避ける
        response.headers['access-control-allow-origin'] = { value: origin }
    }

    return response
}
```

### バックエンドのCloudFrontにCF2を紐づける
- 変更を保存
- 発行 > 関数を発行
  - ディストリビューションID：バックエンドのCloudFront
  - イベントタイプ：Viewer request
  - キャッシュビヘイビア：* 

## 動作確認
メンテナンスモードにいれたい時はCF2の`isMaintenance`を`true`にし、`maintenanceStart`と `maintenanceEnd`を編集して「変更を保存」する
```javascript
const isMaintenance = true
const maintenanceStart = "2024年7月22日 9:00"
const maintenanceEnd = "2024年7月22日 12:00"
```

発行タブから「関数を発行」 5分以内でデプロイ済みになる
[![Image from Gyazo](https://i.gyazo.com/031f2f285f9c8ba2fe77a9dc4d6472ba.png)](https://gyazo.com/031f2f285f9c8ba2fe77a9dc4d6472ba)

この状態でフロントエンドからバックエンドに通信を行うと、503レスポンスが返却され、インターセプトが働きメンテナンスページへ移行する🎉

[![Image from Gyazo](https://i.gyazo.com/94ef645ae36ed4b36dd64649a36662ec.jpg)](https://gyazo.com/94ef645ae36ed4b36dd64649a36662ec)

## ハマったこと
### その1 WAFからの503レスポンスをフロントで解釈できずにNetwork Errorになる
#### 詳細
ローカルのバックエンドで503を擬似的に起こしていたときは発生しなかったが、WAFからのレスポンスでは503を解釈できない事象に遭遇
オリジンでレスポンスを生成せずにWAFで生成したレスポンスなので、何かが足りていない模様

axios経由
```javascript
await axios.get(`/healthcheck`, {
  baseURL: process.env.PUBLIC_URL,
})
```
フロントパッケージ経由
```javascript
await $core.$d.transport.get(`/healthcheck`)
```

CORS違反の模様。
CORS違反のエラーになるとNetwork Errorになり、リモートアドレスも表示すらされない。

[![Image from Gyazo](https://i.gyazo.com/6664f0fc7b9d1c40d4b8356b4ebc06a5.png)](https://gyazo.com/6664f0fc7b9d1c40d4b8356b4ebc06a5)

素の axios であれば下記レスポンスヘッダーをつければ解消した
```
Access-Control-Allow-Origin: *
```

このレスポンスヘッダーだと素のaxiosではうまくいくがパッケージ経由だとまだNetwork Errorになりうまくレスポンスが取れない
[![Image from Gyazo](https://i.gyazo.com/6af4f680b27f8776bedf2203b046d73a.png)](https://gyazo.com/6af4f680b27f8776bedf2203b046d73a)

パッケージ経由の場合であれば下記レスポンスヘッダーで解釈可能になった
```
Access-Control-Allow-Origin: http://front.a.com
Access-Control-Allow-Credentials: true
```
パッケージ経由だと`Access-Control-Allow-Origin`は`*`だとダメ
`Access-Control-Allow-Credentials: true`も必要
多分パッケージ内でここら辺を厳しくチェックしていたので、axiosとの差分につながっていた模様。


## その2 WAFのレスポンスヘッダーだとCORS_ORIGINを1つしか設定できないので、複数フロントがあるかつ * が許されない状況では厳しいかもしれない
単一フロントであればWAFのレスポンスヘッダーに`Access-Control-Allow-Origin: http://front.a.com`を設定すればいいが、フロントオリジンが複数ある場合はWAFだけで設定するのは難しそう。
ということでCF2にて動的に`Access-Control-Allow-Origin`を設定する手法に変更

```javascript
// 許可オリジン フロントエンドのオリジン
const allowedOrigins = [
    'https://front.a.com',
    'https://front.b.com'
]

const origin = request.headers.origin ? request.headers.origin.value : ''

if (allowedOrigins.includes(origin)) {
    // レスポンスヘッダーに許可されたオリジンを設定することでCORS違反を避ける
    response.headers['access-control-allow-origin'] = { value: origin }
}
```