メンテナンスの際に、手軽にAPIやフロントのメンテナンスモードを切り替える仕組みが欲しかったので作りました。
具体的には、IP制限やメンテナンス用のレスポンス定義を、スクリプトで切り替えられるようになります。
やりたいこと
- APIやフロントのメンテナンスモードの切り替えをスクリプトで手軽に行えるようにしたい
- メンテナンスモードON
- 特定のIPリストからのみアクセスを許可する
- IPリスト以外からアクセスがあったときは、メンテナンス中を示す特定のステータスコード・ヘッダー・ボディをレスポンスで返す(詳細は後述)
- メンテナンスモードOFF
- 通常時
- 全てのアクセスを許可する(IP制限なし)
- メンテナンスモードON
方法の概要
以下の記事の内容を合わせて(IP制限+WAFカスタムレスポンスによるメンテナンスモード)、さらにそれをスクリプトでオンオフ切り替えられるようにします。
IP制限
APIのメンテナンスモード
フロント環境のメンテナンスモード(ページ)
どれもWAFを使用しているのですが、今回はREST API、GraphQL API、CloudFrontどれにでも使い回せる設定で作ってみます。
具体的には、メンテナンスモード時に許可IP以外のアクセスに返すレスポンスヘッダー・ボディやhttpステータスコードを、REST API、GraphQL API、CloudFrontでそれぞれ違うものを返すようにしてみました。
- メンテナンスモードON
- メンテナンスモードOFF
- 何も変えない
※注意として、WAFはメンテナンスモードがオフのときも常にリソースにつけっぱなしになります。
どうしても料金が気になる場合は使えなくなってしまいますが、例えばAWSが提供するマネージドルールをこのWAFのルールにつけて常にアタッチしておくようにすれば、セキュリティ対策のついでに本切り替え手法も使える様になるのでおすすめです。
登場するモノ
前提
方法
CloudFormationテンプレートファイル(yml)・IPホワイトリスト用テキストファイル・シェルスクリプト(bash)を用意します。
※GitHubにもあります。
CloudFormationテンプレートファイル(yml)
- ファイル名:waf.yml
### WAF v2 ### AWSTemplateFormatVersion: "2010-09-09" Description: WAF v2 for Maintenance Metadata: "AWS::CloudFormation::Interface": ParameterGroups: - Label: default: "MaintenanceMode" Parameters: - MaintenanceMode - Label: default: "ScopeType" Parameters: - ScopeType - Label: default: "WAFWebACLMetricName" Parameters: - WAFWebACLMetricName - Label: default: "CIDR1" Parameters: - CIDR1 - Label: default: "CIDR2" Parameters: - CIDR2 - Label: default: "CIDR3" Parameters: - CIDR3 - Label: default: "CIDR4" Parameters: - CIDR4 - Label: default: "CIDR5" Parameters: - CIDR5 # ------------------------------------------------------------# # Input Parameters # ------------------------------------------------------------# Parameters: MaintenanceMode: Type: String AllowedValues: ["on", "off"] ScopeType: Type: String AllowedValues: ["CLOUDFRONT", "GRAPHQL", "REST"] WAFWebACLMetricName: Type: String MinLength: 1 MaxLength: 128 AllowedPattern: "[a-zA-Z0-9]*" CIDR1: Type: String Description: White List CIDR 1 Default: "" AllowedPattern: '^((([1-9]?[0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}[1-9]?([0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])/(8|16|24|32))?$' CIDR2: Type: String Description: White List CIDR 2 Default: "" AllowedPattern: '^((([1-9]?[0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}[1-9]?([0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])/(8|16|24|32))?$' CIDR3: Type: String Description: White List CIDR 3 Default: "" AllowedPattern: '^((([1-9]?[0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}[1-9]?([0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])/(8|16|24|32))?$' CIDR4: Type: String Description: White List CIDR 4 Default: "" AllowedPattern: '^((([1-9]?[0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}[1-9]?([0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])/(8|16|24|32))?$' CIDR5: Type: String Description: White List CIDR 5 Default: "" AllowedPattern: '^((([1-9]?[0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}[1-9]?([0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])/(8|16|24|32))?$' # ------------------------------------------------------------# # Conditions # ------------------------------------------------------------# Conditions: IsMaintenance: !Equals [!Ref MaintenanceMode, "on"] IsCloudFront: !Equals [!Ref ScopeType, "CLOUDFRONT"] IsGraphql: !Equals [!Ref ScopeType, "GRAPHQL"] IsCIDR1: !Or - !Not [!Equals [!Ref CIDR1, ""]] - !Equals [!Ref CIDR1, ""] IsCIDR2: !Not [!Equals [!Ref CIDR2, ""]] IsCIDR3: !Not [!Equals [!Ref CIDR3, ""]] IsCIDR4: !Not [!Equals [!Ref CIDR4, ""]] IsCIDR5: !Not [!Equals [!Ref CIDR5, ""]] Resources: # ------------------------------------------------------------# # WAF # ------------------------------------------------------------# WhiteListCIDRSet: Type: AWS::WAFv2::IPSet Properties: Addresses: - !If - IsCIDR1 - !Ref CIDR1 - !Ref "AWS::NoValue" - !If - IsCIDR2 - !Ref CIDR2 - !Ref "AWS::NoValue" - !If - IsCIDR3 - !Ref CIDR3 - !Ref "AWS::NoValue" - !If - IsCIDR4 - !Ref CIDR4 - !Ref "AWS::NoValue" - !If - IsCIDR5 - !Ref CIDR5 - !Ref "AWS::NoValue" IPAddressVersion: IPV4 Name: Maintenance-WebACL-IPSet Scope: !If - IsCloudFront - "CLOUDFRONT" - "REGIONAL" WAFWebACL: Type: AWS::WAFv2::WebACL Properties: Name: Maintenance-WebACL CustomResponseBodies: !If - IsMaintenance - !If - IsGraphql - CustomResponseBodyKeyForGraphql: ContentType: APPLICATION_JSON Content: '{"errors": [{"errorType": "MaintenanceMode", "message": "Unable to access during the maintenance."}]}' - !If - IsCloudFront - !Ref "AWS::NoValue" - CustomResponseBodyKeyForRest: ContentType: APPLICATION_JSON Content: '{"error": {"errorType": "MaintenanceMode", "message": "Unable to access during the maintenance."}}' - !Ref "AWS::NoValue" DefaultAction: Block: !If - IsMaintenance - !If - IsGraphql - CustomResponse: ResponseCode: 200 CustomResponseBodyKey: CustomResponseBodyKeyForGraphql - !If - IsCloudFront - CustomResponse: ResponseCode: 403 ResponseHeaders: - Name: CustomErrorType Value: "MaintenanceMode" - CustomResponse: ResponseCode: 503 CustomResponseBodyKey: CustomResponseBodyKeyForRest ResponseHeaders: - Name: CustomErrorType Value: "MaintenanceMode" - !Ref "AWS::NoValue" Allow: !If - IsMaintenance - !Ref "AWS::NoValue" - {} Scope: !If - IsCloudFront - "CLOUDFRONT" - "REGIONAL" VisibilityConfig: CloudWatchMetricsEnabled: true MetricName: !Ref WAFWebACLMetricName SampledRequestsEnabled: true Rules: - !If - IsMaintenance - Name: !Sub "${PJPrefix}-Maintenance-WebACL-RuleIPSet" Action: Allow: {} Priority: 0 Statement: IPSetReferenceStatement: Arn: !GetAtt WhiteListCIDRSet.Arn VisibilityConfig: CloudWatchMetricsEnabled: true MetricName: !Sub "${WAFWebACLMetricName}-RuleIPSet" SampledRequestsEnabled: true - !Ref "AWS::NoValue"
解説
※ここで解説しないものに関しては、上記でご紹介したリンク先の方で解説していますのでご覧下さい。
ScopeTypeには"CLOUDFRONT", "REGIONAL"ではなく、"CLOUDFRONT", "GRAPHQL", "REST"それぞれを渡すようにしました。
ScopeType: Type: String AllowedValues: ["CLOUDFRONT", "GRAPHQL", "REST"]
Conditionsには、3種類のフラグを追加しました。
Conditions: IsMaintenance: !Equals [!Ref MaintenanceMode, "on"] IsCloudFront: !Equals [!Ref ScopeType, "CLOUDFRONT"] IsGraphql: !Equals [!Ref ScopeType, "GRAPHQL"]
WhiteListCIDRSetには、上記リンクの手法(CIDR1~5
やIsCIDR1~5
パラメータ)を使って、動的にIPを設定できるようにしています。
WhiteListCIDRSetとWAFWebACLのどちらにも出てくるScopeでは、ScopeType
パラメータに["CLOUDFRONT", "GRAPHQL", "REST"]
が入っているので、CloudFrontかどうかをConditionsで定義したIsCloudFront
とIf
関数によって分岐させています。
- ScopeType:CLOUDFRONT -> Scope:CLOUDFRONT
- ScopeType:GRAPHQL, REST -> Scope:REGIONAL
Scope: !If - IsCloudFront - "CLOUDFRONT" - "REGIONAL"
ここからメインのWAFWebACLです。
CustomResponseBodiesからです。
CustomResponseBodies: !If - IsMaintenance - !If - IsGraphql - CustomResponseBodyKeyForGraphql: ContentType: APPLICATION_JSON Content: '{"errors": [{"errorType": "MaintenanceMode", "message": "Unable to access during the maintenance."}]}' - !If - IsCloudFront - !Ref "AWS::NoValue" - CustomResponseBodyKeyForRest: ContentType: APPLICATION_JSON Content: '{"error": {"errorType": "MaintenanceMode", "message": "Unable to access during the maintenance."}}' - !Ref "AWS::NoValue"
まず、メンテナンスモードかどうかを表すIsMaintenance
とIf
でまず分岐をします。
メンテナンスモードがオンのときはさらにネストした分岐へ、オフのときは!Ref "AWS::NoValue"
でCustomResponseBodies
自体の定義を無くしています。
これにより、メンテナンスモードのときのみCustomResponseBodies
を定義することが可能になります。
またメンテナンスモードがオンのときは、今度はIsGraphql
とIsCloudFront
のフラグを使って分岐し、それぞれのボディを定義しています。
- GraphQLのとき
CustomResponseBodyKeyForGraphql
(errorsキーの中に配列を持つ)
- CloudFrontのとき
!Ref "AWS::NoValue"
(ボディ定義なし)
- それ以外(RESTのとき)
CustomResponseBodyKeyForRest
(errorオブジェクト)
次はDefaultActionです。
DefaultAction: Block: !If - IsMaintenance - !If - IsGraphql - CustomResponse: ResponseCode: 200 CustomResponseBodyKey: CustomResponseBodyKeyForGraphql - !If - IsCloudFront - CustomResponse: ResponseCode: 403 ResponseHeaders: - Name: CustomErrorType Value: "MaintenanceMode" - CustomResponse: ResponseCode: 503 CustomResponseBodyKey: CustomResponseBodyKeyForRest ResponseHeaders: - Name: CustomErrorType Value: "MaintenanceMode" - !Ref "AWS::NoValue" Allow: !If - IsMaintenance - !Ref "AWS::NoValue" - {}
ここでは、BlockもAllowも定義しています。
これはIsMaintenance
によって、メンテナンスモードオンのときはBlockが定義され、オフのときはAllowが定義される仕組みです。
- IsMaintenance -> on(true)
- Block
- あり(カスタムレスポンス)
- Allow
- なし(
!Ref "AWS::NoValue"
)
- なし(
- Block
- IsMaintenance -> off(false)
- Block
- なし(
!Ref "AWS::NoValue"
)
- なし(
- Allow
- あり(
{}
)
- あり(
- Block
また、各ステータスコード・ヘッダー・ボディも、本記事前半で定義した様になっています。
最後はRulesですが、こちらもIsMaintenance
によって、オンのときは定義され、オフのときは未定義となります。
Rules: - !If - IsMaintenance - Name: !Sub "${PJPrefix}-Maintenance-WebACL-RuleIPSet" Action: Allow: {} Priority: 0 Statement: IPSetReferenceStatement: Arn: !GetAtt WhiteListCIDRSet.Arn VisibilityConfig: CloudWatchMetricsEnabled: true MetricName: !Sub "${WAFWebACLMetricName}-RuleIPSet" SampledRequestsEnabled: true - !Ref "AWS::NoValue"
IPホワイトリスト用テキストファイル(txt)
- ファイル名:iplist_waf.txt
123.456.789.10/32 234.567.890.12/32
これはちゃんとCIDR表記(/16とか/32とか)にしましょう。(WAFのIPセットではこの形でないといけない)
IPを範囲ではなく1つ1つ定義する場合は、「/32」をつけておけば大丈夫です。
先頭にコメントアウトをつけた行は、後述のシェルスクリプトの処理によって飛ばされ、行の途中に入れたコメントアウトも飛ばされます。
シェルスクリプト(bash)
- ファイル名:maintenance.sh
#!/bin/bash set -eu iplist_file="./iplist_waf.txt" iplist_max=5 CFN_TEMPLATE="./waf.yml" CFN_REGION="us-east-1" CFN_STACK_NAME="WafIp" CFN_WAFWebACLMetricName="WafIpWebacl" CFN_ScopeType="CLOUDFRONT" MaintenanceMode="" while getopts m: OPT; do case $OPT in m) MaintenanceMode="$OPTARG" ;; esac done if [ -z ${MaintenanceMode} ] \ || [ "${MaintenanceMode}" != "on" -a "${MaintenanceMode}" != "off" ]; then echo "-m(maintenance mode): on/off" exit 1 fi cnt=0 for ip in `cat ${iplist_file} | tr -d " \t" | grep -v "^#" | sed -e "s/\([^#]*\)#.*$/\1/g"` do if [ -n "${ip}" ]; then let cnt++ eval CFN_CIDR${cnt}="${ip}" fi if [ $cnt -ge $iplist_max ]; then echo "${iplist_max}個の制限を超えたのでここまでのIPを登録します。" echo "最後のIP: ${ip}" break fi done opt_param="" for i in `seq 1 $iplist_max` do if [ $i -gt $cnt ]; then eval CFN_CIDR${i}="" fi opt_param="${opt_param} CIDR${i}=$(eval echo '$'CFN_CIDR${i})" done aws cloudformation deploy \ --template-file ${CFN_TEMPLATE} \ --region ${CFN_REGION} \ --stack-name ${CFN_STACK_NAME} \ --no-fail-on-empty-changeset \ --parameter-overrides \ WAFWebACLMetricName=${CFN_WAFWebACLMetricName} \ ScopeType=${CFN_ScopeType} \ MaintenanceMode=${MaintenanceMode} \ ${opt_param}
解説
※IPリストファイルの読み込み、CIDR変数の動的定義と展開などは上記でご紹介したリンク先の方で解説していますのでご覧下さい。
今回はCloudFrontにアタッチするスクリプトを例として挙げています。
ScopeTypeは["CLOUDFRONT", "GRAPHQL", "REST"]
という形をyml側で許可しているので、GraphQL用・REST用のWAFを作成する場合は変更してください。
シェルを変えるだけでymlは同じファイルを使って、それぞれのWAFを作成することができます。
CFN_ScopeType="CLOUDFRONT"
MaintenanceMode
という変数を、このシェルの引数-m
でもらうようにしました。
またCloudFormation CLIのデプロイコマンドのオプションにも渡しています。
while getopts m: OPT; do case $OPT in m) MaintenanceMode="$OPTARG" ;; esac done
MaintenanceModeが指定されないか、[on|off]以外のときは終了させています。
if [ -z ${MaintenanceMode} ] \ || [ "${MaintenanceMode}" != "on" -a "${MaintenanceMode}" != "off" ]; then echo "-m(maintenance mode): on/off" exit 1 fi
デプロイ
上記ファイルをすべて同階層において 、シェルスクリプトを実行すればデプロイが走ります。
またシェル実行に関して、MaintenanceMode
というオプションを-m
で渡すようにしたので、メンテナンスモードがオン・オフでそれぞれ以下のような実行になります。
メンテナンスモードオン
sh maintenance.sh -m on
メンテナンスモードオフ
sh maintenance.sh -m off
これらをそれぞれメンテナンス開始時・終了時に叩くことで、メンテナンスモードの切り替えを手軽に行える様になります。
また、一度オンにしてメンテナンスモードにし、諸々のメンテナンス作業を終えた後にオフにするのも忘れがちなので気をつけましょう。(うまくパイプラインに組み込めると楽です)
IPリスト変更時
IPを追加したり削除したりする場合は、IPリストを変更し、再度シェルスクリプトを実行してください。
まとめ
AWS WAFのカスタムレスポンスやIP制限などの機能を使って、手軽にメンテナンスモードの切り替えができるようなスクリプトを作ってみました。
私はこれをさらに拡張して、複数アプリ・マイクロサービス(フロントもREST APIもGraphQL APIも)のメンテナンスモードの切り替えを一元的に・選択式で同時実行を行うツールを作って使用しています。
またこのWAFにマネージドルールも追加したりと、手軽にアレンジできて色々応用も効くので、ぜひこの記事が参考になれば幸いです。