はじめに

Amazon CloudFront で、特定のオリジンのみメンテナンスページを返す構成を検討する機会がありました。要件は以下の通りです。

  • 特定オリジンのみメンテナンスページを表示する
  • 作業者のグローバル IP だけはメンテナンスページを返さずに許可する
  • SEO 的な観点からメンテナンスページは 503 (Service Unavailable) で返す

概要

構成としては以下のようなサーバーレスアーキテクチャで、S3 バケットに対してのみメンテナンスページにする必要がありました。Amazon CloudFront ディストリビューションの配下に Amazon API Gateway もいます。

この S3 バケットにはいわゆる SPA (Single page application) がホストされています (後述しますが、フロントエンドエンジニアの方であればこの時点で怪しいなぁと思われたかもしれません)

調査

CloudFront + S3 構成におけるメンテナンスページの実装にはいくつか方法があります。その中で要件を満たすものがないか調査しました。

要件を満たせなかった方法

以下は検討はしたものの要件を満たせなかった方法です。

Amazon Route 53 で DNS レコードの向き先をメンテナンスページに向ける

まず思いつくのが DNS の向き先をメンテナンスページに向ける方法ですが、ディストリビューション単位での制御になってしまうので今回の要件には適合しません。また IP 許可などの制御も困難です。

AWS WAF ルールを使ってカスタムレスポンスを返す

この方法もよく見かけます。WAF なので IP 許可もできますし、カスタムレスポンス機能で HTML を 503 で返せます。ただしこれもディストリビューション単位での制御になってしまうので、今回の要件には適合しません。

S3 バケット側でリダイレクト設定を書く

次に、S3 バケット側でリダイレクト設定を書く方法です。この場合 S3 バケットを静的ウェブサイトとして設定している必要があります。S3 側で制御するので、オリジン単位という要件に適合します。しかし、送信元 IP でリダイレクト先を制御するような仕組みはないので、IP 許可の要件には適合しません。同様に、オリジンを単純に差し替えるような方式も、IP 許可の要件がある限りは使えません。

要件を満たせた方法

このようにいくつか検討しましたが、やはり自分で関数を書く方法しか残りませんでした。以下の 2 つの方法です。

Lambda@Edge を使う

Lambda@Edge であれば、自前で IP 許可を書くことができますし、HTML もインラインで用意してそのまま 503 で返すといったことが可能です。そしてなによりオリジン単位での制御が可能です。かなり簡略化していますが、以下のようなコードを Viewer Request に設定すればよいでしょう。

import base64
import json

body = """
ここにメンテナンスページの HTML を記載
"""


def lambda_handler(event, context):
  allowed_ip = [<作業者の IP アドレス>]

  request = event["Records"][0]["cf"]["request"]
  client_ip = request["clientIp"]

  if client_ip in allowed_ip:
    SPA_INDEX_PATH = "/"
    if request["method"] == "GET" and request["uri"].find(".") == -1:
      request["uri"] = SPA_INDEX_PATH
    return request

  return {
    "status": "503",
    "statusDescription": "Service Unavailable",
    "headers": {
      "content-type": [{"key": "Content-Type", "value": "text/html"}],
      "content-encoding": [{"key": "Content-Encoding", "value": "UTF-8"}],
    },
    "body":body
  }

今回はさほど凝ったメンテナンスページではなかったので、Lambda@Edge だけで済むシンプルさから直接インラインで書く方式を採用しました。

なお、ホストしているのが SPA であるため、送信元 IP が許可された IP であった場合に静的ファイル向けリクエスト以外をインデックスページに飛ばす処理も通常通り組み込んでいます。以下の部分ですね。

SPA_INDEX_PATH = "/"
if request["method"] == "GET" and request["uri"].find(".") == -1:
  request["uri"] = SPA_INDEX_PATH
return request

CloudFront Functions を使う

Lambda@Edge でできるのであれば、CloudFront Functions でもできそうです。しかしこちらの場合はインラインで HTML を返すような処理はかなり怪しそうです。実装例として、存在しないページに故意にアクセスさせることでカスタムエラーレスポンスを返す方式を紹介します。

  • 403 を 503 に変換してメンテナンスページを返すカスタムエラーレスポンスを作成しておく
  • CloudFront Functions で存在しないページに故意にリライトする
  • 存在しないページにアクセスすると S3 バケットが 403 を返す
  • カスタムエラーレスポンスの設定に基づいてメンテナンスページを 503 で返す

問題点

ここまでで要件は満たせているのですが、実際には SPA 特有の問題がまだ残っています。メンテナンス前からログインしていたユーザーはどうするのかという問題です。この場合すでにユーザーのブラウザ上に Web サービスが展開されているため、意図的にリロードしない限りそのユーザーはメンテナンスの影響を受けません。

このような問題に対処するには、バックエンド側も考慮したメンテナンスモードを実装する必要があります。つまりバックエンド API が呼ばれた時点で閉め出すことができればいいという考え方です。この場合、どのようにメンテナンスモードを設計するかはビジネスロジックに左右されます。

必ず呼ばれる API に対してメンテナンスモードへ誘導するための処理を仕込むことになると思いますが、一例としては認証系の API を利用する方法が考えられます。もし Amazon Cognito を使っているのであれば、ユーザープールに AWS WAF のルールを関連づける方法が有効かもしれません。

いずれにせよ、特定オリジンに対してのみメンテナンス画面を返したいという要件からは逸脱せざるを得ません。

インシデント

ここからはインシデントベースの話になります。ユーザープールに WAF ルールを仕込む方法を実際のリリースで適用したのですが、その際に以下のようなインシデントが発生しました。

事象

  • IP 許可されているはずのメンテナンス作業者に対してもメンテナンスページが表示されてしまった

考えられる原因

  • Amazon API Gateway で Lambda オーソライザーを使っているが、この Lambda 関数が Amazon Cognito の API を呼んでおり、送信元 IP が AWS の IP となったために IP 許可されなかったと推測

対処

  • エンドユーザーのセッションがすべて切れていることを確認後、ユーザープールに関連づけられた WAF ルールを切り離す

状況証拠からの推測にはなってしまいますが、挙動から見てそう外れてはいないと考えています。この件に関しては今後もう少し掘り下げてみたいと思います。

おわりに

特定のオリジンのみメンテナンスしたいという要件でも、実際にはそのオリジンの外部に対する考慮が必要になるケースがあるということを学びました。今後はインフラを設計する段階から、どのような仕様のアプリがそこに載るのか、そのアプリのバージョンアップやメンテナンスはどのような方針になっているのかを意識して設計しようと強く思いました。