Skip to content
Scalability on ECS

Scalability on ECS

Written by Claude Code on 2026-10-18, from the load and fault tests run on the dev environment of arglabs-workload between 16 and 18 October 2026: nine rounds, four reports, more than 24 million requests. The module is arglabs-tfmod-ecs-app; arglabs-workload is the template every ARGLabs app repository copies.

How fast an ECS Fargate service adds tasks when the load jumps, what each scaling option of the module changes, and what the app itself has to do. Every number below was measured, on the same service, with the same tests, from one client.

1. Summary

Fast scaling is the option that matters. The policies AWS offers out of the box read one-minute metrics that reach CloudWatch about 3 minutes late, so they react in 3.5 to 5.5 minutes. With fast_scaling_metrics_namespace set and the app publishing three metrics every 10 s, a saturated service went from 2 to 10 tasks in 39 s, all serving in under 2 minutes; with it off, the same test took 4 min 41 s to the first scale-out and 10 min 29 s to 10 healthy tasks. Under a spike of 600 requests in flight, the service answered 3.7 times more requests in the same 6 minutes, with a median of 124 ms against 602 ms.

No scaling option fixes an app that cannot take the load. The first spike lost 1,367 requests to a listen backlog of 5; the second halved the throughput with 300 threads per task fighting over 0.25 vCPU. The final app (one worker, keep-alive, slow requests handed off, a graceful drain) serves about 450 requests a second per task with 45% less CPU per request, and lost no request in any spike after that.

First scale-out, CPU at 100%
39 s
with fast scaling; 4 min 41 s without it, on the same code. The first round's CPU emergency step took 3 min 45 s.
Spike of 600 against 2 tasks
0 errors
1.24 million requests at 3,443 req/s in 6 minutes. The first round lost 1,367 and served 1,316 req/s.
One task, 0.25 vCPU
~450 req/s
at 100% CPU, from about 300 when the fast scaling rounds started. CPU per request went from 246 to 136 µs (measured locally).
Back from 10 to 2 tasks
34 to 38 min
after the load ends: scale-in stays with target tracking, slow by design.

2. The test bench

ItemValue
Servicearglabs-workload in dev, us-east-1: ECS Fargate ARM64 (Graviton2), 0.25 vCPU and 512 MB per task, 100% Fargate Spot, 2 to 10 tasks
Taskthe example app (Python, standard library) plus two sidecars: Fluent Bit (logs) and ADOT (traces); the app container gets 334 MB
Load balancerpublic ALB, least outstanding requests, health check /healthcheck every 5 s with a 2 s timeout
Clientone Mac in Brazil, about 120 ms to us-east-1 (the floor of every latency below), a multi-process generator with keep-alive connections
Observationa collector every 5 s (service, tasks, targets, scaling activities, alarms), a 1 req/s probe (what a user sees), CloudWatch metrics pulled after each test

The tests, each one starting from 2 tasks unless noted:

TestWhat it does
CPUcpuburn on every task, the new ones included, for 8 to 10 minutes, no request load
Memorymemburn=230 (MB) on every task for 9 minutes
Spike600 requests in flight from the first second, for 6 minutes (closed loop: each connection sends the next request when it gets an answer)
Ramp, 10 tasks20 to 600 in flight with the service fixed at 10 tasks: the capacity
Fixed-rate ramp100, 200, 400, 600, 900, 1200 and 1600 req/s (open loop: requests leave on the clock, and leave late when every connection is waiting)
Flood3000 requests that sleep 40 s, all at once
Faults13 ways of killing tasks, a release that never gets ready, a release that answers 500, Spot interruptions

3. Why the default policies are slow

ECS publishes CPUUtilization and MemoryUtilization and the ALB publishes RequestCountPerTarget as one-minute datapoints. In these tests a minute’s datapoint reached the alarm about 3 minutes after that minute ended. Everything built on them inherits the delay:

Policy on AWS metricsWhat it needsMeasured from the start of the load
Target tracking on requests3 datapoints above the target4 min 30 s with 10 req/s against a target of 100 a minute per task; 5 min 31 s to 5 min 43 s with the load three times a target of 1000 from the first second
Target tracking on memory3 datapoints5 min 30 s, then one task at a time (4 tasks at 76% against 70% ask for 5)
CPU emergency step (enable_emergency_step_scaling)1 datapoint3 min 45 s (first round), 4 min 41 s (on/off round); then every step waits for the next late datapoint: 2, 4, 8, 10 tasks over 10 minutes
Request count emergency step (emergency_request_count_threshold)1 datapoint3 min 45 s to 4 min: no sooner than the CPU step, the ALB metric is as late
Scale-in (every target tracking policy)15 datapoints below 90% of the target10 to 2 tasks in 34 to 38 minutes, one task about every 2 minutes

The late data also acts after the facts. A CPU emergency alarm went back to ALARM 1 min 23 s after a load had ended, on a datapoint from 3 minutes before, and took an idle service from 2 to 4 tasks. In the first round, a memory AlarmLow still on from before a memory test let the CPU policy remove two tasks while the memory was at 76%: Application Auto Scaling scales in when every target tracking policy agrees, and one of them was reading the past.

4. Instrumenting the app

Fast scaling moves the signal into the task: the app measures itself and publishes three high-resolution CloudWatch metrics, and the module scales on 10-second alarms over them.

The contract

MetricUnitWhat it is
CpuUtilizationPercentCPU of the whole task (every container, sidecars included) as a percentage of the task’s vCPU, from the task metadata endpoint’s /task/stats, as the ECS metric computes it
MemoryUtilizationPercentmemory of the whole task without the reclaimable file cache (inactive_file), as a percentage of the task’s memory
RequestsInFlightCountrequests being answered plus the connections waiting in the kernel’s accept queue, sampled every second and published as a statistic set (sample count, sum, minimum, maximum)
  • Namespace and dimensions are the contract. The module injects METRICS_NAMESPACE (<scope>/<app>), METRICS_INTERVAL (10), METRICS_DIMENSIONS (ClusterName=<cluster>,ServiceName=<service>) and METRICS_BURST_THRESHOLD. Every task publishes under the same dimensions, so an alarm’s statistic over a period is a statistic over the tasks; the module’s alarms use the median (p50).
  • StorageResolution 1, every 10 s, from a thread of its own.
  • Publish at once on a burst. When the requests in flight reach METRICS_BURST_THRESHOLD (the module sets it from fast_scaling_inflight_threshold), the publisher does not wait for the end of its interval, at most once every 3 s: the alarm sees the spike in the period it starts in, about 10 s sooner.
  • Stay quiet for the first 20 s. The sidecars and the interpreter start with a burst of CPU (81% of the task over the first interval): published, every new task would read as saturated and every deployment would scale out.
  • Count the queue. A request waiting in the accept queue is load the task has not touched yet; leaving it out makes a saturated task look idle.

The module grants cloudwatch:PutMetricData to the task role with the condition cloudwatch:namespace equal to the namespace (the API takes no resource ARN). The essentials of what the example app does, as a sketch without error handling:

import json, os, threading, time, urllib.request
import boto3

NAMESPACE = os.environ.get("METRICS_NAMESPACE", "")
INTERVAL = float(os.environ.get("METRICS_INTERVAL", "10"))
BURST = int(os.environ.get("METRICS_BURST_THRESHOLD") or 0)
DIMS = [{"Name": k, "Value": v} for k, v in (p.split("=", 1)
        for p in os.environ.get("METRICS_DIMENSIONS", "").split(",") if "=" in p)]
in_flight = 0  # the request handler adds 1 on entry and subtracts 1 on exit (under a lock)


def meta(path):
    with urllib.request.urlopen(os.environ["ECS_CONTAINER_METADATA_URI_V4"] + path, timeout=2) as r:
        return json.load(r)


def publish_forever():
    cw = boto3.client("cloudwatch")
    limits = meta("/task")["Limits"]  # {"CPU": 0.25, "Memory": 512}
    started, last, prev, samples = time.monotonic(), time.monotonic(), None, []
    while True:
        time.sleep(1)
        samples.append(in_flight)  # plus the accept queue, if the server has one
        now = time.monotonic()
        burst = BURST and samples[-1] >= BURST and now - last >= 3
        if now - last < INTERVAL and not burst:
            continue
        last = now
        stats = [c for c in meta("/task/stats").values() if c]
        cpu_ns = sum(c["cpu_stats"]["cpu_usage"]["total_usage"] for c in stats)
        mem = sum(c["memory_stats"]["usage"] - c["memory_stats"]["stats"].get(
            "total_inactive_file", c["memory_stats"]["stats"].get("inactive_file", 0)) for c in stats)
        data = [{"MetricName": "RequestsInFlight", "Dimensions": DIMS, "Unit": "Count",
                 "StorageResolution": 1,
                 "StatisticValues": {"SampleCount": len(samples), "Sum": sum(samples),
                                     "Minimum": min(samples), "Maximum": max(samples)}}]
        if prev and now - started >= max(2 * INTERVAL, 20):
            cpu = 100 * (cpu_ns - prev[0]) / ((now - prev[1]) * 1e9) / limits["CPU"]
            for name, value in (("CpuUtilization", cpu),
                                ("MemoryUtilization", 100 * mem / (limits["Memory"] * 2**20))):
                data.append({"MetricName": name, "Dimensions": DIMS, "Unit": "Percent",
                             "StorageResolution": 1, "Value": round(min(value, 100), 2)})
        prev, samples = (cpu_ns, now), []
        cw.put_metric_data(Namespace=NAMESPACE, MetricData=data)


if NAMESPACE:
    threading.Thread(target=publish_forever, daemon=True).start()

Any language works: the contract is the three names, their units and the dimensions. An app that publishes nothing leaves the alarms without data (notBreaching), so nothing happens and nothing breaks; for that app, keep fast scaling off and the CPU emergency step on.

What it costs, at us-east-1 list prices: one PutMetricData call per task every 10 s is about 259,000 calls a month, USD 2.60 per task-month at USD 0.01 per 1,000; plus three custom metrics and three high-resolution alarms per service and region, USD 1.80 a month. That is about what a 0.25 vCPU Spot task costs; a 30 s interval divides the calls by three, and the alarms then use 30 s periods.

5. The options, one by one

Module inputs of arglabs-tfmod-ecs-app, the default, the value in arglabs-workload (dev), and what each one did in the tests.

Fast scaling

InputDefaultWorkloadWhat it did
fast_scaling_metrics_namespace"" (off)<scope>/<app> (enable_fast_scaling per env)Turns everything below on: the environment variables, the IAM grant, a step policy per metric and the target tracking on the app’s metrics. First scale-out 32 to 52 s after a saturating load started, against 3.5 to 5.5 minutes
fast_scaling_metrics_interval1010Seconds between publications; alarm periods of 10 s up to 10, 30 s up to 30, 60 s above
fast_scaling_evaluation_periods / fast_scaling_datapoints_to_alarm3 / 21 / 1With 2 of 3, Application Auto Scaling aggregates the three datapoints, the low ones included, and an aggregate under the threshold found no step (“No step adjustment found for metric value”): the first scale-out waited a minute. The module now has steps below the threshold too. 1 of 1 fires on the first 10 s datapoint over the threshold; CloudWatch evaluates a period about 17 s after it ends
fast_scaling_statisticp50p50A new task reads low until it gets traffic and pulled an Average under the threshold while the loaded tasks stayed saturated; the median keeps reading them
fast_scaling_step_percent100200Tasks added per firing, as a percentage of the running ones: 200 triples the service (2, 6, 10)
fast_scaling_saturated_step_percent0 (off)400Added instead far above the thresholds (CPU or memory 20 points over the target, twice the in-flight threshold): 2 to 10 in one step on CPU and on a spike, in 36 to 49 s
fast_scaling_inflight_threshold0 (CPU and memory only)8Requests in flight per task that fire the third alarm; also the burst threshold of the publisher
fast_scaling_cooldown6060Seconds between two fast scale-outs: new tasks need about a minute to take traffic, and a shorter cooldown would multiply the service again before they do
fast_scaling_target_trackingtruetrueCPU and memory target tracking (scale-in included) read the app’s metrics instead of the ECS ones. On the ECS ones, a memory AlarmLow still on from before a spike removed a task 2 min 20 s into it

Policies on AWS metrics

InputDefaultWorkloadWhat it did
cpu_target_value / mem_target_value70 / 7070 / 70Target tracking targets, and the thresholds of the fast CPU and memory alarms
request_count_target_value0 (off)1000Requests per minute per task. 100 took the service to 10 tasks with 10 req/s, which 2 tasks served at under 5% CPU, and kept it there for half an hour. 6000 (100 req/s, a third of a task) left a 10 req/s load on 2 tasks. Size it from a measured task, not a guess
enable_emergency_step_scalingtrueoff where fast scaling is onA CPU step on one ECS datapoint, +50%, +100% above 85%: 3 min 45 s to 4 min 41 s to fire, and it fires again after the load is gone. With fast scaling on it adds nothing but late scale-outs
emergency_request_count_threshold0 (off)0The same step on the ALB’s requests per task: it fired 3 min 45 s to 4 min into a spike, no sooner than the CPU one
scale_out_cooldown_value / scale_in_cooldown_value10 / 6030 / 30Between two target tracking actions; scale-in is still bound by its 15 datapoints
min_capacity_value / max_capacity_valuerequired2 / 10The fast steps go straight to the maximum on a saturating spike: max_capacity is the cost ceiling

Around the scaling: load balancer and deployments

InputDefaultWorkloadWhat it did
deregistration_delay55With 0, a task leaving in the middle of a spike (a real Spot interruption, then a stop-task) cut the requests in flight on it: 21 and 33 502s. With 5 s, and the app draining its queue on SIGTERM, 0
load_balancing_algorithm_typeleast_outstanding_requeststhe sameSends more traffic to a target that fails fast (a dead process answers 502 at once and has no outstanding requests): half the requests failed in the 7 to 10 s before the health check removed it. It avoids a hung or slow task well (1 failed request)
load_balancing_anomaly_mitigation (with weighted_random)falsefalseTried in dev: it cut the errors of a task answering 500 with a green health check by 87%, but needs 3 healthy targets, and with 10 tasks one saturated task kept its 10% share and half the requests in flight: 15% and 31% less throughput at 400 and 600 in flight. Back to least outstanding requests
deployment_bake_time_in_minutes + deployment_module_alarms0 / []5 / ["http_5xx", "target_response_time"]ECS checks deployment alarms only during the bake time, which is 0 on ROLLING by default: a release answering 500 finished its deployment 2 min 45 s before the 5xx alarm fired. With 5 minutes it rolled back by itself, 3 min 23 s after the first error. The module now sends the bake time on ROLLING when there are alarms
http_5xx_threshold / target_response_time_threshold0 (off)10 a minute / 1 sWith 0, failrate=1 answered 500 to every request with every target healthy and no alarm. With them on, the 5xx alarm fired 2 min 48 s to 3 min 40 s after the first errors

6. Fast scaling on and off, measured

The same three tests, run with enable_fast_scaling on and off in dev, on the same app image and the same configuration: only the option changed between the two rounds.

Until the first scale-outFrom the first scale-out to 10 healthy tasks
CPU at 100%, fast scaling on
first scale-out at 0:39
1:57
CPU at 100%, fast scaling off
first scale-out at 4:41
10:29
Ramp 100 to 1600 req/s, fast scaling on
first scale-out at 3:47
5:21
Ramp 100 to 1600 req/s, fast scaling off
first scale-out at 5:43
6:41
Spike of 600 in flight, fast scaling on
first scale-out at 0:43
2:18
Spike of 600 in flight, fast scaling off
first scale-out at 4:41
6:25
Minutes from the start of the load to 10 healthy tasks; the first segment ends at the first scale-out. In the ramp the first scale-out also depends on when the load crosses a threshold: CPU passed 70% in the 600 req/s step. Hover a segment for its value.
TestFast scaling onFast scaling off
CPU at 100%, no requests2 to 10 in one step at 39 s (fast-cpu, saturated +400%); 10 healthy at 1 min 57 scpu-emergency at 4 min 41 s (4 tasks), 6 min 41 s (8) and 9 min 41 s (10); 10 healthy at 10 min 29 s
Fixed-rate ramp, 100 to 1600 req/sfast-cpu at 3 min 47 s (the 600 req/s step); p95 125 ms in every step but 900 (199 ms); 8 requests over 1 s in 432,0002 tasks until target tracking on requests took 10 at 5 min 43 s: at 1200 req/s p50 507 ms, p95 1,027 ms, and the client up to 17.8 s late; 5,940 requests over 1 s
Spike of 600 for 6 minutes10 tasks asked at 43 s: 1,239,639 requests, 3,443 req/s, p50 / p95 124 / 486 ms2 tasks for 4 min 41 s, 10 asked at 5 min 40 s: 336,868 requests, 936 req/s, p50 / p95 602 / 1,092 ms, 9.4% over 1 s
Errors (load, probe, ALB 5xx)00
Task-minutes during the test (CPU / ramp / spike)92 / 49 / 5243 / 37 / 15

Nothing failed with fast scaling off: this app queues instead of refusing, so a shortage of tasks shows as latency and throughput, not as errors, as long as it stays under the client’s timeout. An app with tighter timeouts, or one that refuses connections, would turn those minutes into errors. The price of fast scaling is capacity used sooner: the same 10 tasks, minutes earlier. The same option measured twice, on two app versions, moved by up to 11 s for the first scale-out and 33 s for 10 healthy tasks: noise well under the difference above.

7. Inside the task: what the app had to change

The spike of 600 against 2 tasks, the same test after each change. The first two rows are the first rounds, without fast scaling; from T1 on, fast scaling is on.

RunWhat changedFirst scale-out10 tasks askedErrorsreq/sp50 / p95 ms
Round 1stdlib HTTPServer: listen backlog 5, HTTP/1.03 min 30 s4 min 35 s1,367 (504 at 10.1 s)1,316305 / 666
Round 2backlog 1024, keep-alive, a thread per connection3 min 17 s7 min 22 s0637148 / 2,403
T1fast scaling, 2 of 3 datapoints, step +50% / +100%; 8 threads per task1 min 03 s4 min 46 s196 (timeouts, 502)1,658300 / 863
T1b+ step +100%, median, target tracking on the app’s metrics1 min 04 s4 min 45 s3081,406302 / 922
T1c+ 2 requests at a time, a fair queue, step +200%1 min 52 s5 min 05 s01,126415 / 1,208
T1d+ one worker thread, slow requests in threads of their own, saturated step +400%54 s1 min 06 s21 (502, Spot interruption)1,927220 / 1,111
T1e+ compact JSON, drain on SIGTERM57 s2 min 50 s33 (502, a task stopped)2,054204 / 1,113
T1f+ deregistration_delay 5 in dev52 s1 min 04 s02,050209 / 1,105
Round 5+ keep-alive on the worker, one write per answer, burst publish, 1 of 152 s57 s03,621123 / 495
Round 7the same code on a fresh stack49 s49 s03,733122 / 494
On/off, onJSON indented again (Python 3.14 encodes it in C), request target 100043 s43 s03,443124 / 486
On/off, offthe same code with enable_fast_scaling off4 min 41 s5 min 40 s0936602 / 1,092

What each step taught, for any app behind an ALB:

  • Check the listen backlog and keep-alive before a load test. Python’s HTTPServer listens with a backlog of 5 and answers HTTP/1.0, so the ALB opens a connection per request; at about 470 new connections a second per task the queue overflowed, the kernel dropped SYNs, and the ALB answered 504 after its 10 s connect timeout. No target ever went unhealthy.
  • Unlimited concurrency is worse than a queue. With a backlog of 1024, the ALB put all 600 requests inside the 2 tasks: about 300 Python threads per task on 0.25 vCPU fought over the interpreter, each request cost twice the CPU, and the 2 s health check timed out mid-spike. The old backlog of 5 had been admission control by accident.
  • Keep the accepting thread fed. With 8 worker threads, the one accepting connections waited behind them and new connections sat up to 10 s unaccepted. One worker thread, with requests that sleep handed to threads of their own, removed it.
  • Turn off Nagle with keep-alive. Headers and body in two writes waited for the ALB’s delayed ACK: 40 ms on every answer. TCP_NODELAY, then one write per answer.
  • Drain on SIGTERM, and let the ALB drain. Deploys, scale-in and Spot interruptions stop tasks with SIGTERM; the app keeps accepting until the kernel’s queue is empty, and deregistration_delay gives the requests in flight 5 s.
  • Measure CPU per request where it runs. A local profile on Python 3.9 blamed indented JSON for 30% of a request; on Python 3.14 and later, the image’s, indent= uses the C encoder too and costs about 0.5 µs. An unpinned base image changes the CPU per request with no code change.
  • Watch the file descriptor limits of old APIs. select.select() fails above 1,024 descriptors: 3000 slow requests made every request fail, health checks included. poll() has no such limit.

Capacity with the service fixed at 10 tasks, req/s at 300, 400 and 600 in flight: 2,390, 3,083 and 3,537 in the first round; 2,404, 3,205 and 4,679 with the final code. Below 300 in flight the throughput is the concurrency divided by the network latency, the same in every version.

8. Traps

  • A step alarm still in ALARM fires again. Setting the desired count by hand after a test was undone by an alarm that had not gone back to OK. Wait for the alarms first.
  • Late datapoints scale the next test. With fast scaling off, wait about 4 minutes after a load before measuring anything else, or the previous load scales it.
  • Deployment alarms see the past too. A deploy started right after a flood of 40 s requests was rolled back: the response time alarm caught the flood two minutes late, inside the bake time. Deploy once the alarms are OK.
  • HealthyHostCount misses short outages. The ALB publishes it with 2 samples a minute: 40 s without a healthy target never showed in the metric, a full minute did.
  • Logs lag when the CPU is saturated. Fluent Bit shares the task’s 0.25 vCPU: only 13% of the lines reached CloudWatch in their own minute, the rest up to 5 minutes later. None were lost; filter by the @timestamp inside the JSON, not by CloudWatch’s.
  • A new task needs about a minute. About 19 s from creation to the image pull, 30 to 60 s of pulls (the Fluent Bit image is 113 MB compressed), then two health checks: smaller images are the next gain.
  • Spot interruptions are cheap once the task drains. Five real ones during the tests. The one in the middle of a spike, before the 5 s drain, cut 21 requests in flight; the other four cost none: the replacement started a second after the notice and was healthy in 40 to 50 s.

9. The configuration that came out of it

The module inputs as arglabs-workload sets them in dev, through its locals:

module "ecs_app" {
  source = "git::https://bitbucket.org/arglabs/arglabs-tfmod-ecs-app.git//terraform?ref=main"
  # ...
  min_capacity_value         = 2
  max_capacity_value         = 10
  cpu_target_value           = 70
  mem_target_value           = 70
  request_count_target_value = 1000 # requests a minute per task: size it from a measured task

  # Fast scaling: the app publishes CpuUtilization, MemoryUtilization and RequestsInFlight
  fast_scaling_metrics_namespace      = "aio/workload"
  fast_scaling_metrics_interval       = 10
  fast_scaling_evaluation_periods     = 1
  fast_scaling_datapoints_to_alarm    = 1
  fast_scaling_statistic              = "p50"
  fast_scaling_inflight_threshold     = 8
  fast_scaling_step_percent           = 200
  fast_scaling_saturated_step_percent = 400
  fast_scaling_cooldown               = 60
  fast_scaling_target_tracking        = true

  # The steps on late metrics only where fast scaling is off
  enable_emergency_step_scaling     = false
  emergency_request_count_threshold = 0

  # Draining, and deployments that roll back on errors
  deregistration_delay            = 5
  deployment_bake_time_in_minutes = 5
  deployment_module_alarms        = ["http_5xx", "target_response_time"]
  http_5xx_threshold              = 10
  target_response_time_threshold  = 1
}

For an app that cannot publish the metrics: leave fast_scaling_metrics_namespace empty, keep enable_emergency_step_scaling on, and set request_count_target_value from a measured task; expect 4 to 6 minutes to the first scale-out.

10. Method and limits

  • One client, in Brazil, about 120 ms from the service: client-side throughput includes that latency; the ALB’s TargetResponseTime gave the server’s side.
  • Fargate Spot on Graviton2, different days and hours: the first round’s ramp numbers came partly from that day’s hardware. Its image, run again on the later tasks, did 2,308, 2,540 and 2,584 req/s at 300, 400 and 600 in flight, against 2,390, 3,083 and 3,537 that day.
  • One run per configuration in most tests; the variation between runs of the same code is in the text where it was measured.
  • Scale-in was not measured after the first round: between tests the desired count went back to 2 by hand, once the scaling alarms were OK.
  • Dev only: stg and prd run the same code with other values (prd keeps 2 on-demand tasks before Spot).
Last updated on