CodeBuildを使用したFargate開発環境の自動構築

f:id:sumzap_engineer:20200121155733p:plain

はじめに

はじめまして。サムザップでSREチームに所属しています島田です。
このたびエンジニアブログを開設する運びとなり、最初の記事を任せていただきました。
これから弊社の技術的活動を少しでも発信できればと思っております。
ブログ開設の詳しい経緯については既に記事をアップしておりますので、こちらも併せてご確認ください。

tech.sumzap.co.jp

前提

今回は私が新規プロジェクト始動の際に、開発環境を構築した話を紹介します。
私はパブリッククラウドを使用した新規立ち上げをAWSとGCPで経験していますが、今回はAWS上で構築した話になります。
クラウドの選定は立ち上げるプロジェクトの技術要件や、メインエンジニアのスキルなどを考慮して決定しました。

弊社ではインフラリソースの構築にはTerraformを用いて実装しています。
AWSのリソースに対しても以前に実装したものがある程度使えるので流用できるものは流用し、不足分を追加実装しています。

目指したこと

技術要件として挙げられることは4つです。

  1. apiサーバにはFargateを使用する
  2. データストアはAurora、データキャッシュにはElastiCacheを使用する
  3. デプロイ時にテストを回したい
  4. 複数の開発環境をプロジェクト側で自由に作成、更新、削除ができること

1〜3の要件で構築することは比較的簡単ですが、4を含めると格段に難易度が上がります。
それを解決するために工夫した点を紹介していきたいと思います。

構築したシステム

はじめに今回構築したシステムのアクセスの流れは以下のようになっています。
要件1〜3を実現する構成はとてもシンプルな構成ですね。

f:id:sumzap_engineer_blog:20200117123413p:plain

CodeBuildによるデプロイ

上記システムのデプロイにはCodeBuildを使用しています。
CodeBuildとは本来ソースコードをビルドするのに用いるリソースですが、シェルを走らせることが可能なので色々なことを実現できます。※ソースコードをビルドするという本来の目的から大幅に外れることは極力避けましょう
それを利用することによって要件4を実現しています。

CodeBuildで行っていること

  1. ソースコードの取得
  2. イメージ作成(docker build)
  3. ユニットテスト
  4. イメージの配置(ECR)
  5. データベース作成(マイグレーション)
  6. Terraformの実行(ALBリスナー追加, ECSサービス作成 or 更新)

buildspec.yml

上記CodeBuildで1〜6を行う設定は、取得するソースのルートにbuildspec.ymlというファイルを配置し設定します。

buildspec.ymlサンプル(クリックすると表示)

version: 0.2
env:
    variables:
        DOCKER_BUILDKIT: "1"
phases:
    install:
        runtime-versions:
            docker: 18
    pre_build:
        commands:
            - $(aws ecr get-login --no-include-email --region ap-northeast-1)
            - |
                if [ -z "${CODEBUILD_WEBHOOK_TRIGGER}" ]; then
                    NOT_REPLACE_BRANCH="${CODEBUILD_SOURCE_VERSION}";
                else
                    NOT_REPLACE_BRANCH="${CODEBUILD_WEBHOOK_TRIGGER#branch/}";
                fi
            - BRANCH="${NOT_REPLACE_BRANCH#feature/}";
            - WEB_DOCKER_FILE="./docker/${ENVIRONMENT}/web/Dockerfile"
            - WEB_REPOSITORY="web:${BRANCH}"
    build:
        commands:
            # DBのアクセス先変更
            - sed -i -e "s;<DB_SUFFIX>;${BRANCH};g" ./web/.env

            # 2. イメージ作成
            - docker build \
                --build-arg ENVIRONMENT="${ENVIRONMENT}" \
                --build-arg SERVER_NAME="${BRANCH}.sumzap.jp" \
                -f "${WEB_DOCKER_FILE}" \
                -t "${WEB_REPOSITORY}" \
                "${CODEBUILD_SRC_DIR}" > /dev/null

            # 3. ユニットテスト
            - docker run "${WEB_REPOSITORY}" bash -c "cd /var/www/web \
                && php artisan db:create_test \
                && php artisan migrate_test \
                && /var/www/web/vendor/bin/phpunit;"

            # 4. イメージの配置
            - docker push "${WEB_REPOSITORY}"

            # 5. データベース作成(マイグレーション)
            - docker run "${WEB_REPOSITORY}" bash -c "cd /var/www/web && php artisan db:create"
            - docker run "${WEB_REPOSITORY}" bash -c "cd /var/www/web && php artisan migrate"

            # 6. Terraformの実行
            - ./terraform/dev_ci_task_execute.sh apply ${BRANCH}
    post_build:
        commands:
            - docker run "${WEB_REPOSITORY}" bash -c "cd /var/www/web && php artisan db:drop_test"
artifacts:
    files: []

Terraformの実行

CodeBuildの最後にTerraformを使用し、ALBターゲットグループ作成・リスナー追加とECSサービス作成 or 更新を行っています。

Terraformサンプル

variable "default_region" {}
variable "branch_name" {}

# data取得--------------------------------------------------------------------

data "aws_vpc" "vpc" {
  filter {
    name   = "tag:Name"
    values = ["dev-vpc"]
  }
}

data "aws_lb" "ecs_lb" {
  name = "dev-lb"
}

data "aws_lb_listener" "selected" {
  load_balancer_arn = data.aws_lb.ecs_lb.arn
  port              = 443
}

data "aws_route53_zone" "public" {
  name         = "sumzap.jp."
  private_zone = false
}

data "aws_iam_role" "ecs_task_iam_role" {
  name = "ecs-task-iam-role"
}

data "aws_iam_role" "ecs_task_execution_iam_role" {
  name = "ecs-task-execution-iam-role"
}

data "aws_ecr_repository" "ecr_repository_web" {
  name = "web"
}

data "aws_ecr_image" "ecr_image_web" {
  repository_name = data.aws_ecr_repository.ecr_repository_web.name
  image_tag       = var.branch_name
}

data "aws_ecs_cluster" "ecs_cluster" {
  cluster_name = "dev-ecs-cluster"
}

data "aws_subnet_ids" "web" {
  vpc_id = data.aws_vpc.vpc.id
  tags = {
    Type = "web"
  }
}

data "aws_security_group" "common" {
  filter {
    name   = "tag:Name"
    values = ["dev-common-security-group"]
  }
}

data "aws_security_group" "web" {
  filter {
    name   = "tag:Name"
    values = ["dev-web-security-group"]
  }
}

# ---------------------------------------------------------------------------

# https LB設定--------------------------------------------------------------

# lb target group
resource "aws_lb_target_group" "application_lb_target_group" {
  name                 = "${var.branch_name}-tg"
  port                 = 80
  protocol             = "HTTP"
  vpc_id               = data.aws_vpc.vpc.id
  deregistration_delay = 30
  target_type          = "ip"

  stickiness {
    type            = "lb_cookie"
    cookie_duration = 3600
    enabled         = true
  }

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

# lb listener rule
resource "aws_lb_listener_rule" "lb_listener_rule" {
  listener_arn = data.aws_lb_listener.selected.arn

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

  condition {
    field  = "host-header"
    values = [replace("${var.branch_name}.${data.aws_route53_zone.public.name}", "jp.", "jp")]
  }
}

# ECS タスク定義作成--------------------------------------------------------------

# タスク定義
resource "aws_ecs_task_definition" "ecs_task_definition" {
  family                   = "${var.branch_name}-web-td"
  task_role_arn            = data.aws_iam_role.ecs_task_iam_role.arn
  execution_role_arn       = data.aws_iam_role.ecs_task_execution_iam_role.arn
  network_mode             = "awsvpc"
  cpu                      = "256"
  memory                   = "512"
  requires_compatibilities = ["FARGATE"]
  container_definitions = <<JSON
[
  {
    "name": "web",
    "image": "${data.aws_ecr_repository.ecr_repository_web.repository_url}@${data.aws_ecr_image.ecr_image_web.image_digest}",
    "cpu": null,
    "memory": null,
    "memoryReservation": null,
    "ulimits": [
      {
          "name": "nofile",
          "softLimit": 65535,
          "hardLimit": 65535
      },
      {
          "name": "nproc",
          "softLimit": 65535,
          "hardLimit": 65535
      }
    ],
    "logConfiguration": {
      "logDriver": "awslogs",
      "options": {
        "awslogs-group": "/aws/ecs/web",
        "awslogs-region": "${var.default_region}",
        "awslogs-stream-prefix": "dev/${var.branch_name}"
      }
    },
    "portMappings": [
      {
        "containerPort": 80,
        "hostPort": 80,
        "protocol": "tcp"
      }
    ],
    "environment": [],
    "essential": true,
    "dockerLabels": {},
    "mountPoints": [],
    "volumesFrom": []
  }
]
JSON
}

# ECS Cluster Service作成--------------------------------------------------------------

resource "aws_ecs_service" "ecs_service" {
  name                = "${var.branch_name}-web-service"
  cluster             = data.aws_ecs_cluster.ecs_cluster.arn
  task_definition     = aws_ecs_task_definition.ecs_task_definition.arn
  desired_count       = 1
  launch_type         = "FARGATE"
  scheduling_strategy = "REPLICA"

  deployment_controller {
    type = "ECS"
  }

  load_balancer {
    target_group_arn = aws_lb_target_group.application_lb_target_group.arn
    container_name   = "web"
    container_port   = 80
  }

  network_configuration {
    subnets          = data.aws_subnet_ids.web.ids
    security_groups  = [data.aws_security_group.common.id, data.aws_security_group.web.id]
    assign_public_ip = false
  }

  lifecycle {
    ignore_changes = ["desired_count"]
  }
}

データストアの分割

DBのアクセス先変更にはCodeBuild手順2の前で行っています。
ソースコード取得時のブランチ名をサフィックスとして利用してDB名を決定し、イメージ作成前にアプリのコンフィグで設定しているDBの接続先を変更しています。

sed -i -e "s;<DB_SUFFIX>;${BRANCH};g" ./web/.env

.envサンプル

APP_ENV=dev
APP_VERSION=0
APP_TIMEZONE=Asia/Tokyo
APP_DEBUG=true

DB_ENV=db_<DB_SUFFIX>

CACHE_DRIVER=redis
SESSION_DRIVER=redis

作成したイメージを利用し、CodeBuild手順5でDBが存在しない場合に新たにDB作成を行っています。
こうすることで開発環境毎にアクセスするDBを変更することが可能になりました。

※ DBインスタンスは共通のものを使用しています。ここであげているDB接続先はDBスキーマのことを指します。
f:id:sumzap_engineer_blog:20200121130520p:plain

データキャッシュの分割

キャッシュのアクセス先の変更はアプリ側のコードで行っています。
キャッシュとして保存する際のキーのプレフィックスに、データストアの時と同様にブランチ名を利用しています。
キャッシュキー命名ルールによりアクセス先を変更せずにキーの競合を避けています。

f:id:sumzap_engineer_blog:20200121143424p:plain

自動デプロイ

デプロイのキックは手動でも可能ですが、ブランチpushを契機にした自動デプロイも可能にしています。
ブランチの命名ルールを「 feature/dev- 」で始まるブランチに関してCodeBuildでhookして自動デプロイが走るように設定しています。
そうすることによって開発者は意識することなくソースコードをpushするだけで自動的にテストが走り、テストが通れば自動的に開発環境が作られる状態になります。

そして出来上がったデプロイシステムは下記のようになります。

f:id:sumzap_engineer_blog:20200121105022p:plain

Terraform実行後 ↓↓↓

f:id:sumzap_engineer_blog:20200121105043p:plain

自動削除

開発環境の削除も作成と同様に自動で行っています。
開発ブランチがdevelopブランチにマージされたことをhookし、マージ済みのブランチに紐づく開発環境は不要ということで、自動生成と逆手順で削除されます。

  1. CodeBuildでマージブランチをhook
  2. ECRからイメージを取得
  3. 取得イメージを使用しDB削除
  4. Terraformを使用しALBのターゲットグループ・リスナー、ECSサービスの削除

まとめ

新規プロジェクトの開発を効率的に進めるため、インフラ自動構築のシステムとデプロイのシステムを導入しました。
目指したことには記載していませんが、今回のシステムはコスト面もだいぶ抑えています。
データストア、データキャッシュ、LBリソースはそれぞれ1つ用意すればよく、APIサーバもFargateを使用することで、必要な時に必要な分だけ展開できます。(夜間コンテナ停止なども可能)
新規プロジェクト開始時にはこのようなシステムを優先的に実装することで、開発工数・コスト共に最小限に抑えることができると思っています。


f:id:sumzap_engineer_blog:20200117104707j:plain
島田 裕基

サムザップでSREチームに所属。
会社全体の技術推進や新規ゲーム開発に携わっています。