🐥

EC2・Docker・Auto Scaling で構築する柔軟なデプロイ基盤

に公開

はじめに

スマサテでインフラ周りなどを担当しているユリです。

今回はタイトルにあるように、EC2 / Docker / AutoScaling を組み合わせた

  • シンプルかつ低コストで
  • 汎用的で
  • 自由度が高く
  • CI/CDに組み込みやすく
  • 無停止ローリングアップデート可能な

デプロイ基盤の構築について紹介します。 この方法がフィットするケースは

  • コンテナ化されたアプリケーションをとりあえずデプロイしたい
  • ゼロダウンタイムでのデプロイをしたい
  • コスト(利用料金)をなるべく下げたい
  • OSレベルでのチューニングをしたい
  • 高度なオーケストレーションが不要

という状況です。

なぜこれを構築するのか

方法だけ見たい方は次のセクションまで飛ばしてください。

近年は ECS や Fargate などのマネージドコンテナ基盤が広く普及していますし、サーバレスアーキテクチャももはや当たり前のものとなりました。
それでも、運用要件やメンバの技術スタックによっては EC2 を主体としたアーキテクチャの方が適しているケースがあります。

抽象度の高いマネージドサービスではなくてあえてよりプリミティブなインフラを使って自前で構築する理由としては、例えば以下のようなものが挙げられます。

  • ブラックボックスがほぼ存在しない
  • 自由度が高い
    • カーネルパラメータなどのチューニング
    • iptables, systemd などOSレイヤーでの制御
    • ミドルウェアのバージョン選択
    • デバッグ手段(tcpdump他)
    • インフラスペックの選定
    • CI/CDとの組み合わせ
  • 抽象度が高く内部実装が複雑なシステムほどシステム工学的には可用性が低下する
  • 運用コスト(料金)が安い

似たような基盤としては ECS on EC2 もありますが、ECSエージェントの管理やECS特有の概念を理解する必要があり、運用コストは今回紹介する方法よりは高くなります。

そもそもコンテナ技術を利用する目的としては、「使用している言語・フレームワークのデリバリーが複雑なのでDockerコンテナとしてパッケージ化してしまいたい」というケースが多いのではないでしょうか。
フレームワーク側ですでにコンテナ化を前提としたデプロイ手法が提供されている場合もあります。

もしくは、デプロイを簡便にするためにECSを使用するというケースもあると思われます。EC2インスタンス上で直接Dockerコンテナを動かすのは簡単ですが、
これを無停止でのリリースを行いたいとか、CI/CDに組み込みたいとなると多少作り込みが必要になってきます。ならばコストがかかってもECSを……という方針に
なることもあるのではないでしょうか。

このようなケースで必ずしもコンテナオーケストレーション機能やOSの抽象化が必要とは言えないと考えています。
今回紹介する基盤はそのようなニーズにマッチすると考えています。

設計

Auto Scaling Group(以下ASG)から起動されたEC2が Application Load Balancer(以下ALB) のターゲットグループとして登録されるだけです。
非常にシンプルです。デプロイは ASG の Instance Refresh 機能を使用します。

一つのアプリケーションに対して一つこのセットが存在する前提です。

アプリケーションの実行基盤としては、Dockerコンテナを利用することとしました。
その理由に関しては前述した通り、アプリケーションのデリバリーパッケージとしてDockerが利用されているというのが実体としては多いためです。
もちろんEC2インスタンス上で直接バイナリを動かすことも可能です。

実装

今回はこの基盤を Terraform と Packer で実装していきます。

AMI

AMIはPackerで構築します。お好きなイメージをベースに必要なパッケージをインストールしてください。
今回はUbuntuベースとしました。

packer {
  required_version = ">= 1.9.0"

  required_plugins {
    amazon = {
      version = ">= 1.8.0"
      source  = "github.com/hashicorp/amazon"
    }
  }
}

variable "region" {
  type    = string
  default = "ap-northeast-1"
}

variable "subnet_id" {
    type    = string
}

variable "security_group_id" {
    type    = string
}

locals {
  app_name = "ds-api"
  ami_name = "${local.app_name}-base-${formatdate("YYYYMMDDhhmmss", timestamp())}"
}

source "amazon-ebs" "ubuntu" {
  region        = var.region
  instance_type = "t3.micro"

  subnet_id              = var.subnet_id
  security_group_ids     = [var.security_group_id]
  associate_public_ip_address = true

  source_ami_filter {
    filters = {
      name                = "ubuntu/images/hvm-ssd*/ubuntu-noble-24.04-amd64-server-*"
      root-device-type    = "ebs"
      virtualization-type = "hvm"
    }
    owners      = ["099720109477"] # Canonical
    most_recent = true
  }

  ssh_username = "ubuntu"
  ami_name     = local.ami_name
  ami_regions  = [var.region]

  tags = {
    Name = local.ami_name
    App  = local.app_name
  }
}

build {
  name    = "${local.app_name}-base-image"
  sources = ["source.amazon-ebs.ubuntu"]

  provisioner "shell" {
    environment_vars = ["DEBIAN_FRONTEND=noninteractive"]
    inline = [
      "sudo apt update -y",
      "sudo apt upgrade -y",
      "sudo apt install -y mysql-client pcp htop vim git curl wget net-tools dnsutils zstd unzip zip jq",

      # restart 抑止
      "echo 'DPkg::Options {\"--force-confdef\"; \"--force-confold\";};' | sudo tee /etc/apt/apt.conf.d/90-no-restart",

      # 自動OS再起動禁止
      "sudo sed -i 's/Unattended-Upgrade::Automatic-Reboot.*/Unattended-Upgrade::Automatic-Reboot \"false\";/' /etc/apt/apt.conf.d/50unattended-upgrades",
      "sudo sed -i 's/Unattended-Upgrade::Automatic-Reboot-WithUsers.*/Unattended-Upgrade::Automatic-Reboot-WithUsers \"false\";/' /etc/apt/apt.conf.d/50unattended-upgrades",

      # docker
      "sudo apt install -y docker.io docker-compose-v2 docker-buildx",
      "sudo systemctl enable docker",
      "sudo systemctl start docker",
      "sudo usermod -aG docker ubuntu",

      # AWS CLI v2
      "curl -sS \"https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip\" -o \"/tmp/awscliv2.zip\"",
      "unzip /tmp/awscliv2.zip -d /tmp",
      "sudo /tmp/aws/install"
    ]
  }
}

必要な変数(AMIイメージの元になる一時的なEC2インスタンスに設定するサブネットとセキュリティグループ)を設定したファイル variables.pkrvars.hcl を作成し、以下のようにしてAMIを構築します。

$ packer build -var-file=variables.pkrvars.hcl .

正しく構築できれば以下のようなログが出力されます。

==> Wait completed after 8 minutes 16 seconds

==> Builds finished. The artifacts of successful builds are:
--> app-name-base-image.amazon-ebs.ubuntu: AMIs were created:
ap-northeast-1: ami-0xxxxxxxxxxxx

ASG

モジュール化して複数のアプリケーションから使い回せるようにします。

variable "app_name" {
  type = string
}

variable "vpc_id" {
  type = string
}

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

variable "launch_template_id" {
  type = string
}

variable "launch_template_version" {
  type = string
}

variable "health_check_path" {
  type    = string
  default = "/health"
}

variable "desired_capacity" {
  type    = number
  default = 1
}

variable "min_size" {
  type    = number
  default = 1
}

variable "max_size" {
  type    = number
  default = 3
}

variable "instance_warmup" {
  type    = number
  default = 30
}

variable "min_healthy_percentage" {
  type    = number
  default = 50
}

variable "max_healthy_percentage" {
  type    = number
  default = 200
}

variable "instance_port" {
  type    = number
  default = 80
}

resource "aws_lb_target_group" "this" {
  name        = "${replace(var.app_name, "_", "-")}-tg"
  vpc_id      = var.vpc_id
  port        = var.instance_port
  protocol    = "HTTP"

  health_check {
    enabled  = true
    interval = 10
    protocol = "HTTP"
    path     = var.health_check_path
    port     = "traffic-port"
  }
}

resource "aws_autoscaling_group" "this" {
  name                = "${var.app_name}-asg"
  max_size            = var.max_size
  min_size            = var.min_size
  desired_capacity    = var.desired_capacity
  vpc_zone_identifier = var.subnet_ids

  launch_template {
    id      = var.launch_template_id
    version = var.launch_template_version
  }

  target_group_arns = [
    aws_lb_target_group.this.arn
  ]

  lifecycle {
    create_before_destroy = true
    ignore_changes        = [
      min_size,
      desired_capacity,
      max_size,
    ]
  }

  instance_refresh {
    strategy = "Rolling"

    preferences {
      instance_warmup        = var.instance_warmup
      min_healthy_percentage = var.min_healthy_percentage
      max_healthy_percentage = var.max_healthy_percentage
    }

    triggers = ["launch_template"]
  }
}

デプロイの肝となるのは下記の部分です。triggers = ["launch_template"] としておくことで、terraformは Launch Template のバージョンが変わったことを検知したときに、Instance Refresh を実行します。

  instance_refresh {
  strategy = "Rolling"

  preferences {
    instance_warmup        = var.instance_warmup
    min_healthy_percentage = var.min_healthy_percentage
    max_healthy_percentage = var.max_healthy_percentage
  }

  triggers = ["launch_template"]
}

もし起動設定更新後に手動で更新したい場合は triggers = ["launch_template"] を削除すればよいです。
その後、例えば aws cli なら以下を実行します。

aws autoscaling start-instance-refresh \
  --auto-scaling-group-name name-of-asg

Launch Template

続いて Launch Template です。EC2インスタンスの起動設定を定義します。

variable "app_name" {
  type = string
}

variable "instance_type" {
  type    = string
  default = "t3.micro"
}

variable "ami_id" {
  type = string
}

variable "instance_profile_arn" {
  type = string
}

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

variable "ecr_repository" {
  type = string
}


variable "image_tag" {
  type = string
}

variable "container_port" {
  type    = number
  default = 80
}

variable "instance_port" {
  type    = number
  default = 80
}

variable "userdata_extra" {
  type    = string
  default = ""
}

variable "key_name" {
  type    = string
  default = "name of keypair"
}

locals {
  ecr_repo_url = var.ecr_repository

  user_data = base64encode(<<EOF
#!/bin/bash
set -e

# ECR login
aws ecr get-login-password --region ap-northeast-1 | docker login --username AWS --password-stdin ${local.ecr_repo_url}

# Pull and run
docker pull ${local.ecr_repo_url}:${var.image_tag}

docker stop ${var.app_name} || true
docker rm ${var.app_name} || true

docker run -d \
  --name ${var.app_name} \
  -p ${var.instance_port}:${var.container_port} \
  ${local.ecr_repo_url}:${var.image_tag}

${var.userdata_extra}
EOF
  )
}

resource "aws_launch_template" "this" {
  name_prefix = "${var.app_name}-lt-"

  image_id      = var.ami_id
  instance_type = var.instance_type

  iam_instance_profile {
    arn = var.instance_profile_arn
  }

  key_name = var.key_name

  network_interfaces {
    associate_public_ip_address = true
    security_groups = var.security_group_ids
  }

  user_data = local.user_data
}

今回はシンプルにデプロイ対象となるdockerイメージを格納するECRリポジトリとタグを指定し、それをpullして実行するようなものにしました。
user_data 変数で指定するスクリプトは使用するアプリケーションに応じて任意に変えてください。
ただのスクリプトなのでプロジェクト要件に応じて自由に変えることができます。
たとえば docker-compose.yml を取得した後に docker-compose するようにしたり、単にバイナリを取得して動かせばいいならS3などを通じてダウンロードすればよいでしょう。

Instance Profile

一応、今回EC2インスタンスに設定したロール(とインスタンスプロファイル)を載せます。
単にECRへのアクセス権限のみを付与しています。

resource "aws_iam_role" "ec2_container_role" {
  name = "ec2-container-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Principal = {
          Service = "ec2.amazonaws.com"
        }
        Action = "sts:AssumeRole"
      }
    ]
  })
}

resource "aws_iam_instance_profile" "ec2_container_instance_profile" {
  name = "ec2-container-instance-profile"
  role = aws_iam_role.ec2_container_role.name
}

resource "aws_iam_role_policy_attachment" "ecr_read" {
  role       = aws_iam_role.ec2_container_role.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly"
}

使用例

以下のようにして実際のリソースを作ります。これはデプロイのたびに image_tag を更新して terraform apply するので、アプリごとにディレクトリを分けたほうが良いでしょう。

variable "image_tag" {
  description = "The tag of the Docker image to use"
  type        = string
}

variable "ami_id" {
  description = "The AMI ID for the EC2 instances"
  type        = string
}

variable "key_name" {
  description = "The name of the key pair to use for SSH access"
  type        = string
}

resource "aws_ecr_repository" "your_app_repository" {
  name = "your-app"
}

module "your_app_asg" {
  source = "../../modules/asg"
  app_name = "your_app"
  subnet_ids = [
    "subnet-xxxxxx"
  ]
  vpc_id = data.terraform_remote_state.root.outputs.vpc_id
  launch_template_id = module.your_app_lt.id
  launch_template_version = module.your_app_lt.latest_version
  health_check_path = "/"
  instance_port = 80
}

module "your_app_lt" {
  source = "../../modules/launch_template"
  app_name = "your_app"
  ami_id = var.ami_id
  ecr_repository = aws_ecr_repository.your_app_repository.repository_url
  image_tag = var.image_tag
  instance_profile_arn = "instance profile の ARN"
  security_group_ids = [
    "sg-xxxxxxxxx"
  ]
  instance_port = 80
  container_port = 80
  key_name = var.key_name
}

無停止のローリングアップデートをするときは以下のコマンドを実行し、実行対象のイメージタグを指定すればよいです。

terraform apply -var "image_tag=202510201130"

別の方法

ECR上のイメージがimmutableでなくてもよいのならば、起動設定はECR上の latest イメージを常に使用するように設定しておけば、
イメージをpushしたあとに aws autoscaling start-instance-refresh を実行するだけでローリングアップデートが開始されます。

おわりに

今回紹介したデプロイ基盤は、Dockerベースでの運用と無停止リリースが可能なほぼミニマムな構成になっているのではないかと思います。
ECS on EC2/EKSのような複雑なオーケストレーションは不要だが、とりあえず「コンテナで動かしたい」「自由に触りたい」「コストを抑えたい」という、
そんな環境にちょうどフィットする素朴で扱いやすいデプロイ基盤で、ここからプロジェクトの要求に合わせて自由に育てていけます。

スマサテ Tech Blog

Discussion