<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Abhimanyu Bajaj</title>
    <description>The latest articles on DEV Community by Abhimanyu Bajaj (@abhimanyu9988).</description>
    <link>https://dev.to/abhimanyu9988</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4191157%2F10256471-ec76-4ad4-b506-6581a83c9252.jpg</url>
      <title>DEV Community: Abhimanyu Bajaj</title>
      <link>https://dev.to/abhimanyu9988</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/abhimanyu9988"/>
    <language>en</language>
    <item>
      <title>October: Kubernetes memory semantics and Collector edge fixes</title>
      <dc:creator>Abhimanyu Bajaj</dc:creator>
      <pubDate>Sun, 01 Nov 2026 10:32:40 +0000</pubDate>
      <link>https://dev.to/abhimanyu9988/october-kubernetes-memory-semantics-and-collector-edge-fixes-j5f</link>
      <guid>https://dev.to/abhimanyu9988/october-kubernetes-memory-semantics-and-collector-edge-fixes-j5f</guid>
      <description>&lt;p&gt;In October I had 10 PRs merged across four projects. The common thread was practical observability work: clearer Kubernetes memory conventions, Helm chart behavior that is easier to reason about, less flaky Collector tests, and maintainership for components that need active review.&lt;/p&gt;

&lt;p&gt;Most of my week goes into one question: why does something that looks correctly configured not work in a real cluster? These PRs came from the same place.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Fixed flaky Kubernetes inventory cache-sync tests.&lt;/li&gt;
&lt;li&gt;Removed a Java 11+ startup failure in a DataPower monitoring extension.&lt;/li&gt;
&lt;li&gt;Let Operator chart users override only the Target Allocator repository.&lt;/li&gt;
&lt;li&gt;Added Kubernetes memory utilization conventions.&lt;/li&gt;
&lt;li&gt;Clarified Kubernetes working set descriptions.&lt;/li&gt;
&lt;li&gt;Added Pod-level memory limit and request conventions.&lt;/li&gt;
&lt;li&gt;Joined Flink Metrics Receiver code ownership.&lt;/li&gt;
&lt;li&gt;Fixed broad Flink operator metric matching.&lt;/li&gt;
&lt;li&gt;Documented Operator chart certificate drift with Argo CD.&lt;/li&gt;
&lt;li&gt;Restored Azure Data Explorer Exporter active ownership and beta status.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Kubernetes memory conventions became more precise
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Pod memory limits and requests now have desired and current forms
&lt;/h3&gt;

&lt;p&gt;The Kubernetes semantic conventions had Pod-level CPU limit and request metrics, but not the same shape for memory. I added &lt;code&gt;k8s.pod.memory.limit.{desired,current}&lt;/code&gt; and &lt;code&gt;k8s.pod.memory.request.{desired,current}&lt;/code&gt; so the model can represent values from &lt;code&gt;spec.resources&lt;/code&gt; and &lt;code&gt;status.resources&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This shows up in OpenTelemetry semantic conventions, which define standards for consistent telemetry. It closes issue #4133, and there is no release listed for this PR yet.&lt;/p&gt;

&lt;p&gt;Once implemented downstream, dashboards and alerts can distinguish desired Pod memory settings from current Pod memory status.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/open-telemetry/semantic-conventions/pull/4158" rel="noopener noreferrer"&gt;open-telemetry/semantic-conventions PR 4158&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Working set descriptions now say what Linux includes
&lt;/h3&gt;

&lt;p&gt;The working set descriptions for &lt;code&gt;k8s.pod.memory.working_set&lt;/code&gt;, &lt;code&gt;k8s.node.memory.working_set&lt;/code&gt;, and &lt;code&gt;container.memory.working_set&lt;/code&gt; were clarified. On Linux, working set excludes inactive file-backed memory, while active file-backed memory remains included.&lt;/p&gt;

&lt;p&gt;The generated semantic convention documentation now also says that kubelet uses Pod working set when ranking Pods for eviction, and node working set when determining memory availability for the &lt;code&gt;memory.available&lt;/code&gt; eviction signal. There is no release listed for this PR yet.&lt;/p&gt;

&lt;p&gt;The next effect is clearer documentation for comparing usage, working set, and eviction behavior.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/open-telemetry/semantic-conventions/pull/4184" rel="noopener noreferrer"&gt;open-telemetry/semantic-conventions PR 4184&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Memory utilization now uses working set as the numerator
&lt;/h3&gt;

&lt;p&gt;I added four Kubernetes memory utilization metrics: Pod and container limit utilization, and Pod and container request utilization. These use memory working set as the numerator, not total memory usage.&lt;/p&gt;

&lt;p&gt;The PR explains the reason: total usage includes reclaimable page cache, which can make a workload look close to its limit even when part of that memory is reclaimable. I validated the behavior against a kubelet Summary API using kind with Kubernetes v1.37.0. There is no release listed for this PR yet.&lt;/p&gt;

&lt;p&gt;Once implemented downstream, memory utilization can be shown against limits and requests with the numerator semantics documented in the convention and migration guide.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/open-telemetry/semantic-conventions/pull/4219" rel="noopener noreferrer"&gt;open-telemetry/semantic-conventions PR 4219&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Operator Helm chart configuration and docs got clearer
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Private registry users can override only the Target Allocator repository
&lt;/h3&gt;

&lt;p&gt;The OpenTelemetry Operator Helm chart did not let users override only &lt;code&gt;manager.targetAllocatorImage.repository&lt;/code&gt; while keeping the chart’s default tag. I added explicit defaults for the Target Allocator repository and tag, plus validation that repository and tag are either both set or both empty.&lt;/p&gt;

&lt;p&gt;This is for the OpenTelemetry Helm Charts project. It closes issue #1866, where 2 people asked for it, and the chart version was bumped to &lt;code&gt;0.125.0&lt;/code&gt;. There is no release listed for this PR yet.&lt;/p&gt;

&lt;p&gt;The practical effect is simpler values for private registry or air-gapped installs that redirect the Target Allocator image. The upgrade guide also documents the configuration precedence change.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/open-telemetry/opentelemetry-helm-charts/pull/2456" rel="noopener noreferrer"&gt;open-telemetry/opentelemetry-helm-charts PR 2456&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Argo CD users get documentation for auto-generated webhook certs
&lt;/h3&gt;

&lt;p&gt;When &lt;code&gt;admissionWebhooks.autoGenerateCert&lt;/code&gt; is used, the Operator chart can generate a new CA on each render. With Argo CD, or other tools that use &lt;code&gt;helm template&lt;/code&gt;, Helm cannot use &lt;code&gt;lookup&lt;/code&gt; during rendering to reuse the existing Secret, so the webhook &lt;code&gt;caBundle&lt;/code&gt;, certificate Secret, and running operator may become inconsistent.&lt;/p&gt;

&lt;p&gt;I documented this in the README’s TLS certificate section, including the possible &lt;code&gt;x509: certificate signed by unknown authority&lt;/code&gt; failure. I reproduced the behavior with Argo CD v3.5.5, chart 0.126.0, and Kubernetes v1.37.0. This shipped in &lt;code&gt;opentelemetry-operator-0.127.1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The change is documentation: Argo CD users have a clearer reason to use cert-manager or supply certificate material independently of render-time generation.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/open-telemetry/opentelemetry-helm-charts/pull/2473" rel="noopener noreferrer"&gt;open-telemetry/opentelemetry-helm-charts PR 2473&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Collector contrib tests, receivers, and ownership moved forward
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Kubernetes inventory cache-sync tests are deterministic now
&lt;/h3&gt;

&lt;p&gt;Two &lt;code&gt;internal/k8sinventory/informer&lt;/code&gt; tests were flaky because they depended on timing around fake client cache sync. I changed the tests to block the initial Pod &lt;code&gt;List&lt;/code&gt; call explicitly, so the failure paths are deterministic without changing production behavior.&lt;/p&gt;

&lt;p&gt;This is internal test code in the OpenTelemetry Collector contrib repository. It closes issue #50433, which had 24 comments about failed tests on main. There is no release listed for this PR yet.&lt;/p&gt;

&lt;p&gt;The effect is less noise from racey tests when contributors work on Kubernetes inventory code.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/pull/51657" rel="noopener noreferrer"&gt;open-telemetry/opentelemetry-collector-contrib PR 51657&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The Flink Metrics Receiver has another code owner
&lt;/h3&gt;

&lt;p&gt;The Flink Metrics Receiver needed additional maintainers. I added myself as a code owner after reviewing receiver changes, running unit tests, and validating changes against a local Apache Flink 2.3.0 deployment in Docker.&lt;/p&gt;

&lt;p&gt;This shows up in CODEOWNERS and generated README metadata in the OpenTelemetry Collector contrib repository. There is no release listed for this PR yet, and it does not change runtime behavior.&lt;/p&gt;

&lt;p&gt;The practical change is maintenance coverage for reviews, triage, bug fixes, and receiver upkeep.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/pull/52019" rel="noopener noreferrer"&gt;open-telemetry/opentelemetry-collector-contrib PR 52019&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Flink operator metrics now match exact suffixes
&lt;/h3&gt;

&lt;p&gt;The Flink Metrics Receiver used &lt;code&gt;strings.Contains&lt;/code&gt; for operator-level metric names. That could also match longer names like &lt;code&gt;numRecordsInPerSecond&lt;/code&gt; when looking for &lt;code&gt;numRecordsIn&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I changed the four affected operator metric cases to exact suffix matching: &lt;code&gt;numRecordsIn&lt;/code&gt;, &lt;code&gt;numRecordsOut&lt;/code&gt;, &lt;code&gt;numLateRecordsDropped&lt;/code&gt;, and &lt;code&gt;currentOutputWatermark&lt;/code&gt;. Existing metric names, attributes, and configuration are preserved. There is no release listed for this PR yet.&lt;/p&gt;

&lt;p&gt;The result is more precise matching for those receiver metrics.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/pull/52406" rel="noopener noreferrer"&gt;open-telemetry/opentelemetry-collector-contrib PR 52406&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Azure Data Explorer Exporter has active ownership again
&lt;/h3&gt;

&lt;p&gt;The Azure Data Explorer Exporter had been marked unmaintained after its previous active code owner moved to emeritus. I added myself as an active code owner and restored the previous beta stability designation for traces, metrics, and logs.&lt;/p&gt;

&lt;p&gt;This is visible in generated README, CODEOWNERS, component labels, status metadata, and the changelog. It closes issue #52282, and there is no release listed for this PR yet.&lt;/p&gt;

&lt;p&gt;The practical effect is that the component again has an active maintainer path for review and triage.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/pull/52500" rel="noopener noreferrer"&gt;open-telemetry/opentelemetry-collector-contrib PR 52500&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A Machine Agent monitoring extension starts again on Java 11+
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The DataPower extension no longer depends on JDK SAAJ
&lt;/h3&gt;

&lt;p&gt;On Machine Agents running a Java 11+ JRE, the DataPower monitoring extension could fail to instantiate because the JDK no longer ships the SAAJ implementation removed by JEP 320. The extension only used SAAJ to build a fixed SOAP request envelope.&lt;/p&gt;

&lt;p&gt;I removed the &lt;code&gt;javax.xml.soap&lt;/code&gt; dependency and now build the envelope directly as a string, with XML attribute escaping for domain and status-class values. The PR bumps the extension version to 2.0.2, and there is no release link listed for this PR yet.&lt;/p&gt;

&lt;p&gt;The practical change is that Machine Agent users on Java 8 through 21 have a startup path that does not depend on a removed JDK implementation.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/Appdynamics/datapower-monitoring-extension/pull/13" rel="noopener noreferrer"&gt;Appdynamics/datapower-monitoring-extension PR 13&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Still in review
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/open-telemetry/opentelemetry-operator/pull/5850" rel="noopener noreferrer"&gt;open-telemetry/opentelemetry-operator: Generate a list of collector components with specific operator support&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/Appdynamics/process-monitoring-extension/pull/25" rel="noopener noreferrer"&gt;Appdynamics/process-monitoring-extension: Update dependencies to remediate reported vulnerabilities&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/pull/52485" rel="noopener noreferrer"&gt;open-telemetry/opentelemetry-collector-contrib: update tested Flink versions and correct README configuration&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/pull/52450" rel="noopener noreferrer"&gt;open-telemetry/opentelemetry-collector-contrib: Record JVM GC metrics for both JobManager and TaskManager&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/pull/52279" rel="noopener noreferrer"&gt;open-telemetry/opentelemetry-collector-contrib: Add opt-in PVC-level volume metrics&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/pull/51952" rel="noopener noreferrer"&gt;open-telemetry/opentelemetry-collector-contrib: Add Warnings section to probabilistic sampler README&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/CiscoDevNet/appdynamics-charts/pull/36" rel="noopener noreferrer"&gt;CiscoDevNet/appdynamics-charts: ENV variables fix in InfraViz&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/pull/51657" rel="noopener noreferrer"&gt;Fix flaky Kubernetes inventory cache-sync tests&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/Appdynamics/datapower-monitoring-extension/pull/13" rel="noopener noreferrer"&gt;Fix DataPower monitoring extension startup on Java 11+&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/open-telemetry/opentelemetry-helm-charts/pull/2456" rel="noopener noreferrer"&gt;Allow overriding only the Target Allocator image repository&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/open-telemetry/semantic-conventions/pull/4219" rel="noopener noreferrer"&gt;Add Pod and container memory limit and request utilization metrics&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/open-telemetry/semantic-conventions/pull/4184" rel="noopener noreferrer"&gt;Clarify memory working set metric descriptions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/open-telemetry/semantic-conventions/pull/4158" rel="noopener noreferrer"&gt;Add Pod-level memory limit and request metrics&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/pull/52019" rel="noopener noreferrer"&gt;Add a Flink Metrics Receiver code owner&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/pull/52406" rel="noopener noreferrer"&gt;Match Flink operator metrics on exact suffix&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/open-telemetry/opentelemetry-helm-charts/pull/2473" rel="noopener noreferrer"&gt;Explain autoGenerateCert behavior with Argo CD&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/open-telemetry/opentelemetry-helm-charts/releases/tag/opentelemetry-operator-0.127.1" rel="noopener noreferrer"&gt;opentelemetry-operator-0.127.1 release&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/pull/52500" rel="noopener noreferrer"&gt;Restore Azure Data Explorer Exporter active ownership and beta stability&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Drafted with AI assistance from the PRs themselves and reviewed by me.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>opentelemetry</category>
      <category>kubernetes</category>
      <category>observability</category>
    </item>
    <item>
      <title>EKS App Slow, CPU Fine? Scale on Splunk Latency with KEDA</title>
      <dc:creator>Abhimanyu Bajaj</dc:creator>
      <pubDate>Tue, 27 Oct 2026 11:20:45 +0000</pubDate>
      <link>https://dev.to/abhimanyu9988/eks-app-slow-cpu-fine-scale-on-splunk-latency-with-keda-1aa</link>
      <guid>https://dev.to/abhimanyu9988/eks-app-slow-cpu-fine-scale-on-splunk-latency-with-keda-1aa</guid>
      <description>&lt;p&gt;This lab fixes the autoscaling gap CPU HPA misses: users are waiting, but CPU is barely moving.&lt;/p&gt;

&lt;p&gt;You will reproduce the failure on an I/O-bound Python service, then use Splunk Observability, the OpenTelemetry Collector, and KEDA to scale on request latency instead. In my run, the app sat at 1 replica with &lt;strong&gt;3049.2 ms&lt;/strong&gt; peak latency and &lt;strong&gt;2.8%&lt;/strong&gt; peak CPU, then scaled from &lt;strong&gt;1 to 5 ready replicas in 48 seconds&lt;/strong&gt; once KEDA read the Splunk latency metric.&lt;/p&gt;

&lt;p&gt;I work a lot with the OpenTelemetry Collector in Kubernetes, and I reproduce problems like this in a throwaway AWS account before I write about them.&lt;/p&gt;

&lt;p&gt;Here is how it works, and how to build it yourself.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;What you get:&lt;/strong&gt; an EKS Deployment whose replica count follows request latency, not CPU.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The trick:&lt;/strong&gt; the app exposes &lt;code&gt;app_request_latency_ms&lt;/code&gt;, the Splunk OpenTelemetry Collector sends it to Splunk Observability Cloud, and KEDA feeds that value into an HPA.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The proof:&lt;/strong&gt; before, replicas stayed at &lt;code&gt;1&lt;/code&gt;; after, they went &lt;code&gt;1 -&amp;gt; 5&lt;/code&gt; under load and back to &lt;code&gt;1&lt;/code&gt; after load stopped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time and cost:&lt;/strong&gt; the README estimates about &lt;code&gt;40&lt;/code&gt; minutes and well under &lt;code&gt;1 USD&lt;/code&gt;; my recorded reproduced-and-fixed run took &lt;code&gt;31.3&lt;/code&gt; minutes.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why CPU autoscaling misses this slow EKS service
&lt;/h2&gt;

&lt;p&gt;The app in this lab is intentionally I/O-bound. Each pod has 4 backend slots. Each request holds a slot for 200 ms using sleep, not CPU work. When 60 clients arrive, requests queue inside the service. Users feel latency, but the CPU graph barely moves.&lt;/p&gt;

&lt;p&gt;Here is the before state from &lt;code&gt;reproduce.sh&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;== Autoscaling objects in the namespace:
No resources found in demo namespace.
NAME          READY   UP-TO-DATE   AVAILABLE   AGE
latency-app   1/1     1            1           7s
...
== Summary: peak latency 3049.2 ms, peak CPU 2.8% of one core, replicas still 1
   Users feel multi-second latency, CPU stays low, nothing scales.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A normal CPU HPA has no useful signal here. The better signal is the one users already feel: request latency.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1cyfhky9laj0g8ekikfe.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1cyfhky9laj0g8ekikfe.png" alt="60 clients: latency ~3s, CPU stuck at ~2.5%, replicas 1" width="800" height="232"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The trick: let Splunk become the external metric KEDA reads
&lt;/h2&gt;

&lt;p&gt;KEDA makes the scaling decision, but Splunk Observability Cloud is the metric source.&lt;/p&gt;

&lt;p&gt;The path is short: the Python app exposes &lt;code&gt;app_request_latency_ms&lt;/code&gt; on &lt;code&gt;/metrics&lt;/code&gt;, the pod has Prometheus scrape annotations, the Splunk OpenTelemetry Collector sends the metric to Splunk, and KEDA runs a SignalFlow query through the &lt;code&gt;splunk-observability&lt;/code&gt; scaler.&lt;/p&gt;

&lt;p&gt;This is the key part of the ScaledObject from the lab:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;triggers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;splunk-observability&lt;/span&gt;
    &lt;span class="na"&gt;metricType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Value&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;query&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data('app_request_latency_ms',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;filter=filter('k8s.cluster.name',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'${LAB_NAME}')).max().publish()"&lt;/span&gt;
      &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;40"&lt;/span&gt;
      &lt;span class="na"&gt;queryAggregator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;max"&lt;/span&gt;
      &lt;span class="na"&gt;targetValue&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;400"&lt;/span&gt;
      &lt;span class="na"&gt;activationTargetValue&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0"&lt;/span&gt;
    &lt;span class="na"&gt;authenticationRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;splunk-observability-auth&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;fix.sh&lt;/code&gt; expands &lt;code&gt;${LAB_NAME}&lt;/code&gt; before applying the manifest. If you paste this into a YAML file yourself, replace it with your cluster name. For your own service, change the metric name, cluster filter, and target value.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build it yourself in 5 steps
&lt;/h2&gt;

&lt;p&gt;Prerequisites: an AWS account, a Splunk Observability Cloud org, an ingest token, a read-only API token, the matching Splunk realm, and the CLIs used by the scripts: &lt;code&gt;aws&lt;/code&gt;, &lt;code&gt;kubectl&lt;/code&gt;, &lt;code&gt;helm&lt;/code&gt;, &lt;code&gt;jq&lt;/code&gt;, &lt;code&gt;curl&lt;/code&gt;, and &lt;code&gt;terraform&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Create the EKS lab cluster (cluster creation was 6m15s in my run)
&lt;/h3&gt;

&lt;p&gt;This creates a 2-node EKS cluster with self-managed &lt;code&gt;t3.large&lt;/code&gt; EC2 nodes in public subnets.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/Abhimanyu9988/Abhimanyu9988.github.io.git
&lt;span class="nb"&gt;cd &lt;/span&gt;Abhimanyu9988.github.io/labs/eks-app-slow-cpu-fine-scale-on-splunk-latency-with-keda

&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;AWS_REGION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;eu-central-1
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;LAB_NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;scale-an-eks-app-on-requ-041971
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;TF_VAR_lab_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$LAB_NAME&lt;/span&gt;

&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;SPLUNK_REALM&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;eu0
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;SPLUNK_ACCESS_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;splunk-ingest-token&amp;gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;SPLUNK_API_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;splunk-read-only-api-token&amp;gt;

terraform init &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; terraform apply
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The realm must match your Splunk Observability Cloud org. The ingest token is for the collector. The read-only API token is for KEDA and the proof queries.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Reproduce the slow-app, low-CPU state
&lt;/h3&gt;

&lt;p&gt;This deploys the namespace, app, Service, and a load generator with 60 concurrent clients.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Install the collector, KEDA, and the Splunk scaler config
&lt;/h3&gt;

&lt;p&gt;The second script installs the Splunk OpenTelemetry Collector chart &lt;code&gt;0.163.1&lt;/code&gt; and KEDA chart &lt;code&gt;2.21.0&lt;/code&gt;. It also creates the Secret, TriggerAuthentication, and ScaledObject.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bash reproduce.sh
bash fix.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;fix.sh&lt;/code&gt; runs the Helm installs, waits for the collector agent, checks Splunk through the API, applies the KEDA objects, and starts the same load generator again.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Check that Splunk has both cluster and app metrics
&lt;/h3&gt;

&lt;p&gt;Before scaling, the lab proves that data arrived in Splunk Observability Cloud.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Installed charts:
NAME                  NAMESPACE   REVISION   STATUS     CHART                         APP VERSION
keda                  keda        1          deployed   keda-2.21.0                   2.21.0
splunk-otel-collector splunk      1          deployed   splunk-otel-collector-0.163.1 0.163.1
...
k8s.cluster.name=scale-an-eks-app-on-requ-041971: 1609 metric time series, distinct metric names: 74
app metric app_request_latency_ms: 1 time series; dimensions of one series:
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That matters because KEDA is not scraping the pod directly. It asks Splunk for the metric, then exposes that value to Kubernetes as an external metric.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhtjaykeo2f2i6gtcv1nj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhtjaykeo2f2i6gtcv1nj.png" alt="Splunk has the cluster series and the app latency series" width="800" height="187"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Watch the HPA follow the Splunk latency metric
&lt;/h3&gt;

&lt;p&gt;Once the ScaledObject is ready, KEDA creates an HPA. At idle, the metric is &lt;code&gt;0/400&lt;/code&gt;. Under load, the Splunk latency value crosses the target and the HPA raises the replica count.&lt;/p&gt;

&lt;p&gt;The timings below come from sampled script output, with about 10 seconds between checks. The query also uses a 40-second window, so the &lt;code&gt;48s&lt;/code&gt; and &lt;code&gt;72s&lt;/code&gt; figures are approximate, and the metric can stay stale briefly after load stops.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh9jza09x4yvnlzaee3ow.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh9jza09x4yvnlzaee3ow.png" alt="KEDA HPA idle at 0/400 before load" width="800" height="164"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The proof: 3-second latency became a 5-pod scale-out
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Before load test&lt;/td&gt;
&lt;td&gt;1 replica, no HPA&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Before peak latency&lt;/td&gt;
&lt;td&gt;3049.2 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Before peak CPU&lt;/td&gt;
&lt;td&gt;2.8% of one core&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Splunk cluster time series found&lt;/td&gt;
&lt;td&gt;1609&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Splunk app latency time series found&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scale-up under load&lt;/td&gt;
&lt;td&gt;1 -&amp;gt; 5 ready replicas in 48s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scale-down after load stopped&lt;/td&gt;
&lt;td&gt;back to 1 in 72s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Here is the scale-up section from the lab output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;T+  7s  replicas desired=1 ready=1  splunk_latency_ms(max)=0
T+ 21s  replicas desired=1 ready=1  splunk_latency_ms(max)=0
T+ 34s  replicas desired=1 ready=1  splunk_latency_ms(max)=0
T+ 48s  replicas desired=5 ready=5  splunk_latency_ms(max)=3008
Scale-up: 1 -&amp;gt; 5 ready replicas in 48s after load started
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And after the load generator stops:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;T+  4s  replicas desired=5 running=5  splunk_latency_ms(max)=3008
T+ 31s  replicas desired=5 running=5  splunk_latency_ms(max)=3008
T+ 58s  replicas desired=5 running=5  splunk_latency_ms(max)=3008
T+ 72s  replicas desired=1 running=1  splunk_latency_ms(max)=0
Summary: 1 -&amp;gt; 5 replicas in 48s under load; back to 1 in 72s after load stopped (driven by Splunk latency, not CPU).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the point of the lab: Kubernetes scaled on the symptom users feel, not on a resource that stayed quiet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use it for services where CPU is the wrong proxy
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;I/O-bound APIs:&lt;/strong&gt; if your service waits on databases, downstream HTTP calls, or storage, latency may rise long before CPU does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Queue-like request handlers:&lt;/strong&gt; if work piles up inside the process, a latency gauge can be a better scaling input than container CPU.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Existing observability signals:&lt;/strong&gt; if Splunk already has the metric, KEDA can turn it into a Kubernetes scaling signal.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a real service, I would spend most of the time choosing the query and target. The harder question is: what metric means "add pods now" without scaling on noise?&lt;/p&gt;

&lt;h2&gt;
  
  
  Clean up (the EKS cluster and nodes cost money while they run)
&lt;/h2&gt;

&lt;p&gt;Run the cleanup script first so Helm releases and namespaces are removed, then destroy the AWS infrastructure.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bash cleanup.sh
terraform destroy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lab cleanup removed the Helm releases and namespaces, and the final destroy removed &lt;code&gt;18&lt;/code&gt; Terraform resources.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I did not test
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;I did not test scale-to-zero. This lab keeps &lt;code&gt;minReplicaCount: 1&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;I did not compare latency scaling against a CPU HPA on the same Deployment.&lt;/li&gt;
&lt;li&gt;I did not tune the SignalFlow query for production alert quality; I used the lab metric and a &lt;code&gt;400&lt;/code&gt; ms target.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Every file in this lab
&lt;/h2&gt;

&lt;p&gt;Everything above runs from these files. Open any of them to copy it, or clone the repo as shown:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/Abhimanyu9988/Abhimanyu9988.github.io/blob/main/labs/eks-app-slow-cpu-fine-scale-on-splunk-latency-with-keda/README.md" rel="noopener noreferrer"&gt;&lt;code&gt;README.md&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/Abhimanyu9988/Abhimanyu9988.github.io/blob/main/labs/eks-app-slow-cpu-fine-scale-on-splunk-latency-with-keda/cleanup.sh" rel="noopener noreferrer"&gt;&lt;code&gt;cleanup.sh&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/Abhimanyu9988/Abhimanyu9988.github.io/blob/main/labs/eks-app-slow-cpu-fine-scale-on-splunk-latency-with-keda/fix.sh" rel="noopener noreferrer"&gt;&lt;code&gt;fix.sh&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/Abhimanyu9988/Abhimanyu9988.github.io/blob/main/labs/eks-app-slow-cpu-fine-scale-on-splunk-latency-with-keda/k8s/00-namespace.yaml" rel="noopener noreferrer"&gt;&lt;code&gt;k8s/00-namespace.yaml&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/Abhimanyu9988/Abhimanyu9988.github.io/blob/main/labs/eks-app-slow-cpu-fine-scale-on-splunk-latency-with-keda/k8s/10-app.yaml" rel="noopener noreferrer"&gt;&lt;code&gt;k8s/10-app.yaml&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/Abhimanyu9988/Abhimanyu9988.github.io/blob/main/labs/eks-app-slow-cpu-fine-scale-on-splunk-latency-with-keda/k8s/20-loadgen.yaml" rel="noopener noreferrer"&gt;&lt;code&gt;k8s/20-loadgen.yaml&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/Abhimanyu9988/Abhimanyu9988.github.io/blob/main/labs/eks-app-slow-cpu-fine-scale-on-splunk-latency-with-keda/main.tf" rel="noopener noreferrer"&gt;&lt;code&gt;main.tf&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/Abhimanyu9988/Abhimanyu9988.github.io/blob/main/labs/eks-app-slow-cpu-fine-scale-on-splunk-latency-with-keda/providers.tf" rel="noopener noreferrer"&gt;&lt;code&gt;providers.tf&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/Abhimanyu9988/Abhimanyu9988.github.io/blob/main/labs/eks-app-slow-cpu-fine-scale-on-splunk-latency-with-keda/reproduce.sh" rel="noopener noreferrer"&gt;&lt;code&gt;reproduce.sh&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/Abhimanyu9988/Abhimanyu9988.github.io/tree/main/labs/eks-app-slow-cpu-fine-scale-on-splunk-latency-with-keda/evidence" rel="noopener noreferrer"&gt;&lt;code&gt;evidence/&lt;/code&gt;&lt;/a&gt;: the raw output of my run&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://keda.sh/docs/latest/scalers/splunk-observability/" rel="noopener noreferrer"&gt;KEDA Splunk Observability scaler&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/Abhimanyu9988/Abhimanyu9988.github.io/tree/main/labs/eks-app-slow-cpu-fine-scale-on-splunk-latency-with-keda" rel="noopener noreferrer"&gt;Lab code and raw output&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Disclosure: I work at Splunk, part of Cisco. This is my personal blog; the post isn't sponsored or reviewed by my employer.
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;I built this in a disposable AWS lab. The lab code and raw output are in the repo. Drafted with AI assistance and reviewed by me.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>eks</category>
      <category>keda</category>
      <category>splunk</category>
    </item>
    <item>
      <title>From Broken Ingress to Healthy ALB: An EKS and Splunk Hands-On Lab</title>
      <dc:creator>Abhimanyu Bajaj</dc:creator>
      <pubDate>Thu, 22 Oct 2026 11:57:38 +0000</pubDate>
      <link>https://dev.to/abhimanyu9988/from-broken-ingress-to-healthy-alb-an-eks-and-splunk-hands-on-lab-4n5h</link>
      <guid>https://dev.to/abhimanyu9988/from-broken-ingress-to-healthy-alb-an-eks-and-splunk-hands-on-lab-4n5h</guid>
      <description>&lt;p&gt;You’ll take an EKS Ingress with an empty ADDRESS and turn it into a working internet-facing ALB, then prove it with curl, healthy targets, controller reconciliation logs, and Splunk metrics. This is the kind of failure I see in real clusters: the Kubernetes YAML can look right while IAM, Pod Identity, subnet tags, and load balancer reconciliation decide whether traffic ever reaches a pod.&lt;/p&gt;

&lt;p&gt;In this lab, the before-state is one specific case: the &lt;code&gt;alb&lt;/code&gt; Ingress exists before the AWS Load Balancer Controller exists. The lab still creates the IAM role, Pod Identity association, and public subnet tags that the controller needs after it is installed.&lt;/p&gt;

&lt;p&gt;In my run, the proof ended with &lt;strong&gt;HTTP 200, 2/2 healthy ALB targets, 1 successful reconciliation, 0 reconciler errors, and 1,968 Splunk cluster time series&lt;/strong&gt;. Here’s how it works, and how to build it yourself.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;What you get:&lt;/strong&gt; EKS 1.32, two &lt;code&gt;t3.large&lt;/code&gt; worker nodes, nginx, an &lt;code&gt;alb&lt;/code&gt; Ingress, an internet-facing ALB, and Splunk Observability Cloud data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The trick:&lt;/strong&gt; the Ingress only becomes an ALB after the AWS Load Balancer Controller is installed with AWS permissions through EKS Pod Identity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The proof:&lt;/strong&gt; curl returned HTTP 200, ALB target health was 2/2, the controller logged 1 successful reconciliation and 0 errors, and Splunk returned cluster/controller/app series.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time and cost:&lt;/strong&gt; the reproduced-and-fixed run took 28.3 minutes. The lab uses one EKS control plane, two &lt;code&gt;t3.large&lt;/code&gt; instances, and one ALB for about an hour, estimated as well under 1 USD.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why a valid Ingress can still have no ALB
&lt;/h2&gt;

&lt;p&gt;A Kubernetes Ingress is a request. It is not the load balancer by itself.&lt;/p&gt;

&lt;p&gt;Terraform creates the VPC, two public subnets tagged &lt;code&gt;kubernetes.io/role/elb=1&lt;/code&gt;, the EKS cluster, two EC2 worker nodes, the Pod Identity agent, and the IAM role for the controller. Then &lt;code&gt;reproduce.sh&lt;/code&gt; deploys the app and Ingress before any controller is installed.&lt;/p&gt;

&lt;p&gt;The Deployment is ready and the Service exists, but nothing is watching &lt;code&gt;ingressClassName: alb&lt;/code&gt; yet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;== Ingress status (ADDRESS should be empty: nothing reconciles it)
NAME   CLASS   HOSTS   ADDRESS   PORTS   AGE
web    alb     *                 80      34s
Ingress hostname: ''

== Ingress events
Events:       &amp;lt;none&amp;gt;

== IngressClass objects and controller pods
No resources found
aws-load-balancer-controller deployments: 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the useful gap: no controller, no &lt;code&gt;IngressClass&lt;/code&gt;, no ALB, no target group, and no Splunk data for this cluster yet.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fveyd1aaa4iwie550y8uo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fveyd1aaa4iwie550y8uo.png" alt="reproduce.sh: Ingress with empty ADDRESS" width="799" height="277"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The trick: give the &lt;code&gt;alb&lt;/code&gt; Ingress a controller with AWS permissions
&lt;/h2&gt;

&lt;p&gt;The AWS Load Balancer Controller watches Kubernetes resources and creates AWS load balancer resources from them. In this lab, Terraform binds the controller IAM role to the service account &lt;code&gt;kube-system/aws-load-balancer-controller&lt;/code&gt; with EKS Pod Identity.&lt;/p&gt;

&lt;p&gt;The Ingress stays small:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;networking.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Ingress&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;alb.ingress.kubernetes.io/scheme&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;internet-facing&lt;/span&gt;
    &lt;span class="na"&gt;alb.ingress.kubernetes.io/target-type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ip&lt;/span&gt;
    &lt;span class="na"&gt;alb.ingress.kubernetes.io/healthcheck-path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/healthz&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ingressClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;alb&lt;/span&gt;
&lt;span class="nn"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For your service, change the namespace, service name, port, health check path, and whether the ALB should be internet-facing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build it yourself in 4 steps
&lt;/h2&gt;

&lt;p&gt;You need Terraform, aws CLI v2, kubectl, helm 3.16 or newer, jq, and curl, plus a Splunk Observability Cloud ingest token and API token. The access token sends data; the API token is used to query for proof.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Clone the lab and set the names once
&lt;/h3&gt;

&lt;p&gt;This keeps Terraform, Kubernetes labels, and Splunk queries on the same lab name.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/Abhimanyu9988/Abhimanyu9988.github.io.git
&lt;span class="nb"&gt;cd &lt;/span&gt;Abhimanyu9988.github.io/labs/from-broken-ingress-to-healthy-alb-an-eks-and-splunk-hands

&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;AWS_REGION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;eu-central-1
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;LAB_NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;alb-ingress-lab
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;TF_VAR_lab_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$LAB_NAME&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;SPLUNK_REALM&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;us1
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;SPLUNK_ACCESS_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;...
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;SPLUNK_API_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use your own Splunk realm and tokens from your Splunk Observability Cloud org.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Create the EKS foundation (609 seconds in my run)
&lt;/h3&gt;

&lt;p&gt;The repo includes the Terraform. The provider setup supplies the region and permissions boundary, so do not pass those as Terraform variables here.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform init &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; terraform apply
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My apply created 23 resources and printed the cluster name, VPC ID, region, and controller role ARN.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Reproduce the empty ADDRESS state (48 seconds in my run)
&lt;/h3&gt;

&lt;p&gt;This deploys nginx and the Ingress before the controller is installed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./reproduce.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see an empty Ingress hostname, no &lt;code&gt;IngressClass&lt;/code&gt;, 0 controller deployments, and 0 ALBs tagged for the cluster.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Install the controller and prove the ALB (148 seconds in my run)
&lt;/h3&gt;

&lt;p&gt;The script installs the AWS Load Balancer Controller chart &lt;code&gt;1.13.0&lt;/code&gt; for app &lt;code&gt;v2.13.0&lt;/code&gt;, installs the Splunk OpenTelemetry Collector chart &lt;code&gt;0.116.0&lt;/code&gt;, waits for the ALB, checks targets, curls the ALB, checks logs, and queries Splunk.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./fix.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One lab detail: recent Helm versions reject that Splunk chart schema because one &lt;code&gt;deprecated&lt;/code&gt; field is a string where the metaschema wants a boolean, so the script uses &lt;code&gt;--skip-schema-validation&lt;/code&gt; when Helm supports it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp35m3i64wm88egknzrti.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp35m3i64wm88egknzrti.png" alt="fix.sh: Ingress gets an ALB address and active ALB" width="799" height="209"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqlfr1c109xhe2agqoz1p.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqlfr1c109xhe2agqoz1p.png" alt="Targets healthy, curl returns 200" width="800" height="255"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The proof: HTTP 200, 2 healthy targets, and Splunk series
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;AWS Load Balancer Controller deployment&lt;/td&gt;
&lt;td&gt;1/1 available&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ingress ADDRESS&lt;/td&gt;
&lt;td&gt;&lt;code&gt;k8s-demo-web-70bb130243-28212986.eu-central-1.elb.amazonaws.com&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ALB state&lt;/td&gt;
&lt;td&gt;active&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ALB target health&lt;/td&gt;
&lt;td&gt;2/2 healthy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTTP status through ALB&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Controller reconciliations&lt;/td&gt;
&lt;td&gt;1 successful, 0 errors&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Splunk cluster time series&lt;/td&gt;
&lt;td&gt;1,968&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Splunk controller pod time series&lt;/td&gt;
&lt;td&gt;1,621&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Splunk app pod time series&lt;/td&gt;
&lt;td&gt;34&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Controller Prometheus metric series&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The target health and curl output prove AWS is sending traffic to pod IPs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;== 4. ALB target health (target-type ip, one target per pod)
10.20.0.106 80  healthy
10.20.0.53  80  healthy
healthy targets: 2/2

== 5. curl through the load balancer: http://k8s-demo-web-70bb130243-28212986.eu-central-1.elb.amazonaws.com/
HTTP status: 200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The controller logged the reconciliation path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{"level":"info","ts":"2026-10-22T09:51:01Z","logger":"controllers.ingress","msg":"creating targetGroup","stackID":"demo/web","resourceID":"demo/web-web:80"}
{"level":"info","ts":"2026-10-22T09:51:02Z","logger":"controllers.ingress","msg":"created loadBalancer","stackID":"demo/web","resourceID":"LoadBalancer","arn":"arn:aws:elasticloadbalancing:eu-central-1:&amp;lt;account-id&amp;gt;:loadbalancer/ap
{"level":"info","ts":"2026-10-22T09:51:02Z","logger":"controllers.ingress","msg":"successfully deployed model","ingressGroup":"demo/web"}
{"level":"info","ts":"2026-10-22T09:51:03Z","msg":"registering targets","arn":"arn:aws:elasticloadbalancing:eu-central-1:&amp;lt;account-id&amp;gt;:targetgroup/k8s-demo-web-f26d64a1b9/dc37538e55c037db","targets":[{"Id":"10.20.0.106","Availabili
successful reconciliations: 1, reconciler errors: 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Splunk had data for the same lab name on the first attempt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;attempt 1: cluster=1968 controller-pods=1621 app-pods=34
-- controller Prometheus metrics (best effort):
controller_runtime_reconcile_total series: 12

Proof: curl 200 via k8s-demo-web-70bb130243-28212986.eu-central-1.elb.amazonaws.com, 2/2 targets healthy, controller reconciled, Splunk has cluster/controller/app series.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Use it for real ingress checks
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Check reconciliation before changing the app.&lt;/strong&gt; If &lt;code&gt;ADDRESS&lt;/code&gt; is empty, confirm the &lt;code&gt;IngressClass&lt;/code&gt;, controller deployment, Pod Identity association, subnet tags, and controller logs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep health checks boring.&lt;/strong&gt; This lab uses &lt;code&gt;/healthz&lt;/code&gt;, a 10-second interval, and a healthy threshold of 2.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch Kubernetes and AWS.&lt;/strong&gt; Pod readiness tells you the app is ready. ALB target health tells you AWS can reach it. Splunk gives you the cluster, controller, and app view together.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Clean up (the ALB costs money while it runs)
&lt;/h2&gt;

&lt;p&gt;Delete Kubernetes-created load balancer resources before destroying Terraform. The cleanup script waits for the controller-managed ALB, target groups, Helm releases, and security groups to go away.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./cleanup.sh
terraform destroy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My cleanup script finished after 37 seconds, and Terraform destroyed 23 resources.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I didn't test
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;HTTPS listeners, ACM certificates, or redirects from HTTP to HTTPS.&lt;/li&gt;
&lt;li&gt;Private/internal ALBs.&lt;/li&gt;
&lt;li&gt;Production sizing, autoscaling, or multi-AZ pod spread beyond this two-node lab.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Every file in this lab
&lt;/h2&gt;

&lt;p&gt;Everything above runs from these files. Open any of them to copy it, or clone the repo as shown:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/Abhimanyu9988/Abhimanyu9988.github.io/blob/main/labs/from-broken-ingress-to-healthy-alb-an-eks-and-splunk-hands/README.md" rel="noopener noreferrer"&gt;&lt;code&gt;README.md&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/Abhimanyu9988/Abhimanyu9988.github.io/blob/main/labs/from-broken-ingress-to-healthy-alb-an-eks-and-splunk-hands/cleanup.sh" rel="noopener noreferrer"&gt;&lt;code&gt;cleanup.sh&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/Abhimanyu9988/Abhimanyu9988.github.io/blob/main/labs/from-broken-ingress-to-healthy-alb-an-eks-and-splunk-hands/fix.sh" rel="noopener noreferrer"&gt;&lt;code&gt;fix.sh&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/Abhimanyu9988/Abhimanyu9988.github.io/blob/main/labs/from-broken-ingress-to-healthy-alb-an-eks-and-splunk-hands/k8s/app.yaml" rel="noopener noreferrer"&gt;&lt;code&gt;k8s/app.yaml&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/Abhimanyu9988/Abhimanyu9988.github.io/blob/main/labs/from-broken-ingress-to-healthy-alb-an-eks-and-splunk-hands/k8s/ingress.yaml" rel="noopener noreferrer"&gt;&lt;code&gt;k8s/ingress.yaml&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/Abhimanyu9988/Abhimanyu9988.github.io/blob/main/labs/from-broken-ingress-to-healthy-alb-an-eks-and-splunk-hands/main.tf" rel="noopener noreferrer"&gt;&lt;code&gt;main.tf&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/Abhimanyu9988/Abhimanyu9988.github.io/blob/main/labs/from-broken-ingress-to-healthy-alb-an-eks-and-splunk-hands/providers.tf" rel="noopener noreferrer"&gt;&lt;code&gt;providers.tf&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/Abhimanyu9988/Abhimanyu9988.github.io/blob/main/labs/from-broken-ingress-to-healthy-alb-an-eks-and-splunk-hands/reproduce.sh" rel="noopener noreferrer"&gt;&lt;code&gt;reproduce.sh&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/Abhimanyu9988/Abhimanyu9988.github.io/tree/main/labs/from-broken-ingress-to-healthy-alb-an-eks-and-splunk-hands/evidence" rel="noopener noreferrer"&gt;&lt;code&gt;evidence/&lt;/code&gt;&lt;/a&gt;: the raw output of my run&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://kubernetes-sigs.github.io/aws-load-balancer-controller/latest/" rel="noopener noreferrer"&gt;AWS Load Balancer Controller documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/eks/latest/userguide/pod-identities.html" rel="noopener noreferrer"&gt;Amazon EKS Pod Identities&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/kubernetes-sigs/aws-load-balancer-controller/releases/tag/v3.6.1" rel="noopener noreferrer"&gt;AWS Load Balancer Controller v3.6.1 release&lt;/a&gt; — background source; this lab pins chart &lt;code&gt;1.13.0&lt;/code&gt; and app &lt;code&gt;v2.13.0&lt;/code&gt; so Terraform downloads the matching IAM policy.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Disclosure: I work at Splunk, part of Cisco. This is my personal blog; the post isn't sponsored or reviewed by my employer.
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;I built this in a disposable AWS lab. The lab code and raw output are in the repo. Drafted with AI assistance and reviewed by me.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>eks</category>
      <category>kubernetes</category>
      <category>splunk</category>
    </item>
    <item>
      <title>Trace a Java service on EKS with one annotation, zero code changes</title>
      <dc:creator>Abhimanyu Bajaj</dc:creator>
      <pubDate>Wed, 21 Oct 2026 18:10:48 +0000</pubDate>
      <link>https://dev.to/abhimanyu9988/trace-a-java-service-on-eks-with-one-annotation-zero-code-changes-3314</link>
      <guid>https://dev.to/abhimanyu9988/trace-a-java-service-on-eks-with-one-annotation-zero-code-changes-3314</guid>
      <description>&lt;p&gt;Getting traces out of a Java service usually means a ticket to the app team; in this build, one EKS deployment annotation injects the OpenTelemetry Java agent with no code change and no image rebuild. I’ll start with a plain Java service that has no tracing, then add the Operator, an Instrumentation resource, and a Collector until spans show up with service.name=orders-java.&lt;/p&gt;

&lt;p&gt;The same image stays in place: &lt;code&gt;eclipse-temurin:17.0.13_11-jdk&lt;/code&gt;. In my run, the instrumented rollout finished in &lt;strong&gt;85 seconds&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I work a lot with observability agents inside Kubernetes clusters, so I want this kind of change proven from cluster output, not only from a diagram.&lt;/p&gt;

&lt;p&gt;Here's how it works, and how to build it yourself.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;What you get:&lt;/strong&gt; an EKS cluster where a plain Java service emits OpenTelemetry traces without source edits or an image rebuild.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The trick:&lt;/strong&gt; the OpenTelemetry Operator watches one pod-template annotation and injects the Java agent plus OTEL environment variables.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The proof:&lt;/strong&gt; same image before and after, rollout finished in 85s, Collector received 4 Java client spans with &lt;code&gt;service.name: Str(orders-java)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time and cost:&lt;/strong&gt; my reproduced run took 23.6 minutes total including cluster creation; the README says the EKS control plane takes about 10 minutes and the run costs well under 1 USD.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why a healthy Java pod can still produce no traces
&lt;/h2&gt;

&lt;p&gt;A service can serve traffic and still be invisible to tracing. In the baseline run, &lt;code&gt;/orders&lt;/code&gt; returned HTTP 200s, but there was no Java agent, no OTEL environment, no init container, and no Collector.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;== Evidence of the gap
-- Tracing-related env vars in the app container (JAVA_TOOL_OPTIONS / OTEL_*):
(none)
-- Init containers on the pod:
(none)
-- Java agent mentions in app logs:
count=0
-- OpenTelemetry CRDs / collector in the cluster:
opentelemetry CRDs installed: 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the platform-team problem: the app is up, but nothing is creating spans and nothing is receiving them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trick: mutate the pod template, not the Java code
&lt;/h2&gt;

&lt;p&gt;The OpenTelemetry Operator adds a Kubernetes &lt;code&gt;Instrumentation&lt;/code&gt; resource. That resource tells the injected agent where to export traces and which defaults to use.&lt;/p&gt;

&lt;p&gt;Then this annotation on the Deployment pod template turns on Java auto-instrumentation:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;instrumentation.opentelemetry.io/inject-java: "true"&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;On rollout, the Operator adds an init container, copies the Java agent into the pod, sets &lt;code&gt;JAVA_TOOL_OPTIONS&lt;/code&gt;, sets the OTLP endpoint, and gives the service a name. In this lab, that name is &lt;code&gt;orders-java&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build it yourself in 5 steps
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1: Create the EKS lab cluster (about 10 minutes for the control plane)
&lt;/h3&gt;

&lt;p&gt;This creates a VPC, an EKS cluster, and two self-managed &lt;code&gt;t3.medium&lt;/code&gt; worker nodes joined with &lt;code&gt;nodeadm&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/Abhimanyu9988/Abhimanyu9988.github.io.git
&lt;span class="nb"&gt;cd &lt;/span&gt;Abhimanyu9988.github.io/labs/trace-a-java-service-on-eks-with-one-annotation-zero-code
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;AWS_REGION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;eu-central-1 &lt;span class="nv"&gt;LAB_NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;otel-java &lt;span class="nv"&gt;TF_VAR_lab_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;otel-java
terraform init &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; terraform apply &lt;span class="nt"&gt;-auto-approve&lt;/span&gt;
./reproduce.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;reproduce.sh&lt;/code&gt; waits for nodes, applies &lt;code&gt;k8s/app.yaml&lt;/code&gt;, starts the load generator, and prints the before-state evidence.&lt;/p&gt;

&lt;p&gt;If your clone only has &lt;code&gt;main.tf&lt;/code&gt;, add a small Terraform file with the variable declarations and provider wiring the README calls out. Set &lt;code&gt;permissions_boundary&lt;/code&gt; to &lt;code&gt;null&lt;/code&gt; if you do not use one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"region"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;default&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"eu-central-1"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"lab_name"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"permissions_boundary"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;default&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;provider&lt;/span&gt; &lt;span class="s2"&gt;"aws"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;region&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;region&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 2: Keep the app Deployment plain on purpose
&lt;/h3&gt;

&lt;p&gt;The sample app lives in &lt;code&gt;k8s/app.yaml&lt;/code&gt;. It mounts Java source from a ConfigMap and runs it with the JDK image. For your own service, the annotation still belongs under &lt;code&gt;spec.template.metadata.annotations&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;orders-java&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;demo&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;orders-java&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;orders-java&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app&lt;/span&gt;
          &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;eclipse-temurin:17.0.13_11-jdk&lt;/span&gt;
          &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;java"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-Xmx256m"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/app/Main.java"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
          &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;containerPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
          &lt;span class="na"&gt;readinessProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;tcpSocket&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
            &lt;span class="na"&gt;initialDelaySeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
            &lt;span class="na"&gt;periodSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
          &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;200m&lt;/span&gt;
              &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;384Mi&lt;/span&gt;
            &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;768Mi&lt;/span&gt;
          &lt;span class="na"&gt;volumeMounts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;src&lt;/span&gt;
              &lt;span class="na"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/app&lt;/span&gt;
      &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;src&lt;/span&gt;
          &lt;span class="na"&gt;configMap&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;orders-java-src&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Change the image, command, ports, and probes for your service. Keep the pod-template location for the annotation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Add a Collector that proves spans in logs
&lt;/h3&gt;

&lt;p&gt;The Collector manifest is &lt;code&gt;k8s/collector.yaml&lt;/code&gt;. It uses the debug exporter so the proof stays inside Kubernetes logs instead of depending on a tracing backend.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Namespace&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;observability&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;opentelemetry.io/v1beta1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;OpenTelemetryCollector&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;otel&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;observability&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deployment&lt;/span&gt;
  &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
  &lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;receivers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;otlp&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;protocols&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;grpc&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;0.0.0.0:4317&lt;/span&gt;
          &lt;span class="na"&gt;http&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;0.0.0.0:4318&lt;/span&gt;
    &lt;span class="na"&gt;processors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;batch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{}&lt;/span&gt;
    &lt;span class="na"&gt;exporters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;debug&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;verbosity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;detailed&lt;/span&gt;
    &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;pipelines&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;traces&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;receivers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;otlp&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
          &lt;span class="na"&gt;processors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;batch&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
          &lt;span class="na"&gt;exporters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;debug&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In a real cluster, keep the OTLP receiver and replace the debug exporter with your tracing backend exporter.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Point Java auto-instrumentation at the Collector
&lt;/h3&gt;

&lt;p&gt;The Instrumentation resource is &lt;code&gt;k8s/instrumentation.yaml&lt;/code&gt;. It points Java agents in the &lt;code&gt;demo&lt;/code&gt; namespace at the in-cluster Collector service and turns off metrics and logs for this tracing-only lab.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;opentelemetry.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Instrumentation&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;java-auto&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;demo&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;exporter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://otel-collector.observability.svc.cluster.local:4318&lt;/span&gt;
  &lt;span class="na"&gt;propagators&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;tracecontext&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;baggage&lt;/span&gt;
  &lt;span class="na"&gt;sampler&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;parentbased_always_on&lt;/span&gt;
  &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;OTEL_EXPORTER_OTLP_PROTOCOL&lt;/span&gt;
      &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http/protobuf&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;OTEL_METRICS_EXPORTER&lt;/span&gt;
      &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;none&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;OTEL_LOGS_EXPORTER&lt;/span&gt;
      &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;none&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Change the endpoint if your Collector runs in a different namespace or you send directly to an external endpoint.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Install the Operator and add the one annotation
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;fix.sh&lt;/code&gt; installs the pinned Operator Helm chart &lt;code&gt;0.71.2&lt;/code&gt;, applies &lt;code&gt;k8s/collector.yaml&lt;/code&gt; and &lt;code&gt;k8s/instrumentation.yaml&lt;/code&gt;, patches the Deployment, waits for rollout, and prints Collector logs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./fix.sh
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; observability logs deploy/otel-collector &lt;span class="nt"&gt;--tail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nt"&gt;-1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The patch it applies is the only app-side change: &lt;code&gt;instrumentation.opentelemetry.io/inject-java: "true"&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The proof: same image, injected agent, Collector received spans
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;What changed&lt;/th&gt;
&lt;th&gt;Evidence&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Before&lt;/td&gt;
&lt;td&gt;App deployed with no tracing&lt;/td&gt;
&lt;td&gt;no &lt;code&gt;JAVA_TOOL_OPTIONS&lt;/code&gt;, no init containers, OpenTelemetry CRDs installed: 0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Operator&lt;/td&gt;
&lt;td&gt;OpenTelemetry Operator installed&lt;/td&gt;
&lt;td&gt;chart &lt;code&gt;opentelemetry-operator-0.71.2&lt;/code&gt;, app version &lt;code&gt;0.110.0&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Collector&lt;/td&gt;
&lt;td&gt;OTLP receiver created&lt;/td&gt;
&lt;td&gt;service exposed &lt;code&gt;4317/TCP,4318/TCP&lt;/code&gt; inside the cluster&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Annotation&lt;/td&gt;
&lt;td&gt;Deployment patched&lt;/td&gt;
&lt;td&gt;rollout finished in 85s, image still &lt;code&gt;eclipse-temurin:17.0.13_11-jdk&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trace proof&lt;/td&gt;
&lt;td&gt;Java agent exported spans&lt;/td&gt;
&lt;td&gt;4 spans received, &lt;code&gt;service.name: Str(orders-java)&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;After rollout, the new pod showed the auto-instrumentation init container and Java agent startup line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;== Injected pod: orders-java-578744fdb4-lxj4j
-- init containers:
opentelemetry-auto-instrumentation-java
-- agent image:
ghcr.io/open-telemetry/opentelemetry-operator/autoinstrumentation-java:1.33.5
-- app log head:
Picked up JAVA_TOOL_OPTIONS:  -javaagent:/otel-auto-instrumentation-java/javaagent.jar
[otel.javaagent 2026-10-19 18:15:24:512 +0000] [main] INFO io.opentelemetry.javaagent.tooling.VersionLogger - opentelemetry-javaagent - version: 1.33.5
orders-java listening on 8080
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Collector excerpt is cut, but it shows the important path: Java agent to OTLP HTTP to Collector debug output.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2026-10-19T18:15:40.063Z    info    ResourceSpans #0
...
     -&amp;gt; service.name: Str(orders-java)
     -&amp;gt; telemetry.sdk.language: Str(java)
InstrumentationScope io.opentelemetry.java-http-client 1.33.5-alpha
Span #0
    Name           : GET
    Kind           : Client
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 4 spans in this run are outbound Java HTTP client spans from the &lt;code&gt;/orders&lt;/code&gt; to &lt;code&gt;/inventory&lt;/code&gt; call. The build proves the Java auto-instrumentation and Collector path; it did not capture inbound server spans for &lt;code&gt;/orders&lt;/code&gt; in this run.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Faxgaubogc7ne2qvv1izs.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Faxgaubogc7ne2qvv1izs.png" alt="Annotation patch and rollout, same image" width="799" height="209"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqgs56yyvyfv33ljm9p6q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqgs56yyvyfv33ljm9p6q.png" alt="Env vars injected by the Operator" width="799" height="277"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2y56jwi23z6miejlc7gh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2y56jwi23z6miejlc7gh.png" alt="Collector summary: spans received from orders-java" width="799" height="209"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Use it for services you do not want to rebuild
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Platform-owned rollout:&lt;/strong&gt; create the Operator, Collector, and &lt;code&gt;Instrumentation&lt;/code&gt; resource once per namespace, then let teams opt in with the pod-template annotation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Service naming:&lt;/strong&gt; accept the deployment-derived &lt;code&gt;OTEL_SERVICE_NAME&lt;/code&gt;, or set &lt;code&gt;resource.opentelemetry.io/service.name&lt;/code&gt; on the pod template if your naming standard is different.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backend swap:&lt;/strong&gt; keep the same app annotation and Instrumentation shape, but replace the debug exporter with the exporter your tracing platform needs.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Clean up (the EKS cluster and EC2 nodes cost money while they run)
&lt;/h2&gt;

&lt;p&gt;Remove the Kubernetes objects first, then destroy the AWS resources.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./cleanup.sh
terraform destroy &lt;span class="nt"&gt;-auto-approve&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What I didn't test
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;I did not send these spans to a production tracing backend; I used the Collector debug exporter only.&lt;/li&gt;
&lt;li&gt;I did not capture inbound server spans or parent/child links in this run.&lt;/li&gt;
&lt;li&gt;I did not test private subnets, managed node groups, Fargate, IRSA, Node.js, .NET, or Python auto-instrumentation.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Every file in this lab
&lt;/h2&gt;

&lt;p&gt;Everything above runs from these files. Open any of them to copy it, or clone the repo as shown:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/Abhimanyu9988/Abhimanyu9988.github.io/blob/main/labs/trace-a-java-service-on-eks-with-one-annotation-zero-code/README.md" rel="noopener noreferrer"&gt;&lt;code&gt;README.md&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/Abhimanyu9988/Abhimanyu9988.github.io/blob/main/labs/trace-a-java-service-on-eks-with-one-annotation-zero-code/cleanup.sh" rel="noopener noreferrer"&gt;&lt;code&gt;cleanup.sh&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/Abhimanyu9988/Abhimanyu9988.github.io/blob/main/labs/trace-a-java-service-on-eks-with-one-annotation-zero-code/fix.sh" rel="noopener noreferrer"&gt;&lt;code&gt;fix.sh&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/Abhimanyu9988/Abhimanyu9988.github.io/blob/main/labs/trace-a-java-service-on-eks-with-one-annotation-zero-code/k8s/app.yaml" rel="noopener noreferrer"&gt;&lt;code&gt;k8s/app.yaml&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/Abhimanyu9988/Abhimanyu9988.github.io/blob/main/labs/trace-a-java-service-on-eks-with-one-annotation-zero-code/k8s/collector.yaml" rel="noopener noreferrer"&gt;&lt;code&gt;k8s/collector.yaml&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/Abhimanyu9988/Abhimanyu9988.github.io/blob/main/labs/trace-a-java-service-on-eks-with-one-annotation-zero-code/k8s/instrumentation.yaml" rel="noopener noreferrer"&gt;&lt;code&gt;k8s/instrumentation.yaml&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/Abhimanyu9988/Abhimanyu9988.github.io/blob/main/labs/trace-a-java-service-on-eks-with-one-annotation-zero-code/main.tf" rel="noopener noreferrer"&gt;&lt;code&gt;main.tf&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/Abhimanyu9988/Abhimanyu9988.github.io/blob/main/labs/trace-a-java-service-on-eks-with-one-annotation-zero-code/providers.tf" rel="noopener noreferrer"&gt;&lt;code&gt;providers.tf&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/Abhimanyu9988/Abhimanyu9988.github.io/blob/main/labs/trace-a-java-service-on-eks-with-one-annotation-zero-code/reproduce.sh" rel="noopener noreferrer"&gt;&lt;code&gt;reproduce.sh&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/Abhimanyu9988/Abhimanyu9988.github.io/tree/main/labs/trace-a-java-service-on-eks-with-one-annotation-zero-code/evidence" rel="noopener noreferrer"&gt;&lt;code&gt;evidence/&lt;/code&gt;&lt;/a&gt;: the raw output of my run&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://opentelemetry.io/docs/platforms/kubernetes/operator/" rel="noopener noreferrer"&gt;OpenTelemetry Operator for Kubernetes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://opentelemetry.io/docs/platforms/kubernetes/operator/automatic/" rel="noopener noreferrer"&gt;OpenTelemetry automatic instrumentation with the Operator&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;I built this in a disposable AWS lab. The lab code and raw output are in the repo. Drafted with AI assistance and reviewed by me.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>eks</category>
      <category>opentelemetry</category>
      <category>java</category>
    </item>
    <item>
      <title>Roll back an EKS release in 16 seconds without restarting a pod</title>
      <dc:creator>Abhimanyu Bajaj</dc:creator>
      <pubDate>Mon, 19 Oct 2026 17:55:14 +0000</pubDate>
      <link>https://dev.to/abhimanyu9988/roll-back-an-eks-release-in-16-seconds-without-restarting-a-pod-2fcl</link>
      <guid>https://dev.to/abhimanyu9988/roll-back-an-eks-release-in-16-seconds-without-restarting-a-pod-2fcl</guid>
      <description>&lt;p&gt;Every team running on Kubernetes knows this moment. A release goes out, something looks off, and the only "undo" button is another deploy: roll back the image, wait for new pods, hope nothing else breaks on the way.&lt;/p&gt;

&lt;p&gt;I wanted a rollback that is just a dial. So I built one on EKS. The new version gets 10% of real traffic first, and if it misbehaves, one &lt;code&gt;kubectl&lt;/code&gt; command sends everyone back to the old one. In my lab, traffic was fully back on v1 &lt;strong&gt;16 seconds&lt;/strong&gt; later, and not a single pod restarted.&lt;/p&gt;

&lt;p&gt;Here's how it works, and how to build it yourself.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;What you get:&lt;/strong&gt; an Application Load Balancer in front of two versions of your app, with a traffic dial you turn from Kubernetes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The trick:&lt;/strong&gt; the AWS Load Balancer Controller reads the traffic weights from one Ingress annotation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The proof:&lt;/strong&gt; 90/10, then 50/50, then a rollback to 100/0 in 16 seconds, with 0 pod restarts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time and cost:&lt;/strong&gt; my run took 25 minutes end to end in a throwaway AWS account; the lab budgets about 45 minutes and under 1 USD.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why a normal Kubernetes Service can't give you a 10% canary
&lt;/h2&gt;

&lt;p&gt;A Service spreads requests across whatever pods match its selector. Put 3 v1 pods and 1 v2 pod behind one Service, and v2 gets "about a quarter, depending on luck". That's what my lab showed before the build:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;== Before: how is traffic split between v1 and v2? (40 in-cluster requests to Service 'app')
     34 version-v1
      6 version-v2
v1=34 v2=6  (ratio follows the pod count 3:1; to get 10% you would need 9 v1 pods per v2 pod)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So a real 10% canary means running nine v1 pods for every v2 pod. And rolling back means scaling or redeploying. There is no weight you can set to zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trick: one Ingress, two target groups, one dial
&lt;/h2&gt;

&lt;p&gt;The AWS Load Balancer Controller turns a Kubernetes Ingress into an ALB. Give the Ingress a weighted &lt;code&gt;forward&lt;/code&gt; action, and the ALB splits traffic between two target groups, one per Service, by weight. The number of pods behind each Service no longer matters.&lt;/p&gt;

&lt;p&gt;The whole dial is one annotation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;alb.ingress.kubernetes.io/actions.weighted&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;-&lt;/span&gt;
  &lt;span class="s"&gt;{"type":"forward","forwardConfig":{"targetGroups":[{"serviceName":"app-v1","servicePort":80,"weight":90},{"serviceName":"app-v2","servicePort":80,"weight":10}]}}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Change the numbers, and the controller updates the ALB listener rule. Your Deployments never change. The controller gets its AWS permissions through EKS Pod Identity, so there's no IRSA setup and nothing extra on the node role.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build it yourself in 4 steps
&lt;/h2&gt;

&lt;p&gt;You need Terraform, the AWS CLI v2, kubectl, helm, jq and curl, plus an AWS account you can experiment in.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Create the cluster (about 12 to 15 minutes)
&lt;/h3&gt;

&lt;p&gt;This creates an EKS 1.31 cluster with two &lt;code&gt;t3.medium&lt;/code&gt; workers, the Pod Identity agent and the IAM role for the controller.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/Abhimanyu9988/Abhimanyu9988.github.io.git
&lt;span class="nb"&gt;cd &lt;/span&gt;Abhimanyu9988.github.io/labs/eks-canary-releases-alb
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;AWS_REGION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;eu-central-1
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;LAB_NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;canary
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;TF_VAR_lab_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAB_NAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
terraform init &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; terraform apply
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see &lt;code&gt;Plan: 23 to add, 0 to change, 0 to destroy.&lt;/code&gt; before you confirm.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Deploy two versions of the app
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bash reproduce.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This creates &lt;code&gt;app-v1&lt;/code&gt; (3 pods) and &lt;code&gt;app-v2&lt;/code&gt; (1 pod) in the &lt;code&gt;demo&lt;/code&gt; namespace, one Service for each, and shows the "before" split above.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Add the load balancer and the dial
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bash fix.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This installs the AWS Load Balancer Controller (Helm chart &lt;code&gt;1.11.0&lt;/code&gt;, controller &lt;code&gt;v2.11.0&lt;/code&gt;) with Pod Identity, applies &lt;code&gt;k8s/ingress.yaml&lt;/code&gt; with the 90/10 weights, waits for the ALB, and then walks through 90/10, 50/50 and 100/0, counting 200 requests at each stage.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Turn the dial yourself
&lt;/h3&gt;

&lt;p&gt;Send half the traffic to v2:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; demo annotate ingress app &lt;span class="nt"&gt;--overwrite&lt;/span&gt; &lt;span class="s1"&gt;'alb.ingress.kubernetes.io/actions.weighted={"type":"forward","forwardConfig":{"targetGroups":[{"serviceName":"app-v1","servicePort":80,"weight":50},{"serviceName":"app-v2","servicePort":80,"weight":50}]}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Something wrong with v2? Roll back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; demo annotate ingress app &lt;span class="nt"&gt;--overwrite&lt;/span&gt; &lt;span class="s1"&gt;'alb.ingress.kubernetes.io/actions.weighted={"type":"forward","forwardConfig":{"targetGroups":[{"serviceName":"app-v1","servicePort":80,"weight":100},{"serviceName":"app-v2","servicePort":80,"weight":0}]}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The proof: 90/10, 50/50, and a 16-second rollback
&lt;/h2&gt;

&lt;p&gt;Each stage sent 200 requests through the ALB:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Weights&lt;/th&gt;
&lt;th&gt;ALB listener rule&lt;/th&gt;
&lt;th&gt;What 200 requests hit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;90/10&lt;/td&gt;
&lt;td&gt;app-v1=90, app-v2=10&lt;/td&gt;
&lt;td&gt;v1=179, v2=21&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50/50&lt;/td&gt;
&lt;td&gt;app-v1=50, app-v2=50&lt;/td&gt;
&lt;td&gt;v1=92, v2=108&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100/0 (rollback)&lt;/td&gt;
&lt;td&gt;app-v1=100, app-v2=0&lt;/td&gt;
&lt;td&gt;v1=200, v2=0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rollback timing, straight from the run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ALB rule updated 3s after kubectl annotate; 100% of sampled traffic on v1 after 16s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the pods? The same ones, still running, never restarted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pod app-v1-5b8cd854d6-2k7kl Running restarts=0 age=4m13s
pod app-v1-5b8cd854d6-6m5vm Running restarts=0 age=4m13s
pod app-v1-5b8cd854d6-krvvr Running restarts=0 age=4m13s
pod app-v2-649d75bcd8-dd555 Running restarts=0 age=4m13s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole point: v2 can stay deployed and healthy while the load balancer decides how much traffic it gets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use it for your own releases
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Give each version its own Service&lt;/strong&gt;, like &lt;code&gt;app-v1&lt;/code&gt; and &lt;code&gt;app-v2&lt;/code&gt;. The weighted action points at Services, not Deployments.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make the annotation your release step.&lt;/strong&gt; 10%, 50%, 100% and a rollback to 0% are the same one-line change, so they fit any pipeline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use the official IAM policy in production.&lt;/strong&gt; The lab's controller policy is simplified for a throwaway account; take the official one from the controller release.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Clean up (the ALB costs money while it runs)
&lt;/h2&gt;

&lt;p&gt;Delete the Ingress first, so the controller removes the ALB, target groups and security groups, then destroy everything else. Use the same lab name.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;AWS_REGION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;eu-central-1
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;LAB_NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;canary
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;TF_VAR_lab_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAB_NAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
bash cleanup.sh
terraform destroy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What I didn't test
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;TLS certificates, host-based routing, or several paths on one ALB.&lt;/li&gt;
&lt;li&gt;Automatic promotion or rollback driven by metrics.&lt;/li&gt;
&lt;li&gt;Long-running connections while the weights change.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://kubernetes-sigs.github.io/aws-load-balancer-controller/latest/" rel="noopener noreferrer"&gt;AWS Load Balancer Controller documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/eks/latest/userguide/pod-identities.html" rel="noopener noreferrer"&gt;Amazon EKS Pod Identities&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;I built this in a disposable AWS lab. The lab code and raw output are in the repo. Drafted with AI assistance and reviewed by me.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>eks</category>
      <category>kubernetes</category>
      <category>devops</category>
    </item>
    <item>
      <title>Fargate SQS worker stuck at 0 tasks? The IF(running &gt; 0) guard never gets to run</title>
      <dc:creator>Abhimanyu Bajaj</dc:creator>
      <pubDate>Sun, 18 Oct 2026 15:11:28 +0000</pubDate>
      <link>https://dev.to/abhimanyu9988/fargate-sqs-worker-stuck-at-0-tasks-the-ifrunning-0-guard-never-gets-to-run-19n4</link>
      <guid>https://dev.to/abhimanyu9988/fargate-sqs-worker-stuck-at-0-tasks-the-ifrunning-0-guard-never-gets-to-run-19n4</guid>
      <description>&lt;p&gt;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 &amp;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;This one was reproduced and fixed in eu-central-1.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Problem: an ECS Fargate SQS worker with min capacity 0 stayed at desired=0 even with 10 visible messages.&lt;/p&gt;

&lt;p&gt;Cause: Container Insights &lt;code&gt;RunningTaskCount&lt;/code&gt; had no datapoints at zero tasks, so &lt;code&gt;IF(running &amp;gt; 0, backlog / running, backlog)&lt;/code&gt; returned no &lt;code&gt;bpt&lt;/code&gt; datapoints.&lt;/p&gt;

&lt;p&gt;Fix: use &lt;code&gt;IF(FILL(running, 0) &amp;gt; 0, backlog / FILL(running, 0), backlog)&lt;/code&gt; so the expression falls back to raw backlog at zero tasks.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you'll see
&lt;/h2&gt;

&lt;p&gt;The service starts at zero, as intended:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;== Initial state: desired=0 running=0
[
    {
        "min": 0,
        "max": 5
    }
]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After sending 10 SQS messages, it stays there for the whole wait:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;t+30s desired=0 running=0 visible_in_queue=10
...
t+480s desired=0 running=0 visible_in_queue=10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The target-tracking alarms never get enough data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part is the metric math. SQS backlog has data. &lt;code&gt;RunningTaskCount&lt;/code&gt; does not. The computed backlog-per-task series, &lt;code&gt;bpt&lt;/code&gt;, also has no data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{"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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Final state from the failed run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why it happens
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;That is fine while at least one task is running. The awkward edge is zero.&lt;/p&gt;

&lt;p&gt;In my lab, Container Insights did not publish a &lt;code&gt;RunningTaskCount&lt;/code&gt; datapoint for this service while it had zero running tasks. CloudWatch metric math then returned no datapoints for this expression:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;IF(running &amp;gt; 0, backlog / running, backlog)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;code&gt;running&lt;/code&gt; datapoint to evaluate.&lt;/p&gt;

&lt;p&gt;The Stack Overflow answers suggested wrapping &lt;code&gt;running&lt;/code&gt; with &lt;code&gt;FILL(running, 0)&lt;/code&gt;. My lab confirms that this fix was enough for scale-out from zero. After replacing the policy with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;IF(FILL(running, 0) &amp;gt; 0, backlog / FILL(running, 0), backlog)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Application Auto Scaling set desired count to 5, Fargate tasks reached RUNNING, and the queue drained.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;DesiredTaskCount&lt;/code&gt; may be useful in some designs because Fargate takes time to move from desired to running. I did not test those variants here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Replicate it yourself, step by step
&lt;/h2&gt;

&lt;p&gt;You need AWS CLI, Terraform, &lt;code&gt;jq&lt;/code&gt;, and GNU &lt;code&gt;date&lt;/code&gt;. The lab uses eu-central-1 through its provider config.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Get the lab.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/Abhimanyu9988/Abhimanyu9988.github.io.git
&lt;span class="nb"&gt;cd &lt;/span&gt;Abhimanyu9988.github.io/labs/scale-to-zero-sqs-workers-on-ecs-fargate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In &lt;code&gt;providers.tf&lt;/code&gt;, set &lt;code&gt;permissions_boundary&lt;/code&gt; to &lt;code&gt;null&lt;/code&gt; for your own account if you do not use an IAM permissions boundary.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Initialize Terraform and apply the lab.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform init
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Real init output from the run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Initializing provider plugins...
- Finding hashicorp/aws versions matching "~&amp;gt; 5.80"...
- Installing hashicorp/aws v5.100.0...
- Installed hashicorp/aws v5.100.0 (signed by HashiCorp)

Terraform has been successfully initialized!
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now create the resources:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform apply
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;code&gt;aws-cli&lt;/code&gt; image looping on &lt;code&gt;receive-message&lt;/code&gt;; each message takes 20 seconds in this lab.&lt;/p&gt;

&lt;p&gt;The plan and apply showed 19 resources added:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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/&amp;lt;account-id&amp;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"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Set the shell values the scripts read from Terraform outputs.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;AWS_DEFAULT_REGION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"eu-central-1"&lt;/span&gt;
&lt;span class="nv"&gt;CLUSTER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;terraform output &lt;span class="nt"&gt;-raw&lt;/span&gt; cluster_name&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;SERVICE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;terraform output &lt;span class="nt"&gt;-raw&lt;/span&gt; service_name&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;QURL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;terraform output &lt;span class="nt"&gt;-raw&lt;/span&gt; queue_url&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;QNAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;terraform output &lt;span class="nt"&gt;-raw&lt;/span&gt; queue_name&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;RID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;terraform output &lt;span class="nt"&gt;-raw&lt;/span&gt; resource_id&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;POLICY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;terraform output &lt;span class="nt"&gt;-raw&lt;/span&gt; policy_name&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Check the initial ECS service state and scaling target.
&lt;/li&gt;
&lt;/ol&gt;

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

&lt;/div&gt;



&lt;p&gt;Output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;== Initial state: desired=0 running=0
[
    {
        "min": 0,
        "max": 5
    }
]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Send 10 messages to SQS.
&lt;/li&gt;
&lt;/ol&gt;

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

&lt;/div&gt;



&lt;p&gt;Output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;== Sending 10 messages
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sleep &lt;/span&gt;30
aws ecs describe-services &lt;span class="nt"&gt;--cluster&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CLUSTER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--services&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SERVICE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'services[0].desiredCount'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text
aws ecs describe-services &lt;span class="nt"&gt;--cluster&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CLUSTER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--services&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SERVICE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'services[0].runningCount'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text
aws sqs get-queue-attributes &lt;span class="nt"&gt;--queue-url&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$QURL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--attribute-names&lt;/span&gt; ApproximateNumberOfMessages &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'Attributes.ApproximateNumberOfMessages'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output from the run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;== 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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Ask Application Auto Scaling for the target-tracking alarm names, then ask CloudWatch for their state and reason.
&lt;/li&gt;
&lt;/ol&gt;

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

&lt;/div&gt;



&lt;p&gt;Output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;== 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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Query the metric math directly. This is the same broken expression from the original policy.
&lt;/li&gt;
&lt;/ol&gt;

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

&lt;/div&gt;



&lt;p&gt;Output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;== 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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Check scaling activities. There were none in the reproduced failure.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws application-autoscaling describe-scaling-activities &lt;span class="nt"&gt;--service-namespace&lt;/span&gt; ecs &lt;span class="nt"&gt;--resource-id&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'ScalingActivities[].[StatusCode,Description]'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;== Scaling activities
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The failed reproduction ends like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Apply the fix: replace the target-tracking policy with the same metric, but wrap &lt;code&gt;running&lt;/code&gt; in &lt;code&gt;FILL(running, 0)&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;First confirm the before state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws ecs describe-services &lt;span class="nt"&gt;--cluster&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CLUSTER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--services&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SERVICE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'services[0].desiredCount'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text
aws ecs describe-services &lt;span class="nt"&gt;--cluster&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CLUSTER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--services&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SERVICE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'services[0].runningCount'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text
aws sqs get-queue-attributes &lt;span class="nt"&gt;--queue-url&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$QURL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--attribute-names&lt;/span&gt; ApproximateNumberOfMessages &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'Attributes.ApproximateNumberOfMessages'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;== Before: desired=0 running=0 visible=10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create the fixed policy configuration:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;Output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;== Updating policy with: IF(FILL(running, 0) &amp;gt; 0, backlog / FILL(running, 0), backlog)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Push the fixed policy into Application Auto Scaling:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;Output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;arn:aws:autoscaling:eu-central-1:&amp;lt;account-id&amp;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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Watch the service scale out from zero. The same ECS and SQS reads are repeated every 30 seconds.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sleep &lt;/span&gt;30
aws ecs describe-services &lt;span class="nt"&gt;--cluster&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CLUSTER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--services&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SERVICE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'services[0].desiredCount'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text
aws ecs describe-services &lt;span class="nt"&gt;--cluster&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CLUSTER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--services&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SERVICE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'services[0].runningCount'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text
aws sqs get-queue-attributes &lt;span class="nt"&gt;--queue-url&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$QURL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--attribute-names&lt;/span&gt; ApproximateNumberOfMessages &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'Attributes.ApproximateNumberOfMessages'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text
aws sqs get-queue-attributes &lt;span class="nt"&gt;--queue-url&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$QURL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--attribute-names&lt;/span&gt; ApproximateNumberOfMessagesNotVisible &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'Attributes.ApproximateNumberOfMessagesNotVisible'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;== 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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Check scaling activities again.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws application-autoscaling describe-scaling-activities &lt;span class="nt"&gt;--service-namespace&lt;/span&gt; ecs &lt;span class="nt"&gt;--resource-id&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'ScalingActivities[].[StatusCode,Description]'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;== Scaling activities
Successful  Setting desired count to 5.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Watch the queue drain.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws sqs get-queue-attributes &lt;span class="nt"&gt;--queue-url&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$QURL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--attribute-names&lt;/span&gt; ApproximateNumberOfMessages &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'Attributes.ApproximateNumberOfMessages'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text
aws sqs get-queue-attributes &lt;span class="nt"&gt;--queue-url&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$QURL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--attribute-names&lt;/span&gt; ApproximateNumberOfMessagesNotVisible &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'Attributes.ApproximateNumberOfMessagesNotVisible'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text
aws ecs describe-services &lt;span class="nt"&gt;--cluster&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CLUSTER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--services&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SERVICE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'services[0].desiredCount'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text
aws ecs describe-services &lt;span class="nt"&gt;--cluster&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CLUSTER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--services&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SERVICE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'services[0].runningCount'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;== 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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Clean up the live service state before destroying Terraform resources.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Delete the scaling policy so the autoscaler does not relaunch tasks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws application-autoscaling delete-scaling-policy &lt;span class="nt"&gt;--service-namespace&lt;/span&gt; ecs &lt;span class="nt"&gt;--resource-id&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--scalable-dimension&lt;/span&gt; ecs:service:DesiredCount &lt;span class="nt"&gt;--policy-name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$POLICY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set the ECS service desired count back to zero:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws ecs update-service &lt;span class="nt"&gt;--cluster&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CLUSTER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--service&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SERVICE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--desired-count&lt;/span&gt; 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;List any remaining tasks and stop each one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;t &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;aws ecs list-tasks &lt;span class="nt"&gt;--cluster&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CLUSTER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'taskArns[]'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;aws ecs stop-task &lt;span class="nt"&gt;--cluster&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CLUSTER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--task&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$t&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wait until no task ARNs are returned. The cleanup script repeats this check up to 18 times, 10 seconds apart:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;Purge the queue:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws sqs purge-queue &lt;span class="nt"&gt;--queue-url&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$QURL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cleanup script suppressed command output in my run.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Destroy the lab.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform destroy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output from destroy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Plan: 0 to add, 0 to change, 18 to destroy.
...
Destroy complete! Resources: 18 destroyed.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What I didn't test
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;I did not test the in-flight-message expression using &lt;code&gt;ApproximateNumberOfMessagesNotVisible&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I did not test a separate 0-to-1 scale-out policy keyed on &lt;code&gt;DesiredTaskCount&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost and time
&lt;/h2&gt;

&lt;p&gt;The run was in eu-central-1 and took 19.1 minutes end to end.&lt;/p&gt;

&lt;p&gt;Terraform created 19 resources. After the cleanup step deleted the scaling policy, Terraform destroyed 18 resources.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;RunningTaskCount&lt;/code&gt; as zero with &lt;code&gt;FILL&lt;/code&gt;, otherwise your careful &lt;code&gt;IF&lt;/code&gt; guard may never get a datapoint to evaluate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://stackoverflow.com/questions/80006730/aws-ecs-fargate-sqs-autoscaling-with-min-capacity-0-how-to-bootstrap-from-0-tas" rel="noopener noreferrer"&gt;Stack Overflow: ECS Fargate SQS autoscaling with min capacity 0&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;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.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>ecs</category>
      <category>fargate</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
