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 applyapplied 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
References
- HashiCorp. (2024). Introduction to Terraform. https://developer.hashicorp.com/terraform/intro
- HashiCorp. (2024). Terraform AWS Provider Documentation. https://registry.terraform.io/providers/hashicorp/aws/latest/docs
- Labs, G. (2024). k6 Documentation. https://grafana.com/docs/k6/latest/
- Services, A. W. (2024). Amazon Elastic Container Registry User Guide. https://docs.aws.amazon.com/AmazonECR/latest/userguide/what-is-ecr.html
- Services, A. W. (2024). AWS Fargate for Amazon ECS. https://docs.aws.amazon.com/AmazonECS/latest/developerguide/AWS_Fargate.html
- Services, A. W. (2024). What is Amazon Elastic Container Service? https://docs.aws.amazon.com/AmazonECS/latest/developerguide/Welcome.html
- Services, A. W. (2024). What is an Application Load Balancer? https://docs.aws.amazon.com/elasticloadbalancing/latest/application/introduction.html
- Services, A. W. (2024). Target groups for your Application Load Balancers. https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-target-groups.html
- Services, A. W. (2024). Service load balancing in Amazon ECS. https://docs.aws.amazon.com/AmazonECS/latest/developerguide/service-load-balancing.html
- 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.