<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://abhimanyu9988.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://abhimanyu9988.github.io/" rel="alternate" type="text/html" /><updated>2026-10-18T15:24:28+00:00</updated><id>https://abhimanyu9988.github.io/feed.xml</id><title type="html">Abhimanyu Bajaj</title><subtitle>Real problems on AWS containers, reproduced and explained</subtitle><entry><title type="html">Fargate SQS worker stuck at 0 tasks? The IF(running &amp;gt; 0) guard never gets to run</title><link href="https://abhimanyu9988.github.io/posts/scale-to-zero-sqs-workers-on-ecs-fargate/" rel="alternate" type="text/html" title="Fargate SQS worker stuck at 0 tasks? The IF(running &amp;gt; 0) guard never gets to run" /><published>2026-10-18T00:00:00+00:00</published><updated>2026-10-18T00:00:00+00:00</updated><id>https://abhimanyu9988.github.io/posts/scale-to-zero-sqs-workers-on-ecs-fargate</id><content type="html" xml:base="https://abhimanyu9988.github.io/posts/scale-to-zero-sqs-workers-on-ecs-fargate/"><![CDATA[<p>I rebuilt a Stack Overflow setup in a throwaway account: an ECS Fargate worker with min capacity 0, scaled by target tracking on SQS backlog per task, with the guard IF(running &gt; 0, backlog / running, backlog). I sent 10 messages and the service stayed at desired=0 for the whole 8-minute wait. The backlog series had datapoints, but RunningTaskCount and the metric-math result had none, so the guard never produced a value. Swapping in FILL(running, 0) with one put-scaling-policy call got the service scaling out from 0 and draining the queue. Scale-in back to 0 isn’t covered by this run.</p>

<p>This hurts when you are trying to save idle Fargate cost for a worker that only wakes up when SQS has work. The Terraform can look reasonable. The expression even has an IF guard for zero tasks. But when messages arrive, nothing starts, the queue sits full, and the target-tracking alarms stay in INSUFFICIENT_DATA.</p>

<p>Most of my week goes on one question: why does something that looks correctly configured not work in a real cluster? I lead a support engineering team for observability agents that run inside Kubernetes clusters, and before I write about a problem, I reproduce it myself in a throwaway AWS account and keep the raw output.</p>

<p>This one was reproduced and fixed in eu-central-1.</p>

<p><strong>TL;DR</strong></p>

<p>Problem: an ECS Fargate SQS worker with min capacity 0 stayed at desired=0 even with 10 visible messages.</p>

<p>Cause: Container Insights <code class="language-plaintext highlighter-rouge">RunningTaskCount</code> had no datapoints at zero tasks, so <code class="language-plaintext highlighter-rouge">IF(running &gt; 0, backlog / running, backlog)</code> returned no <code class="language-plaintext highlighter-rouge">bpt</code> datapoints.</p>

<p>Fix: use <code class="language-plaintext highlighter-rouge">IF(FILL(running, 0) &gt; 0, backlog / FILL(running, 0), backlog)</code> so the expression falls back to raw backlog at zero tasks.</p>

<h2 id="what-youll-see">What you’ll see</h2>

<p>The service starts at zero, as intended:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>== Initial state: desired=0 running=0
[
    {
        "min": 0,
        "max": 5
    }
]
</code></pre></div></div>

<p>After sending 10 SQS messages, it stays there for the whole wait:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>t+30s desired=0 running=0 visible_in_queue=10
...
t+480s desired=0 running=0 visible_in_queue=10
</code></pre></div></div>

<p>The target-tracking alarms never get enough data:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>TargetTracking-service/scale-to-zero-sqs-worker-447482-cluster/scale-to-zero-sqs-worker-447482-worker-AlarmHigh-bca35fd1-1f83-47f3-bb4f-7e1460dd8a71	INSUFFICIENT_DATA
TargetTracking-service/scale-to-zero-sqs-worker-447482-cluster/scale-to-zero-sqs-worker-447482-worker-AlarmLow-af7b562c-9e1e-4887-b89d-fae66aa4bd5e	INSUFFICIENT_DATA
Unchecked: Initial alarm creation
</code></pre></div></div>

<p>The important part is the metric math. SQS backlog has data. <code class="language-plaintext highlighter-rouge">RunningTaskCount</code> does not. The computed backlog-per-task series, <code class="language-plaintext highlighter-rouge">bpt</code>, also has no data:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>{"Id":"backlog","datapoints":8,"Values":[10.0,10.0,10.0,10.0,10.0,10.0,10.0,10.0]}
{"Id":"running","datapoints":0,"Values":[]}
{"Id":"bpt","datapoints":0,"Values":[]}
bpt datapoints: 0
</code></pre></div></div>

<p>Final state from the failed run:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Final: desired=0 running=0 messages_waiting=10 bpt_datapoints=0
Messages are waiting but the 'bpt' series has no datapoints (RunningTaskCount missing), so the alarm never fires and the service stays at 0.
</code></pre></div></div>

<h2 id="why-it-happens">Why it happens</h2>

<p>The source question reported this pattern: an ECS Fargate worker consumes SQS, target tracking uses backlog per task, and the service has minimum capacity 0. AWS commonly recommends a backlog-per-task metric for queue workers: visible messages divided by running workers.</p>

<p>That is fine while at least one task is running. The awkward edge is zero.</p>

<p>In my lab, Container Insights did not publish a <code class="language-plaintext highlighter-rouge">RunningTaskCount</code> datapoint for this service while it had zero running tasks. CloudWatch metric math then returned no datapoints for this expression:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>IF(running &gt; 0, backlog / running, backlog)
</code></pre></div></div>

<p>So the fallback branch did not save the policy. The problem was not division by zero. The problem was missing data. The guard never had a real <code class="language-plaintext highlighter-rouge">running</code> datapoint to evaluate.</p>

<p>The Stack Overflow answers suggested wrapping <code class="language-plaintext highlighter-rouge">running</code> with <code class="language-plaintext highlighter-rouge">FILL(running, 0)</code>. My lab confirms that this fix was enough for scale-out from zero. After replacing the policy with:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>IF(FILL(running, 0) &gt; 0, backlog / FILL(running, 0), backlog)
</code></pre></div></div>

<p>Application Auto Scaling set desired count to 5, Fargate tasks reached RUNNING, and the queue drained.</p>

<p>What is only likely, not proven by this run: adding in-flight SQS messages may help avoid scaling in too early while messages are being processed, and a separate bootstrap policy keyed on <code class="language-plaintext highlighter-rouge">DesiredTaskCount</code> may be useful in some designs because Fargate takes time to move from desired to running. I did not test those variants here.</p>

<h2 id="replicate-it-yourself-step-by-step">Replicate it yourself, step by step</h2>

<p>You need AWS CLI, Terraform, <code class="language-plaintext highlighter-rouge">jq</code>, and GNU <code class="language-plaintext highlighter-rouge">date</code>. The lab uses eu-central-1 through its provider config.</p>

<ol>
  <li>Get the lab.</li>
</ol>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git clone https://github.com/Abhimanyu9988/Abhimanyu9988.github.io.git
<span class="nb">cd </span>Abhimanyu9988.github.io/labs/scale-to-zero-sqs-workers-on-ecs-fargate
</code></pre></div></div>

<p>In <code class="language-plaintext highlighter-rouge">providers.tf</code>, set <code class="language-plaintext highlighter-rouge">permissions_boundary</code> to <code class="language-plaintext highlighter-rouge">null</code> for your own account if you do not use an IAM permissions boundary.</p>

<ol>
  <li>Initialize Terraform and apply the lab.</li>
</ol>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>terraform init
</code></pre></div></div>

<p>Real init output from the run:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Initializing provider plugins...
- Finding hashicorp/aws versions matching "~&gt; 5.80"...
- Installing hashicorp/aws v5.100.0...
- Installed hashicorp/aws v5.100.0 (signed by HashiCorp)

Terraform has been successfully initialized!
</code></pre></div></div>

<p>Now create the resources:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>terraform apply
</code></pre></div></div>

<p>It creates an SQS queue, a VPC with two public subnets, an ECS cluster with Container Insights enabled, a Fargate service at desired count 0, IAM roles, a log group, an Application Auto Scaling target with min 0 and max 5, and the original target-tracking policy. The worker is the public <code class="language-plaintext highlighter-rouge">aws-cli</code> image looping on <code class="language-plaintext highlighter-rouge">receive-message</code>; each message takes 20 seconds in this lab.</p>

<p>The plan and apply showed 19 resources added:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Plan: 19 to add, 0 to change, 0 to destroy.
...
Apply complete! Resources: 19 added, 0 changed, 0 destroyed.

Outputs:

cluster_name = "scale-to-zero-sqs-worker-447482-cluster"
policy_name = "scale-to-zero-sqs-worker-447482-backlog-per-task"
queue_name = "scale-to-zero-sqs-worker-447482-jobs"
queue_url = "https://sqs.eu-central-1.amazonaws.com/&lt;account-id&gt;/scale-to-zero-sqs-worker-447482-jobs"
resource_id = "service/scale-to-zero-sqs-worker-447482-cluster/scale-to-zero-sqs-worker-447482-worker"
service_name = "scale-to-zero-sqs-worker-447482-worker"
</code></pre></div></div>

<ol>
  <li>Set the shell values the scripts read from Terraform outputs.</li>
</ol>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">export </span><span class="nv">AWS_DEFAULT_REGION</span><span class="o">=</span><span class="s2">"eu-central-1"</span>
<span class="nv">CLUSTER</span><span class="o">=</span><span class="si">$(</span>terraform output <span class="nt">-raw</span> cluster_name<span class="si">)</span>
<span class="nv">SERVICE</span><span class="o">=</span><span class="si">$(</span>terraform output <span class="nt">-raw</span> service_name<span class="si">)</span>
<span class="nv">QURL</span><span class="o">=</span><span class="si">$(</span>terraform output <span class="nt">-raw</span> queue_url<span class="si">)</span>
<span class="nv">QNAME</span><span class="o">=</span><span class="si">$(</span>terraform output <span class="nt">-raw</span> queue_name<span class="si">)</span>
<span class="nv">RID</span><span class="o">=</span><span class="si">$(</span>terraform output <span class="nt">-raw</span> resource_id<span class="si">)</span>
<span class="nv">POLICY</span><span class="o">=</span><span class="si">$(</span>terraform output <span class="nt">-raw</span> policy_name<span class="si">)</span>
</code></pre></div></div>

<ol>
  <li>Check the initial ECS service state and scaling target.</li>
</ol>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>aws ecs describe-services <span class="nt">--cluster</span> <span class="s2">"</span><span class="nv">$CLUSTER</span><span class="s2">"</span> <span class="nt">--services</span> <span class="s2">"</span><span class="nv">$SERVICE</span><span class="s2">"</span> <span class="nt">--query</span> <span class="s1">'services[0].desiredCount'</span> <span class="nt">--output</span> text
aws ecs describe-services <span class="nt">--cluster</span> <span class="s2">"</span><span class="nv">$CLUSTER</span><span class="s2">"</span> <span class="nt">--services</span> <span class="s2">"</span><span class="nv">$SERVICE</span><span class="s2">"</span> <span class="nt">--query</span> <span class="s1">'services[0].runningCount'</span> <span class="nt">--output</span> text
aws application-autoscaling describe-scalable-targets <span class="nt">--service-namespace</span> ecs <span class="nt">--resource-ids</span> <span class="s2">"</span><span class="nv">$RID</span><span class="s2">"</span> <span class="nt">--query</span> <span class="s1">'ScalableTargets[].{min:MinCapacity,max:MaxCapacity}'</span> <span class="nt">--output</span> json
</code></pre></div></div>

<p>Output:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>== Initial state: desired=0 running=0
[
    {
        "min": 0,
        "max": 5
    }
]
</code></pre></div></div>

<ol>
  <li>Send 10 messages to SQS.</li>
</ol>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">for </span>i <span class="k">in</span> <span class="si">$(</span><span class="nb">seq </span>1 10<span class="si">)</span><span class="p">;</span> <span class="k">do
  </span>aws sqs send-message <span class="nt">--queue-url</span> <span class="s2">"</span><span class="nv">$QURL</span><span class="s2">"</span> <span class="nt">--message-body</span> <span class="s2">"job-</span><span class="nv">$i</span><span class="s2">"</span> <span class="o">&gt;</span>/dev/null
<span class="k">done</span>
</code></pre></div></div>

<p>Output:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>== Sending 10 messages
</code></pre></div></div>

<ol>
  <li>Watch desired count, running count, and visible messages. The same reads are repeated every 30 seconds for up to 16 checks.</li>
</ol>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sleep </span>30
aws ecs describe-services <span class="nt">--cluster</span> <span class="s2">"</span><span class="nv">$CLUSTER</span><span class="s2">"</span> <span class="nt">--services</span> <span class="s2">"</span><span class="nv">$SERVICE</span><span class="s2">"</span> <span class="nt">--query</span> <span class="s1">'services[0].desiredCount'</span> <span class="nt">--output</span> text
aws ecs describe-services <span class="nt">--cluster</span> <span class="s2">"</span><span class="nv">$CLUSTER</span><span class="s2">"</span> <span class="nt">--services</span> <span class="s2">"</span><span class="nv">$SERVICE</span><span class="s2">"</span> <span class="nt">--query</span> <span class="s1">'services[0].runningCount'</span> <span class="nt">--output</span> text
aws sqs get-queue-attributes <span class="nt">--queue-url</span> <span class="s2">"</span><span class="nv">$QURL</span><span class="s2">"</span> <span class="nt">--attribute-names</span> ApproximateNumberOfMessages <span class="nt">--query</span> <span class="s1">'Attributes.ApproximateNumberOfMessages'</span> <span class="nt">--output</span> text
</code></pre></div></div>

<p>Output from the run:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>== Waiting up to 8 minutes for the service to leave 0 tasks
t+30s desired=0 running=0 visible_in_queue=10
t+60s desired=0 running=0 visible_in_queue=10
...
t+480s desired=0 running=0 visible_in_queue=10
</code></pre></div></div>

<ol>
  <li>Ask Application Auto Scaling for the target-tracking alarm names, then ask CloudWatch for their state and reason.</li>
</ol>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">ALARMS</span><span class="o">=</span><span class="si">$(</span>aws application-autoscaling describe-scaling-policies <span class="nt">--service-namespace</span> ecs <span class="nt">--resource-id</span> <span class="s2">"</span><span class="nv">$RID</span><span class="s2">"</span> <span class="nt">--query</span> <span class="s1">'ScalingPolicies[0].Alarms[].AlarmName'</span> <span class="nt">--output</span> text<span class="si">)</span>
aws cloudwatch describe-alarms <span class="nt">--alarm-names</span> <span class="nv">$ALARMS</span> <span class="nt">--query</span> <span class="s1">'MetricAlarms[].[AlarmName,StateValue]'</span> <span class="nt">--output</span> text
aws cloudwatch describe-alarms <span class="nt">--alarm-names</span> <span class="nv">$ALARMS</span> <span class="nt">--query</span> <span class="s1">'MetricAlarms[0].StateReason'</span> <span class="nt">--output</span> text | <span class="nb">cut</span> <span class="nt">-c1-300</span>
</code></pre></div></div>

<p>Output:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>== Policy alarms
TargetTracking-service/scale-to-zero-sqs-worker-447482-cluster/scale-to-zero-sqs-worker-447482-worker-AlarmHigh-bca35fd1-1f83-47f3-bb4f-7e1460dd8a71	INSUFFICIENT_DATA
TargetTracking-service/scale-to-zero-sqs-worker-447482-cluster/scale-to-zero-sqs-worker-447482-worker-AlarmLow-af7b562c-9e1e-4887-b89d-fae66aa4bd5e	INSUFFICIENT_DATA
Unchecked: Initial alarm creation
</code></pre></div></div>

<ol>
  <li>Query the metric math directly. This is the same broken expression from the original policy.</li>
</ol>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">START</span><span class="o">=</span><span class="si">$(</span><span class="nb">date</span> <span class="nt">-u</span> <span class="nt">-d</span> <span class="s1">'15 minutes ago'</span> +%Y-%m-%dT%H:%M:%SZ<span class="si">)</span>
<span class="nv">END</span><span class="o">=</span><span class="si">$(</span><span class="nb">date</span> <span class="nt">-u</span> +%Y-%m-%dT%H:%M:%SZ<span class="si">)</span>
<span class="nb">cat</span> <span class="o">&gt;</span> /tmp/q-broken.json <span class="o">&lt;&lt;</span><span class="no">EOF</span><span class="sh">
[
 {"Id":"backlog","ReturnData":true,"MetricStat":{"Metric":{"Namespace":"AWS/SQS","MetricName":"ApproximateNumberOfMessagesVisible","Dimensions":[{"Name":"QueueName","Value":"</span><span class="nv">$QNAME</span><span class="sh">"}]},"Period":60,"Stat":"Sum"}},
 {"Id":"running","ReturnData":true,"MetricStat":{"Metric":{"Namespace":"ECS/ContainerInsights","MetricName":"RunningTaskCount","Dimensions":[{"Name":"ClusterName","Value":"</span><span class="nv">$CLUSTER</span><span class="sh">"},{"Name":"ServiceName","Value":"</span><span class="nv">$SERVICE</span><span class="sh">"}]},"Period":60,"Stat":"Average"}},
 {"Id":"bpt","ReturnData":true,"Expression":"IF(running &gt; 0, backlog / running, backlog)"}
]
</span><span class="no">EOF
</span><span class="nv">METRICS</span><span class="o">=</span><span class="si">$(</span>aws cloudwatch get-metric-data <span class="nt">--start-time</span> <span class="s2">"</span><span class="nv">$START</span><span class="s2">"</span> <span class="nt">--end-time</span> <span class="s2">"</span><span class="nv">$END</span><span class="s2">"</span> <span class="nt">--metric-data-queries</span> file:///tmp/q-broken.json<span class="si">)</span>
<span class="nb">echo</span> <span class="s2">"</span><span class="nv">$METRICS</span><span class="s2">"</span> | jq <span class="nt">-c</span> <span class="s1">'.MetricDataResults[] | {Id, datapoints: (.Values|length), Values}'</span>
<span class="nv">BPT_N</span><span class="o">=</span><span class="si">$(</span><span class="nb">echo</span> <span class="s2">"</span><span class="nv">$METRICS</span><span class="s2">"</span> | jq <span class="s1">'[.MetricDataResults[] | select(.Id=="bpt") | (.Values|length)] | add // 0'</span><span class="si">)</span>
<span class="nb">echo</span> <span class="s2">"bpt datapoints: </span><span class="nv">$BPT_N</span><span class="s2">"</span>
</code></pre></div></div>

<p>Output:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>== Metric math as the policy evaluates it (last 15 min, 60s period)
{"Id":"backlog","datapoints":8,"Values":[10.0,10.0,10.0,10.0,10.0,10.0,10.0,10.0]}
{"Id":"running","datapoints":0,"Values":[]}
{"Id":"bpt","datapoints":0,"Values":[]}
bpt datapoints: 0
</code></pre></div></div>

<ol>
  <li>Check scaling activities. There were none in the reproduced failure.</li>
</ol>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>aws application-autoscaling describe-scaling-activities <span class="nt">--service-namespace</span> ecs <span class="nt">--resource-id</span> <span class="s2">"</span><span class="nv">$RID</span><span class="s2">"</span> <span class="nt">--query</span> <span class="s1">'ScalingActivities[].[StatusCode,Description]'</span> <span class="nt">--output</span> text | <span class="nb">head</span> <span class="nt">-5</span>
</code></pre></div></div>

<p>Output:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>== Scaling activities
</code></pre></div></div>

<p>The failed reproduction ends like this:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Final: desired=0 running=0 messages_waiting=10 bpt_datapoints=0
Messages are waiting but the 'bpt' series has no datapoints (RunningTaskCount missing), so the alarm never fires and the service stays at 0.
</code></pre></div></div>

<ol>
  <li>Apply the fix: replace the target-tracking policy with the same metric, but wrap <code class="language-plaintext highlighter-rouge">running</code> in <code class="language-plaintext highlighter-rouge">FILL(running, 0)</code>.</li>
</ol>

<p>First confirm the before state:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>aws ecs describe-services <span class="nt">--cluster</span> <span class="s2">"</span><span class="nv">$CLUSTER</span><span class="s2">"</span> <span class="nt">--services</span> <span class="s2">"</span><span class="nv">$SERVICE</span><span class="s2">"</span> <span class="nt">--query</span> <span class="s1">'services[0].desiredCount'</span> <span class="nt">--output</span> text
aws ecs describe-services <span class="nt">--cluster</span> <span class="s2">"</span><span class="nv">$CLUSTER</span><span class="s2">"</span> <span class="nt">--services</span> <span class="s2">"</span><span class="nv">$SERVICE</span><span class="s2">"</span> <span class="nt">--query</span> <span class="s1">'services[0].runningCount'</span> <span class="nt">--output</span> text
aws sqs get-queue-attributes <span class="nt">--queue-url</span> <span class="s2">"</span><span class="nv">$QURL</span><span class="s2">"</span> <span class="nt">--attribute-names</span> ApproximateNumberOfMessages <span class="nt">--query</span> <span class="s1">'Attributes.ApproximateNumberOfMessages'</span> <span class="nt">--output</span> text
</code></pre></div></div>

<p>Output:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>== Before: desired=0 running=0 visible=10
</code></pre></div></div>

<p>Create the fixed policy configuration:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">EXPR</span><span class="o">=</span><span class="s1">'IF(FILL(running, 0) &gt; 0, backlog / FILL(running, 0), backlog)'</span>
<span class="nb">cat</span> <span class="o">&gt;</span> /tmp/policy-fixed.json <span class="o">&lt;&lt;</span><span class="no">EOF</span><span class="sh">
{
 "TargetValue": 2,
 "ScaleOutCooldown": 60,
 "ScaleInCooldown": 300,
 "CustomizedMetricSpecification": {
  "Metrics": [
   {"Id":"backlog","ReturnData":false,"MetricStat":{"Metric":{"Namespace":"AWS/SQS","MetricName":"ApproximateNumberOfMessagesVisible","Dimensions":[{"Name":"QueueName","Value":"</span><span class="nv">$QNAME</span><span class="sh">"}]},"Stat":"Sum"}},
   {"Id":"running","ReturnData":false,"MetricStat":{"Metric":{"Namespace":"ECS/ContainerInsights","MetricName":"RunningTaskCount","Dimensions":[{"Name":"ClusterName","Value":"</span><span class="nv">$CLUSTER</span><span class="sh">"},{"Name":"ServiceName","Value":"</span><span class="nv">$SERVICE</span><span class="sh">"}]},"Stat":"Average"}},
   {"Id":"bpt","Label":"Backlog per task","ReturnData":true,"Expression":"</span><span class="nv">$EXPR</span><span class="sh">"}
  ]
 }
}
</span><span class="no">EOF
</span></code></pre></div></div>

<p>Output:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>== Updating policy with: IF(FILL(running, 0) &gt; 0, backlog / FILL(running, 0), backlog)
</code></pre></div></div>

<p>Push the fixed policy into Application Auto Scaling:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>aws application-autoscaling put-scaling-policy <span class="se">\</span>
  <span class="nt">--policy-name</span> <span class="s2">"</span><span class="nv">$POLICY</span><span class="s2">"</span> <span class="nt">--policy-type</span> TargetTrackingScaling <span class="se">\</span>
  <span class="nt">--service-namespace</span> ecs <span class="nt">--resource-id</span> <span class="s2">"</span><span class="nv">$RID</span><span class="s2">"</span> <span class="nt">--scalable-dimension</span> ecs:service:DesiredCount <span class="se">\</span>
  <span class="nt">--target-tracking-scaling-policy-configuration</span> file:///tmp/policy-fixed.json <span class="se">\</span>
  <span class="nt">--query</span> <span class="s1">'PolicyARN'</span> <span class="nt">--output</span> text
</code></pre></div></div>

<p>Output:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>arn:aws:autoscaling:eu-central-1:&lt;account-id&gt;:scalingPolicy:0b5eb0c3-92be-46eb-9e09-d6e225ccfe6b:resource/ecs/service/scale-to-zero-sqs-worker-447482-cluster/scale-to-zero-sqs-worker-447482-worker:policyName/scale-to-zero-sqs-worker-447482-backlog-per-task
</code></pre></div></div>

<ol>
  <li>Watch the service scale out from zero. The same ECS and SQS reads are repeated every 30 seconds.</li>
</ol>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sleep </span>30
aws ecs describe-services <span class="nt">--cluster</span> <span class="s2">"</span><span class="nv">$CLUSTER</span><span class="s2">"</span> <span class="nt">--services</span> <span class="s2">"</span><span class="nv">$SERVICE</span><span class="s2">"</span> <span class="nt">--query</span> <span class="s1">'services[0].desiredCount'</span> <span class="nt">--output</span> text
aws ecs describe-services <span class="nt">--cluster</span> <span class="s2">"</span><span class="nv">$CLUSTER</span><span class="s2">"</span> <span class="nt">--services</span> <span class="s2">"</span><span class="nv">$SERVICE</span><span class="s2">"</span> <span class="nt">--query</span> <span class="s1">'services[0].runningCount'</span> <span class="nt">--output</span> text
aws sqs get-queue-attributes <span class="nt">--queue-url</span> <span class="s2">"</span><span class="nv">$QURL</span><span class="s2">"</span> <span class="nt">--attribute-names</span> ApproximateNumberOfMessages <span class="nt">--query</span> <span class="s1">'Attributes.ApproximateNumberOfMessages'</span> <span class="nt">--output</span> text
aws sqs get-queue-attributes <span class="nt">--queue-url</span> <span class="s2">"</span><span class="nv">$QURL</span><span class="s2">"</span> <span class="nt">--attribute-names</span> ApproximateNumberOfMessagesNotVisible <span class="nt">--query</span> <span class="s1">'Attributes.ApproximateNumberOfMessagesNotVisible'</span> <span class="nt">--output</span> text
</code></pre></div></div>

<p>Output:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>== Waiting up to 10 minutes for the service to scale out from 0 and a task to reach RUNNING
t+30s desired=5 running=0 visible=10 in_flight=0
t+60s desired=5 running=5 visible=10 in_flight=0
</code></pre></div></div>

<ol>
  <li>Check scaling activities again.</li>
</ol>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>aws application-autoscaling describe-scaling-activities <span class="nt">--service-namespace</span> ecs <span class="nt">--resource-id</span> <span class="s2">"</span><span class="nv">$RID</span><span class="s2">"</span> <span class="nt">--query</span> <span class="s1">'ScalingActivities[].[StatusCode,Description]'</span> <span class="nt">--output</span> text | <span class="nb">head</span> <span class="nt">-5</span>
</code></pre></div></div>

<p>Output:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>== Scaling activities
Successful	Setting desired count to 5.
</code></pre></div></div>

<ol>
  <li>Watch the queue drain.</li>
</ol>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>aws sqs get-queue-attributes <span class="nt">--queue-url</span> <span class="s2">"</span><span class="nv">$QURL</span><span class="s2">"</span> <span class="nt">--attribute-names</span> ApproximateNumberOfMessages <span class="nt">--query</span> <span class="s1">'Attributes.ApproximateNumberOfMessages'</span> <span class="nt">--output</span> text
aws sqs get-queue-attributes <span class="nt">--queue-url</span> <span class="s2">"</span><span class="nv">$QURL</span><span class="s2">"</span> <span class="nt">--attribute-names</span> ApproximateNumberOfMessagesNotVisible <span class="nt">--query</span> <span class="s1">'Attributes.ApproximateNumberOfMessagesNotVisible'</span> <span class="nt">--output</span> text
aws ecs describe-services <span class="nt">--cluster</span> <span class="s2">"</span><span class="nv">$CLUSTER</span><span class="s2">"</span> <span class="nt">--services</span> <span class="s2">"</span><span class="nv">$SERVICE</span><span class="s2">"</span> <span class="nt">--query</span> <span class="s1">'services[0].desiredCount'</span> <span class="nt">--output</span> text
aws ecs describe-services <span class="nt">--cluster</span> <span class="s2">"</span><span class="nv">$CLUSTER</span><span class="s2">"</span> <span class="nt">--services</span> <span class="s2">"</span><span class="nv">$SERVICE</span><span class="s2">"</span> <span class="nt">--query</span> <span class="s1">'services[0].runningCount'</span> <span class="nt">--output</span> text
</code></pre></div></div>

<p>Output:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>== Watching queue drain (up to 2.5 min, bounded by script time budget)
visible=6 in_flight=0 desired=5 running=5
visible=0 in_flight=4 desired=5 running=5
visible=0 in_flight=0 desired=5 running=5
</code></pre></div></div>

<ol>
  <li>Clean up the live service state before destroying Terraform resources.</li>
</ol>

<p>Delete the scaling policy so the autoscaler does not relaunch tasks:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>aws application-autoscaling delete-scaling-policy <span class="nt">--service-namespace</span> ecs <span class="nt">--resource-id</span> <span class="s2">"</span><span class="nv">$RID</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">--scalable-dimension</span> ecs:service:DesiredCount <span class="nt">--policy-name</span> <span class="s2">"</span><span class="nv">$POLICY</span><span class="s2">"</span>
</code></pre></div></div>

<p>Set the ECS service desired count back to zero:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>aws ecs update-service <span class="nt">--cluster</span> <span class="s2">"</span><span class="nv">$CLUSTER</span><span class="s2">"</span> <span class="nt">--service</span> <span class="s2">"</span><span class="nv">$SERVICE</span><span class="s2">"</span> <span class="nt">--desired-count</span> 0
</code></pre></div></div>

<p>List any remaining tasks and stop each one:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">for </span>t <span class="k">in</span> <span class="si">$(</span>aws ecs list-tasks <span class="nt">--cluster</span> <span class="s2">"</span><span class="nv">$CLUSTER</span><span class="s2">"</span> <span class="nt">--query</span> <span class="s1">'taskArns[]'</span> <span class="nt">--output</span> text<span class="si">)</span><span class="p">;</span> <span class="k">do
  </span>aws ecs stop-task <span class="nt">--cluster</span> <span class="s2">"</span><span class="nv">$CLUSTER</span><span class="s2">"</span> <span class="nt">--task</span> <span class="s2">"</span><span class="nv">$t</span><span class="s2">"</span>
<span class="k">done</span>
</code></pre></div></div>

<p>Wait until no task ARNs are returned. The cleanup script repeats this check up to 18 times, 10 seconds apart:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">for </span>i <span class="k">in</span> <span class="si">$(</span><span class="nb">seq </span>1 18<span class="si">)</span><span class="p">;</span> <span class="k">do
  </span><span class="nv">N</span><span class="o">=</span><span class="si">$(</span>aws ecs list-tasks <span class="nt">--cluster</span> <span class="s2">"</span><span class="nv">$CLUSTER</span><span class="s2">"</span> <span class="nt">--query</span> <span class="s1">'length(taskArns)'</span> <span class="nt">--output</span> text<span class="si">)</span>
  <span class="o">[</span> <span class="nt">-z</span> <span class="s2">"</span><span class="nv">$N</span><span class="s2">"</span> <span class="o">]</span> <span class="o">||</span> <span class="o">[</span> <span class="s2">"</span><span class="nv">$N</span><span class="s2">"</span> <span class="o">=</span> <span class="s2">"0"</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="nb">break
  sleep </span>10
<span class="k">done</span>
</code></pre></div></div>

<p>Purge the queue:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>aws sqs purge-queue <span class="nt">--queue-url</span> <span class="s2">"</span><span class="nv">$QURL</span><span class="s2">"</span>
</code></pre></div></div>

<p>The cleanup script suppressed command output in my run.</p>

<ol>
  <li>Destroy the lab.</li>
</ol>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>terraform destroy
</code></pre></div></div>

<p>Output from destroy:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Plan: 0 to add, 0 to change, 18 to destroy.
...
Destroy complete! Resources: 18 destroyed.
</code></pre></div></div>

<h2 id="what-i-didnt-test">What I didn’t test</h2>

<p>I did not test scale-in back to zero. The lab notes say the target-tracking low alarm needs about 15 minutes of low data, and this run was bounded by the script time.</p>

<p>I did not test the in-flight-message expression using <code class="language-plaintext highlighter-rouge">ApproximateNumberOfMessagesNotVisible</code>.</p>

<p>I did not test a separate 0-to-1 scale-out policy keyed on <code class="language-plaintext highlighter-rouge">DesiredTaskCount</code>.</p>

<p>I did not test ECS task scale-in protection. That matters for a different question: whether a busy worker can be stopped during scale-in. If a task is stopped while processing, SQS messages can be redelivered after the 60 second visibility timeout in this lab.</p>

<h2 id="cost-and-time">Cost and time</h2>

<p>The run was in eu-central-1 and took 19.1 minutes end to end.</p>

<p>Terraform created 19 resources. After the cleanup step deleted the scaling policy, Terraform destroyed 18 resources.</p>

<p>The timed parts from the raw output were: init 6 seconds, apply 36 seconds, reproduce 572 seconds, fix 162 seconds, cleanup 17 seconds, and the later init 3 seconds. The workload used a few minutes of 0.25 vCPU Fargate tasks. Container Insights metrics cost cents.</p>

<h2 id="the-takeaway">The takeaway</h2>

<p>If your scale-to-zero ECS worker depends on a metric from running tasks, test the zero-task case directly. For SQS backlog per task, treat missing <code class="language-plaintext highlighter-rouge">RunningTaskCount</code> as zero with <code class="language-plaintext highlighter-rouge">FILL</code>, otherwise your careful <code class="language-plaintext highlighter-rouge">IF</code> guard may never get a datapoint to evaluate.</p>

<h2 id="sources">Sources</h2>

<ul>
  <li><a href="https://stackoverflow.com/questions/80006730/aws-ecs-fargate-sqs-autoscaling-with-min-capacity-0-how-to-bootstrap-from-0-tas">Stack Overflow: ECS Fargate SQS autoscaling with min capacity 0</a></li>
</ul>

<hr />
<p><em>I reproduced this in a disposable AWS lab. The lab code and raw output are in the repo. Drafted with AI assistance and reviewed by me.</em></p>]]></content><author><name></name></author><category term="aws" /><category term="ecs" /><category term="fargate" /><category term="devops" /><summary type="html"><![CDATA[I reproduced an ECS Fargate scale-to-zero SQS worker that never bootstraps from zero. The fix was to wrap the missing RunningTaskCount metric with FILL(running, 0).]]></summary></entry></feed>