<?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: Sagar</title>
    <description>The latest articles on DEV Community by Sagar (@sagar2395).</description>
    <link>https://dev.to/sagar2395</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%2F4196857%2F02a3f847-6a65-482f-b6a6-037c762af22e.jpg</url>
      <title>DEV Community: Sagar</title>
      <link>https://dev.to/sagar2395</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sagar2395"/>
    <language>en</language>
    <item>
      <title>Kubernetes CPU throttling: p99 in seconds, no restarts, and a limit that's too low</title>
      <dc:creator>Sagar</dc:creator>
      <pubDate>Fri, 30 Oct 2026 08:15:33 +0000</pubDate>
      <link>https://dev.to/sagar2395/kubernetes-cpu-throttling-p99-in-seconds-no-restarts-and-a-limit-thats-too-low-39pm</link>
      <guid>https://dev.to/sagar2395/kubernetes-cpu-throttling-p99-in-seconds-no-restarts-and-a-limit-thats-too-low-39pm</guid>
      <description>&lt;p&gt;Your service is slow and nothing looks wrong. The p99 latency is in seconds, the median is fine, every pod is &lt;code&gt;Running&lt;/code&gt; and &lt;code&gt;Ready&lt;/code&gt; with &lt;code&gt;0&lt;/code&gt; restarts, and the nodes are nearly idle. That pattern usually means a CPU limit is throttling the container: it isn't killed, it's paused. Compare &lt;code&gt;kubectl top pod&lt;/code&gt; with the CPU limit in &lt;code&gt;kubectl describe pod&lt;/code&gt;. If the two numbers match, the pod is capped.&lt;/p&gt;

&lt;p&gt;I broke a small Go service on purpose to show this on a real cluster: a "cost review" lowered its CPU limit while a load generator sent it steady traffic. Every output below is copied from that recording (I lined up the columns).&lt;/p&gt;

&lt;h2&gt;
  
  
  What throttling looks like from outside
&lt;/h2&gt;

&lt;p&gt;A throttled pod passes every health check you'd normally look at. The latency alert fired because the p99 stayed above half a second, but the error rate was flat and the pod looked healthy:&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;$ &lt;/span&gt;kubectl get pods &lt;span class="nt"&gt;-n&lt;/span&gt; go-api
NAME                      READY   STATUS    RESTARTS   AGE
go-api-649df775bd-md4lq   1/1     Running   0          4m15s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The nodes weren't busy either, so this wasn't a neighbour stealing CPU:&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;$ &lt;/span&gt;kubectl top nodes
NAME                    CPU&lt;span class="o"&gt;(&lt;/span&gt;cores&lt;span class="o"&gt;)&lt;/span&gt;   CPU&lt;span class="o"&gt;(&lt;/span&gt;%&lt;span class="o"&gt;)&lt;/span&gt;   MEMORY&lt;span class="o"&gt;(&lt;/span&gt;bytes&lt;span class="o"&gt;)&lt;/span&gt;   MEMORY&lt;span class="o"&gt;(&lt;/span&gt;%&lt;span class="o"&gt;)&lt;/span&gt;
k3d-snowops-agent-0-0   151m         3%       860Mi           10%
k3d-snowops-agent-1-0   154m         3%       444Mi           5%
k3d-snowops-server-0    399m         9%       3085Mi          38%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;kubectl rollout history&lt;/code&gt; showed a second revision, so the pod template had changed. That's the "what changed?" clue, but the history doesn't say what.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compare top with the limit
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;kubectl top pod&lt;/code&gt; shows what a container uses, and &lt;code&gt;kubectl describe pod&lt;/code&gt; shows what it's allowed. Put them side by side:&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;$ &lt;/span&gt;kubectl top pod &lt;span class="nt"&gt;-n&lt;/span&gt; go-api
NAME                      CPU&lt;span class="o"&gt;(&lt;/span&gt;cores&lt;span class="o"&gt;)&lt;/span&gt;   MEMORY&lt;span class="o"&gt;(&lt;/span&gt;bytes&lt;span class="o"&gt;)&lt;/span&gt;
go-api-649df775bd-md4lq   11m          29Mi

&lt;span class="nv"&gt;$ &lt;/span&gt;kubectl describe pod go-api-649df775bd-md4lq &lt;span class="nt"&gt;-n&lt;/span&gt; go-api
...
    Limits:
      cpu:     11m
      memory:  128Mi
    Requests:
      cpu:     11m
      memory:  32Mi
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(I cut the rest of the describe output.) 11m of CPU looks like an idle pod. It isn't. It's exactly the limit, and a pod that sits on its limit in every sample is using everything it's allowed. The lab picks this limit from a measurement of your machine, so your number will differ. The match is what matters.&lt;/p&gt;

&lt;p&gt;Grafana's Pod Resources dashboard made it plain. The &lt;code&gt;CPU throttled (share of periods)&lt;/code&gt; panel had go-api's pod throttled in nearly every period:&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%2F9g0h832p30vuib3ue0d8.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%2F9g0h832p30vuib3ue0d8.png" alt="Grafana panel &amp;quot;CPU throttled (share of periods)&amp;quot;: the go-api pod sits near 100% for the whole five minutes" width="799" height="412"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Grafana panel "CPU throttled (share of periods)": the go-api pod sits near 100% for the whole five minutes&lt;/p&gt;

&lt;p&gt;That panel is the ratio of two cAdvisor counters, which you can graph on any cluster with Prometheus:&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;sum &lt;/span&gt;by &lt;span class="o"&gt;(&lt;/span&gt;namespace, pod&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;rate&lt;span class="o"&gt;(&lt;/span&gt;container_cpu_cfs_throttled_periods_total&lt;span class="o"&gt;{&lt;/span&gt;container!&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="o"&gt;}[&lt;/span&gt;2m]&lt;span class="o"&gt;))&lt;/span&gt;
  / &lt;span class="nb"&gt;sum &lt;/span&gt;by &lt;span class="o"&gt;(&lt;/span&gt;namespace, pod&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;rate&lt;span class="o"&gt;(&lt;/span&gt;container_cpu_cfs_periods_total&lt;span class="o"&gt;{&lt;/span&gt;container!&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="o"&gt;}[&lt;/span&gt;2m]&lt;span class="o"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why a CPU limit slows a pod instead of killing it
&lt;/h2&gt;

&lt;p&gt;A CPU limit is enforced as a quota of CPU time per scheduling period. The kernel gives the container its limit's share of each period (100 ms by default), and once the container has spent it, the container waits for the next period (kubernetes.io, how pods with resource limits are run). A memory limit is different: go over it and the container is killed with &lt;code&gt;OOMKilled&lt;/code&gt;. A CPU limit only holds the container back (kubernetes.io, assign CPU resources).&lt;/p&gt;

&lt;p&gt;So most requests land early in a period and finish fast. The unlucky ones arrive after the quota is spent, queue behind the work in progress, and wait one period or several. That's how the median stays flat while the p99 climbs to seconds, with no error anywhere.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does kubectl top show low CPU while the pod is throttled?
&lt;/h3&gt;

&lt;p&gt;Because a capped pod can never use more than its cap. &lt;code&gt;kubectl top&lt;/code&gt; reports what the container actually used, averaged over a short window (kubernetes.io, resource metrics pipeline). It can't show what the pod wanted. A pod pinned at its limit reads as a small, flat, round number. And a multi-threaded app can be throttled even when its average is below the limit, because several threads can burn a period's quota in a few milliseconds. Watch the throttled ratio, not only usage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Adding replicas didn't fix it
&lt;/h2&gt;

&lt;p&gt;Scaling out is the usual reflex for a slow service, so I tried it:&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;$ &lt;/span&gt;kubectl scale deploy/go-api &lt;span class="nt"&gt;--replicas&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;3 &lt;span class="nt"&gt;-n&lt;/span&gt; go-api
deployment.apps/go-api scaled

&lt;span class="nv"&gt;$ &lt;/span&gt;kubectl top pod &lt;span class="nt"&gt;-n&lt;/span&gt; go-api
NAME                      CPU&lt;span class="o"&gt;(&lt;/span&gt;cores&lt;span class="o"&gt;)&lt;/span&gt;   MEMORY&lt;span class="o"&gt;(&lt;/span&gt;bytes&lt;span class="o"&gt;)&lt;/span&gt;
go-api-649df775bd-gx4p8   8m           7Mi
go-api-649df775bd-md4lq   9m           25Mi
go-api-649df775bd-n2q6c   9m           7Mi
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each pod got a third of the traffic and still sat just under the same 11m ceiling. The throttled panel showed all three throttled in roughly half or more of their periods:&lt;/p&gt;

&lt;p&gt;!The same panel after scaling to three replicas: the three go-api pods are still throttled in about 45% to 100% of periods&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%2Fqmin2wbwfdberv0f7i5b.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%2Fqmin2wbwfdberv0f7i5b.png" alt="The same panel after scaling to three replicas: the three go-api pods are still throttled in about 45% to 100% of periods" width="799" height="412"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The same panel after scaling to three replicas: the three go-api pods are still throttled in about 45% to 100% of periods&lt;/p&gt;

&lt;p&gt;The lab's check stayed red: &lt;code&gt;FAIL: go-api/go-api is still not sized for the load it serves.&lt;/code&gt; Replicas spread the load, but every pod keeps a ceiling below what it needs, and the cost review that set the limit now pays for three pods instead of one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measure first, then set the limit
&lt;/h2&gt;

&lt;p&gt;You can't size a limit while the pod is capped, because &lt;code&gt;kubectl top&lt;/code&gt; reads the cap. So the fix has two steps. I scaled back to one replica and lifted the limit far above anything it had used, as a measuring step:&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;$ &lt;/span&gt;kubectl scale deploy/go-api &lt;span class="nt"&gt;--replicas&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nt"&gt;-n&lt;/span&gt; go-api
&lt;span class="nv"&gt;$ &lt;/span&gt;kubectl &lt;span class="nb"&gt;set &lt;/span&gt;resources deploy/go-api &lt;span class="nt"&gt;-n&lt;/span&gt; go-api &lt;span class="nt"&gt;--limits&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;cpu&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;500m
deployment.apps/go-api resource requirements updated
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A minute into steady load, &lt;code&gt;kubectl top&lt;/code&gt; showed the real demand:&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;$ &lt;/span&gt;kubectl top pod &lt;span class="nt"&gt;-n&lt;/span&gt; go-api
NAME                      CPU&lt;span class="o"&gt;(&lt;/span&gt;cores&lt;span class="o"&gt;)&lt;/span&gt;   MEMORY&lt;span class="o"&gt;(&lt;/span&gt;bytes&lt;span class="o"&gt;)&lt;/span&gt;
go-api-6d5ddc4d8b-db7gc   24m          7Mi
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;24m, and 23m a minute later. More than twice the old limit. Then I set it properly: a request near the steady use, so the scheduler is told the truth, and a limit with plenty of headroom for bursts:&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;$ &lt;/span&gt;kubectl &lt;span class="nb"&gt;set &lt;/span&gt;resources deploy/go-api &lt;span class="nt"&gt;-n&lt;/span&gt; go-api &lt;span class="nt"&gt;--requests&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;cpu&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;50m &lt;span class="nt"&gt;--limits&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;cpu&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;200m
deployment.apps/go-api resource requirements updated

&lt;span class="nv"&gt;$ &lt;/span&gt;kubectl rollout status deploy/go-api &lt;span class="nt"&gt;-n&lt;/span&gt; go-api
Waiting &lt;span class="k"&gt;for &lt;/span&gt;deployment &lt;span class="s2"&gt;"go-api"&lt;/span&gt; rollout to finish: 1 old replicas are pending termination...
deployment &lt;span class="s2"&gt;"go-api"&lt;/span&gt; successfully rolled out
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(I left out the scale command's output and one repeated &lt;code&gt;Waiting&lt;/code&gt; line.) The request is the pod's guaranteed share of a busy node. The limit is the ceiling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proving the fix
&lt;/h2&gt;

&lt;p&gt;The fix shows on the user signal, not only on the resource signal. The server-side p99 fell from about 8 seconds back down to the median:&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%2F0rfcdbusoc2wlo3q48qa.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%2F0rfcdbusoc2wlo3q48qa.png" alt="Grafana &amp;quot;Latency percentiles (server-side)&amp;quot;: p99 drops from about 8 s to near zero after the limit is lifted, while p50 stays flat at zero the whole time" width="800" height="511"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Grafana "Latency percentiles (server-side)": p99 drops from about 8 s to near zero after the limit is lifted, while p50 stays flat at zero the whole time&lt;/p&gt;

&lt;p&gt;On the throttled panel, the old pods' lines ended and the new pod drew no line at all. That panel only draws pods throttled in more than 5% of periods, so the new pod is under that. The lab's check then passed and closed the incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  Questions people ask next
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Should I remove CPU limits altogether?
&lt;/h3&gt;

&lt;p&gt;It's a common choice for latency-sensitive services, and a reasonable one. Without a limit the pod can use idle CPU on the node, and its request still guarantees it a share when the node is busy. Two catches: a namespace &lt;code&gt;LimitRange&lt;/code&gt; may add a default limit nobody chose (check &lt;code&gt;kubectl describe limitrange -n &amp;lt;ns&amp;gt;&lt;/code&gt;), and a pod only gets the &lt;code&gt;Guaranteed&lt;/code&gt; QoS class if its limits equal its requests. Keep a limit where you need either of those. And this is about the CPU limit only: memory can't be throttled, so keep the memory limit.&lt;/p&gt;

&lt;h3&gt;
  
  
  How much throttling is a problem?
&lt;/h3&gt;

&lt;p&gt;There's no universal threshold, so judge it against latency. In this lab, a limit of 1.5 times the measured demand still left the app throttled in about a sixth of its periods with p99 near 150 ms. At twice the demand, throttling was near zero and p99 went back to a few milliseconds. Graph the throttled ratio next to your p99 and look for the two moving together.&lt;/p&gt;

&lt;h2&gt;
  
  
  Other causes of slow pods with no restarts
&lt;/h2&gt;

&lt;p&gt;Throttling is the cause where the pod's own CPU reads flat at a round number that equals its limit. If it doesn't, look elsewhere:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A busy node&lt;/strong&gt;: &lt;code&gt;kubectl top nodes&lt;/code&gt; near 100%, and a neighbour taking the CPU. The fix is placement or requests, not this pod's limit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Too few replicas for the traffic&lt;/strong&gt;: CPU per pod is high but below the limit. That's a job for scaling, or a HorizontalPodAutoscaler.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A slow dependency&lt;/strong&gt;: the app waits on a database or another service. Traces or the dependency's own latency show it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory pressure&lt;/strong&gt;: a tight memory limit causes garbage-collection pauses before it causes &lt;code&gt;OOMKilled&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it yourself
&lt;/h2&gt;

&lt;p&gt;On any test cluster with steady traffic on a Deployment, set a CPU limit far below what it uses:&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="nb"&gt;set &lt;/span&gt;resources deploy/&amp;lt;app&amp;gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &amp;lt;ns&amp;gt; &lt;span class="nt"&gt;--limits&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;cpu&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10m
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then work through it: p99 against the median, pods, nodes, &lt;code&gt;top&lt;/code&gt; against the limit, the throttled ratio, then measure and set.&lt;/p&gt;

&lt;p&gt;I ran this drill in SnowOps Labs, an open-source Kubernetes lab where you inject faults like this one into a real app and fix them (GitHub). Disclosure: I build it. It measures your machine before it sets the limit, runs the load for you, and checks the limit you set, not just the moment's latency, so scaling out or stopping the traffic won't pass.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ladder to remember
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Compare p99 with the median. Slow but not broken means something is waiting.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;kubectl get pods&lt;/code&gt; and &lt;code&gt;kubectl top nodes&lt;/code&gt;: no restarts, no busy node.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;kubectl top pod&lt;/code&gt; against the limit in &lt;code&gt;kubectl describe pod&lt;/code&gt;. Equal means capped.&lt;/li&gt;
&lt;li&gt;Check the throttled ratio (&lt;code&gt;container_cpu_cfs_throttled_periods_total&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Lift the limit, measure the real use under load.&lt;/li&gt;
&lt;li&gt;Set a request near that use and a limit with headroom, or remove the CPU limit.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;*This is part of a series I'm writing on debugging Kubernetes in production. I am building &lt;a href="https://www.github.com/sagar2395/snowopslabs" rel="noopener noreferrer"&gt;Snowopslabs&lt;/a&gt; which I have used to simulate this incident and resolve it. Check out this tool if you want to play around with Kubernetes incidents and scenarios and learn along the way.&lt;/p&gt;

</description>
      <category>sre</category>
      <category>kubernetes</category>
      <category>performance</category>
      <category>devops</category>
    </item>
    <item>
      <title>Pods Running, Service returns 503: the selector bug that fools everyone</title>
      <dc:creator>Sagar</dc:creator>
      <pubDate>Sun, 25 Oct 2026 06:58:32 +0000</pubDate>
      <link>https://dev.to/sagar2395/pods-running-service-returns-503-the-selector-bug-that-fools-everyone-5aln</link>
      <guid>https://dev.to/sagar2395/pods-running-service-returns-503-the-selector-bug-that-fools-everyone-5aln</guid>
      <description>&lt;p&gt;Every request to the app fails with a 503. The body says &lt;code&gt;no available server&lt;/code&gt;. And yet &lt;code&gt;kubectl get pods&lt;/code&gt; looks perfect:&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;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-i&lt;/span&gt; http://go-api.snowops.localhost/health
HTTP/1.1 503 Service Unavailable
Content-Type: text/plain&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;charset&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;utf-8
X-Content-Type-Options: nosniff
Date: Mon, 12 Oct 2026 14:25:45 GMT
Content-Length: 20

no available server

&lt;span class="nv"&gt;$ &lt;/span&gt;kubectl get pods &lt;span class="nt"&gt;-n&lt;/span&gt; go-api
NAME                      READY   STATUS    RESTARTS   AGE
go-api-5d8497557b-7pfhz   1/1     Running   0          4m42s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One of one ready, zero restarts, nothing deployed. I broke a small Go service on purpose to walk through this one, because it fools people precisely by looking healthy everywhere you'd normally look. The commands below work on any cluster. The cause in my drill is one renamed label, and at the end I'll go through the other things that leave a Service with nowhere to send traffic.&lt;/p&gt;

&lt;h2&gt;
  
  
  The piece most people never check
&lt;/h2&gt;

&lt;p&gt;A request from outside reaches a pod in three hops. The ingress controller receives it and looks up the Service the route points at. The Service doesn't forward anything to "the app" in general. It forwards to a list of pod addresses, its endpoints. Then the request lands on one of those pods.&lt;/p&gt;

&lt;p&gt;That list isn't something you write by hand. A Service has a selector, a set of labels, and Kubernetes keeps the list up to date with every pod that carries all of those labels and is ready to serve. Today the list lives in EndpointSlice objects (Kubernetes docs: EndpointSlices). The older Endpoints object still exists, and &lt;code&gt;kubectl get endpoints&lt;/code&gt; still works, but newer clusters print a warning when you use it.&lt;/p&gt;

&lt;p&gt;Here is the healthy state, before I broke anything:&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;$ &lt;/span&gt;kubectl get endpoints go-api &lt;span class="nt"&gt;-n&lt;/span&gt; go-api
Warning: v1 Endpoints is deprecated &lt;span class="k"&gt;in &lt;/span&gt;v1.33+&lt;span class="p"&gt;;&lt;/span&gt; use discovery.k8s.io/v1 EndpointSlice
NAME     ENDPOINTS          AGE
go-api   10.42.0.136:8080   67s

&lt;span class="nv"&gt;$ &lt;/span&gt;kubectl get endpointslices &lt;span class="nt"&gt;-n&lt;/span&gt; go-api
NAME           ADDRESSTYPE   PORTS   ENDPOINTS     AGE
go-api-kt9b6   IPv4          8080    10.42.0.136   82s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One address, the pod's IP, on port 8080. Keep that picture in mind. If the list is empty, the ingress has a route and a Service and nowhere to send the request. That is exactly what &lt;code&gt;no available server&lt;/code&gt; means. It comes from Traefik, the ingress controller in this lab, not from the app.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the outage looks like from the inside
&lt;/h2&gt;

&lt;p&gt;Before going to the terminal, I looked at the dashboard. A load generator (k6) had been sending the app steady traffic from inside the cluster, so there's a before and an after.&lt;/p&gt;

&lt;p&gt;This was the part that surprised me most:&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%2Fspgrgpbkqk685te7fshq.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%2Fspgrgpbkqk685te7fshq.png" alt="Grafana during the outage: the app's own 5xx error rate stays at zero while k6's failed request rate climbs to 100%" width="800" height="246"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Grafana during the outage: the app's own 5xx error rate stays at zero while k6's failed request rate climbs to 100%&lt;/p&gt;

&lt;p&gt;On the left, the app's own error rate. Flat at zero for the whole outage. On the right, what the client saw: every request failing. Both panels are telling the truth. No request reached the pod, so the pod had nothing to count, good or bad. Its metrics didn't go red, they went quiet.&lt;/p&gt;

&lt;p&gt;The panel that shows it best puts both sides on one chart:&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%2Fgpeh7bn13dk4jd4xgmf1.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%2Fgpeh7bn13dk4jd4xgmf1.png" alt="Offered vs served: k6 keeps offering about 50 requests a second (green) while the requests the app handles (yellow) fall away" width="800" height="162"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Offered vs served: k6 keeps offering about 50 requests a second (green) while the requests the app handles (yellow) fall away&lt;/p&gt;

&lt;p&gt;Green is what k6 offered, yellow is what the app handled. Normally the two lines sit on top of each other. The gap between them is the outage.&lt;/p&gt;

&lt;p&gt;If your alerting only looks at what a service says about itself, this outage looks like a quiet afternoon. You need at least one signal from the client side: a synthetic check, a probe from outside the cluster, or the ingress controller's own metrics. An alert that watches pods won't help either, because the pods really are fine. What does work is an alert on the thing that broke. The lab ships one, and it was firing:&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%2F2603gp4bci0isht622ph.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%2F2603gp4bci0isht622ph.png" alt="Prometheus: the LabFaultServiceEndpointsEmpty rule firing, absent() over the Service's ready EndpointSlice endpoints for 1 minute" width="798" height="238"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Prometheus: the LabFaultServiceEndpointsEmpty rule firing, absent() over the Service's ready EndpointSlice endpoints for 1 minute&lt;/p&gt;

&lt;p&gt;It uses &lt;code&gt;absent()&lt;/code&gt; rather than &lt;code&gt;count(...) == 0&lt;/code&gt;, and the lab's rule file explains why: kube-state-metrics exports one series per endpoint address, so when there are no addresses there is no series at all, and a count would never fire. That's an easy rule to get wrong, and worth stealing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Find the tell: an empty endpoint list
&lt;/h2&gt;

&lt;p&gt;Back in the terminal, the order of checks follows the request. The pods are healthy, so check the link in the middle:&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;$ &lt;/span&gt;kubectl get endpoints go-api &lt;span class="nt"&gt;-n&lt;/span&gt; go-api
Warning: v1 Endpoints is deprecated &lt;span class="k"&gt;in &lt;/span&gt;v1.33+&lt;span class="p"&gt;;&lt;/span&gt; use discovery.k8s.io/v1 EndpointSlice
NAME     ENDPOINTS   AGE
go-api   &amp;lt;none&amp;gt;      7m27s

&lt;span class="nv"&gt;$ &lt;/span&gt;kubectl get endpointslices &lt;span class="nt"&gt;-n&lt;/span&gt; go-api
NAME           ADDRESSTYPE   PORTS     ENDPOINTS   AGE
go-api-kt9b6   IPv4          &amp;lt;&lt;span class="nb"&gt;unset&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;   &amp;lt;&lt;span class="nb"&gt;unset&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;     7m41s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;&amp;lt;none&amp;gt;&lt;/code&gt;. A few minutes ago that column held the pod's address. The EndpointSlice is still there, but it's empty. That's the tell: the Service exists, the pod exists, and the Service isn't selecting it.&lt;/p&gt;

&lt;p&gt;Nothing else would have told me. Nothing crashed, nothing restarted, and Kubernetes emits no event when a selector stops matching, because from its point of view nothing failed. The Service is doing exactly what its spec says.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compare the selector with the labels
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;-o wide&lt;/code&gt; prints a Service's selector in the last column:&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;$ &lt;/span&gt;kubectl get svc go-api &lt;span class="nt"&gt;-n&lt;/span&gt; go-api &lt;span class="nt"&gt;-o&lt;/span&gt; wide
NAME     TYPE        CLUSTER-IP      EXTERNAL-IP   PORT&lt;span class="o"&gt;(&lt;/span&gt;S&lt;span class="o"&gt;)&lt;/span&gt;    AGE   SELECTOR
go-api   ClusterIP   10.43.223.247   &amp;lt;none&amp;gt;        8080/TCP   8m    app.kubernetes.io/instance&lt;span class="o"&gt;=&lt;/span&gt;go-api,app.kubernetes.io/name&lt;span class="o"&gt;=&lt;/span&gt;go-api-v2,app&lt;span class="o"&gt;=&lt;/span&gt;go-api
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(My terminal wrapped that last line, I joined it back.) Three labels. Now the labels the pod actually carries:&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;$ &lt;/span&gt;kubectl get pods &lt;span class="nt"&gt;-n&lt;/span&gt; go-api &lt;span class="nt"&gt;--show-labels&lt;/span&gt;
NAME                      READY   STATUS    RESTARTS   AGE     LABELS
go-api-5d8497557b-7pfhz   1/1     Running   0          8m16s   app.kubernetes.io/instance&lt;span class="o"&gt;=&lt;/span&gt;go-api,app.kubernetes.io/name&lt;span class="o"&gt;=&lt;/span&gt;go-api,app&lt;span class="o"&gt;=&lt;/span&gt;go-api,pod-template-hash&lt;span class="o"&gt;=&lt;/span&gt;5d8497557b
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Line them up and the difference is one value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;Service wants:  app.kubernetes.io/name&lt;span class="o"&gt;=&lt;/span&gt;go-api-v2
Pod carries:    app.kubernetes.io/name&lt;span class="o"&gt;=&lt;/span&gt;go-api
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two out of three labels match, and that counts for nothing. A selector is an AND: a pod has to carry every label in it (Kubernetes docs: labels and selectors). One key off, and not one pod matches.&lt;/p&gt;

&lt;p&gt;A small tip if you're doing this on your own cluster: I used &lt;code&gt;get -o wide&lt;/code&gt; rather than &lt;code&gt;kubectl describe svc&lt;/code&gt; here. &lt;code&gt;describe&lt;/code&gt; shows the selector too, but on this lab it also prints an annotation the drill leaves behind with the original selector in it, which gives the answer away. On your cluster &lt;code&gt;describe&lt;/code&gt; is fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which side is wrong?
&lt;/h2&gt;

&lt;p&gt;A mismatch has two sides, and you could make them agree from either one. The Deployment settles it. It selects its pods by labels too, and it stamps those labels onto every pod it creates:&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;$ &lt;/span&gt;kubectl get deploy go-api &lt;span class="nt"&gt;-n&lt;/span&gt; go-api &lt;span class="nt"&gt;-o&lt;/span&gt; wide
NAME     READY   UP-TO-DATE   AVAILABLE   AGE     CONTAINERS   IMAGES          SELECTOR
go-api   1/1     1            1           8m33s   go-api       go-api:v1.2.0   &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;go-api,app.kubernetes.io/instance&lt;span class="o"&gt;=&lt;/span&gt;go-api,app.kubernetes.io/name&lt;span class="o"&gt;=&lt;/span&gt;go-api
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;name=go-api&lt;/code&gt;. The pods are right, and the Service is the odd one out. There's a structural reason to fix it there as well: a Deployment's selector can't be changed after it's created (in &lt;code&gt;apps/v1&lt;/code&gt; it's immutable), while a Service's selector can be changed at any time, to anything, and Kubernetes accepts it without a warning. That's how this kind of drift gets in.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tempting fix
&lt;/h2&gt;

&lt;p&gt;The Service wants a pod labelled &lt;code&gt;go-api-v2&lt;/code&gt;. So why not give the pod that label? It's one command:&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;$ &lt;/span&gt;kubectl label pod &lt;span class="nt"&gt;-n&lt;/span&gt; go-api go-api-5d8497557b-7pfhz app.kubernetes.io/name&lt;span class="o"&gt;=&lt;/span&gt;go-api-v2 &lt;span class="nt"&gt;--overwrite&lt;/span&gt;
pod/go-api-5d8497557b-7pfhz labeled
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And it works, in the sense that requests flow again. The endpoint list fills back in and the health check returns 200. If you stopped here, you'd close the incident. But list the pods:&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;$ &lt;/span&gt;kubectl get pods &lt;span class="nt"&gt;-n&lt;/span&gt; go-api &lt;span class="nt"&gt;--show-labels&lt;/span&gt;
NAME                      READY   STATUS    RESTARTS   AGE     LABELS
go-api-5d8497557b-2qwx4   1/1     Running   0          18s     app.kubernetes.io/instance&lt;span class="o"&gt;=&lt;/span&gt;go-api,app.kubernetes.io/name&lt;span class="o"&gt;=&lt;/span&gt;go-api,app&lt;span class="o"&gt;=&lt;/span&gt;go-api,pod-template-hash&lt;span class="o"&gt;=&lt;/span&gt;5d8497557b
go-api-5d8497557b-7pfhz   1/1     Running   0          9m16s   app.kubernetes.io/instance&lt;span class="o"&gt;=&lt;/span&gt;go-api,app.kubernetes.io/name&lt;span class="o"&gt;=&lt;/span&gt;go-api-v2,app&lt;span class="o"&gt;=&lt;/span&gt;go-api,pod-template-hash&lt;span class="o"&gt;=&lt;/span&gt;5d8497557b
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are two now. A ReplicaSet also finds its pods by their labels. The moment I relabelled the pod, it stopped matching the ReplicaSet's selector, so as far as the ReplicaSet was concerned it had lost a pod, and it made a replacement with the original labels. The docs describe this as isolating a pod from its ReplicaSet (Kubernetes docs: ReplicaSet), and it's normally used on purpose, to pull a misbehaving pod out of service for debugging.&lt;/p&gt;

&lt;p&gt;So the Service is now held up by one orphan pod that nothing owns. If it's evicted, or its node goes away, nothing replaces it and the 503s come back. And the next rollout creates pods from the template, which still says &lt;code&gt;go-api&lt;/code&gt;. The person doing that rollout won't connect it to today's incident.&lt;/p&gt;

&lt;p&gt;The lab's check wasn't fooled:&lt;/p&gt;

&lt;p&gt;!The SnowOps Labs check refuses the relabel: Not resolved yet, the Service wants "go-api-v2", pods are labelled "go-api"&lt;/p&gt;

&lt;p&gt;The SnowOps Labs check refuses the relabel: Not resolved yet, the Service wants "go-api-v2", pods are labelled "go-api"&lt;/p&gt;

&lt;p&gt;It compares the Service's selector with the Deployment's pod template rather than with whatever labels happen to be on a pod right now, for exactly this reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real fix: change the Service
&lt;/h2&gt;

&lt;p&gt;First, undo the shortcut. Nothing owns the relabelled pod, so deleting it is permanent:&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;$ &lt;/span&gt;kubectl delete pod &lt;span class="nt"&gt;-n&lt;/span&gt; go-api go-api-5d8497557b-7pfhz
pod &lt;span class="s2"&gt;"go-api-5d8497557b-7pfhz"&lt;/span&gt; deleted from go-api namespace
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then patch the Service's selector back to what the Deployment uses:&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;$ &lt;/span&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; go-api patch svc go-api &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s1"&gt;'{"spec":{"selector":{"app.kubernetes.io/name":"go-api"}}}'&lt;/span&gt;
service/go-api patched
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;kubectl patch&lt;/code&gt; sends only the fields you give it, and for a Service the selector map is merged, so the other two labels stay as they were. Only &lt;code&gt;name&lt;/code&gt; changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prove it, in the order you found it
&lt;/h2&gt;

&lt;p&gt;I check in the same order I diagnosed, from the inside out. First the Service:&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;$ &lt;/span&gt;kubectl get endpoints go-api &lt;span class="nt"&gt;-n&lt;/span&gt; go-api
NAME     ENDPOINTS          AGE
go-api   10.42.2.168:8080   10m
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(I dropped the deprecation warning from here on.) An address again, and a different one from the start. It belongs to the pod the ReplicaSet created, which is the one that should be serving. Then the same question from outside:&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;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-i&lt;/span&gt; http://go-api.snowops.localhost/health
HTTP/1.1 200 OK
Content-Length: 16
Content-Type: application/json
Date: Mon, 12 Oct 2026 14:32:28 GMT

&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"status"&lt;/span&gt;:&lt;span class="s2"&gt;"ok"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same request as at the start, different answer. On the dashboard the app's request rate came back up to meet k6's, and the lab's check passed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it on your own cluster
&lt;/h2&gt;

&lt;p&gt;You don't need the lab for this one. Pick a Service in a test namespace, note its selector, and change one label to a value no pod carries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get svc &amp;lt;your-service&amp;gt; &lt;span class="nt"&gt;-o&lt;/span&gt; wide
kubectl patch svc &amp;lt;your-service&amp;gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s1"&gt;'{"spec":{"selector":{"&amp;lt;key&amp;gt;":"&amp;lt;value no pod has&amp;gt;"}}}'&lt;/span&gt;
kubectl get endpoints &amp;lt;your-service&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The endpoints go to &lt;code&gt;&amp;lt;none&amp;gt;&lt;/code&gt; straight away. Your pods stay Running and Ready, nothing restarts, and no event warns you. Patch the label back to undo it. Only do this on a test Service: a selector change moves traffic immediately.&lt;/p&gt;

&lt;p&gt;If you'd rather practise the whole incident, with traffic, the dashboards, the alert and a check that won't accept the relabel shortcut, it's one of the drills in SnowOps Labs, a free, open source Kubernetes lab that runs on a laptop. Disclosure: I build it. Every screenshot above is from it, and the code is on GitHub.&lt;/p&gt;

&lt;h2&gt;
  
  
  Other ways to end up with no endpoints
&lt;/h2&gt;

&lt;p&gt;A renamed label is one cause. When &lt;code&gt;kubectl get endpoints&lt;/code&gt; comes back empty and your pods look fine, these are the others I'd check, roughly in this order. The Kubernetes guide to debugging Services walks through the same ground.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The pods aren't Ready.&lt;/strong&gt; Running isn't Ready. A pod failing its readiness probe is left out of the Service's ready endpoints, so a broken readiness probe empties the list just as well. &lt;code&gt;kubectl get pods&lt;/code&gt; shows &lt;code&gt;0/1&lt;/code&gt; in the READY column. Check that first, which is why it comes first in the recap below.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A typo in the label key, not the value.&lt;/strong&gt; &lt;code&gt;app.kubernetes.io/name&lt;/code&gt; against &lt;code&gt;app.kubernetes.io/Name&lt;/code&gt;, or &lt;code&gt;app&lt;/code&gt; against &lt;code&gt;application&lt;/code&gt;. Same result, harder to spot by eye. Compare the two outputs character by character.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The wrong namespace.&lt;/strong&gt; A Service only selects pods in its own namespace. A Service applied to &lt;code&gt;default&lt;/code&gt; while the pods live in &lt;code&gt;go-api&lt;/code&gt; has a perfect selector and no endpoints.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A Service with no selector at all.&lt;/strong&gt; Then Kubernetes doesn't manage its endpoints, and they stay empty unless something creates EndpointSlices for it. That's intended for pointing a Service at something outside the cluster, and a surprise when it's a mistake.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Endpoints exist, but requests still fail.&lt;/strong&gt; Then it isn't this problem. Look at the Service's &lt;code&gt;targetPort&lt;/code&gt; against the port the container really listens on, and at what the ingress route points to.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At work, the label usually changes somewhere other than the cluster: a Helm chart refactor that renames the selector labels, or a Service copied from another app with its app name left in. Whatever produced the change, fix it there as well (the chart, the Kustomize overlay, the GitOps repo), or the next sync puts the bad selector straight back.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;When users get 503s and the pods look healthy:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;curl -i&lt;/code&gt; the URL, or open it, and confirm what users see.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;kubectl get pods&lt;/code&gt;: pods that aren't Ready also empty the endpoint list, so rule that out first.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;kubectl get endpoints &amp;lt;service&amp;gt;&lt;/code&gt; (or &lt;code&gt;get endpointslices&lt;/code&gt;): an empty list means the Service selects nothing.&lt;/li&gt;
&lt;li&gt;Compare &lt;code&gt;kubectl get svc &amp;lt;service&amp;gt; -o wide&lt;/code&gt; with &lt;code&gt;kubectl get pods --show-labels&lt;/code&gt;. One mismatched label is enough.&lt;/li&gt;
&lt;li&gt;Fix the Service (or whatever generates it), not the pods, and check that the endpoints come back before you check the URL.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;em&gt;This incident is resolved using snowops labs, which is the open source project I am building for simulating real world platform engineering scenarios. Checkout this project: &lt;a href="https://labs.snowops.net" rel="noopener noreferrer"&gt;snowopslabs&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>debugging</category>
      <category>devops</category>
      <category>sre</category>
    </item>
    <item>
      <title>CrashLoopBackOff: how to find the real cause (and why deleting the pod won't help)</title>
      <dc:creator>Sagar</dc:creator>
      <pubDate>Wed, 21 Oct 2026 11:48:23 +0000</pubDate>
      <link>https://dev.to/sagar2395/crashloopbackoff-how-to-find-the-real-cause-and-why-deleting-the-pod-wont-help-130g</link>
      <guid>https://dev.to/sagar2395/crashloopbackoff-how-to-find-the-real-cause-and-why-deleting-the-pod-wont-help-130g</guid>
      <description>&lt;p&gt;A config change goes out. A couple of minutes later every request to the app fails, and &lt;code&gt;kubectl get pods&lt;/code&gt; shows the status nobody wants to see:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;NAME                      READY   STATUS             RESTARTS      AGE
go-api-7b45497dfd-qxqx2   0/1     CrashLoopBackOff   4 &lt;span class="o"&gt;(&lt;/span&gt;15s ago&lt;span class="o"&gt;)&lt;/span&gt;   115s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I broke a small Go service on purpose to walk through this one properly, from the first symptom to a fix you can prove. The commands work on any cluster. The specific bug is just one example, and I'll come back at the end to the other things that cause the same status.&lt;/p&gt;

&lt;h2&gt;
  
  
  What CrashLoopBackOff actually tells you
&lt;/h2&gt;

&lt;p&gt;It is easy to read CrashLoopBackOff as an error. It isn't one. It is Kubernetes describing what it is doing about an error.&lt;/p&gt;

&lt;p&gt;The container starts and then exits. The kubelet on the node restarts it, because the pod's &lt;code&gt;restartPolicy&lt;/code&gt; says so. If it exits again, the kubelet waits before the next try, and the wait grows each time: 10 seconds, 20, 40, and so on, capped at five minutes (Kubernetes docs: container restarts). While it is waiting, the pod shows &lt;code&gt;CrashLoopBackOff&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So the status answers "what is happening?" (the container keeps dying) and says nothing about why. That second question is the whole job, and the pod has already written the answer down somewhere. You just have to know where to read it.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, check whether anyone is still being served
&lt;/h2&gt;

&lt;p&gt;Before digging into the crash, it's worth thirty seconds to find out how bad things are. A crash loop during a rollout is often harmless: with a rolling update and a readiness probe, the old pods keep serving until a new one becomes Ready. If the new pod never gets there, the rollout just stalls.&lt;/p&gt;

&lt;p&gt;A Deployment keeps one ReplicaSet per version of its pod template, so listing them tells you whether an older version is still up:&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;$ &lt;/span&gt;kubectl get rs &lt;span class="nt"&gt;-n&lt;/span&gt; go-api
NAME                DESIRED   CURRENT   READY   AGE
go-api-5d8497557b   0         0         0       5m46s
go-api-7b45497dfd   1         1         0       2m9s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not good. The old ReplicaSet is at zero, and the new one wants a pod and has none ready. Nothing is serving. In my drill I set it up that way on purpose, but you get the same picture in real life with a &lt;code&gt;Recreate&lt;/code&gt; strategy, or when the crash only starts after the pod has already passed its readiness check. Either way, this isn't a stuck rollout any more. It's an outage.&lt;/p&gt;

&lt;p&gt;The browser agreed:&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%2Ff0c9ipaaj5ewl6o4wqef.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%2Ff0c9ipaaj5ewl6o4wqef.png" alt="Snowopslabs - Kubernetes logs previous" width="800" height="132"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That message comes from the ingress. It had a route for the app and no ready pod to send the request to.&lt;/p&gt;

&lt;h3&gt;
  
  
  A detour worth taking: the dashboard that says everything is fine
&lt;/h3&gt;

&lt;p&gt;This was the part of the drill that surprised me most. Here is the Grafana dashboard during the outage. On the left is the app's own error rate (HTTP 5xx). On the right is the failure rate seen by k6, the load generator sending traffic from outside.&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%2F3bhy8leq2avmyxcqvobl.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%2F3bhy8leq2avmyxcqvobl.png" alt="Grafana dashboard" width="800" height="325"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The app reports zero errors. The client sees 100% of requests fail. Both are telling the truth: a process that never finishes starting can't count the requests it never received, so its own metrics go quiet rather than red. If your alerting only looks at what the service says about itself, a total outage can look like a calm afternoon. Watch at least one signal from the client side too, whether that's an ingress or load balancer metric, a synthetic check, or a probe from outside the cluster.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read the crash's last words: &lt;code&gt;kubectl logs --previous&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Plain &lt;code&gt;kubectl logs&lt;/code&gt; shows the current container. In a crash loop the current container has often just started, or hasn't started yet, so you get nothing useful. The container that just died is still on the node, and &lt;code&gt;--previous&lt;/code&gt; (or &lt;code&gt;-p&lt;/code&gt;) asks for its logs:&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;$ &lt;/span&gt;kubectl logs &lt;span class="nt"&gt;-n&lt;/span&gt; go-api go-api-7b45497dfd-qxqx2 &lt;span class="nt"&gt;--previous&lt;/span&gt;
&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"level"&lt;/span&gt;:&lt;span class="s2"&gt;"INFO"&lt;/span&gt;,&lt;span class="s2"&gt;"msg"&lt;/span&gt;:&lt;span class="s2"&gt;"server starting"&lt;/span&gt;,&lt;span class="s2"&gt;"port"&lt;/span&gt;:&lt;span class="s2"&gt;"80800"&lt;/span&gt;,&lt;span class="s2"&gt;"service"&lt;/span&gt;:&lt;span class="s2"&gt;"go-api"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"level"&lt;/span&gt;:&lt;span class="s2"&gt;"ERROR"&lt;/span&gt;,&lt;span class="s2"&gt;"msg"&lt;/span&gt;:&lt;span class="s2"&gt;"server error"&lt;/span&gt;,&lt;span class="s2"&gt;"error"&lt;/span&gt;:&lt;span class="s2"&gt;"listen tcp: address 80800: invalid port"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(I trimmed the timestamps.) Two lines, and the second one is the answer. The server tried to listen on port 80800. Port numbers stop at 65535, so the bind fails and the process exits.&lt;/p&gt;

&lt;p&gt;Most of the time the logs get you this far. When they don't, the next command usually does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Confirm it from Kubernetes' side: &lt;code&gt;kubectl describe pod&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;describe&lt;/code&gt; is long, so I pipe it into &lt;code&gt;less&lt;/code&gt; and search for &lt;code&gt;Last State&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;&lt;span class="nv"&gt;$ &lt;/span&gt;kubectl describe pod &lt;span class="nt"&gt;-n&lt;/span&gt; go-api go-api-7b45497dfd-qxqx2 | less
...
    Last State:     Terminated
      Reason:       Error
      Exit Code:    1
    Ready:          False
    Restart Count:  4
    ...
    Environment:
      PORT:                 80800
      READINESS_FAILURE:    &lt;span class="nb"&gt;false
      &lt;/span&gt;SHUTDOWN_TIMEOUT:     10s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things to take from this block.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The exit code.&lt;/strong&gt; &lt;code&gt;1&lt;/code&gt; means the app chose to exit with an error. Nothing killed it. If you see &lt;code&gt;137&lt;/code&gt; instead, the process got a SIGKILL (128 + 9), and a &lt;code&gt;Reason&lt;/code&gt; of &lt;code&gt;OOMKilled&lt;/code&gt; tells you it ran out of memory. Those two numbers send you down completely different paths, so it's worth reading this line before anything else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The environment.&lt;/strong&gt; The same 80800 again, now in the spec. So the bad value isn't something the app made up at runtime. Kubernetes handed it over.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The events at the bottom.&lt;/strong&gt; In this case they only confirm the loop: &lt;code&gt;Back-off restarting failed container&lt;/code&gt;. For other causes they carry the real clue, such as &lt;code&gt;Liveness probe failed&lt;/code&gt;, a missing ConfigMap or Secret, or an image that can't be pulled.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix everyone tries first
&lt;/h2&gt;

&lt;p&gt;With the pod clearly broken, deleting it is tempting. I tried:&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;$ &lt;/span&gt;kubectl delete pod &lt;span class="nt"&gt;-n&lt;/span&gt; go-api go-api-7b45497dfd-qxqx2
pod &lt;span class="s2"&gt;"go-api-7b45497dfd-qxqx2"&lt;/span&gt; deleted

&lt;span class="nv"&gt;$ &lt;/span&gt;kubectl get pods &lt;span class="nt"&gt;-n&lt;/span&gt; go-api
NAME                      READY   STATUS             RESTARTS      AGE
go-api-7b45497dfd-4c995   0/1     CrashLoopBackOff   1 &lt;span class="o"&gt;(&lt;/span&gt;21s ago&lt;span class="o"&gt;)&lt;/span&gt;   22s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A new name, the same crash, within seconds. The pod was never the problem. A pod owned by a Deployment is a copy of the Deployment's pod template, and the ReplicaSet will keep making fresh copies of the same mistake for as long as you delete them. The only thing deleting achieved was resetting the restart counter, which made the situation look less serious than it was.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix it where it lives: the pod template
&lt;/h2&gt;

&lt;p&gt;So the question becomes where the template gets &lt;code&gt;PORT=80800&lt;/code&gt; from. &lt;code&gt;describe deploy&lt;/code&gt; shows the template:&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;$ &lt;/span&gt;kubectl describe deploy go-api &lt;span class="nt"&gt;-n&lt;/span&gt; go-api | less
...
  Containers:
   go-api:
    Image:      go-api:v1.2.0
    Port:       8080/TCP &lt;span class="o"&gt;(&lt;/span&gt;http&lt;span class="o"&gt;)&lt;/span&gt;
    Liveness:   http-get http://:http/health &lt;span class="nv"&gt;delay&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;15s &lt;span class="nb"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;5s &lt;span class="nv"&gt;period&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10s
    Readiness:  http-get http://:http/ready &lt;span class="nv"&gt;delay&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10s &lt;span class="nb"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;3s &lt;span class="nv"&gt;period&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;5s
    Environment:
      PORT:                 80800
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The container declares port 8080, both probes use that named port, and the environment variable says 80800. One zero too many. The fix goes on the Deployment:&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;$ &lt;/span&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; go-api &lt;span class="nb"&gt;set env &lt;/span&gt;deploy/go-api &lt;span class="nv"&gt;PORT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;8080
deployment.apps/go-api &lt;span class="nb"&gt;env &lt;/span&gt;updated
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Changing the template starts a new rollout. In a real team you'd make the same change in whatever produced the manifest (the Helm values, the Kustomize overlay, the GitOps repo) or the next sync will put the typo straight back. &lt;code&gt;kubectl set env&lt;/code&gt; is fine for stopping the bleeding, as long as the real fix follows it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;kubectl rollout undo deploy/go-api -n go-api&lt;/code&gt; would have worked too. It returns the template to the previous revision, environment included. When you're paged and you know the last change caused it, rolling back first and investigating second is often the right call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prove the fix instead of assuming it
&lt;/h2&gt;

&lt;p&gt;Don't eyeball &lt;code&gt;get pods&lt;/code&gt; a few seconds after the change. &lt;code&gt;rollout status&lt;/code&gt; waits until the new pods are actually Ready:&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;$ &lt;/span&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; go-api rollout status deploy/go-api
Waiting &lt;span class="k"&gt;for &lt;/span&gt;deployment &lt;span class="s2"&gt;"go-api"&lt;/span&gt; rollout to finish: 1 old replicas are pending termination...
deployment &lt;span class="s2"&gt;"go-api"&lt;/span&gt; successfully rolled out

&lt;span class="nv"&gt;$ &lt;/span&gt;kubectl get pods &lt;span class="nt"&gt;-n&lt;/span&gt; go-api
NAME                      READY   STATUS    RESTARTS   AGE
go-api-5d8497557b-r47rn   1/1     Running   0          26s

&lt;span class="nv"&gt;$ &lt;/span&gt;kubectl get rs &lt;span class="nt"&gt;-n&lt;/span&gt; go-api
NAME                DESIRED   CURRENT   READY   AGE
go-api-5d8497557b   1         1         1       9m8s
go-api-7b45497dfd   0         0         0       5m31s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at the ReplicaSet names. Kubernetes didn't create a third one. The fixed template is identical to the one from before the bad change, so the Deployment scaled the old ReplicaSet back up. It's a small detail, but it's a nice confirmation that the template now matches the last version that worked.&lt;/p&gt;

&lt;p&gt;Then check from outside, the way users see it. The health URL answered again, and the request rate on the dashboard came back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it on your own cluster
&lt;/h2&gt;

&lt;p&gt;You don't need anything special to reproduce this. On a test cluster, take any Deployment whose app reads its port from an environment variable and give it an impossible one:&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="nb"&gt;set env &lt;/span&gt;deploy/&amp;lt;your-test-app&amp;gt; &lt;span class="nv"&gt;PORT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;80800
kubectl get pods &lt;span class="nt"&gt;-w&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the default rolling update you'll likely see the stalled-rollout version: the old pod stays Running while the new one crash-loops. That's a useful thing to see for yourself, because it's exactly why the ReplicaSet check at the start matters. Put it back with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl rollout undo deploy/&amp;lt;your-test-app&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you'd rather practise the full outage version, with traffic, dashboards, alerts and a check that tells you when you've actually fixed it, this drill is one of the incidents in SnowOps Labs, a free, open-source Kubernetes lab that runs on a laptop. Disclosure: I build it. Every screenshot above is from it, and the code is on &lt;a href="https://www.github.com/sagar2395/snowopslabs" rel="noopener noreferrer"&gt;GitHub&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%2Fd5afyzizxlz8i9552x71.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%2Fd5afyzizxlz8i9552x71.png" alt="Snowopslabs UI" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Other causes of the same status
&lt;/h2&gt;

&lt;p&gt;The port typo is one cause among many. The Kubernetes docs group the usual suspects into application errors, configuration errors, resource limits, and failing health checks. In practice that looks like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A bug or a missing dependency in the app.&lt;/strong&gt; Logs with &lt;code&gt;--previous&lt;/code&gt; usually say so, often as a stack trace or a "connection refused" to a database that isn't there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missing or wrong configuration.&lt;/strong&gt; A typo like this one, a variable renamed in one place and not the other, a config file mounted at the wrong path, or a value that's fine in staging and wrong in production.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not enough memory.&lt;/strong&gt; Exit code 137 with &lt;code&gt;Reason: OOMKilled&lt;/code&gt; in &lt;code&gt;describe&lt;/code&gt;. Raise the limit or fix the leak, but find out which one first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A liveness probe that kills a healthy app.&lt;/strong&gt; The app starts, the probe fails (too strict, wrong path, too short a delay for a slow start), and the kubelet restarts the container. The events say &lt;code&gt;Liveness probe failed&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The container never really starts.&lt;/strong&gt; A wrong &lt;code&gt;command&lt;/code&gt; or &lt;code&gt;args&lt;/code&gt;, an image built for another CPU architecture, a missing binary. Here the logs are often empty, because nothing ran long enough to write any. &lt;code&gt;describe&lt;/code&gt; and its events carry the message instead.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One extra tip from the docs on finding why a pod failed: set &lt;code&gt;terminationMessagePolicy: FallbackToLogsOnError&lt;/code&gt; on your containers. When a container fails without writing a termination message, Kubernetes then copies the last lines of its log into the pod status, so &lt;code&gt;describe&lt;/code&gt; shows the error even after the logs are gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;When a pod is in CrashLoopBackOff, this order gets you to the cause quickly, whatever the cause turns out to be:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;kubectl get pods&lt;/code&gt; shows the symptom and the restart count.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;kubectl get rs&lt;/code&gt; tells you whether anything is still serving, which decides how urgent this is.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;kubectl logs &amp;lt;pod&amp;gt; --previous&lt;/code&gt; gives you the dead container's last words.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;kubectl describe pod &amp;lt;pod&amp;gt;&lt;/code&gt; gives the exit code, the reason and the events.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then fix the thing the pod was copied from, not the pod, and let &lt;code&gt;rollout status&lt;/code&gt; tell you when it's really done.&lt;/p&gt;

</description>
      <category>debugging</category>
      <category>devops</category>
      <category>go</category>
      <category>kubernetes</category>
    </item>
  </channel>
</rss>
