<< BACK

Dual Target Group Registration for ECS Fargate

how to register the same ECS service with two independent target groups simultaneously — the CDK implementation, security group requirements, and lifecycle mechanics that make zero-downtime ALB migrations work.

DATE:
JUN.22.2025
READ:
14 MIN

Migrating traffic from one load balancer to another is one of the riskiest infrastructure changes you can make. DNS cutover timing, connection draining, health check states — there are a dozen ways it can go wrong. But what if your ECS tasks could serve traffic from both load balancers simultaneously?

That’s exactly what dual target group registration gives you: a single ECS Fargate service registered with two independent target groups, each behind its own ALB, handling traffic at the same time with zero downtime. This post covers the CDK implementation, how ECS manages dual registration, the security group requirements, and the lifecycle mechanics you need to understand.


The scenario

Our ECS Fargate services were originally deployed using ApplicationLoadBalancedFargateService — the high-level CDK construct that creates an ALB, target group, listener, and ECS service in one shot. Each service had its own public-facing ALB.

We needed to consolidate these behind a single shared internal ALB fronted by API Gateway. But we couldn’t simply switch over — the existing ALBs were serving production traffic through DNS records and couldn’t be removed until the new path was fully validated.

The solution: register the same ECS tasks with both the original target group and a new target group attached to the shared ALB’s listener. Both paths work simultaneously. Traffic flows through either path. If the new path has issues, the old path is completely unaffected.


The CDK implementation

Step 1: The original service (already deployed)

++
from aws_cdk import (
    Stack,
    aws_ecs as ecs,
    aws_ecs_patterns as ecs_patterns,
    aws_ec2 as ec2,
    aws_elasticloadbalancingv2 as elbv2,
)

class BackendStackQA(Stack):
    def __init__(self, scope, construct_id, **kwargs):
        super().__init__(scope, construct_id, **kwargs)

        vpc = ec2.Vpc(self, "VpcQA", max_azs=2)
        cluster = ecs.Cluster(self, "ClusterQA", vpc=vpc)

        fargate_service = ecs_patterns.ApplicationLoadBalancedFargateService(
            self, "BackendServiceQA",
            cluster=cluster,
            cpu=512,
            memory_limit_mib=1024,
            desired_count=2,
            task_image_options=ecs_patterns.ApplicationLoadBalancedTaskImageOptions(
                image=ecs.ContainerImage.from_ecr_repository(ecr_repo, tag="qa"),
                container_port=8000,
            ),
            public_load_balancer=True,
        )
++

Step 2: Add the second target group

The key method is service.attach_to_application_target_group(). This registers the existing ECS service with a new target group without modifying the original:

++
        from utils import get_shared_alb_listener_arn, get_shared_alb_sg_id

        shared_listener_arn = get_shared_alb_listener_arn(self)
        shared_alb_sg_id = get_shared_alb_sg_id(self)

        shared_alb_sg = ec2.SecurityGroup.from_security_group_id(
            self, "SharedAlbSg", shared_alb_sg_id,
        )

        # Create a new target group for the shared ALB
        backend_tg = elbv2.ApplicationTargetGroup(
            self, "BackendSharedTg",
            vpc=vpc,
            port=8000,
            protocol=elbv2.ApplicationProtocol.HTTP,
            target_type=elbv2.TargetType.IP,
            health_check=elbv2.HealthCheck(
                path="/api/health/",
                port="8000",
                healthy_http_codes="200",
                interval=Duration.seconds(30),
                timeout=Duration.seconds(10),
                healthy_threshold_count=2,
                unhealthy_threshold_count=3,
            ),
        )

        # Register the existing ECS service with the new target group
        fargate_service.service.attach_to_application_target_group(backend_tg)

        # CRITICAL: Allow the shared ALB to reach the ECS tasks
        fargate_service.service.connections.allow_from(
            shared_alb_sg,
            ec2.Port.tcp(8000),
            "Shared ALB to backend",
        )

        # Attach the target group to the shared ALB listener via a rule
        shared_listener = elbv2.ApplicationListener.from_application_listener_attributes(
            self, "ImportedSharedListener",
            listener_arn=shared_listener_arn,
            security_group=shared_alb_sg,
        )

        elbv2.ApplicationListenerRule(
            self, "BackendQaRoutingRule",
            listener=shared_listener,
            priority=100,
            conditions=[
                elbv2.ListenerCondition.path_patterns(["/api/qa/*"]),
            ],
            target_groups=[backend_tg],
        )
++

When you deploy this, CloudFormation creates the new target group, updates the ECS service to include it in its loadBalancers configuration, creates the security group rule, and creates the listener rule. The ECS service update triggers a rolling deployment where new tasks are registered in both target groups, and old tasks are deregistered from both.


How ECS manages dual registration

Independent health checks

Each target group runs its own health checks independently:

  • The original target group might check /health every 15 seconds
  • The shared target group might check /api/health/ every 30 seconds
  • A task can be healthy in one target group and unhealthy in another

This independence is important. If the shared ALB’s health check path is misconfigured, tasks will be marked unhealthy in the shared target group but continue serving traffic through the original ALB. Your existing traffic is never disrupted by the new target group’s configuration.

Task registration lifecycle

When ECS launches a new task:

  1. Task starts and gets an ENI with a private IP
  2. ECS registers the task IP with every target group in the service’s loadBalancers list
  3. Each target group begins its own independent health check sequence
  4. The task becomes healthy in each target group independently

When ECS drains a task (during deployment or scale-in):

  1. ECS marks the task as DRAINING in all target groups simultaneously
  2. Each target group starts its own deregistration delay (default: 300 seconds)
  3. The task is removed from each target group after its respective delay
  4. ECS stops the task only after all target groups have deregistered it

Under the hood: the loadBalancers array

The ECS service’s loadBalancers configuration contains an entry per target group:

++
{
  "loadBalancers": [
    {
      "targetGroupArn": "arn:aws:...original-tg...",
      "containerName": "web",
      "containerPort": 8000
    },
    {
      "targetGroupArn": "arn:aws:...shared-tg...",
      "containerName": "web",
      "containerPort": 8000
    }
  ]
}
++

Both entries reference the same container and port. ECS uses this to know which target groups to register each task with.


Security group requirements

This is where most implementations break. When you have a single ECS service behind two ALBs, the service’s security group must allow ingress from both ALB security groups.

The ApplicationLoadBalancedFargateService construct automatically creates an ingress rule from the original ALB’s security group. But the shared ALB’s security group is managed by the shared infrastructure stack. You must explicitly add the rule:

++
# This is what attach_to_application_target_group does NOT do automatically
# when the ALB is in a different stack
fargate_service.service.connections.allow_from(
    shared_alb_sg,
    ec2.Port.tcp(8000),
    "Shared ALB to backend",
)
++

Without this rule, the shared ALB’s health checks fail — packets never reach the task — and all targets show as unhealthy. The original ALB continues working fine.

Debugging security group issues

When targets show as unhealthy in only one target group, check security groups first:

++
# Get the ECS service's security group
aws ecs describe-services 
  --cluster ClusterQA 
  --services BackendServiceQA 
  --query "services[0].networkConfiguration.awsvpcConfiguration.securityGroups"

# Check its ingress rules — you should see two rules on port 8000
aws ec2 describe-security-groups 
  --group-ids sg-ecs-service-id 
  --query "SecurityGroups[0].IpPermissions"
++

Limits and considerations

Five target groups per service — ECS’s hard limit. Cannot be increased. In practice, two or three (original ALB + shared ALB, possibly an NLB for gRPC) is common.

Health check alignment matters. Longer health check intervals or higher thresholds slow down rolling deployments. Optimize shared target group health checks:

++
health_check=elbv2.HealthCheck(
    interval=Duration.seconds(15),
    timeout=Duration.seconds(5),
    healthy_threshold_count=2,
    unhealthy_threshold_count=2,
)
++

IP target type is required for Fargate. Fargate uses awsvpc networking — each task gets its own ENI. Target groups must use IP target type, not INSTANCE. The L3 construct handles this for the original target group, but you must set it explicitly for additional groups:

++
backend_tg = elbv2.ApplicationTargetGroup(
    self, "BackendSharedTg",
    target_type=elbv2.TargetType.IP,  # Required for Fargate
    ...
)
++

Beyond migration: other use cases

Blue/green deployments

Register the green ECS service with a separate target group. Use weighted forwarding to gradually shift traffic:

++
elbv2.ApplicationListenerRule(
    self, "GreenWeightedRule",
    listener=shared_listener,
    priority=90,
    conditions=[elbv2.ListenerCondition.path_patterns(["/api/*"])],
    action=elbv2.ListenerAction.weighted_forward(
        target_groups=[
            elbv2.WeightedTargetGroup(target_group=blue_tg, weight=90),
            elbv2.WeightedTargetGroup(target_group=green_tg, weight=10),
        ],
    ),
)
++

A/B testing

Register the same service with two ALBs serving different audiences. The public ALB handles customer traffic; an internal ALB handles QA traffic with debug headers enabled.

Multi-protocol support

Register the same service with an ALB target group (HTTP) and an NLB target group (TCP). This allows the service to receive both HTTP traffic through API Gateway and raw TCP connections for WebSocket or gRPC.


Removing the original target group

Once the new path is validated and DNS has cut over, remove the original ALB. But you can’t simply delete the ApplicationLoadBalancedFargateService construct — it owns the ECS service itself.

Options:

  1. Migrate the stack to use a standalone ecs.FargateService with only the shared target group, using RemovalPolicy.RETAIN on the original ALB resources during transition, then delete the retained resources manually.

  2. If you used dual registration from the start, simply remove the original target group attachment and the original ALB stack. The ECS service continues serving through the shared target group without interruption.

Monitor both target groups with UnHealthyHostCount alarms before making any removal decisions:

++
backend_tg.metric_unhealthy_host_count().create_alarm(
    self, "SharedTgUnhealthyHosts",
    evaluation_periods=2,
    threshold=1,
    comparison_operator=cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
)
++