<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Jesica Kearney</title>
    <description>The latest articles on DEV Community by Jesica Kearney (@jesskearney).</description>
    <link>https://dev.to/jesskearney</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2867225%2F21d18fcf-424a-4fe0-a0ef-dddc2d1d05a5.jpg</url>
      <title>DEV Community: Jesica Kearney</title>
      <link>https://dev.to/jesskearney</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jesskearney"/>
    <language>en</language>
    <item>
      <title>I operate Karpenter every day. I wanted to see how its brain works, so I built a controller.</title>
      <dc:creator>Jesica Kearney</dc:creator>
      <pubDate>Fri, 02 Oct 2026 11:38:41 +0000</pubDate>
      <link>https://dev.to/jesskearney/i-operate-karpenter-every-day-i-wanted-to-see-how-its-brain-works-so-i-built-a-controller-55c0</link>
      <guid>https://dev.to/jesskearney/i-operate-karpenter-every-day-i-wanted-to-see-how-its-brain-works-so-i-built-a-controller-55c0</guid>
      <description>&lt;p&gt;I've been running EKS with Karpenter in production for a while. I can debug a node that won't provision, I know what a NodeClaim is, and I've written the Terraform that installs the whole thing.&lt;/p&gt;

&lt;p&gt;What I'd never done is look at the machinery. There's a gap between "pod is unschedulable" and "EC2 instance appears" where something is watching, deciding and acting, and I only knew it from the outside.&lt;/p&gt;

&lt;p&gt;This was pure curiosity. I wanted to see how the brain works.&lt;/p&gt;

&lt;p&gt;So I spent a couple of evenings building a controller with &lt;a href="https://github.com/kubernetes/client-go" rel="noopener noreferrer"&gt;client-go&lt;/a&gt; — the official Go library every Kubernetes component is built on, &lt;code&gt;kubectl&lt;/code&gt; and Karpenter included. No controller-runtime, no Kubebuilder, nowhere to hide. About 250 lines, running against a local kind cluster.&lt;/p&gt;

&lt;p&gt;It works. But the three things that went wrong taught me more than the working version did, and none of them are in the tutorials.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built
&lt;/h2&gt;

&lt;p&gt;A controller that watches nodes for a disruption taint and reports which pods on them &lt;strong&gt;won't come back&lt;/strong&gt; if the node dies.&lt;/p&gt;

&lt;p&gt;Not all pods are equal when a node goes away:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Owned by a ReplicaSet, StatefulSet or Job → rescheduled elsewhere, fine&lt;/li&gt;
&lt;li&gt;Owned by a DaemonSet → goes with the node, expected&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No controller owner → gone, permanently&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last case is what &lt;code&gt;kubectl drain&lt;/code&gt; refuses to evict without &lt;code&gt;--force&lt;/code&gt;. I wanted to surface it &lt;em&gt;before&lt;/em&gt; the drain, which is when it's actually useful.&lt;/p&gt;

&lt;p&gt;The shape is the same as every controller:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   API server
       │  WATCH (opened once, held open)
       ▼
  Node informer ────► local cache
       │
       │ AddFunc / UpdateFunc / DeleteFunc
       ▼
   enqueue(key)         "clientgo-control-plane"
       │
       ▼
  ┌─────────────┐
  │  workqueue  │  dedupes · rate limits · tracks in-flight
  └─────────────┘
       │
       ▼
  worker goroutine
       │
       ▼
  reconcile(key)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything left of &lt;code&gt;reconcile&lt;/code&gt; is plumbing. All the decisions live in about 40 lines inside it.&lt;/p&gt;

&lt;p&gt;Right — the bugs.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Four pods said their owner was the node
&lt;/h2&gt;

&lt;p&gt;First working version. I tainted the node and got every pod on it with its owner. Most were what you'd expect — ReplicaSets, DaemonSets. Then these:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;kube-system/etcd-clientgo-control-plane — owned by Node clientgo-control-plane
kube-system/kube-apiserver-clientgo-control-plane — owned by Node clientgo-control-plane
kube-system/kube-scheduler-clientgo-control-plane — owned by Node clientgo-control-plane
kube-system/kube-controller-manager-clientgo-control-plane — owned by Node clientgo-control-plane
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A Node isn't a workload controller. Nothing creates pods from a Node. I'd expected either a real owner or none at all.&lt;/p&gt;

&lt;p&gt;They're &lt;strong&gt;static pods&lt;/strong&gt;. The kubelet reads their manifests from &lt;code&gt;/etc/kubernetes/manifests/&lt;/code&gt; on local disk and runs them directly, with the API server not involved at all — which it has to, because you can't create the API server through the API server.&lt;/p&gt;

&lt;p&gt;The kubelet then creates a read-only &lt;strong&gt;mirror pod&lt;/strong&gt; in the API so the thing appears in &lt;code&gt;kubectl get pods&lt;/code&gt;, and sets the Node as its owner. The mirror pod is a reflection. Delete it and the kubelet puts it straight back.&lt;/p&gt;

&lt;p&gt;So my controller was about to judge whether something would be recreated, using an ownership model that doesn't describe how these pods live at all. They'd survive a kubelet restart. They wouldn't survive the node going away. Neither fact is visible in &lt;code&gt;ownerReferences&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I filtered them on the annotation the kubelet sets on every mirror pod:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;isMirror&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;pod&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Annotations&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"kubernetes.io/config.mirror"&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="n"&gt;isMirror&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;continue&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What I actually learned:&lt;/strong&gt; there are two lifecycles running on every node, and the API only shows you one. I'd read about static pods before. I'd never had a reason to care about the difference until my own code tried to reason about them with the wrong model.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. My controller could make a claim but not retract it
&lt;/h2&gt;

&lt;p&gt;Second version annotated at-risk pods so the finding outlived my terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;node-drain-controller/at-risk: clientgo-control-plane
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Worked nicely. Then I removed the taint, and the annotation stayed. Forever.&lt;/p&gt;

&lt;p&gt;The pod was now carrying a note saying it was at risk on a node that was completely healthy. Nothing was ever going to remove it.&lt;/p&gt;

&lt;p&gt;Here's the line that caused it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;taint&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;        &lt;span class="c"&gt;// "no taint, nothing to do"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I'd treated "this node has no taint" as &lt;strong&gt;nothing to do&lt;/strong&gt;. It isn't. It's information: &lt;em&gt;this node is healthy&lt;/em&gt;. And if it's healthy, anything on it claiming otherwise is wrong and needs correcting.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;taint&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;clearAnnotations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What I actually learned:&lt;/strong&gt; I'd written half a reconcile loop. A reconcile function isn't "do the thing when the trigger fires" — that's an event handler. It's &lt;em&gt;make reality match what it should be, whatever reality currently is&lt;/em&gt;. And that has to work in both directions, or the state you write drifts away from the truth and nobody can tell which annotations still mean anything.&lt;/p&gt;

&lt;p&gt;There's a subtlety in the cleanup too. It keys off the annotation I left behind, not the conditions I used when leaving it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;pod&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Annotations&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;riskAnnotation&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;continue&lt;/span&gt;        &lt;span class="c"&gt;// I never annotated this one&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If I later change &lt;em&gt;which&lt;/em&gt; pods get annotated, cleanup still finds everything I've ever written. Re-deriving the original condition would orphan state I could no longer locate — which is how controllers leave debris nobody can trace back to them.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Making it configurable broke state written under the old config
&lt;/h2&gt;

&lt;p&gt;Last addition: a CRD so the taint key wasn't hardcoded.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jk.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;DisruptionPolicy&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;taintKey&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;spot-interruption&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Changed it to &lt;code&gt;maintenance&lt;/code&gt; with &lt;code&gt;kubectl patch&lt;/code&gt;, restarted, and got:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;loaded policy: taintKey=maintenance annotate=true
caches synced — starting workers
cleared annotation on default/orphan — node no longer tainted
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The node still had &lt;code&gt;spot-interruption&lt;/code&gt; on it. The pod was still annotated under that key. But the controller was now looking for &lt;code&gt;maintenance&lt;/code&gt;, found none, concluded the node was fine, and cleaned up.&lt;/p&gt;

&lt;p&gt;Which is &lt;em&gt;correct&lt;/em&gt;, and I only got away with it because the bidirectional cleanup from bug 2 existed and the controller restarted and re-evaluated everything. If I'd implemented a live policy watch instead of reading at startup, the same change would have left stale annotations with no path back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I actually learned:&lt;/strong&gt; changing a controller's configuration invalidates state it wrote under the old configuration, and that's a design question, not an implementation detail. "Make the taint key configurable" sounds like a one-line change. It isn't. It means deciding what happens to everything you've already written.&lt;/p&gt;

&lt;p&gt;I left it reading at startup and wrote the limitation down, which I think is the right call — a half-built live watch would have been worse than an honest restart requirement.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one I didn't hit
&lt;/h2&gt;

&lt;p&gt;Worth mentioning because it's in a lot of tutorials.&lt;/p&gt;

&lt;p&gt;In the worker loop, this placement is wrong:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Controller&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;runWorker&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Done&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c"&gt;// ← never fires&lt;/span&gt;
        &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reconcile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;defer&lt;/code&gt; is &lt;strong&gt;function-scoped&lt;/strong&gt;, not block-scoped. &lt;code&gt;runWorker&lt;/code&gt; loops forever, so the defer doesn't run until shutdown. Every key stays marked in-flight permanently, the queue stops redelivering them, and the controller silently stops reconciling. No error. No log.&lt;/p&gt;

&lt;p&gt;Splitting the loop so each item gets its own function is what makes it fire:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Controller&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;runWorker&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;processNextItem&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Controller&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;processNextItem&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;shutdown&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;shutdown&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Done&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;       &lt;span class="c"&gt;// ← fires per item&lt;/span&gt;
    &lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I avoided this one because a commenter on the tutorial I was following had already caught it. I'd have written the broken version otherwise, and I don't think I'd have found it quickly — a controller that processes each object exactly once and then goes quiet looks a lot like a controller with nothing to do.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed about how I debug Karpenter
&lt;/h2&gt;

&lt;p&gt;This is the part I actually wanted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Memory sizing stopped being arbitrary.&lt;/strong&gt; Our Karpenter controller requests 2Gi. That's the informer caches — every Pod, Node, NodeClaim and NodePool held in RAM. When I size a controller now I'm sizing its caches, not guessing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The restart delay makes sense.&lt;/strong&gt; After a Karpenter restart there's a window where nothing provisions, plus a spike of API server load. That's every informer doing its initial LIST, and &lt;code&gt;WaitForCacheSync&lt;/code&gt; holding the workers back until it's done — because a controller acting on a half-populated cache is worse than one that's briefly idle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The "why is it doing nothing" question has a better answer.&lt;/strong&gt; Controllers are level-triggered: they react to what's true now, not to which event fired. A controller sitting silent is usually correct, not stuck. Mine runs reconcile several times a minute and almost always decides there's nothing to do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Our Terraform's ugliest code has a reason.&lt;/strong&gt; We have a destroy-time provisioner with a bash loop — &lt;code&gt;sleep 10&lt;/code&gt;, a counter, give up after 30 — waiting for NodeClaims to drain, and eventually force-stripping finalizers. I'd always read that as a hack. It is, but it's a &lt;em&gt;necessary&lt;/em&gt; one: Karpenter puts a finalizer on every Node so it can drain pods and terminate the EC2 instance before the object disappears, and Terraform has no way to wait on a watch. The workqueue does that properly — exponential backoff, per-key, non-blocking. The bash loop is the same idea with none of the machinery.&lt;/p&gt;

&lt;h2&gt;
  
  
  Would I use raw client-go again?
&lt;/h2&gt;

&lt;p&gt;No. For anything real I'd use controller-runtime — &lt;code&gt;SetupWithManager&lt;/code&gt; does five things in one line that took me two evenings, and it does them better.&lt;/p&gt;

&lt;p&gt;But I can now say what those five things are, and that's worth more to me than the two evenings. When something in Karpenter behaves oddly I'm no longer guessing at a black box; I'm reasoning about an informer, a queue and a reconcile loop I've written myself.&lt;/p&gt;

&lt;p&gt;If you operate Kubernetes and haven't written a controller, I'd recommend it on exactly those grounds. Not because you'll ship it. Because the next time you debug one, you'll know what's inside.&lt;/p&gt;

&lt;p&gt;I went in expecting something that remembers and reacts. What's actually there is something that forgets everything, looks at the world fresh, and fixes the difference. That's the whole trick.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Code is on &lt;a href="https://github.com/JayKearney/node-drain-controller" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. The README goes into the design decisions and the limitations in more detail.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>go</category>
      <category>devops</category>
      <category>learning</category>
    </item>
    <item>
      <title>What actually happens when a TLS certificate expires (and how I built a Bash guardrail for it)</title>
      <dc:creator>Jesica Kearney</dc:creator>
      <pubDate>Sat, 12 Sep 2026 17:51:20 +0000</pubDate>
      <link>https://dev.to/jesskearney/what-actually-happens-when-a-tls-certificate-expires-and-how-i-built-a-bash-guardrail-for-it-4m35</link>
      <guid>https://dev.to/jesskearney/what-actually-happens-when-a-tls-certificate-expires-and-how-i-built-a-bash-guardrail-for-it-4m35</guid>
      <description>&lt;p&gt;Every few months, somewhere, a website goes down for the most avoidable reason in infrastructure: a TLS certificate expired and nobody was watching. Browsers throw a full-screen red warning, API clients refuse to connect, and an on-call engineer gets paged for something a calendar reminder could have prevented.&lt;/p&gt;

&lt;p&gt;I've dealt with enough certificate issues in production to want a lightweight guardrail I fully control — so I built one in Bash, rather than reaching for a managed service that hides the mechanics. It's a small tool, but it's a good excuse to walk through what a certificate actually is and why expiry is only half the problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A certificate is a public identity document&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A website's certificate is public by design. When a client connects over HTTPS, the server presents its certificate to anyone who asks — before any encryption begins — because the certificate is how the server proves it is who it claims to be.&lt;/p&gt;

&lt;p&gt;You can see this yourself. This one command opens a TLS connection and reads the certificate's expiry date:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; | openssl s_client &lt;span class="nt"&gt;-servername&lt;/span&gt; github.com &lt;span class="nt"&gt;-connect&lt;/span&gt; github.com:443 2&amp;gt;/dev/null &lt;span class="se"&gt;\&lt;/span&gt;
  | openssl x509 &lt;span class="nt"&gt;-noout&lt;/span&gt; &lt;span class="nt"&gt;-enddate&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No login, no API key. The certificate carries only public metadata: the domain it was issued for, the issuing authority, the public key, and the validity window. The private key never leaves the server. That split is the whole basis of TLS — anyone can verify the server's identity, but only the server can prove it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The handshake, step by step&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Checking a certificate is just performing the opening moves of a normal HTTPS connection:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;TCP connect&lt;/strong&gt; to the host on port 443, exactly as a browser would.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ClientHello + SNI.&lt;/strong&gt; The client signals it wants to speak TLS, and the 
&lt;em&gt;-servername&lt;/em&gt; flag sets SNI (Server Name Indication) — because many sites share one IP address, SNI tells the server which site's certificate to present. Omit it and you can be handed the wrong certificate entirely.&lt;/li&gt;
&lt;li&gt;The server presents its certificate in the clear.&lt;/li&gt;
&lt;li&gt;Parse the &lt;em&gt;notAfter&lt;/em&gt; date and compute days remaining.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A browser would continue from here into an encrypted session; the check just reads the date and disconnects.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Expiry is only one of the checks&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Expiry is the obvious failure mode, but it's only one of several independent checks a browser runs. A certificate can be perfectly in-date and still be rejected — because it was issued for the wrong hostname.&lt;/p&gt;

&lt;p&gt;The clearest example is a wildcard-scope mismatch. A &lt;em&gt;*.badssl.com&lt;/em&gt; certificate is valid, but wildcards match only one level, so it does not cover &lt;em&gt;wrong.host.badssl.com.&lt;/em&gt; Other common causes in the wild:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Shared IP / wrong default cert&lt;/strong&gt; — a missing SNI default hands you another site's valid certificate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reused certificate&lt;/strong&gt; — a cert deployed onto the wrong host by mistake.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CDN / load-balancer gaps&lt;/strong&gt; — a new subdomain pointed at infrastructure whose certificate doesn't include it yet.
In every case the browser hard-blocks the connection, because a valid certificate belonging to someone else is exactly what a man-in-the-middle attack looks like. Expiry checks miss all of these, so hostname matching has to be a separate check:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bash
openssl s_client &lt;span class="nt"&gt;-servername&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$domain&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-connect&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$domain&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;:443 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-verify_hostname&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$domain&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This returns &lt;strong&gt;&lt;em&gt;Verify return code: 0 (ok)&lt;/em&gt;&lt;/strong&gt; on a match, or &lt;strong&gt;&lt;em&gt;62 (hostname mismatch)&lt;/em&gt;&lt;/strong&gt; when the certificate is real but wrong for that host.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Turning a script into infrastructure&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A script that prints text is a toy. What makes it usable by real systems is exit codes. This one follows the Nagios convention:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Code    Meaning
0   All certificates OK
1   Warning window, or a hostname mismatch
2   Critical, expired, or unreachable
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That single design choice is what lets a scheduler act on the result without parsing any text. Wired to a &lt;em&gt;GitHub Actions cron&lt;/em&gt;, it needs no extra tooling: because the script exits non-zero on trouble, a failing certificate turns the workflow run red — and that red status is the alert.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why build what you could buy&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Some companies offer certificate monitoring as a checkbox, so building it by hand isn't about saving money. The value is owning every layer under that checkbox — &lt;strong&gt;the handshake, SNI, the public/private key split, why hostname verification is separate from expiry, and how exit codes turn a script into something a monitoring system can consume&lt;/strong&gt;. When the managed version misbehaves, that's the difference between debugging it and filing a support ticket.&lt;/p&gt;

&lt;p&gt;The full tool — including a &lt;strong&gt;companion process/inode inspector&lt;/strong&gt; — is on GitHub:[&lt;a href="https://github.com/JayKearney/linux-toolbox" rel="noopener noreferrer"&gt;https://github.com/JayKearney/linux-toolbox&lt;/a&gt;]. Feedback and questions welcome.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>bash</category>
      <category>security</category>
      <category>kubernetes</category>
    </item>
  </channel>
</rss>
