Scale idle Fargate SQS workers to zero
Published · OhChimp Engineering

An Amazon ECS worker service on Fargate that drains an SQS queue bills every task it keeps running, whether the queue holds a million messages or none. In us-east-1 a 1 vCPU, 2 GB task costs $36.04 a month, so a worker service pinned at a minimum of two tasks for a queue busy 8 hours a day on 22 working days (176 of 730 hours) spends about $54.70 of its $72.08 monthly bill (76%) waiting on an empty queue.
Why an idle Fargate worker costs the same as a busy one
Fargate bills the vCPU and memory a task reserves, for as long as the task runs, and has no idle rate. The Fargate pricing page lists Linux on x86 in us-east-1 at $0.04048 per vCPU-hour and $0.004445 per GB-hour. Graviton (ARM) tasks list at $0.03238 and $0.00356. Billing is per second with a one-minute minimum, and it runs from the moment the task starts pulling its image until the task stops.
| Task size (x86, us-east-1) | Per hour | Per 730-hour month |
|---|---|---|
| 0.25 vCPU, 0.5 GB | $0.01234 | $9.01 |
| 0.5 vCPU, 1 GB | $0.02469 | $18.02 |
| 1 vCPU, 2 GB | $0.04937 | $36.04 |
| 2 vCPU, 4 GB | $0.09874 | $72.08 |
On Graviton the same 1 vCPU, 2 GB task costs $0.0395 an hour, or $28.84 over 730 hours, in us-east-1. A task that has nothing to read from its queue still holds its full reservation, so the per-hour rate in that table is the price of an empty queue too.
The money hides in the scaling floor. Most queue workers sit behind Application Auto Scaling with a minimum capacity of 1 or 2, set once so the first message of the morning finds a warm task. Take a worker that is busy for 8 hours a day on 22 working days, about 176 hours a month. The other 554 hours the queue is empty, and two 1 vCPU, 2 GB tasks spend them burning 554 × 2 × $0.04937, or $54.70. Scale-to-zero cuts that idle spend and leaves the busy hours untouched.
Amazon ECS supports it directly. The service auto scaling docs say that if you want the task count to scale to zero when there is no work, you set a minimum capacity of 0.
Why the first message can wait 15 minutes
Scaling to zero changes how fast the first message gets picked up, and the cause sits in SQS rather than ECS.
SQS pushes its CloudWatch metrics at one-minute intervals while the queue is active. Per the SQS monitoring docs, a queue counts as active for up to six hours after it last held a message or saw any API call. After six hours of neither, SQS stops delivering metrics, and when the queue wakes up there is a delay of up to 15 minutes before its metrics reach CloudWatch again.
While your workers run, they keep the queue awake by polling it. Scale them to zero and nothing touches the queue overnight. The first message after six or more hours with no messages and no API calls can sit for up to 15 minutes before the alarm that should start a task even sees it, and then the task still has to pull its image and boot. That delay suits a nightly report and is far too slow for a password-reset email.
You have two ways around it. Any API call counts as access, so a scheduled GetQueueAttributes call every few hours (an EventBridge Scheduler schedule makes a few hundred calls a month, well inside the SQS free tier) keeps the queue active and its metrics flowing. Or accept the delay and keep a minimum of one task on queues whose consumers expect an answer in seconds.
Two more traps sit next to this one. The metric most scaling setups watch, ApproximateNumberOfMessagesVisible, counts only messages waiting to be picked up and excludes the ones a worker is processing right now. A scale-in rule built on it alone can stop a task in the middle of a job. The SQS metrics reference also notes that ApproximateNumberOfMessagesNotVisible can occasionally read non-zero on an empty queue, so a scale-in alarm should require several consecutive empty datapoints rather than one.
The second trap is the scaling recipe itself. AWS's own SQS example scales on backlog per task, which divides the visible message count by RunningTaskCount from Container Insights, and it sets the minimum capacity to 1. At zero running tasks that divisor is zero, so the expression stops producing a usable value. A floor of zero needs a policy that reads raw queue depth.
Find your idle workers by hand
Start with the services and their scaling floors. List every service in a cluster with its desired and running counts. Don't filter with --launch-type FARGATE: a service created with a capacity provider strategy, which is what the console does when the cluster has the Fargate providers attached, has no launch type and drops out of that filter. Keep the rows whose launch is FARGATE or whose provider is FARGATE or FARGATE_SPOT.
CLUSTER=prod
for svc in $(aws ecs list-services --cluster "$CLUSTER" \
--query "serviceArns[]" --output text); do
aws ecs describe-services --cluster "$CLUSTER" --services "$svc" \
--query "services[0].{name:serviceName,launch:launchType,provider:capacityProviderStrategy[0].capacityProvider,desired:desiredCount,running:runningCount,taskDef:taskDefinition}" \
--output text
done
Then pull every scalable target in the account. The ResourceId reads service/<cluster>/<service>, and MinCapacity is the floor you pay for when the queue is empty.
aws application-autoscaling describe-scalable-targets \
--service-namespace ecs \
--query "ScalableTargets[].{service:ResourceId,min:MinCapacity,max:MaxCapacity}" \
--output table
A service that appears in the first list and not in the second has no autoscaling at all. Its desired count is fixed, so it runs that many tasks around the clock.
To tie a service to its queue, read the alarms behind its scaling policies. The alarm's dimensions name the queue.
aws application-autoscaling describe-scaling-policies \
--service-namespace ecs \
--resource-id service/prod/email-worker \
--query "ScalingPolicies[].Alarms[].AlarmName" --output text
aws cloudwatch describe-alarms --alarm-names <alarm-name-from-above> \
--query "MetricAlarms[].{metric:MetricName,dims:Dimensions}"
Now measure how many hours the queue actually had work. Counting hours with a NumberOfMessagesSent above zero undercounts: a nightly batch that lands in one hour and takes ten to drain shows one busy hour, not ten. Count the hours where a message was waiting or in flight instead, by adding the hourly maximum of ApproximateNumberOfMessagesVisible and ApproximateNumberOfMessagesNotVisible. Hours with no datapoint are hours the queue was asleep, so count them as empty too.
Q='[{"Name":"QueueName","Value":"email-jobs"}]'
aws cloudwatch get-metric-data \
--start-time "$(date -u -d '30 days ago' +%Y-%m-%dT%H:00:00Z)" \
--end-time "$(date -u +%Y-%m-%dT%H:00:00Z)" \
--metric-data-queries '[
{"Id":"waiting","ReturnData":false,"MetricStat":{"Period":3600,"Stat":"Maximum",
"Metric":{"Namespace":"AWS/SQS","MetricName":"ApproximateNumberOfMessagesVisible","Dimensions":'"$Q"'}}},
{"Id":"inflight","ReturnData":false,"MetricStat":{"Period":3600,"Stat":"Maximum",
"Metric":{"Namespace":"AWS/SQS","MetricName":"ApproximateNumberOfMessagesNotVisible","Dimensions":'"$Q"'}}},
{"Id":"busy","Expression":"waiting + inflight"}
]' \
--output json | jq '[.MetricDataResults[] | select(.Id == "busy") | .Values[] | select(. > 0)] | length'
That prints the number of hours, out of the 720 in the window, that had a message waiting or being processed. Subtract it from 720 to get the idle hours. The date -d form is GNU coreutils; on macOS use date -u -v-30d +%Y-%m-%dT%H:00:00Z.
Price the idle time from the task definition. Read its CPU and memory (CPU in units of 1024 per vCPU, memory in MiB):
aws ecs describe-task-definition --task-definition email-worker:42 \
--query "taskDefinition.{cpu:cpu,memory:memory}"
Idle cost per month is idle hours × minimum capacity × the hourly rate from the table above. Any service where idle cost is a large share of its bill (76% in the example above) and whose queue tolerates a cold start is a candidate.
Scale to zero without dropping a message
The change has three parts: protect in-flight work, add scaling policies that read raw queue depth, and lower the floor.
Protect tasks that are mid-job
Have the worker mark itself protected while it processes a message, using the ECS agent's task scale-in protection endpoint. A protected task is skipped by scale-in. Protection expires after ExpiresInMinutes (default 120, maximum 2,880), and calling it again resets the clock.
# Inside the container, before taking a message
curl -s -X PUT "$ECS_AGENT_URI/task-protection/v1/state" \
-H "Content-Type: application/json" \
-d '{"ProtectionEnabled": true, "ExpiresInMinutes": 30}'
# After deleting the message
curl -s -X PUT "$ECS_AGENT_URI/task-protection/v1/state" \
-H "Content-Type: application/json" \
-d '{"ProtectionEnabled": false}'
Set the expiry longer than your slowest job, and renew it on long jobs. Protection lapses on its own after ExpiresInMinutes, so a stuck worker cannot block scale-in forever. Keep the queue's visibility timeout longer than the slowest job too, or SQS hands the message to a second worker.
Add the scale-out policy
Register the target with a floor of 0. Keep your current maximum.
aws application-autoscaling register-scalable-target \
--service-namespace ecs \
--resource-id service/prod/email-worker \
--scalable-dimension ecs:service:DesiredCount \
--min-capacity 0 \
--max-capacity 10
The scale-out policy adds tasks in steps as the backlog grows. Step bounds are relative to the alarm threshold, so with a threshold of 1 message the first step covers 1 to 99 visible messages.
aws application-autoscaling put-scaling-policy \
--service-namespace ecs \
--resource-id service/prod/email-worker \
--scalable-dimension ecs:service:DesiredCount \
--policy-name email-worker-scale-out \
--policy-type StepScaling \
--step-scaling-policy-configuration '{
"AdjustmentType": "ChangeInCapacity",
"Cooldown": 60,
"MetricAggregationType": "Maximum",
"StepAdjustments": [
{"MetricIntervalLowerBound": 0, "MetricIntervalUpperBound": 99, "ScalingAdjustment": 1},
{"MetricIntervalLowerBound": 99, "MetricIntervalUpperBound": 999, "ScalingAdjustment": 3},
{"MetricIntervalLowerBound": 999, "ScalingAdjustment": 5}
]
}'
Wire it to an alarm on visible messages, using the PolicyARN the call returned.
aws cloudwatch put-metric-alarm \
--alarm-name email-worker-backlog \
--namespace AWS/SQS \
--metric-name ApproximateNumberOfMessagesVisible \
--dimensions Name=QueueName,Value=email-jobs \
--statistic Maximum \
--period 60 \
--evaluation-periods 1 \
--threshold 1 \
--comparison-operator GreaterThanOrEqualToThreshold \
--alarm-actions <scale-out-policy-arn>
Add the scale-to-zero policy
Scale in to exactly zero only when both visible and in-flight messages have read zero for 15 straight minutes. ExactCapacity sets the task count outright, and protected tasks keep running until they finish.
aws application-autoscaling put-scaling-policy \
--service-namespace ecs \
--resource-id service/prod/email-worker \
--scalable-dimension ecs:service:DesiredCount \
--policy-name email-worker-scale-to-zero \
--policy-type StepScaling \
--step-scaling-policy-configuration '{
"AdjustmentType": "ExactCapacity",
"Cooldown": 300,
"StepAdjustments": [
{"MetricIntervalUpperBound": 0, "ScalingAdjustment": 0}
]
}'
aws cloudwatch put-metric-alarm \
--alarm-name email-worker-queue-empty \
--evaluation-periods 15 \
--threshold 0 \
--comparison-operator LessThanOrEqualToThreshold \
--metrics '[
{"Id": "v", "ReturnData": false, "MetricStat": {"Metric": {"Namespace": "AWS/SQS",
"MetricName": "ApproximateNumberOfMessagesVisible",
"Dimensions": [{"Name": "QueueName", "Value": "email-jobs"}]}, "Period": 60, "Stat": "Maximum"}},
{"Id": "n", "ReturnData": false, "MetricStat": {"Metric": {"Namespace": "AWS/SQS",
"MetricName": "ApproximateNumberOfMessagesNotVisible",
"Dimensions": [{"Name": "QueueName", "Value": "email-jobs"}]}, "Period": 60, "Stat": "Maximum"}},
{"Id": "total", "Expression": "v + n", "Label": "Messages visible plus in flight", "ReturnData": true}
]' \
--alarm-actions <scale-to-zero-policy-arn>
Lowering the floor to 0 does not stop any running task by itself. Tasks drain only when the queue-empty alarm above fires its ExactCapacity 0 action.
If your service is defined in Terraform or CloudFormation, make the same change there, or the next apply puts min_capacity back to 2 (see why Terraform drift reverts right-sizing).
Rolling back
Raise the floor again and delete the scale-to-zero pair. The ECS docs say a desired count below the minimum is only raised on the next scale-out activity, and a scale-in leaves it alone, so set the desired count directly if you need tasks running right away.
aws application-autoscaling register-scalable-target \
--service-namespace ecs \
--resource-id service/prod/email-worker \
--scalable-dimension ecs:service:DesiredCount \
--min-capacity 2 \
--max-capacity 10
aws ecs update-service --cluster prod --service email-worker --desired-count 2
aws application-autoscaling delete-scaling-policy \
--service-namespace ecs \
--resource-id service/prod/email-worker \
--scalable-dimension ecs:service:DesiredCount \
--policy-name email-worker-scale-to-zero
aws cloudwatch delete-alarms --alarm-names email-worker-queue-empty
A quick decision tree
Scale to zero when the service already has autoscaling, its idle hours justify the change, and the first message can wait up to about 15 minutes plus task start. Otherwise keep a minimum of 1 and shrink the task size instead.
graph TD
A[Fargate service consuming an SQS queue] --> B{Has a scalable target?}
B -- No --> C[Fixed task count: add autoscaling first]
B -- Yes --> D{Idle hours x min capacity worth the change?}
D -- No --> E[Leave it]
D -- Yes --> F{Can the first message wait up to ~15 min plus task start?}
F -- Yes --> G[Min capacity 0, step policies on raw queue depth, task protection]
F -- No --> H{Is a keep-alive GetQueueAttributes enough?}
H -- Yes --> G
H -- No --> I[Keep min 1, shrink the task size instead]
Related reading
- Cloud Run min instances: idle is $9.72 or $49.25 covers the same warm-floor trade-off on Google Cloud, where a minimum instance count keeps billing between requests.
- Stop Terraform drift from reverting your right-sizing explains why a floor you lower in the console comes back on the next apply.
Common questions
Can an ECS service scale to zero tasks?
Yes. Register the service's scalable target with --min-capacity 0, and Application Auto Scaling can set the desired count to zero. AWS's ECS documentation names a minimum capacity of 0 as the way to let a service scale to zero when there is no work.
How much does an idle Fargate task cost?
The same as a busy one. In us-east-1 a Linux x86 task bills $0.04048 per vCPU-hour and $0.004445 per GB-hour, so a 1 vCPU, 2 GB task costs $0.04937 an hour, or $36.04 over a 730-hour month, whether or not it is processing anything. Two such tasks idle for 554 of 730 hours cost $54.70.
Why does a scale-to-zero Fargate service start slowly after a quiet night?
An SQS queue that has held no messages and seen no API calls for six hours stops sending metrics to CloudWatch, and when it wakes up its metrics can take up to 15 minutes to arrive. The scale-out alarm cannot fire until they do. A scheduled GetQueueAttributes call every few hours keeps the queue active.
Will scaling in kill a task that is processing a message?
It can, if the scale-in rule only watches ApproximateNumberOfMessagesVisible, which excludes in-flight messages. Base the scale-in alarm on visible plus not-visible messages, and have the worker set ECS task scale-in protection while it holds a message.
Should I use target tracking or step scaling to scale from zero?
AWS's SQS guidance uses target tracking on backlog per task with a minimum of 1. That metric divides by the running task count, which is zero once the service has scaled in, so a scale-to-zero service is simpler to run on step scaling against raw queue depth.
How OhChimp approaches this
OhChimp lists your ECS clusters and services, estimates each service's monthly Fargate cost from its task definition, and follows the autoscaling chain to the CloudWatch alarm and SQS queue that drive it. It flags services still running tasks while their queue sits at zero, services whose minimum capacity sits well above what they run, and services with no autoscaling at all. Each finding becomes a reviewable plan with a confidence score, a risk level and rollback steps, and nothing changes until you press apply. The projected saving stays an estimate, because OhChimp cannot yet measure what a single AWS plan saves. See the AWS integration for what it reads.