Context
- AWSコンテナ設計・構築入門を読んだ続きとして、
- ECSリソースについては他AWSリソースとライフサイクルが異なるとして、ECSはTerraformではなくecspressoによる構成管理を行うとよさそう
モチベーション
-
TerraformでECSリソースを管理しようとすると、以下のような課題がある
-
a.) TerraformでECSタスク定義を管理する上での課題
aws_ecs_task_definition
を更新すると、タスクリビジョンの積み上げではなく差替(replacement)の差分が生じる- リビジョンを戻すということができない
- rollback相当のことをしたい場合、前回のタスク定義でまたreplacementする差分をapplyする
- 課題
- リビジョンを遡ることができない
- 過去のリビジョンを(gitから)探し出すのが手間
- 迅速なrollbackができない
- rollbackは思考を自動化した手順で行いたいが、それを妨げる
- リビジョンを遡ることができない
-
b.) TerraformでECSサービスを管理する上での課題
- Auto Scalingによってdesired_countの変更が起き得る
aws_ecs_service
で定義しているdesired_countは、たとえばaws_appautoscaling_target
定義によるAuto Scaling発動によって、terraform applyを介さずに状態が変わり得る- -> Terraform定義と稼働しているECSサービスとの間に差分が生まれる
- 課題
- 定義と実態で差分がある状態でterraform applyが実行されると(定義自体に差分は生じず)、Auto Scalingによるスケールアウトを意図せず打ち消してしまうなどが起きる
- ignore_changesに含めて意図しない「修正」を行わないようにすることは可能だが、本来のdesired_countを変更したいときなどはignore_changes外す必要があり、トリッキーな対応を要する
- いずれにせよ、applyが意図した収斂になるか判別できない(tfstateが最新状態を反映しているかが不明である以上は)
- 定義と実態で差分がある状態でterraform applyが実行されると(定義自体に差分は生じず)、Auto Scalingによるスケールアウトを意図せず打ち消してしまうなどが起きる
- Auto Scalingによってdesired_countの変更が起き得る
ecspressoによるECS管理
- ecspressoは何を解決するか
- a.) Terraform ECSタスク定義のリビジョンが残らない/rollback問題
- タスク定義の登録(
register)や実行(run
)が個別で行える - ECSサービスのデプロイ(
deploy
)で、“最新リビジョンのタスク定義を参照(新規登録しない)“オプション(--latest-task-definition
)の付与が可能 - -> タスク定義登録前のリビジョンへのロールバックが可能(
rollback
)
- タスク定義の登録(
- b.) ECSサービスのTerraform定義と実態の乖離と誤applyリスク
- -> 定義と現在のECS(=実際にデプロイされているECS)状態との差分の確認(
diff
)ができる
- -> 定義と現在のECS(=実際にデプロイされているECS)状態との差分の確認(
- a.) Terraform ECSタスク定義のリビジョンが残らない/rollback問題
ECSリソースのTerraform -> ecspresso移行
TL;DR version
before:TerraformでのECSリソース定義
ecs.tf
## Backend container
resource "aws_ecs_task_definition" "sbcntr_backend" {
family = local.task_def.backend.name
container_definitions = templatefile(local.task_def.backend.container_def_file, {
container_name = local.task_def.backend.container_name
image = local.task_def.backend.image_url
awslogs-group = local.task_def.backend.cwlogs_group
awslogs-region = var.aws_region
awslogs-prefix = local.task_def.backend.cwlogs_prefix
})
requires_compatibilities = ["FARGATE"]
cpu = "256"
memory = "512"
network_mode = "awsvpc"
task_role_arn = aws_iam_role.sbcntr_task.arn
execution_role_arn = aws_iam_role.sbcntr_task_exec.arn
}
resource "aws_ecs_cluster" "sbcntr_backend" {
name = var.cluster_def.backend.name
}
resource "aws_ecs_service" "sbcntr_backend" {
name = local.service_def.backend.name
cluster = local.service_def.backend.cluster_name
task_definition = aws_ecs_task_definition.sbcntr_backend.arn
desired_count = local.service_def.common.desired_count
launch_type = "FARGATE"
enable_execute_command = true
network_configuration {
subnets = var.subnet_ids
security_groups = local.service_def.backend.security_groups
}
load_balancer {
target_group_arn = local.service_def.backend.lb_target_group_arn
container_name = local.service_def.backend.container_name
container_port = 80
}
deployment_controller {
type = "CODE_DEPLOY"
}
}
- CodeDeployによるBlue/GreenデプロイタイプのECS定義
resource "aws_codedeploy_app" "sbcntr_backend" {
name = "sbcntr-ecs-backend-codedeploy"
compute_platform = "ECS"
}
resource "aws_codedeploy_deployment_group" "sbcntr" {
app_name = aws_codedeploy_app.sbcntr_backend.name
deployment_config_name = "CodeDeployDefault.ECSAllAtOnce"
deployment_group_name = "sbcntr-ecs-backend-blue-green-deployment-group"
service_role_arn = aws_iam_role.ecs_codedeploy.arn
auto_rollback_configuration {
enabled = true
events = ["DEPLOYMENT_FAILURE"]
}
blue_green_deployment_config {
deployment_ready_option {
//action_on_timeout = "CONTINUE_DEPLOYMENT"
action_on_timeout = "STOP_DEPLOYMENT"
wait_time_in_minutes = 5
}
terminate_blue_instances_on_deployment_success {
action = "TERMINATE"
termination_wait_time_in_minutes = 0
}
}
deployment_style {
deployment_option = "WITH_TRAFFIC_CONTROL"
deployment_type = "BLUE_GREEN"
}
ecs_service {
cluster_name = aws_ecs_cluster.sbcntr_backend.name
service_name = aws_ecs_service.sbcntr_backend.name
}
load_balancer_info {
target_group_pair_info {
prod_traffic_route {
listener_arns = [
aws_lb_listener.internal_blue.arn
]
}
test_traffic_route {
listener_arns = [
aws_lb_listener.internal_green.arn
]
}
target_group {
name = aws_lb_target_group.internal_blue.name
}
target_group {
name = aws_lb_target_group.internal_green.name
}
}
}
}
codedeploy.tf
after:ecspresso移行後のTerraform定義
ecs.tf
resource "aws_ecs_cluster" "sbcntr_backend" {
name = local.backend_def.cluster_name
}
resource "null_resource" "ecspresso_backend" {
triggers = {
cluster = aws_ecs_cluster.sbcntr_backend.name
task_exec_role = aws_iam_role.sbcntr_task_exec.name
task_role = aws_iam_role.sbcntr_task.name
alb_backend = aws_lb.internal.arn
alb_target_group = aws_lb_target_group.internal_blue.arn
}
provisioner "local-exec" {
command = "ecspresso deploy --no-wait"
working_dir = "./ecspresso/backend-app"
environment = {
# Common
AWS_ACCOUNT_ID = var.aws_account_id,
ECS_CLUSTER = local.backend_def.cluster_name,
ECS_SERVICE = local.backend_def.service_name,
# Task
CW_LOG_GROUP_ECS_TASK_BACKEND = local.backend_def.cwlogs_group,
ECS_TASK_EXEC_ROLE_ARN = aws_iam_role.sbcntr_task_exec.arn,
ECS_TASK_ROLE_ARN = aws_iam_role.sbcntr_task.arn,
DB_HOST = var.db_host,
DB_NAME = var.db_name,
# Service
LB_TG_INTERNAL_BLUE = aws_lb_target_group.internal_blue.arn,
SG_BACKEND_APP = aws_security_group.sbcntr_sg_container.id,
VPC_SUBNET_APP_AZ_A = var.subnet_ids[0],
VPC_SUBNET_APP_AZ_C = var.subnet_ids[1]
# CodeDeploy
CODEDEPLOY_APP_NAME = local.backend_def.codedeploy_app_name,
CODEDEPLOY_GROUP_NAME = local.backend_def.codedeploy_group_name,
}
}
provisioner "local-exec" {
when = destroy
command = "aws ecs delete-service --cluster $ECS_CLUSTER --service $ECS_SERVICE --force"
working_dir = "."
environment = {
ECS_CLUSTER = "sbcntr-ecs-backend-cluster",
ECS_SERVICE = "sbcntr-ecs-backend-service"
}
}
}
data "aws_ecs_service" "sbcntr_backend" {
cluster_arn = aws_ecs_cluster.sbcntr_backend.arn
service_name = local.backend_def.service_name
depends_on = [
null_resource.ecspresso_backend,
]
}
null_resource
のlocal-exec provisionerでecspresso deploy
コマンドを実行aws_ecs_service
data sourceで実際にデプロイされたECSサービスのリソースを参照
codedeploy.tf
resource "aws_codedeploy_app" "sbcntr_backend" {
name = local.backend_def.codedeploy_app_name
compute_platform = "ECS"
}
resource "aws_codedeploy_deployment_group" "sbcntr" {
app_name = aws_codedeploy_app.sbcntr_backend.name
deployment_config_name = "CodeDeployDefault.ECSAllAtOnce"
deployment_group_name = local.backend_def.codedeploy_group_name
service_role_arn = aws_iam_role.ecs_codedeploy.arn
auto_rollback_configuration {
enabled = true
events = ["DEPLOYMENT_FAILURE"]
}
blue_green_deployment_config {
deployment_ready_option {
//action_on_timeout = "CONTINUE_DEPLOYMENT"
action_on_timeout = "STOP_DEPLOYMENT"
wait_time_in_minutes = 5
}
terminate_blue_instances_on_deployment_success {
action = "TERMINATE"
termination_wait_time_in_minutes = 0
}
}
deployment_style {
deployment_option = "WITH_TRAFFIC_CONTROL"
deployment_type = "BLUE_GREEN"
}
ecs_service {
cluster_name = local.backend_def.cluster_name
service_name = data.aws_ecs_service.sbcntr_backend.service_name
}
load_balancer_info {
target_group_pair_info {
prod_traffic_route {
listener_arns = [
aws_lb_listener.internal_blue.arn
]
}
test_traffic_route {
listener_arns = [
aws_lb_listener.internal_green.arn
]
}
target_group {
name = aws_lb_target_group.internal_blue.name
}
target_group {
name = aws_lb_target_group.internal_green.name
}
}
}
}
aws_ecs_service
data sourceで実際にデプロイされたECSサービスのリソースを参照して、CodeDeploy deployment groupに紐づくECSサービスを指定- CodeDeploy -> ECSサービス の依存関係を記述
after:ecspressoの定義
実際にデプロイされているECS構成のecspresso定義を生成する
ecspresso init
で生成できる
ecspresso init --cluster <cluster_name> --service <service_name> --jsonnet
-
--jsonnet
オプション付与でJsonnet形式でdumpできる -
ecspresso.yml
region: '' cluster: '{{ must_env `ECS_CLUSTER` }}' service: '{{ must_env `ECS_SERVICE` }}' service_definition: ecs-service-def.jsonnet task_definition: ecs-task-def.jsonnet timeout: 10m0s plugins: - name: tfstate config: path: terraform.tfstate - name: ssm
-
ecs-service-def.jsonnet
{ deploymentConfiguration: { maximumPercent: 200, minimumHealthyPercent: 100, }, deploymentController: { type: 'CODE_DEPLOY', }, desiredCount: 2, enableECSManagedTags: false, enableExecuteCommand: true, healthCheckGracePeriodSeconds: 0, launchType: 'FARGATE', loadBalancers: [ { containerName: 'app', containerPort: 80, targetGroupArn: '{{ or (env `LB_TG_INTERNAL_BLUE` ``) (tfstate `module.ecs.aws_lb_target_group.internal_blue.arn`) }}', }, ], networkConfiguration: { awsvpcConfiguration: { assignPublicIp: 'DISABLED', securityGroups: [ '{{ or (env `SG_BACKEND_APP` ``) (tfstate `module.ecs.aws_security_group.sbcntr_sg_container.id`) }}', ], subnets: [ "{{ or (env `VPC_SUBNET_APP_AZ_A` ``) (tfstate `module.network.aws_subnet.sbcntr_subnet_private_container1['a'].id`) }}", "{{ or (env `VPC_SUBNET_APP_AZ_C` ``) (tfstate `module.network.aws_subnet.sbcntr_subnet_private_container1['c'].id`) }}", ], }, }, pendingCount: 0, platformFamily: 'Linux', platformVersion: '1.4.0', propagateTags: 'NONE', runningCount: 0, schedulingStrategy: 'REPLICA', }
-
ecs-task-def.jsonnet
{ containerDefinitions: [ { cpu: 256, environment: [ { name: 'DB_NAME', value: 'sbcntrapp', }, { name: 'DB_HOST', value: '{{ or (env `DB_HOST` ``) (tfstate `module.rds.aws_db_instance.sbcntr_db.address`) }}', }, { name: 'DB_PASSWORD', value: '{{ ssm `/sbcntr/db/DB_PASSWORD` }}', }, { name: 'DB_USERNAME', value: '{{ ssm `/sbcntr/db/DB_USERNAME` }}', }, ], essential: true, image: '{{ must_env `AWS_ACCOUNT_ID` }}.dkr.ecr.ap-northeast-1.amazonaws.com/sbcntr-backend:v1', logConfiguration: { logDriver: 'awslogs', options: { 'awslogs-group': "{{ or (env `CW_LOG_GROUP_ECS_TASK_BACKEND` ``) (tfstate `module.ecs.aws_cloudwatch_log_group.sbcntr['backend_task'].name`) }}", 'awslogs-region': 'ap-northeast-1', 'awslogs-stream-prefix': 'ecs', }, }, memory: 512, name: 'app', portMappings: [ { appProtocol: '', containerPort: 80, hostPort: 80, protocol: 'tcp', }, ], }, ], cpu: '256', executionRoleArn: '{{ or (env `ECS_TASK_EXEC_ROLE_ARN` ``) (tfstate `module.ecs.aws_iam_role.sbcntr_task_exec.arn`) }}', family: 'sbcntr-backend-def', ipcMode: '', memory: '512', networkMode: 'awsvpc', pidMode: '', requiresCompatibilities: [ 'FARGATE', ], taskRoleArn: '{{ or (env `ECS_TASK_EXEC_ROLE_ARN` ``) (tfstate `module.ecs.aws_iam_role.sbcntr_task.arn`) }}', }
`{{ or (env `xxx` ``) (tfstate `yyy`) }}`
-
こうすることで、
- ECSサービス作成時は
null_resource
local-exec provisionerからのecspresso deployコマンド実行時に、環境変数としてパラメータを与え、 - 作成以後はtfstateファイルから作成済リソースの値を読み込む、という区分けができる
- ECSサービス作成時は
-
ポイント
- CodeDeployによるBlue/Greenデプロイする上で、deployment group作成前に関連するECSサービス作成が先に行われる必要があるので、その依存関係を記述する
# aws_codedeploy_deployment_group -depends-> aws_ecs_serivce data source data "aws_ecs_service" ... { } resource "aws_codedeploy_deployment_group" ... { ... ecs_service { ... service_name = data.aws_ecs_service.sbcntr_backend.service_name } ...
- が、ECSサービスのdeployment controller typeが
CODE_DEPLOY
だと、ecspresso deploy
の完了待受が正常終了しない挙動をしていた- 原因としてはaws-sdk-go側の実装として、ECSサービス作成完了待受を行う実装が、aws-cli ecsのdescribe-services出力結果でいうところの
deployments
にローリングデプロイ(つまりECS
deployment controller type)結果が格納されることをもって完了判定するようになっており、CODE_DEPLOY
deployment typeではそもそも検知されないというもの- deploymentsフィールド自体、
ECS
deployment専用のものであるという仕様が記載されている
- deploymentsフィールド自体、
- この件についてはecspresso本体にissueを起票させていただいた
- https://github.com/kayac/ecspresso/issues/500
- (追記)上記issue起票後、CODE_DEPLOY typeのECSサービス作成においてはdeployment groupの存在を必須としない修正の対応をしていただいた☕
- workaroundとして、ecspresso deployに
--no-wait
オプションを付与することでECSサービス作成をもって(steady state遷移待たず)リソース作成完了とさせることで後続のCodeDeploy deployment group作成も行えるのでこれで事なきを得ている
- 原因としてはaws-sdk-go側の実装として、ECSサービス作成完了待受を行う実装が、aws-cli ecsのdescribe-services出力結果でいうところの
- が、ECSサービスのdeployment controller typeが
- CodeDeployとECSサービスが紐付いている場合の削除(terraform destroy時)については、ecspresso実行コマンドとして
ecspresso scale --tasks 0 && ecspresso delete --force
とすべきところだが、ここはaws-cliで直接ECSサービスの強制削除を行うようにしている- 本来であれば、deployment groupに依存するECSサービスを削除した上でdeployment group削除といきたいところだが、
- 依存関係として、
aws_codedeploy_deployment_group -> data.aws_ecs_service -> null_resource(ecspresso provisioner)
という依存グラフがある中、 - terraform destroyを行うと、まず先にdeployment group削除が実行されてしまい、そうなるとecspressoによるECSタスク削除(タスク数0へのロールアウト)実行が阻まれてしまう(これはCODE_DEPLOY deployment controller typeのECSサービスの場合、ecspressoは関連するCodeDeploy deployment groupが存在することを事前条件としている為)
- ECSサービスが先に削除されるよう、依存グラフを
null_resource(ecspresso provisioner) -> codedeploy_deployment_group
としたいところだが、そうなると循環依存が発生してしまう(terraform planエラーになる..) - workaroundとして、destroy時にはecspressoではなくaws-cliをつかってECSサービスの強制削除を行うようにした(関連するdeployment groupの存在チェックをはさまない)
provisioner "local-exec" { when = destroy command = "aws ecs delete-service --cluster $ECS_CLUSTER --service $ECS_SERVICE --force" working_dir = "." environment = { ECS_CLUSTER = "***-cluster", ECS_SERVICE = "***-service" } }
- 依存関係として、
- 本来であれば、deployment groupに依存するECSサービスを削除した上でdeployment group削除といきたいところだが、
- CodeDeployによるBlue/Greenデプロイする上で、deployment group作成前に関連するECSサービス作成が先に行われる必要があるので、その依存関係を記述する
その他ecspresso利点
- 設定が柔軟
- Terraform(.tfstate)とのインテグレーションがサポートされている
- これがないと始まらなかったりする
- インフラリソースとECSリソース(アプリケーション)のIaCをそれぞれのライフサイクルで管理できてうれしい
- Jsonnet形式で設定記述できる(=プログラミングできる)ので、概ねやりたいようにできる自由度があってうれしい
- Terraform(.tfstate)とのインテグレーションがサポートされている
- 安心安全感
- 上述の
diff
の他verify
コマンドが備わっていて「タスクが立たない」ケースを事前に検知できてうれしい***-service/***-cluster Starting verify TaskDefinition ExecutionRole[arn:aws:iam::xxx:role/***-task-exec-role] --> [OK] TaskRole[arn:aws:iam::xxx:role/***-task-role] --> [OK] ContainerDefinition[app] Image[xxx.dkr.ecr.ap-northeast-1.amazonaws.com/***-frontend:2c90e20] --> [OK] Secret DB_PASSWORD[***] --> Secret DB_PASSWORD[***] [NG] failed to get ssm parameter ***: operation error SSM: GetParameter, https response error StatusCode: 400, RequestID: 200bf926-a975- 4f54-acd1-8abc84a49d98, ParameterNotFound: --> ContainerDefinition[app] [NG] verify Secret DB_PASSWORD[***] failed: failed to get ssm parameter ***: operation error SSM: GetParameter, https response error StatusCode: 400, RequestID: 200bf926-a975-4f54-acd1-8abc84a49d98, ParameterNotFound:
- ↑はタスク定義から参照しているssmパラメータストアの参照に失敗している例
- タスク起動前にこうした検査が行えて便利
- ecspresso作者(@fujiwaraさん)別作のtracer cliも併用することで、「タスクが立たない」調査について本当にラクになった
- 上述の
- 実用感
run
,rollback
,scale
など、ちょっとアレしたいときのユースケースが痒いところに手が届くといった具合でカバーされていてうれしい- CodeDeployでのBlue/Greenの場合もサクッとdeployment URL叩ける気遣いがうれしい
参考