<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[KIM KYUNGHWAN]]></title><description><![CDATA[KIM KYUNGHWAN]]></description><link>https://kkhdevs.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Fri, 13 Nov 2026 18:28:21 GMT</lastBuildDate><atom:link href="https://kkhdevs.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Pods stuck Pending after node failure: why didn't Kubernetes reschedule them to the other node?]]></title><description><![CDATA[Pods stuck Pending after node failure: a required NotIn node affinity and two topology spread defaults on a 2-node k3s cluster. How to check yours.]]></description><link>https://kkhdevs.hashnode.dev/pods-stuck-pending-after-node-failure-why-didn-t-kubernetes-reschedule-them-to-the-other-node</link><guid isPermaLink="true">https://kkhdevs.hashnode.dev/pods-stuck-pending-after-node-failure-why-didn-t-kubernetes-reschedule-them-to-the-other-node</guid><category><![CDATA[Kubernetes]]></category><category><![CDATA[scheduling]]></category><category><![CDATA[SRE]]></category><category><![CDATA[Devops]]></category><category><![CDATA[postmortem]]></category><dc:creator><![CDATA[KIM KYUNGHWAN]]></dc:creator><pubDate>Fri, 13 Nov 2026 04:12:37 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6af44048178e74800edef134/b8817181-7ccb-489c-81a0-a58eb18b0e40.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><img src="https://benchlet.com/guides/pods-pending-after-node-failure-node-affinity-topology-spread-k3s/cover.png" alt="Scheduler message " /></p>
<p>If your pods are not rescheduled when a node goes down and stay stuck Pending after node failure, run <code>kubectl describe pod</code> and read the <code>FailedScheduling</code> event. After a drain it reads <code>0/2 nodes are available: 1 node(s) didn't match Pod's node affinity/selector, 1 node(s) were unschedulable.</code>; after a node dies, the second item is <code>1 node(s) had untolerated taint {node.kubernetes.io/unreachable: }</code> instead (<code>1 node(s) had untolerated taint(s)</code> on Kubernetes 1.35 and later). Either way, the pod has been recreated, and no node satisfies its own placement rules. On our cluster 15 stateless app Deployments carried a required node affinity, <code>role NotIn [observability]</code>, and the <code>role=observability</code> label is on the only other node, so each of them could run on exactly one node. Nine of them also had topology spread rules that kept two defaults: <code>nodeTaintsPolicy</code> (Ignore), which can leave the second replica Pending with <code>didn't match pod topology spread constraints</code> once a node is gone, and <code>matchLabelKeys</code> (empty), which during an ordinary rolling update moved seven single-replica apps to the other node. We changed the rule from <code>requiredDuringSchedulingIgnoredDuringExecution</code> to <code>preferredDuringSchedulingIgnoredDuringExecution</code> with weight 100 and added <code>nodeTaintsPolicy: Honor</code> and <code>matchLabelKeys: [pod-template-hash]</code> to the spread rules; below are the scheduler output, the Kubernetes documents that explain it, read-only checks with the value that means you have the same problem, and the drain test that confirmed the fix. The cluster is a 2-node k3s cluster (k3s v1.33.5+k3s1, one server and one agent); all times are UTC, 2026-11-13 unless a date is given.</p>
<h2>What we saw: <code>0/2 nodes are available: 1 node(s) didn't match Pod's node affinity/selector</code></h2>
<p>The two nodes, both Ready, no taints:</p>
<table>
<thead>
<tr>
<th>Node</th>
<th>k3s role</th>
<th>CPU</th>
<th>Memory</th>
<th>GPUs</th>
<th>Labels</th>
</tr>
</thead>
<tbody><tr>
<td><code>server-1</code></td>
<td>the only server: control plane, embedded etcd</td>
<td>12</td>
<td>32207388Ki</td>
<td>0</td>
<td><code>role=observability</code>, <code>storage=server-1</code></td>
</tr>
<tr>
<td><code>agent-1</code></td>
<td>agent</td>
<td>6</td>
<td>32802564Ki</td>
<td>4 <code>nvidia.com/gpu</code></td>
<td>none of these</td>
</tr>
</tbody></table>
<p>The finding came from a full run of kubegap, an open-source audit of Kubernetes cluster operations, on 2026-11-11 at 05:08:33. Its problem 5, translated from Korean: "The two nodes cannot stand in for each other. The apps are tied to agent-1, and the databases, the registry and the observability stack to server-1, by labels and local-path volumes. server-1 is the only k3s server and etcd, so losing server-1 also stops the API server." It counted 18 workloads that could run only on agent-1 and 24 only on server-1, CoreDNS with 1 replica on server-1 and no PodDisruptionBudget, and both ingress-nginx replicas on server-1.</p>
<p>On 2026-11-13 at 02:25 we listed the node rules of every Deployment and StatefulSet. Fifteen running app Deployments, in four production namespaces, had the same required rule (excerpt, names masked):</p>
<pre><code>$ kubectl get deploy,sts -A -o json | jq …   # a variant of check 1's jq program that also prints replicas and PVC claims; check 1 prints the rule as nodeSelector=… required=…
Deployment   prod-f/signal-f     1  role NotIn observability
Deployment   prod-f/web-f        1  role NotIn observability
Deployment   prod-b/api-b        2  role NotIn observability
Deployment   prod-b/admin-b      1  role NotIn observability
...
Deployment   prod-d/api-d        2  role NotIn observability
Deployment   prod-e/web-e        1  role NotIn observability
</code></pre>
<p>The same rule written as a label selector:</p>
<pre><code>$ kubectl get nodes -l 'role notin (observability)'
NAME      STATUS   ROLES    AGE    VERSION
agent-1   Ready    &lt;none&gt;   347d   v1.33.5+k3s1
</code></pre>
<p>One node. Nine of the 15 Deployments also had this spread rule, with no <code>nodeTaintsPolicy</code> and no <code>matchLabelKeys</code>:</p>
<pre><code class="language-yaml">topologySpreadConstraints:
  - maxSkew: 1
    topologyKey: kubernetes.io/hostname
    whenUnsatisfiable: DoNotSchedule
    labelSelector:
      matchLabels:
        app: api-d
</code></pre>
<p>Three of them ran 2 replicas at 02:27, and all six of those pods were on <code>agent-1</code>: <code>api-b</code>, <code>api-d</code> and an admin app that its HorizontalPodAutoscaler later scaled to 1. At 02:35 there were 38 running pods (DaemonSets excluded) on <code>agent-1</code> and 59 on <code>server-1</code>.</p>
<p>We fixed the rules first (next section) and then drained <code>agent-1</code> at 02:51:48 to test the fix. So the scheduler never wrote the message at the top of this page for the 15 app Deployments. The scheduler wrote it during the drain for pods that are tied to <code>agent-1</code> on purpose: a TURN server on hostNetwork, a GPU embedding service and a vector database, each with a <code>kubernetes.io/hostname</code> selector. With the old rule the scheduler would have given the 15 apps' pods the same <code>didn't match Pod's node affinity/selector</code> reason for <code>server-1</code>, because the rule matches only <code>agent-1</code> (inference from the rule and the selector output above; we did not run the drain before the fix).</p>
<p><img src="https://benchlet.com/guides/pods-pending-after-node-failure-node-affinity-topology-spread-k3s/term-pending.png" alt="Terminal output of kubectl get pods with status.phase=Pending and the PodScheduled message for eight masked pods: four " /></p>
<p><em>During the drain at 02:54 UTC: the eight pods that had nowhere else to go, each with the scheduler's reason. No other pod was Pending.</em></p>
<p>At 02:54:12 these eight were the only Pending pods: four pods with local-path volumes on <code>agent-1</code> (<code>didn't match PersistentVolume's node affinity</code>), the three pods above with hostname selectors, and the second GPU workload (<code>Insufficient nvidia.com/gpu</code>; <code>server-1</code> has no GPU). <code>server-1</code> was running 89 non-DaemonSet pods and <code>agent-1</code> none. One of the eight lines, unmasked except for the pod name:</p>
<pre><code>0/2 nodes are available: 1 node(s) didn't match Pod's node affinity/selector, 1 node(s) were unschedulable. preemption: 0/2 nodes are available: 2 Preemption is not helpful for scheduling.
</code></pre>
<p><img src="https://benchlet.com/guides/pods-pending-after-node-failure-node-affinity-topology-spread-k3s/prom-pods-per-node.png" alt="Prometheus graph, 02:35 to 03:10 UTC, of running non-DaemonSet pods per node: agent-1 falls from about 36 to 0 at 02:52 and climbs back after 02:55; server-1 rises to 89 and comes back down." /></p>
<p><em>Prometheus, running pods per node (DaemonSets excluded), 02:35–03:10 UTC. agent-1 drops to 0 at the drain and refills after uncordon and the rollout restarts.</em></p>
<h2>Why</h2>
<h3>A required <code>NotIn</code> rule is satisfied every day and fails on the day the node is lost</h3>
<p>The Kubernetes documentation for node affinity:</p>
<ul>
<li><code>requiredDuringSchedulingIgnoredDuringExecution</code>: "The scheduler can't schedule the Pod unless the rule is met."</li>
<li><code>preferredDuringSchedulingIgnoredDuringExecution</code>: "If a matching node is not available, the scheduler still schedules the Pod." Each preferred term has a weight from 1 to 100.</li>
<li>"NotIn and DoesNotExist allow you to define node anti-affinity behavior."</li>
</ul>
<p><code>role NotIn [observability]</code> reads as "anywhere except the observability node". With two nodes, it means "only <code>agent-1</code>". <code>IgnoredDuringExecution</code> means running pods are never checked again, so the rule does nothing while <code>agent-1</code> is up. It is evaluated only when a pod is scheduled, so it fails only when a pod has to be scheduled while <code>agent-1</code> cannot take it: when <code>agent-1</code> has failed, is drained or cordoned, or has no room left.</p>
<p>The scheduler runs its filters in a fixed order (NodeUnschedulable, NodeName, TaintToleration, NodeAffinity, …, PodTopologySpread, from <code>default_plugins.go</code> in the Kubernetes source), and a node is rejected by the first filter that fails. A drained node therefore reports <code>were unschedulable</code>. On the surviving node only the node affinity failure is reported; a topology spread failure on the same node is never evaluated, so it does not appear in the message.</p>
<p>For a node that dies instead of being drained, the second item is a taint, not <code>were unschedulable</code>. Assembled from the Kubernetes source, not observed by us: up to Kubernetes 1.34 the line reads <code>1 node(s) had untolerated taint {node.kubernetes.io/unreachable: }</code>, and from 1.35 it reads <code>1 node(s) had untolerated taint(s)</code> without the key.</p>
<h3><code>nodeTaintsPolicy</code> defaults to Ignore</h3>
<p>The Kubernetes documentation for topology spread: "If this value is null, the behavior is equivalent to the Ignore policy." With Honor, "nodes without taints, along with tainted nodes for which the incoming pod has a toleration, are included." The field is GA in 1.33. The Kubernetes blog describes the failure: "This may lead to a node with untolerated taint as the only candidate for spreading, and as a result, the pod will stuck in Pending if it doesn't tolerate the taint." and "For backwards compatibility, <code>nodeTaintsPolicy</code> defaults to <code>Ignore</code>."</p>
<p>What this means on two nodes once the node rule is preferred, as we reasoned it before the test (not reproduced; we added Honor before draining): with Ignore, an unreachable or cordoned <code>agent-1</code> still counts as a spreading domain, and it holds 0 matching pods, because the spread count skips pods that are terminating (<code>countPodsMatchSelector</code> in the Kubernetes source). The first replacement replica goes to <code>server-1</code>. The second would make the skew 2 − 0 = 2, more than <code>maxSkew: 1</code>, so with <code>DoNotSchedule</code> it stays Pending with <code>didn't match pod topology spread constraints</code>. A 2-replica Deployment would run at half its replicas for as long as <code>agent-1</code> is gone.</p>
<h3>Without <code>matchLabelKeys</code>, every rolling update counts the old pod</h3>
<p>This part we observed. Our first fix changed the node rule to preferred and added Honor. Argo CD had applied it by 02:39:23, which started a rolling update of all 15 Deployments. The new pod of seven single-replica apps, and one new replica each of <code>api-b</code> and <code>api-d</code>, went to <code>server-1</code>, although the new rule prefers <code>agent-1</code> with weight 100.</p>
<p>The reason (inference from the spread rule and the documentation below): the <code>labelSelector</code> matches pods of every revision, so during the update the old pod on <code>agent-1</code> is still counted. A new pod on <code>agent-1</code> would make the skew 2 − 0 = 2; on <code>server-1</code> it makes 1 − 1 = 0. <code>DoNotSchedule</code> is a filter and the preferred node affinity is only a score, so <code>agent-1</code> is removed before scores are compared and the weight of 100 never applies to it.</p>
<p><img src="https://benchlet.com/guides/pods-pending-after-node-failure-node-affinity-topology-spread-k3s/term-rollout.png" alt="Terminal table of pods after the first rollout with NODE and IMAGE PULL ON server-1 columns: seven single-replica pods in red on server-1 with pull times from 2m25.179s to 5m49.087s and sizes from 55 MB to 844 MB, and one replica each of api-b and api-d on server-1 with pulls of 6m13.859s and 4m0.693s." /></p>
<p><em>After the first fix, an ordinary rollout put seven single-replica apps on server-1. Their images had never been there: 2 min 25 s to 5 min 49 s to pull.</em></p>
<p>Those images had never been pulled on <code>server-1</code>. There were nine pulls on <code>server-1</code> from the in-cluster registry between 02:41:44 and 02:45:35, from 2m25.179s (843,669,709 bytes) to 6m13.859s (835,296,653 bytes); a 94,655,960-byte image took 5m49.087s.</p>
<p>The Kubernetes documentation for <code>matchLabelKeys</code>: with <code>pod-template-hash</code>, "which is added automatically by the Deployment controller", the rule can "distinguish between different revisions in a single Deployment". The field is beta and enabled by default since 1.27. With <code>matchLabelKeys: [pod-template-hash]</code> the old revision's pod is not counted, so a single-replica app's new pod can go back to the preferred node.</p>
<h2>What to check in your cluster now</h2>
<p>Checks 1 to 4 only read. They need <code>kubectl</code> and <code>jq</code>.</p>
<p><strong>1. Workloads whose required rules leave exactly one node.</strong> A required rule (<code>nodeSelector</code> or <code>requiredDuringSchedulingIgnoredDuringExecution</code>) is checked against labels that exist today. List every required node rule with the first command, then rewrite each one as a selector for <code>kubectl get nodes -l</code> (<code>key=value</code> for <code>nodeSelector</code>, <code>key in (a,b)</code> for <code>In</code>, <code>key notin (a)</code> for <code>NotIn</code>, <code>key</code> for <code>Exists</code>, <code>!key</code> for <code>DoesNotExist</code>, a comma for <code>AND</code>, one command per <code>OR</code> term) and count the nodes it prints:</p>
<pre><code class="language-bash">kubectl get deploy,sts -A -o json | jq -r '.items[] | .spec.template.spec as $s
  | select($s.nodeSelector or $s.affinity.nodeAffinity.requiredDuringSchedulingIgnoredDuringExecution)
  | "\(.kind) \(.metadata.namespace)/\(.metadata.name) nodeSelector=\($s.nodeSelector // {} | tojson) required=\($s.affinity.nodeAffinity.requiredDuringSchedulingIgnoredDuringExecution.nodeSelectorTerms // [] | map(.matchExpressions // [] | map("\(.key) \(.operator) \(.values // [] | join(","))") | join(" AND ")) | join(" OR "))"'
kubectl get nodes -l 'role notin (observability)'   # our rule; use yours
</code></pre>
<p>For our old rule, <code>kubectl get nodes -l</code> printed one node. Same problem if a stateless Deployment (no local volume, no GPU, no hostNetwork) matches one node: that Deployment cannot start anywhere while that node is down. Workloads pinned on purpose (a database on a local volume, a GPU job) show up here too; for them the answer is a restore, not a reschedule. After our fix, the 27 workloads that still match one node are databases, the registry, observability, a GPU job and a TURN server.</p>
<p><strong>2. Spread rules that block the second replica, or push single replicas off their node.</strong></p>
<pre><code class="language-bash">kubectl get deploy -A -o json | jq -r '
  .items[] | (.metadata.namespace+"/"+.metadata.name) as $n | .spec.replicas as $r |
  .spec.template.spec.topologySpreadConstraints[]? | select(.whenUnsatisfiable=="DoNotSchedule") |
  "\($n) replicas=\($r) key=\(.topologyKey) nodeTaintsPolicy=\(.nodeTaintsPolicy // "Ignore(default)") matchLabelKeys=\((.matchLabelKeys // [])|join(","))"'
</code></pre>
<p>Same problem if:</p>
<ul>
<li><code>key=kubernetes.io/hostname</code>, <code>nodeTaintsPolicy=Ignore(default)</code> and <code>replicas</code> 2 or more: when one node is cordoned or unreachable it still counts as a place with 0 replicas, so with <code>maxSkew: 1</code> only one replica can start on the remaining node.</li>
<li>the same rule with an empty <code>matchLabelKeys</code> on a Deployment that should stay on one preferred node: during every rolling update the old pod is still counted, and the new pod is forced onto the other node.</li>
</ul>
<p>After our fix all 9 app rules print <code>nodeTaintsPolicy=Honor matchLabelKeys=pod-template-hash</code>. The k3s packaged CoreDNS Deployment still prints a hostname <code>DoNotSchedule</code> rule with <code>Ignore(default)</code>. With 1 replica it does not meet the first condition, and we left it as it is (see "Not changed, and why").</p>
<p><strong>3. Volumes that tie pods to one node.</strong></p>
<pre><code class="language-bash">kubectl get pv -o json | jq -r '.items[] | select(.spec.nodeAffinity.required) |
  "\(.spec.claimRef.namespace)/\(.spec.claimRef.name) \(.spec.storageClassName) \([.spec.nodeAffinity.required.nodeSelectorTerms[].matchExpressions[].values[]]|join(","))"'
</code></pre>
<p>Same problem if a production database's PV is listed with one node. Its pod will report <code>didn't match PersistentVolume's node affinity</code> on every other node. Moving it needs a restore from a copy kept elsewhere, or replicated storage. Our local-path PVs: 24 on <code>server-1</code>, 14 on <code>agent-1</code>.</p>
<p><strong>4. Is there room for the pods that would move?</strong></p>
<pre><code class="language-bash">kubectl describe nodes | grep -E '^Name:|^Allocatable:|^Allocated resources:|^  (cpu|memory)[: ]'
</code></pre>
<p>Add the <code>Allocated resources</code> requests of the node you might lose to those of the other node, and compare the sum with the other node's <code>Allocatable</code>. This overstates the load that would move, because it includes DaemonSet pods and pods that cannot move; subtract them if the sum comes out close. Ours: the two <code>Allocated resources</code> lines add up to 3,340m + 5,140m = 8,480m CPU, under the 12,000m allocatable, and 15,314 + 17,366 = 32,680 MiB memory, over the 31,452 MiB allocatable. After we subtracted DaemonSet pods and the eight pinned pods, 1,550m CPU and 8,896 MiB would move from <code>agent-1</code>, and <code>server-1</code> had 6,860m CPU and 14,086 MiB unrequested. Same problem if, after the subtraction, the requests that would move are larger than the other node's <code>Allocatable</code> minus its own requests: some pods will report <code>Insufficient cpu</code> or <code>Insufficient memory</code>.</p>
<p><strong>5. Test it with a drain.</strong> This one changes the cluster.</p>
<pre><code class="language-bash">kubectl drain &lt;node&gt; --ignore-daemonsets --delete-emptydir-data --timeout=300s
kubectl get pods -A --field-selector status.phase=Pending \
  -o custom-columns='NAMESPACE:.metadata.namespace,POD:.metadata.name,WHY:.status.conditions[0].message'
kubectl uncordon &lt;node&gt;
kubectl rollout restart deployment/&lt;name&gt; -n &lt;namespace&gt;   # pods do not move back on their own
</code></pre>
<p>Before draining, let running Jobs finish and look for pods without an owner:</p>
<pre><code class="language-bash">kubectl get pods -A -o json | jq -r '.items[]|select(.metadata.ownerReferences==null)|.metadata.namespace+"/"+.metadata.name'
</code></pre>
<p>Drain deletes finished pods, and an ownerless one is gone for good. Exclude it with <code>--pod-selector</code>.</p>
<p>Pass: every Deployment that is not pinned on purpose is Available on the other node, and each Pending pod has a reason you expected (<code>didn't match PersistentVolume's node affinity</code>, <code>Insufficient nvidia.com/gpu</code>, a hostname selector). Fail: a stateless app Pending with <code>didn't match Pod's node affinity/selector</code> or <code>didn't match pod topology spread constraints</code>.</p>
<p>A drain evicts at once. A node that really dies is first marked unreachable, and Kubernetes adds a toleration "with tolerationSeconds=300" to every pod, so pods "remain bound to Nodes for 5 minutes after one of these problems is detected". Add at least five minutes to what the drain shows: the count starts when the node controller marks the node unreachable, not when the node fails.</p>
<p>If you do not want to repeat checks 1 and 3 by hand after every change, <a href="https://github.com/KyungHwanKim-devs/kubegap" target="_blank" rel="noopener">kubegap</a> runs them: it reports each workload that can run on only one node, pods tied to one node by a local volume, hostNetwork or a GPU, replicas of one Deployment on the same node, and workloads that depend on a node label. It runs check 4 only for losing the largest node, which for us is <code>server-1</code>, so check 4 for losing <code>agent-1</code> stays manual. Its report on our cluster had no finding for the spread defaults of check 2, so that one stays manual. Its run on 2026-11-11 is where this problem was found.</p>
<h2>What we changed, and what we have not</h2>
<h3>Changed</h3>
<p><strong>Commit 1, pushed 02:38:33.</strong> In 16 Deployment manifests (15 deployed, one not deployed), <code>requiredDuringSchedulingIgnoredDuringExecution</code> became <code>preferredDuringSchedulingIgnoredDuringExecution</code> with weight 100 and the same <code>NotIn</code> expression, and <code>nodeTaintsPolicy: Honor</code> was added to the 10 spread rules. A server-side dry-run passed for all 16. Argo CD applied it by 02:39:23.</p>
<p><strong>Commit 2, pushed 02:46:38.</strong> <code>matchLabelKeys: [pod-template-hash]</code> on the 10 spread rules. After its rollout settled at 02:50:25, every single-replica app was back on <code>agent-1</code>, and <code>api-b</code> and <code>api-d</code> had one replica on each node.</p>
<p>One Deployment after both commits:</p>
<pre><code class="language-yaml">      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: kubernetes.io/hostname
          whenUnsatisfiable: DoNotSchedule
          nodeTaintsPolicy: Honor
          matchLabelKeys:
            - pod-template-hash
          labelSelector:
            matchLabels:
              app: api-d
      affinity:
        nodeAffinity:
          preferredDuringSchedulingIgnoredDuringExecution:
            - weight: 100
              preference:
                matchExpressions:
                  - key: role
                    operator: NotIn
                    values:
                      - observability
</code></pre>
<p>We chose preferred with weight 100 rather than removing the rule, so that each app keeps its usual node every day and can still start on the other node when its usual node is gone.</p>
<p><strong>The drain test.</strong> A backup Job running on <code>agent-1</code> was allowed to finish first (02:50:47). A probe of 20 public addresses started at 02:50:47, one round about every 5 s. Then:</p>
<pre><code>$ kubectl drain agent-1 --ignore-daemonsets --delete-emptydir-data --pod-selector=app!=&lt;the ownerless pod's app label&gt; --timeout=300s
node/agent-1 cordoned
Warning: ignoring DaemonSet-managed Pods: ...
evicting pod ...
...
node/agent-1 drained
# exit 0 at 2026-11-13T02:52:55Z
</code></pre>
<p>The drain started at 02:51:48 and printed 83 eviction lines. The selector excluded an ownerless pod that had been Failed since May, because drain would have deleted it and nothing would have recreated it.</p>
<ul>
<li>At 02:52:55 two of the 15 app Deployments were not fully available, <code>admin-b</code> (0 of 1) and <code>api-d</code> (1 of 2); from 02:53:12 to 02:53:51 only <code>api-d</code> was still short, and at 02:54:10 all 15 were fully available. That is 2 min 22 s after the drain started.</li>
<li>The eight Pending pods were the ones shown in the first section, each with an expected reason.</li>
<li>24 containers on <code>server-1</code> started from images already there and 14 images were pulled, taking from 1.349 s to 2m9.6s. Eight of the nine images pulled during the first rollout were still on <code>server-1</code>; for the ninth, one pod reported it already present at 02:52:00 and another reported a 1.349 s pull at 02:52:02, which we did not look into.</li>
</ul>
<p><img src="https://benchlet.com/guides/pods-pending-after-node-failure-node-affinity-topology-spread-k3s/probe.png" alt="Grid of probe results for all 20 public addresses, one row each and one cell per 5-second round from 02:50 to 02:58 UTC: api-b and api-d stay green through the drain; single-replica apps show 1 to 15 red cells after 02:51:48; the API on a local volume on agent-1 and the search API stay red until after uncordon at 02:54:29." /></p>
<p><em>All 20 public addresses during the drain and the restore. The two apps with one replica per node never failed while agent-1 was drained; single-replica apps were down for about 5 to 85 seconds; the two tied to agent-1 stayed down until after uncordon.</em></p>
<p>From the probe (an outage window is the first to the last failing round, plus one interval):</p>
<ul>
<li>The two 2-replica APIs never left their normal response code during the drain.</li>
<li>Single-replica apps were off baseline for about 5 to 85 s: 1 round for the search web UI, 4 rounds from 02:51:54 to 02:52:19 for one site, 11 rounds from 02:52:04 to 02:53:02 for one admin app, 15 rounds to 02:53:24 for an internal API that has no node rule.</li>
<li>A search API returned 503 from 02:52:04 to 02:54:24, because its embedding service needs the GPU on <code>agent-1</code>.</li>
<li>An API whose Postgres is on a local volume on <code>agent-1</code> returned 502 and 503 from 02:51:48 to 02:54:51.</li>
</ul>
<p><strong>Restore.</strong> <code>kubectl uncordon agent-1</code> at 02:54:29; the pinned pods were Ready and nothing was Pending at 02:54:57. Pods do not move back by themselves, so we ran <code>kubectl rollout restart</code> on the 15 app Deployments at 02:54:57 (settled 02:58:50, single-replica apps back on <code>agent-1</code>) and, at 02:59:19, on 11 Deployments without node rules that the drain had moved to <code>server-1</code>. At 03:04:37 there were 37 running pods on <code>agent-1</code> and 63 on <code>server-1</code>. During the restarts one probe round each returned no answer for two addresses (02:55:18 and 02:56:00), and the search API was off baseline until 03:04:33 while it restarted. All 20 addresses were at their normal code from 03:04:43.</p>
<p><strong>kubegap again.</strong> A full rerun at 03:04:58: of the 60 findings the 2026-11-11 report listed under problem 5, 19 are gone (the 15 one-eligible-node findings of the app Deployments and 4 replicas-on-one-node findings) and 41 remain.</p>
<h3>Not changed, and why</h3>
<p>These are our decisions for this cluster, with the reasons below. They are not advice for your cluster.</p>
<ul>
<li><strong>The control plane stays one k3s server on <code>server-1</code>.</strong> The k3s documentation: "HA embedded etcd cluster must be comprised of an odd number of server nodes for etcd to maintain quorum", and HA needs "Three or more server nodes". Two machines cannot form an etcd quorum that survives one failure, and we will not add a third machine: the cluster runs a few personal projects and small contract work. Losing <code>server-1</code> is handled as a restore, from the etcd snapshots, server token and database dumps copied to <code>agent-1</code> twice a day or daily; a restore from those copies has been tested. kubegap's rerun also shows that without <code>server-1</code> the CPU requests would not fit: 6.0 cores allocatable against 7.61 requested.</li>
<li><strong>Ingress controller replicas and CoreDNS were not spread.</strong> Both run only on <code>server-1</code> (ingress-nginx: 2 replicas, MetalLB L2 address, <code>externalTrafficPolicy: Local</code>, PodDisruptionBudget minAvailable 1; CoreDNS: 1 replica, no PodDisruptionBudget). Both matter only when <code>server-1</code> is lost, which is the restore case above. CoreDNS is a k3s packaged component; the k3s documentation says those manifests "should not be altered. The files are re-written to disk whenever K3s is started", so changing it would also need a <code>.skip</code> file on the server so that k3s stops managing it.</li>
<li><strong>Pods tied to <code>agent-1</code> by local-path volumes, hostNetwork or GPUs stay tied.</strong> Moving them needs replicated storage, a network change or another GPU.</li>
</ul>
<h2>Where else this happened</h2>
<p>The Incident Library, a searchable collection of public post-incident reports, has no case with this exact cause (a required node affinity or a topology spread default leaving replacement pods Pending after a node loss). These three show a cluster that could not survive losing nodes: replicas packed onto one node, a control plane node that could not be replaced, and all workers NotReady at once.</p>
<ul>
<li>Moonlight, 2019-01-18: on a three-node GKE cluster the scheduler sometimes put all three replicas of a web app on one node; CPU-heavy pods drove that node into kernel panics, and the crash moved from node to node until the site was down. Anti-affinity rules on all major Deployments restored it.</li>
<li>Magic Pages, 2025-04-14: one of three control plane nodes of a hetzner-k3s cluster ran out of memory, and the cluster's own Hetzner API calls kept the rate limit at zero, so the node could not be replaced and every customer site went down.</li>
<li>Saltside, 2017-04-26: parallel Helm installs saturated small gp2 root volumes and left all three workers of a kops cluster NotReady.</li>
</ul>
<p>The three cases above are part of a set of six in which a NotReady node was a single point of failure: <a href="https://benchlet.com/incidentlib/app?symptom=node-not-ready&amp;factor=single-point-of-failure" target="_blank" rel="noopener">Node NotReady + single point of failure, 6 cases</a>. Two of the cases under <a href="https://benchlet.com/incidentlib/app?component=kube-apiserver&amp;factor=single-point-of-failure" target="_blank" rel="noopener">Kubernetes API server + single point of failure, 7 cases</a> are clusters that went down with their only control plane node, as ours would if <code>server-1</code> were lost: a homelab whose only master node kernel-panicked after a power outage (2025-03-03), and NRE Labs, whose single-master kubeadm cluster kept losing its API server (2018-12-04).</p>
<h2>Timeline</h2>
<table>
<thead>
<tr>
<th>Time (UTC)</th>
<th>Event</th>
</tr>
</thead>
<tbody><tr>
<td>2026-11-11 05:08:33</td>
<td>kubegap full run reports problem 5: the two nodes cannot stand in for each other.</td>
</tr>
<tr>
<td>2026-11-13 02:25–02:28</td>
<td>State recorded: 15 app Deployments with <code>role NotIn [observability]</code>, which matches only <code>agent-1</code>.</td>
</tr>
<tr>
<td>02:38:33</td>
<td>Commit 1 pushed: required → preferred weight 100, <code>nodeTaintsPolicy: Honor</code>.</td>
</tr>
<tr>
<td>02:39:23</td>
<td>Argo CD has applied commit 1 to the 15 Deployments; rolling updates start.</td>
</tr>
<tr>
<td>02:41:44–02:45:35</td>
<td>Nine image pulls on <code>server-1</code> (2m25s to 6m13s) for the pods the rollout moved there.</td>
</tr>
<tr>
<td>02:46:38</td>
<td>Commit 2 pushed: <code>matchLabelKeys: [pod-template-hash]</code>.</td>
</tr>
<tr>
<td>02:50:25</td>
<td>Commit 2 rollout settled; single-replica apps back on <code>agent-1</code>.</td>
</tr>
<tr>
<td>02:50:47</td>
<td>Backup Job on <code>agent-1</code> complete; public probe starts.</td>
</tr>
<tr>
<td>02:51:48</td>
<td><code>kubectl drain agent-1</code> starts.</td>
</tr>
<tr>
<td>02:52:55</td>
<td><code>node/agent-1 drained</code>; 2 app Deployments not fully available.</td>
</tr>
<tr>
<td>02:54:10</td>
<td>All 15 app Deployments fully available on <code>server-1</code>.</td>
</tr>
<tr>
<td>02:54:12</td>
<td>Eight pinned pods Pending with expected reasons; 89 pods on <code>server-1</code>, 0 on <code>agent-1</code>.</td>
</tr>
<tr>
<td>02:54:29</td>
<td><code>kubectl uncordon agent-1</code>.</td>
</tr>
<tr>
<td>02:54:57</td>
<td>Pinned pods Ready, nothing Pending; rollout restart of the 15 app Deployments.</td>
</tr>
<tr>
<td>02:58:50</td>
<td>App rollout settled.</td>
</tr>
<tr>
<td>02:59:19</td>
<td>Rollout restart of 11 Deployments without node rules.</td>
</tr>
<tr>
<td>03:04:37</td>
<td>37 pods on <code>agent-1</code>, 63 on <code>server-1</code>.</td>
</tr>
<tr>
<td>03:04:43</td>
<td>All 20 public addresses back at their normal code.</td>
</tr>
<tr>
<td>03:04:58</td>
<td>kubegap rerun: 19 of 60 problem-5 findings gone, 41 remain.</td>
</tr>
</tbody></table>
<h2>FAQ</h2>
<h3>pods not rescheduled when node goes down</h3>
<p>Pods are evicted only after the node is marked unreachable and the 300-second toleration has run out. If the replacement pods are then stuck Pending after the node failure, read the <code>FailedScheduling</code> event, which starts <code>0/2 nodes are available:</code> on a two-node cluster: the dead node shows <code>had untolerated taint {node.kubernetes.io/unreachable: }</code> (<code>had untolerated taint(s)</code> on 1.35 and later), and <code>didn't match Pod's node affinity/selector</code> on the surviving node means a required node rule excludes it. Count the nodes it matches with <code>kubectl get nodes -l</code>.</p>
<h3>required vs preferred node affinity</h3>
<p><code>requiredDuringSchedulingIgnoredDuringExecution</code> is a filter: no matching node, no scheduling. <code>preferredDuringSchedulingIgnoredDuringExecution</code> is a score with weight 1 to 100, added to matching nodes that passed every filter: the pod usually goes to a matching node and goes elsewhere when none is available, but a filter such as a <code>DoNotSchedule</code> spread rule can still remove the matching node. We use preferred with weight 100 so apps keep their usual node daily but can move when it fails.</p>
<h3>didn't match pod topology spread constraints</h3>
<p>With <code>whenUnsatisfiable: DoNotSchedule</code> and <code>maxSkew: 1</code>, a node that is cordoned or unreachable still counts as a domain with zero pods while <code>nodeTaintsPolicy</code> is unset (Ignore), so the second replica cannot join the first on the surviving node; the Kubernetes blog describes pods stuck Pending this way. Set <code>nodeTaintsPolicy: Honor</code>. For rolling updates, add <code>matchLabelKeys: [pod-template-hash]</code> so old pods are not counted.</p>
<h3>k3s two node high availability</h3>
<p>Not for the control plane. k3s embedded etcd needs an odd number of servers, and HA needs three or more; two servers cannot keep quorum when one fails. With two machines, Deployments can move between nodes after fixing their rules, but the single server is recovered from etcd snapshots and dumps kept on the other node.</p>
<h3>test node failure with kubectl drain</h3>
<p>Let running Jobs finish, and use <code>--pod-selector</code> to exclude finished pods that have no owner, because drain deletes finished pods and nothing recreates an ownerless one. Then run <code>kubectl drain &lt;node&gt; --ignore-daemonsets --delete-emptydir-data</code> and list Pending pods with their scheduling message. Pass if every stateless Deployment is Available elsewhere and each Pending pod has an expected reason. A drain evicts at once; after a real failure, eviction comes at least 300 seconds later, counted from when the node is marked unreachable. Uncordon, then <code>rollout restart</code>, because pods do not move back.</p>
<hr />
<p><em>Originally published at <a href="https://benchlet.com/guides/pods-pending-after-node-failure-node-affinity-topology-spread-k3s/" target="_blank" rel="noopener">benchlet.com</a>.</em></p>
]]></content:encoded></item><item><title><![CDATA[Kubernetes CronJob pg_dump backup: does the dump end up on the same node as the database?]]></title><description><![CDATA[Kubernetes CronJob pg_dump backup on k3s local-path: 5 of 7 dumps and every etcd snapshot were on the database's own disk. How to check yours.]]></description><link>https://kkhdevs.hashnode.dev/kubernetes-cronjob-pg-dump-backup-does-the-dump-end-up-on-the-same-node-as-the-database</link><guid isPermaLink="true">https://kkhdevs.hashnode.dev/kubernetes-cronjob-pg-dump-backup-does-the-dump-end-up-on-the-same-node-as-the-database</guid><category><![CDATA[Kubernetes]]></category><category><![CDATA[Backup]]></category><category><![CDATA[SRE]]></category><category><![CDATA[postmortem]]></category><category><![CDATA[Devops]]></category><dc:creator><![CDATA[KIM KYUNGHWAN]]></dc:creator><pubDate>Thu, 12 Nov 2026 19:55:58 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6af44048178e74800edef134/781f7f7b-1227-43f4-be47-7bf98da12d37.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><img src="https://benchlet.com/guides/kubernetes-pg-dump-cronjob-backup-same-node-k3s-etcd-snapshot/cover.png" alt="Terminal line " /></p>
<p><em>Every backup Job completed. Five of the seven dumps were on the same disk as the databases they copy.</em></p>
<p>If your <strong>Kubernetes CronJob pg_dump backup</strong> writes to a PVC of the <code>local-path</code> StorageClass (the k3s default) and <code>kubectl get jobs -A</code> shows every backup Job as <code>Complete 1/1</code>, that output does not tell you which node's disk the dump is on. On our cluster all seven backup Jobs completed on 2026-11-11, but five of the dumps were on the same single disk as the databases they copy, and every k3s etcd snapshot and the server token needed to restore them were on that disk too. A local-path volume is created on the node where the first pod that uses it is scheduled, and in five of our seven backup CronJobs nothing placed that pod on a node other than the database's. To see yours, list each PV with its node (check 1 below): if a backup claim and the database's data claim show the same NODE, the dump is on the database's node, and on the same disk if that node has only one. The cluster runs k3s v1.33.5+k3s1 on two computers: one server, which holds etcd and all seven backed-up databases on a single SSD, and one agent. All times are UTC.</p>
<h2>What we saw</h2>
<p><code>server-1</code> is the only server and has one 500 GB SATA SSD, no RAID; <code>agent-1</code> is the agent, on the same network. Storage is the k3s built-in local-path provisioner; applications are deployed by Argo CD. We captured this state on 2026-11-12 from 01:25 UTC; names are masked and columns are trimmed.</p>
<p>Seven backup CronJobs (six <code>pg_dump</code>, one <code>mysqldump</code>) run daily between 02:50 and 03:50. The latest run of each (the output had three Jobs per CronJob and one old Job created by hand; only the newest Job of each CronJob is kept here, and the CONTAINERS, IMAGES and SELECTOR columns are cut):</p>
<pre><code>$ kubectl get jobs -A
NAMESPACE   NAME                               STATUS     COMPLETIONS   DURATION   AGE
prod-a      app-a-postgres-backup-29906115     Complete   1/1           9m7s       22h
prod-b      app-b-postgres-backup-29906130     Complete   1/1           4s         21h
prod-b      app-b-postgres-3-backup-29906130   Complete   1/1           4s         21h
prod-b      app-b-postgres-2-backup-29906130   Complete   1/1           4s         21h
prod-c      app-c-postgres-backup-29906150     Complete   1/1           4s         21h
prod-d      app-d-mysql-backup-29906090        Complete   1/1           11s        22h
prod-e      app-e-postgres-backup-29906100     Complete   1/1           4s         22h
</code></pre>
<p>Each job writes a gzip dump (<code>&lt;name&gt;_&lt;timestamp&gt;.sql.gz</code>; the mysqldump job <code>&lt;name&gt;-&lt;date&gt;-&lt;time&gt;.sql.gz</code>) to a local-path PVC mounted at <code>/backup</code> and deletes older files. No command in any of the seven sends anything outside the cluster: there is no <code>s3</code>, <code>rclone</code>, <code>restic</code>, <code>scp</code> or <code>rsync</code> in them.</p>
<p><img src="https://benchlet.com/guides/kubernetes-pg-dump-cronjob-backup-same-node-k3s-etcd-snapshot/term-pv-before.png" alt="kubectl get pv output with columns NS, PVC, NODE and RECLAIM; five backup PVCs in red are on server-1, the same node as their database volumes, and two backup PVCs are on agent-1." /></p>
<p><em><code>kubectl get pv</code> at 2026-11-12 01:25 UTC. Red: a dump volume on the same node as its database, 5 of 7.</em></p>
<p>The same volumes, next to the node of each source database pod:</p>
<table>
<thead>
<tr>
<th>CronJob</th>
<th>dump PVC on</th>
<th>source database on</th>
<th>same node</th>
<th>latest dump (2026-11-11)</th>
</tr>
</thead>
<tbody><tr>
<td><code>app-e-postgres-backup</code></td>
<td>server-1</td>
<td>server-1</td>
<td>yes</td>
<td>62,739 bytes</td>
</tr>
<tr>
<td><code>app-b-postgres-backup</code></td>
<td>server-1</td>
<td>server-1</td>
<td>yes</td>
<td>411,735 bytes</td>
</tr>
<tr>
<td><code>app-b-postgres-2-backup</code></td>
<td>server-1</td>
<td>server-1</td>
<td>yes</td>
<td>11,176 bytes</td>
</tr>
<tr>
<td><code>app-b-postgres-3-backup</code></td>
<td>agent-1</td>
<td>server-1</td>
<td>no</td>
<td>8,287 bytes</td>
</tr>
<tr>
<td><code>app-d-mysql-backup</code></td>
<td>server-1</td>
<td>server-1</td>
<td>yes</td>
<td>15,777,210 bytes</td>
</tr>
<tr>
<td><code>app-a-postgres-backup</code></td>
<td>agent-1</td>
<td>server-1</td>
<td>no</td>
<td>1,024,335,407 bytes</td>
</tr>
<tr>
<td><code>app-c-postgres-backup</code></td>
<td>server-1</td>
<td>server-1</td>
<td>yes</td>
<td>2,434,541 bytes</td>
</tr>
</tbody></table>
<p>All seven source databases are on <code>server-1</code>, whose one disk holds a single filesystem at <code>/</code> with the etcd database, the etcd snapshots and the local-path volumes. Every PV had reclaim policy <code>Delete</code>.</p>
<p><img src="https://benchlet.com/guides/kubernetes-pg-dump-cronjob-backup-same-node-k3s-etcd-snapshot/term-etcd-before.png" alt="kubectl get etcdsnapshotfiles output: five snapshots, all on node server-1, every LOCATION starting with file:///var/lib/rancher/k3s/server/db/snapshots/." /></p>
<p><em>All five k3s etcd snapshots, <code>file://</code> on the server's own disk.</em></p>
<p>The five snapshots are <code>etcd-snapshot-server-1-&lt;unix time&gt;</code>, 41,775,136 to 45,232,160 bytes each. The k3s unit has no <code>--etcd-s3*</code> argument and there is no <code>/etc/rancher/k3s/config.yaml</code>. The server token, <code>/var/lib/rancher/k3s/server/token</code> (mode 600, root), is on the same disk. A cleanup CronJob keeps only the 5 newest snapshot files, daily at 03:00.</p>
<p>One disk failure on <code>server-1</code> would have removed the etcd database, all 5 snapshots and the token, every source database, and 5 of the 7 dumps (inference from the facts above). Only the <code>app-a</code> and <code>app-b-postgres-3</code> dumps on <code>agent-1</code> would have been left.</p>
<h2>Why local-path created the dump volumes on the database's node</h2>
<p><strong>local-path creates the volume where the first pod runs.</strong> Both StorageClasses use <code>WaitForFirstConsumer</code>: the volume is created on the node where the first pod that uses the claim is scheduled. The second class is named after the server, but nothing in it pins a volume to that node: no <code>allowedTopologies</code>, only a path.</p>
<pre><code class="language-yaml"># kubectl get storageclass local-path-server-1 -o yaml (trimmed)
allowVolumeExpansion: false
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: local-path-server-1
parameters:
  path: /var/lib/rancher/k3s/storage
provisioner: rancher.io/local-path
reclaimPolicy: Delete
volumeBindingMode: WaitForFirstConsumer
</code></pre>
<p>After that the PV is fixed to the node. From the local-path-provisioner README: PersistentVolumes created by the provisioner "use the <code>kubernetes.io/hostname</code> label for node affinity. This ensures the volume is only accessible on the node where it was provisioned."</p>
<p><strong>Only one of the seven jobs was placed on another node on purpose.</strong></p>
<ul>
<li><code>app-a-postgres-backup</code> has <code>nodeSelector: kubernetes.io/hostname: agent-1</code>. It was created on 2026-07-02 with that selector on purpose, and it was the only backup placed away from its database by design.</li>
<li><code>app-b-postgres-3-backup</code> had no nodeSelector and used <code>local-path-server-1</code>. Its volume is on <code>agent-1</code> only because its first pod was scheduled there (inference from the binding rule above).</li>
<li><code>app-c-postgres-backup</code> had <code>nodeSelector: storage: server-1</code>, which pinned its dump to the database's node.</li>
<li><code>app-d-mysql-backup</code> had <code>nodeSelector: kubernetes.io/hostname: server-1</code> and wrote to a claim from the database StatefulSet's own <code>volumeClaimTemplate</code>, so its dump volume was created with the database pod, on its node.</li>
<li><code>app-b-postgres-backup</code>, <code>app-b-postgres-2-backup</code> and <code>app-e-postgres-backup</code> had no nodeSelector, and their dump volumes were created on <code>server-1</code>, where their databases run.</li>
</ul>
<p><strong>A <code>Complete</code> Job status does not record which disk the dump was written to.</strong></p>
<p><strong>k3s keeps etcd snapshots on the server unless told otherwise.</strong> From the k3s etcd-snapshot docs: "Scheduled snapshots are enabled by default, at 00:00 and 12:00 system time, with 5 snapshots retained per server node on local disk" and "K3s etcd snapshots are stored on the node file system, and may optionally be uploaded to an S3-compatible object store for disaster recovery scenarios." We had not set <code>--etcd-s3</code>, and <code>server-1</code> is the only server, so all five snapshots were on one disk.</p>
<p><strong>The snapshot is unusable without the token.</strong> From the k3s backup and restore docs: "you must also back up the server token file at /var/lib/rancher/k3s/server/token" and "If you do not use the same token value when restoring, the snapshot will be unusable". A copy of the snapshots alone would not have been enough.</p>
<p><strong>The snapshot does not hold volume contents.</strong> It is "a complete copy of the etcd datastore, along with cluster certificate authority (CA) certificates and private keys" (k3s docs), so it has the API objects but not the data in volumes (inference from what etcd stores): the dumps and the snapshots have to be moved separately.</p>
<h2>Check where your Kubernetes CronJob pg_dump backup is stored</h2>
<p>These are read-only, except checks 5 and 6, which create throwaway objects. <a href="https://github.com/KyungHwanKim-devs/kubegap" target="_blank" rel="noopener">kubegap</a>, an audit tool that reads the cluster of your current kube context, found this problem here on 2026-11-11 by reading the same three things as checks 1 to 3. It reports all three in one run, so you can run it instead of pasting those three commands. Checks 4 to 6 are run by hand.</p>
<p><strong>1. Which node holds each dump, and which node holds the database it copies?</strong></p>
<pre><code>kubectl get pv --sort-by=.spec.claimRef.namespace -o custom-columns='NS:.spec.claimRef.namespace,PVC:.spec.claimRef.name,NODE:.spec.nodeAffinity.required.nodeSelectorTerms[0].matchExpressions[0].values[0],CLASS:.spec.storageClassName,RECLAIM:.spec.persistentVolumeReclaimPolicy'
</code></pre>
<p>Same problem if: the row of a backup PVC and the row of the database's data PVC show the same NODE, and that node has one disk (<code>lsblk -d -o NAME,TYPE,SIZE</code> on the node lists one row of TYPE <code>disk</code>; on <code>server-1</code> it was <code>sda disk 465.8G</code>). Read NODE, not CLASS: with local-path a StorageClass name does not choose the node; the first pod that uses the claim does (<code>WaitForFirstConsumer</code>). RECLAIM <code>Delete</code> means deleting the claim deletes the directory on the node.</p>
<p><strong>2. Does any backup job send its result to another machine?</strong></p>
<pre><code>kubectl get cronjob -A -o json | jq -r '.items[]
  | select(.metadata.name | test("backup|dump|snapshot"))
  | [.metadata.namespace, .metadata.name,
     (.spec.jobTemplate.spec.template.spec.nodeSelector // {} | tostring),
     ([.spec.jobTemplate.spec.template.spec.containers[] | ((.command // []) + (.args // [])) | join(" ")] | join(" ")
      | test("s3|rclone|restic|kopia|scp|rsync") | tostring)]
  | @tsv'
</code></pre>
<p>On our cluster, before the change:</p>
<pre><code>prod-a	app-a-postgres-backup	{"kubernetes.io/hostname":"agent-1"}	false
prod-b	app-b-postgres-backup	{}	false
prod-b	app-b-postgres-3-backup	{}	false
prod-b	app-b-postgres-2-backup	{}	false
prod-c	app-c-postgres-backup	{"storage":"server-1"}	false
prod-d	app-d-mysql-backup	{"kubernetes.io/hostname":"server-1"}	false
prod-e	app-e-postgres-backup	{}	false
</code></pre>
<p>Same problem if: the last column is <code>false</code> and the nodeSelector column is empty or names the database's node. Ours had all seven <code>false</code>, four with <code>{}</code> and two naming <code>server-1</code>. The match reads only the command and args, so <code>true</code> means "look closer", and a script mounted from a ConfigMap shows <code>false</code> even if it uploads: open the script before you conclude.</p>
<p><strong>3. Where are the etcd snapshots?</strong> (k3s with embedded etcd)</p>
<pre><code>kubectl get etcdsnapshotfile
</code></pre>
<p>Same problem if: every LOCATION starts with <code>file://</code> and nothing copies that directory to another machine (check 2). Those snapshots are on the server's own disk, next to the etcd database they copy.</p>
<p><strong>4. Did you back up the server token anywhere but the server?</strong></p>
<p>On the server: <code>sudo ls -l /var/lib/rancher/k3s/server/token</code>. Then look for the same file where your snapshot copies go.</p>
<p>Same problem if: the token exists only on the server. Without the same token value, a restored snapshot is unusable (k3s docs, backup and restore).</p>
<p><strong>5. Does the newest dump restore from the other machine?</strong></p>
<p>A Job in the dump's namespace that mounts the dump PVC read-only (it is scheduled on the node that holds the PVC), starts an empty server in an <code>emptyDir</code>, restores with <code>ON_ERROR_STOP</code>, and counts tables. The Job below is for PostgreSQL; use the image your backup uses (uid 70 is <code>postgres</code> in the alpine image):</p>
<pre><code class="language-yaml">apiVersion: batch/v1
kind: Job
metadata: {name: restore-test, namespace: &lt;ns&gt;}
spec:
  backoffLimit: 0
  ttlSecondsAfterFinished: 3600
  activeDeadlineSeconds: 900
  template:
    spec:
      restartPolicy: Never
      securityContext: {runAsUser: 70, runAsGroup: 70, fsGroup: 70}
      containers:
      - name: restore
        image: postgres:16-alpine
        command: ["/bin/sh", "-c"]
        args:
        - |
          set -eu
          F=$(ls -t /backup/*.sql.gz | head -1); echo "latest $F"
          gzip -t "$F"
          initdb -D /work/pg -U postgres &gt;/dev/null
          pg_ctl -D /work/pg -o "-c listen_addresses='' -c unix_socket_directories=/work" -w start &gt;/dev/null
          createdb -h /work -U postgres restored
          zcat "$F" | psql -h /work -U postgres -d restored -v ON_ERROR_STOP=1 -q
          psql -h /work -U postgres -d restored -Atc "select count(*) from pg_tables where schemaname not in ('pg_catalog','information_schema')"
        volumeMounts:
        - {name: backup, mountPath: /backup, readOnly: true}
        - {name: work, mountPath: /work}
      volumes:
      - {name: backup, persistentVolumeClaim: {claimName: &lt;dump-pvc&gt;, readOnly: true}}
      - {name: work, emptyDir: {}}
</code></pre>
<p>Then <code>kubectl get pod -n &lt;ns&gt; -l job-name=restore-test -o wide</code> shows the node it ran on, and the table count should equal the source's (<code>select count(*) from pg_tables where schemaname not in ('pg_catalog','information_schema')</code> in the source database).</p>
<p>Same problem if: the Job ran on the database's node, fails, or the count differs.</p>
<p><strong>6. Does the etcd copy restore?</strong></p>
<p>Copy the newest snapshot and the token from the other machine to a third one with Docker, check the sha256 against the copy, then restore with the same k3s version and no agent (no kubelet, so no workload from the snapshot can start):</p>
<pre><code>docker run --rm --privileged -e K3S_TOKEN="$(cat token)" -v k3s-test:/var/lib/rancher/k3s -v "$PWD":/snap:ro \
  rancher/k3s:&lt;your version, e.g. v1.33.5-k3s1&gt; server --disable-agent \
  --cluster-reset --cluster-reset-restore-path=/snap/&lt;snapshot file&gt;
docker run -d --name k3s-test --privileged -e K3S_TOKEN="$(cat token)" -v k3s-test:/var/lib/rancher/k3s \
  rancher/k3s:&lt;same version&gt; server --disable-agent
docker exec k3s-test kubectl get ns,deploy,sts,cronjob -A --no-headers | wc -l
docker rm -f k3s-test; docker volume rm k3s-test
</code></pre>
<p>The first command prints "Managed etcd cluster membership has been reset, restart without --cluster-reset flag now" and exits. Same problem if: it exits with an error, or the object counts are far from the live cluster's (objects created after the snapshot account for small differences). Delete the copied snapshot afterwards: it contains every Secret of the cluster.</p>
<h2>What we changed and what we have not</h2>
<p><img src="https://benchlet.com/guides/kubernetes-pg-dump-cronjob-backup-same-node-k3s-etcd-snapshot/placement.png" alt="Before and after diagram of server-1 and agent-1. Before: server-1 holds 7 database servers, the etcd database, 5 of 7 dumps, 5 etcd snapshots and the server token; agent-1 holds 2 of 7 dumps. After: server-1 keeps the originals; agent-1 holds 7 of 7 dumps, the etcd snapshot copies taken twice a day and a copy of the server token." /></p>
<p><em>What was on each machine before and after. Red: lost together with the database if server-1's disk fails.</em></p>
<p>Changes 2 and 3 went through the manifests repository and Argo CD. Change 1 was made with <code>kubectl patch</code> and recorded in the repository afterwards. The node settings on <code>server-1</code> are recorded in the README next to the CronJob.</p>
<p><strong>1. <code>Retain</code> on 30 of the 32 PVs (2026-11-12 01:35).</strong> We set every PV outside the logging and monitoring namespaces to <code>Retain</code> with <code>kubectl patch pv &lt;name&gt; -p '{"spec":{"persistentVolumeReclaimPolicy":"Retain"}}'</code>, and no pod restarted. Retain keeps the node directory when a claim is deleted and does nothing when the disk fails, so it is not the fix for this problem.</p>
<p><strong>2. Every dump Job runs on <code>agent-1</code>.</strong> All seven CronJobs now have:</p>
<pre><code class="language-yaml">spec:
  jobTemplate:
    spec:
      template:
        spec:
          nodeSelector:
            kubernetes.io/hostname: agent-1
</code></pre>
<p>The five that were on <code>server-1</code> write to a new PVC of class <code>local-path</code> (<code>app-b-postgres-backup-agent-1</code>, <code>app-b-postgres-2-backup-agent-1</code>, <code>app-c-postgres-backup-agent-1</code>, <code>app-d-mysql-backup-agent-1</code>, <code>app-e-postgres-backup-agent-1</code>). Because of <code>WaitForFirstConsumer</code>, each is created on <code>agent-1</code>, where its first pod runs. Changing only the nodeSelector would not have moved the old volumes: an existing local-path PV keeps its node affinity, so a pod pinned elsewhere could not mount it (inference from the provisioner's node affinity rule). Retention is now 7 days for all seven (<code>find -mtime +6 -delete</code>).</p>
<p><strong>3. The etcd snapshots and the token are pulled to <code>agent-1</code>.</strong> A CronJob <code>kube-system/etcd-snapshot-backup</code> on <code>agent-1</code> runs at <code>30 0,12 * * *</code>, 30 minutes after each k3s snapshot, and pulls <code>/var/lib/rancher/k3s/server/db/snapshots/</code> and <code>/var/lib/rancher/k3s/server/token</code> from <code>server-1</code> with rsync over SSH into the PVC <code>etcd-snapshot-backup-agent-1</code>, kept 7 days. Its name contains "backup", so the existing alert <code>BackupCronJobStale</code> (no success for 26 hours) covers it.</p>
<p>On <code>server-1</code>, root may log in over SSH only with a forced-command key and only from <code>agent-1</code> and the pod network:</p>
<pre><code># /etc/ssh/sshd_config.d/10-etcd-snapshot-backup.conf
Match Address 10.0.0.11,10.42.0.0/16
    PermitRootLogin forced-commands-only
</code></pre>
<pre><code># /root/.ssh/authorized_keys (one line; key not shown)
restrict,from="10.0.0.11,10.42.0.0/16",command="/usr/bin/rrsync -ro /var/lib/rancher/k3s/server/" ssh-ed25519 &lt;public key&gt; etcd-snapshot-backup@agent-1
</code></pre>
<p><code>rrsync -ro</code> limits the key to read-only rsync under <code>/var/lib/rancher/k3s/server/</code>. <code>sshd -t</code> passed. <code>sshd -T -C user=root,host=x,addr=&lt;address&gt;</code> was compared before and after for two addresses: <code>203.0.113.9</code>, outside the Match, was unchanged, and <code>10.0.0.11</code> changed only in <code>permitrootlogin</code> (<code>no</code> to <code>forced-commands-only</code>). Addresses in <code>10.42.0.0/16</code> are inside the Match too. The private key is kept in the secret manager the cluster already uses and reaches the pod as a Secret through External Secrets.</p>
<p>The first copy failed. All three attempts of the Job, on <code>agent-1</code>, ended the same way:</p>
<pre><code>ssh: connect to host 10.0.0.12 port 22: Operation timed out
rsync: connection unexpectedly closed (0 bytes received so far) [Receiver]
rsync error: unexplained error (code 255) at io.c(232) [Receiver=3.4.1]
</code></pre>
<p>The <code>agent-1</code> node itself could not reach <code>server-1:22</code> either. The ufw rows on <code>server-1</code> before the change:</p>
<pre><code>22/tcp                     ALLOW IN    100.64.0.0/10
Anywhere                   ALLOW IN    10.42.0.0/16
Default: deny (incoming), allow (outgoing), allow (routed)
</code></pre>
<p>That allows <code>22/tcp</code> from the Tailscale range and every port from the pod network, but nothing from <code>agent-1</code>'s own address, <code>10.0.0.11</code>. The pod's connection timed out although the pod network was allowed, so it reached <code>server-1</code> from <code>10.0.0.11</code> (inference). One rule fixed it:</p>
<pre><code>ufw allow from 10.0.0.11 to any port 22 proto tcp comment "etcd-snapshot-backup from agent-1"
</code></pre>
<p>The next run copied 5 snapshots and the token to <code>agent-1</code>.</p>
<p><strong>Verification (2026-11-12 17:24 to 17:55).</strong> Each backup CronJob was run once by hand after Argo CD applied the change: all seven ran on <code>agent-1</code> and completed (<code>app-a-postgres-backup</code>, the largest, in 9 minutes). Every backup PVC in use is now on <code>agent-1</code>.</p>
<p>The newest dump of each of the five moved databases was then restored from its PVC on <code>agent-1</code> into an empty server (psql with <code>ON_ERROR_STOP=1</code>, or mysql), and compared with counts taken inside the source pod at the same minute:</p>
<p><img src="https://benchlet.com/guides/kubernetes-pg-dump-cronjob-backup-same-node-k3s-etcd-snapshot/term-restore.png" alt="Restore test output: app-b-postgres, app-b-postgres-2, app-c-postgres, app-e-postgres and app-d-mysql all ran on agent-1, exited 0, and have equal table counts between source and restore; row counts are equal for the four PostgreSQL databases, and for app-d-mysql they were not counted at the source." /></p>
<p><em>Five dumps restored from agent-1 at 17:42 UTC. Table and row counts equal the source; the MySQL source rows were not counted.</em></p>
<table>
<thead>
<tr>
<th>database</th>
<th>restore</th>
<th>tables source / restored</th>
<th>rows source / restored</th>
</tr>
</thead>
<tbody><tr>
<td><code>app-b-postgres</code></td>
<td>exit 0</td>
<td>93 / 93</td>
<td>576 / 576</td>
</tr>
<tr>
<td><code>app-b-postgres-2</code></td>
<td>exit 0</td>
<td>103 / 103</td>
<td>6 / 6</td>
</tr>
<tr>
<td><code>app-c-postgres</code></td>
<td>exit 0</td>
<td>149 / 149</td>
<td>122,805 / 122,805</td>
</tr>
<tr>
<td><code>app-e-postgres</code></td>
<td>exit 0</td>
<td>13 / 13</td>
<td>1,001 / 1,001</td>
</tr>
<tr>
<td><code>app-d-mysql</code></td>
<td>exit 0</td>
<td>67 / 67</td>
<td>not counted at the source / 620,704</td>
</tr>
</tbody></table>
<p>The etcd copy was restored the same way as check 6. The newest snapshot (12:00) and the token were copied from <code>agent-1</code> with a matching sha256 and restored with the same k3s version (<code>--cluster-reset --cluster-reset-restore-path</code>, then a normal start) in a container on a third machine, with the agent disabled so nothing from the snapshot could run:</p>
<p><img src="https://benchlet.com/guides/kubernetes-pg-dump-cronjob-backup-same-node-k3s-etcd-snapshot/term-etcd-restore.png" alt="etcd restore test output: the snapshot fetched from agent-1 with a matching sha256, live and restored object counts, the " /></p>
<p><em>The 12:00 snapshot copy restored by k3s v1.33.5 with no agent.</em></p>
<p>Restored versus live: namespaces 28/28, deployments 68/68, statefulsets 22/22, cronjobs 29/30, secrets 145/147, configmaps 153/154, persistentvolumes 32/38. Every difference is an object created after the 12:00 snapshot: the new CronJob, two Secrets (the copy's SSH key and one from an unrelated change that day), the copy's known_hosts ConfigMap and the six new PVCs. The only processes in the container were <code>k3s</code> and its supervisor.</p>
<p>Three earlier attempts failed on the test script, not the copy; you may hit them with check 6:</p>
<ul>
<li><code>kubectl exec ... cat</code> of the snapshot returned 43,974,656 bytes instead of 45,989,920; <code>kubectl cp</code> with a sha256 check worked.</li>
<li>With <code>--network none</code> or an internal Docker network, k3s exited with <code>no default routes found</code>; the default bridge network worked.</li>
<li>With <code>--token-file</code>, k3s exited with <code>/var/lib/rancher/k3s/server/token does not exist, please pass --token</code>; <code>K3S_TOKEN</code> worked.</li>
</ul>
<p><strong>What we have not changed:</strong></p>
<ul>
<li><strong>No off-site copy.</strong> Both copies are on a second computer in the same house: a disk failure on <code>server-1</code> no longer takes the backups with it, but a fire, theft or power event that takes both machines would. This is a practice cluster, and I chose to stop at the second machine.</li>
<li><strong>The old dump PVCs on <code>server-1</code> are kept.</strong> They hold the dumps taken before the change and are no longer written to.</li>
<li><strong>Two dumps were not restore-tested in this change.</strong> <code>app-a</code> and <code>app-b-postgres-3</code> were already on <code>agent-1</code>; the restore test covered the five that moved.</li>
<li><strong>Eight PVs are <code>Delete</code>.</strong> Read at 18:21 after the change, 30 PVs are <code>Retain</code> and 8 are <code>Delete</code>: the six new backup claims, which got <code>Delete</code> from the StorageClass default, and one volume each in the logging and monitoring namespaces.</li>
<li><strong>The snapshot copy holds every Secret and the cluster CA keys</strong>, so access to <code>agent-1</code>'s disk now matters as much as access to <code>server-1</code>'s (inference).</li>
</ul>
<h2>Where else this happened</h2>
<p>The <a href="https://benchlet.com/incidentlib/app" target="_blank" rel="noopener">Incident Library</a>, a searchable collection of public incident reports, has three that show what happened elsewhere when a control plane or a data store depended on a single disk, node, zone or pod; the first is the disk failure that <code>server-1</code> is exposed to:</p>
<ul>
<li>An individual engineer's blog, 2023-05-07 (IL-0185): a disk failure made the first control plane node of a Kubespray cluster read-only, and recovery needed the inventory reordered and a new control plane node joined.</li>
<li>Devsisters, 2021-02-19 (IL-0172): an AWS Tokyo cooling failure took down 6 of 60 CockroachDB nodes on Kubernetes in 22 minutes; ranges lost quorum because no availability zone locality was configured, and a table backup taken from the surviving nodes did not work, which is the failure that checks 5 and 6 are there to catch before you need the backup.</li>
<li>Palark, 2020-03-13 (IL-0196): one RedisFailover change restarted the only Redis pod that held data, and Sentinel promoted the empty new replica, erasing the data; as with the five dumps on <code>server-1</code>, the data was in one place.</li>
</ul>
<p>The library lists <a href="https://benchlet.com/incidentlib/app?component=etcd&amp;factor=single-point-of-failure" target="_blank" rel="noopener">5 reports where etcd was a single point of failure</a>, IL-0185 among them, and <a href="https://benchlet.com/incidentlib/app?symptom=data-loss" target="_blank" rel="noopener">2 where data was lost</a>, the Devsisters and Palark cases above.</p>
<h2>Timeline (UTC)</h2>
<table>
<thead>
<tr>
<th>When</th>
<th>What</th>
</tr>
</thead>
<tbody><tr>
<td>2025-11-11</td>
<td>First backup CronJob created; its dump volume is created on the database's node</td>
</tr>
<tr>
<td>2026-07-02</td>
<td><code>app-a-postgres-backup</code> placed on <code>agent-1</code> on purpose, the only one</td>
</tr>
<tr>
<td>2026-11-11 02:50–03:50</td>
<td>All seven backup Jobs <code>Complete</code>; five dumps on the database's disk</td>
</tr>
<tr>
<td>2026-11-11 05:08</td>
<td>kubegap audit lists backups and etcd snapshots that never leave the node</td>
</tr>
<tr>
<td>2026-11-12 17:20–17:41</td>
<td>Dump Jobs pinned to <code>agent-1</code>, etcd copy CronJob added; first copy blocked by ufw, then done</td>
</tr>
<tr>
<td>2026-11-12 17:42–17:55</td>
<td>Five moved dumps and the etcd copy restored from <code>agent-1</code></td>
</tr>
</tbody></table>
<h2>FAQ</h2>
<h3>kubernetes cron job for postgres db backup</h3>
<p>A Kubernetes CronJob pg_dump backup, like a Kubernetes CronJob mysqldump backup, writes its dump to a PVC. With local-path, the claim is created on the first Job pod's node, possibly the database's. Pin the Job with <code>nodeSelector</code> to another node with a new claim: an existing local-path PV stays on its node. Or upload it off the cluster.</p>
<h3>kubernetes rancher local path specify node</h3>
<p>The StorageClass does not choose the node. With <code>volumeBindingMode: WaitForFirstConsumer</code>, local-path creates the volume on the node where the first pod using the claim is scheduled, and then pins the PV there with <code>kubernetes.io/hostname</code> node affinity. To choose the node, put <code>nodeSelector</code> or node affinity on that first pod before the claim is bound.</p>
<h3>where does k3s local-path store data</h3>
<p>In <code>/var/lib/rancher/k3s/storage</code> on the node that holds the volume (<code>${HOME}/.rancher/k3s/storage</code> in rootless mode). The node is recorded in the PV's node affinity. Read it with the <code>kubectl get pv</code> command in check 1 above. With reclaim policy <code>Delete</code>, deleting the claim deletes that directory on the node.</p>
<h3>How do you verify that PostgreSQL backups on Kubernetes can actually be restored?</h3>
<p>Run a Job that mounts the dump's claim read-only, starts an empty PostgreSQL in an <code>emptyDir</code>, restores with <code>psql -v ON_ERROR_STOP=1</code>, and counts tables. Compare with the same count in the source. Check which node the Job ran on: for a backup meant to survive a disk failure, it must not be the database's node.</p>
<h3>restore k3s etcd snapshot new machine</h3>
<p>Copy the snapshot and the server token file <code>/var/lib/rancher/k3s/server/token</code> to the new machine. Run <code>k3s server --cluster-reset --cluster-reset-restore-path=&lt;file&gt;</code> with the token in <code>K3S_TOKEN</code> (with <code>--token-file</code>, k3s exited with <code>/var/lib/rancher/k3s/server/token does not exist, please pass --token</code>), then start k3s without <code>--cluster-reset</code>. We used the same k3s version. Without the same token the snapshot is unusable.</p>
<hr />
<p><em>Originally published at <a href="https://benchlet.com/guides/kubernetes-pg-dump-cronjob-backup-same-node-k3s-etcd-snapshot/" target="_blank" rel="noopener">benchlet.com</a>.</em></p>
]]></content:encoded></item><item><title><![CDATA[Sixteen pods evicted for disk, thirteen of them above the threshold]]></title><description><![CDATA[Sixteen pods evicted on one k3s node in three waves. Thirteen above the 5 % threshold: evictionMinimumReclaim was 10 % and nothing requested…]]></description><link>https://kkhdevs.hashnode.dev/sixteen-pods-evicted-for-disk-thirteen-of-them-above-the-threshold</link><guid isPermaLink="true">https://kkhdevs.hashnode.dev/sixteen-pods-evicted-for-disk-thirteen-of-them-above-the-threshold</guid><category><![CDATA[Kubernetes]]></category><category><![CDATA[Devops]]></category><category><![CDATA[SRE]]></category><category><![CDATA[Docker]]></category><dc:creator><![CDATA[KIM KYUNGHWAN]]></dc:creator><pubDate>Thu, 12 Nov 2026 00:26:37 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6af44048178e74800edef134/c0f83a78-a656-446e-b20d-aadb2a13925c.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>On 2026-11-08 the kubelet on one of our two nodes evicted 16 pods for <code>ephemeral-storage</code>, 14 production workloads and two cluster components, in three waves between 13:46 and 16:14 UTC. Only three of the sixteen eviction messages show free space under the kubelet's threshold. The other thirteen say 5.9 to 15.4 GiB were free, and nine of those say 12 GiB or more. None of the 136 containers the audit counted on the cluster had asked for any disk, so the kubelet had little to rank them by except priority and how much each one held on disk at that moment.</p>
<p>This is a two-node k3s cluster that I run alone for a few side projects. Times are UTC. Object names below are role names, not the real ones.</p>
<h2>What it looked like</h2>
<p>Three days later the failed pods were still there. The control plane garbage-collects terminated pods only past a threshold of 12,500, so on a small cluster they stay until something else deletes them:</p>
<pre><code>$ kubectl get pods -A --field-selector=status.phase=Failed
NAMESPACE   NAME                        READY   STATUS    RESTARTS   AGE
prod-a      turn-server-…-k2m9q         0/1     Evicted   0          236d
prod-a      signaling-…-r7t4c           0/1     Evicted   0          3d
prod-b      api-server-…-p3w8d          0/1     Evicted   0          28d
prod-b      api-server-…-z6h2n          0/1     Evicted   0          28d
prod-b      api-server-…-b9v5f          0/1     Evicted   0          9d
prod-b      api-server-…-j4c7s          0/1     Evicted   0          3d
…                                       (23 Failed; the 16 Evicted all on the same node)
</code></pre>
<p>Thirteen of the sixteen messages had the same shape; the other three stop after the available value:</p>
<pre><code>The node was low on resource: ephemeral-storage.
Threshold quantity: 5729877282, available: 15662516Ki.
Container app was using 17556Ki, request is 0, has larger consumption of ephemeral-storage.
</code></pre>
<p>The threshold is 5,729,877,282 bytes, 5.34 GiB. The node had 15,662,516 KiB available, 14.9 GiB. The container being evicted held 17 MiB. That is the message on a pod evicted at 14:03; the seven pods evicted between 16:13:49 and 16:14:16 carry "available" values between 12.1 and 15.4 GiB.</p>
<p>The previous two write-ups in this series start from the same afternoon: the 4.5 GiB image that filled the disk and the signaling server that could not come back because its image had been garbage-collected, then the TURN server that had been crash-looping since September. This one is about the evictions themselves.</p>
<h2>The three waves</h2>
<p>I had the one-minute free-space series from Prometheus and the eviction time of each pod from its last status condition. Put together:</p>
<p><img src="https://benchlet.com/guides/diskpressure-evicted-production-pods-ci-runner-same-node/waves.png" alt="Free space on the node's root filesystem on 2026-11-08 with the 16 evictions plotted at the available value from each message; three waves at 13:46, 14:03 and 16:10 to 16:14, with the 5 % threshold and the 16 GiB line drawn in; only three dots are under the threshold" /></p>
<table>
<thead>
<tr>
<th>Wave</th>
<th>Pods</th>
<th>Free space in the messages</th>
<th>One-minute series</th>
</tr>
</thead>
<tbody><tr>
<td>13:46:24–13:46:51</td>
<td>5</td>
<td>5.3, 5.9, 5.9, 6.0, 6.1 GiB</td>
<td>5.3 GiB at 13:46, 2.6 GiB from 13:48; <code>DiskPressure</code> set at 13:47</td>
</tr>
<tr>
<td>14:03:48–14:03:56</td>
<td>2</td>
<td>14.9, 15.2 GiB</td>
<td>14.4 GiB since 13:56; 15.3 at 14:04, 27.3 at 14:05</td>
</tr>
<tr>
<td>16:10:52–16:14:16</td>
<td>9</td>
<td>4.3, 4.5, then 12.1 → 15.4 GiB</td>
<td>4.5 GiB at 16:10, 2.1 at 16:12–16:13, 14.5 at 16:14, 27.3 at 16:15</td>
</tr>
</tbody></table>
<p>Only the first pod of the first wave and the first two of the third were evicted with free space under the threshold.</p>
<h2>Why thirteen pods were evicted above the threshold</h2>
<p>The node's kubelet configuration:</p>
<pre><code>evictionHard:           imagefs.available: 5%, nodefs.available: 5%
evictionMinimumReclaim: imagefs.available: 10%, nodefs.available: 10%
evictionSoft:           none
</code></pre>
<p><code>evictionMinimumReclaim</code> is the setting I had never looked at. Once a hard threshold has been crossed, the kubelet does not stop evicting as soon as the signal is back above the threshold. It keeps evicting, one pod per loop, until the signal is above the threshold plus the minimum reclaim, both measured as a share of the filesystem. On a 106.7 GiB filesystem that is 5.34 GiB plus 10.67 GiB: eviction continues until 16.0 GiB are free.</p>
<p><img src="https://benchlet.com/guides/diskpressure-evicted-production-pods-ci-runner-same-node/reclaim.png" alt="Diagram of the three bands on a 32 GiB scale: eviction starts below 5.34 GiB, continues up to 16.0 GiB because of the 10 % minimum reclaim, stops above; the 16 evictions plotted by wave" /></p>
<p>Both later waves ended within a minute of free space jumping past 16.0 GiB: the last evictions are at 14:03:56 and 16:14:16, the jumps show in the 14:05 (15.3 to 27.3 GiB) and 16:15 (14.5 to 27.3 GiB) samples. The evictions themselves freed almost nothing; the thirteen messages that state a usage add up to 182 MiB. What freed 12 GiB in a minute each time is not in any record I have. The first write-up put it down to the kubelet reclaiming unused images, which is what it does before it evicts, and the shape of the series is consistent with that. The image garbage collector's own high-water mark, 85 % used, is the same 16.0 GiB on this disk. After 14:05 the threshold was cleared, and from 14:20 the node sat at 12.9 GiB, above 5.34 and below 16.0, with no evictions until the second dip.</p>
<p>The upstream default for <code>evictionMinimumReclaim</code> is empty, which means "stop at the threshold". k3s sets 5 % thresholds and a 10 % minimum reclaim in its agent defaults, and this node runs them unchanged; I had not set either. On a node where the image store and the root filesystem are the same disk, 10 % is 10.67 GiB, and the 42 pods on this node hold 0.2 GiB between them today, and the sixteen evicted containers held 182 MiB in total, so evicting pods was never going to reach it.</p>
<h2>Nothing asked for disk</h2>
<p>The last line of the thirteen messages that have one ends with <code>request is 0</code>. The audit counted what the scheduler and the kubelet actually knew about disk on this cluster:</p>
<pre><code>running containers                           136
with an ephemeral-storage request              0
with an ephemeral-storage limit                0
LimitRange objects in any namespace            0
</code></pre>
<p>With no requests, the scheduler places pods without looking at disk at all. When the kubelet has to pick what to evict under disk pressure it sorts pods three ways: pods whose usage exceeds their request first, then by priority, then by how far usage exceeds the request. With every request at zero, every pod holding anything on disk is in the first group, and the order inside it is priority and then whoever holds the most. One more rule sits in front of all of that: a pod the kubelet has no stats for ranks ahead of every pod it can measure. The one pod in wave 1 and the two in wave 3 whose messages carry no usage line are, I infer from the ranking code, exactly those, which is why at 16:10 and 16:12, with two runners still on the node, the signaling and TURN servers went first. The evicted containers held between 0.7 MiB and 37 MiB. The things actually consuming the disk, a 4.5 GiB model image being unpacked and, later, two CI runners building at once, are not things pod eviction can free: the image is node-level storage, and the runners are the pods holding the most, which puts them first in the sort only if they are still there when the kubelet looks.</p>
<p>The CI runners run as a RunnerDeployment with a docker-in-docker sidecar, whose image layers go into the sidecar's writable layer and whose checkouts go into an <code>emptyDir</code>, both on the node's root disk. They had CPU and memory requests and limits and nothing for storage, and no node selector, so a runner lands on whichever node has room, including the one that serves production. The audit I used to find this listed the runners as the probable cause of the 11-08 dip. For the first dip the Prometheus series does not support that: no runner pod was on the node between 13:30 and 13:55, and the free-space curve follows the model image deploy described in the first write-up; that attribution is an inference from timing, not a measurement. For the second dip it points the other way: two runner pods sat on the node from 16:04 to 16:13, the only time that day two were there at once, and free space went from 10.3 GiB at 16:07 to 2.1 GiB at 16:12. What they wrote is not recorded; the runners' own records are gone with the pods.</p>
<h2>What I could not see</h2>
<ul>
<li>Per-container disk use on 11-08. <code>container_fs_usage_bytes</code> has no series on this k3s install, and the kubelet's eviction counters were reset by a node reboot two days later. The eviction messages are the only per-pod record.</li>
<li>Why the kubelet waited from 13:56, when free space was back at 14.4 GiB, until 14:03 to evict the next two pods. The kubelet log on the node was not readable from my session. One consequence is measured: the replacement pods created at 13:46 did not start until 14:09, so the node admitted nothing for 23 minutes.</li>
<li>What fills the disk today. The kubelet's stats summary says the root filesystem has 72.9 GiB used, of which the image store accounts for 35.0 GiB and all 42 pods' ephemeral storage for 0.2 GiB. The node image list adds up to 15.3 GiB, so about 20 GiB of the image store belongs to nothing in the image list, which I expect to be containerd snapshots and content I cannot read, and about 38 GiB more is outside kubelet accounting entirely. Breaking that down needs root on the node.</li>
</ul>
<h2>The fix</h2>
<p>Three changes, all in git, applied by Argo CD.</p>
<p>The runner and its docker-in-docker sidecar now request and limit ephemeral storage. The limits mean the kubelet evicts the runner on its own once the sidecar's writable layer passes 20 GiB, the runner's passes 10 GiB, or the pod as a whole, <code>emptyDir</code> included, passes 30 GiB, before the node is under pressure. The request means that under pressure a runner that has grown past 5 GiB sorts ahead of pods that are a few MiB over zero:</p>
<pre><code class="language-yaml">resources:
  requests: { cpu: 500m, memory: 1Gi, ephemeral-storage: 2Gi }
  limits:   { cpu: "2",  memory: 4Gi, ephemeral-storage: 10Gi }
dockerdContainerResources:
  requests: { ephemeral-storage: 5Gi }
  limits:   { ephemeral-storage: 20Gi }
</code></pre>
<p>The two GPU workloads request 2 Gi each. With a 2 Gi request and a few MiB of usage they no longer exceed their request, so the kubelet sorts them after every pod that does; while everything else requests zero, they are the last to go.</p>
<p>And an alert that fires before the kubelet acts:</p>
<pre><code class="language-yaml">- alert: NodeRootDiskLow        # &lt; 15 % for 10m, warning
- alert: NodeRootDiskCritical   # &lt;  8 % for  5m, critical → the route that mentions everyone
</code></pre>
<p>After the sync:</p>
<pre><code>$ kubectl get pods -A -o json | … ephemeral-storage requests
ci            runner-…-d8k3p   runner   2Gi / 10Gi
ci            runner-…-d8k3p   docker   5Gi / 20Gi
ci            runner-…-m2r6w   runner   2Gi / 10Gi
ci            runner-…-m2r6w   docker   5Gi / 20Gi
prod-b        embedding-…      app      2Gi / –
prod-c        ai-…             ai       2Gi / –
</code></pre>
<p><img src="https://benchlet.com/guides/diskpressure-evicted-production-pods-ci-runner-same-node/disk30d.png" alt="Thirty days of free space on the node, 30-minute samples, with the 11-08 dip, the kubelet's 5 % line and the new 15 % and 8 % alert lines" /></p>
<h2>Timeline</h2>
<table>
<thead>
<tr>
<th>When (UTC)</th>
<th>What</th>
</tr>
</thead>
<tbody><tr>
<td>2026-11-08 13:37</td>
<td>Deployment of a new service updated to a 4.5 GiB image; its pod starts 13:40 (first write-up)</td>
</tr>
<tr>
<td>2026-11-08 13:46:24–13:46:51</td>
<td>Wave 1: five pods evicted, 5.3–6.1 GiB free</td>
</tr>
<tr>
<td>2026-11-08 13:47–14:09</td>
<td><code>DiskPressure=true</code>; free space 2.6 GiB until 13:55, 14.4 from 13:56, 27.3 at 14:05 (the condition clears about five minutes after free space passed threshold plus minimum reclaim at 14:05)</td>
</tr>
<tr>
<td>2026-11-08 14:03:48–14:03:56</td>
<td>Wave 2: two pods evicted, 14.9–15.2 GiB free</td>
</tr>
<tr>
<td>2026-11-08 16:04–16:13</td>
<td>Two CI runner pods on the node at once</td>
</tr>
<tr>
<td>2026-11-08 16:10–16:19</td>
<td>Second <code>DiskPressure</code> window; 4.5 GiB at 16:10, 2.1 GiB at 16:12–16:13, 27.3 at 16:15</td>
</tr>
<tr>
<td>2026-11-08 16:10:52–16:14:16</td>
<td>Wave 3: nine pods evicted, 4.3 → 15.4 GiB free</td>
</tr>
<tr>
<td>2026-11-10 06:01</td>
<td>Node rebooted; eviction counters reset</td>
</tr>
<tr>
<td>2026-11-11 05:08</td>
<td>Cluster audit lists the 16 Failed pods and the zero ephemeral-storage requests</td>
</tr>
<tr>
<td>2026-11-11 23:45</td>
<td>ephemeral-storage requests and limits on the runners, requests on the two GPU workloads, two disk alerts, all rolled out</td>
</tr>
</tbody></table>
<h2>What I am changing</h2>
<p>No disk alert fired that day. The kube-prometheus-stack chart's <code>NodeFilesystemAlmostOutOfSpace</code> rule wants free space under 5 % for 30 minutes; on 11-08 it was under 5 % for eight minutes at the longest stretch and thirteen in the day. The kubelet evicts at 5 % immediately; it had finished before the rule's 30 minutes were up.</p>
<p>Done:</p>
<ul>
<li><code>ephemeral-storage</code> requests and limits on the CI runner and its docker-in-docker sidecar; requests on the two GPU workloads.</li>
<li><code>NodeRootDiskLow</code> at 15 % for 10 minutes, <code>NodeRootDiskCritical</code> at 8 % for 5 minutes, the latter on the route that mentions everyone.</li>
</ul>
<p>Not done yet:</p>
<ul>
<li><code>evictionMinimumReclaim</code>. It lives in the k3s agent configuration on the node, outside git. With the upstream default, no minimum reclaim, the day would have been three evictions: the first pod of wave 1 and the first two of wave 3. At 2–3 % it would have been seven: all five of wave 1, none of wave 2, two of wave 3. It needs a node-level change and a kubelet restart.</li>
<li>A node for CI. The runners should not share a disk with production at all. That needs hardware I do not have this month.</li>
<li>Root on the node to break down the 38 GiB that no pod or image accounts for.</li>
<li>A default <code>ephemeral-storage</code> request through a <code>LimitRange</code> in every production namespace, small enough not to change scheduling, so a new Deployment cannot arrive with zero again; the two cluster components stay outside it.</li>
</ul>
<p>If your eviction messages show "available" values far above the threshold, read the kubelet's <code>evictionMinimumReclaim</code> first. <code>kubectl get --raw /api/v1/nodes/&lt;node&gt;/proxy/configz | jq .kubeletconfig.evictionMinimumReclaim</code> shows it without logging in to the node.</p>
<p>Public post-incident reports where a node ran out of disk and took unrelated workloads with it are collected under the <a href="https://benchlet.com/incidentlib/app?symptom=node-disk-full" target="_blank" rel="noopener">node disk full</a> and <a href="https://benchlet.com/incidentlib/app?symptom=pods-evicted" target="_blank" rel="noopener">pods evicted</a> symptoms in Incident Library. The audit that surfaced this is <a href="https://github.com/KyungHwanKim-devs/kubegap" target="_blank" rel="noopener">kubegap</a>.</p>
<hr />
<p><em>Originally published at <a href="https://benchlet.com/guides/diskpressure-evicted-production-pods-ci-runner-same-node/" target="_blank" rel="noopener">benchlet.com</a>.</em></p>
]]></content:encoded></item><item><title><![CDATA[A pod that ran for six months crash-looped on its first restart]]></title><description><![CDATA[coturn:latest with imagePullPolicy Always: one restart in late September pulled 4.18.0, which had removed --no-dtls. 280 restarts a day for 45 days, alert…]]></description><link>https://kkhdevs.hashnode.dev/a-pod-that-ran-for-six-months-crash-looped-on-its-first-restart</link><guid isPermaLink="true">https://kkhdevs.hashnode.dev/a-pod-that-ran-for-six-months-crash-looped-on-its-first-restart</guid><category><![CDATA[Docker]]></category><category><![CDATA[SRE]]></category><category><![CDATA[Kubernetes]]></category><category><![CDATA[Devops]]></category><dc:creator><![CDATA[KIM KYUNGHWAN]]></dc:creator><pubDate>Wed, 11 Nov 2026 13:17:14 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6af44048178e74800edef134/adec1657-b5ce-4125-91cc-c6d9361057e0.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Our TURN server, the relay that lets two browsers talk when neither can be reached directly, had zero available replicas from about 2026-09-28 until 2026-11-11 11:58 UTC. The container died within a second of starting, every five minutes, about 280 times a day, for around 45 days. Its Deployment had not been touched since March. The alert for it fired on every day Prometheus still has.</p>
<p>This is a two-node k3s cluster that I run alone for a few side projects. Times are UTC.</p>
<h2>What it looked like</h2>
<p>A read-only audit of the cluster (<a href="https://github.com/KyungHwanKim-devs/kubegap" target="_blank" rel="noopener">kubegap</a>) that I ran on 2026-11-11 at 05:08 for a different problem listed this one next to it: a Deployment in a production namespace with <code>0/1</code> ready and a pod in <code>CrashLoopBackOff</code> with 718 restarts. By 11:40, when I got to it, the number was 794.</p>
<pre><code>$ kubectl -n dam-prod get pod -l app=dam-coturn
NAME                          READY   STATUS                   RESTARTS            AGE
dam-coturn-549854dfcb-bchx5   0/1     CrashLoopBackOff         794 (2m11s ago)     2d19h
dam-coturn-549854dfcb-c8mfp   0/1     ContainerStatusUnknown   28 (2d19h ago)      2d21h
dam-coturn-549854dfcb-jgp4h   0/1     ContainerStatusUnknown   11621 (2d21h ago)   235d
</code></pre>
<p>Three pods. The one created in March with 11,621 restarts, a replacement from 2026-11-08 with 28, and the current one with 794. The two older ones had been evicted on 11-08 when the node ran out of disk (that is the previous write-up); the eviction did not cause this, it only produced a new pod that failed the same way.</p>
<p>The previous container's log had the answer in its first line, followed by the program's entire usage text:</p>
<pre><code>$ kubectl -n dam-prod logs dam-coturn-549854dfcb-bchx5 --previous | head -4
turnserver: unrecognized option '--no-dtls'
2026-11-11T11:38:04.215+0000 WARNING Cannot find config file: turnserver.conf. ...
2026-11-11T11:38:04.215+0000 WARNING Cannot find config file: turnserver.conf. ...
turnserver: unrecognized option '--no-dtls'
</code></pre>
<p>Started 11:38:04, finished 11:38:04, exit code 255. The binary did not know a flag that was in the Deployment's <code>args</code>, so it printed help and quit. A few hundred lines further down, the same usage text showed what had replaced it:</p>
<pre><code> --no-tls          Do not start TLS client listeners.
 --dtls            Start DTLS client listeners. DTLS is not started by default.
</code></pre>
<p>The alert had been firing since before the retention window. The Alertmanager list that morning had six groups, five of them routed to the Discord receiver and two of those for this namespace:</p>
<p><img src="https://benchlet.com/guides/crashloopbackoff-coturn-unrecognized-option-no-dtls/alertmanager-before.png" alt="Alertmanager alert list before the fix: six groups, five routed to discord, including KubePodCrashLooping and KubeDeploymentReplicasMismatch for namespace dam-prod" /></p>
<h2>Which binary was this?</h2>
<p>The Deployment said <code>image: coturn/coturn:latest</code> with <code>imagePullPolicy: Always</code>. The pod's status said what that had resolved to:</p>
<pre><code>Image ID:  docker.io/coturn/coturn@sha256:bbefd3e1fdfd...
</code></pre>
<p>Docker Hub's tag API maps that digest to <code>4.18.0</code>, pushed 2026-09-08 11:41, and <code>latest</code> has pointed at it since 11:42, five seconds later. So the running binary was coturn 4.18.0, and the flag it rejected had to have been removed somewhere between whatever version the pod had pulled in March and 4.18.0.</p>
<p>The coturn ChangeLog has one line about DTLS in 4.17.0: "Start the DTLS listeners only when --dtls is given (#2026)". That pull request, merged 2026-08-02, made DTLS opt-in but kept <code>--no-dtls</code> as a deprecated alias that logs a warning. The source at the 4.17.0 tag still has the entry:</p>
<pre><code class="language-c">/* deprecated: */ {"no-dtls", optional_argument, NULL, NO_DTLS_OPT},
</code></pre>
<p>At the 4.18.0 tag the line is gone. The commit that removed it is <a href="https://github.com/coturn/coturn/pull/2079">coturn/coturn#2079</a>, merged 2026-09-07 22:43, whose title and ChangeLog entry read "Update help with missing arguments". The body says what it does: "Remove the deprecated <code>--no-dtls</code> and <code>--no-cli</code> turnserver flags — both were already no-ops". Thirteen hours later Docker Hub's <code>latest</code> moved to the image built from it. (The PR says it removed <code>--no-cli</code> too, but its diff only drops that flag from the help text and the test scripts, not from the option table; the 4.18.0 binary still accepts it and logs an <code>ERROR</code> line about it. More on that below.)</p>
<h2>When did it start?</h2>
<p>Prometheus on this cluster keeps 30 days. The oldest sample for the March pod's restart counter is from 10-12 11:41, and it already reads 4,014. From there the counter climbs by 280 or 281 a day, 284 or 285 on two days, until the eviction on 11-08. Condensed from a one-day-step range query:</p>
<pre><code>kube_pod_container_status_restarts_total{pod="dam-coturn-...-jgp4h"}
10-12   4,014
10-13   4,294     +280
10-14   4,574     +280
...
11-07  11,314
11-08  11,594
11-09  11,621     evicted 11-08 13:46; counter frozen
</code></pre>
<p>280 a day is one cycle every 308 s (86,400 ÷ 308 = 280.5): the five-minute back-off cap, plus about eight seconds for the kubelet to check the tag with the registry, create the container, watch it exit within a second, and notice. If the slope was the same before the retention window and the counter was near zero before that, 4,014 restarts take 14.3 days, which puts the first failure at about 2026-09-28, three weeks after <code>latest</code> changed. A few restarts could predate it; the date is an estimate. The hourly series in the chart below starts an hour later and reads 4,018; its last point for the current pod is 786, the last scrape before kubectl read 794.</p>
<p><img src="https://benchlet.com/guides/crashloopbackoff-coturn-unrecognized-option-no-dtls/restarts.png" alt="Restart counter of the three dam-coturn pods over the 30-day retention window, with the same slope continued back to zero at about 2026-09-28 and the 2026-09-08 marker where Docker Hub latest moved to 4.18.0" /></p>
<p>I cannot say why the container restarted on that day after running since 2026-03-20 with, as far as the counter arithmetic shows, no restarts before then. The node has rebooted since (current boot 11-10) and nothing older than 30 days is in Prometheus. One restart was enough: with <code>imagePullPolicy: Always</code> the kubelet asks the registry what the tag points to on every container start, restarts included, and downloads only if the digest changed. The events for the current pod show it, <code>Pulling image "coturn/coturn:latest"</code> 351 times in 29 hours, one per restart. From 09-08 on the answer to that pull was a binary that would not accept the Deployment's arguments.</p>
<p><img src="https://benchlet.com/guides/crashloopbackoff-coturn-unrecognized-option-no-dtls/timeline.png" alt="Two timelines, upstream and cluster, side by side: the flag deprecated in August and removed in September upstream; the pod started in March, did not restart until late September, and then pulled the new image" /></p>
<p>There was nothing to roll back to, either. The ReplicaSet is the one from March, so no <code>imageID</code> for the March container exists anywhere, and Prometheus does not keep container info that long. Which version had been running for six months is unknown.</p>
<h2>The fix</h2>
<p>One commit, which Argo CD applied within the minute:</p>
<pre><code class="language-yaml">spec:
  strategy:
    type: Recreate
  template:
    spec:
      containers:
        - name: coturn
          image: coturn/coturn:4.18.0@sha256:bbefd3e1fdfd...
          imagePullPolicy: IfNotPresent
          args:
            # --no-dtls removed
            - --no-multicast-peers
            - --denied-peer-ip=10.0.0.0-10.255.255.255
            - --denied-peer-ip=172.16.0.0-172.31.255.255
            - --denied-peer-ip=192.168.0.0-192.168.255.255
            - --denied-peer-ip=127.0.0.0-127.255.255.255
</code></pre>
<p>The pin is to the digest that was already on the node, so nothing new had to be pulled. The <code>denied-peer-ip</code> ranges were overdue regardless: this relay was coming back onto the internet after six weeks off, and a TURN server that will relay to private addresses, including the relay's own LAN, is a way into the network behind it.</p>
<p><code>Recreate</code> is there because of the host port. This Deployment uses <code>hostNetwork</code> and declares <code>hostPort: 3478</code> for UDP and TCP, and the pod is pinned to one node by a <code>nodeSelector</code>. I did not try a <code>RollingUpdate</code>, but the arithmetic says it would not have finished: with one replica, <code>maxUnavailable: 25%</code> rounds down to 0 and <code>maxSurge: 25%</code> rounds up to 1, so the controller has to create the new pod before it may delete the old one; the scheduler will not place a second pod that needs port 3478 on a node where a pod already holds it, crashing or not, and there is no other node it may use. The new pod would sit in <code>Pending</code> and the old one would keep crashing. <code>Recreate</code> deletes first. Every future rollout will have a gap of about 15 s (measured on the rollouts today); with one replica there was never going to be a rollout without one.</p>
<pre><code>$ kubectl -n dam-prod get events --sort-by=.lastTimestamp | grep coturn   (condensed)
Scaled down replica set dam-coturn-549854dfcb from 1 to 0
Container image "coturn/coturn:4.18.0@sha256:bbefd3e1fdfd..." already present on machine
Started container coturn
Scaled up replica set dam-coturn-6f99fd45d5 from 0 to 1

$ kubectl -n dam-prod logs deploy/dam-coturn | grep -E "Version|Black|Relay address"
INFO Black listing: 10.0.0.0-10.255.255.255
INFO Black listing: 172.16.0.0-172.31.255.255
INFO Black listing: 192.168.0.0-192.168.255.255
INFO Black listing: 127.0.0.0-127.255.255.255
INFO Relay address to use: 192.168.0.101
INFO Coturn Version Coturn-4.18.0 'Gorst'
</code></pre>
<p>A STUN Binding request to port 3478 got no reply at 11:42 and a <code>Binding Success</code> at 11:58:21, both from the LAN and through the router's public address. That checks that the server answers, not that it relays; I did not run an Allocate with the credentials.</p>
<p>Two lines in the new log needed a second commit. <code>ERROR no-cli option is deprecated, see --cli</code> means that flag will go the way <code>--no-dtls</code> went. And <code>WARNING NO EXPLICIT LISTENER ADDRESS(ES) ARE CONFIGURED</code> was followed by the server opening 3478 on every interface the host has, including the cluster's and the VPN's. The second commit dropped <code>--no-cli</code> and added <code>--listening-ip</code> for the one address the relay should bind; the next pod's log lists listeners on that address only. A third added the link-local, 100.64/10 and 0/8 ranges to the denied list, since the VPN gives this node an address in 100.64/10. One line, <code>ERROR CONFIG: Unknown argument:</code> with nothing after the colon, is still there and I have not found what produces it.</p>
<p>Fifteen minutes later neither <code>KubePodCrashLooping</code> nor <code>KubeDeploymentReplicasMismatch</code> was active for the namespace, in Prometheus or in Alertmanager:</p>
<p><img src="https://benchlet.com/guides/crashloopbackoff-coturn-unrecognized-option-no-dtls/alertmanager-after.png" alt="Alertmanager alert list after the fix: the two dam-prod groups are gone; KubeCPUOvercommit, KubeMemoryOvercommit, Watchdog and two KubeJobFailed remain" /></p>
<h2>Timeline</h2>
<table>
<thead>
<tr>
<th>When (UTC)</th>
<th>What</th>
</tr>
</thead>
<tbody><tr>
<td>2026-03-19</td>
<td>Deployment written: <code>coturn/coturn:latest</code>, <code>imagePullPolicy: Always</code>, <code>--no-dtls</code> in args</td>
</tr>
<tr>
<td>2026-03-20 15:58</td>
<td>Pod starts</td>
</tr>
<tr>
<td>2026-08-02</td>
<td>coturn PR #2026 merged (4.17.0): DTLS opt-in, <code>--no-dtls</code> deprecated but accepted</td>
</tr>
<tr>
<td>2026-09-07 22:43</td>
<td>coturn PR #2079 merged (4.18.0): <code>--no-dtls</code> removed. ChangeLog line: "Update help with missing arguments"</td>
</tr>
<tr>
<td>2026-09-08 11:42</td>
<td>Docker Hub <code>latest</code> → 4.18.0</td>
</tr>
<tr>
<td>≈ 2026-09-28</td>
<td>First restart (estimated from the counter slope). Kubelet pulls <code>latest</code>, binary rejects <code>--no-dtls</code>, exit 255. <code>CrashLoopBackOff</code> at ~280 a day from here on</td>
</tr>
<tr>
<td>2026-10-12</td>
<td>Oldest sample Prometheus still has: counter 4,014, <code>KubePodCrashLooping</code> already firing</td>
</tr>
<tr>
<td>2026-11-08 13:46</td>
<td>Node <code>DiskPressure</code>; pod evicted. Two replacements fail the same way</td>
</tr>
<tr>
<td>2026-11-11 05:08</td>
<td>Cluster audit lists the Deployment with 0 available and the log line</td>
</tr>
<tr>
<td>2026-11-11 11:57</td>
<td>Manifest commit: digest pin, flag removed, <code>Recreate</code>, denied peer ranges</td>
</tr>
<tr>
<td>2026-11-11 11:58</td>
<td>New pod running; STUN answers. About 45 days</td>
</tr>
<tr>
<td>2026-11-11 12:08</td>
<td>Second commit (12:00) rolled out: <code>--no-cli</code> gone, <code>--listening-ip</code> set. Third (12:19): more denied ranges</td>
</tr>
</tbody></table>
<h2>What I am changing</h2>
<p>The alert fired for the whole window. <code>KubePodCrashLooping</code> for this Deployment's pods is in every sample Prometheus has. In Prometheus the firing set on 10-12 and on 11-11 was the same list: five <code>CPUThrottlingHigh</code>, <code>KubeCPUOvercommit</code>, <code>KubeDeploymentReplicasMismatch</code>, two <code>KubeJobFailed</code>, <code>KubePodCrashLooping</code>, <code>KubeQuotaFullyUsed</code>, plus <code>KubeMemoryOvercommit</code> from 11-09. Alertmanager inhibits the throttling and quota ones, so what reached Discord was the overcommit alerts, the job failures and this crash loop, re-sent on the route's repeat interval. The crash-loop alert even arrived as a fresh notification three times, once per pod name, and was still not acted on. The previous incident's alert was new and went unnoticed among these standing ones. This one was one of the standing ones.</p>
<p>Done:</p>
<ul>
<li>Image pinned by digest, <code>imagePullPolicy: IfNotPresent</code>, <code>strategy: Recreate</code> for the host-network workload.</li>
<li><code>--no-dtls</code> and <code>--no-cli</code> gone; <code>--denied-peer-ip</code> for RFC 1918, loopback, link-local, 100.64/10 and 0/8, <code>--no-multicast-peers</code>, <code>--listening-ip</code>.</li>
</ul>
<p>Not done yet:</p>
<ul>
<li>The other 14 images on this cluster referenced by a mutable tag. Each gets the same treatment: digest, <code>IfNotPresent</code>, and a deliberate upgrade step instead of an accidental one.</li>
<li>The TURN credentials. The relay still authenticates with a static username and password that are compiled into the web client. The standard answer is <code>--use-auth-secret</code> with short-lived credentials minted by the signaling server, which is an application change, not a manifest change.</li>
<li>The standing alerts, again. Fix or silence each one so that a firing alert means something; page on <code>available == 0</code> for anything with a public hostname.</li>
<li>A startup-log check: grep the first lines of a new container's log for <code>ERROR</code> and <code>deprecated</code> and fail the rollout on a match.</li>
</ul>
<p>For any Deployment on a mutable tag that has been up for months, record what is actually running before anything restarts it: <code>kubectl get pod -o jsonpath='{.status.containerStatuses[*].imageID}'</code>. The tag in the manifest does not tell you.</p>
<p>Public post-incident reports that start the same way, a container restarting until something upstream of it is changed, are collected under the <a href="https://benchlet.com/incidentlib/app?symptom=crash-loop" target="_blank" rel="noopener">crash loop</a> symptom in Incident Library; the ones where an upgrade removed something the configuration relied on are under <a href="https://benchlet.com/incidentlib/app?root_cause=upgrade-breaking-change" target="_blank" rel="noopener">breaking change in an upgrade</a>.</p>
<hr />
<p><em>Originally published at <a href="https://benchlet.com/guides/crashloopbackoff-coturn-unrecognized-option-no-dtls/" target="_blank" rel="noopener">benchlet.com</a>.</em></p>
]]></content:encoded></item><item><title><![CDATA[ImagePullBackOff on an image nobody had touched since March]]></title><description><![CDATA[A registry GC with --delete-untagged removed OCI index children in March; the node cache hid it until a disk-pressure eviction in November.]]></description><link>https://kkhdevs.hashnode.dev/imagepullbackoff-on-an-image-nobody-had-touched-since-march</link><guid isPermaLink="true">https://kkhdevs.hashnode.dev/imagepullbackoff-on-an-image-nobody-had-touched-since-march</guid><category><![CDATA[Kubernetes]]></category><category><![CDATA[Devops]]></category><category><![CDATA[Docker]]></category><category><![CDATA[SRE]]></category><dc:creator><![CDATA[KIM KYUNGHWAN]]></dc:creator><pubDate>Wed, 11 Nov 2026 10:23:50 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6af44048178e74800edef134/e4332a81-b21e-454a-83e4-da4a98afdfae.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Our WebRTC signaling server was unavailable for 2 days, 17 hours and 55 minutes, from 2026-11-08 13:46 to 2026-11-11 07:41 UTC. The last commit to its code was on 2026-03-23 and the Deployment had pointed at the same image tag since then. The outage started with a disk filling up on the node. It lasted because of what a registry cron job had done to that image on 2026-03-24.</p>
<p>This is a two-node k3s cluster that I run alone for a few side projects. Times are UTC.</p>
<h2>What it looked like</h2>
<p>By the morning of 2026-11-11 the Deployment's ReplicaSet had three pods. The original, created in March, had ended on 2026-11-08 at 13:46:42 with exit code 0 and phase <code>Succeeded</code>. A replacement created one second later had never run and ended with <code>ContainerStatusUnknown</code>, exit code 137. A second replacement, created at 16:10:52, was <code>Pending</code> with this event repeating every five minutes:</p>
<pre><code>Failed to pull image "registry.example.com/dam-signaling:prod-&lt;sha&gt;":
  failed to pull and unpack image ...:
  .../manifests/sha256:211614369c9d... : not found
</code></pre>
<p>The audit tool I mention below counted 1,565 of these events in 24 hours on that node. On the registry side, the server log had 1,068 requests for that manifest digest since 2026-11-09 23:58, every one answered <code>404 manifest unknown</code>.</p>
<p>The usual causes of <code>manifest unknown</code> did not fit. The tag existed in the registry's filesystem. The same node had pulled other images from the same registry that afternoon. The Deployment had not changed since March.</p>
<h2>Finding it</h2>
<p>I found the outage on 2026-11-11 at 05:08, from a read-only audit of the cluster (<a href="https://github.com/KyungHwanKim-devs/kubegap" target="_blank" rel="noopener">kubegap</a>) that I had run for another reason. It listed the Deployment with zero available replicas and the pull failures next to it. The investigation then went through the disk, the registry, and the node cache, in that order.</p>
<h3>The disk</h3>
<p>The node's root filesystem is 106.7 GiB. On 2026-11-08 free space went from 19.4 GiB at 12:45 to 2.6 GiB at 13:48. The node reported <code>DiskPressure</code> from 13:47 to 14:10 and again from 16:10 to 16:20.</p>
<pre><code>node_filesystem_avail_bytes{mountpoint="/"}      GiB, one-minute samples
12:45  19.4
12:58   8.1
13:07  11.8
13:38  11.4
13:48   2.6     DiskPressure 13:47–14:10
13:56  14.4
14:05  27.3
14:18  13.3
</code></pre>
<p><img src="https://benchlet.com/guides/imagepullbackoff-manifest-unknown-registry-garbage-collection/disk.png" alt="Free space on the node root filesystem on 2026-11-08, one-minute samples, with the two DiskPressure windows shaded and the 5 % evictionHard line." /></p>
<p>I caused this. That afternoon I pushed 25 deploy commits for a new service, and one of them shipped a 4.5 GiB image with the service's ML models inside. The Deployment picked it up at 13:37 and the pod started at 13:40. The kubelet's <code>evictionHard</code> is <code>imagefs.available&lt;5%</code> and <code>nodefs.available&lt;5%</code>; on k3s with a single root filesystem those are the same disk. When a node crosses that threshold the kubelet first reclaims node-level resources, which for an image filesystem means deleting every image no container is using, and then evicts pods if it is still under. The jump from 2.6 GiB to 27.3 GiB free between 13:48 and 14:05 is consistent with that. By 16:15 the node's eviction counters read 23 for <code>imagefs</code> and 27 for <code>nodefs</code>, and 16 pods across the cluster carried the <code>Evicted</code> reason.</p>
<p>The signaling pod is not among those 16. It ended at 13:46:42, one minute before the <code>DiskPressure</code> condition was recorded, with exit code 0. That is not what an eviction looks like, and I could not read the kubelet journal on that node that day, so I do not know what stopped it. The sequence I can show is: the container stopped inside the disk-pressure window; the first replacement died during the second window; the second replacement needed an image that was no longer in the node's store.</p>
<p>The models-in-image problem got the obvious fix the same evening: models on a volume, 1.6 GiB image.</p>
<h3>The registry</h3>
<p>The pull was failing on a tag that had been pulled without trouble in March. The registry is <code>docker/distribution</code> 2.8.3 with filesystem storage, so I looked at the files:</p>
<pre><code>repositories/dam-signaling/_manifests/tags/prod-&lt;sha&gt;/current/link
  -&gt; sha256:447fa22a...      OCI image index, 2 children

children of the index
  sha256:211614369c9d...     linux/amd64 image manifest   (what containerd asked for)
  sha256:203e8548...         annotation vnd.docker.reference.type: attestation-manifest

repositories/dam-signaling/_manifests/revisions/sha256/
  447fa22a...                present
  211614369c9d...            missing
  203e8548...                missing
  (directory mtime 2026-03-24 04:00:01)

blobs referenced by the image manifest: 10
  present     4   (base layers shared with other images)
  missing     6   (config + 5 layers)
</code></pre>
<p>The tag pointed at an image index. The index was there. Both manifests the index pointed at had been unlinked from the repository, and 6 of the 10 blobs behind the platform manifest were gone. The revisions directory had last been written at 04:00:01 on 2026-03-24, the night after the push.</p>
<p>04:00 is when the registry's garbage-collection CronJob runs. From mid-March until 2026-06-02 that job ran <code>registry garbage-collect --delete-untagged=true</code>. I had turned the flag on to reclaim disk. I turned it off after another image broke, and did not go back to check what else the flag had removed.</p>
<p>What happens, in three parts:</p>
<ul>
<li>Since buildx 0.10 (January 2023; <code>docker/build-push-action</code> v4 picked it up), a push attaches a provenance attestation by default. Even for one platform the result is an OCI image index with two children, the platform manifest and the attestation manifest. The tag points at the index. The children have no tag of their own.</li>
<li>distribution 2.x's garbage collector with <code>--delete-untagged</code> enumerates manifests, keeps those a tag resolves to, and deletes the rest. It does not descend into an index. It therefore kept the index, deleted both children, and then deleted every blob that only the children referenced.</li>
<li>Afterwards the tag list is intact and the tag resolves to the index. A client that follows the index to the platform manifest gets <code>manifest unknown</code>.</li>
</ul>
<p><img src="https://benchlet.com/guides/imagepullbackoff-manifest-unknown-registry-garbage-collection/index.png" alt="Tag to OCI image index to its two children. The index stays; both children and six of ten blobs were removed by garbage collection with --delete-untagged." /></p>
<p>This was reported as <a href="https://github.com/distribution/distribution/issues/3004">distribution/distribution#3004</a> in 2019 and again as <a href="https://github.com/distribution/distribution/issues/3178">#3178</a> in 2020. The fix, <a href="https://github.com/distribution/distribution/pull/4285">#4285</a>, was merged in April 2024 and is only in the 3.x releases. 2.8.3, the last 2.x release, predates it.</p>
<h3>The node cache</h3>
<p>containerd on the node had the image, and with the default <code>imagePullPolicy: IfNotPresent</code> for a non-<code>latest</code> tag nothing asks the registry for an image that is already present. The pod restarted five times between March and November, most recently on 2026-10-28, each time from the local store. The first client to ask the registry for the platform manifest after March was the replacement pod on 2026-11-08, after the cached copy had been removed.</p>
<p>Knowing what to look for, I checked every image tag referenced by a running workload against the registry filesystem:</p>
<pre><code>images referenced by workloads                            40
complete                                                  34
broken (index kept, children unlinked, blobs missing)      6
</code></pre>
<p>All six had been pushed in March 2026 while the flag was on: four on the 14th between 19:02 and 19:14, two on the 23rd. Every image pushed after May was either a single manifest or an intact index. Five of the six broken ones were still running from the node's cache, with no pullable copy anywhere.</p>
<h2>The fix</h2>
<p>Rebuild from the same commit with two lines added to the workflow:</p>
<pre><code class="language-yaml">- uses: docker/build-push-action@v6
  with:
    provenance: false
    platforms: linux/amd64
</code></pre>
<p>With no attestation and one platform, buildx pushes a single <code>application/vnd.oci.image.manifest.v1+json</code>, and there is no index child for this collector to orphan. The new tag resolved to that manifest with every referenced blob present. The pod was ready at 07:41:37 on 2026-11-11.</p>
<p>Then the other five. Four of those repositories had CI that pushed to GitHub's container registry and edited manifest paths that no longer existed, so whatever had put their images into our registry in March, it was not the workflow in the repo. Three of the four built successfully and failed at the deploy step before I noticed. I pointed the four workflows at the real registry and the real manifest paths, rebuilt, and by 09:20 all six workloads were running from complete single-manifest images.</p>
<h2>Timeline</h2>
<table>
<thead>
<tr>
<th>When (UTC)</th>
<th>What</th>
</tr>
</thead>
<tbody><tr>
<td>2026-03-14 18:19</td>
<td>Registry GC CronJob created; <code>--delete-untagged=true</code> is in a commit dated the next day. Four of the six images that would break are pushed between 19:02 and 19:14</td>
</tr>
<tr>
<td>2026-03-23 13:59</td>
<td>Signaling image pushed as an OCI index with an attestation child; pod starts</td>
</tr>
<tr>
<td>2026-03-24 04:00</td>
<td>GC unlinks both children and deletes 6 of 10 blobs. Pod keeps running from the node's cache</td>
</tr>
<tr>
<td>2026-06-02</td>
<td>Flag removed from the live CronJob after a different image breaks. Existing damage not surveyed</td>
</tr>
<tr>
<td>2026-11-08 13:37</td>
<td>4.5 GiB image for another service deployed; pod starts 13:40</td>
</tr>
<tr>
<td>2026-11-08 13:46:42</td>
<td>Signaling pod ends, exit 0. First replacement created</td>
</tr>
<tr>
<td>2026-11-08 13:47</td>
<td><code>DiskPressure</code>. Image reclaim and evictions begin</td>
</tr>
<tr>
<td>2026-11-08 14:30</td>
<td><code>KubeDeploymentReplicasMismatch</code> for the Deployment starts firing</td>
</tr>
<tr>
<td>2026-11-08 16:10</td>
<td>Second <code>DiskPressure</code> window. First replacement fails (137); second replacement created, <code>ImagePullBackOff</code></td>
</tr>
<tr>
<td>2026-11-11 05:08</td>
<td>Cluster audit lists the unavailable Deployment and 1,565 pull failures</td>
</tr>
<tr>
<td>2026-11-11 07:41</td>
<td>Rebuilt image running. Outage 2 d 17 h 55 min</td>
</tr>
<tr>
<td>2026-11-11 09:20</td>
<td>Other five images rebuilt and rolled out</td>
</tr>
</tbody></table>
<h2>What I am changing</h2>
<p>The alert rule existed and fired. <code>KubeDeploymentReplicasMismatch</code> went pending at 14:00 and firing at 14:30 on 2026-11-08 and stayed firing until the fix. Alertmanager's route sends everything above <code>info</code> to a Slack channel and repeats every four hours. I have not gone back through the channel history, and I did not act on it. One week earlier, on 2026-11-01, that channel already had nine alerts firing at the same time, including a TURN server that the audit dates as crash-looping since September. The rule was there. The channel was not being read.</p>
<p>Done:</p>
<ul>
<li><code>provenance: false</code> and <code>platforms: linux/amd64</code> in the six workflows that push to this registry.</li>
<li>Models out of the image and onto a volume.</li>
<li>Four CI pipelines reconnected to the manifests they are meant to update.</li>
</ul>
<p>Not done yet:</p>
<ul>
<li>The remaining workflows in the organisation, or upgrade the registry to distribution 3.x, which handles index children in GC.</li>
<li>A daily job that walks every image tag referenced by a workload: resolve the tag, fetch the index, fetch each child, <code>HEAD</code> each blob. On a broken image the tag list and the index both look normal, so the check has to go down to the blobs.</li>
<li>Fix or silence the nine standing alerts. A page, not a Slack line, for <code>available == 0</code> lasting more than ten minutes on anything with a public hostname.</li>
<li><code>ephemeral-storage</code> requests on the one workload allowed to be large, and an alert on node disk under 15 %.</li>
<li>The registry's manifests under the same GitOps reconciliation as everything else. They are in git, but nothing applies them: the live CronJob was patched by hand on 2 June, two days after the commit that removed the flag.</li>
</ul>
<p>If you run, or have ever run, <code>docker/distribution</code> 2.x with <code>--delete-untagged</code>, check every multi-manifest image pushed in that window by asking for the platform manifest, not the tag. A running pod only proves that the node has the image.</p>
<p>Public post-incident reports where pods could not come back because the registry could not serve their image are collected under the <a href="https://benchlet.com/incidentlib/app?symptom=image-pull-failures" target="_blank" rel="noopener">image pull failures</a> symptom in Incident Library, searchable by the error text you saw.</p>
<hr />
<p><em>Originally published at <a href="https://benchlet.com/guides/imagepullbackoff-manifest-unknown-registry-garbage-collection/" target="_blank" rel="noopener">benchlet.com</a>.</em></p>
]]></content:encoded></item></channel></rss>