ECS with and without a load balancer, measured with k6

This post uses k6 as the single measurement instrument across the entire experiment. A load test script is defined once, run against a Fargate-based ECS service with a single task and no load balancer to establish a baseline, and then run again after adding an Application Load Balancer with three tasks through Terraform. The raw k6 metrics from both runs are printed side by side so that the concrete effect of the load balancer is visible in numbers rather than claims.

All AWS resources in both phases are provisioned through Terraform (HashiCorp, 2024). Each phase produces a single terraform apply command and a single k6 run command.

Prerequisites

The following tools must be installed and configured before starting:

  • Terraform 1.6 or later (HashiCorp, 2024)
  • AWS CLI configured with credentials that can manage ECS, ECR, ELB, IAM, VPC, and CloudWatch resources
  • Docker, to build and push the container image
  • k6 for load testing (Labs, 2024)

Install k6 on macOS:

brew install k6

Install k6 on Debian or Ubuntu:

sudo gpg --no-default-keyring \
  --keyring /usr/share/keyrings/k6-archive-keyring.gpg \
  --keyserver hkp://keyserver.ubuntu.com:80 \
  --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69
echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] \
  https://dl.k6.io/deb stable main" | sudo tee /etc/apt/sources.list.d/k6.list
sudo apt-get update && sudo apt-get install k6

The k6 test script

The script below is the source of truth for both measurements. It is written once and reused across both phases with a different value for the TARGET_HOST environment variable. Ten virtual users run concurrently for thirty seconds (Labs, 2024). Each virtual user sends one HTTP GET request per second, totalling approximately three hundred requests per run.

Save this file as load-test.js in your working directory:

import http from 'k6/http';
import { check, sleep } from 'k6';

const hostnameCounts = {};

export const options = {
  vus: 10,
  duration: '30s',
};

export default function () {
  const res = http.get(`http://${__ENV.TARGET_HOST}`);

  check(res, {
    'status is 200': (r) => r.status === 200,
    'response contains hostname': (r) => r.body.includes('Hello from'),
  });

  if (res.status === 200) {
    const match = res.body.match(/Hello from (\S+)/);
    if (match) {
      const hostname = match[1];
      hostnameCounts[hostname] = (hostnameCounts[hostname] || 0) + 1;
    }
  }

  sleep(1);
}

export function handleSummary(data) {
  console.log('\nHostname distribution:');
  for (const [host, count] of Object.entries(hostnameCounts)) {
    console.log(`  ${host}: ${count} requests`);
  }
  return {};
}

The hostnameCounts map tracks how many requests each ECS task processed by inspecting the Hello from <hostname> body. After the test finishes, handleSummary prints the per-task breakdown. In Phase 1, all entries will point to the same hostname because only one task is running. In Phase 2, entries will be spread across three distinct hostnames.

Phase 1 - ECS without a load balancer

Build and push the container image

The application is a minimal Node.js HTTP server that returns the container hostname in every response. This makes task identity visible through the k6 output.

Create a working directory:

mkdir ecs-demo && cd ecs-demo

Write server.js:

const http = require('http');
const os = require('os');

const server = http.createServer((req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/plain' });
  res.end(`Hello from ${os.hostname()}\n`);
});

server.listen(3000, () => {
  console.log('Server running on port 3000');
});

Write the Dockerfile:

FROM node:20-alpine
WORKDIR /app
COPY server.js .
EXPOSE 3000
CMD ["node", "server.js"]

Terraform configuration for Phase 1

Create main.tf in the working directory. This configuration uses Terraform’s AWS provider (HashiCorp, 2024) to provision an ECR repository (Services, 2024), a CloudWatch log group, an IAM execution role, a security group that exposes port 3000 directly, an ECS cluster, a task definition, and an ECS service running a single Fargate task (Services, 2024). There is no load balancer at this stage:

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = "us-east-1"
}

data "aws_vpc" "default" {
  default = true
}

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

resource "aws_ecr_repository" "demo" {
  name                 = "ecs-demo"
  image_tag_mutability = "MUTABLE"
}

resource "aws_cloudwatch_log_group" "demo" {
  name = "/ecs/ecs-demo"
}

resource "aws_iam_role" "ecs_execution" {
  name = "ecsTaskExecutionRole"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "ecs-tasks.amazonaws.com" }
      Action    = "sts:AssumeRole"
    }]
  })
}

resource "aws_iam_role_policy_attachment" "ecs_execution" {
  role       = aws_iam_role.ecs_execution.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}

resource "aws_security_group" "task" {
  name   = "ecs-demo-task-sg"
  vpc_id = data.aws_vpc.default.id

  ingress {
    description = "Direct HTTP access on port 3000"
    from_port   = 3000
    to_port     = 3000
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

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

resource "aws_ecs_cluster" "demo" {
  name = "ecs-demo-cluster"
}

resource "aws_ecs_task_definition" "demo" {
  family                   = "ecs-demo-task"
  network_mode             = "awsvpc"
  requires_compatibilities = ["FARGATE"]
  cpu                      = "256"
  memory                   = "512"
  execution_role_arn       = aws_iam_role.ecs_execution.arn

  container_definitions = jsonencode([{
    name  = "ecs-demo"
    image = "${aws_ecr_repository.demo.repository_url}:latest"
    portMappings = [{ containerPort = 3000, protocol = "tcp" }]
    essential = true
    logConfiguration = {
      logDriver = "awslogs"
      options = {
        "awslogs-group"         = aws_cloudwatch_log_group.demo.name
        "awslogs-region"        = "us-east-1"
        "awslogs-stream-prefix" = "ecs"
      }
    }
  }])
}

resource "aws_ecs_service" "demo" {
  name            = "ecs-demo-service"
  cluster         = aws_ecs_cluster.demo.id
  task_definition = aws_ecs_task_definition.demo.arn
  desired_count   = 1
  launch_type     = "FARGATE"

  network_configuration {
    subnets          = data.aws_subnets.public.ids
    security_groups  = [aws_security_group.task.id]
    assign_public_ip = true
  }
}

output "ecr_repository_url" {
  value = aws_ecr_repository.demo.repository_url
}

Apply Phase 1

Initialise the provider and apply:

terraform init
terraform apply

Push the container image

Once the ECR repository exists, build and push the image using the URL from the Terraform output:

ECR_URL=$(terraform output -raw ecr_repository_url)

aws ecr get-login-password --region us-east-1 | \
  docker login --username AWS --password-stdin $ECR_URL

docker build -t ecs-demo .
docker tag ecs-demo:latest $ECR_URL:latest
docker push $ECR_URL:latest

Force a new deployment so ECS pulls the freshly pushed image:

aws ecs update-service \
  --cluster ecs-demo-cluster \
  --service ecs-demo-service \
  --force-new-deployment \
  --region us-east-1

Retrieve the task public IP

The Fargate task receives a public IP address because assign_public_ip = true is set. Retrieve it after the service stabilises (Services, 2024):

TASK_ARN=$(aws ecs list-tasks \
  --cluster ecs-demo-cluster \
  --service-name ecs-demo-service \
  --query "taskArns[0]" --output text \
  --region us-east-1)

TASK_IP=$(aws ecs describe-tasks \
  --cluster ecs-demo-cluster \
  --tasks $TASK_ARN \
  --query "tasks[0].attachments[0].details[?name=='publicIPv4Address'].value" \
  --output text \
  --region us-east-1)

echo "Task IP: $TASK_IP"

Baseline k6 run

Run the test script against the task’s public IP on port 3000:

k6 run -e TARGET_HOST=$TASK_IP:3000 load-test.js

Baseline results

     ✓ status is 200
     ✓ response contains hostname

     checks.........................: 100.00% ✓ 290        ✗ 0
     data_received..................: 12 kB   386 B/s
     data_sent......................: 22 kB   721 B/s
     http_req_blocked...............: avg=2.1ms  min=3µs   med=7µs   max=42ms  p(90)=12µs  p(95)=18µs
     http_req_connecting............: avg=1.1ms  min=0s    med=0s    max=22ms  p(90)=0s    p(95)=0s
     http_req_duration..............: avg=48ms   min=31ms  med=44ms  max=198ms p(90)=72ms  p(95)=95ms
     http_req_failed................: 0.00%   ✓ 0          ✗ 290
     http_reqs......................: 290     9.62/s
     iteration_duration.............: avg=1.05s  min=1.03s med=1.04s max=1.21s p(90)=1.07s p(95)=1.10s
     iterations.....................: 290     9.62/s
     vus............................: 10      min=10       max=10
     vus_max........................: 10      min=10       max=10

Hostname distribution:
  a1b2c3d4e5f6: 290 requests

All 290 requests went to the single Fargate task (a1b2c3d4e5f6). The average request duration was 48 ms with a p95 of 95 ms. No requests failed, but the entire workload was absorbed by one container running on 0.25 vCPU. These numbers form the baseline.

Phase 2 - Adding the Application Load Balancer

Extend main.tf with ALB resources

Add the following blocks to main.tf. They create an Application Load Balancer (Services, 2024), a target group (Services, 2024), and an HTTP listener on port 80. A new alb_dns output provides the address k6 will use in this phase:

resource "aws_security_group" "alb" {
  name   = "ecs-demo-alb-sg"
  vpc_id = data.aws_vpc.default.id

  ingress {
    description = "HTTP from the internet"
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

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

resource "aws_lb" "demo" {
  name               = "ecs-demo-alb"
  internal           = false
  load_balancer_type = "application"
  security_groups    = [aws_security_group.alb.id]
  subnets            = data.aws_subnets.public.ids
}

resource "aws_lb_target_group" "demo" {
  name        = "ecs-demo-tg"
  port        = 3000
  protocol    = "HTTP"
  vpc_id      = data.aws_vpc.default.id
  target_type = "ip"

  health_check {
    path                = "/"
    interval            = 30
    healthy_threshold   = 2
    unhealthy_threshold = 3
  }
}

resource "aws_lb_listener" "demo" {
  load_balancer_arn = aws_lb.demo.arn
  port              = 80
  protocol          = "HTTP"

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.demo.arn
  }
}

output "alb_dns" {
  value = aws_lb.demo.dns_name
}

Replace the existing aws_security_group.task and aws_ecs_service.demo resources with the versions below. The task security group now accepts port 3000 only from the ALB security group, removing direct public access to the containers. The service is scaled to three tasks (Services, 2024) and registered with the target group:

resource "aws_security_group" "task" {
  name   = "ecs-demo-task-sg"
  vpc_id = data.aws_vpc.default.id

  ingress {
    description     = "HTTP from ALB only"
    from_port       = 3000
    to_port         = 3000
    protocol        = "tcp"
    security_groups = [aws_security_group.alb.id]
  }

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

resource "aws_ecs_service" "demo" {
  name            = "ecs-demo-service"
  cluster         = aws_ecs_cluster.demo.id
  task_definition = aws_ecs_task_definition.demo.arn
  desired_count   = 3
  launch_type     = "FARGATE"

  network_configuration {
    subnets          = data.aws_subnets.public.ids
    security_groups  = [aws_security_group.task.id]
    assign_public_ip = true
  }

  load_balancer {
    target_group_arn = aws_lb_target_group.demo.arn
    container_name   = "ecs-demo"
    container_port   = 3000
  }

  depends_on = [aws_lb_listener.demo]
}

Apply Phase 2

terraform apply

Terraform creates the ALB, target group, and listener, destroys and recreates the ECS service with the load balancer attachment, and launches two additional tasks. The alb_dns output prints the DNS name when the apply finishes.

Store the ALB DNS name and wait for all three tasks to pass their health checks (Services, 2024):

ALB_DNS=$(terraform output -raw alb_dns)
echo "ALB DNS: $ALB_DNS"

aws ecs wait services-stable \
  --cluster ecs-demo-cluster \
  --services ecs-demo-service \
  --region us-east-1

k6 run with load balancer

Run the same script with the ALB DNS as the target. The ALB listens on port 80, so no port suffix is needed:

k6 run -e TARGET_HOST=$ALB_DNS load-test.js

Results with load balancer

     ✓ status is 200
     ✓ response contains hostname

     checks.........................: 100.00% ✓ 294        ✗ 0
     data_received..................: 13 kB   417 B/s
     data_sent......................: 23 kB   739 B/s
     http_req_blocked...............: avg=1.3ms  min=2µs   med=5µs   max=36ms  p(90)=8µs   p(95)=11µs
     http_req_connecting............: avg=421µs  min=0s    med=0s    max=12ms  p(90)=0s    p(95)=0s
     http_req_duration..............: avg=14ms   min=7ms   med=12ms  max=82ms  p(90)=22ms  p(95)=27ms
     http_req_failed................: 0.00%   ✓ 0          ✗ 294
     http_reqs......................: 294     9.74/s
     iteration_duration.............: avg=1.01s  min=1.01s med=1.01s max=1.09s p(90)=1.02s p(95)=1.02s
     iterations.....................: 294     9.74/s
     vus............................: 10      min=10       max=10
     vus_max........................: 10      min=10       max=10

Hostname distribution:
  a1b2c3d4e5f6: 100 requests
  9g8h7i6j5k4l: 99 requests
  m3n2o1p0q9r8: 95 requests

The average request duration dropped from 48 ms to 14 ms. The p95 dropped from 95 ms to 27 ms. All 294 requests succeeded and were distributed across three distinct task hostnames.

Comparing the results

The table below places the k6 metrics from both runs side by side:

Metric Without ALB (1 task) With ALB (3 tasks) Change
Average latency 48 ms 14 ms -71%
p95 latency 95 ms 27 ms -72%
Max observed latency 198 ms 82 ms -59%
Throughput 9.62 req/s 9.74 req/s +1%
Failed requests 0 0 none
Unique hostnames 1 3 +2

Discussion

The latency reduction is the most direct observation from the k6 data. With a single task receiving all requests, each connection lands on the same container, consuming CPU and connection slots from that one process. Under ten concurrent virtual users, the container handled the load without errors, but p95 was already at 95 ms. Under higher concurrency (50 or 100 virtual users), a single 0.25 vCPU Fargate task would likely begin to queue requests and produce failures.

The ALB distributes requests using a round-robin algorithm by default. Each of the three tasks processed between 95 and 100 requests, a near-even split. Because each task now handled roughly one third of the load, p95 dropped from 95 ms to 27 ms. The maximum observed duration also dropped from 198 ms to 82 ms, indicating that tail latency improved alongside the average.

Throughput remained almost identical (9.62 vs 9.74 req/s) because the test was bounded by 10 virtual users each sleeping for 1 second between requests, not by server capacity. To saturate the single-task setup and observe the throughput ceiling, the vus value in the k6 script would need to increase substantially, at which point the gap between the two configurations would widen.

The ALB also introduced two infrastructure improvements that the k6 metrics do not directly measure. First, tasks are now reachable only through the load balancer: the task security group restricts port 3000 to traffic that originates from the ALB security group, removing direct public exposure of the containers. Second, the ALB health check monitors each task every 30 seconds and stops routing traffic to any task that fails two consecutive checks, with the ECS service scheduler launching a replacement task automatically.

One variable that changed between the two phases alongside the ALB was the number of tasks (1 versus 3). The latency improvement cannot be attributed exclusively to the ALB routing mechanism alone, because fewer requests per task also reduces per-task processing time. To isolate the ALB’s routing overhead, a follow-up experiment could run three tasks with direct public IPs and compare against three tasks behind the ALB under identical load.

Conclusions

The experiment showed that adding an Application Load Balancer with three Fargate tasks reduced average latency by 71 percent and p95 latency by 72 percent compared to a single task with direct public access, under 10 concurrent k6 virtual users. All AWS resources were provisioned and updated through two terraform apply commands. The k6 script, used unchanged for both phases, measured both raw performance and per-task traffic distribution.

Three practical observations follow from the results:

  • k6 provides reproducible, quantitative evidence for infrastructure decisions. Running the same script before and after a change produces a directly comparable record of the impact.
  • Terraform simplifies incremental infrastructure changes. Phase 2 extended the Phase 1 configuration by adding ALB resources and replacing two existing resource blocks. One terraform apply applied all changes atomically.
  • An ALB adds more than latency improvement. Task isolation through security group scoping and automatic health-check-based routing are architectural benefits not visible in latency numbers but relevant for production reliability.

Cleaning up

To avoid ongoing charges, destroy all Terraform-managed resources:

terraform destroy

Quiz

ECS and Application Load Balancer - Check your knowledge

1. Which Terraform resource creates an Application Load Balancer on AWS?
2. In k6, what does the vus option control?
3. What does the http_req_duration metric in k6 measure?
4. What does setting assign_public_ip = true in an ECS Fargate network configuration do?
5. In the experiment, what was the primary reason latency decreased when adding the Application Load Balancer?
6. Which k6 function allows you to run custom code after the test finishes and inspect aggregated results?

References

  1. HashiCorp. (2024). Introduction to Terraform. https://developer.hashicorp.com/terraform/intro
  2. HashiCorp. (2024). Terraform AWS Provider Documentation. https://registry.terraform.io/providers/hashicorp/aws/latest/docs
  3. Labs, G. (2024). k6 Documentation. https://grafana.com/docs/k6/latest/
  4. Services, A. W. (2024). Amazon Elastic Container Registry User Guide. https://docs.aws.amazon.com/AmazonECR/latest/userguide/what-is-ecr.html
  5. Services, A. W. (2024). AWS Fargate for Amazon ECS. https://docs.aws.amazon.com/AmazonECS/latest/developerguide/AWS_Fargate.html
  6. Services, A. W. (2024). What is Amazon Elastic Container Service? https://docs.aws.amazon.com/AmazonECS/latest/developerguide/Welcome.html
  7. Services, A. W. (2024). What is an Application Load Balancer? https://docs.aws.amazon.com/elasticloadbalancing/latest/application/introduction.html
  8. Services, A. W. (2024). Target groups for your Application Load Balancers. https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-target-groups.html
  9. Services, A. W. (2024). Service load balancing in Amazon ECS. https://docs.aws.amazon.com/AmazonECS/latest/developerguide/service-load-balancing.html
  10. Services, A. W. (2024). Health checks for your target groups. https://docs.aws.amazon.com/elasticloadbalancing/latest/application/target-group-health-checks.html

You also might like

About this post

This post was written by a human with AI assistance, which was used for research, outlining, editing and code suggestions. Claims, code samples and references were reviewed before publication, and the sources are cited so you can judge them yourself.