やりたいこと
ECS Fargate でホスティングしているアプリケーションでマルチステージング環境を構築します。
[commit_hash].domain.jp
といった形で、コミットハッシュをサブドメインとしたドメインにアクセスするとそれに対応したバージョンのアプリケーションにアクセスできるようになります。
それによって複数人開発で開発環境をコミットごとに分けて利用できるようになります!
実際に試してみる
前提
- CloudFront → ALB → ECS で構築されている
- domain.jp というドメインでホスティングされる
- ドメインのレコード等は Route53 で管理しており、
domain.jp
*.domain.jp
それぞれの A レコードが登録されている。 - A レコードに対するエイリアスとして CloudFront のディストリビューションが設定されている
アプリケーションの中身(Nginx)を用意する
今回 ECS Fargate 上で動作させるアプリケーションの中身は簡単な HTML を表示する Nginx の Docker イメージとします。
アプリケーションリポジトリのディレクトリ
.
├── .github
│ ├── terraform
│ │ ├── data.tf
│ │ ├── locals.tf
│ │ ├── main.tf
│ │ ├── provider.tf
│ │ └── variables.tf
│ └── workflows
│ └── deploy.yml
├── Dockerfile
├── nginx.conf
└── task_definitions
└── sample-app.json
nginx.conf
events {}
http {
server {
listen 80;
location / {
default_type text/plain;
return 200 "Hello, Nginx!\n";
}
}
}
Dockerfile
FROM nginx:alpine
COPY nginx.conf /etc/nginx/nginx.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
タスク定義
{
"containerDefinitions": [
{
"name": "sample-app",
"image": "000000000000.dkr.ecr.ap-northeast-1.amazonaws.com/sample-app:latest",
"portMappings": [
{
"name": "sample-app--80-tcp",
"containerPort": 80,
"hostPort": 80,
"protocol": "tcp"
}
],
"essential": true,
"environment": [],
"mountPoints": [],
"volumesFrom": [],
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/sandbox-rails-app",
"mode": "non-blocking",
"awslogs-create-group": "true",
"max-buffer-size": "25m",
"awslogs-region": "ap-northeast-1",
"awslogs-stream-prefix": "ecs"
},
"secretOptions": []
},
"systemControls": []
}
],
"family": "sample-app",
"taskRoleArn": "arn:aws:iam::000000000000:role/EcsRole",
"executionRoleArn": "arn:aws:iam::000000000000:role/EcsRole",
"networkMode": "awsvpc",
"volumes": [],
"placementConstraints": [],
"compatibilities": ["EC2", "FARGATE"],
"cpu": "512",
"memory": "1024",
"runtimePlatform": {
"cpuArchitecture": "X86_64",
"operatingSystemFamily": "LINUX"
},
"tags": []
}
デプロイ用のパイプライン (GitHub Actions)を用意する
GitHub Actions で以下のようなデプロイのパイプラインを構築します。
- 特定のブランチ上の変更内容をビルドして ECR にイメージをプッシュする
- ALB のリスナールール・ターゲットグループ・ECS サービスを既存の ALB, ECS クラスター上に作成する (Terraform を実行)
- 上記で作成した環境に変更内容を反映した ECS タスク定義をデプロイする
name: Deploy ECS Service for multi-staging
on:
workflow_dispatch:
env:
AWS_ROLE_ARN: arn:aws:iam::000000000000:role/deploy-role
AWS_REGION: ap-northeast-1
ECR_REPOSITORY: sample-app
ECS_TASK_DEF_FILE: sample-app.json
ECS_CLUSTER: sample-app
CONTAINER_NAME: sample-app
TERRAFORM_VERSION: 1.10.5
permissions:
id-token: write
contents: read
jobs:
build-and-push-to-ecr:
runs-on: ubuntu-latest
outputs:
image_tag: ${{ steps.set_image_tag.outputs.IMAGE_TAG }}
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v3
with:
role-to-assume: ${{ env.AWS_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
role-session-name: deploy-session
- name: Login to ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v1
with:
mask-password: "true"
- name: Set IMAGE_TAG
id: set_image_tag
run: echo "IMAGE_TAG=$(date +%Y%m%d)-$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
- name: Build and push
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
run: |
docker buildx build \
-f Dockerfile \
-t ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ steps.set_image_tag.outputs.IMAGE_TAG }} \
--push \
--provenance=false \
.
create-multi-staging-resources:
needs: build-and-push-to-ecr
runs-on: ubuntu-latest
outputs:
service_name: ${{ steps.define-service-name.outputs.SERVICE_NAME }}
steps:
- name: Checkout Repository
uses: actions/checkout@v3
- name: Format Subdomain
id: format-subdomain
run: |
SUBDOMAIN=$(git rev-parse --short HEAD)
echo "SUBDOMAIN=$SUBDOMAIN" >> "$GITHUB_OUTPUT"
- name: Define Service Name
id: define-service-name
run: |
echo "SERVICE_NAME=app-${{ steps.format-subdomain.outputs.SUBDOMAIN }}" >> $GITHUB_OUTPUT
- name: Echo Subdomain
run: echo "Subdomain is ${{ steps.format-subdomain.outputs.SUBDOMAIN }}"
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v2
with:
role-to-assume: ${{ env.AWS_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
- name: Setup Terraform
uses: hashicorp/setup-terraform@v2
with:
terraform_version: ${{ env.TERRAFORM_VERSION }}
- name: Initialize Terraform
working-directory: .github/terraform/
run: terraform init
- name: Apply Terraform Configuration
working-directory: .github/terraform/
run: |
terraform apply -auto-approve \
-var="subdomain=${{ steps.format-subdomain.outputs.SUBDOMAIN }}" \
-var="listener_priority=$((100 + RANDOM % 900))" \
-var="service_name=${{ steps.define-service-name.outputs.SERVICE_NAME }}"
deploy-to-ecs:
needs: [build-and-push-to-ecr, create-multi-staging-resources]
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v3
with:
role-to-assume: ${{ env.AWS_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
role-session-name: deploy-session
- uses: aws-actions/amazon-ecr-login@v1
id: login-ecr
with:
mask-password: "true"
- name: Fill in the new image ID in the Amazon ECS task definition
id: task-def-be
uses: aws-actions/amazon-ecs-render-task-definition@v1
with:
task-definition: ./task_definitions/${{ env.ECS_TASK_DEF_FILE }}
container-name: ${{ env.CONTAINER_NAME }}
image: ${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{needs.build-and-push-to-ecr.outputs.image_tag}}
- name: Registers ECS task definition and deploys ECS service
uses: aws-actions/amazon-ecs-deploy-task-definition@v1
with:
task-definition: ${{ steps.task-def-be.outputs.task-definition }}
service: ${{ needs.create-multi-staging-resources.outputs.SERVICE_NAME }}
cluster: ${{ env.ECS_CLUSTER }}
wait-for-service-stability: true
アプリケーションのリポジトリ内に terraform ファイルを用意する
ECS クラスター・タスク定義・ALB・リスナー・VPC・セキュリティグループはすでに別途構築されているものとします。
data.tf
data "aws_ecs_cluster" "existing_cluster" {
cluster_name = "sample-app"
}
data "aws_ecs_task_definition" "existing_task" {
task_definition = "sample-app"
}
data "aws_lb" "existing_lb" {
name = "sample-app"
}
data "aws_lb_listener" "https_443" {
load_balancer_arn = data.aws_lb.existing_lb.arn
port = 443
}
data "aws_security_group" "alb_sg" {
id = "sg-XXXXXXXXX" # 既存のALB用SG
}
data "aws_security_group" "ecs_sg" {
id = "sg-YYYYYYYYY" # 既存のECS用SG
}
data "aws_vpc" "default" {
filter {
name = "tag:Name"
values = ["default"]
}
}
data "aws_subnets" "private" {
filter {
name = "vpc-id"
values = [data.aws_vpc.default.id]
}
tags = {
Tier = "private"
}
}
main.tf
# ECS service
resource "aws_ecs_service" "default" {
name = var.service_name
cluster = data.aws_ecs_cluster.existing_cluster.id
task_definition = data.aws_ecs_task_definition.existing_task.arn
desired_count = 1
launch_type = "FARGATE"
network_configuration {
assign_public_ip = true # 検証用ではpublic subnet
subnets = data.aws_subnets.private.ids
security_groups = [data.aws_security_group.ecs_sg.id]
}
load_balancer {
target_group_arn = aws_lb_target_group.http_80.arn
container_name = "sample-app"
container_port = 80
}
}
# ALB
resource "aws_lb_target_group" "http_80" {
name = "multi-staging-${var.subdomain}"
port = 80
protocol = "HTTP"
vpc_id = data.aws_vpc.default.id
target_type = "ip"
health_check {
enabled = true
path = "/"
}
}
resource "aws_lb_listener_rule" "host_based" {
listener_arn = data.aws_lb_listener.https_443.arn
priority = var.listener_priority
action {
type = "forward"
target_group_arn = aws_lb_target_group.http_80.arn
}
condition {
host_header {
values = ["${var.subdomain}.${var.domain_name}"]
}
}
}
variables.tf
variable "listener_priority" {
type = number
description = "Priority for the ALB listener rule"
default = 100
}
variable "domain_name" {
default = "domain.jp"
}
variable "subdomain" {
type = string
description = "Commit Hash"
}
variable "service_name" {
type = string
}
上記 Terraform を GitHub Actions で実施してマルチステージングかに必要なリソースをプロビジョニングします。
最新のコミットハッシュが a123456
だとしたら、以下のリソースが作成されます
- ECS サービス
a123456-sample-app
- ALB ターゲットグループ
multi-staging-a123456
- リスナールール
- サブドメインが
a123456
の場合、上記のターゲットグループにルーティングする
- サブドメインが
実際に動かして試してみる
適当にブランチを切って、nginx.conf の内容を変えてみます
events {}
http {
server {
listen 80;
location / {
default_type text/plain;
return 200 "This is another version!\n";
}
}
}
表示される文字列を Hello, Nginx!から変えてみました。
これで GitHub Actions のワークフローを実行してみます。
AWS コンソール > ECS から名称にコミットハッシュのついたサービスがデプロイされていることを確認
domain.jp
にアクセスしてみる
0adda9f.domain.jp
にアクセスすると
別バージョンであることが確認できます!
課題点
リソースが増える分コストが増える
マルチステージング化によって ECS サービスが増えるので、その分追加のコストがかかってしまいます。
Fargate Spot という余剰リソースを使ってコンテナサービスをプロビジョニングできるので、それを利用するのが良いかなと思います
リソースを手動で削除する必要がある
GitHub Actions 上で Terraform を実行し、state は保存しないようにしているため、不要になったリソースは都度手動で削除する必要があります。
マルチステージングで作成されるリソースに特定のタグをつけて、Lambda + EventBridge で定期的に削除する設定などを追加で入れてあげると良いかもしれません。