TerraformでECSのBlue/GreenデプロイをApp Meshでやってみる

2022-04-25

Amazon ECSはAWS独自のコンテナ実行・管理サービス、AWS App MeshはAmazon EKSでも利用できるサービスメッシュのサービスです。
この記事では、TerraformでECS+App Meshを構築し、Blue/Greenデプロイメントを実現する方法について記載します。

なお、独自の方法なので、良い子は真似しないでください。
利用する場合は、くれぐれも大人の方と一緒に用法・用量守って正しくお使いください。

概要

AWS App Mesh については以下のスライドを参照してください。

20201014 AWS Black Belt Online Seminar AWS App Mesh Deep Dive

Blue/Green デプロイメントの実現方法は、 Virtual Router にてリクエストの振り分け先となる Virtual Node の重み付けや紐付けを変更することで実現します。
Blue 用の Virtual Node と Green 用の Virtual Node を作成して Virtual Router の設定を変更することで行います。

Terraform

ここでは Terraform のコードを交えて Blue/Green デプロイメントを行うための仕組みについて説明します。
なお、全コードを載せるかなり多くなってしまいますので、ポイントとなる部分のみ載せること、あらかじめご容赦ください。

Virtual Node

以下は Blue 用の Virtual Node を構築するコードです。

#####################################
# App Mesh / Virtual Node
#####################################
resource "aws_appmesh_virtual_node" "sample_blue" {
  count     = local.sample_param["blue_is_active"] ? 1 : 0
  name      = "${local.sample_param["app_name"]}-blue"
  mesh_name = aws_appmesh_mesh.sample.id
  spec {
    listener {
      port_mapping {
        port     = local.sample_param["app_port"]
        protocol = "http"
      }
      health_check {
        protocol            = "http"
        path                = local.sample_param["healthcheck_path"]
        healthy_threshold   = 2
        unhealthy_threshold = 2
        timeout_millis      = 2000
        interval_millis     = 5000
      }
    }
    service_discovery {
      aws_cloud_map {
        attributes = {
          ECS_SERVICE_NAME = "${local.base_name}-sample-blue"
        }
        namespace_name = aws_service_discovery_private_dns_namespace.sample_private_dns.name
        service_name   = local.sample_param["app_name"]
      }
    }
    logging {
      access_log {
        file {
          path = "/dev/stdout"
        }
      }
    }
  }
}

#####################################
# ECS Service
#####################################
resource "aws_ecs_service" "sample_blue" {
  count            = local.sample_param["blue_is_active"] ? 1 : 0
  name             = "${local.base_name}-sample-blue"
  cluster          = aws_ecs_cluster.sample.arn
  task_definition  = aws_ecs_task_definition.sample_blue[count.index].arn
  desired_count    = local.sample_param["desired_count"]
  launch_type      = "FARGATE"
  platform_version = local.sample_param["platform_version"]
  network_configuration {
    assign_public_ip = true
    security_groups  = [aws_security_group.sample.id]
    subnets = [
      aws_subnet.sample_protected_1a.id,
      aws_subnet.sample_protected_1c.id,
    ]
  }
  service_registries {
    registry_arn = aws_service_discovery_service.sample.arn
  }
  lifecycle {
    ignore_changes = [task_definition]
  }
}

#####################################
# ECS Task
#####################################
resource "aws_ecs_task_definition" "sample_blue" {
  count                    = local.sample_param["blue_is_active"] ? 1 : 0
  family                   = "${local.sample_param["app_name"]}-blue"
  cpu                      = local.sample_param["task_cpu"]
  memory                   = local.sample_param["task_memory"]
  network_mode             = "awsvpc"
  requires_compatibilities = ["FARGATE"]
  execution_role_arn       = aws_iam_role.sample_exec.arn
  task_role_arn            = aws_iam_role.sample.arn
  container_definitions    = data.template_file.sample_blue[count.index].rendered
  proxy_configuration {
    type           = "APPMESH"
    container_name = "envoy"
    properties = {
      AppPorts         = "${local.sample_param["app_port"]}"
      EgressIgnoredIPs = "169.254.170.2,169.254.169.254"
      IgnoredUID       = "1337"
      ProxyEgressPort  = 15001
      ProxyIngressPort = 15000
    }
  }
}

#####################################
# ECS Task Definition
#####################################
data "template_file" "sample_blue" {
  count    = local.sample_param["blue_is_active"] ? 1 : 0
  template = file("${path.module}/tpl/task-def-sample.tpl")
  vars = {
    // app container def
    name           = "${local.sample_param["app_name"]}-blue"
    image          = local.sample_param["app_image_blue"]
    memory         = local.sample_param["app_memory"]
    containerPort  = local.sample_param["app_port"]
    app_logs_group = aws_cloudwatch_log_group.sample.name
    // envoy container def
    envoy_image          = local.sample_param["envoy_image"]
    envoy_cpu            = local.sample_param["envoy_cpu"]
    envoy_memory_rsv     = local.sample_param["envoy_memory_rsv"]
    envoy_logs_group     = aws_cloudwatch_log_group.sample_envoy.name
    appmesh_resource_arn = "mesh/${aws_appmesh_mesh.sample.name}/virtualNode/${local.sample_param["app_name"]}-blue"
    // xray container def
    xray_image      = local.sample_param["xray_image"]
    xray_cpu        = local.sample_param["xray_cpu"]
    xray_memory_rsv = local.sample_param["xray_memory_rsv"]
    xray_logs_group = aws_cloudwatch_log_group.sample_xray.name
  }
}

local.sample_param["blue_is_active"]true のときに作成されるようになっています。
なお、上記のコードで文字列 bluegreen に置換したものを Green 用の Virtual Node を構築するコードとして別途準備してください。

Virtual Router

以下は Virtual Router を構築するコードです。

#####################################
# App Mesh / Virtual Router
#####################################
resource "aws_appmesh_virtual_router" "sample" {
  name      = local.sample_param["app_name"]
  mesh_name = aws_appmesh_mesh.sample.id
  spec {
    listener {
      port_mapping {
        port     = local.sample_param["app_port"]
        protocol = "http"
      }
    }
  }
}

// route for blue and green
resource "aws_appmesh_route" "sample" {
  count               = local.sample_param["blue_is_active"] == "true" && local.sample_param["green_is_active"] == "true" ? 1 : 0
  name                = local.sample_param["app_name"]
  mesh_name           = aws_appmesh_mesh.sample.id
  virtual_router_name = aws_appmesh_virtual_router.sample.name
  spec {
    http_route {
      match {
        prefix = "/"
      }
      retry_policy {
        tcp_retry_events = [
          "connection-error",
        ]
        max_retries = 1
        per_retry_timeout {
          unit  = "s"
          value = 1
        }
      }
      action {
        weighted_target {
          virtual_node = aws_appmesh_virtual_node.sample_blue[count.index].name
          weight       = local.sample_param["blue_weight"]
        }
        weighted_target {
          virtual_node = aws_appmesh_virtual_node.sample_green[count.index].name
          weight       = local.sample_param["green_weight"]
        }
      }
    }
  }
}

// route for blue
resource "aws_appmesh_route" "sample_blue" {
  count               = local.sample_param["blue_is_active"] == "true" && local.sample_param["green_is_active"] == "false" ? 1 : 0
  name                = "${local.sample_param["app_name"]}-blue"
  mesh_name           = aws_appmesh_mesh.sample.id
  virtual_router_name = aws_appmesh_virtual_router.sample.name
  spec {
    http_route {
      match {
        prefix = "/"
      }
      retry_policy {
        tcp_retry_events = [
          "connection-error",
        ]
        max_retries = 1
        per_retry_timeout {
          unit  = "s"
          value = 1
        }
      }
      action {
        weighted_target {
          virtual_node = aws_appmesh_virtual_node.sample_blue[count.index].name
          weight       = local.sample_param["blue_weight"]
        }
      }
    }
  }
}

// route for green
resource "aws_appmesh_route" "sample_green" {
  count               = local.sample_param["blue_is_active"] == "false" && local.sample_param["green_is_active"] == "true" ? 1 : 0
  name                = "${local.sample_param["app_name"]}-green"
  mesh_name           = aws_appmesh_mesh.sample.id
  virtual_router_name = aws_appmesh_virtual_router.sample.name
  spec {
    http_route {
      match {
        prefix = "/"
      }
      retry_policy {
        tcp_retry_events = [
          "connection-error",
        ]
        max_retries = 1
        per_retry_timeout {
          unit  = "s"
          value = 1
        }
      }
      action {
        weighted_target {
          virtual_node = aws_appmesh_virtual_node.sample_green[count.index].name
          weight       = local.sample_param["green_weight"]
        }
      }
    }
  }
}

// route for blue and green とコメントを打っているのが Blue と Green 両方の Virtual Node へ、 // route for blue が Blue のみへ、 // route for green が Green のみへルーティングする設定です。
ぞれぞれ local.sample_param["blue_is_active"]local.sample_param["green_is_active"]true か否かによって作成されるようになっており、これらのパラメータの値によってルーティング先を切り替える仕組みとなっています。
また、 local.sample_param["blue_weight"]local.sample_param["green_weight"] の値によってリクエストルーティングの流量を変更することができます。
Blue/Green デプロイメントとは言っていますが、 この weight の値の割合をコントロールすることによって カナリアリリース も可能です。

タスクのロール

以下は ECS タスクが利用するロールを構築するコードです。

#####################################
# ECS Task Role
#####################################
resource "aws_iam_role" "sample" {
  name               = "${local.base_name}-sample"
  assume_role_policy = data.aws_iam_policy_document.sample_assume.json
}

data "aws_iam_policy_document" "sample_assume" {
  statement {
    actions = ["sts:AssumeRole"]
    principals {
      type        = "Service"
      identifiers = ["ecs-tasks.amazonaws.com"]
    }
  }
}

data "aws_iam_policy_document" "sample" {
  count = 1
  statement {
    actions = [
      "appmesh:StreamAggregatedResources",
    ]
    resources = local.sample_param["blue_is_active"] == true ? local.sample_param["green_is_active"] == true ? ["${aws_appmesh_virtual_node.sample_blue[count.index].arn}", "${aws_appmesh_virtual_node.sample_green[count.index].arn}"] : ["${aws_appmesh_virtual_node.sample_blue[count.index].arn}"] : ["${aws_appmesh_virtual_node.sample_green[count.index].arn}"]
  }
}

resource "aws_iam_role_policy" "sample" {
  count  = 1
  role   = aws_iam_role.sample.name
  policy = data.aws_iam_policy_document.sample[count.index].json
}

resource "aws_iam_role_policy_attachment" "sample_cloudwatch" {
  role       = aws_iam_role.sample.name
  policy_arn = "arn:aws:iam::aws:policy/CloudWatchFullAccess"
}

resource "aws_iam_role_policy_attachment" "sample_xray" {
  role       = aws_iam_role.sample.name
  policy_arn = "arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess"
}

local.sample_param["blue_is_active"]local.sample_param["green_is_active"]true か否かによって、 appmesh:StreamAggregatedResources アクションの対象リソースとなる Virtual Node が切り替わるようになっています。

変数

以下はこれまでの Terraform コードで利用する変数の定義です。

locals {
  sample_param = {
    // service def
    desired_count    = 1
    platform_version = "1.4.0"
    // task def
    task_cpu    = 512  # 0.5 vCPU
    task_memory = 1024 # 1 GB
    // app container def
    app_name         = "sample"
    app_memory       = 256 # ハード制限(上限確保量)
    app_port         = "8080"
    healthcheck_path = "/health"
    // app container def blue
    blue_weight    = 1
    blue_is_active = true
    app_image_blue = "xxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/sample:0.0.1"
    // app container def green
    green_weight    = 0
    green_is_active = false
    app_image_green = "xxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/sample:0.0.2"
    // envoy container def
    envoy_image      = "840364872350.dkr.ecr.ap-northeast-1.amazonaws.com/aws-appmesh-envoy:v1.20.0.1-prod"
    envoy_cpu        = 32
    envoy_memory_rsv = 256 # ソフト制限(通常確保量)
    // xray container def
    xray_image      = "amazon/aws-xray-daemon"
    xray_cpu        = 32
    xray_memory_rsv = 256 # ソフト制限(通常確保量)
  }
}

ポイントは blue_is_activegreen_is_activeblue_weightgreen_weightapp_image_blueapp_image_blue です。
次節で使い方について説明していきます。

使い方

前節にて Terraform のコードについて説明しました。
本節では実際に Blue/Green デプロイメントを行う手順について説明します。

まず初期状態は各変数が以下の状態です。

blue_is_active = true
blue_weight    = 1
app_image_blue = "xxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/sample:0.0.1" # v1 と呼びます

green_is_active = false
green_weight    = 0
app_image_green = "xxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/sample:0.0.2" # v2 と呼びます

Blue が v1 のアクティブ状態でルーティングも行われています。
Green は v2 の非アクティブ状態でルーティングは行われていません。
この状態から v2 の Green をデプロイして、最終的に v1 の Blue を停止させます。

  1. green_is_active = true として terraform apply
  2. しばらくすると v2 の Green が起動しますが、リクエストはルーティングされていません -> 正しく起動することを確認
  3. green_weight = 1 として terraform apply
  4. しばらくすると v2 の Green にもリクエストがルーティングされます -> アプリケーションが正しく処理を行なっていることを確認
  5. blue_weight = 0 として terraform apply
  6. しばらくすると v1 の Blue にリクエストがルーティングされなくなります -> Blue へのリクエストが無風になることを確認
  7. blue_is_active = false として terraform apply
  8. しばらくすると v1 の Blue が停止・削除されます

以上で Blue/Green デプロイメントが完了です。
上記のように変数の値を変更して terraform apply することで切り戻しも簡単に行えます。
次回は Blue を v3 として Blue/Green デプロイメントすることになるでしょう。

いかがでしたか?
bluegreen が Virtual Node 名に丸見えになってしまうのがダサくてイヤな感じですが、なかなか使えるのではないでしょうか。

おすすめ書籍

おすすめ記事