6
3

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

Posted at

概要

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

全体図

Image from Gyazo

要件

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

フロント実装手順

各々フロントエンドにてメンテナンスページとrouterを準備

ここらへんは好きなように準備

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>

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のレスポンスが帰ってきたらメンテナンスページに移行させる

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を採用する。

CF2では

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

CF2コードを準備

  • CloudFront > 関数 > 関数を作成
  • 名前:適当に
  • Runtime:cloudfront-js-2.0
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のisMaintenancetrueにし、maintenanceStartmaintenanceEndを編集して「変更を保存」する

const isMaintenance = true
const maintenanceStart = "2024年7月22日 9:00"
const maintenanceEnd = "2024年7月22日 12:00"

発行タブから「関数を発行」 5分以内でデプロイ済みになる
Image from Gyazo

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

Image from Gyazo

ハマったこと

その1 WAFからの503レスポンスをフロントで解釈できずにNetwork Errorになる

詳細

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

axios経由

await axios.get(`/healthcheck`, {
  baseURL: process.env.PUBLIC_URL,
})

フロントパッケージ経由

await $core.$d.transport.get(`/healthcheck`)

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

Image from Gyazo

素の axios であれば下記レスポンスヘッダーをつければ解消した

Access-Control-Allow-Origin: *

このレスポンスヘッダーだと素のaxiosではうまくいくがパッケージ経由だとまだNetwork Errorになりうまくレスポンスが取れない
Image from Gyazo

パッケージ経由の場合であれば下記レスポンスヘッダーで解釈可能になった

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を設定する手法に変更

// 許可オリジン フロントエンドのオリジン
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 }
}
6
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
3