概要
複数のフロントエンド × 単一のバックエンドを持つSPA (Single Page Application)のメンテナンスモードを実装しました。
インフラとしてはAWSを使っています。
今回は諸々の事情でWAFにてメンテナンスレスポンスを返すのではなく、一段踏み込んだCloudFont Functions(以降CF2)でのメンテナンスレスポンス返却を行っています。
全体図
要件
- 複数のフロントエンドドメインがあるがそれら全てにおいて、メンテナンスモード中はユーザーにメンテナンス画面を表示したい
- 特定のIPからはメンテナンス中でも通常通り触れるようにしたい
フロント実装手順
各々フロントエンドにてメンテナンスページとrouterを準備
ここらへんは好きなように準備
<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>
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のレスポンスが帰ってきたらメンテナンスページに移行させる
/**
* 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のisMaintenance
をtrue
にし、maintenanceStart
と maintenanceEnd
を編集して「変更を保存」する
const isMaintenance = true
const maintenanceStart = "2024年7月22日 9:00"
const maintenanceEnd = "2024年7月22日 12:00"
この状態でフロントエンドからバックエンドに通信を行うと、503レスポンスが返却され、インターセプトが働きメンテナンスページへ移行する🎉
ハマったこと
その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になり、リモートアドレスも表示すらされない。
素の axios であれば下記レスポンスヘッダーをつければ解消した
Access-Control-Allow-Origin: *
このレスポンスヘッダーだと素のaxiosではうまくいくがパッケージ経由だとまだNetwork Errorになりうまくレスポンスが取れない
パッケージ経由の場合であれば下記レスポンスヘッダーで解釈可能になった
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 }
}