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.
2. The test bench
| Item | Value |
|---|---|
| Service | arglabs-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 |
| Task | the example app (Python, standard library) plus two sidecars: Fluent Bit (logs) and ADOT (traces); the app container gets 334 MB |
| Load balancer | public ALB, least outstanding requests, health check /healthcheck every 5 s with a 2 s timeout |
| Client | one Mac in Brazil, about 120 ms to us-east-1 (the floor of every latency below), a multi-process generator with keep-alive connections |
| Observation | a 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:
| Test | What it does |
|---|---|
| CPU | cpuburn on every task, the new ones included, for 8 to 10 minutes, no request load |
| Memory | memburn=230 (MB) on every task for 9 minutes |
| Spike | 600 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 tasks | 20 to 600 in flight with the service fixed at 10 tasks: the capacity |
| Fixed-rate ramp | 100, 200, 400, 600, 900, 1200 and 1600 req/s (open loop: requests leave on the clock, and leave late when every connection is waiting) |
| Flood | 3000 requests that sleep 40 s, all at once |
| Faults | 13 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 metrics | What it needs | Measured from the start of the load |
|---|---|---|
| Target tracking on requests | 3 datapoints above the target | 4 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 memory | 3 datapoints | 5 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 datapoint | 3 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 datapoint | 3 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 target | 10 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
| Metric | Unit | What it is |
|---|---|---|
CpuUtilization | Percent | CPU 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 |
MemoryUtilization | Percent | memory of the whole task without the reclaimable file cache (inactive_file), as a percentage of the task’s memory |
RequestsInFlight | Count | requests 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>) andMETRICS_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). StorageResolution1, 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 fromfast_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
| Input | Default | Workload | What 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_interval | 10 | 10 | Seconds 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_alarm | 3 / 2 | 1 / 1 | With 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_statistic | p50 | p50 | A 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_percent | 100 | 200 | Tasks added per firing, as a percentage of the running ones: 200 triples the service (2, 6, 10) |
fast_scaling_saturated_step_percent | 0 (off) | 400 | Added 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_threshold | 0 (CPU and memory only) | 8 | Requests in flight per task that fire the third alarm; also the burst threshold of the publisher |
fast_scaling_cooldown | 60 | 60 | Seconds 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_tracking | true | true | CPU 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
| Input | Default | Workload | What it did |
|---|---|---|---|
cpu_target_value / mem_target_value | 70 / 70 | 70 / 70 | Target tracking targets, and the thresholds of the fast CPU and memory alarms |
request_count_target_value | 0 (off) | 1000 | Requests 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_scaling | true | off where fast scaling is on | A 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_threshold | 0 (off) | 0 | The 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_value | 10 / 60 | 30 / 30 | Between two target tracking actions; scale-in is still bound by its 15 datapoints |
min_capacity_value / max_capacity_value | required | 2 / 10 | The fast steps go straight to the maximum on a saturating spike: max_capacity is the cost ceiling |
Around the scaling: load balancer and deployments
| Input | Default | Workload | What it did |
|---|---|---|---|
deregistration_delay | 5 | 5 | With 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_type | least_outstanding_requests | the same | Sends 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) | false | false | Tried 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_alarms | 0 / [] | 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_threshold | 0 (off) | 10 a minute / 1 s | With 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.
| Test | Fast scaling on | Fast scaling off |
|---|---|---|
| CPU at 100%, no requests | 2 to 10 in one step at 39 s (fast-cpu, saturated +400%); 10 healthy at 1 min 57 s | cpu-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/s | fast-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,000 | 2 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 minutes | 10 tasks asked at 43 s: 1,239,639 requests, 3,443 req/s, p50 / p95 124 / 486 ms | 2 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) | 0 | 0 |
| Task-minutes during the test (CPU / ramp / spike) | 92 / 49 / 52 | 43 / 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.
| Run | What changed | First scale-out | 10 tasks asked | Errors | req/s | p50 / p95 ms |
|---|---|---|---|---|---|---|
| Round 1 | stdlib HTTPServer: listen backlog 5, HTTP/1.0 | 3 min 30 s | 4 min 35 s | 1,367 (504 at 10.1 s) | 1,316 | 305 / 666 |
| Round 2 | backlog 1024, keep-alive, a thread per connection | 3 min 17 s | 7 min 22 s | 0 | 637 | 148 / 2,403 |
| T1 | fast scaling, 2 of 3 datapoints, step +50% / +100%; 8 threads per task | 1 min 03 s | 4 min 46 s | 196 (timeouts, 502) | 1,658 | 300 / 863 |
| T1b | + step +100%, median, target tracking on the app’s metrics | 1 min 04 s | 4 min 45 s | 308 | 1,406 | 302 / 922 |
| T1c | + 2 requests at a time, a fair queue, step +200% | 1 min 52 s | 5 min 05 s | 0 | 1,126 | 415 / 1,208 |
| T1d | + one worker thread, slow requests in threads of their own, saturated step +400% | 54 s | 1 min 06 s | 21 (502, Spot interruption) | 1,927 | 220 / 1,111 |
| T1e | + compact JSON, drain on SIGTERM | 57 s | 2 min 50 s | 33 (502, a task stopped) | 2,054 | 204 / 1,113 |
| T1f | + deregistration_delay 5 in dev | 52 s | 1 min 04 s | 0 | 2,050 | 209 / 1,105 |
| Round 5 | + keep-alive on the worker, one write per answer, burst publish, 1 of 1 | 52 s | 57 s | 0 | 3,621 | 123 / 495 |
| Round 7 | the same code on a fresh stack | 49 s | 49 s | 0 | 3,733 | 122 / 494 |
| On/off, on | JSON indented again (Python 3.14 encodes it in C), request target 1000 | 43 s | 43 s | 0 | 3,443 | 124 / 486 |
| On/off, off | the same code with enable_fast_scaling off | 4 min 41 s | 5 min 40 s | 0 | 936 | 602 / 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
HTTPServerlistens 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_delaygives 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.
HealthyHostCountmisses 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
@timestampinside 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
TargetResponseTimegave 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).