🐥

TerraformでAWS WAFを使ったAPI側のメンテナンスモードを実装した

2023/10/03に公開

はじめに

WebサービスのAPI側のメンテナンスモードをTerraformで実装しましたのでその方法をまとめました。
今回のアーキテクチャ構成は、上の図のようにフロントエンドはSPAをAmplifyでホスティング、バックエンドはAPI Gatewayがフロントへのコンテンツ提供を集約しています。
バックエンドのAPIがフロントのクライアントアプリに503レスポンスを返すことで、クライアントアプリはメンテナンスページを表示する実装にする予定ですが、今回はそのバックエンド部分のみの実装になります。
Amazon API GatewayのREST APIではAWS WAFを使えるので、メンテナンスモードのときだけ、AWS WAFが503を返すようにTerraformで実装します。

Terraform

ディレクトリ構造

以下のようなファイル構成にしました。apigatewayモジュールにAPI GatewayとWAFの定義を書いていきます。

.
├── environments
│   └── dev
│       ├── main.tf
│       ├── terraform.tf
│       └── variables.tf
└── modules
    └── apigateway
        ├── main.tf
        └── variables.tf

API Gateway

modules/apigateway/main.tf
resource "aws_api_gateway_rest_api" "main" {
  ...

  endpoint_configuration {
    types = ["REGIONAL"]
  }
}

ALBやCloudFrontにWAFをつける場合もありますが、今回はAPI Gateway(REST API)になります。
※HTTP APIにはまだWAFはつけられないようです。

WAF IP Set

modules/apigateway/main.tf
# ******************************
# WAF IP Set
# ******************************
resource "aws_wafv2_ip_set" "developer" {
  name               = "developer"
  description        = "Developer IP set"
  scope              = "REGIONAL"
  ip_address_version = "IPV4"
  addresses          = []
  lifecycle {
    ignore_changes = [addresses]
  }
}

これは、メンテナンスモード中も開発者だけは503にならないように、開発者のIPのホワイトリストを登録するための箱です。 address は空にしたままで、 ignore_changes にしている理由はIPアドレスだけはTerraformの管理から除外するためです。あとでAWSコンソールから手動で登録するようにしています。

WAF Rule Group

modules/apigateway/main.tf
# ******************************
# WAF Rule Group
# ******************************
resource "aws_wafv2_rule_group" "maintenance" {
  name     = "rulegrp-maintenance"
  scope    = "REGIONAL"
  capacity = 1
  custom_response_body {
    key          = "maintenance"
    content_type = "APPLICATION_JSON"
    content = jsonencode(
      {
        status  = "maintenance",
        message = "サービスは現在メンテナンス中です。お手数をおかけしますが、しばらくお待ちください。"
      }
    )
  }
  rule {
    name     = "block-non-developers"
    priority = 0
    action {
      block {
        custom_response {
          response_code            = 503
          custom_response_body_key = "maintenance"
        }
      }
    }
    statement {
      not_statement {
        statement {
          ip_set_reference_statement {
            arn = aws_wafv2_ip_set.developer.arn
          }
        }
      }
    }
    visibility_config {
      cloudwatch_metrics_enabled = false
      metric_name                = "BlockNonDevelopers"
      sampled_requests_enabled   = false
    }
  }
  visibility_config {
    cloudwatch_metrics_enabled = false
    metric_name                = "maintenance"
    sampled_requests_enabled   = false
  }
}

このルールグループは、平常時はWAFにはアタッチされないルールグループです。 not_statement を使って開発者のIPではなかった場合503になるようにしています。レスポンス内容もカスタマイズしており、bodyはJSONで以下が返るようにしています。

{
    "message": "サービスは現在メンテナンス中です。お手数をおかけしますが、しばらくお待ちください。",
    "status": "maintenance"
}

WAF ACL

modules/apigateway/main.tf
# ******************************
# WAF ACL
# ******************************
resource "aws_wafv2_web_acl" "main" {
  name        = "acl-rest-api"
  description = "Apply OWASP rules to API Gateway."
  scope       = "REGIONAL"
  default_action {
    allow {}
  }
  rule {
    name     = "AWS-AWSManagedRulesCommonRuleSet"
    priority = 0
    override_action {
      none {}
    }
    statement {
      managed_rule_group_statement {
        name        = "AWSManagedRulesCommonRuleSet"
        vendor_name = "AWS"
      }
    }
    visibility_config {
      cloudwatch_metrics_enabled = false
      metric_name                = "OWASP"
      sampled_requests_enabled   = false
    }
  }
  dynamic "rule" {
    for_each = var.is_maintenance ? [1] : []
    content {
      name     = "maintenance"
      priority = 1
      override_action {
        none {}
      }
      statement {
        rule_group_reference_statement {
          arn = aws_wafv2_rule_group.maintenance.arn
        }
      }
      visibility_config {
        cloudwatch_metrics_enabled = false
        metric_name                = "maintenance-rule"
        sampled_requests_enabled   = false
      }
    }
  }
  visibility_config {
    cloudwatch_metrics_enabled = false
    metric_name                = "waf-for-apigateway"
    sampled_requests_enabled   = false
  }
}
resource "aws_wafv2_web_acl_association" "main" {
  resource_arn = aws_api_gateway_stage.main.arn
  web_acl_arn  = aws_wafv2_web_acl.main.arn
}
modules/apigateway/variables.tf
variable "is_maintenance" {
  type    = bool
  default = false
}

これらの定義では普段はAWSのコアルールセットで一般的なリスクの高い脆弱性に対応しますが、変数 is_maintenancetrue だった場合は、追加でメンテナンス用のルールグループがアタッチされるように書いています。dynamicとfor_eachを使うことで実現しています。

エントリーポイント

environments/dev/variables.tf
variable "apigateway_is_maintenance" {
  type    = bool
  default = false
}
environments/dev/main.tf
...(略)
module "apigateway" {
  source = "../../modules/apigateway"

  is_maintenance     = var.apigateway_is_maintenance
}

エントリーポイントで apigateway_is_maintenance 変数を指定することで、apigatewayモジュールの is_maintenance フラグを上書きできるようにしてあるので、
Terraform CloudのWorkspace Variablesで、以下のように apigateway_is_maintenance 変数をtrueにしてapplyするだけでメンテナンスモードにすることができます。

IP設定

AWSコンソールから手動でメンテナンスモード中もアクセスしたいIPのリストを設定します。

アクセス結果

許可されていないIPからPOSTMANでリクエストをしてみたところ503 Service Unavailableと、JSONメッセージが返ってきました。(許可されているIPの場合はいつも通りアクセスできました。)
本格的にフロントとの連携を考え始めるなら時間に関するメッセージを追加してもいいかなと思っています。
例えば以下のような感じです。

{
    "message": "サービスは現在メンテナンス中です。お手数をおかけしますが、しばらくお待ちください。",
    "status": "maintenance",
    "start_time": "2023-10-02T08:00:00Z",
    "end_time": "2023-10-02T10:00:00Z",
    "estimated_duration_minutes": 120
}

このような情報があれば、フロントでメンテナンス予定時刻を表示することができますね。Terraformの変数で時間も入れられるようにしておいたら、使いやすくなりますかね。色々考えるのが楽しいです。

メンテナンスモード解除

メンテナンスモードを戻す時は、Terraform Cloudでフラグをfalseにして、terraform applyをすれば元に戻せます。

フラグがtrueになった時に初めてルールグループを作る方法でもよかったのですが、terraform applyをしなくても、ワンチャン手動でAWSコンソールのWAF ACLのルールでルールグループをアタッチするだけでもメンテナンスモードにできるように、フラグでの制御は、アタッチのON/OFFだけにしました。

まとめ

API GatewayのWAFでメンテナンスモードを実装しました。次はフロントの実装をしたら記事を書こうかなと思っています。

レスキューナウテックブログ

Discussion