<?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: Csaba Ajtony</title>
    <description>The latest articles on DEV Community by Csaba Ajtony (@pits2022).</description>
    <link>https://dev.to/pits2022</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%2F4146619%2Fd0189284-70e3-4ee2-9365-fa0dcac1d0d4.jpg</url>
      <title>DEV Community: Csaba Ajtony</title>
      <link>https://dev.to/pits2022</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/pits2022"/>
    <language>en</language>
    <item>
      <title>The 12 kubectl Queries We Run Before Every Cloud Cost Review</title>
      <dc:creator>Csaba Ajtony</dc:creator>
      <pubDate>Tue, 06 Oct 2026 08:27:35 +0000</pubDate>
      <link>https://dev.to/pits2022/the-12-kubectl-queries-we-run-before-every-cloud-cost-review-2g61</link>
      <guid>https://dev.to/pits2022/the-12-kubectl-queries-we-run-before-every-cloud-cost-review-2g61</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://www.professional-it-services.com/the-12-kubectl-queries-we-run-before-every-cloud-cost-review/" rel="noopener noreferrer"&gt;Professional IT Services blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Twelve queries show what a Kubernetes cluster costs and where it wastes money: nodes and taints, requests and limits, real usage, top pods, QoS classes, idle requests, pods over their requests, volumes, orphans, leftovers, load balancers and restarts. They need only &lt;code&gt;kubectl&lt;/code&gt;, &lt;code&gt;jq&lt;/code&gt; and the &lt;code&gt;hcloud&lt;/code&gt; CLI, and the output below comes from our production K3s cluster.&lt;/p&gt;

&lt;p&gt;That is the most direct route to &lt;strong&gt;reducing Kubernetes operational overhead&lt;/strong&gt;: before changing anything, measure what is requested, what is used and what is billed. Our earlier post covers what we changed. This one is the checklist we run first, and the same queries run on client clusters before their cost reviews.&lt;/p&gt;

&lt;p&gt;All results were measured &lt;strong&gt;read-only on 1 October 2026&lt;/strong&gt; on &lt;code&gt;pits-prod-fsn1-cluster-01&lt;/code&gt;: K3s on Hetzner Cloud, five nodes, 98 running pods, 135 containers. Client namespaces are anonymised (&lt;code&gt;client-a&lt;/code&gt;, &lt;code&gt;client-b&lt;/code&gt;, &lt;code&gt;shop-wp&lt;/code&gt;), pod-hash suffixes are trimmed, and long outputs are cut to the rows that matter.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 12 queries at a glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Query&lt;/th&gt;
&lt;th&gt;Question it answers&lt;/th&gt;
&lt;th&gt;Our result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;code&gt;kubectl get nodes -o custom-columns&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;What are we paying for, and what is tainted?&lt;/td&gt;
&lt;td&gt;5 nodes, 42 vCPU, 4 of 5 with an empty instance-type label&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;&lt;code&gt;kubectl describe nodes&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;How much is requested and limited per node?&lt;/td&gt;
&lt;td&gt;CPU limits 124% of capacity, 204% on one node&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;&lt;code&gt;kubectl top nodes&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;What is actually used?&lt;/td&gt;
&lt;td&gt;4.7% CPU, one node at 14%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;&lt;code&gt;kubectl top pods -A --sort-by=memory&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Who uses the memory?&lt;/td&gt;
&lt;td&gt;One database pod at 4,290 Mi&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;QoS census with &lt;code&gt;jq&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Who is evicted first?&lt;/td&gt;
&lt;td&gt;0 Guaranteed, 77 Burstable, 21 BestEffort&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Requests against usage per pod&lt;/td&gt;
&lt;td&gt;Where are requests idle?&lt;/td&gt;
&lt;td&gt;About 2.7 GiB idle on two database replicas&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Pods above their requests&lt;/td&gt;
&lt;td&gt;Where is the other side of the problem?&lt;/td&gt;
&lt;td&gt;12 pods above a request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;Volume capacity against use&lt;/td&gt;
&lt;td&gt;Which volumes are empty?&lt;/td&gt;
&lt;td&gt;14 of 16 10-GB volumes under 1% used&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;Orphaned volumes&lt;/td&gt;
&lt;td&gt;What do we pay for with no pod attached?&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;Zero-replica workloads, leftover jobs&lt;/td&gt;
&lt;td&gt;What was left behind?&lt;/td&gt;
&lt;td&gt;1 StatefulSet at 0, 10 completed job pods&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;LoadBalancer&lt;/code&gt; and &lt;code&gt;NodePort&lt;/code&gt; Services&lt;/td&gt;
&lt;td&gt;What is billed outside the cluster?&lt;/td&gt;
&lt;td&gt;One Service, one billed load balancer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;Restarts and OOMKills&lt;/td&gt;
&lt;td&gt;Is anything already breaking?&lt;/td&gt;
&lt;td&gt;No OOMKill&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Why run the queries before every review, not just once?
&lt;/h2&gt;

&lt;p&gt;Because the numbers move without anyone deciding to move them. In May 2026 we published &lt;a href="https://www.professional-it-services.com/how-we-cut-kubernetes-resource-overhead-by-50-using-only-built-in-tools/" rel="noopener noreferrer"&gt;how we cut Kubernetes resource overhead by 50% using only built-in tools&lt;/a&gt;, which took the CPU limits commitment from 105% down to &lt;strong&gt;77.7%&lt;/strong&gt;. Five months later the same metric reads &lt;strong&gt;124% across the cluster and 204% on one node&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;There was no single bad decision behind it. The drift has two parts:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;May 2026 post&lt;/th&gt;
&lt;th&gt;1 October 2026&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CPU limits committed&lt;/td&gt;
&lt;td&gt;77.7%&lt;/td&gt;
&lt;td&gt;124%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cluster capacity&lt;/td&gt;
&lt;td&gt;about 58 vCPU&lt;/td&gt;
&lt;td&gt;42 vCPU&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPU limits in cores&lt;/td&gt;
&lt;td&gt;about 45&lt;/td&gt;
&lt;td&gt;52.05&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPU limits against the old 58 vCPU&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;about 90%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The denominator shrank.&lt;/strong&gt; After Hetzner raised its prices in spring 2026 (usage in April and May is already billed at the new rates: CAX11 from €3.29 to €4.49, CAX31 from €11.99 to €15.99, CAX41 from €23.99 to €31.49), we went looking for ways to downscale. The worker count stayed at four, but capacity went from 58 to 42 vCPU. Monthly compute cost went from €114.95 to €81.95, which is €33 or 29% less. The newest node, a CX53 (16 vCPU, 32 GB) at €29.49 a month, replaced a CAX41 at €31.49 on 29 September.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The limits grew.&lt;/strong&gt; Based on usage metrics, we raised the limit-to-request ratio on many deployments, the "low requests, generous limits" pattern from the May post.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On the old 58 vCPU the same 52.05 cores of limits would read about 90%, so most of the jump is the smaller denominator and the rest is deliberate. The May capacity is inferred from the Hetzner invoices, not measured, so treat 58 and 90% as approximate. The lesson stands either way: run the queries before every review, because a good number in May says nothing about October.&lt;/p&gt;

&lt;h2&gt;
  
  
  The queries, with real output
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Node inventory: type, architecture, capacity, taints
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get nodes &lt;span class="nt"&gt;-o&lt;/span&gt; custom-columns&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'NAME:.metadata.name,TYPE:.metadata.labels.node\.kubernetes\.io/instance-type,ARCH:.status.nodeInfo.architecture,CPU:.status.allocatable.cpu,TAINTS:.spec.taints[*].key'&lt;/span&gt;
hcloud server list &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;columns&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;name,type,location
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAME            TYPE     ARCH    CPU   TAINTS
master-node     k3s      arm64   2     &amp;lt;none&amp;gt;
worker-node-6   &amp;lt;none&amp;gt;   amd64   8     dedicated
worker-node-7   &amp;lt;none&amp;gt;   amd64   8     &amp;lt;none&amp;gt;
worker-node-8   &amp;lt;none&amp;gt;   amd64   8     &amp;lt;none&amp;gt;
worker-node-9   &amp;lt;none&amp;gt;   amd64   16    &amp;lt;none&amp;gt;

NAME            TYPE    LOCATION
master-node     cax11   fsn1
worker-node-6   cx43    fsn1
worker-node-8   cx43    fsn1
worker-node-7   cx43    fsn1
worker-node-9   cx53    fsn1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What it tells you:&lt;/strong&gt; the inventory, and the first gap: K3s without the Hetzner cloud controller manager leaves the instance-type label empty (&lt;code&gt;k3s&lt;/code&gt; on the master, &lt;code&gt;&amp;lt;none&amp;gt;&lt;/code&gt; elsewhere), so the price mapping needs &lt;code&gt;hcloud server list&lt;/code&gt;. Read the &lt;code&gt;TAINTS&lt;/code&gt; column now; it explains query 3.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Requests and limits per node
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl describe nodes | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'/^Name:/{n=$2} /^  (cpu|memory) /{print n, $1, $5}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;master-node cpu (95%)
master-node memory (26%)
worker-node-6 cpu (48%)
worker-node-6 memory (45%)
worker-node-7 cpu (138%)
worker-node-7 memory (118%)
worker-node-8 cpu (204%)
worker-node-8 memory (117%)
worker-node-9 cpu (117%)
worker-node-9 memory (113%)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The awk keeps only the &lt;strong&gt;limits&lt;/strong&gt; percentage from each "Allocated resources" block. For the cluster total against allocatable 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;alloc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;kubectl get nodes &lt;span class="nt"&gt;-o&lt;/span&gt; json | jq &lt;span class="s1"&gt;'[.items[].status.allocatable.cpu | tonumber] | add * 1000'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
kubectl get pods &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="nt"&gt;--field-selector&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;status.phase&lt;span class="o"&gt;=&lt;/span&gt;Running &lt;span class="nt"&gt;-o&lt;/span&gt; json | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="nt"&gt;--argjson&lt;/span&gt; a &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$alloc&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s1"&gt;'
  def cpu: if . == null then 0 elif endswith("m") then (.[:-1] | tonumber) else (tonumber * 1000) end;
  def sum(f): [.items[].spec.containers[].resources | f | cpu] | add;
  sum(.requests.cpu) as $r | sum(.limits.cpu) as $l
  | "requests \($r)m (\($r * 100 / $a | round)%), limits \($l)m (\($l * 100 / $a | round)%)"'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;requests 8425m (20%), limits 52050m (124%)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What it tells you:&lt;/strong&gt; how far the node can be oversold. Memory limits above 100% mean that if every pod grew to its limit, the node would run out of memory and the kernel would OOM-kill pods or the kubelet would evict them. CPU above 100% means throttling, not a crash.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Actual usage
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl top nodes &lt;span class="nt"&gt;--no-headers&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{c+=$2; m+=$4} END {printf "%dm CPU, %.1f GiB\n", c, m/1024}'&lt;/span&gt;
kubectl top node worker-node-6 &lt;span class="nt"&gt;--no-headers&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $1, "cpu", $2, "mem", $5}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1968m CPU, 25.3 GiB
worker-node-6 cpu 45m mem 14%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;CPU&lt;/th&gt;
&lt;th&gt;Memory&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Allocatable&lt;/td&gt;
&lt;td&gt;42,000m&lt;/td&gt;
&lt;td&gt;80.1 GiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Requested&lt;/td&gt;
&lt;td&gt;8,425m (20%)&lt;/td&gt;
&lt;td&gt;34.2 GiB (43%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Actually used&lt;/td&gt;
&lt;td&gt;1,968m (4.7%)&lt;/td&gt;
&lt;td&gt;25.3 GiB (32%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Used as a share of requested&lt;/td&gt;
&lt;td&gt;23%&lt;/td&gt;
&lt;td&gt;74%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Limits committed&lt;/td&gt;
&lt;td&gt;52,050m (124%)&lt;/td&gt;
&lt;td&gt;78.7 GiB (98%)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;What it tells you:&lt;/strong&gt; the gap between what was reserved and what runs. CPU is the wide one: 20% requested, 4.7% used. The outlier is &lt;code&gt;worker-node-6&lt;/code&gt;: 45m CPU and 2.2 GiB of memory, &lt;strong&gt;14%&lt;/strong&gt; of an 8 vCPU / 15.2 GiB node that costs €15.99 a month.&lt;/p&gt;

&lt;p&gt;Query 1 explains why. MariaDB Galera is no longer pinned to one dedicated node: its replicas spread over three labelled workers, one per node. But &lt;code&gt;worker-node-6&lt;/code&gt; still carries the &lt;code&gt;dedicated=mariadb:NoSchedule&lt;/code&gt; taint, so nothing except Galera and the DaemonSets can land there. Today it runs one Galera replica plus four DaemonSet pods. The chart comment says the taint stays "until a multi-arch audit", and since 29 September every worker is amd64. The taint is a leftover from the mixed-architecture period, and it keeps every other workload off the node. &lt;strong&gt;Re-check that the reason behind each taint still holds.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Top pods by memory
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl top pods &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="nt"&gt;--sort-by&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;memory &lt;span class="nt"&gt;--no-headers&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-6&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $1"/"$2, $4}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;prod-mariadb/mariadb-galera-new-0 4290Mi
prod-mariadb/mariadb-galera-new-1 1441Mi
prod-mariadb/mariadb-galera-new-2 1272Mi
prod-prometheus/prometheus-prometheus-stack-kube-prom-prometheus-0 1255Mi
prod-mailu/mailu-clamav-0 985Mi
prod-prometheus/loki-chunks-cache-0 881Mi
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What it tells you:&lt;/strong&gt; where the memory goes. The database primary dominates, and the Loki chunks cache is down to 881 Mi, so the change from the May post held (it was 8 GB). Sort by &lt;code&gt;cpu&lt;/code&gt; the same way; Prometheus used 243m.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. QoS census
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get pods &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="nt"&gt;--field-selector&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;status.phase&lt;span class="o"&gt;=&lt;/span&gt;Running &lt;span class="nt"&gt;-o&lt;/span&gt; json | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.items[].status.qosClass'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;uniq&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt;
kubectl get pods &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="nt"&gt;--field-selector&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;status.phase&lt;span class="o"&gt;=&lt;/span&gt;Running &lt;span class="nt"&gt;-o&lt;/span&gt; json | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'[.items[].spec.containers[]] as $c | "\($c | map(select(.resources.limits.memory == null)) | length) of \($c | length) containers have no memory limit"'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;     21 BestEffort
     77 Burstable
56 of 135 containers have no memory limit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What it tells you:&lt;/strong&gt; who is evicted first under memory pressure. No &lt;code&gt;Guaranteed&lt;/code&gt; line means none (that class needs requests equal to limits on every container). The 21 BestEffort pods include all of cert-manager, the hcloud CSI controller and node plugins, and the k3s &lt;code&gt;svclb&lt;/code&gt; pods; 56 of 135 containers have no memory limit at all.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Requests against usage, per pod
&lt;/h3&gt;

&lt;p&gt;Both this query and the next one start from one joined table: requests from the pod specs, usage from the metrics API.&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 pods &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="nt"&gt;--field-selector&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;status.phase&lt;span class="o"&gt;=&lt;/span&gt;Running &lt;span class="nt"&gt;-o&lt;/span&gt; json &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; pods.json
kubectl get &lt;span class="nt"&gt;--raw&lt;/span&gt; /apis/metrics.k8s.io/v1beta1/pods &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; usage.json
jq &lt;span class="nt"&gt;-rs&lt;/span&gt; &lt;span class="s1"&gt;'
  def q: if . == null then 0 else
    capture("^(?&amp;lt;n&amp;gt;[0-9.]+)(?&amp;lt;u&amp;gt;[A-Za-z]*)$") as $c
    | ($c.n | tonumber) * ({"n":1e-9,"u":1e-6,"m":1e-3,"":1,"Ki":1024,"Mi":1048576,"Gi":1073741824}[$c.u]) end;
  (.[1].items | map({(.metadata.namespace + "/" + .metadata.name):
     {cpu: ([.containers[].usage.cpu | q] | add), mem: ([.containers[].usage.memory | q] | add)}}) | add) as $u
  | .[0].items[] | (.metadata.namespace + "/" + .metadata.name) as $k | select($u[$k])
  | [$k, ([.spec.containers[].resources.requests.cpu | q] | add) * 1000, $u[$k].cpu * 1000,
     ([.spec.containers[].resources.requests.memory | q] | add) / 1048576, $u[$k].mem / 1048576]
  | map(if type == "number" then floor else . end) | @tsv'&lt;/span&gt; pods.json usage.json &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; usage.tsv
&lt;span class="c"&gt;# columns: pod, cpu request (m), cpu usage (m), memory request (Mi), memory usage (Mi)&lt;/span&gt;

&lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt;&lt;span class="s1"&gt;'\t'&lt;/span&gt; &lt;span class="s1"&gt;'{print $1 "\t" $4 "\t" $5 "\t" $4-$5}'&lt;/span&gt; usage.tsv | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt;&lt;span class="s1"&gt;$'&lt;/span&gt;&lt;span class="se"&gt;\t&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt; &lt;span class="nt"&gt;-k4&lt;/span&gt; &lt;span class="nt"&gt;-nr&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-4&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;prod-mariadb/mariadb-galera-new-2    4096   1272   2824
prod-mariadb/mariadb-galera-new-1    4096   1441   2655
prod-nextcloud/nextcloud-…           2176    652   1524
prod-mailu/mailu-clamav-0            2048    985   1063
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What it tells you:&lt;/strong&gt; the biggest idle memory requests (request, usage, difference in Mi): two database replicas hold roughly 2.6 and 2.8 GiB they do not use. Swap columns 4 and 5 for 2 and 3 to rank CPU: Nextcloud requests 300m and uses 13m, &lt;code&gt;shop-wp&lt;/code&gt; requests 300m and uses 28m, and six Mailu and ingress pods request 200m each while using 1 to 8m. A database buffer pool is meant to be warm, so read this as a list of where to look, not what to cut.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. Pods that use more than they request
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt;&lt;span class="s1"&gt;'\t'&lt;/span&gt; &lt;span class="s1"&gt;'($4&amp;gt;0 &amp;amp;&amp;amp; $5&amp;gt;$4) || ($2&amp;gt;0 &amp;amp;&amp;amp; $3&amp;gt;$2) {n++} END {print n " pods above a request"}'&lt;/span&gt; usage.tsv
&lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt;&lt;span class="s1"&gt;'\t'&lt;/span&gt; &lt;span class="s1"&gt;'$4&amp;gt;0 &amp;amp;&amp;amp; $5&amp;gt;$4 {print $1 "\t" $4 "\t" $5 "\t" $5-$4}'&lt;/span&gt; usage.tsv | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt;&lt;span class="s1"&gt;$'&lt;/span&gt;&lt;span class="se"&gt;\t&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt; &lt;span class="nt"&gt;-k4&lt;/span&gt; &lt;span class="nt"&gt;-nr&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-3&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;12 pods above a request
prod-mariadb/mariadb-galera-new-0                                   4096   4290   194
prod-prometheus/prometheus-prometheus-stack-kube-prom-prometheus-0  1074   1257   183
ingress-nginx/ingress-nginx-controller-…                             128    268   140
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What it tells you:&lt;/strong&gt; the other side of over-requesting. Under-requested pods are the first evicted when a node runs short of memory. Besides these memory rows, Dovecot used 327m CPU against a 200m request, and all three ingress-nginx pods were above their memory request.&lt;/p&gt;

&lt;h3&gt;
  
  
  8. Volume capacity against actual use
&lt;/h3&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;n &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;kubectl get nodes &lt;span class="nt"&gt;-o&lt;/span&gt; name | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;/ &lt;span class="nt"&gt;-f2&lt;/span&gt;&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;kubectl get &lt;span class="nt"&gt;--raw&lt;/span&gt; /api/v1/nodes/&lt;span class="nv"&gt;$n&lt;/span&gt;/proxy/stats/summary | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.pods[].volume[]? | select(.pvcRef) | [.pvcRef.namespace + "/" + .pvcRef.name, .capacityBytes, .usedBytes] | @tsv'&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; pvc.tsv
&lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt;&lt;span class="s1"&gt;'\t'&lt;/span&gt; &lt;span class="s1"&gt;'$2 &amp;gt; 9e9 &amp;amp;&amp;amp; $2 &amp;lt; 11e9 {n++; if ($3 / $2 &amp;lt; 0.01) u++} END {print n " volumes of ~10 GB, " u " under 1% used"}'&lt;/span&gt; pvc.tsv
hcloud volume list &lt;span class="nt"&gt;-o&lt;/span&gt; noheader &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;columns&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;size | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{s+=$1} END {print NR " volumes, " s " GB"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;16 volumes of ~10 GB, 14 under 1% used
24 volumes, 510 GB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What it tells you:&lt;/strong&gt; the kubelet stats endpoint (it needs permission on &lt;code&gt;nodes/proxy&lt;/code&gt;) reports capacity and use per claim. 14 of the 16 ten-gigabyte volumes were under 1% used, most under 60 MB, yet &lt;strong&gt;10 GB is Hetzner's minimum volume size&lt;/strong&gt; (Hetzner's documentation gives the range as 10 GB to 10 TB), so the saving is fewer volumes, not smaller ones. 510 GB at €0.0572 per GB-month is about €29 a month; the July invoice shows €31.22 for volumes, about the price of two CX43 nodes. One gotcha: the two local &lt;code&gt;emptydir-storage&lt;/code&gt; claims report the node's root disk (149.9 GiB), not the claim.&lt;/p&gt;

&lt;h3&gt;
  
  
  9. Orphaned volumes
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get pv &lt;span class="nt"&gt;-o&lt;/span&gt; json | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.items[] | select(.status.phase != "Bound") | .metadata.name'&lt;/span&gt;
&lt;span class="nb"&gt;comm&lt;/span&gt; &lt;span class="nt"&gt;-23&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;kubectl get pvc &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; json | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.items[] | .metadata.namespace + "/" + .metadata.name'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;kubectl get pods &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; json | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.items[] | .metadata.namespace as $ns | .spec.volumes[]? | select(.persistentVolumeClaim) | $ns + "/" + .persistentVolumeClaim.claimName'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(no output)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What it tells you:&lt;/strong&gt; the first command lists &lt;code&gt;Released&lt;/code&gt; and &lt;code&gt;Available&lt;/code&gt; volumes, the second lists claims that no pod mounts. Both were empty. A clean result is worth keeping in the review: the Hetzner CSI volumes use the &lt;code&gt;Delete&lt;/code&gt; reclaim policy, so a deleted claim takes its billed volume with it.&lt;/p&gt;

&lt;h3&gt;
  
  
  10. Zero-replica workloads and leftover jobs
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get deploy,sts &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; json | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.items[] | select(.spec.replicas == 0) | .kind + " " + .metadata.namespace + "/" + .metadata.name'&lt;/span&gt;
kubectl get pods &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="nt"&gt;--field-selector&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;status.phase&lt;span class="o"&gt;=&lt;/span&gt;Succeeded &lt;span class="nt"&gt;--sort-by&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;.metadata.creationTimestamp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; custom-columns&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'NAMESPACE:.metadata.namespace,POD:.metadata.name,CREATED:.metadata.creationTimestamp'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;StatefulSet prod-nextcloud/nextcloud-redis-replicas

NAMESPACE      POD                    CREATED
prod-mailu     backup-29723220-…      2026-07-07T03:00:00Z
...            (9 more completed job pods)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What it tells you:&lt;/strong&gt; what was paused or finished and never removed. One StatefulSet sat at zero replicas, and there were 10 completed job pods, the oldest a July mail-backup run. A zero-replica workload is a deliberate pause or a forgotten one, and the volumes it owns would still be billed, which is what query 9 catches.&lt;/p&gt;

&lt;h3&gt;
  
  
  11. LoadBalancer and NodePort Services
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get svc &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; json | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.items[] | select(.spec.type == "LoadBalancer" or .spec.type == "NodePort") | [.metadata.namespace, .metadata.name, .spec.type, (.status.loadBalancer.ingress[0].ip // "-"), ([.spec.ports[].nodePort] | join(","))] | @tsv'&lt;/span&gt;
hcloud load-balancer list &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;columns&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;name,type
hcloud primary-ip list &lt;span class="nt"&gt;-o&lt;/span&gt; noheader &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;columns&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;uniq&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ingress-nginx   ingress-nginx-controller   LoadBalancer   &amp;lt;master-ip&amp;gt;   30221,31065

NAME              TYPE
load-balancer-1   lb11

      5 ipv4
      5 ipv6
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What it tells you:&lt;/strong&gt; kubectl shows one &lt;code&gt;LoadBalancer&lt;/code&gt; Service, answered by the k3s ServiceLB on the master's IP. The invoice shows a Hetzner Load Balancer 11 (€7.49 a month) forwarding ports 80 and 443 to NodePorts 30221 and 31065 on four nodes. It is &lt;strong&gt;not an orphan&lt;/strong&gt;, only invisible from the Service. The five primary IPv4 addresses are the same story: separate invoice lines (€0.50 each) that no Kubernetes object points to.&lt;/p&gt;

&lt;h3&gt;
  
  
  12. Restarts and OOMKills
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get pods &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; json | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.items[] | .metadata.namespace as $ns | .metadata.name as $p | .status.containerStatuses[]? | select(.lastState.terminated.reason == "OOMKilled") | [$ns, $p, .name] | @tsv'&lt;/span&gt;
kubectl get pods &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; json | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.items[] | .metadata.namespace as $ns | .metadata.name as $p | .status.containerStatuses[]? | select(.restartCount &amp;gt; 0) | [$ns + "/" + $p, .name, .restartCount, (.lastState.terminated.finishedAt // "-")] | @tsv'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt;&lt;span class="s1"&gt;$'&lt;/span&gt;&lt;span class="se"&gt;\t&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt; &lt;span class="nt"&gt;-k3&lt;/span&gt; &lt;span class="nt"&gt;-nr&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-8&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(no OOMKilled rows)

kube-system/hcloud-csi-node-…   hcloud-csi-driver   14   2026-06-17T19:19:06Z
...
prod-mailu/mailu-front-…        front               1    2026-09-29T12:40:59Z
prod-mailu/mailu-front-…        front               1    2026-09-29T12:41:59Z
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What it tells you:&lt;/strong&gt; whether a tight limit is already hurting. There was no OOMKill anywhere. The 14 restarts of the CSI node driver date from the &lt;a href="https://www.professional-it-services.com/kubernetes-deployments-daemonsets-and-statefulsets-a-deep-dive/" rel="noopener noreferrer"&gt;17 June outage&lt;/a&gt;, and the two &lt;code&gt;mailu-front&lt;/code&gt; restarts from 29 September.&lt;/p&gt;

&lt;h2&gt;
  
  
  What can't kubectl tell you about the bill?
&lt;/h2&gt;

&lt;p&gt;Everything above is a view of the cluster. The invoice is a view of the cloud account, and three things sit between them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Instance types.&lt;/strong&gt; Without the cloud controller manager the label is empty, so a node cannot be priced from kubectl alone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Load balancers.&lt;/strong&gt; A balancer created outside the cluster has no Service of its own. The only trace in Kubernetes is a NodePort.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Addresses and volumes.&lt;/strong&gt; Primary IPv4 addresses are billed per address, and every PVC becomes a billed volume with a 10 GB floor.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So map each node, Service and volume to an invoice line once, by hand, and keep the table with the queries. Ours, from the July 2026 invoice (net prices):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What kubectl shows&lt;/th&gt;
&lt;th&gt;Invoice line&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;5 nodes&lt;/td&gt;
&lt;td&gt;CAX11 €4.49, CAX41 €31.49, 3 × CX43 €15.99 each (the CAX41 has since become a CX53 at €29.49)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ingress-nginx-controller&lt;/code&gt; Service&lt;/td&gt;
&lt;td&gt;Load Balancer 11, €7.49&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Node public addresses&lt;/td&gt;
&lt;td&gt;5 × Primary IPv4, €0.50 each&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;24 PVCs on Hetzner volumes&lt;/td&gt;
&lt;td&gt;Volume, €0.0572 per GB-month, 545.761 GB-months, €31.22&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Compute, load balancer, addresses and volumes came to &lt;strong&gt;€125.28 a month&lt;/strong&gt; on that invoice, and €134.96 with the Storage Box and Object Storage. That is the number the 12 queries are trying to move.&lt;/p&gt;

&lt;h2&gt;
  
  
  How we use these queries in practice
&lt;/h2&gt;

&lt;p&gt;Every deployment on our clusters starts with sensible requests and limits, and gets tuned from the metrics afterwards. The queries above are the metrics part. We run the same set on client clusters before their cost reviews, because the order of work matters: measure first, then change requests, then look at nodes and volumes.&lt;/p&gt;

&lt;p&gt;For a team that wants this done once, that is a &lt;a href="https://www.professional-it-services.com/our-services/" rel="noopener noreferrer"&gt;cloud cost optimization audit&lt;/a&gt;: the same read of requests, limits, usage and invoice lines, delivered as a prioritised action plan. If the numbers should stay healthy (limits drift back, a taint outlives its reason, a node swap changes the denominator), it belongs in a &lt;a href="https://www.professional-it-services.com/devops-plans/" rel="noopener noreferrer"&gt;Fractional DevOps retainer&lt;/a&gt;. To see your own cluster's numbers first, &lt;a href="https://www.professional-it-services.com/contact/" rel="noopener noreferrer"&gt;book a free discovery call&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>devops</category>
      <category>finops</category>
    </item>
    <item>
      <title>Kubernetes Deployments, DaemonSets, and StatefulSets: a Deep Dive from a Production Outage</title>
      <dc:creator>Csaba Ajtony</dc:creator>
      <pubDate>Mon, 28 Sep 2026 08:35:32 +0000</pubDate>
      <link>https://dev.to/pits2022/kubernetes-deployments-daemonsets-and-statefulsets-a-deep-dive-from-a-production-outage-4ghc</link>
      <guid>https://dev.to/pits2022/kubernetes-deployments-daemonsets-and-statefulsets-a-deep-dive-from-a-production-outage-4ghc</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://www.professional-it-services.com/kubernetes-deployments-daemonsets-and-statefulsets-a-deep-dive/" rel="noopener noreferrer"&gt;Professional IT Services blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every introduction to Kubernetes workload controllers gives the same three-line answer: Deployments for stateless apps, DaemonSets for one pod per node, StatefulSets for databases. That answer is correct, and it is also the part that never causes an outage. What causes outages is how each controller behaves when a node is drained, a volume refuses to attach, or a rollout hits a pod that never becomes healthy.&lt;/p&gt;

&lt;p&gt;This deep dive answers the question from a production cluster rather than from the documentation. The worked example is our own &lt;strong&gt;K3s cluster on Hetzner Cloud: 5 nodes running 50 Deployments, 6 DaemonSets and 12 StatefulSets, with 26 persistent volumes&lt;/strong&gt;. And the story that ties it together is the &lt;strong&gt;three-hour outage of 17 June 2026&lt;/strong&gt;, when a routine node upgrade took every WordPress site on the cluster offline, and the workload controllers decided both how bad it got and how it was repaired.&lt;/p&gt;

&lt;p&gt;One boundary first: this article is about &lt;strong&gt;which controller, and how it behaves when things break&lt;/strong&gt;. Sizing — requests, limits, what the Galera pods and the DaemonSets actually consume — is covered separately in &lt;a href="https://www.professional-it-services.com/how-we-cut-kubernetes-resource-overhead-by-50-using-only-built-in-tools/" rel="noopener noreferrer"&gt;how we cut Kubernetes resource overhead by 50% using only built-in tools&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 30-second decision tree
&lt;/h2&gt;

&lt;p&gt;Ask three questions, in this order, and stop at the first yes.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Does the pod work with the node itself&lt;/strong&gt; — its logs, its metrics, its disks, its network, or traffic that arrives at that node? → &lt;strong&gt;DaemonSet.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does each replica need its own identity and its own data&lt;/strong&gt; that must follow it when it is rescheduled — a database member, a message broker, a quorum peer? → &lt;strong&gt;StatefulSet.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Everything else&lt;/strong&gt; → &lt;strong&gt;Deployment.&lt;/strong&gt; That includes an app that mounts a single persistent volume — but then choose the update strategy on purpose (see the next section).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In one sentence: &lt;strong&gt;a Deployment is for interchangeable pods, a StatefulSet is for pods that are not interchangeable, and a DaemonSet is for pods that belong to a node rather than to the application.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Deployments: the default, and the persistent-volume trap
&lt;/h2&gt;

&lt;p&gt;A Deployment keeps a given number of identical pods running through a ReplicaSet. Pods get random names, any one can replace any other, and updates roll out gradually, with rollback built in. That is exactly right for web servers, APIs, workers and most of what a cluster runs, which is why &lt;strong&gt;50 of our 68 workload controllers are Deployments&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can a Deployment use persistent storage?
&lt;/h3&gt;

&lt;p&gt;Yes. The previous version of this article said Deployments have ephemeral storage, and that was wrong: a Deployment can mount a PersistentVolumeClaim like any other pod. The trap is not the storage. &lt;strong&gt;It is the default update strategy combined with a ReadWriteOnce volume.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A Deployment's default strategy is &lt;code&gt;RollingUpdate&lt;/code&gt; with &lt;code&gt;maxSurge: 25%&lt;/code&gt; and &lt;code&gt;maxUnavailable: 25%&lt;/code&gt;. Kubernetes rounds the surge &lt;strong&gt;up&lt;/strong&gt; and the unavailability &lt;strong&gt;down&lt;/strong&gt;. With &lt;code&gt;replicas: 1&lt;/code&gt;, that means a surge of 1 and an unavailability of 0: &lt;strong&gt;the new pod starts before the old one stops&lt;/strong&gt;. If the scheduler places the new pod on a different node, the ReadWriteOnce volume is still attached to the old node, the new pod sits in &lt;code&gt;ContainerCreating&lt;/code&gt; with a &lt;code&gt;Multi-Attach error&lt;/code&gt;, and the rollout stalls.&lt;/p&gt;

&lt;p&gt;On our cluster, &lt;strong&gt;15 Deployments mount a PVC. All 15 are ReadWriteOnce Hetzner volumes, and all 15 run a single replica.&lt;/strong&gt; They include the WordPress sites, a mail stack whose &lt;strong&gt;five components share one 100 GiB volume&lt;/strong&gt;, Nextcloud, Grafana and a newsletter server. Every one of them is a candidate for the trap.&lt;/p&gt;

&lt;p&gt;After the June outage we reviewed all fifteen. The rule the review settled on: &lt;strong&gt;&lt;code&gt;Recreate&lt;/code&gt; where it is necessary, &lt;code&gt;RollingUpdate&lt;/code&gt; where it is feasible.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Recreate&lt;/code&gt;&lt;/strong&gt; stops the old pod before starting the new one. Each rollout costs a few seconds of downtime, but it cannot deadlock on a volume. Three of the fifteen use it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;RollingUpdate&lt;/code&gt; on one node.&lt;/strong&gt; ReadWriteOnce means &lt;em&gt;one node at a time&lt;/em&gt;, not one pod — the per-pod mode is &lt;code&gt;ReadWriteOncePod&lt;/code&gt;. Two pods on the &lt;strong&gt;same&lt;/strong&gt; node can both mount the volume. So the WordPress sites keep their zero-downtime rollouts, and are kept on one node instead.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Our web chart does it with a &lt;code&gt;pod-group&lt;/code&gt; label and a pod-affinity rule, so the new pod is drawn to the node where its predecessor, and any other pod sharing the volume, already runs:&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;strategy&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;RollingUpdate&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;pod-group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;.Values.podAffinity.group&lt;/span&gt; &lt;span class="pi"&gt;}}&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;affinity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;podAffinity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;preferredDuringSchedulingIgnoredDuringExecution&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;weight&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100&lt;/span&gt;
            &lt;span class="na"&gt;podAffinityTerm&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;labelSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;matchExpressions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pod-group&lt;/span&gt;
                    &lt;span class="na"&gt;operator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;In&lt;/span&gt;
                    &lt;span class="na"&gt;values&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;.Values.podAffinity.group&lt;/span&gt; &lt;span class="pi"&gt;}}&lt;/span&gt;
              &lt;span class="na"&gt;topologyKey&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kubernetes.io/hostname&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The mail stack and Nextcloud go further and are pinned to a node with a &lt;code&gt;nodeSelector&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you copy this pattern, know its limit.&lt;/strong&gt; &lt;code&gt;preferred&lt;/code&gt; affinity is a hint, not a guarantee: when the node is under pressure, the scheduler is allowed to place the new pod elsewhere, and then you are back to the Multi-Attach error. &lt;code&gt;required&lt;/code&gt; affinity or a &lt;code&gt;nodeSelector&lt;/code&gt; gives the guarantee, at the price of a pod that stays &lt;code&gt;Pending&lt;/code&gt; if that one node is full. If you have not thought about this trade-off for a particular app, &lt;code&gt;Recreate&lt;/code&gt; is the safe default.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does a DaemonSet really run on every node?
&lt;/h2&gt;

&lt;p&gt;No. &lt;strong&gt;A DaemonSet runs one pod on every node it is allowed onto&lt;/strong&gt;, and the difference is where the interesting decisions are.&lt;/p&gt;

&lt;p&gt;Our cluster has a dedicated database node, tainted &lt;code&gt;dedicated=mariadb:NoSchedule&lt;/code&gt; so that only the database lands there. This is what the six DaemonSets do with it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;DaemonSet&lt;/th&gt;
&lt;th&gt;Pods&lt;/th&gt;
&lt;th&gt;Runs on the DB node?&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;fluent-bit&lt;/code&gt; (logs)&lt;/td&gt;
&lt;td&gt;5/5&lt;/td&gt;
&lt;td&gt;Yes, explicit toleration&lt;/td&gt;
&lt;td&gt;No logs from the DB node is a blind spot&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;loki-canary&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;5/5&lt;/td&gt;
&lt;td&gt;Yes, explicit toleration&lt;/td&gt;
&lt;td&gt;Same reason&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;prometheus-node-exporter&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;5/5&lt;/td&gt;
&lt;td&gt;Yes, tolerates every &lt;code&gt;NoSchedule&lt;/code&gt; taint&lt;/td&gt;
&lt;td&gt;No metrics from the DB node is a blind spot&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hcloud-csi-node&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;5/5&lt;/td&gt;
&lt;td&gt;Yes, tolerates every taint&lt;/td&gt;
&lt;td&gt;Without the CSI node plugin, no volume attaches on that node&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ingress-nginx-controller&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4/5&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;No, by design&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The DB node serves no web traffic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;svclb-ingress-nginx-controller&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4/5&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;No&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Follows the ingress controller&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same cluster, same taint, and the opposite answer is right for different DaemonSets. &lt;strong&gt;The observability agents must reach the tainted node&lt;/strong&gt; — the database node is exactly where you want logs and metrics when something goes wrong. &lt;strong&gt;The ingress controller must not.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The detail that catches people: the DaemonSet controller automatically adds tolerations for the &lt;strong&gt;built-in&lt;/strong&gt; node-condition taints (&lt;code&gt;not-ready&lt;/code&gt;, &lt;code&gt;unreachable&lt;/code&gt;, &lt;code&gt;memory-pressure&lt;/code&gt;, &lt;code&gt;disk-pressure&lt;/code&gt;, &lt;code&gt;pid-pressure&lt;/code&gt;, &lt;code&gt;unschedulable&lt;/code&gt;, and &lt;code&gt;network-unavailable&lt;/code&gt; for host-network pods). It does &lt;strong&gt;not&lt;/strong&gt; add tolerations for your own taints. Add a custom taint to a node, and every DaemonSet without a matching toleration silently stops covering it. There is no error, only a &lt;code&gt;DESIRED&lt;/code&gt; count one lower than your node count. After tainting a node, &lt;code&gt;kubectl get ds -A&lt;/code&gt; is the check.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why run the ingress controller as a DaemonSet?
&lt;/h3&gt;

&lt;p&gt;Because of how traffic reaches the cluster. A Hetzner load balancer forwards incoming traffic to all worker nodes, so every worker node needs an ingress controller to answer it. The chart supports both modes, and ours is set to DaemonSet:&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;controller&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="c1"&gt;# -- Use a `DaemonSet` or `Deployment`&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;DaemonSet&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The old version of this article also claimed that DaemonSets have "no built-in rolling update strategy (until Kubernetes 1.7+)". That is long obsolete: &lt;code&gt;RollingUpdate&lt;/code&gt; is the default in the &lt;code&gt;apps/v1&lt;/code&gt; API, and all six of our DaemonSets use it with &lt;code&gt;maxUnavailable: 1&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  StatefulSets as they actually run in production
&lt;/h2&gt;

&lt;p&gt;The textbook StatefulSet gives each pod a stable ordinal name (&lt;code&gt;db-0&lt;/code&gt;, &lt;code&gt;db-1&lt;/code&gt;, &lt;code&gt;db-2&lt;/code&gt;), a stable DNS entry through a headless Service, and its own PersistentVolumeClaim from a &lt;code&gt;volumeClaimTemplate&lt;/code&gt;, which follows the pod when it is rescheduled. By default it starts pods one at a time in ordinal order (&lt;code&gt;podManagementPolicy: OrderedReady&lt;/code&gt;) and rolls updates in reverse order, with a &lt;code&gt;partition&lt;/code&gt; to stage them.&lt;/p&gt;

&lt;p&gt;Our production database is a &lt;strong&gt;three-node MariaDB Galera cluster&lt;/strong&gt;, and it shows how differently a real one is configured. It is not a hand-written StatefulSet. It is declared as a &lt;code&gt;MariaDB&lt;/code&gt; custom resource, and &lt;strong&gt;mariadb-operator&lt;/strong&gt; generates the StatefulSet from it:&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;k8s.mariadb.com/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;MariaDB&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;3&lt;/span&gt;
  &lt;span class="na"&gt;galera&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;affinity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;podAntiAffinity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;requiredDuringSchedulingIgnoredDuringExecution&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;labelSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;matchExpressions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app.kubernetes.io/instance&lt;/span&gt;
                &lt;span class="na"&gt;operator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;In&lt;/span&gt;
                &lt;span class="na"&gt;values&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;mariadb-galera-new&lt;/span&gt;
          &lt;span class="na"&gt;topologyKey&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kubernetes.io/hostname&lt;/span&gt;
  &lt;span class="na"&gt;podDisruptionBudget&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;minAvailable&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The ordering people choose StatefulSets for is switched off
&lt;/h3&gt;

&lt;p&gt;The generated StatefulSet runs with &lt;strong&gt;&lt;code&gt;podManagementPolicy: Parallel&lt;/code&gt;&lt;/strong&gt; and &lt;strong&gt;&lt;code&gt;updateStrategy: OnDelete&lt;/code&gt;&lt;/strong&gt;. The ordered, one-at-a-time behaviour that most articles list as the main reason to use a StatefulSet is turned off — and the operator does the ordering itself, rolling the &lt;strong&gt;replicas first and the primary last&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The reason is how Galera works. A Galera member that cannot see its peers cannot form a quorum, so "start pod-0, wait until it is Ready, then start pod-1" can stall a full-cluster restart. The operator starts the members together, and applies the order that actually matters for a database — never restart the node taking writes until the others are updated — in its own logic.&lt;/p&gt;

&lt;p&gt;The rest of the configuration is where the operational safety lives:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Required anti-affinity on the hostname&lt;/strong&gt;, so no two members share a node, plus a &lt;code&gt;nodeSelector&lt;/code&gt; and a toleration that admit them to the database nodes only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A PodDisruptionBudget of &lt;code&gt;minAvailable: 2&lt;/code&gt;.&lt;/strong&gt; A three-node Galera needs two nodes for quorum, so a node drain may take down at most one member at a time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;persistentVolumeClaimRetentionPolicy: Retain&lt;/code&gt; for both deletion and scale-down.&lt;/strong&gt; Deleting the StatefulSet or scaling it down never deletes the data. In June this was what made the repair possible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two &lt;code&gt;volumeClaimTemplates&lt;/code&gt; per pod&lt;/strong&gt;: a 20 GiB data volume and a 100 MiB Galera state volume. The 100 MiB claims are bound at &lt;strong&gt;10 GiB&lt;/strong&gt;, because Hetzner's minimum volume size is 10 GB — three times over, for about 300 MB of state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Volumes are tied to a location.&lt;/strong&gt; A Hetzner volume attaches only to a server in the same location. A PVC created in one datacenter cannot follow its pod to another one; moving it means copying the data into a new PVC.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  17 June 2026: what the controllers did during a three-hour outage
&lt;/h2&gt;

&lt;p&gt;The Galera cluster in production at the time was the previous one: a Bitnami chart StatefulSet with the default ordered pod management. This is how a routine maintenance task became a total outage.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A routine worker-node upgrade&lt;/strong&gt; drained a node.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Galera lost its Primary component.&lt;/strong&gt; All three members went non-Primary, and &lt;strong&gt;every WordPress site on the cluster returned "Error establishing a database connection"&lt;/strong&gt;. Contributing factors, as the runbook records them: one member was running in a different datacenter, the pods sat at about &lt;strong&gt;99% of a 5 GiB memory limit&lt;/strong&gt;, and heavy database dumps were running at the same time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The CSI pods could not be scheduled either.&lt;/strong&gt; No CSI, no volume attachments — and from that point everything that needed a persistent volume was down, not just the database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recovery started by bootstrapping node-0&lt;/strong&gt; with an empty cluster address (&lt;code&gt;gcomm://&lt;/code&gt;), so it formed a new Primary component on its own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The StatefulSet's rolling update was stuck.&lt;/strong&gt; Under &lt;code&gt;OrderedReady&lt;/code&gt;, the controller will not roll past a pod that never becomes Ready; the Kubernetes documentation says that in this state you have to delete the broken pods by hand. The fix was to delete the StatefulSet &lt;strong&gt;without deleting its pods or PVCs&lt;/strong&gt; and recreate it:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   kubectl delete statefulset &amp;lt;name&amp;gt; &lt;span class="nt"&gt;--cascade&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;orphan
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Three-node HA came back through a staged rollout with &lt;code&gt;partition=1&lt;/code&gt;.&lt;/strong&gt; Node-0 kept serving untouched, nodes 1 and 2 rejoined through a full state transfer (SST), and node-0 rolled last. The memory limit went from 5 GiB to 7 GiB.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Total downtime: &lt;strong&gt;about three hours. No data was lost.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  What changed afterwards
&lt;/h3&gt;

&lt;p&gt;The Bitnami image in use had been moved to the frozen &lt;code&gt;bitnamilegacy&lt;/code&gt; repository, with no further security updates, so the cluster was moved to mariadb-operator. The first cutover attempt — a full dump and reload under a write freeze — took &lt;strong&gt;about 60 minutes&lt;/strong&gt; on a three-node Galera, which is far too long to freeze writes. It was redone the next day as &lt;strong&gt;asynchronous GTID replication&lt;/strong&gt; from the old cluster to the new one, followed by a write freeze of &lt;strong&gt;seconds&lt;/strong&gt; and a switch of the Service selector.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The database's DNS name never changed.&lt;/strong&gt; The old &lt;code&gt;mariadb-galera&lt;/code&gt; Service now selects the new primary, so not a single tenant configuration file had to be touched. At the same time, the database and several application volumes were moved from the old datacenter to the main one — by copying the data into new PVCs, since a volume cannot move in place.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lessons that apply to any cluster
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A node drain is decided by your PodDisruptionBudgets, taints and tolerations&lt;/strong&gt; — check what each controller will do before upgrading nodes, including the CSI components.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Know what ordered pod management does with a pod that never becomes Ready.&lt;/strong&gt; It waits. The rollout will not repair itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--cascade=orphan&lt;/code&gt; plus a &lt;code&gt;Retain&lt;/code&gt; PVC policy&lt;/strong&gt; lets you rebuild a controller without touching the running pods or the data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the Service name stable.&lt;/strong&gt; The DNS name is the contract with every client of the database; everything behind it can be replaced.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Move data safely:&lt;/strong&gt; never dump inside the database pod (it will run out of memory), never stream large data through the kube-apiserver (a small control-plane node will fall over), copy pod to pod inside the cluster with a bandwidth cap (&lt;code&gt;pv -L 8m&lt;/code&gt;), one database at a time, with &lt;code&gt;set -o pipefail&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Key differences at a glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Deployment&lt;/th&gt;
&lt;th&gt;DaemonSet&lt;/th&gt;
&lt;th&gt;StatefulSet&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Manages&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Interchangeable replicas&lt;/td&gt;
&lt;td&gt;One pod per eligible node&lt;/td&gt;
&lt;td&gt;Replicas with stable identity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Pod names&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Random hash&lt;/td&gt;
&lt;td&gt;Random hash, one per node&lt;/td&gt;
&lt;td&gt;Ordinal: &lt;code&gt;name-0&lt;/code&gt;, &lt;code&gt;name-1&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Placement&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Scheduler decides&lt;/td&gt;
&lt;td&gt;Every node it tolerates&lt;/td&gt;
&lt;td&gt;Scheduler decides; anti-affinity usually required&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Storage&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Any PVC; shared RWO volumes need care&lt;/td&gt;
&lt;td&gt;Usually node-local paths&lt;/td&gt;
&lt;td&gt;Own PVC per pod via &lt;code&gt;volumeClaimTemplates&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Default update&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;RollingUpdate&lt;/code&gt;, 25% surge / 25% unavailable&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;RollingUpdate&lt;/code&gt;, &lt;code&gt;maxUnavailable: 1&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;RollingUpdate&lt;/code&gt; in reverse ordinal order, with &lt;code&gt;partition&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Ordering&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;OrderedReady&lt;/code&gt; by default; clustered DB operators often use &lt;code&gt;Parallel&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Typical failure&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Multi-Attach error on a single-replica RWO rollout&lt;/td&gt;
&lt;td&gt;Silently skips nodes with custom taints&lt;/td&gt;
&lt;td&gt;Rollout stuck on a pod that never becomes Ready&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Use for&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Web apps, APIs, workers&lt;/td&gt;
&lt;td&gt;Logs, metrics, CSI, ingress on every node&lt;/td&gt;
&lt;td&gt;Databases, brokers, quorum-based systems&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Final thoughts
&lt;/h2&gt;

&lt;p&gt;Choosing the controller takes thirty seconds with the decision tree above. The work is in the behaviour around it: the update strategy of a Deployment with a volume, the tolerations of every DaemonSet, and the pod management, disruption budget and retention policy of every StatefulSet. On our cluster, each of those was either a cause of the June outage or part of its repair.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Keeping the resulting cluster efficient is the other half: our production K3s audit — &lt;a href="https://www.professional-it-services.com/how-we-cut-kubernetes-resource-overhead-by-50-using-only-built-in-tools/" rel="noopener noreferrer"&gt;how we cut Kubernetes resource overhead by 50% using only built-in tools&lt;/a&gt; — shows what over-requested StatefulSets and cluster-wide DaemonSets actually cost once they are measured.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>devops</category>
      <category>k3s</category>
      <category>database</category>
    </item>
  </channel>
</rss>
