API GatewayとS3を使ってメンテナンスページをホスティングしてみた

痒いところに手が届く少し変わった構成のご紹介です
2023.06.07

CX事業本部@大阪の岩田です。

Webアプリをデプロイする際に、一時的に「メンテナンスページ」を表示したいという要件は「あるある」だと思います。このブログではメンテナンスページ実現のパーツとして一風変わったAPI Gateway × S3の統合を利用する構成をご紹介します。

メンテナンスページの概要

メンテナンスページに求める要件は以下の通りとします

  • 基本的に全てのパスに対してメンテナンスページを表示する
    • /hogeへのアクセスであっても/fugaへのアクセスであっても全て同一のhtmlファイルを返却する
  • メンテナンスページのレスポンスステータスは200ではなく503を返却する
  • 単一のHTMLファイルでメンテナンスページを用意するのではなく、画像も読み込みたい
    • 画像ファイルへのリクエストはメンテナンスページにリライトせずにリクエストされた画像ファイルをそのまま返却する

メンテナンスページは以下のようなイメージです。

構成はこんな感じになります。

AWSリソースの構築

以下のCloudFormationテンプレートを使ってサクッと構築します。

AWSTemplateFormatVersion: '2010-09-09'
Resources:
  MaintenanceS3Bucket:
    Type: AWS::S3::Bucket
  ApiGwRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: apigateway.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess
        - arn:aws:iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs
  MaintenanceApi:
    Type: AWS::ApiGateway::RestApi
    Properties:
      Name: maintenance-api
      Body:
        openapi: "3.0.1"
        info:
          title: "maintenance-test"
        paths:
          /{proxy+}:
            x-amazon-apigateway-any-method:
              parameters:
              - name: "proxy"
                in: "path"
                required: true
                schema:
                  type: "string"
              responses:
                "404":
                  description: "404 response"
                  content: {}
                "200":
                  description: "200 response"
                  headers:
                    Content-Type:
                      schema:
                        type: "string"
                  content: {}
              x-amazon-apigateway-integration:
                credentials: !GetAtt ApiGwRole.Arn
                httpMethod: "GET"
                uri: !Sub "arn:aws:apigateway:${AWS::Region}:s3:path/${MaintenanceS3Bucket}/{file}"
                responses:
                  default:
                    statusCode: "200"
                    responseParameters:
                      method.response.header.Content-Type: "integration.response.header.Content-Type"
                    responseTemplates:
                      text/html: |
                        #set($context.responseOverride.status = 503)
                        $input.body
                      image/png: ''
                  "404":
                    statusCode: "404"
                    responseTemplates:
                      application/json: |
                        #set($context.responseOverride.header.Content-Type = "text/html")
                        not found                      
                requestParameters:
                  integration.request.path.file: "method.request.path.proxy"
                requestTemplates:
                  application/json: |
                    #set($file = $method.request.path.proxy)
                    #if (!$file.matches("^.*\.(jpg|png)$"))
                      #set($context.requestOverride.path.file = "index.html")
                    #end
                passthroughBehavior: "when_no_match"
                cacheKeyParameters:
                - "method.request.path.proxy"
                type: "aws"
        x-amazon-apigateway-binary-media-types:
          - "image/*"
  ApiDeployment:
    Type: AWS::ApiGateway::Deployment
    Properties:
      RestApiId: !Ref MaintenanceApi
  ApiStage:
    Type: AWS::ApiGateway::Stage
    Properties:
      RestApiId: !Ref MaintenanceApi
      StageName: dev
      DeploymentId: !Ref ApiDeployment

API Gatewayのリソースとしてプロキシリソースを作成し、/以外のアクセスを全てS3にプロキシする構成です。実際に利用する場合は/に対しても{proxy+}と同様の設定を入れておくと良いでしょう。

CloudFormationのスタック作成が完了したらメンテナンスページと画像をS3バケットにアップしておきます。

メンテナンスページのHTMLファイルは以下のようなコンテンツです

<html lang="ja">
...略
  <body>
    <div class="content">
      <img src="clanyan.png" alt="ごめんなさい" width="172" height="auto" class="clanyan">
      <p>
        メンテナンス中です
      </p>
    </div>
...略

API Gatewayにアクセスしてみる

準備ができたらデプロイされたAPI Gatewayのステージにアクセスしてみます。ブラウザのアドレスバーにhttps://<API Gatewayのエンドポイント>/<ステージ名>/hogeというURLを入力してアクセスすると以下のページが表示されました。

/hogeへのアクセスに対してステータスコード503でindex.htmlのコンテンツが返却されていることが確認できました。また画像ファイルもしっかりと読み込めています。無事に要件を満たせてそうです。

ブラウザが自動的にリクエストするfavicon.icoについてはうまく処理できていないですが、今回は無視することにします。

ポイントの解説

いくつかポイントとなる設定を見ていきましょう。

統合リクエストの設定

まず統合リクエストの設定です。この部分でAPI GatewayへのリクエストをS3にプロキシします。

実行ロールに指定するIAMロールにはS3バケットへの権限を付与しておいて下さい。

さらにマッピングテンプレートを利用して画像ファイル以外へのリクエストをindex.htmlに対するリクエストにリライトします

マッピングテンプレートの中身は以下の通りです。

#set($file = $method.request.path.proxy)
#if (!$file.matches("^.*\.(jpg|png)$"))
  #set($context.requestOverride.path.file = "index.html")
#end

リクエストされたパスの拡張子が.jpgもしくは.pngの場合はそのままS3にリクエストをプロキシし、それ以外の場合はindex.htmlにプロキシします。マッピングテンプレートの中で上書きしたパスパラメータfileを「パス上書き」の設定から参照することで実現しています。この拡張子をチェックする正規表現を修正すれば.cssなど他の拡張子で終わるリクエストもリライトせずにプロキシ可能です。

統合レスポンスの設定

同様に統合レスポンスにもいくつか設定を行います

まずデフォルトのメソッドレスポンスです。レスポンスヘッダのContent-TypeにはS3から返却されたレスポンスのContent-Typeをそのまま設定します。さらに、マッピングテンプレートとして

  • image/png
  • text/html

の2つを用意します。※png以外の画像を利用する場合は適宜設定を追加して下さい。

Content-Typeがimage/pngの場合はS3からのレスポンスをそのまま返却したいので、マッピングテンプレートの中身は空のままにしておきます。別途API Gatewayのバイナリメディアタイプでimage/*を指定しているので、これでS3からレスポンスされた画像ファイルをそのままブラウザで表示できます。

続いてContent-Typeがtext/htmlの場合です

こちらはステータスコードとして200ではなく503を返却したいので、マッピングテンプレートでステータスコードを上書きしつつ、ボディはそのまま無加工で返却します。マッピングテンプレートの記述としては以下になります。

#set($context.responseOverride.status = 503)
$input.body

続いて404のメソッドレスポンスです。画像ファイルへのリクエストはそのままS3にプロキシする構成にしているので、存在しない画像ファイルをリクエストされた場合はこの設定に引っかかることになります。

API Gatewayがデフォルトで利用するContent-Type: application/jsonのマッピングテンプレートを以下のように記述しています

#set($context.responseOverride.header.Content-Type = "text/html")
not found

やっている処理としては

  • レスポンスボディにnot foundという文字列を返却する
  • レスポンスヘッダのContent-Typeをtext/htmlに設定する

の2点です

バイナリメディアタイプ

統合リクエストの部分ですでに記載しましたが、バイナリメディアタイプにimage/*を指定しています。この設定により、うまくバイナリの画像ファイルが扱えるようになります。

まとめ

メンテナンスページを実現するためのパーツとしてAPI Gateway × S3という少し変化球な構成を紹介しました。この構成のメリットとしては以下のような点が挙げられます。

  • サーバーレスなサービスのみで構成しているため、環境を常時起動していてもコストが問題になり辛い
  • ステータスの書き換えやURLのリライトなど絶妙に痒いところに手が届く

また、CloudFront × S3の構成と比べるとCNAMEの重複に関する考慮が不要なのもポイントです。例えばCloudFront × ALBで稼働しているシステムにメンテナンスをかける例を考えてみます。メインで利用するCloudFront以外にメンテナンスページ用のCloudFrontを用意しておき、メンテナンス実施時には対象ドメインのエイリアスレコードの向き先をメンテナンスページ用のCloudFrontに向けて...

という手順が踏めれば良いのですが、CloudFrontに設定可能なCNAMEは重複が許可されていないため、CNAMEの移行はもうひと手間かましてやる必要があります。

エイリアスレコードの向き先をCloudFrontからAPI Gatewayに変える場合はRoute53のマネコンからチャチャっと作業できるので、こちらの方がより簡潔にメンテナンスページへの切り替えが実現できるのではないでしょうか?

メンテナンスページ実現のための1つのパーツとしてこのような構成が取れることも覚えておいて下さい。いつか役に立つ日が来るかも?