Terraform で ECS 環境を構築する③ 〜ECS on Fargate 編〜

前回、前々回の続きで、Terraform で ECS 環境の構築を行います。 今回は、ECS の部分を構築します。

blog.linkode.co.jp

blog.linkode.co.jp

はじめに:最終的なゴールと今回の目標の確認

  • 最終的な目標は、外部からのリクエストを受けられる ECS サービスの構築です。
    • 下図の青く囲ったところが前回まで構築した部分、赤く囲ったところが今回の目標です。

f:id:linkode-okazaki:20200923163234p:plain

ECS の構築

ECS の構成要素

AWS のコンテナオーケストレーションツールの ECS について簡単に説明します。

  • 以前触れた EKS はコンテナオーケストレーターとして Kubernetes を使用できました。一方、ECS のコンテナオーケストレーターには AWS 独自のものが採用されています。
  • ECS は主に以下の構成要素からなります:
    • ECS クラスタ:ホストサーバを束ねる。
    • タスク定義:アプリケーションを構成するコンテナの設定内容を記述する(JSON 形式)。
      • 使用するコンテナ、使用する起動タイプ、アプリケーションのコンテナインスタンスで開くポートなどを設定。
    • タスククラスター内のタスク定義をインスタンス化したもの。コンテナの実行単位。
    • ECS サービス:どのタスクを何個起動するのかということと、コンテナと ELB(ALB/NLB) の紐付けを管理する。

f:id:linkode-okazaki:20200923150600p:plain

ECS で Web サーバを構築する

  • ここで、 ECS を用いて Web サーバを構築します。
    • ECS をプライベートネットワークに配置します。
    • クラスタ内では nginx コンテナを起動するようタスクを定義します。
    • 前回の記事で作成した ALB 経由でリクエストを受け取り、nginx コンテナで処理するようにします。
    • 起動タイプは Fargate を指定します。

ECS クラスタとタスクの定義

  • まず、ECS クラスタとタスク定義を以下のように設定します:
    • ECS クラスタの設定はクラスタ名を指定するだけです。
    • タスク定義では以下の内容を設定します。
      • family : タスク定義名のプレフィックスを指定。ここで指定した名前にリビジョン番号を付与したものがタスク定義名になる
        • 下の例では、「linkode-example:1」のようになり、番号はタスク定義更新時にインクリメントされる。
      • cpumemory : タスクが使用するリソースのサイズを設定。
        • 設定できる値の組み合わせは決まっている*1
      • network_mode : Fargate 起動タイプの場合は、awsvpc を指定
      • requires_compatibilities : 起動タイプを指定。
      • container_definitions : コンテナ定義を実装。外部ファイルに記述して読み込むか、下のように json の内容を jsonencode で囲って直接 tf ファイル内に記述する。
        • name : コンテナ名
        • image : 使用するコンテナイメージ
        • essential : タスク実行に必須かどうかのフラグ
        • portMappings : マッピングするコンテナのプロトコルとポート番号
# ECS クラスター
resource "aws_ecs_cluster" "example" {
  name = "example"
}

# タスク定義
resource "aws_ecs_task_definition" "example" {
  family                   = "linkode-example"
  cpu                      = "256"
  memory                   = "1024"
  network_mode             = "awsvpc"
  requires_compatibilities = ["FARGATE"]
  container_definitions = jsonencode([
    {
      "name" : "example",
      "image" : "nginx:latest",
      "essential" : true,
      "portMappings" : [
        {
          "protocol" : "tcp",
          "containerPort" : 80
        }
      ]
    }
  ])
}

ECS サービスの定義

  • ECS サービスは以下のように設定します:
    • cluster : 先ほど作成した ECS クラスタの ARN を指定。
    • task_definition : 先ほど作成したタスク定義の ARN を指定。
    • desired_count : ECS サービスが維持するタスク数。
    • launch_type : 起動タイプを指定。
    • platform_version : デフォルトでは LATEST が指定されるが、最新バージョンを指さない場合がある*2ため、明示的にバージョンを指定する。
    • health_check_grace_period_seconds : タスク起動時のヘルスチェック猶予期間を秒単位で指定(デフォルトは 0 秒)。
      • 十分な猶予期間を設定しておかないとヘルスチェックが NG となってしまい、タスクの起動と終了が無限に続いてしまう。
    • network_configuration : ネットワーク構成を記述。
      • assign_public_ip : パブリック IP アドレスを割り当てるか否かを指定。
      • security_groups : セキュリティグループを指定。
      • subnets : サブネットを指定。
    • load_balancer : ターゲットグループとコンテナの名前、ポート番号を指定し、ロードバランサーと関連付ける。
    • lifecycle : タスク定義のライフサイクルを設定。
      • Fargate の場合、デプロイのたびにタスク定義が更新され、 terraform plan 実行時に差分が出るため、 Terraform ではタスク定義の変更を無視すべき。
      • よって、 ignore_changes でタスク定義の変更を無視するように設定する。
# ECS サービス
resource "aws_ecs_service" "example" {
  name                              = "example"
  cluster                           = aws_ecs_cluster.example.arn
  task_definition                   = aws_ecs_task_definition.example.arn
  desired_count                     = 2
  launch_type                       = "FARGATE"
  platform_version                  = "1.4.0"
  health_check_grace_period_seconds = 60

  network_configuration {
    assign_public_ip = false
    security_groups  = [module.nginx_sg.security_group_id]

    subnets = [
      aws_subnet.private_a.id,
      aws_subnet.private_c.id,
    ]
  }

  load_balancer {
    target_group_arn = aws_lb_target_group.ecs.arn
    container_name   = "example"
    container_port   = 80
  }

  lifecycle {
    ignore_changes = [task_definition]
  }
}

module "nginx_sg" {
  source      = "./security_group"
  name        = "nginx-sg"
  vpc_id      = aws_vpc.example.id
  port        = 80
  cidr_blocks = [aws_vpc.example.cidr_block]
}
  • ここまでの内容を ecs.tf に記述します。
.
|-- alb.tf
|-- ecs.tf        // 新規作成したファイル
|-- main.tf
|-- network.tf
|-- route53.tf
|-- s3.tf
`-- security_group
    |-- main.tf
    |-- output.tf
    `-- variable.tf

ALB ターゲットグループとリスナールールの作成

  • 前回作成した alb.tf にターゲットグループの設定を追記します。
    • target_type : ターゲットの種類を指定。ECS インスタンス, IP アドレス, Lambda 関数が指定可能。
      • Fargate の場合は ip を指定せねばならない。
      • ip を指定した場合は、 vpc_id, port, protocol を設定する。HTTPS の終端は ALB で行うため、ここでの protocol は HTTP を指定することが多い。
    • deregistration_delay : 登録解除する前に ALB が待機する時間を設定。
    • health_check : ヘルスチェックの設定。
      • path : ヘルスチェックで使用するパス
      • healthy_threshold : 正常判定を行うまでのヘルスチェック実行回数
      • unhealthy_threshold : 異常判定を行うまでのヘルスチェック実行回数
      • timeout : ヘルスチェックのタイムアウト時間(秒単位で指定)
      • interval : ヘルスチェックの実行間隔(秒単位で指定)
      • matcher : 正常判定を行うために使用する HTTP ステータスコード
      • port : ヘルスチェックで使用するポート
        • traffic_port を指定すると、上の port で指定したポート番号が使用される
      • protocol : ヘルスチェック時に使用するプロトコル
    • ALB, ターゲットグループを ECS サービスと同時に作成するとエラーになるため、 depends_on で依存関係を記述。
resource "aws_lb_target_group" "ecs" {
  name                 = "example"
  target_type          = "ip"
  vpc_id               = aws_vpc.example.id
  port                 = 80
  protocol             = "HTTP"
  deregistration_delay = 300

  health_check {
    path                = "/"
    healthy_threshold   = 5
    unhealthy_threshold = 2
    timeout             = 5
    interval            = 30
    matcher             = 200
    port                = "traffic-port"
    protocol            = "HTTP"
  }

  depends_on = [aws_lb.example]
}
  • 更に、前回作成した、 HTTPS のリスナーにリスナールールを追加し、上で作成したターゲットグループに転送されるように設定します。
    • priority : 優先順位を設定。数字が低いほど優先順位が高い。
      • aws_lb_listener で記述した default_rule は最も優先順位が低い。
    • condition : パスベースやホストベース等で条件を指定。
resource "aws_lb_listener_rule" "ecs" {
  listener_arn = aws_lb_listener.https.arn
  priority     = 100

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

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

適用と確認

  • ここまで作成したら、適用します。
$ terraform fmt
$ terraform get
$ terraform validate
$ terraform plan
$ terraform apply
  • 完了したら、curl かブラウザからアクセスし、 nginx のデフォルトが表示されることを確認します。
$ curl https://linkode-example.ml
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
...

Fargate におけるログの設定

  • Fargate では仮想マシンに直接ログインできないため、 CloudWatch Logs と連携し、ログが記録できるように設定する必要があります。

IAM ポリシーのモジュール化

  • ECS に権限を付与するにあたって、 IAM ロールをモジュール化しておきます。

main.tf の内容(クリックして展開)

resource "aws_iam_role" "default" {
  name               = var.name
  assume_role_policy = data.aws_iam_policy_document.assume_role.json
}

data "aws_iam_policy_document" "assume_role" {
  statement {
    actions = ["sts:AssumeRole"]

    principals {
      type        = "Service"
      identifiers = [var.identifier]
    }
  }
}

resource "aws_iam_policy" "default" {
  name   = var.name
  policy = var.policy
}

resource "aws_iam_role_policy_attachment" "default" {
  role       = aws_iam_role.default.name
  policy_arn = aws_iam_policy.default.arn
}

output.tf の内容(クリックして展開)

output "iam_role_arn" {
  value = aws_iam_role.default.arn
}

output "iam_role_name" {
  value = aws_iam_role.default.name
}

variable.tf の内容(クリックして展開)

variable "name" {
    type = string
}

variable "policy" {
    type = string
}
variable "identifier" {
    type = string
}

.
|-- alb.tf
|-- ecs.tf
|-- iam_role        // 新規作成したディレクトリ
|   |-- main.tf
|   |-- output.tf
|   `-- variable.tf
|-- main.tf
|-- network.tf
|-- route53.tf
|-- s3.tf
`-- security_group
    |-- main.tf
    |-- output.tf
    `-- variable.tf

ECS に付与する IAM ロールの定義

  • 上で作成したモジュールを利用して、 ECS タスク実行 IAM ロールを定義します。
    • ECS タスク実行 IAM ロールでの使用が想定されている AmazonECSTaskExecutionRolePolicy を参照します。
    • 参照したポリシーを用いて、ポリシードキュメントを定義します。
    • モジュールを利用する際の identifier には ecs-tasks.amazonaws.com を指定し、この IAM ロールを ECS で使うことを宣言します。
data "aws_iam_policy" "ecs_task_execution_role_policy" {
  arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}

data "aws_iam_policy_document" "ecs_task_execution" {
  source_json = data.aws_iam_policy.ecs_task_execution_role_policy.policy

  statement {
    effect    = "Allow"
    actions   = ["ssm:GetParameters", "kms:Decrypt"]
    resources = ["*"]
  }
}

module "ecs_task_execution_role" {
  source     = "./iam_role"
  name       = "ecs-task-execution"
  identifier = "ecs-tasks.amazonaws.com"
  policy     = data.aws_iam_policy_document.ecs_task_execution.json
}

CloudWatch Logs の定義と Docker コンテナのロギング設定

  • まず、CloudWatch Logs を以下のように定義します。
resource "aws_cloudwatch_log_group" "for_ecs" {
  name              = "/ecs/example"
  retention_in_days = 180
}
  • 先程の IAM の設定と上記 CloudWatch Logs の設定を cloudwatch.tf に記述して保存します。
.
|-- alb.tf
|-- clowdwatch.tf    // 新規作成したファイル
|-- ecs.tf
|-- iam_role
|   |-- main.tf
|   |-- output.tf
|   `-- variable.tf
|-- main.tf
|-- network.tf
|-- route53.tf
|-- s3.tf
`-- security_group
    |-- main.tf
    |-- output.tf
    `-- variable.tf
  • 次に、Docker コンテナが CloudWatch Logs にログを投げられるようにします。
  • aws_ecs_task_definition の設定を以下のように変更します。
    • execution_role_arn を追加し、上で設定した IAM ロールを付与します。
    • コンテナ定義の logConfiguration でログの設定を追加します。
resource "aws_ecs_task_definition" "example" {
  family                   = "linkode-example"
  cpu                      = "256"
  memory                   = "1024"
  network_mode             = "awsvpc"
  requires_compatibilities = ["FARGATE"]
  container_definitions    = jsonencode([
    {
      "name" : "example",
      "image" : "nginx:latest",
      "essential" : true,
      // ----- 新規追加した行 ここから -----
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-region": "ap-northeast-1",
          "awslogs-stream-prefix": "nginx",
          "awslogs-group": "/ecs/example"
        }
      },
      // ----- 新規追加した行 ここまで -----
      "portMappings" : [
        {
          "protocol" : "tcp",
          "containerPort" : 80
        }
      ]
    }
  ])
  execution_role_arn       = module.ecs_task_execution_role.iam_role_arn // 新規追加した行
}
  • ここまで記述できたら、適用します。
$ terraform fmt
$ terraform get
$ terraform validate
$ terraform plan
$ terraform apply
  • 正しく設定できていれば、ヘルスチェックのログが CloudWatch Logs に飛びます。

f:id:linkode-okazaki:20200923150554p:plain

まとめ

  • ECS の環境を作成し、ALB とつなげることによって、外部からのリクエストを受けられるアプリケーション環境を構築することが出来ました。
  • また、Docker コンテナのログを CloudWatch Logs に投げられるようにしたことで、 Fargate で稼働するコンテナのログも記録・確認出来るようになりました。
  • 次回は、自作のコンテナイメージをデプロイするパイプラインを構築します。

参考資料