何がしたいのか

リリース作業中に、ユーザにシステムアクセスさせたくない時ありますよね?
そういう時は「工事中ページ」の表示です。
この工事中ページの表示ON/OFFをボタン1つで実施できればいいねということで、今回、WAFとCloudFrontを利用して実現してみました。


通常運用時は、ユーザからのリクエストをWAFによるチェック後にCloudFrontがS3やFargateに割り振ります。
リリース作業の際にはGitHub Actionsを実行しWAFのルールを書き換えて、ユーザからのリクエストを常にNG(ステータスコード=403)とし、CloudFrontのカスタムエラーレスポンス機能により403エラー時に工事中ページを表示します。

注意事項

本方法はWAF web-ACLのデフォルトActionを変更することで実現しています。
そのため、IP制限を行ない、デフォルトActionが既にDenyと設定されているシステムでは本方法をそのまま利用することはできません。

対応内容(通常画面から工事中ページを表示する)

GitHub Actions(実際は、Actionの中からPythonのプログラムを実行)で作成した内容は次のとおりです。

1. 工事中ページの文言を書き換える

工事中ページには、何時から何時までの間アクセスできないのかを表示した方が親切ですね。
INPUTで開始日時と作業時間を受け取り、S3バケットで保持している工事中ページのHTMLを書き換えます。

        # S3クライアントを作成
        s3_client = boto3.client('s3')
        s3_response = s3_client.get_object(Bucket=bucket_name, Key=maintenance_html_path)
        html_text = s3_response['Body'].read().decode('utf-8')
        # 正規表現パターンを定義
        pattern = r'.*'
        main_text_pattern = r'.*'
        main_text = 'ただいま、システムメンテナンスを行なっております。ご不便をおかけしますが、再開まで今しばらくお待ちください。'
        if mode != OperationMode.RELEASE:
            # メンテナンス日時の文字列をクリアする。再度置換できるようにタグは残す
            replacement = f''
            new_html_text = re.sub(pattern, replacement, html_text)
            # 案内文を置換
            main_text = main_text + 'メンテナンスが完了いたしました。'
            new_html_text = re.sub(main_text_pattern, main_text, new_html_text, count=1)

        else:
            logging.info("S3から工事中ページを取得し、日時を書き換えUpload")
            # S3オブジェクトを取得してテキストデータを読み込む
            # メンテナンス日時の設定
            start_date_object: datetime =  datetime.strptime(start_datetime, '%Y-%m-%d %H:%M')
            end_date_object: datetime = start_date_object + timedelta(hours=work_hours)

            # 日時置換文字列を定義
            replacement = f'再開予定日時{end_date_object.strftime("%Y年%m月%d日 %H:%M")}※再開予定日時は前後することがございます。'

            # 日時の文字列を置換
            new_html_text = re.sub(pattern, replacement, html_text)

            # 案内文を置換
            new_html_text = re.sub(main_text_pattern, main_text, new_html_text, count=1)

        s3_response = s3_client.put_object(
                Body=new_html_text,
                Bucket=bucket_name,
                Key=maintenance_html_path,
                ContentType='text/html'
            )
        return

2. WAF web-ACLのデフォルトActionを変更する

web-ACLに設定されたルールに合致しない場合に実行されるのがデフォルトActionです。
パブリックなシステムでは、デフォルトActionの設定値を全リクエスト許可となっています。
リリース作業中は、この デフォルトActionの設定値を全リクエスト拒否に変更します。

こうすることで、今まで、正常と判断されていたユーザからのリクエストが403エラーとなります。

具体的にはまず、現在設定されているweb-ACLの情報を取得し、デフォルトActionを書き換えます

        scope="CLOUDFRONT"
        # WAFv2クライアントを作成
        wafv2_client = boto3.client('wafv2', region_name='us-east-1')

        # WebACLの現在の設定を取得
        response = wafv2_client.get_web_acl(
            Name=web_acl_name,
            Scope=scope,
            Id=web_acl_id
        )
        # 上記で取得したjsonの"WebACL"-"DefaultAction"がデフォルトActionの値ですので本値を拒否(Block)に変更します。
        current_web_acl = response['WebACL']
        current_web_acl['DefaultAction'] = {"Block": {}}

        # 更新されたWebACLを適用
        response = wafv2_client.update_web_acl(
            Name=current_web_acl['Name'],
            Scope=scope,
            DefaultAction=current_web_acl['DefaultAction'],
            Rules=current_web_acl['Rules'],
            Id=current_web_acl['Id'],
            VisibilityConfig=current_web_acl['VisibilityConfig'],
            LockToken=response['LockToken']
        )

3. CloudFrontのカスタムエラーレスポンスに工事中ページを設定する

2の対応により、ユーザがシステムにリクエストを送信するとWAFが拒否(ステータスコード403を返却)するようになります。
CloudFrontのカスタムエラーレスポンスに403時のエラーHTMLに1.のHTMLを設定することで工事中ページが表示されるようになります。

        client = boto3.client('cloudfront')
        # 現在のCloudFrontの設定内容を取得
        distribution_config = client.get_distribution_config(Id=cloudfront_distribution_id)
        custom_error_responses = distribution_config['DistributionConfig']['CustomErrorResponses']
        logging.info(custom_error_responses)

        # エラーコードが403以外のItem一覧を作成する
        custom_error_responses['Items'] = [obj for obj in custom_error_responses['Items'] if obj['ErrorCode'] != 403]
        if mode == OperationMode.RELEASE:
            # リリース中はカスタムエラーレスポンスの末尾に403エラー時に工事中ページを表示設定する
            logging.info("カスタムエラーレスポンス(403)に工事中ページを設定")
            custom_error_responses['Items'].append({
                'ErrorCode': 403, 
                'ResponsePagePath': "/" + maintenance_html_path,
                'ResponseCode': '403',
                'ErrorCachingMinTTL': 10
            })
        custom_error_responses["Quantity"] = len(custom_error_responses['Items'])

        # Update the distribution configuration
        client.update_distribution(
            DistributionConfig=distribution_config['DistributionConfig'],
            Id=cloudfront_distribution_id,
            IfMatch=distribution_config['ETag']
        )

本対応は403エラー時のカスタムエラーが未設定の時に、403エラーを追加設定するケースです。
工事中ページ用の設定を追加するために’Items’に設定を追加して、’Quantity’の数を更新している。

既に403エラーページがされている時は、追加ではなく更新を行なってください。

4. CloudFrontのキャッシュをクリアする

1.のHTMLが必ず表示されるようにCloudFrontのキャッシュデータをクリアする。
工事中ページのURL Pathは”/error-page/maintenance-page.html”となる想定です。

        # CloudFrontのキャッシュをクリアする
        client.create_invalidation(
            DistributionId=cloudfront_distribution_id,
            InvalidationBatch = {
                'Paths' : {
                    'Quantity': 1,
                    'Items': ['/error-page/*'],
                },
                'CallerReference' : str(int(time.time()))
            }
        )

上記で工事中ページへの切り替えが完了します。

 

対応内容(通常ページから工事中ページを表示する)

工事中ページから通常ページに戻すには逆の操作を次のように実施します。(工事中ページ切り替えとほぼ同じとなるためソースは割愛します)

  • WAF web-ACLのデフォルトActionを拒否から許可に変更する
  • CloudFrontのカスタムエラーレスポンスで403エラーの工事中ページを通常の403エラーページにする。もしくは 403エラーページを除去する。
  • CloudFrontのキャッシュをクリアする

必要な権限について

本機能をGitHub Actionsから設定するには次の権限が必要となります。

S3バケットのアクセス権限

工事中ページのHTMLの取得・更新に必要

  • s3:GetObject
  • s3:PutObject

WAFのデフォルトAction書き換えのアクセス権限

WAFのWebACLを取得・更新に必要

  • wafv2:GetWebAcl
  • wafv2:UpdateWebAcl

CloudFrontのカスタムエラーページ更新

CloudFrontのカスタムエラーページを更新し、キャッシュページを削除rする。

  • cloudfront:CreateInvalidation
  • cloudfront:GetDistributionConfig
  • cloudfront:UpdateDistribution

最後に

今回はGitHub Actionsを利用しましたが、同様の仕組みはAWS LambdaやShellScriptでも実現可能かと思います。
本記事が皆さんの助けになれば幸いです。