Terraform で ECS 環境を構築する④ 〜DynamoDB, SQS, ECR, CodePipeline 編〜

このシリーズの一旦の完結編です。 前回構築した ECS にデプロイする仕組みを構築します。 また、コンテナアプリから利用する DynamoDB と SQS も Terraform で構築します。

blog.linkode.co.jp

blog.linkode.co.jp

blog.linkode.co.jp

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

  • 前回までで、下図の青く囲った部分を構築しました。
  • 今回は、赤く囲った部分を構築します。
  • ソースコードを管理する GitLab は別で用意します。

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

アプリケーションに必要なリソースを作成する

  • 自作のコンテナイメージを作成する前に、アプリケーションで利用するリソースを作成します。
  • DynamoDB は以下のように設定します。
    • id をハッシュキーとするテーブルを定義します。
    • 以下の内容を dynamodb.tf に記述して保存します。
resource "aws_dynamodb_table" "example" {
  name           = "Example"
  read_capacity  = 5
  write_capacity = 5
  hash_key       = "id"

  attribute {
    name = "id"
    type = "S"
  }

  tags = {
    Name = "example"
  }
}
  • SQS は以下のように設定します。
    • SQS のメッセージ送信権限を付与したキューを定義します。
    • 以下の内容を sqs.tf に記述して保存します。
resource "aws_sqs_queue" "example" {
  name                      = "example"
  max_message_size          = 2048
  message_retention_seconds = 60 * 60 * 24 * 1
  policy                    = data.aws_iam_policy_document.example-receive.json
}

data "aws_iam_policy_document" "example-receive" {
  statement {
    effect = "Allow"
    actions = [
      "sqs:SendMessage",
    ]
  }
}
  • ECS のタスクに DynamoDB や SQS にアクセスする権限を付与します。
  • ecs.tf に以下を追加します。
    • aws_ecs_task_definitiontask_role_arn を追加します。
    • container_definitionsimage には後で作成する ECR の URL を記述します。
    • AmazonEC2ContainerServiceRole, AmazonDynamoDBFullAccess, AmazonSQSFullAccess のポリシーを付与した IAM ロールを作成し、上の task_role_arn に指定します。
# タスク定義
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" : "${aws_ecr_repository.example.repository_url}:latest",
      "essential" : true,
      "logConfiguration" : {
        "logDriver" : "awslogs",
        "options" : {
          "awslogs-region" : "ap-northeast-1",
          "awslogs-stream-prefix" : "tf-example",
          "awslogs-group" : "/ecs/example"
        }
      },
      "portMappings" : [
        {
          "protocol" : "tcp",
          # アプリケーションの内容に合わせてポート番号を変更
          "containerPort" : 3000
        }
      ]
    }
  ])
  execution_role_arn = module.ecs_task_execution_role.iam_role_arn
  task_role_arn      = aws_iam_role.ecs_task.arn  # 新規追加した行
}

# 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.tf-example_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   = 3000
  }

  lifecycle {
    ignore_changes = [task_definition]
  }
}

module "tf-example_sg" {
  source      = "./security_group"
  name        = "tf-example-sg"
  vpc_id      = aws_vpc.example.id
  # アプリケーションの内容に合わせてポート番号を変更
  port        = 3000
  cidr_blocks = [aws_vpc.example.cidr_block]
}

# 以下、新規追加した箇所
# ECS タスク定義に付与する IAM ロール
data "aws_iam_policy_document" "ecs_assume_role" {
  statement {
    effect  = "Allow"
    actions = ["sts:AssumeRole"]
    principals {
      type        = "Service"
      identifiers = ["ecs-tasks.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "ecs_task" {
  name               = "tf_example-ecs_task-role"
  assume_role_policy = data.aws_iam_policy_document.ecs_assume_role.json
}

resource "aws_iam_role_policy_attachment" "ecs_service" {
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonEC2ContainerServiceRole"
  role       = aws_iam_role.ecs_task.name
}

resource "aws_iam_role_policy_attachment" "dynamodb-full-access" {
  policy_arn = "arn:aws:iam::aws:policy/AmazonDynamoDBFullAccess"
  role       = aws_iam_role.ecs_task.name
}

resource "aws_iam_role_policy_attachment" "sqs_full" {
  policy_arn = "arn:aws:iam::aws:policy/AmazonSQSFullAccess"
  role       = aws_iam_role.ecs_task.name
}

アプリケーションの準備

  • DynamoDB と SQS を利用する簡単なアプリケーションを TypeScript と Docker で作成します。
    • アプリケーションと Dockerfile の内容は GitLab に上げてあります。

gitlab.com

ECR の作成

  • 作成した Docker イメージを push するためのリポジトリを ECR に作成します。
  • 以下の内容を ecr.tf に記述して保存します。
    • aws_ecr_repository には、リポジトリ名を指定するだけで OK です。
    • aws_ecr_lifecycle_policy にはリポジトリのライフサイクルポリシーを定義します。
      • ライフサイクルポリシーは [AWS の公式ドキュメント] を基に定義します。
      • 以下の定義では、「release」で始まるイメージタグを 30 個までに制限しています。
# ECR リポジトリの定義
resource "aws_ecr_repository" "example" {
  name = "example-repo"
}

# ECR ライフサイクルポリシーの定義
resource "aws_ecr_lifecycle_policy" "example_repo" {
  repository = aws_ecr_repository.example.name
  policy     = <<EOF
    {
        "rules": [
            {
                "rulePriority": 1,
                "description": "Expire last 30 release tagged images",
                "selection": {
                    "tagStatus": "tagged",
                    "tagPrefixList": ["release"],
                    "countType": "imageCountMoreThan",
                    "countNumber": 30
                },
                "action": {
                    "type": "expire"
                }
            }
        ]
    }
EOF
}

試しにデプロイ

  • ここまでの追加・変更内容を適用します。
.
|-- alb.tf
|-- clowdwatch.tf
|-- dynamodb.tf                // 新規作成したファイル
|-- ecr.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
`-- sqs.tf                     // 新規作成したファイル
$ terraform fmt
$ terraform get
$ terraform validate
$ terraform plan
$ terraform apply
  • ここまで適用したら、 DynamoDB, SQS, ECR が無事に作成されていることを確認します。
  • ECR が無事に作成できたら、 Docker イメージを作成し、 push します。
    • 以下のコマンドを順番に実行します。
$ aws ecr get-login-password --region [リージョン名] | docker login --username AWS --password-stdin [アカウント名].dkr.ecr.[リージョン名].amazonaws.com
$ docker build -t [イメージ名] .
$ docker tag [イメージ名]:latest [アカウント名].dkr.ecr.[リージョン名].amazonaws.com/[リポジトリ名]:latest
$ docker push [アカウント名].dkr.ecr.[リージョン名].amazonaws.com/[リポジトリ名]:latest
  • リポジトリにイメージがプッシュできたら、 ECS をマネジメントコンソールから確認し、ECR から pull してきたイメージで稼働していることを確認します。
    • イメージが更新されていない場合、マネジメントコンソールから直接タスクを終了して、再度タスクが立ち上がるのを待ちます。

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

  • 新たなイメージで稼働したら、下記のコマンドで、アプリで実装した動作をすることを確認します。
$ curl 'https://linkode-example.ml/?name=example'
{"name":"example"}
  • また、 DynamoDB や SQS への動作も正常に行われていることも確認します。

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

デプロイメントパイプラインの設計

  • ここまでで、自作のコンテナイメージを ECS で動かすことが出来るようになりました。
  • ここから、ソースコードの変更を自動的に検知して、イメージのビルド→ ECR へのプッシュ→ ECS イメージの更新の流れが自動的に行われるように設定します。
    • ソースコードは GitLab に保存します。
    • 単体テスト、イメージビルド、ECR へのプッシュは GitLab CI/CD で行います。
    • AWS 側では ECR へのイメージプッシュを検知し、それをトリガーとして ECS のイメージ更新を行います。

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

GitLab のリポジトリ作成と GitLab CI の設定

  • まずは、ソースコードを保管する GitLab のリポジトリを作成します。
  • リポジトリのルートには、.gitlab-ci.yml を作成し、コミットしておきます。
    • GitLab CI/CD や .gitlab-ci.yml に関する説明は過去の記事をご覧ください。

blog.linkode.co.jp

  • 以下の内容で作成します。
    • test ステージの内容は今回割愛します。
    • build ステージでは、Google が開発した kaniko を利用します。
      • kaniko は、Docker in Docker を使わずに Docker Image をビルドしてくれるソフトウェアです。

.gitlab-ci.yml の内容(クリックして展開)

stages:
  - test
  - build

test:
  stage: test
  image: 
    name: node:14.10-alpine
  script: 
    - cd app
    - yarn run test

build:
  stage: build
  image:
    name: gcr.io/kaniko-project/executor:debug
    entrypoint: [""]
  script:
    - |
      cat > /kaniko/.docker/config.json <<EOF
      {
        "credsStore": "ecr-login"
      }
      EOF
    - /kaniko/executor --context $CI_PROJECT_DIR/app --dockerfile $CI_PROJECT_DIR/app/Dockerfile --destination [アカウント名].dkr.ecr.ap-northeast-1.amazonaws.com/example-repo:latest

  • また、リポジトリの CI/CD の設定から、 AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEY をそれぞれ設定します。

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

  • ここまで作成できたら、一旦 GitLab CI/CD を動かし、ECR のイメージ更新ができていることを確認します。

CodePipeline の作成

  • 次に、ECR へのイメージのプッシュを検知して、ECS のイメージ更新を行う設定を行います。
  • 以下の内容で codepipeline.tf を作成します。
    • S3, ECR, ESC, IAM に関する適切な権限を付与します。
    • aws_codepipeline では、以下のステージを定義します。
      • Source: ECR から最新イメージを取得します。また、ECR と ECS をつなぐ設定を S3 に置いてある設定ファイルから読み取ります。
      • Deploy : S3 の設定ファイルの内容を基に、 ECS のイメージを更新します。

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

# CodePipeline で使用する IAM ロール
data "aws_iam_policy_document" "codepipeline" {
  statement {
    effect    = "Allow"
    resources = ["*"]

    actions = [
      "s3:PutObject",
      "s3:GetObject",
      "s3:GetObjectVersion",
      "s3:GetBucketVersioning",
      "ecr:DescribeImages",
      "ecs:DescribeServices",
      "ecs:DescribeTaskDefinition",
      "ecs:DescribeTasks",
      "ecs:ListTasks",
      "ecs:RegisterTaskDefinition",
      "ecs:UpdateService",
      "iam:PassRole",
    ]
  }
}

module "codepipeline_role" {
  source     = "./iam_role"
  name       = "tf-example-codepipeline"
  identifier = "codepipeline.amazonaws.com"
  policy     = data.aws_iam_policy_document.codepipeline.json
}

# CodePipeline 本体
resource "aws_codepipeline" "example" {
  name     = "example"
  role_arn = module.codepipeline_role.iam_role_arn

  stage {
    name = "Source"

    action {
      name             = "ECRSetting"
      category         = "Source"
      owner            = "AWS"
      provider         = "ECR"
      version          = 1
      output_artifacts = ["ECROutArtifact"]

      configuration = {
        ImageTag       = "latest",
        RepositoryName = aws_ecr_repository.example.name
      }
    }

    action {
      name             = "S3Setting"
      category         = "Source"
      owner            = "AWS"
      provider         = "S3"
      version          = 1
      output_artifacts = ["S3OutArtifact"]

      configuration = {
        S3Bucket             = [後で作成する S3 バケット名],
        S3ObjectKey          = "imagedefinitions.json.zip",
        PollForSourceChanges = "false"
      }
    }
  }

  stage {
    name = "Deploy"

    action {
      name            = "Deploy"
      category        = "Deploy"
      owner           = "AWS"
      provider        = "ECS"
      version         = 1
      input_artifacts = ["S3OutArtifact"]

      configuration = {
        ClusterName       = aws_ecs_cluster.example.name
        ServiceName       = aws_ecs_service.example.name
        DeploymentTimeout = 10
      }
    }
  }

  artifact_store {
    location = "tf-example"
    type     = "S3"
  }
}

  • 次に、 ECR へのイメージプッシュのイベントを検知する EventBridge を定義します。
  • 以下のように eventbridge.tf を作成します。
    • aws_cloudwatch_event_rule で検知する対象の ECR リポジトリを指定します。
    • aws_cloudwatch_event_target で、ルールで定めたイベントが起こった場合の動作を設定します。

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

# ---
# CodePipeline を起動させる Event Bridge

# Event Bridge の Role
data "aws_iam_policy_document" "eventbridge" {
  statement {
    effect    = "Allow"
    resources = ["${aws_codepipeline.example.arn}"]

    actions = [
      "codepipeline:StartPipelineExecution",
    ]
  }
}

module "eventbridge_role" {
  source     = "./iam_role"
  name       = "tf-example_eventbridge"
  identifier = "events.amazonaws.com"
  policy     = data.aws_iam_policy_document.eventbridge.json
}

# Event Bridge 本体
resource "aws_cloudwatch_event_rule" "ecr_push" {
  name        = "ecr_push"
  description = "ECR にイメージを Push されたら、 CodePipeline のイベントを発火する"

  event_pattern = jsonencode(
    {
      "source" : [
        "aws.ecr"
      ],
      "detail-type" : [
        "ECR Image Action"
      ],
      "detail" : {
        "action-type" : [
          "PUSH"
        ],
        "result" : [
          "SUCCESS"
        ],
        "repository-name" : [
          "example-repo"
        ]
      }
    }
  )
}

resource "aws_cloudwatch_event_target" "ecr" {
  rule      = aws_cloudwatch_event_rule.ecr_push.name
  target_id = "CodePipeline"
  arn       = aws_codepipeline.example.arn
  role_arn  = module.eventbridge_role.iam_role_arn
}

  • S3 に ECR と ECS をつなぐ設定を記述したファイルを置きます。
  • 以下の内容で imagedefinitions.json を作成し、 ZIP 圧縮しておきます。
[
    {
        "name": "example",
        "imageUri": "[アカウント名].dkr.ecr.ap-northeast-1.amazonaws.com/example-repo:latest"
    }
]
  • 以下のように、バージョニングを有効にしたバケットを作成します。

f:id:linkode-okazaki:20200925154439p:plainf:id:linkode-okazaki:20200925154433p:plain

  • 作成したら、 ZIP 圧縮した JSON ファイルをバケットのルートにアップロードします。

再度、デプロイを試す

  • ここまでで準備完了です。実際に、 GitLab にソースコードをプッシュして、デプロイまで自動的に流れることを確認します。
  • ローカルでの変更を GitLab にプッシュします。

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

  • GitLab CI/CD が動き始め、パイプラインが作動し始めます。

f:id:linkode-okazaki:20200925154410p:plainf:id:linkode-okazaki:20200925154405p:plain

  • パイプラインが無事成功したら、AWS の CodePipeline の画面でデプロイの流れが動作していることがわかります。
  • このあと、ECS を確認すると、無事にサービスのタスク定義のリビジョンが上がっていることが確認できます。

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

まとめ

  • 4 回に渡って、 ECS のサービス環境を Terraform で構築する方法を追ってきました。
  • GitLab と連携し、 CI/CD の第一歩も実現できました。
  • AWS のすべてのリソースを Terraform で管理するのではなく、場合に応じて手動で管理したほうが良いものもあります。プロジェクトや利用するサービスの内容に応じて判断することになりますが、正解は無さそうです。
  • 今後は、インフラ環境をコード化出来るようになったことを利用し、インフラ環境の CI/CD の実現を進めていければと思います。

参考資料