本記事は
【コンテナウィーク】
3日目の記事です。
💻
2日目
▶▶ 本記事 ▶▶
4日目
📱
はじめまして、2021年キャリア入社の加藤です。
Amazon ECSのデプロイツールであるecspressoを用いて複数環境にECSデプロイを実施してみようと思います。 業務でTerraformを使用する機会が多いので、ecspressoと連携させ、より実践的な使い方を試します。
ecspresso とは
ecspressoはfujiwara氏が公開しているECSのデプロイツール(OSS)です。 github.com
設計思想として 「ECSのデプロイに関わる最小限のリソースのみを管理するツール」と書かれており、ECSデプロイに特化したツールであることが分かります。 ecspressoの管理対象は「ecspressoの設定ファイル」「ECSサービスの設定ファイル」「ECSタスク定義の設定ファイル」のみとなっており、それら以外のリソースを管理する場合はTerraformやAWS CloudFormationなどのIaCツールを用いることが一般的なようです。
ちなみに読み方は「エスプレッソ」です☕️
なぜIaCでECSを管理しないのか
IaCを導入するのであれば、ECSも合わせて管理した方が良いように思えますが、その場合運用上やっかいな事象に直面します。
- ECSは他インフラリソースと更新のライフサイクルが異なる
- ECS更新を専用のワークフローで運用するとIaC側との棲み分けを検討する必要がある
- IaC外での変更を無視する、二重管理しないようIaC側では持たない etc...
私が担当しているシステムでもTerraformでECSサービスやECSタスク定義を構築しつつ、別途デプロイワークフローを構築していることが多いです。デプロイワークフローの構築にはスクリプトを作り込んだりGitHub Actionsを活用したり様々ですが、IaCとは切り離して構築しています。
この辺りの隙間を埋めるためのツールがecspressoです。
ecspressoはTerraformやCloudFormationとの連携が可能なため、IaCとの相性も良いです。またECSを運用する上で使用頻度が高いオペレーション(Force new deploymentやexec等)もサブコマンドとして実装されており、細かい作り込みが不要となり実装コストを抑える効果も期待出来ます。zenn.dev
なお、ECSクラスターは管轄外ですので、IaC側で管理する必要があります。(ECSクラスターの更新頻度は高くないので、あまり気にならないです)
構成図
本題に入ります。まず構築する環境についての構成図です。
VPCにパブリックサブネットとプライベートサブネットを構築しパブリックサブネットにALB、プライベートサブネットにECS(Faragate)を設置します。
環境はdevとprodを構築します。これらは同じAWSアカウント同じリージョン別VPCとしています。(クロスアカウントで環境を分ける際はIAMなどアカウントレベルのリソースに考慮が必要です。) これらの環境に対して自端末からecspressoを用いてECSデプロイを実施します。ecspressoはロゴがなかったので☕️にしました。
コード体系
ecspressoとTerraformを管理するリポジトリは下記のようなコード体系とします。
├── config.yaml ├── ecs-service-def.json ├── ecs-task-def.json └── terraform └── aws ├── envs │ ├── dev │ │ ├── backend.tf │ │ ├── main.tf │ │ └── output.tf │ └── prod │ ├── (devと同じ) └── modules ├── ecs │ └── main.tf └── network └── main.tf
各ファイル/ディレクトリについて簡単に解説します。中身については構築時に触れます。
config.yaml
ecspressoの設定ファイルです。対象のECSクラスターやECSサービス、pluginなどを設定します。
環境毎で用意せず単一ファイルを環境間で共通ファイルとして使用します。
ecs-service-def.json
ECSサービスの設定ファイルです。サブネットやALB、デプロイなどを設定します。
こちらもconfig.yaml同様、環境共通ファイルです。
ecs-task-def.json
ECSタスク定義の設定ファイルです。コンテナイメージやコンテナリソースなどを設定します。
同様に環境共通ファイルです。
terraform/aws
AWSリソースのTerraformコードを管理します。Terraformを用いて他のサービス(GCPやAzure、Datadog等)を管理したい場合があるので、この階層を設けました。
envs
各環境のtfファイルを管理します。モジュールは環境で使い回すため、別途ディレクトリを分けて管理します。各ディレクトリのtfファイルは環境毎のパラメータを書くことで環境差分を管理します。
各tfファイルの中身は後々紹介します。
modules
Terraformリソース群(モジュール)をまとめます。モジュールの分け方はリソースのライフサイクル毎で分けるのが良いかと思います。今回はシンプルに「network」と「ecs」とします。
各環境で共通のモジュールを使用することでコードの共通化を図ります。
やってみよう
ではデプロイしていきます。以下のような流れで進めます。
- Terraform実行に必要なAWSリソースを構築
- ECS周辺のAWSリソースを構築
- ECSサービスとECSタスク定義の構築(dev)
- ecspressoによる設定ファイルの取り込み
- 設定ファイルを修正
- ecspresso deploy(prod)
0. 前提
前提として「AWSアカウントが作成されていること」「自端末にIAMユーザのcredential情報が設定されていること」「自端末にTerraformとecspressoがインストールされていること」とします。 各ツールは検証時の最新バージョンを用います。
terraform → v1.6.2
ecspresso → v2.2.4
1. Terraform実行に必要なAWSリソースを構築
まず、Terraformの実行に必要なAWSリソースをAWSマネジメントコンソールから構築します。今回は運用上よく使われる下記3つのリソースを構築します。
- S3バケット
- state管理
- デフォルト設定で構築
- DynamoDB
- lock管理
- パーティションキーに"LockID"を指定し他はデフォルト設定で構築
- IAMロール
- Terraform実行用ロール
- 検証用のためAdministratorAccessを付与
- 信頼関係にIAMユーザからのAssumeRoleを許可
ここで作成するリソースはenvs/環境/backend.tfで設定します。
dev/backend.tf
terraform { backend "s3" { bucket = "t3-kato-terraform-backend" key = "dev/terraform.tfstate" region = "us-west-2" dynamodb_table = "t3-kato-terraform-backend" assume_role = { role_arn = "arn:aws:iam::<<AWSアカウントID>>:role/t3-kato-terraform" } } }
prod/backend.tf
terraform { backend "s3" { bucket = "t3-kato-terraform-backend" key = "prod/terraform.tfstate" region = "us-west-2" dynamodb_table = "t3-kato-terraform-backend" assume_role = { role_arn = "arn:aws:iam::<<AWSアカウントID>>:role/t3-kato-terraform" } } }
2. ECS周辺のAWSリソースを構築
次にECS周辺のAWSリソースをTerraformで構築します。
- VPC
- VPC、パブリックサブネット、プライベートサブネット、ルートテーブル、インターネットゲートウェイ、NatGateway
- セキュリティグループ
- ALB用とECS用を準備
- ALB、ターゲットグループ
- ALBはパブリックサブネットに設置
- CloudWatchLogs ロググループ
- ECSサービスのログ出力先として準備
- ECS クラスター
- ecspressoではECSクラスターは管理されないためTerraformで定義
modules内のファイルは下記の通りです。
modules/network/main.tf
variable "name" { type = string } variable "env" { type = string } module "vpc" { source = "terraform-aws-modules/vpc/aws" version = "5.1.2" name = "${var.name}-${var.env}" cidr = "10.0.0.0/16" azs = ["us-west-2a", "us-west-2b"] private_subnets = ["10.0.1.0/24", "10.0.2.0/24"] public_subnets = ["10.0.101.0/24", "10.0.102.0/24"] enable_nat_gateway = true enable_vpn_gateway = false } resource "aws_security_group" "ecs" { name = "${var.name}-${var.env}-ecs" vpc_id = module.vpc.vpc_id } resource "aws_security_group_rule" "ingress_from_alb_to_ecs" { type = "ingress" from_port = 80 to_port = 80 protocol = "tcp" source_security_group_id = module.alb.security_group_id security_group_id = aws_security_group.ecs.id } resource "aws_security_group_rule" "egress_from_ecs_to_internet" { type = "egress" from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] security_group_id = aws_security_group.ecs.id } module "alb" { source = "terraform-aws-modules/alb/aws" version = "8.7.0" name = "${var.name}-${var.env}" load_balancer_type = "application" vpc_id = module.vpc.vpc_id subnets = module.vpc.public_subnets security_group_rules = { ingress_http = { type = "ingress" from_port = 80 to_port = 80 protocol = "tcp" cidr_blocks = ["<<許可したいIP>>"] } egress_all = { type = "egress" from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } } target_groups = [ { backend_protocol = "HTTP" backend_port = 80 target_type = "ip" } ] http_tcp_listeners = [ { port = 80 protocol = "HTTP" target_group_index = 0 } ] }
modules/ecs/main.tf
variable "name" { type = string } variable "env" { type = string } resource "aws_ecs_cluster" "this" { name = "${var.name}-${var.env}" } resource "aws_cloudwatch_log_group" "this" { name = "/ecs/${var.name}-${var.env}" }
ここでは積極的にTerraformモジュール(terraform-aws-modules/vpc/aws等)を採用していますが、Terraformモジュールを使うとバージョン管理などが煩雑となるので、実運用で採用する際は考慮が必要です。
modulesディレクトリへの設定が完了したら、envs/dev に移動しterraform apply
を実行します。
envs/dev/main.tf
terraform { required_version = ">= 1.6.2" required_providers { aws = { source = "hashicorp/aws" version = ">= 5.23.1" } } } provider "aws" { region = "us-west-2" } locals { env = "dev" name = "t3-kato" } module "network" { source = "../../modules/network" env = local.env name = local.name } module "ecs" { source = "../../modules/ecs" env = local.env name = local.name }
同様にprodでもterraform apply
を実行します。devとprodのmain.tfの差分は下記の通りです。
❯ diff terraform/aws/envs/dev/main.tf terraform/aws/envs/prod/main.tf 16c16 < env = "dev" --- > env = "prod"
applyが正常に完了していると下記のようにtfstateが保存されます。
❯ terraform state list module.ecs.aws_cloudwatch_log_group.this module.ecs.aws_ecs_cluster.this module.network.aws_security_group.ecs module.network.aws_security_group_rule.egress_from_ecs_to_internet module.network.aws_security_group_rule.ingress_from_alb_to_ecs module.network.module.alb.aws_lb.this[0] module.network.module.alb.aws_lb_listener.frontend_http_tcp[0] module.network.module.alb.aws_lb_target_group.main[0] module.network.module.alb.aws_security_group.this[0] module.network.module.alb.aws_security_group_rule.this["egress_all"] module.network.module.alb.aws_security_group_rule.this["ingress_http"] module.network.module.vpc.aws_default_network_acl.this[0] module.network.module.vpc.aws_default_route_table.default[0] module.network.module.vpc.aws_default_security_group.this[0] module.network.module.vpc.aws_eip.nat[0] module.network.module.vpc.aws_eip.nat[1] module.network.module.vpc.aws_internet_gateway.this[0] module.network.module.vpc.aws_nat_gateway.this[0] module.network.module.vpc.aws_nat_gateway.this[1] module.network.module.vpc.aws_route.private_nat_gateway[0] module.network.module.vpc.aws_route.private_nat_gateway[1] module.network.module.vpc.aws_route.public_internet_gateway[0] module.network.module.vpc.aws_route_table.private[0] module.network.module.vpc.aws_route_table.private[1] module.network.module.vpc.aws_route_table.public[0] module.network.module.vpc.aws_route_table_association.private[0] module.network.module.vpc.aws_route_table_association.private[1] module.network.module.vpc.aws_route_table_association.public[0] module.network.module.vpc.aws_route_table_association.public[1] module.network.module.vpc.aws_subnet.private[0] module.network.module.vpc.aws_subnet.private[1] module.network.module.vpc.aws_subnet.public[0] module.network.module.vpc.aws_subnet.public[1] module.network.module.vpc.aws_vpc.this[0]
モジュール内にモジュールを定義してしまったため、少し読みにくいですね・・・
3. ECSサービスとECSタスク定義の構築(dev)
続いてdevのECSサービスとECSタスク定義を構築します。
- ECSタスク定義
- 起動タイプはFargateを選択
- タスクロールはなし、タスク実行ロールはデフォルトのecsTaskExecutionRoleを選択
- コンテナはAmazon ECR Public Galleryから最新版のnginxコンテナイメージを選択
- その他はデフォルトを設定
- ECSサービス
- デプロイ方法はローリングアップデートを選択
- プライベートサブネットを選択
- パブリックIPは不要なのでオフにしておく
- 必要なタスク数は1としておく
ECSサービス作成後正常にタスクが起動すればALBのDNS名にアクセスすることでnginxの画面が表示されます。
ここまでで、devの構築は完了です。
続いてecspressoを用いて、prodへデプロイしてみましょう。
4. ecspressoによる設定ファイルの取り込み
ecspressoには以下のコマンドが用意されています。
❯ ecspresso init -h Usage: ecspresso init --service=STRING --task-definition=STRING create configuration files from existing ECS service
このコマンドを用いて、先程作成したECSサービスとECSタスク定義を取り込みます。
ecspresso init --config config.yaml --region us-west-2 --cluster t3-kato-dev --service t3-kato-dev 2023/11/09 15:58:38 t3-kato-dev/t3-kato-dev save service definition to ecs-service-def.json 2023/11/09 15:58:38 t3-kato-dev/t3-kato-dev save task definition to ecs-task-def.json 2023/11/09 15:58:38 t3-kato-dev/t3-kato-dev save config to config.yaml
これで設定ファイルの取り込みは完了です。 各ファイルの中身を見てみましょう。
config.yaml
region: us-west-2 cluster: t3-kato-dev service: t3-kato-dev service_definition: ecs-service-def.json task_definition: ecs-task-def.json timeout: "10m0s"
ecs-task-def.json
{ "containerDefinitions": [ { "cpu": 0, "essential": true, "image": "public.ecr.aws/nginx/nginx:1.25", "logConfiguration": { "logDriver": "awslogs", "options": { "awslogs-create-group": "true", "awslogs-group": "/ecs/t3-kato-dev", "awslogs-region": "us-west-2", "awslogs-stream-prefix": "ecs" } }, "name": "nginx", "portMappings": [ { "appProtocol": "http", "containerPort": 80, "hostPort": 80, "name": "nginx-80-tcp", "protocol": "tcp" } ] } ], "cpu": "1024", "executionRoleArn": "arn:aws:iam::<<AWSアカウントID>>:role/ecsTaskExecutionRole", "family": "t3-kato-dev", "ipcMode": "", "memory": "3072", "networkMode": "awsvpc", "pidMode": "", "requiresCompatibilities": [ "FARGATE" ], "runtimePlatform": { "cpuArchitecture": "X86_64", "operatingSystemFamily": "LINUX" } }
ecs-service-def.json
{ "deploymentConfiguration": { "deploymentCircuitBreaker": { "enable": true, "rollback": true }, "maximumPercent": 200, "minimumHealthyPercent": 100 }, "deploymentController": { "type": "ECS" }, "desiredCount": 1, "enableECSManagedTags": true, "enableExecuteCommand": false, "healthCheckGracePeriodSeconds": 0, "launchType": "FARGATE", "loadBalancers": [ { "containerName": "nginx", "containerPort": 80, "targetGroupArn": "arn:aws:elasticloadbalancing:us-west-2:<<AWSアカウントID>>:targetgroup/tf-20231030054829960000000002/76f8f80964e93c67" } ], "networkConfiguration": { "awsvpcConfiguration": { "assignPublicIp": "DISABLED", "securityGroups": [ "sg-0b7784dbfd998ff98" ], "subnets": [ "subnet-079a698011fff2a88", "subnet-0304c5170a559deae" ] } }, "platformFamily": "Linux", "platformVersion": "LATEST", "propagateTags": "NONE", "schedulingStrategy": "REPLICA" }
各種設定が取り込まれていることがわかります。
試しにecs-task-def.jsonのimageを更新してみます。
"image": "public.ecr.aws/nginx/nginx:1.25",
を
"image": "public.ecr.aws/nginx/nginx:1.24",
とし、ecspresso diff
を実行します。
❯ ecspresso diff --config config.yaml --- arn:aws:ecs:us-west-2:<<AWSアカウントID>>:task-definition/t3-kato-dev:2 +++ ecs-task-def.json @@ -3,7 +3,7 @@ { "cpu": 0, "essential": true, - "image": "public.ecr.aws/nginx/nginx:1.25", + "image": "public.ecr.aws/nginx/nginx:1.24", "logConfiguration": { "logDriver": "awslogs", "options": {
差分が表示されました。ecspresso diff
は差分を確認するだけなので実リソースへの影響はありません。
5. 設定ファイルを修正
4で取り込んだ設定ファイルをprodでも使えるように修正していきます。
環境差分については、以下2パターンで管理するものとします。
- tfstate
- ecspressoのpluginを導入し利用
- サブネットやセキュリティグループなどTerraformリソースで定義されているものはtfstateから参照可能
- Terraformリソースから取得出来ない値(ECSタスクのCPUやメモリ等)は、outputを活用することで参照可能
- ただし設定ファイル内のint型で定義されている値については現状tfstateから参照出来ない*1
- 環境の切り替えはconfig.yamlに記載するtfstateのURLを変更することで切り替える
- 環境変数
env
又はmust_env
を用いることで環境変数を読み込むことが可能- config.yamlではpluginが利用出来ない(つまりtfstateから値が参照できない)ため環境変数を用いる
- 環境変数は実行基盤(自端末、CodeBuild、GitHub Actions等)に設定追加が必要なため環境差分は極力tfstateに寄せる
- イメージタグのみデプロイ時に環境変数で埋め込めた方が便利なので例外的に環境変数で持たせる
- 環境の切り替えは
ecspresso deploy
等のコマンド実行時に変数を代入することで切り替える
なお、SSM pluginを用いることでAWS Systems ManagerのParameter Storeから値を参照することが可能です。 しかし、SSM pluginの構文上、対象のParameter Storeのパスに環境変数などを当て込むことが難しく、こちらを用いての環境切り替えはスクリプトの作り込みなどが必要となりそうでしたので、今回は採用を見送りました。
ではファイルを修正します。以下、修正前と後での差分です。
config.yaml
diff --git a/config.yaml b/config.yaml index ea088e5..2632d26 100644 --- a/config.yaml +++ b/config.yaml @@ -2,2 +2,2 @@ region: us-west-2 -cluster: t3-kato-dev -service: t3-kato-dev +cluster: t3-kato-{{ must_env `ENV` }} +service: t3-kato-{{ must_env `ENV` }} @@ -6,0 +7,4 @@ timeout: "10m0s" +plugins: + - name: tfstate + config: + url: s3://t3-kato-terraform-backend/{{ must_env `ENV` }}/terraform.tfstate
ecs-service-def.json
diff --git a/ecs-service-def.json b/ecs-service-def.json index 8ffb0b1..ab02e22 100644 --- a/ecs-service-def.json +++ b/ecs-service-def.json @@ -22 +22 @@ - "targetGroupArn": "arn:aws:elasticloadbalancing:us-west-2:<<AWSアカウントID>>:targetgroup/tf-20231030054829960000000002/76f8f80964e93c67" + "targetGroupArn": "{{ tfstate `module.network.module.alb.aws_lb_target_group.main[0].arn` }}" @@ -29 +29 @@ - "sg-0b7784dbfd998ff98" + "{{ tfstate `module.network.aws_security_group.ecs.id` }}" @@ -32,2 +32,2 @@ - "subnet-079a698011fff2a88", - "subnet-0304c5170a559deae" + "{{ tfstate `module.network.module.vpc.aws_subnet.private[0].id` }}", + "{{ tfstate `module.network.module.vpc.aws_subnet.private[1].id` }}"
ecs-task-def.json
diff --git a/ecs-task-def.json b/ecs-task-def.json index ed78eb1..a125768 100644 --- a/ecs-task-def.json +++ b/ecs-task-def.json @@ -6 +6 @@ - "image": "public.ecr.aws/nginx/nginx:1.25", + "image": "public.ecr.aws/nginx/nginx:{{ must_env `IMAGE_TAG` }}", @@ -11 +11 @@ - "awslogs-group": "/ecs/t3-kato-dev", + "awslogs-group": "{{ tfstate `module.ecs.aws_cloudwatch_log_group.this.name` }}", @@ -28 +28 @@ - "cpu": "1024", + "cpu": "{{ tfstate `output.cpu` }}", @@ -30 +30 @@ - "family": "t3-kato-dev", + "family": "t3-kato-{{ must_env `ENV` }}", @@ -32 +32 @@ - "memory": "3072", + "memory": "{{ tfstate `output.memory` }}",
output.tfは下記の通りです。prodも同様です。
dev/envs/output.tf
output "cpu" { value = "1024" } output "memory" { value = "3072" }
6. ecspresso deploy(prod)
いよいよprodにデプロイします。
下記コマンドでECSデプロイを実行します。ecspresso deploy
コマンドはECSサービスが存在しない場合自動で作成してくれます。
❯ ENV=prod IMAGE_TAG=1.25 ecspresso deploy --config config.yaml 2023/11/09 19:32:36 t3-kato-prod/t3-kato-prod Starting deploy 2023/11/09 19:32:37 t3-kato-prod/t3-kato-prod Service t3-kato-prod not found. Creating a new service 2023/11/09 19:32:37 t3-kato-prod/t3-kato-prod Starting create service 2023/11/09 19:32:37 t3-kato-prod/t3-kato-prod Registering a new task definition... 2023/11/09 19:32:37 t3-kato-prod/t3-kato-prod Task definition is registered t3-kato-prod:8 2023/11/09 19:32:38 t3-kato-prod/t3-kato-prod Service is created 2023/11/09 19:32:41 t3-kato-prod/t3-kato-prod Waiting for service stable...(it will take a few minutes) 2023/11/09 19:32:45 (service t3-kato-prod) has started 1 tasks: (task 11c3ef949b62428290c5770306b75b04). 2023/11/09 19:32:52 t3-kato-prod/t3-kato-prod PRIMARY t3-kato-prod:8 desired:1 pending:1 running:0 IN_PROGRESS(ECS deployment ecs-svc/3896359576468577722 in progress.) 2023/11/09 19:33:05 (service t3-kato-prod) registered 1 targets in (target-group arn:aws:elasticloadbalancing:us-west-2:<<AWSアカウントID>>:targetgroup/tf-20231030070953159400000002/0ee53b36e1ca3188) 2023/11/09 19:33:12 t3-kato-prod/t3-kato-prod PRIMARY t3-kato-prod:8 desired:1 pending:0 running:1 IN_PROGRESS(ECS deployment ecs-svc/3896359576468577722 in progress.) 2023/11/09 19:33:24 t3-kato-prod/t3-kato-prod Service is stable now. Completed!
これでECSデプロイは完了です。(何度かやり直したのでECSタスク定義のリビジョンが上がってます・・・)
dev同様にALBのDNS名からnginxの画面が表示されればOKです。
最後に
ecspressoとTerraformを連携して複数環境にECSデプロイを実施してみました。
環境差分をどのように管理するか試行錯誤しましたが、最終的にはtfstateに集約させるのが良さそうという考えに至りました。 ただしtfstateからはint型の代入が難しいなど工夫が必要な箇所もあったので、検討の余地はありそうです。
今回は単純にデプロイしただけですが、ecspressoについて以下の事柄もいつか触ってみたいと思います。
- 諸々サブコマンドの実行
- GitHub Actionsの活用
- B/Gデプロイ
- ecschedulerの活用