0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ECS Managed Daemons が「落ちても本番を巻き込まない」設定に対応したので Terraform で検証してみた

0
Posted at

はじめに

「ログ収集エージェントが落ちただけなのに、なぜか本番のアプリまで止まった」

ECS Managed Daemons はまさにこれをやります。デーモンタスクが落ちると、ECS は そのコンテナインスタンスを丸ごと drain して入れ替え ます。デーモンのカバレッジを保証するための仕様なのですが、「メトリクス収集が死んだくらいで本番タスクを動かさないでほしい」というケースも当然あります。

2026年9月3日のアップデートで、ここに 非クリティカル(non-critical) という選択肢が入りました。本記事では Terraform で実際に構築して挙動を確かめます。結論から言うと Terraform AWS Provider にはまだ critical 引数が実装されていない ので、その回避策込みでまとめます。

この記事のポイント

  • ECS Managed Daemons に critical パラメータが追加された。デフォルトは true(従来どおりの挙動)
  • critical = false にすると、デーモンが落ちてもインスタンスは ACTIVE のまま。アプリタスクは走り続け、新規タスクも配置され続ける。インスタンス登録もブロックしない
  • 非クリティカルでも EventBridge イベントとアクションログは出るので、気づけなくなるわけではない
  • Terraform AWS Provider v6.65.0 時点で aws_ecs_daemon に critical 引数は無い。aws_cloudformation_stack で AWS::ECS::Daemon を包むのが現実的な回避策

そもそも Managed Daemons とは

Managed Daemons は、ECS Managed Instances(ECS 側が EC2 のライフサイクルを管理してくれるキャパシティプロバイダー)の上で、1インスタンスにつきちょうど1つデーモンタスクを常駐させる仕組みです。通常のタスク定義とは別に「デーモンタスク定義」という専用リソースを登録して使います。

ポイントは 順序が保証されている ことです。ECS はインスタンスをプロビジョニングしたあと、デーモンタスクを起動してからアプリタスクを RUNNING に遷移させます。なお critical はこの順序には影響しません。変わるのは 起動できなかったとき・途中で落ちたとき の振る舞いだけです。

参考: Amazon ECS Managed Daemons

今回のアップデート: critical / non-critical

critical は真偽値で、CreateDaemon / UpdateDaemon で指定します。

critical = true(デフォルト) critical = false
デーモンが起動失敗したとき インスタンスが 登録されない インスタンスは 登録される
デーモンが途中で停止・異常になったとき ECS がインスタンスを drain して置き換える インスタンスは ACTIVE のまま
既存のアプリタスク 巻き添えで停止・再配置される 走り続ける
新規アプリタスクの配置 止まる 続く
起動失敗時の EventBridge イベント 出る 出る
デプロイのサーキットブレーカー カウントされる カウントされる

何がうれしいのか

  • 決済・注文処理など、絶対に落としたくないサービス。メトリクス収集が数分欠けても事業影響はないが、タスクが churn するほうが致命的
  • 導入・検証中のエージェント。新しいセキュリティエージェントをまず non-critical で入れ、安定したら critical に昇格させる段階的導入

逆に、コンプライアンス上そのエージェントが動いていないインスタンスでワークロードを動かしてはいけないケース(監査ログ、EDR など)は critical = true のままにすべきです。機械的に全部 false にするのはおすすめしません。

やってみた

「わざと起動に失敗するデーモン」を作り、critical を切り替えたときにアプリタスクとインスタンスがどうなるかを観察します。Terraform で作るのは次の一式です。

20260919-ecs-managed-daemon-resources.png

デーモンだけ CloudFormation を挟んでいるのが今回のポイントで、理由は手順3 で説明します。

前提条件

  • Terraform v1.5 以上(検証は v1.5.6)、AWS Provider v6.65.0、time Provider v0.14.2
  • AWS CLI v2.36.45 以上、リージョンは ap-northeast-1
  • EC2 インスタンスが起動するので課金が発生します。 検証後は必ず terraform destroy してください

出たばかりの機能はドキュメントと実装がズレることがあるので、まず CLI でパラメータの存在を確認しておきます。

aws ecs create-daemon help | col -b | grep -A 6 -- '--critical | --no-critical (boolean)'
       --critical | --no-critical (boolean)
	  If the critical parameter of a daemon is true , and the daemon task
	  fails, stops, or becomes unhealthy, Amazon ECS drains the container
	  instance and stops the other tasks running on it. If the critical
	  parameter is false , the daemon task failure doesn't affect the
	  other tasks on the instance. The default value is true .

手順1: 土台一式

Managed Instances には3つのロールが要ります。

  • インフラストラクチャロール(ECS が EC2 を起動・終了する)
  • インスタンスロール(EC2 自身が ECS と通信する)
  • タスク実行ロール(イメージ pull とログ出力)
土台の Terraform(クリックで展開)
# main.tf
terraform {
  required_version = ">= 1.5.0"
  required_providers {
    aws  = { source = "hashicorp/aws", version = "~> 6.65" }
    time = { source = "hashicorp/time", version = "~> 0.13" }
  }
}

provider "aws" { region = var.region }

variable "region" { default = "ap-northeast-1" }
variable "name" { default = "mdaemon-demo" }
variable "daemon_critical" {
  type    = bool
  default = false
}

# 既存のデフォルト VPC を利用(新規作成はしない)
data "aws_vpc" "default" { default = true }

data "aws_subnets" "default" {
  filter {
    name   = "vpc-id"
    values = [data.aws_vpc.default.id]
  }
  filter {
    name   = "default-for-az"
    values = ["true"]
  }
}

# マネージドインスタンス用(アウトバウンドのみ)
resource "aws_security_group" "mi" {
  name   = "${var.name}-sg"
  vpc_id = data.aws_vpc.default.id

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

# デーモンとアプリのコンテナログ
resource "aws_cloudwatch_log_group" "this" {
  name              = "/ecs/${var.name}"
  retention_in_days = 1
}

# Managed Instances に必要な 3 つのロール
locals {
  roles = {
    infrastructure = {
      service = "ecs.amazonaws.com"
      policy  = "arn:aws:iam::aws:policy/AmazonECSInfrastructureRolePolicyForManagedInstances"
    }
    instance = {
      service = "ec2.amazonaws.com"
      policy  = "arn:aws:iam::aws:policy/AmazonECSInstanceRolePolicyForManagedInstances"
    }
    task_execution = {
      service = "ecs-tasks.amazonaws.com"
      policy  = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
    }
  }
}

resource "aws_iam_role" "this" {
  for_each = local.roles
  name     = "${var.name}-${each.key}-role"

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

resource "aws_iam_role_policy_attachment" "this" {
  for_each   = local.roles
  role       = aws_iam_role.this[each.key].name
  policy_arn = each.value.policy
}

resource "aws_iam_instance_profile" "instance" {
  name = "${var.name}-instance-profile"
  role = aws_iam_role.this["instance"].name
}

# マネージドポリシーの PassRole は role/ecsInstanceRole* 限定なので自前で許可
resource "aws_iam_role_policy" "pass_instance_role" {
  name = "PassInstanceRole"
  role = aws_iam_role.this["infrastructure"].id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Action    = "iam:PassRole"
      Resource  = aws_iam_role.this["instance"].arn
      Condition = { StringLike = { "iam:PassedToService" = "ec2.*" } }
    }]
  })
}

手順2: クラスターとキャパシティプロバイダー

クラスターとキャパシティプロバイダーの Terraform(クリックで展開)
# cluster.tf
resource "aws_ecs_cluster" "this" {
  name = var.name
}

# EC2 のライフサイクルを ECS に任せる Managed Instances
resource "aws_ecs_capacity_provider" "mi" {
  name    = "${var.name}-mi"
  cluster = aws_ecs_cluster.this.name # managed_instances_provider では必須

  managed_instances_provider {
    infrastructure_role_arn = aws_iam_role.this["infrastructure"].arn

    instance_launch_template {
      ec2_instance_profile_arn = aws_iam_instance_profile.instance.arn

      network_configuration {
        subnets         = data.aws_subnets.default.ids
        security_groups = [aws_security_group.mi.id]
      }

      instance_requirements {
        vcpu_count {
          min = 2
          max = 4
        }
        memory_mib {
          min = 4096
          max = 8192
        }
      }

      storage_configuration { storage_size_gib = 30 }
    }
  }

  depends_on = [aws_iam_role_policy_attachment.this]
}

# ACTIVE になる前に紐づけると InvalidParameterException になるため待つ
resource "time_sleep" "capacity_provider_active" {
  depends_on      = [aws_ecs_capacity_provider.mi]
  create_duration = "120s"
}

resource "aws_ecs_cluster_capacity_providers" "this" {
  depends_on = [time_sleep.capacity_provider_active]

  cluster_name       = aws_ecs_cluster.this.name
  capacity_providers = [aws_ecs_capacity_provider.mi.name]

  default_capacity_provider_strategy {
    capacity_provider = aws_ecs_capacity_provider.mi.name
    weight            = 1
  }
}

手順3: 壊れたデーモンと、critical をどう渡すか

デーモンタスク定義は Provider が対応しているので素直に書けます。

デーモンタスク定義の Terraform(クリックで展開)
# daemon.tf
# わざと起動直後に落ちる「壊れた観測エージェント」
resource "aws_ecs_daemon_task_definition" "broken_agent" {
  family             = "${var.name}-broken-agent"
  cpu                = "256"
  memory             = "512"
  execution_role_arn = aws_iam_role.this["task_execution"].arn

  container_definition {
    name      = "agent"
    image     = "public.ecr.aws/docker/library/busybox:1.37"
    essential = true
    command   = ["sh", "-c", "echo 'agent booting...'; exit 1"]

    log_configuration {
      log_driver = "awslogs"
      options = {
        "awslogs-group"         = aws_cloudwatch_log_group.this.name
        "awslogs-region"        = var.region
        "awslogs-stream-prefix" = "daemon"
      }
    }
  }
}

問題はデーモン本体です。aws_ecs_daemon は critical に対応していない(awscc_ecs_daemon も同様)ので、Critical を持っている CloudFormation の AWS::ECS::Daemon を aws_cloudformation_stack で包んで書きます。

# Critical はここで渡す
Properties = {
  DaemonName              = "${var.name}-broken-agent"
  ClusterArn              = aws_ecs_cluster.this.arn
  DaemonTaskDefinitionArn = aws_ecs_daemon_task_definition.broken_agent.arn
  CapacityProviderArns    = [aws_ecs_capacity_provider.mi.arn]
  Critical                = var.daemon_critical
}
リソース全体(クリックで展開)
# daemon.tf(続き)
# aws_ecs_daemon は critical 未対応のため CloudFormation で作る
resource "aws_cloudformation_stack" "daemon" {
  name = "${var.name}-daemon"

  template_body = jsonencode({
    AWSTemplateFormatVersion = "2010-09-09"
    Resources = {
      Daemon = {
        Type = "AWS::ECS::Daemon"
        Properties = {
          DaemonName              = "${var.name}-broken-agent"
          ClusterArn              = aws_ecs_cluster.this.arn
          DaemonTaskDefinitionArn = aws_ecs_daemon_task_definition.broken_agent.arn
          CapacityProviderArns    = [aws_ecs_capacity_provider.mi.arn]
          Critical                = var.daemon_critical
        }
      }
    }
    Outputs = {
      DaemonArn = { Value = { "Fn::GetAtt" = ["Daemon", "DaemonArn"] } }
    }
  })

  depends_on = [aws_ecs_cluster_capacity_providers.this]
}

output "daemon_arn" {
  value = aws_cloudformation_stack.daemon.outputs["DaemonArn"]
}

手順4: 巻き添えを食う側のアプリサービス

30秒おきに生存ログを出すだけのタスクを1本置きます。これが「デーモンが落ちたときに止まるかどうか」の観測対象です。

アプリ側の Terraform(クリックで展開)
# app.tf
# 30 秒おきに生存ログを出すだけのアプリ
resource "aws_ecs_task_definition" "app" {
  family                   = "${var.name}-app"
  requires_compatibilities = ["EC2"]
  network_mode             = "awsvpc"
  cpu                      = "256"
  memory                   = "512"
  execution_role_arn       = aws_iam_role.this["task_execution"].arn

  container_definitions = jsonencode([{
    name      = "app"
    image     = "public.ecr.aws/docker/library/busybox:1.37"
    essential = true
    command   = ["sh", "-c", "while true; do echo \"app alive $(date -u)\"; sleep 30; done"]
    logConfiguration = {
      logDriver = "awslogs"
      options = {
        "awslogs-group"         = aws_cloudwatch_log_group.this.name
        "awslogs-region"        = var.region
        "awslogs-stream-prefix" = "app"
      }
    }
  }])
}

resource "aws_ecs_service" "app" {
  name            = "${var.name}-app"
  cluster         = aws_ecs_cluster.this.id
  task_definition = aws_ecs_task_definition.app.arn
  desired_count   = 1

  capacity_provider_strategy {
    capacity_provider = aws_ecs_capacity_provider.mi.name
    weight            = 1
  }

  network_configuration {
    subnets         = data.aws_subnets.default.ids
    security_groups = [aws_security_group.mi.id]
  }

  depends_on = [aws_ecs_cluster_capacity_providers.this]
}

手順5: アクションログを有効化する

What's New に「service action logs を記録する」とありますが、アクションログはクラスター単位でオプトインが必要です。CloudWatch Logs の vended log delivery で有効化します。

アクションログ有効化の Terraform(クリックで展開)
# observability.tf
# アクションログの配信先
resource "aws_cloudwatch_log_group" "action_logs" {
  name              = "/aws/vendedlogs/ecs/action-logs/${var.name}"
  retention_in_days = 1
}

resource "aws_cloudwatch_log_delivery_source" "action_logs" {
  name = "${var.name}-action-logs"
  # docs は EcsActionLogs だが API は ACTION_LOGS しか受け付けない
  # ValidationException: Provided LogType is not valid. Supported options are [ACTION_LOGS].
  log_type     = "ACTION_LOGS"
  resource_arn = aws_ecs_cluster.this.arn
}

resource "aws_cloudwatch_log_delivery_destination" "action_logs" {
  name          = "${var.name}-action-logs-dest"
  output_format = "json"

  delivery_destination_configuration {
    destination_resource_arn = aws_cloudwatch_log_group.action_logs.arn
  }
}

resource "aws_cloudwatch_log_delivery" "action_logs" {
  delivery_source_name     = aws_cloudwatch_log_delivery_source.action_logs.name
  delivery_destination_arn = aws_cloudwatch_log_delivery_destination.action_logs.arn
}

手順6: apply

terraform init
terraform apply   # デフォルトは daemon_critical = false

結果確認: critical = false のとき

critical の値はデーモンリビジョンで確認する

デーモンリビジョンに紐づく属性なので describe-daemon-revisions を叩きます。

DAEMON_ARN=$(terraform output -raw daemon_arn)
REV_ARN=$(aws ecs describe-daemon --daemon-arn "$DAEMON_ARN" \
  --query 'daemon.currentRevisions[0].arn' --output text)

aws ecs describe-daemon-revisions --daemon-revision-arns "$REV_ARN" \
  --query 'daemonRevisions[0].critical'
# false

インスタンスとアプリの状態

デーモンは起動に失敗し続けています。

aws ecs describe-daemon --daemon-arn "$DAEMON_ARN" \
  --query 'daemon.currentRevisions[0].{running:totalRunningCount,withoutDaemon:totalWithoutDaemonCount}'
# { "running": 0, "withoutDaemon": 1 }

withoutDaemon: 1 は「デーモンが乗っていないインスタンスが1台ある」という意味です。その1台とアプリサービスを見ます。

CI=$(aws ecs list-container-instances --cluster mdaemon-demo --query 'containerInstanceArns[]' --output text)

aws ecs describe-container-instances --cluster mdaemon-demo --container-instances $CI \
  --query 'containerInstances[].{ec2:ec2InstanceId,status:status,running:runningTasksCount}' --output table

aws ecs describe-services --cluster mdaemon-demo --services mdaemon-demo-app \
  --query 'services[0].{desired:desiredCount,running:runningCount,pending:pendingCount}'
ec2 = i-09c9f9921f4546f35 / status = ACTIVE / running = 1
service: { "desired": 1, "running": 1, "pending": 0 }

デーモンが死んでいるのにインスタンスは ACTIVE、アプリタスクは走り続けています。 停止済みタスクを見ると、落ちているのはデーモンだけだと分かります。

aws ecs list-tasks --cluster mdaemon-demo --desired-status STOPPED --query 'taskArns[]' --output text \
  | xargs aws ecs describe-tasks --cluster mdaemon-demo --tasks \
  --query 'tasks[].{td:taskDefinitionArn,reason:stoppedReason,exit:containers[0].exitCode}' --output table
# mdaemon-demo-broken-agent:2 | Essential container in task exited | 1

コンソールでも一目で分かります。「コンテナインスタンス: 0(デーモンあり)/ 1(デーモンなし)」という警告付きの表示なのに、「重大: いいえ」「最終デプロイステータス: 成功」です。

20260919-ecs-daemon-noncritical-detail.png

デーモン一覧にも「重大」列が増えていました。

20260919-ecs-daemon-list.png

結果確認: critical = true にすると何が起きるか

対照実験として true に切り替えます。Terraform の plan は CloudFormation スタックの差分(~ Critical = false -> true)として出ます。

terraform apply -var daemon_critical=true

45秒おきにインスタンスとサービスの状態を観察した結果がこちらです。

02:08:53 | instances= ['3cf4fb:DRAINING:1t', '7b5f1c:DEREGISTERING:0t'] | svc run=1   pend=0
02:09:39 | instances= ['3095ea:REGISTERING:0t', '3cf4fb:DRAINING:1t']   | svc run=1 pend=1
02:10:26 | instances= ['3095ea:DEREGISTERING:0t', '3cf4fb:DRAINING:1t'] | svc run=1 pend=0
02:11:13 | instances= ['62d137:REGISTERING:0t', '3cf4fb:DRAINING:1t']   | svc run=1 pend=1
02:11:59 | instances= ['62d137:DEREGISTERING:0t', '3cf4fb:DRAINING:1t'] | svc run=1 pend=0
02:12:46 | instances= ['624539:REGISTERING:0t', '3cf4fb:DRAINING:1t']   | svc run=1 pend=1

インスタンス ID が毎回変わっているのが分かるでしょうか。どのインスタンスも一度も ACTIVE にならず、観察していた約20分のあいだこの輪を回り続けました。

サービスの pending も 0 と 1 を行き来し、新しいアプリタスクを置ける場所がありません。コンソールでも「重大: はい」「最終デプロイステータス: 進行中」のまま止まらなくなります。

20260919-ecs-daemon-critical-detail.png

このループは手順5 で有効化したアクションログにも残ります。デーモンタスクの起動と drain が交互に記録されていました。

aws logs filter-log-events --log-group-name /aws/vendedlogs/ecs/action-logs/mdaemon-demo \
  --limit 5 --query 'events[].message' --output text | jq -r '.detail.eventName'
# DAEMON_SERVICE_UPDATED
# DAEMON_DEPLOYMENT_IN_PROGRESS
# DAEMON_TASK_STARTED
# DAEMON_DRAIN_COMPLETED

20260919-ecs-daemon-action-logs.png

なお eventName の値はドキュメントに一覧が無く、これは観察されたものです。アラートに繋ぐなら EventBridge 側の detail-type: ECS Daemon Service Action / eventName: DAEMON_TASK_START_IMPAIRED を使うと、デーモンタスクの起動失敗だけを拾えます(参考: Daemon events)。

ここから戻るのが一苦労

この状態だと CloudFormation の UpdateDaemon がいつまでも完了しません。cancel-update-stack でロールバックさせようとしたのですが、ロールバック(= Critical を false に戻す更新)も 30 分以上 UPDATE_ROLLBACK_IN_PROGRESS のまま進まなくなりました。 デーモンのアクションログも途中で途切れたきりです。その間に aws_cloudformation_stack のデフォルトタイムアウト(30分)が先に来て、Terraform 側はこう落ちます。

Error: waiting for CloudFormation Stack (...) update: timeout while waiting for state to become
'CREATE_COMPLETE, UPDATE_COMPLETE, ...' (last state: 'UPDATE_ROLLBACK_IN_PROGRESS', timeout: 30m0s)

結局、デーモンを API から直接消して脱出しました。削除するとインスタンスがデーモン無しで作り直され、アプリサービスも復帰します。

aws cloudformation cancel-update-stack --stack-name mdaemon-demo-daemon
# それでも進まない場合はデーモンを直接削除する
aws ecs delete-daemon --daemon-arn "$DAEMON_ARN"

デーモンが消えると CloudFormation は UPDATE_ROLLBACK_FAILED に落ちるので、その後 terraform destroy が通ります。詰まるのは「起動できないデーモンを抱えたまま critical にした」場合なので、正常に動くデーモンなら起きない話ではあります。

料金と後片付け

ECS Managed Instances は EC2 インスタンス料金に加えて管理料金がかかります。critical は API パラメータなのでこれ自体の切り替えで課金は変わりませんが、検証が終わったら必ず片付けてください。

terraform destroy

参考リンク

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?