Terraform活用:ALB/WAF構成のAWS環境へのWebAPIの段階的移行

こんにちは、アプリケーション開発を担当している田中です。
オンプレミス環境からAWS環境へバックエンドAPIを移行しました。移行対象のコマンド数が100本ほどあり、リスク軽減のため段階的に移行をしたので、どう段階移行を実現したのかを書いておこうと思います。

要件

  • 現行環境(オンプレ)と新環境(AWS)を並行稼働し、APIコマンドを段階的に移行する
  • 移行はURLパスごとのかたまりで行う
    ※sample-api-app.io/{機能名}/* 程度の単位でいくつかに分割して順次移行
  • ホワイトリスト形式のIPアドレスベースのアクセス制限を行う(現行踏襲)
  • IPアクセス制限はURLパス単位に設定できるようにする

前提

  • 現行環境(オンプレ)と新環境(AWS)間は、インターナルなネットワークで接続が可能
  • 手前にクラウドWAFがあり、このWAFにより接続元IPがヘッダー情報に格納されるため、アクセス制限の際はヘッダーからIPを参照する

段階移行のイメージ

構築手順

1. URLパスベースの振り分け

振り分けはALB(Application Load Balancer)のリスナールールで行います。ALBのパスベースでの振り分け設定方法は「ALB パスベースルーティング」などでネット検索するとたくさん出てくるので、コンソールからの設定方法は割愛します。のちほどterraformのサンプルコードを記載しているので参考にしてみてください。
以下2つのリスナールールを用意し、それぞれに条件を追加します。

■80ポートのリスナールール

  • HTTPS(443)へリダイレクトする

■443ポートのリスナールール
現行環境(オンプレ)VMのIP群と、新環境(AWS)のECSタスクのIP群のターゲットグループをそれぞれ作成した上で以下の条件をリスナールールに追加します。

  • 新環境(AWS)への振り分け対象URLパスパターンの場合、新環境のターゲットグループへ振り分け
  • 1つのリスナールールに付与できる条件は最大5つなので、5つ以上のパスパターンを振り分ける場合は上記と同様のリスナールールを追加
  • すべてのアクセス(/*)を現行環境(オンプレ)のターゲットグループへ振り分け(この条件を最も低い優先度で登録し、上記条件に一致しないパスをすべてオンプレへ転送します)

2. IPアドレスでのアクセス制限

IPアドレスによるアクセス制限の方式は、以下3つの方式から検討し、AWS WAFによるアクセス制限を採用しました。

AWS WAFこれを採用した
ALBリスナールール1ルールにつき5条件までしか設定できず、適用するパスを限定する条件設定と組み合わせると複雑になってしまう
セキュリティグループヘッダー情報を条件に制限がかけられないため要件を満たせない
IPベースのアクセス制限方式を検討

3. terraformサンプル

弊社ではAWSリソースをterraformで実装してコード管理しています。以下にterraformで実装した際のサンプルコードを掲載します。
今回はVPCやサブネット、ECSリソースは別のモジュールで作成するものとして割愛しています。
また、CodeDeployによるBlue/Greenデプロイを想定してBlue/Greenそれぞれのリスナーを用意しています。

ディレクトリ構成

main.tf
modules
├alb
│ ├alb.tf
│ ├target_group.tf
│ ├variables.tf
│ └outputs.tf
│
├waf
│ ├waf.tf
│ └variables.tf
│

main.tf

locals {
  application_name = "sample-api-app"
}

# --------------------------------------------------------
# Application Load Balancer
# --------------------------------------------------------
module "alb" {
  source                  = "../../modules/alb"

  application_name        = local.application_name

  private_subnet_ids      = module.network.vpc.private_subnets # 別途moduleを作って作成してください
  certificate_arn         = "arn:aws:acm:ap-northeast-1:xxxxxxxxxxxx:certificate/uuuuuuuu-uuuu-iiii-dddd-000000000000"
  security_group_id       = module.security_groups.alb.id # 別途moduleを作って作成してください

  vpc_id                  = module.network.vpc.vpc_id # 別途moduleを作って作成してください
  aws_health_check_path   = "/aws-health-check-path" # アプリケーションに合わせて設定
  onpre_health_check_path = "/onpre-health-check-path" # アプリケーションに合わせて設定
  routing_paths1          = ["/aaa*", "/bbb*", "/ccc*", "/ddd*", "/eee*"]
  listener_rule_priority1 = 1
  routing_paths2          = ["/fff*", "/ggg*", "/hhh*", "/iii*", "/jjj*"]
  listener_rule_priority2 = 2
}

# --------------------------------------------------------
# AWS WAF
# --------------------------------------------------------
module "waf" {
  source             = "../../modules/waf"

  application_name   = local.application_name

  alb_arn            = module.alb.aws_alb_arn
  allowed_ip_address = [
    "xxx.xxx.xxx.xxx/32",
    "yyy.yyy.yyy.yyy/30"
  ]
  ip_header_name     = "X-WAF-Connecting-IP" # 手前のクラウドWAFで接続元IPアドレスが格納されるヘッダーのパラメータ名
}

modules/alb

variable "application_name" {
  type        = string
  description = "アプリケーション名"
}

variable "private_subnet_ids" {
  type = list(string)
}

variable "certificate_arn" {
  type        = string
  description = "証明書のARN"
}

variable "security_group_id" {
  type = string
}

variable "vpc_id" {
  type = string
}

variable "aws_health_check_path" {
  type = string
}

variable "onpre_health_check_path" {
  type = string
}

variable "routing_paths1" {
  type = list(string)
}

variable "listener_rule_priority1" {
  type = number
}

variable "routing_paths2" {
  type = list(string)
}

variable "listener_rule_priority2" {
  type = number
}
locals {
  # ALBの名前は32文字まで
  alb_name = "${substr(var.application_name, 0, 28)}-alb"
}

# --------------------------------------------------------
# ALB
# --------------------------------------------------------
resource "aws_lb" "alb" {
  name               = local.alb_name
  internal           = true
  load_balancer_type = "application"
  security_groups    = [var.security_group_id]
  subnets            = var.private_subnet_ids

  enable_deletion_protection = false
  drop_invalid_header_fields = true
  idle_timeout               = 300
  xff_header_processing_mode = "preserve"

  tags = {
    Name = "${var.application_name}-alb"
  }
}

# --------------------------------------------------------
# Listener
# --------------------------------------------------------
# Blue/Greenデプロイ用にそれぞれのlistenerを作成する
# http(80, 8080) -> https(443, 8443) にリダイレクトする
resource "aws_lb_listener" "blue_https" {
  load_balancer_arn = aws_lb.alb.arn
  port              = 443
  protocol          = "HTTPS"
  ssl_policy        = "ELBSecurityPolicy-TLS13-1-2-2021-06"
  certificate_arn   = var.certificate_arn

  default_action {
    type = "fixed-response"

    fixed_response {
      content_type = "text/plain"
      message_body = "404: page not found"
      status_code  = "404"
    }
  }
}

resource "aws_lb_listener" "blue_http" {
  load_balancer_arn = aws_lb.alb.arn
  port              = 80
  protocol          = "HTTP"

  default_action {
    type = "redirect"

    redirect {
      port        = "443"
      protocol    = "HTTPS"
      status_code = "HTTP_301"
    }
  }
}

resource "aws_lb_listener" "green_https" {
  load_balancer_arn = aws_lb.alb.arn
  port              = 8443
  protocol          = "HTTPS"
  ssl_policy        = "ELBSecurityPolicy-TLS13-1-2-2021-06"
  certificate_arn   = var.certificate_arn

  default_action {
    type = "fixed-response"

    fixed_response {
      content_type = "text/plain"
      message_body = "404: page not found"
      status_code  = "404"
    }
  }
}

resource "aws_lb_listener" "green_http" {
  load_balancer_arn = aws_lb.alb.arn
  port              = 8080
  protocol          = "HTTP"

  default_action {
    type = "redirect"

    redirect {
      port        = "8443"
      protocol    = "HTTPS"
      status_code = "HTTP_301"
    }
  }
}

# --------------------------------------------------------
# Listener Rule
# --------------------------------------------------------
# Blue/Green x AWS/オンプレごとにリスナールールを作成する
# AWS環境へ振り分けるルールは、パスパターン5つまでしか設定できないため、パスパターンの数に合わせてルールを増やす
# http(80, 8080)はhttps(443, 8443)にリダイレクトするため、httpsのルールのみを追加すればOK
# これらのルールはAWSへ完全移行後に削除します
resource "aws_lb_listener_rule" "aws_blue_listener_rule1" {
  listener_arn = aws_lb_listener.blue_https.arn
  priority     = var.listener_rule_priority1

  action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.blue.arn
  }

  condition {
    path_pattern {
      values = var.routing_paths1
    }
  }

  lifecycle {
    ignore_changes = [action]
  }

  tags = {
    Name = "${var.application_name}-aws-rule1"
  }
}

resource "aws_lb_listener_rule" "aws_blue_listener_rule2" {
  listener_arn = aws_lb_listener.blue_https.arn
  priority     = var.listener_rule_priority2

  action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.blue.arn
  }

  condition {
    path_pattern {
      values = var.routing_paths2
    }
  }

  lifecycle {
    ignore_changes = [action]
  }

  tags = {
    Name = "${var.application_name}-aws-rule2"
  }
}

resource "aws_lb_listener_rule" "aws_green_listener_rule1" {
  listener_arn = aws_lb_listener.green_https.arn
  priority     = var.listener_rule_priority1

  action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.green.arn
  }

  condition {
    path_pattern {
      values = var.routing_paths1
    }
  }

  lifecycle {
    ignore_changes = [action]
  }

  tags = {
    Name = "${var.application_name}-aws-rule1"
  }
}

resource "aws_lb_listener_rule" "aws_green_listener_rule2" {
  listener_arn = aws_lb_listener.green_https.arn
  priority     = var.listener_rule_priority2

  action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.green.arn
  }

  condition {
    path_pattern {
      values = var.routing_paths2
    }
  }

  lifecycle {
    ignore_changes = [action]
  }

  tags = {
    Name = "${var.application_name}-aws-rule2"
  }
}

resource "aws_lb_listener_rule" "onpre_blue_listener_rule" {
  listener_arn = aws_lb_listener.blue_https.arn
  priority     = 100 # 最も低い優先度で設定する

  action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.onpre.arn
  }

  condition {
    path_pattern {
      values = ["/*"]
    }
  }

  tags = {
    Name = "${var.application_name}-onpre-rule"
  }
}

resource "aws_lb_listener_rule" "onpre_green_listener_rule" {
  listener_arn = aws_lb_listener.green_https.arn
  priority     = 100 # 最も低い優先度で設定する

  action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.onpre.arn
  }

  condition {
    path_pattern {
      values = ["/*"]
    }
  }

  tags = {
    Name = "${var.application_name}-onpre-rule"
  }
}
# --------------------------------------------------------
# Target Group
# --------------------------------------------------------
# AWS環境のBlue、Greenおよび、オンプレ環境の3つのターゲットグループを作成する
# AWSへの完全移行後にオンプレ環境のターゲットグループは削除する
resource "aws_lb_target_group" "blue" {
  name_prefix = "a-tg1-"
  port        = 80
  protocol    = "HTTP"
  vpc_id      = var.vpc_id
  target_type = "ip"

  health_check {
    path                = var.aws_health_check_path
    protocol            = "HTTP"
    timeout             = 30
    interval            = 60
    matcher             = 200
    healthy_threshold   = 3
    unhealthy_threshold = 3
  }

  tags = {
    Name = "${var.application_name}-aws-albtg1"
  }

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_lb_target_group" "green" {
  name_prefix = "a-tg2-"
  port        = 80
  protocol    = "HTTP"
  vpc_id      = var.vpc_id
  target_type = "ip"

  health_check {
    path                = var.aws_health_check_path
    protocol            = "HTTP"
    timeout             = 30
    interval            = 60
    matcher             = 200
    healthy_threshold   = 3
    unhealthy_threshold = 3
  }

  tags = {
    Name = "${var.application_name}-aws-albtg2"
  }

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_lb_target_group" "onpre" {
  name_prefix = "tg-op-"
  port        = 80
  protocol    = "HTTP"
  vpc_id      = var.vpc_id
  target_type = "ip"

  health_check {
    path                = var.onpre_health_check_path
    protocol            = "HTTP"
    timeout             = 30
    interval            = 60
    matcher             = 200
    healthy_threshold   = 3
    unhealthy_threshold = 3
  }

  tags = {
    Name = "${var.application_name}-onpre-albtg"
  }

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_lb_target_group_attachment" "onpre" {
  for_each = toset(["xxx.xxx.xxx.xxx", "yyy.yyy.yyy.yyy"]) # オンプレ環境WebサーバーのIPアドレス

  target_group_arn  = aws_lb_target_group.onpre.arn
  target_id         = each.value
  availability_zone = "all" # オンプレ環境のプライベートIPはVPC外にあるためallを指定
  port              = 80
}
output "aws_alb_arn" {
  value = aws_lb.alb.arn
}

modules/waf

variable "application_name" {
  type        = string
  description = "アプリケーション名"
}

variable "allowed_ip_address" {
  type = list(string)
  description = "アクセス許可するIPアドレスのリスト"
}

variable "alb_arn" {
  type        = string
  description = "作成したALBのARN"
}

variable "ip_header_name" {
  type        = string
  description = "接続元IPが格納されているヘッダー名"
}
resource "aws_wafv2_ip_set" "_" {
  name               = "${var.application_name}-waf-ipset"
  description        = "Allowed ip set for ${var.application_name}"
  scope              = "REGIONAL"
  ip_address_version = "IPV4"
  addresses          = var.allowed_ip_address
}

resource "aws_wafv2_web_acl" "_" {
  name        = "${var.application_name}-waf-acl"
  description = "ACL for ${var.application_name}"
  scope       = "REGIONAL"

  default_action {
    block {}
  }

  rule {
    name     = "WAFIPsetRule"
    priority = 1

    action {
      allow {}
    }

    statement {
      ip_set_reference_statement {
        arn = aws_wafv2_ip_set._.arn
        ip_set_forwarded_ip_config {
          header_name       = var.ip_header_name
          fallback_behavior = "MATCH"
          position          = "ANY"
        }
      }
    }

    visibility_config {
      cloudwatch_metrics_enabled = false
      metric_name                = "${var.application_name}-waf-ipset"
      sampled_requests_enabled   = false
    }
  }

  visibility_config {
    cloudwatch_metrics_enabled = false
    metric_name                = "${var.application_name}-waf-acl"
    sampled_requests_enabled   = true
  }
}

resource "aws_wafv2_web_acl_association" "_" {
  resource_arn = var.alb_arn
  web_acl_arn  = aws_wafv2_web_acl._.arn
}

まとめ

AWSへの移行はアプリケーション開発者でインフラリソースの管理をよしなに行い、インフラチームとの調整は最初の導通確認のみで以降はほぼ調整不要でした。今後も調整コストが減り、プロジェクトのコントロールがとても楽になる実感がありました。

「段階的な移行」と言うと関係者が増えてリリース計画が複雑化するため構えてしまう印象がありますが、これであればかなりハードルが下がりますね。AWS強い。

ブログの著者欄

田中 草

GMOインターネットグループ株式会社

2023年7月GMOインターネットグループ株式会社に入社。お名前.comレンタルサーバーのアプリケーション開発を中心に携わっています。

採用情報

関連記事

KEYWORD

採用情報

SNS FOLLOW

GMOインターネットグループのSNSをフォローして最新情報をチェック