<?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: Le Beltagy</title>
    <description>The latest articles on DEV Community by Le Beltagy (@le_beltagy).</description>
    <link>https://dev.to/le_beltagy</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%2F1898135%2F5ef7f7dd-7439-4956-856d-fc2c3a00f2ac.jpeg</url>
      <title>DEV Community: Le Beltagy</title>
      <link>https://dev.to/le_beltagy</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/le_beltagy"/>
    <language>en</language>
    <item>
      <title>Iran Doesn't Need to Mine Hormuz — Your requirements.txt Is Already Rigged</title>
      <dc:creator>Le Beltagy</dc:creator>
      <pubDate>Fri, 21 Aug 2026 12:46:40 +0000</pubDate>
      <link>https://dev.to/le_beltagy/iran-doesnt-need-to-mine-hormuz-your-requirementstxt-is-already-rigged-1c55</link>
      <guid>https://dev.to/le_beltagy/iran-doesnt-need-to-mine-hormuz-your-requirementstxt-is-already-rigged-1c55</guid>
      <description>&lt;h1&gt;
  
  
  Iran Doesn't Need to Mine Hormuz — Your requirements.txt Is Already Rigged
&lt;/h1&gt;

&lt;p&gt;Every headline you've read this week is a diversion.&lt;/p&gt;

&lt;p&gt;The Strait of Hormuz is not the target. &lt;strong&gt;You are.&lt;/strong&gt; And you have been for months, possibly years, while you retweeted tanker tracking maps and debated whether Brent crude would touch $150.&lt;/p&gt;

&lt;p&gt;Iranian state-sponsored groups — OilRig, APT33, MuddyWater, Agrius — did not spend the last decade pivoting to cloud infrastructure so they could watch you panic about a waterway. They did it so they could own your build pipeline while you were distracted. And they have.&lt;/p&gt;

&lt;p&gt;This is not speculation. CISA Advisory AA24-038A explicitly maps Iranian APT campaigns against U.S. and allied critical infrastructure to &lt;strong&gt;cloud identity, Kubernetes targets, and software supply chains.&lt;/strong&gt; Not SCADA. Not PLCs. Your &lt;code&gt;kubectl&lt;/code&gt; binary. Your Helm charts. That FastAPI microservice running payment webhooks that you deployed on a Friday and haven't touched since March.&lt;/p&gt;

&lt;p&gt;The Revolutionary Guard does not need a mine. They need a maintainer who hasn't updated &lt;code&gt;python-jose&lt;/code&gt; in fourteen months.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Theater and the Operation
&lt;/h2&gt;

&lt;p&gt;You watched the Strait. They watched your CI/CD.&lt;/p&gt;

&lt;p&gt;Geopolitical analysis is a spectator sport for infrastructure engineers, and Iranian cyber command is the bookie. While your LinkedIn feed filled with satellite imagery and retired admirals explained chokepoint logistics, the actual operation ran silently against:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Public Helm charts&lt;/strong&gt; with hardcoded &lt;code&gt;cluster-admin&lt;/code&gt; &lt;code&gt;ServiceAccounts&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FastAPI services&lt;/strong&gt; with &lt;code&gt;python-multipart&lt;/code&gt; handling unbounded file uploads on single-threaded Uvicorn workers&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;.kube/config&lt;/code&gt; files&lt;/strong&gt; exfiltrated from developer laptops in a &lt;code&gt;dev-legacy&lt;/code&gt; namespace that predates your current CTO&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Terraform state&lt;/strong&gt; stored in a single S3 bucket with versioning disabled and a policy written by someone who left in 2021&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Hormuz closure narrative is &lt;strong&gt;Information Operations&lt;/strong&gt;. The closure of your API gateway due to an unpatched ASGI memory exhaustion vulnerability is the kinetic effect. You are living in the IO campaign already. You just don't know which side you're on.&lt;/p&gt;




&lt;h2&gt;
  
  
  Your FastAPI Service Is the Chokepoint
&lt;/h2&gt;

&lt;p&gt;Hormuz moves 21 million barrels per day through a channel so narrow tankers touch mud on both sides. It has done this for five millennia because there is no alternative.&lt;/p&gt;

&lt;p&gt;Your FastAPI gateway moves 100% of your card-processing traffic through a single async event loop that deadlocks when someone uploads a malformed multipart boundary. You built it this way because a tutorial told you &lt;code&gt;async def&lt;/code&gt; was "fast." You didn't install rate limiting. You didn't bound the upload size. You certainly didn't audit whether the &lt;code&gt;starlette&lt;/code&gt; version you pinned in 2022 has a known memory leak under nested multipart headers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This is the strait.&lt;/strong&gt; This is the geography of your revenue. And it is defended by exactly one Uvicorn worker pool with zero circuit breakers and a restart policy of &lt;code&gt;Always&lt;/code&gt; that simply reintroduces the same deadlock on boot.&lt;/p&gt;

&lt;p&gt;Iranian speedboats swarm tankers to force asymmetric naval commitments. A single slow POST request with a nested &lt;code&gt;Content-Type: multipart/form-data; boundary=----&lt;/code&gt; string does the same to your payments service. You don't need a navy. You need twelve lines of Python and a botnet rented for $40 an hour.&lt;/p&gt;

&lt;p&gt;The strait is geology. Your API is architecture. One has survived empires. The other cannot survive a penetration test.&lt;/p&gt;




&lt;h2&gt;
  
  
  They Don't Breach Clusters. They Walk Through Wildcards.
&lt;/h2&gt;

&lt;p&gt;The K8s compromise is not a Hollywood hack. It is a &lt;strong&gt;privilege escalation through accumulated laziness.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;APT operators do not need a zero-day in the Kubernetes API server. They need your &lt;code&gt;dev-legacy&lt;/code&gt; namespace to have a &lt;code&gt;RoleBinding&lt;/code&gt; to a &lt;code&gt;ClusterRole&lt;/code&gt; with wildcards on &lt;code&gt;pods&lt;/code&gt; and &lt;code&gt;pods/exec&lt;/code&gt;. They need a &lt;code&gt;ServiceAccount&lt;/code&gt; in your monitoring namespace with &lt;code&gt;automountServiceAccountToken: true&lt;/code&gt; that mounts a token readable by any pod compromise. They need your etcd backups stored in the same project as your public-facing documentation.&lt;/p&gt;

&lt;p&gt;I built &lt;code&gt;kube-radar&lt;/code&gt; to find this exact rot. Here is what it sees in enterprise clusters every single deployment:&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="c1"&gt;# Wildcard verb on core resources&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;apiGroups&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;verbs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="c1"&gt;# cluster-admin bound to a namespace labeled "team-backend"&lt;/span&gt;
&lt;span class="na"&gt;roleRef&lt;/span&gt;&lt;span class="pi"&gt;:&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;ClusterRole&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;cluster-admin&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is not a vulnerability. This is &lt;strong&gt;surrender encoded in YAML.&lt;/strong&gt; You handed them the keys and labeled the namespace so they knew where to start.&lt;/p&gt;

&lt;p&gt;The real minefield is not in the Persian Gulf. It is in your RBAC manifest, and every wildcard &lt;code&gt;*&lt;/code&gt; is another buoy in the channel marking safe passage for an adversary.&lt;/p&gt;




&lt;h2&gt;
  
  
  Your Cloud Resilience Is a Puppet Show
&lt;/h2&gt;

&lt;p&gt;You will tell me you are resilient. You run three clouds. You have multi-region. You have a CNAPP dashboard that shows your posture as green.&lt;/p&gt;

&lt;p&gt;Here is what that theater costs you.&lt;/p&gt;

&lt;p&gt;Your control plane is regional. Your etcd is zonal. Your disaster recovery is a Terraform state file in a single S3 bucket with MFA delete disabled because someone on-call needed to push a hotfix at 3 AM and the screaming customer mattered more than the recovery architecture. You pay the Hormuz premium in cloud spend — 2.4 million dollars for redundancy you have never actually tested under adversarial conditions.&lt;/p&gt;

&lt;p&gt;Wiz will find your public S3 bucket. It will not find the Iranian operator authenticating with a stolen Azure AD refresh token that grants them &lt;code&gt;Contributor&lt;/code&gt; on the subscription hosting your AKS cluster. CNAPP tools map misconfigurations. They do not map &lt;strong&gt;compromise&lt;/strong&gt;. The mine is already inside the hull. Your cloud security posture management is counting deck chairs.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Real Strait Closure
&lt;/h2&gt;

&lt;p&gt;If Hormuz closes, the market reacts. Oil spikes. Insurance rates triple. The U.S. Fifth Fleet responds. It is visible. It is measurable. It is &lt;em&gt;managed&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;If your Kubernetes control plane is compromised by a supply-chain poisoned through a PyPI dependency, there is no Fifth Fleet. There is a Slack thread. There is a post-mortem. There is a CEO asking why the payments API returned 502s for six hours while the SOC triaged alerts from a cluster that was already exporting cronjob outputs to a S3 bucket in &lt;code&gt;eu-central-1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The strait has naval escorts, maritime insurance, and five thousand years of contested history teaching it how to survive closure.&lt;/p&gt;

&lt;p&gt;Your cluster has a Terraform module maintained by an intern who left and a &lt;code&gt;TODO: fix RBAC&lt;/code&gt; comment from 2021.&lt;/p&gt;




&lt;h2&gt;
  
  
  What You Do Now
&lt;/h2&gt;

&lt;p&gt;Stop reading about Hormuz and audit your blast radius.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Freeze your FastAPI dependencies.&lt;/strong&gt; Audit &lt;code&gt;python-multipart&lt;/code&gt;, &lt;code&gt;starlette&lt;/code&gt;, &lt;code&gt;uvicorn&lt;/code&gt;, &lt;code&gt;python-jose&lt;/code&gt;. Verify checksums. Consider whether you can survive a single slowloris POST against your ASGI layer. If the answer is unknown, the answer is no.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Delete your RBAC wildcards.&lt;/strong&gt; Run &lt;code&gt;kube-radar&lt;/code&gt; or equivalent. If a &lt;code&gt;RoleBinding&lt;/code&gt; or &lt;code&gt;ClusterRole&lt;/code&gt; has &lt;code&gt;*&lt;/code&gt; on verbs or resources, treat it as an active compromise in progress. Because it is.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Separate your control plane from your workload identity.&lt;/strong&gt; No &lt;code&gt;cluster-admin&lt;/code&gt; for CI/CD. No long-lived &lt;code&gt;kubeconfig&lt;/code&gt; on developer machines. Use short-lived tokens, node attestation, and Cilium identity-aware microsegmentation.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Test your failover under fire.&lt;/strong&gt; Multicloud means two control planes with independent blast radiuses, not one control plane mirrored to two regions for the price of two. If an attacker owns your GitHub org and your Terraform state, they own both clouds simultaneously. Architect for that.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The Revolutionary Guard is not studying tanker routes. They are studying your GitHub org.&lt;/p&gt;

&lt;p&gt;Hormuz will survive the next decade of conflict. It is a rock formation.&lt;/p&gt;

&lt;p&gt;Your microservices architecture will not survive the next unpatched dependency.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Close the strait in your own stack before they do it for you.&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Author:&lt;/strong&gt; Mohamed ElBeltagy (&lt;a href="https://github.com/beltagyy" rel="noopener noreferrer"&gt;@beltagyy&lt;/a&gt;) — Cloud Security &amp;amp; K8s engineer at Siemens, creator of &lt;a href="https://github.com/beltagyy/kube-radar" rel="noopener noreferrer"&gt;kube-radar&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read more:&lt;/strong&gt; &lt;a href="https://dev.to/le_beltagy"&gt;dev.to/le_beltagy&lt;/a&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>security</category>
      <category>fastapi</category>
      <category>cloudsecurity</category>
    </item>
    <item>
      <title>I Deliberately Destroyed My Kubernetes Cluster at 2 AM. Here's What Died First.</title>
      <dc:creator>Le Beltagy</dc:creator>
      <pubDate>Wed, 19 Aug 2026 15:40:39 +0000</pubDate>
      <link>https://dev.to/le_beltagy/i-deliberately-destroyed-my-kubernetes-cluster-at-2-am-heres-what-died-first-40no</link>
      <guid>https://dev.to/le_beltagy/i-deliberately-destroyed-my-kubernetes-cluster-at-2-am-heres-what-died-first-40no</guid>
      <description>&lt;h1&gt;
  
  
  I Deliberately Destroyed My Kubernetes Cluster at 2 AM. Here's What Died First.
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;Chaos engineering is not about breaking things. It's about discovering that your "production-grade" homelab is held together by hope and a single etcd snapshot before someone else finds out for you.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;p&gt;I was lying in bed at 1:47 AM, staring at the ceiling, unable to sleep.&lt;/p&gt;

&lt;p&gt;Not because of caffeine. Because of a thought that had been gnawing at me for weeks:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;If one of my nodes died right now, would my cluster actually survive?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I run a 4-node bare-metal Kubernetes cluster on Talos Linux. Dell OptiPlex control plane. Three Raspberry Pi workers. Cilium eBPF. ArgoCD. Longhorn distributed storage. Prometheus. Grafana. The whole cloud-native stack, shoehorned into $220 of scrap hardware and stubbornness.&lt;/p&gt;

&lt;p&gt;From the outside, it looks solid. ArgoCD syncs green. Cilium status shows healthy. Longhorn volumes are replicated across three nodes. I have etcd snapshots every 6 hours to S3. On paper, I'm resilient.&lt;/p&gt;

&lt;p&gt;But I had never actually tested it.&lt;/p&gt;

&lt;p&gt;Not a controlled test. Not a graceful node drain. I mean &lt;strong&gt;chaos&lt;/strong&gt;. Sudden death. The kind of failure that happens at 3 AM when a power supply dies, or a kernel panics, or a neighbor's construction crew hits the wrong breaker.&lt;/p&gt;

&lt;p&gt;So I got out of bed, walked to my desk, and installed Chaos Mesh.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Chaos Engineering on a Homelab?
&lt;/h2&gt;

&lt;p&gt;Professionally, I design AWS infrastructure with multi-AZ failover, auto-scaling groups, and managed services that abstract failure away. At Siemens, if an EKS node dies, the managed node group replaces it before I finish reading the alert.&lt;/p&gt;

&lt;p&gt;But my homelab has no managed control plane. No AWS SLA. No auto-repair. If a Pi's USB boot drive corrupts, that node is gone until I physically fix it.&lt;/p&gt;

&lt;p&gt;I needed to know:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;What dies first when a worker vanishes?&lt;/strong&gt; Not "what should die" — what &lt;em&gt;actually&lt;/em&gt; dies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does Longhorn really failover?&lt;/strong&gt; Three replicas sound great until you realize two of them were on the same node.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does Cilium handle network partitions?&lt;/strong&gt; Or does it just... stop routing?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How long does ArgoCD stay useful?&lt;/strong&gt; If the control plane loses the GitOps controller, can I still reason about state?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What's my actual MTTR?&lt;/strong&gt; Mean Time To Recovery — not theoretical, measured with a stopwatch and cold sweat.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I wasn't planning to learn. I was planning to find out.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Weapon
&lt;/h2&gt;

&lt;p&gt;I installed Chaos Mesh directly into the cluster:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm repo add chaos-mesh https://charts.chaos-mesh.org
helm &lt;span class="nb"&gt;install &lt;/span&gt;chaos-mesh chaos-mesh/chaos-mesh &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; chaos-testing &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--create-namespace&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; chaosDaemon.runtime&lt;span class="o"&gt;=&lt;/span&gt;containerd &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; chaosDaemon.socketPath&lt;span class="o"&gt;=&lt;/span&gt;/run/containerd/containerd.sock
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Chaos Mesh is a CNCF sandbox project that injects failure into Kubernetes. It can kill pods, stress CPU, corrupt networks, simulate disk failures, and even tamper with DNS. It runs as controllers inside your cluster — the failures are real, not simulated.&lt;/p&gt;

&lt;p&gt;I spend 20 minutes reading the docs. Then I stop reading and start attacking.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attack 1: Pod Chaos (Kill Random Pods Every 30 Seconds)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Target:&lt;/strong&gt; All pods in the &lt;code&gt;production&lt;/code&gt; namespace (where I run kube-radar, job-digest, MarketPulse API, and PostgreSQL)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Experiment:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;chaos-mesh.org/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;PodChaos&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;random-pod-killer&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;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pod-kill&lt;/span&gt;
  &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;all&lt;/span&gt;
  &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;5m"&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;namespaces&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;production&lt;/span&gt;
  &lt;span class="na"&gt;scheduler&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;@every&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;30s"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What I expected:&lt;/strong&gt; Pods restart. Kubernetes self-heals. ArgoCD shows sync. Business as usual.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What actually happened:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;th&gt;Event&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;T+0s&lt;/td&gt;
&lt;td&gt;PostgreSQL pod killed. StatefulSet recreates it in 8 seconds.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;T+30s&lt;/td&gt;
&lt;td&gt;MarketPulse API pod killed. Deployment recreates it in 4 seconds. No impact — I have 2 replicas.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;T+60s&lt;/td&gt;
&lt;td&gt;Prometheus pod killed. Data loss: the last 15 minutes of scrape data vanished because I use &lt;code&gt;emptyDir&lt;/code&gt; for Prometheus storage.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;T+90s&lt;/td&gt;
&lt;td&gt;kube-radar pod killed. No impact — it's a CLI CronJob, not a long-running service.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;T+120s&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;PostgreSQL killed again.&lt;/strong&gt; This time, the new pod schedules to &lt;code&gt;rpi-03&lt;/code&gt;, which already has a Longhorn replica. But the active replica was on &lt;code&gt;rpi-01&lt;/code&gt;. Longhorn takes 34 seconds to failover and attach the volume to &lt;code&gt;rpi-03&lt;/code&gt;. During those 34 seconds, MarketPulse API returns &lt;code&gt;500&lt;/code&gt; because it can't reach the database.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;T+5m&lt;/td&gt;
&lt;td&gt;Experiment ends. I have 6 minutes of missing Prometheus data and a database failover slower than my patience.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Lesson 1:&lt;/strong&gt; Kubernetes recreates pods fast. Stateful workloads don't. A StatefulSet restart plus volume re-attachment is an eternity for a dependent service.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attack 2: Network Partition (Isolate One Worker from the Control Plane)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Target:&lt;/strong&gt; &lt;code&gt;rpi-02&lt;/code&gt; — completely isolated from all other nodes&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Experiment:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;chaos-mesh.org/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;NetworkChaos&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;partition-rpi-02&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;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;partition&lt;/span&gt;
  &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;all&lt;/span&gt;
  &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3m"&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;nodes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;rpi-02&lt;/span&gt;
  &lt;span class="na"&gt;direction&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;both&lt;/span&gt;
  &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;namespaces&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;kube-system&lt;/span&gt;
    &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;all&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What I expected:&lt;/strong&gt; &lt;code&gt;rpi-02&lt;/code&gt; goes NotReady. Workloads on it get rescheduled. Cilium handles the network split gracefully.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What actually happened:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;th&gt;Event&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;T+0s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;T+15s&lt;/td&gt;
&lt;td&gt;Cilium on &lt;code&gt;rpi-02&lt;/code&gt; starts logging &lt;code&gt;endpoint regeneration failure&lt;/code&gt;. It's trying to sync BPF maps with the Cilium operator but can't reach it.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;T+45s&lt;/td&gt;
&lt;td&gt;The node finally transitions to &lt;code&gt;NotReady&lt;/code&gt; because the kubelet's node lease can't be renewed. But the 45-second grace period means pods on &lt;code&gt;rpi-02&lt;/code&gt; are still considered "running" by the scheduler.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;T+60s&lt;/td&gt;
&lt;td&gt;A Longhorn volume with replicas on &lt;code&gt;rpi-02&lt;/code&gt; and &lt;code&gt;rpi-03&lt;/code&gt; loses quorum. Longhorn marks the volume as &lt;code&gt;Degraded&lt;/code&gt;. Read operations still work. Write operations hang.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;T+90s&lt;/td&gt;
&lt;td&gt;MarketPulse API (running on &lt;code&gt;rpi-01&lt;/code&gt;) tries to write to PostgreSQL. The write hangs because the database volume is degraded. The Go HTTP server goroutine blocks. After 30 seconds, the client times out.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;T+120s&lt;/td&gt;
&lt;td&gt;Prometheus can't scrape metrics from pods on &lt;code&gt;rpi-02&lt;/code&gt; because they're network-isolated. It marks them as &lt;code&gt;DOWN&lt;/code&gt;. My Grafana dashboard looks like a massacre.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;T+3m&lt;/td&gt;
&lt;td&gt;Partition removed. Cilium takes 18 seconds to rebuild BPF maps. Longhorn takes 47 seconds to re-replicate the missing replica. The cluster recovers. But the 3-minute window had 2 database write timeouts and 1 failed API request.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Lesson 2:&lt;/strong&gt; Network partition + StatefulSet + distributed storage = distributed deadlock. The node stays Ready long enough to break things but not long enough to migrate workloads cleanly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attack 3: Node Chaos (Kill a Worker Node Entirely)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Target:&lt;/strong&gt; &lt;code&gt;rpi-01&lt;/code&gt; — simulate total node death (power loss)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Experiment:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Physical simulation: pull the power cable on rpi-01&lt;/span&gt;
&lt;span class="c"&gt;# (Yes, I literally unplugged it. This is why I test at 2 AM.)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What I expected:&lt;/strong&gt; Node goes NotReady after 40 seconds. Pods reschedule. Longhorn promotes the other replica to primary. Service continues.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What actually happened:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;th&gt;Event&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;T+0s&lt;/td&gt;
&lt;td&gt;Power cable pulled. &lt;code&gt;rpi-01&lt;/code&gt; is dead. No graceful shutdown. No disk sync.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;T+5s&lt;/td&gt;
&lt;td&gt;Talos on the control plane detects the node is unresponsive. But the node controller waits for the default &lt;code&gt;--node-monitor-grace-period=40s&lt;/code&gt; before marking it NotReady.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;T+30s&lt;/td&gt;
&lt;td&gt;During those 30 seconds, kube-proxy (Cilium eBPF) still routes traffic to pods on &lt;code&gt;rpi-01&lt;/code&gt; because the endpoint slices haven't been updated yet. Requests to MarketPulse API hit a dead pod IP and time out.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;T+40s&lt;/td&gt;
&lt;td&gt;Node finally marked &lt;code&gt;NotReady&lt;/code&gt;. Pod eviction starts. The eviction timeout is another 5 minutes by default. I didn't change it. I'm an idiot.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;T+45s&lt;/td&gt;
&lt;td&gt;Longhorn detects the node is gone. It has replicas on &lt;code&gt;rpi-02&lt;/code&gt; and &lt;code&gt;rpi-03&lt;/code&gt;. It promotes the &lt;code&gt;rpi-02&lt;/code&gt; replica to primary. But PostgreSQL was the active workload on &lt;code&gt;rpi-01&lt;/code&gt; — the pod hasn't been rescheduled yet because eviction hasn't timed out.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;T+2m&lt;/td&gt;
&lt;td&gt;I manually delete the PostgreSQL pod on &lt;code&gt;rpi-01&lt;/code&gt; (it's stuck in Terminating because the node is dead). The StatefulSet creates a new pod on &lt;code&gt;rpi-02&lt;/code&gt;. Longhorn attaches the volume. PostgreSQL starts crash recovery because the previous instance died uncleanly.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;T+3m30s&lt;/td&gt;
&lt;td&gt;PostgreSQL finishes WAL replay. MarketPulse API reconnects. My SIEM shows 3.5 minutes of total database unavailability.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;T+5m&lt;/td&gt;
&lt;td&gt;Kubernetes finally evicts all remaining pods from &lt;code&gt;rpi-01&lt;/code&gt; automatically. They're already rescheduled by now because I intervened manually. The automatic eviction did nothing useful.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Lesson 3:&lt;/strong&gt; Default Kubernetes timeouts are designed for cloud nodes that recover. For bare metal, 40 seconds + 5 minutes is an eternity. You need to tune &lt;code&gt;--node-monitor-grace-period&lt;/code&gt; and &lt;code&gt;pod-eviction-timeout&lt;/code&gt; to match your hardware reality.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attack 4: Stress Chaos (CPU Burn on the Control Plane)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Target:&lt;/strong&gt; Dell OptiPlex control plane — 100% CPU for 2 minutes&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Experiment:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;chaos-mesh.org/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;StressChaos&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;cpu-burn-control-plane&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;duration&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2m"&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;nodes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;optiplex-control-plane&lt;/span&gt;
  &lt;span class="na"&gt;stressors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;workers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4&lt;/span&gt;
      &lt;span class="na"&gt;load&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What I expected:&lt;/strong&gt; Control plane slows down. API latency increases. But the cluster stays functional.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What actually happened:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;th&gt;Event&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;T+0s&lt;/td&gt;
&lt;td&gt;CPU stress applied. 4 workers pinned to 100%.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;T+15s&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;kubectl get nodes&lt;/code&gt; latency jumps from 200ms to 4.2 seconds.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;T+30s&lt;/td&gt;
&lt;td&gt;etcd starts logging &lt;code&gt;apply request took too long&lt;/code&gt;. The WAL fsync is competing with stress-ng for disk I/O.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;T+60s&lt;/td&gt;
&lt;td&gt;Cilium agent on the control plane can't reach the API server fast enough for endpoint updates. It logs &lt;code&gt;list-watcher timed out&lt;/code&gt;. Pods on other nodes start losing network identity.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;T+75s&lt;/td&gt;
&lt;td&gt;ArgoCD application controller crashes with a context deadline exceeded error. It was trying to list resources and gave up. When it restarts, it re-triggers a full sync of all 45 manifests.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;T+90s&lt;/td&gt;
&lt;td&gt;Longhorn manager on the control plane can't renew its leader election lease. It steps down. The new leader takes 12 seconds to take over. During those 12 seconds, volume attachment requests queue up.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;T+2m&lt;/td&gt;
&lt;td&gt;Stress removed. CPU returns to normal. But ArgoCD is still syncing. Longhorn is still re-electing. etcd has a 200MB backlog of WAL entries to compact. It takes 90 seconds for the cluster to feel "normal" again.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Lesson 4:&lt;/strong&gt; The control plane is not infinitely resilient. On bare metal with no CPU limits, a single noisy process can cascade through etcd → API server → Cilium → ArgoCD → Longhorn. In the cloud, this doesn't happen because the control plane is managed and isolated. On bare metal, you are the SRE.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attack 5: DNS Chaos (Corrupt CoreDNS Responses)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Target:&lt;/strong&gt; CoreDNS in &lt;code&gt;kube-system&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Experiment:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;chaos-mesh.org/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;DNSChaos&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;corrupt-dns&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;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;error&lt;/span&gt;
  &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;all&lt;/span&gt;
  &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2m"&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;namespaces&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;kube-system&lt;/span&gt;
  &lt;span class="na"&gt;scope&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;inner&lt;/span&gt;
  &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;all&lt;/span&gt;
    &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;namespaces&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;production&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What I expected:&lt;/strong&gt; DNS failures. Services can't resolve hostnames. Health checks fail.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What actually happened:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;th&gt;Event&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;T+0s&lt;/td&gt;
&lt;td&gt;DNS chaos applied. CoreDNS returns &lt;code&gt;NXDOMAIN&lt;/code&gt; for 50% of queries.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;T+5s&lt;/td&gt;
&lt;td&gt;MarketPulse API tries to connect to &lt;code&gt;postgresql.production.svc.cluster.local&lt;/code&gt;. Gets &lt;code&gt;NXDOMAIN&lt;/code&gt;. Retries. Fails. Returns &lt;code&gt;500&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;T+10s&lt;/td&gt;
&lt;td&gt;job-digest Python scraper tries to resolve &lt;code&gt;api.linkedin.com&lt;/code&gt; through cluster DNS. Gets &lt;code&gt;NXDOMAIN&lt;/code&gt;. The urllib3 retry logic waits 3 seconds, retries, waits 6 seconds, retries, gives up. The CronJob fails.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;T+20s&lt;/td&gt;
&lt;td&gt;Prometheus can't resolve scrape targets. All targets show &lt;code&gt;DOWN&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;T+30s&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Cilium Hubble Relay&lt;/strong&gt; can't resolve &lt;code&gt;hubble-peer.kube-system.svc.cluster.local&lt;/code&gt;. It stops receiving flow data. My network observability goes blind.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;T+60s&lt;/td&gt;
&lt;td&gt;ArgoCD can't resolve &lt;code&gt;github.com&lt;/code&gt; to check for manifest updates. Sync status shows &lt;code&gt;Unknown&lt;/code&gt; instead of &lt;code&gt;Synced&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;T+2m&lt;/td&gt;
&lt;td&gt;Chaos removed. DNS recovers in 5 seconds because CoreDNS caches are in-memory. But the failed CronJob doesn't automatically re-run. The &lt;code&gt;500&lt;/code&gt; errors in MarketPulse triggered my alerting. My phone buzzed. I remembered it's 2:47 AM and my wife is sleeping. I silence the alert.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Lesson 5:&lt;/strong&gt; DNS is not a luxury. When DNS breaks, everything breaks simultaneously because everything assumes DNS works. In a homelab, you don't have Route 53 health checks or multi-region DNS. You have one CoreDNS replica and a dream.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Scorecard
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Attack&lt;/th&gt;
&lt;th&gt;Expected&lt;/th&gt;
&lt;th&gt;Reality&lt;/th&gt;
&lt;th&gt;MTTR&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pod Chaos&lt;/td&gt;
&lt;td&gt;Full recovery in &amp;lt;10s&lt;/td&gt;
&lt;td&gt;34s database failover + data loss&lt;/td&gt;
&lt;td&gt;~45s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Network Partition&lt;/td&gt;
&lt;td&gt;Graceful isolation&lt;/td&gt;
&lt;td&gt;3m distributed deadlock&lt;/td&gt;
&lt;td&gt;~3m&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Node Death&lt;/td&gt;
&lt;td&gt;Auto-reschedule in 40s&lt;/td&gt;
&lt;td&gt;3.5m manual intervention needed&lt;/td&gt;
&lt;td&gt;~3.5m&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPU Stress&lt;/td&gt;
&lt;td&gt;Slowdown, no outage&lt;/td&gt;
&lt;td&gt;90s cascading recovery&lt;/td&gt;
&lt;td&gt;~90s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DNS Chaos&lt;/td&gt;
&lt;td&gt;Service failures&lt;/td&gt;
&lt;td&gt;Total cluster blindness&lt;/td&gt;
&lt;td&gt;~5s (after removal)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Summary:&lt;/strong&gt; My cluster survived everything. But "survived" doesn't mean "handled gracefully." Every attack revealed a gap between "Kubernetes works" and "Kubernetes works when things go wrong."&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Fixed the Next Morning
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Tuned Kubernetes failure detection
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Kubelet configuration&lt;/span&gt;
&lt;span class="na"&gt;nodeStatusUpdateFrequency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;
&lt;span class="na"&gt;nodeMonitorGracePeriod&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;20s&lt;/span&gt;
&lt;span class="na"&gt;podEvictionTimeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;60s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On bare metal, nodes don't come back. Don't wait 5 minutes to accept reality.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Added a second CoreDNS replica with anti-affinity
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
&lt;span class="na"&gt;affinity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;podAntiAffinity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;requiredDuringSchedulingIgnoredDuringExecution&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;labelSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;k8s-app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kube-dns&lt;/span&gt;
        &lt;span class="na"&gt;topologyKey&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kubernetes.io/hostname&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One CoreDNS death should not blind the cluster.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Moved Prometheus to Longhorn-backed storage
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;volumeClaimTemplates&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prometheus-data&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;storageClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;longhorn&lt;/span&gt;
        &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;20Gi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;emptyDir&lt;/code&gt; for monitoring data is a mistake. Promote it to persistent storage.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Configured pod disruption budgets
&lt;/h3&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;policy/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PodDisruptionBudget&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;postgres-pdb&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;minAvailable&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgresql&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This prevents voluntary evictions from destabilizing critical workloads during node drains or chaos experiments.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Automated the chaos experiments
&lt;/h3&gt;

&lt;p&gt;I created a Kubernetes CronJob that runs a subset of these experiments every Sunday at 3 AM:&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;batch/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;CronJob&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;weekly-chaos&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;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;3&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;0"&lt;/span&gt;
  &lt;span class="na"&gt;jobTemplate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;chaos&lt;/span&gt;
              &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;chaos-mesh/chaos-daemon:latest&lt;/span&gt;
              &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/bin/sh"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-c"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
              &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
                  &lt;span class="s"&gt;kubectl apply -f /experiments/pod-chaos.yaml&lt;/span&gt;
                  &lt;span class="s"&gt;sleep 300&lt;/span&gt;
                  &lt;span class="s"&gt;kubectl delete -f /experiments/pod-chaos.yaml&lt;/span&gt;
          &lt;span class="na"&gt;restartPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;OnFailure&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;If you're not breaking it on purpose, someone will break it by accident.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  3 Lessons That Transfer to Production
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Hope is not a strategy
&lt;/h3&gt;

&lt;p&gt;Before this night, my disaster recovery plan was: "I have etcd snapshots and Longhorn replicas. I'll figure it out." After 5 attacks, I have specific MTTR numbers, specific failure modes, and a specific list of what to fix. In production, "I'll figure it out" is not an SRE principle.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Defaults are not your friends
&lt;/h3&gt;

&lt;p&gt;Kubernetes defaults are tuned for cloud providers with managed control planes, fast node replacement, and redundant infrastructure. On bare metal — or even in a cost-optimized cloud environment — those defaults are dangerously optimistic. Tune them or suffer at 2 AM.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Observability is the only thing that separates chaos from panic
&lt;/h3&gt;

&lt;p&gt;During the DNS attack, the only reason I knew what was happening was because I had Prometheus, Grafana, and Cilium Hubble. Without them, I would have been guessing. With them, I could trace the failure chain: DNS → service resolution → API timeout → &lt;code&gt;500&lt;/code&gt; response. Observability doesn't prevent failure. It prevents &lt;strong&gt;blind failure&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;I installed Chaos Mesh on my homelab cluster and attacked it 5 ways: random pod kills, network partitions, node death, CPU starvation, and DNS corruption.&lt;/p&gt;

&lt;p&gt;The cluster survived everything. But gracefully? No. Database failover took 34 seconds. Network partition caused a 3-minute distributed deadlock. Node death needed manual intervention. CPU stress triggered cascading failures through etcd, Cilium, ArgoCD, and Longhorn. DNS chaos blinded the entire cluster in 30 seconds.&lt;/p&gt;

&lt;p&gt;I fixed 5 things the next morning: tuned failure detection, added CoreDNS redundancy, moved Prometheus to persistent storage, configured PodDisruptionBudgets, and automated weekly chaos experiments.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you run Kubernetes anywhere — cloud, bare metal, or a drawer full of Raspberry Pis — break it yourself before the universe breaks it for you.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Start here&lt;/span&gt;
helm repo add chaos-mesh https://charts.chaos-mesh.org
helm &lt;span class="nb"&gt;install &lt;/span&gt;chaos-mesh chaos-mesh/chaos-mesh &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; chaos-testing &lt;span class="nt"&gt;--create-namespace&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;What's your MTTR? Have you ever actually measured it? Drop it in the comments — or admit you don't know. Both are valid.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>chaosengineering</category>
      <category>devops</category>
      <category>homelab</category>
    </item>
    <item>
      <title>I Automated My Entire GitOps Security Stack. The First Thing It Blocked Was My Own Salary.</title>
      <dc:creator>Le Beltagy</dc:creator>
      <pubDate>Wed, 12 Aug 2026 10:00:00 +0000</pubDate>
      <link>https://dev.to/le_beltagy/i-automated-my-entire-gitops-security-stack-the-first-thing-it-blocked-was-my-own-salary-227e</link>
      <guid>https://dev.to/le_beltagy/i-automated-my-entire-gitops-security-stack-the-first-thing-it-blocked-was-my-own-salary-227e</guid>
      <description>&lt;h1&gt;
  
  
  I Automated My Entire GitOps Security Stack. The First Thing It Blocked Was My Own Salary.
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;From "zero-trust hero" to "I can't deploy my own payday fix" — how I chained kube-radar, admission webhooks, and ArgoCD into an autonomous security pipeline, the regex that deemed me a threat, and why your guardrails become prison bars when you forget the escape hatch.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;p&gt;It started with a single YAML file that should never have made it to production.&lt;/p&gt;

&lt;p&gt;I was reviewing a pull request for VehicleMetrics at 10 PM on a Thursday. A junior contributor — bless their enthusiasm — had added a new ClusterRole for a debugging sidecar. It looked innocent enough:&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;rbac.authorization.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ClusterRole&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;vehiclemetrics-debug&lt;/span&gt;
&lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;apiGroups&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;verbs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cluster-admin in a trench coat. One &lt;code&gt;kubectl apply&lt;/code&gt; away from "we don't know who owns this cluster anymore."&lt;/p&gt;

&lt;p&gt;I caught it because I was &lt;em&gt;looking&lt;/em&gt;. But what about the PRs I don't review? What about the 2 AM "hotfix" branches that bypass CODEOWNERS because someone's pager is screaming? What about the Helm chart I copy-pasted from Stack Overflow that binds a service account to &lt;code&gt;pods/exec&lt;/code&gt; — the permission that lets anyone &lt;code&gt;kubectl exec&lt;/code&gt; into a running pod and dump environment variables?&lt;/p&gt;

&lt;p&gt;I manage Kubernetes at Siemens professionally. I run bare-metal clusters in my closet obsessively. And I had built NEMESIS, my purple-team tool, to attack my own infrastructure.&lt;/p&gt;

&lt;p&gt;But I had never built something to &lt;strong&gt;stop the attack before it started&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So I did what any engineer with too much caffeine and a weekend would do: I automated the entire security review pipeline. No human in the loop. If the code was malicious, sloppy, or just stupid, it would never touch the cluster.&lt;/p&gt;

&lt;p&gt;The system worked perfectly.&lt;/p&gt;

&lt;p&gt;Then Friday at 4:47 PM happened.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Not Just Use Branch Protection?
&lt;/h2&gt;

&lt;p&gt;You're thinking: &lt;em&gt;this is a git problem, not a Kubernetes problem.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Branch protection, CODEOWNERS, required reviews — I had all of it. Here's why it's not enough:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. YAML is a liar&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That wildcard ClusterRole? It passed &lt;code&gt;yamllint&lt;/code&gt;. It passed &lt;code&gt;helm lint&lt;/code&gt;. It passed a human reviewer who was looking at 14 files in a 3,000-line PR. The danger wasn't in the syntax. It was in the &lt;strong&gt;semantics&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Security is boring until it's catastrophic&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Nobody wants to be the reviewer who blocks a PR for three hours debating whether &lt;code&gt;pods/exec&lt;/code&gt; is necessary. So they approve it. I know because I've done it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. "LGTM" is not a security control&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A thumbs-up emoji doesn't enforce least privilege. A required reviewer count doesn't understand RBAC. I wanted a system that understood Kubernetes security natively, not a social protocol that assumed everyone was careful.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Stack I Built
&lt;/h2&gt;

&lt;p&gt;I took three tools I already trusted and wired them into ArgoCD's deployment pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GitHub PR
    │
    ▼
gitops-validator (GitHub App)
    ├─ kube-radar scan → RBAC wildcard / overprivilege detection
    ├─ NEMESIS static analysis → container image CVE + misconfig
    └─ kyverno-lite webhook → policy enforcement (custom rules)
    │
    ▼
ArgoCD PreSync Job
    └─ admission-controller validates the rendered manifests
    │
    ▼
Cluster (only if all gates pass)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Tool 1: kube-radar (my own Go CLI)
&lt;/h3&gt;

&lt;p&gt;I wrote this when I was learning Go. It parses Kubernetes RBAC resources and scores them by risk. Wildcards = instant block. &lt;code&gt;pods/exec&lt;/code&gt;, &lt;code&gt;secrets/*&lt;/code&gt;, &lt;code&gt;clusterroles/*&lt;/code&gt; without namespace restriction = flag for human review.&lt;/p&gt;

&lt;p&gt;I containerized it and turned it into a GitHub Actions job.&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="c1"&gt;# .github/workflows/gitops-security.yml&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;rbac-scan&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&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;kube-radar scan&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker://ghcr.io/le-beltagy/kube-radar:v0.3&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;scan --path ./manifests --severity critical --fail-on-critical&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Tool 2: NEMESIS static analysis
&lt;/h3&gt;

&lt;p&gt;The same purple-team engine that lives in my cluster as a DaemonSet has a &lt;code&gt;scan&lt;/code&gt; mode. It reads a container image reference, pulls it into an isolated namespace, and runs Trivy + kube-bench + custom checks. If the image contains a known CVE with CVSS &amp;gt; 7.0, the pipeline fails.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tool 3: kyverno-lite (custom admission webhook)
&lt;/h3&gt;

&lt;p&gt;I didn't need all of Kyverno's DSL. I needed four hard rules:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;No container runs as root&lt;/li&gt;
&lt;li&gt;No image uses &lt;code&gt;latest&lt;/code&gt; tag&lt;/li&gt;
&lt;li&gt;No RBAC rule has &lt;code&gt;*&lt;/code&gt; on apiGroups, resources, AND verbs&lt;/li&gt;
&lt;li&gt;Every deployment must have &lt;code&gt;resources.requests&lt;/code&gt; set&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So I wrote a lightweight admission webhook in Go — just 400 lines — using &lt;code&gt;controller-runtime&lt;/code&gt;. It receives &lt;code&gt;AdmissionReview&lt;/code&gt; requests from the Kubernetes API server and returns &lt;code&gt;allowed: true/false&lt;/code&gt;.&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="c"&gt;// webhook.go — the RBAC gatekeeper&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;validateRBAC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;admissionv1&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AdmissionRequest&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="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;role&lt;/span&gt; &lt;span class="n"&gt;rbacv1&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ClusterRole&lt;/span&gt;
    &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unmarshal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;role&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;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;role&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Rules&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c"&gt;// Rule 3: The "Deadly Asterisk"&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;slices&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;APIGroups&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"*"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
           &lt;span class="n"&gt;slices&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Resources&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"*"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
           &lt;span class="n"&gt;slices&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Verbs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&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;return&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt; &lt;span class="c"&gt;// ❌ DENIED&lt;/span&gt;
        &lt;span class="p"&gt;}&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;true&lt;/span&gt; &lt;span class="c"&gt;// ✅ ALLOWED&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I packaged it as a &lt;code&gt;ValidatingWebhookConfiguration&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;admissionregistration.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ValidatingWebhookConfiguration&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;gitops-security-webhook&lt;/span&gt;
&lt;span class="na"&gt;webhooks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rbac.gitops.lebeltagy.io&lt;/span&gt;
    &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;operations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CREATE"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;UPDATE"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;apiGroups&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rbac.authorization.k8s.io"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;apiVersions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v1"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;clusterroles"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;roles"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;clientConfig&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gitops-webhook&lt;/span&gt;
        &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;security&lt;/span&gt;
        &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/validate-rbac"&lt;/span&gt;
    &lt;span class="na"&gt;failurePolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Fail&lt;/span&gt;
    &lt;span class="na"&gt;admissionReviewVersions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v1"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;sideEffects&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;failurePolicy: Fail&lt;/code&gt;. This is the critical line. If the webhook is down, &lt;strong&gt;nothing gets deployed&lt;/strong&gt;. I wanted security over availability. I would regret this later.&lt;/p&gt;




&lt;h2&gt;
  
  
  The ArgoCD Integration
&lt;/h2&gt;

&lt;p&gt;ArgoCD has a feature most people ignore: &lt;strong&gt;PreSync hooks&lt;/strong&gt;. You can run a Kubernetes Job before any sync operation. If the Job fails, the sync aborts.&lt;/p&gt;

&lt;p&gt;I created a PreSync Job that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Renders the Helm chart&lt;/li&gt;
&lt;li&gt;Runs kube-radar against the rendered manifests&lt;/li&gt;
&lt;li&gt;Runs NEMESIS against the container images referenced in the manifests&lt;/li&gt;
&lt;li&gt;Sends a Slack notification with the scan results
&lt;/li&gt;
&lt;/ol&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;batch/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Job&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;gitops-security-gate&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;argocd.argoproj.io/hook&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PreSync&lt;/span&gt;
    &lt;span class="na"&gt;argocd.argoproj.io/hook-delete-policy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HookSucceeded&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;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;validator&lt;/span&gt;
          &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/le-beltagy/gitops-validator:v1.2&lt;/span&gt;
          &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;REPO_URL&lt;/span&gt;
              &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://github.com/le-beltagy/vehiclemetrics"&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;TARGET_REVISION&lt;/span&gt;
              &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HEAD"&lt;/span&gt;
      &lt;span class="na"&gt;restartPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Never&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If this Job exits with code 0, ArgoCD deploys. If it exits with code 1, the sync is blocked and the Application shows &lt;code&gt;SyncFailed&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I tested it on a deliberately bad PR — that wildcard ClusterRole from the beginning. The pipeline caught it. The PR was blocked. The cluster stayed safe.&lt;/p&gt;

&lt;p&gt;I felt like a god.&lt;/p&gt;




&lt;h2&gt;
  
  
  Friday, 4:47 PM
&lt;/h2&gt;

&lt;p&gt;It was the last workday of the month. Payroll for the Dutch startup's pilot program — my first real SaaS revenue — needed to be invoiced through the VehicleMetrics billing service.&lt;/p&gt;

&lt;p&gt;The billing service had a bug. A timezone issue: it calculated prorated usage in UTC but invoiced in CET, overcharging the customer by exactly one day. The customer noticed. I needed to ship a fix before 5 PM or the invoice would go out wrong.&lt;/p&gt;

&lt;p&gt;I wrote the fix in 12 minutes. One line changed in a Python utility. Tests passed. I pushed, merged, and watched ArgoCD.&lt;/p&gt;

&lt;p&gt;The PreSync Job started.&lt;/p&gt;

&lt;p&gt;It failed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;[gitops-validator] ERROR: kube-radar detected CRITICAL violation
[gitops-validator] File: manifests/vehiclemetrics-billing-sa.yaml
[gitops-validator] Resource: ServiceAccount/vehiclemetrics-billing
[gitops-validator] Issue: Binds to ClusterRole with pods/exec permission
[gitops-validator] SEVERITY: Critical — automatic block enabled
[gitops-validator] Exit code: 1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wait. What?&lt;/p&gt;

&lt;p&gt;I hadn't changed anything in &lt;code&gt;vehiclemetrics-billing-sa.yaml&lt;/code&gt;. That file had been in the repo for weeks. Why was it failing &lt;em&gt;now&lt;/em&gt;?&lt;/p&gt;

&lt;p&gt;I checked the git diff. The billing fix was a one-line Python change. No RBAC touched. But the PreSync Job scans the &lt;em&gt;entire rendered manifest tree&lt;/em&gt;, not just the diff. And kube-radar had a new rule I had merged the night before — version v0.3 — that now flagged &lt;code&gt;pods/exec&lt;/code&gt; as critical, not just a warning.&lt;/p&gt;

&lt;p&gt;The rule change was good. The ServiceAccount &lt;em&gt;was&lt;/em&gt; overprivileged. But I had shipped the new kube-radar rule on Thursday evening, forgotten about it, and now on Friday at 4:52 PM, my own security pipeline was treating my production manifests as a threat.&lt;/p&gt;

&lt;p&gt;ArgoCD showed &lt;code&gt;SyncFailed&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The billing fix was not deployed.&lt;/p&gt;




&lt;h2&gt;
  
  
  4:55 PM: The Panic Override
&lt;/h2&gt;

&lt;p&gt;I had two choices:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option A:&lt;/strong&gt; Fix the RBAC properly — create a restricted Role with only the necessary permissions, update the ServiceAccount binding, run the tests, commit, push, wait for the pipeline.&lt;/p&gt;

&lt;p&gt;Estimated time: 20 minutes. It was 4:55 PM. The invoice batch job ran at 5:00 PM.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option B:&lt;/strong&gt; Bypass the security pipeline and force the sync.&lt;/p&gt;

&lt;p&gt;ArgoCD lets you do this. You can click "Sync" with "Prune" and "Replace" checked. You can skip the PreSync hook. I am an admin. I have the power.&lt;/p&gt;

&lt;p&gt;I hovered over the button.&lt;/p&gt;

&lt;p&gt;And I realized: if I bypassed my own security gate the first time it inconvenienced me, the entire system was theater. I had built an automated bouncer and was about to sneak in through the back door because I was wearing the right jacket.&lt;/p&gt;

&lt;p&gt;So I didn't.&lt;/p&gt;




&lt;h2&gt;
  
  
  5:03 PM: The Real Fix (And The Bug I Actually Shipped)
&lt;/h2&gt;

&lt;p&gt;I spent 8 minutes — invoice job be damned — writing a proper Role:&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;rbac.authorization.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Role&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;vehiclemetrics-billing&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vehiclemetrics-prod&lt;/span&gt;
&lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;apiGroups&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pods"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;verbs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;get"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;list"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;apiGroups&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pods/log"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;verbs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;get"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Removed &lt;code&gt;pods/exec&lt;/code&gt;. Removed the ClusterRole binding. Applied the principle of least privilege.&lt;/p&gt;

&lt;p&gt;Committed. Pushed. Pipeline passed. ArgoCD synced at &lt;strong&gt;5:02 PM&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The invoice job? It ran at 5:03 PM. With the correct timezone fix. The customer never knew how close they came to a bad bill — or how close I came to disabling my own security stack.&lt;/p&gt;

&lt;p&gt;But here's the part that haunts me:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The original &lt;code&gt;pods/exec&lt;/code&gt; permission wasn't even needed.&lt;/strong&gt; It was a copy-paste from a debugging session three weeks ago. I had left it in the manifest because "it worked" and I was too lazy to scope it down.&lt;/p&gt;

&lt;p&gt;My security pipeline didn't create a problem. It &lt;strong&gt;revealed&lt;/strong&gt; a problem I had been ignoring. The thing that blocked my salary was the thing that was already wrong.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Aftermath: 48 Hours of Paranoia
&lt;/h2&gt;

&lt;p&gt;I spent the weekend reviewing every manifest in the repo. Here's what I found:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;Issue&lt;/th&gt;
&lt;th&gt;Risk&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;debug-namespace/role.yaml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;verbs: ["*"]&lt;/code&gt; on ConfigMaps&lt;/td&gt;
&lt;td&gt;Any pod can read secrets mounted as ConfigMaps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;monitoring/sa.yaml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ServiceAccount bound to &lt;code&gt;cluster-admin&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Prometheus can read all secrets&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;temp/backup-job.yaml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Container runs as root&lt;/td&gt;
&lt;td&gt;Privilege escalation vector&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ingress/traefik-rbac.yaml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;apiGroups: ["*"]&lt;/code&gt; on 3 resources&lt;/td&gt;
&lt;td&gt;Over-scoped for ingress needs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Four critical issues. In my own repo. That I had written or approved.&lt;/p&gt;

&lt;p&gt;Without the automated gate, they would have stayed there until someone exploited them — or until a compliance audit found them and I had to explain why a billing service could exec into pods.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Changed (The No-Escape-Hatch Problem)
&lt;/h2&gt;

&lt;p&gt;The system was right to block me. But the system was also dangerous because it had &lt;strong&gt;no emergency override&lt;/strong&gt; that didn't require me to become a liar.&lt;/p&gt;

&lt;p&gt;Here's the architecture now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GitHub PR
    │
    ▼
gitops-validator (GitHub App)
    ├─ kube-radar scan → CRITICAL = block PR
    ├─ NEMESIS scan → CRITICAL = block PR
    └─ kyverno-lite webhook → policy check
    │
    ▼
ArgoCD PreSync Job
    └─ Full validation rerun
    │
    ▼
ValidatingWebhookConfiguration (cluster gate)
    │
    ▼
Cluster
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; I added a &lt;code&gt;@security-override&lt;/code&gt; label. If a PR is labeled with this, the pipeline still runs, still reports every violation, but emits a &lt;strong&gt;warning instead of a block&lt;/strong&gt;. The label can only be applied by a GitHub Team called &lt;code&gt;security-admins&lt;/code&gt;, which has exactly one member: me. And every override is logged to a dedicated Slack channel and a write-once S3 bucket.&lt;/p&gt;

&lt;p&gt;I also changed &lt;code&gt;failurePolicy: Fail&lt;/code&gt; to &lt;code&gt;failurePolicy: Ignore&lt;/code&gt; on the admission webhook, with a twist: if the webhook is unreachable, ArgoCD flags the Application as &lt;code&gt;Unknown&lt;/code&gt; and pauses automated syncs. Security is enforced when healthy. Availability is preserved when degraded.&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;webhooks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rbac.gitops.lebeltagy.io&lt;/span&gt;
    &lt;span class="c1"&gt;# ...&lt;/span&gt;
    &lt;span class="na"&gt;failurePolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Ignore&lt;/span&gt;  &lt;span class="c1"&gt;# Don't crash deploys if webhook is down&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But the real fix wasn't technical. It was procedural:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;I stopped treating "works" as the standard. "Least privilege" is the standard.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The Numbers
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Before (manual review)&lt;/th&gt;
&lt;th&gt;After (automated gates)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;RBAC violations in prod&lt;/td&gt;
&lt;td&gt;4 known, unknown unknowns&lt;/td&gt;
&lt;td&gt;0 (all caught in CI)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CVEs deployed to cluster&lt;/td&gt;
&lt;td&gt;~3 per month (after-the-fact scans)&lt;/td&gt;
&lt;td&gt;0 (blocked in PreSync)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time to review a PR&lt;/td&gt;
&lt;td&gt;45 min avg (human)&lt;/td&gt;
&lt;td&gt;3 min (automated) + human for exceptions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;False positive rate&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;td&gt;~5% (tunable via severity threshold)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Times I almost disabled my own salary&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Don't ship new scanner rules on Thursday night
&lt;/h3&gt;

&lt;p&gt;If you're changing what "critical" means, do it Monday morning when you have the week to deal with the blast radius. Not the day before you might need to deploy a hotfix.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Every guardrail needs a documented escape hatch
&lt;/h3&gt;

&lt;p&gt;Not a secret backdoor. A visible, audited, tightly-scoped bypass. If your emergency procedure is "log in as admin and disable the thing," you don't have security. You have security theater with an intermission.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Scan the diff, not the world
&lt;/h3&gt;

&lt;p&gt;The PreSync Job originally scanned the entire manifest tree. Now it scans only the Helm release diff: what &lt;em&gt;changed&lt;/em&gt;, not what &lt;em&gt;exists&lt;/em&gt;. Existing bad configs get flagged in a weekly full scan, not during hotfix deployments.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Your production manifests are dirtier than you think
&lt;/h3&gt;

&lt;p&gt;I promise you. Go run kube-radar or Popeye or any RBAC scanner against your cluster right now. You'll find something embarrassing. The question isn't whether you have debt — it's whether you have a system that finds it before your attacker does.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why I'm Keeping It
&lt;/h2&gt;

&lt;p&gt;It's been three weeks since the Friday incident. The Dutch pilot expanded to a second customer. I haven't had a single 3 AM security scare. And when my latest contributor opened a PR with &lt;code&gt;pods/exec&lt;/code&gt; in it, the pipeline blocked it before I even saw the notification.&lt;/p&gt;

&lt;p&gt;I didn't have to be the bad cop. The code was.&lt;/p&gt;

&lt;p&gt;Is automated GitOps security more work to maintain? Yes. I spend maybe an hour per week tuning rules and reviewing override logs.&lt;/p&gt;

&lt;p&gt;Is it worth it? Last week, a penetration tester — hired by the second customer — spent two days trying to escalate privileges in the cluster. He found one over-scoped Role. It was in a staging namespace with no production data.&lt;/p&gt;

&lt;p&gt;He wrote in his report: &lt;em&gt;"The target environment exhibits unusually robust RBAC hygiene for an early-stage SaaS platform."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That sentence was worth every minute.&lt;/p&gt;




&lt;h2&gt;
  
  
  TL;DR — The "Don't Block Your Own Salary" Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Run an RBAC scanner against your cluster today (kube-radar, Popeye, or rbac-audit)&lt;/li&gt;
&lt;li&gt;[ ] Add a PreSync security gate to ArgoCD/Flux before your next deploy&lt;/li&gt;
&lt;li&gt;[ ] Ship new scanner rules on Monday, not Thursday&lt;/li&gt;
&lt;li&gt;[ ] Build an override mechanism that is &lt;strong&gt;audited&lt;/strong&gt;, not &lt;strong&gt;secret&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Change &lt;code&gt;failurePolicy: Fail&lt;/code&gt; to &lt;code&gt;Ignore&lt;/code&gt; if you don't have 24/7 webhook SREs&lt;/li&gt;
&lt;li&gt;[ ] Scan the diff for deploy gates, scan the world for weekly audits&lt;/li&gt;
&lt;li&gt;[ ] Remember: the pipeline that blocks you is the pipeline that saves you&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;Want the admission webhook code + ArgoCD PreSync manifests?&lt;/strong&gt; Drop a comment — I'll open-source the gitops-validator repo if there's interest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ever been locked out of your own system by your own automation?&lt;/strong&gt; Tell me your war story below. We can start a support group.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Tags: #kubernetes #security #gitops #devops #go #argocd #rbac #automation #platformengineering&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>security</category>
      <category>gitops</category>
      <category>devops</category>
    </item>
    <item>
      <title>I Replaced kube-proxy with eBPF in Production (And Why My Monitoring Went Blind for 6 Hours)</title>
      <dc:creator>Le Beltagy</dc:creator>
      <pubDate>Thu, 06 Aug 2026 19:42:26 +0000</pubDate>
      <link>https://dev.to/le_beltagy/i-replaced-kube-proxy-with-ebpf-in-production-and-why-my-monitoring-went-blind-for-6-hours-1n18</link>
      <guid>https://dev.to/le_beltagy/i-replaced-kube-proxy-with-ebpf-in-production-and-why-my-monitoring-went-blind-for-6-hours-1n18</guid>
      <description>&lt;h1&gt;
  
  
  I Replaced kube-proxy with eBPF in Production (And Why My Monitoring Went Blind for 6 Hours)
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;From a "simple" Cilium upgrade to a 6-hour outage where my SIEM couldn't see a single packet — the real story of deleting kube-proxy, the eBPF program that saved me, and why the docs never warned me about the one metric that matters.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;p&gt;It started with a &lt;code&gt;cilium upgrade&lt;/code&gt; command I ran on a Tuesday evening.&lt;/p&gt;

&lt;p&gt;My homelab cluster — the same 4-node bare-metal setup I wrote about last month (Dell OptiPlex + 3 Raspberry Pis, Talos Linux, Cilium, ArgoCD, Longhorn) — was running Cilium 1.15 in kube-proxy-replacement &lt;strong&gt;partial&lt;/strong&gt; mode. Cilium was handling some traffic, but kube-proxy's iptables rules were still doing the heavy lifting for NodePort and ClusterIP services.&lt;/p&gt;

&lt;p&gt;I'd read the Cilium 1.16 release notes. The eBPF kube-proxy replacement was now "production-ready for all environments." The Talos Linux docs had a single bullet point: &lt;em&gt;"Set &lt;code&gt;kubeProxyReplacement: true&lt;/code&gt; in Cilium values."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It looked harmless. It looked like flipping a switch.&lt;/p&gt;

&lt;p&gt;So I did.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm upgrade cilium cilium/cilium &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; kube-system &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; &lt;span class="nv"&gt;kubeProxyReplacement&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; &lt;span class="nv"&gt;k8sServiceHost&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;auto &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; &lt;span class="nv"&gt;k8sServicePort&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;6443
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Cilium pods rolled. The nodes stayed up. &lt;code&gt;kubectl get pods -A&lt;/code&gt; showed everything running.&lt;/p&gt;

&lt;p&gt;I went to bed.&lt;/p&gt;

&lt;p&gt;At 2:47 AM, my phone buzzed. Not an alert from the cluster. An alert from &lt;strong&gt;my SIEM&lt;/strong&gt; — or rather, the &lt;em&gt;absence&lt;/em&gt; of data from it. The log ingestion rate for &lt;code&gt;kubernetes-audit&lt;/code&gt; and &lt;code&gt;network-flow&lt;/code&gt; had flatlined to zero.&lt;/p&gt;

&lt;p&gt;The cluster was "healthy." The monitoring was dead.&lt;/p&gt;

&lt;p&gt;This is not a tutorial. This is an autopsy of what happens when you delete the datapath that your security stack depends on, without knowing it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Not Just Keep kube-proxy?
&lt;/h2&gt;

&lt;p&gt;I run Kubernetes professionally at Siemens. I know what managed EKS gives you: kube-proxy in &lt;code&gt;iptables&lt;/code&gt; mode, happily translating ClusterIP virtual IPs into pod endpoints, maintained by AWS, never thought about.&lt;/p&gt;

&lt;p&gt;But I was hitting a wall.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. iptables doesn't scale&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In my homelab, 45 manifests and ~120 services meant ~3,500 iptables rules across all nodes. Every new service triggered a full &lt;code&gt;iptables-restore&lt;/code&gt; that locked the kernel's netfilter table for hundreds of milliseconds. On a Raspberry Pi 4, that's a noticeable latency spike.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The gap between "cluster works" and "I understand the datapath"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;kube-proxy&lt;/code&gt; is a black box. You run it, it works, you ignore it. But if you can't explain how a packet flows from &lt;code&gt;Service:80&lt;/code&gt; to &lt;code&gt;Pod:8080&lt;/code&gt; at the kernel level, do you really own your cluster?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. eBPF gives you superpowers — if you know the cost&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Cilium's eBPF programs can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Replace conntrack with BPF map-based connection tracking&lt;/li&gt;
&lt;li&gt;Provide socket-level load balancing (no NAT needed for pod-to-pod)&lt;/li&gt;
&lt;li&gt;Deliver Hubble flow logs with L3-L7 visibility&lt;/li&gt;
&lt;li&gt;Enforce network policies before the packet hits the host stack&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But eBPF isn't magic. It's a different universe. And the tools that read the &lt;em&gt;old&lt;/em&gt; universe don't work in the new one.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Architecture (Before and After)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Before: kube-proxy + Cilium (Hybrid)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────────────────────────────────────────────────────────┐
│                         Worker Node                                  │
│                                                                     │
│  ┌─────────────┐    ┌──────────────────┐    ┌──────────────────┐   │
│  │   Pod A     │───►│   ClusterIP      │───►│   kube-proxy     │   │
│  │  (app)      │    │   Service        │    │   (iptables)     │   │
│  └─────────────┘    └──────────────────┘    └────────┬─────────┘   │
│                                                      │              │
│                                     ┌────────────────▼─────────┐   │
│                                     │   iptables NAT/REDIRECT   │   │
│                                     │   -A KUBE-SVC-XXX ...     │   │
│                                     └────────────────┬─────────┘   │
│                                                      │              │
│                                     ┌────────────────▼─────────┐   │
│                                     │   conntrack table         │   │
│                                     │   /proc/net/nf_conntrack  │   │
│                                     └────────────────┬─────────┘   │
│                                                      │              │
│                                     ┌────────────────▼─────────┐   │
│                                     │   Pod B (target)          │   │
│                                     └──────────────────────────┘   │
└─────────────────────────────────────────────────────────────────────┘
                       ▲
                       │
    ┌──────────────────┼──────────────────┐
    │  SIEM reads      │  Falco reads     │  Security tools
    │  iptables rules  │  conntrack       │  depend on netfilter
    │  for flow logs   │  for connections │
    └──────────────────┘──────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  After: Cilium eBPF (kube-proxy-deleted)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────────────────────────────────────────────────────────┐
│                         Worker Node                                  │
│                                                                     │
│  ┌─────────────┐    ┌──────────────────┐    ┌──────────────────┐   │
│  │   Pod A     │───►│   ClusterIP      │───►│   Cilium Agent   │   │
│  │  (app)      │    │   Service        │    │   (eBPF)         │   │
│  └─────────────┘    └──────────────────┘    └────────┬─────────┘   │
│                                                      │              │
│                                     ┌────────────────▼─────────┐   │
│                                     │   eBPF Programs           │   │
│                                     │   ├─ bpf_sock (socket LB) │   │
│                                     │   ├─ bpf_lxc (pod egress) │   │
│                                     │   ├─ bpf_host (NodePort)  │   │
│                                     │   └─ bpf_network (ingress)│   │
│                                     └────────────────┬─────────┘   │
│                                                      │              │
│                                     ┌────────────────▼─────────┐   │
│                                     │   BPF Maps (not iptables) │   │
│                                     │   cilium_lb4_services_v2  │   │
│                                     │   cilium_ct4_global       │   │
│                                     └────────────────┬─────────┘   │
│                                                      │              │
│                                     ┌────────────────▼─────────┐   │
│                                     │   Pod B (target)          │   │
│                                     └──────────────────────────┘   │
└─────────────────────────────────────────────────────────────────────┘
                       ▲
                       │
    ┌──────────────────┘
    │  SIEM: "WHERE ARE MY FLOW LOGS?!"
    │  Falco: "I CAN'T SEE CONNTRACK!"
    │  Legacy tools: broken by design
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Design Decision
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Before (kube-proxy)&lt;/th&gt;
&lt;th&gt;After (eBPF)&lt;/th&gt;
&lt;th&gt;Impact&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Service Load Balancing&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;iptables DNAT&lt;/td&gt;
&lt;td&gt;eBPF socket-level redirect&lt;/td&gt;
&lt;td&gt;Latency: -30%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Connection Tracking&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;conntrack kernel module&lt;/td&gt;
&lt;td&gt;eBPF BPF map (&lt;code&gt;cilium_ct4_global&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;No &lt;code&gt;/proc/net/nf_conntrack&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;NAT&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;iptables MASQUERADE&lt;/td&gt;
&lt;td&gt;eBPF SNAT/DNAT&lt;/td&gt;
&lt;td&gt;No &lt;code&gt;iptables -t nat -L&lt;/code&gt; output&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Network Policy Enforcement&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;iptables + ipsets&lt;/td&gt;
&lt;td&gt;eBPF TC/XDP programs&lt;/td&gt;
&lt;td&gt;Earlier drop, better performance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Flow Logs&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Conntrack events → Falco&lt;/td&gt;
&lt;td&gt;Hubble eBPF exporter&lt;/td&gt;
&lt;td&gt;Different schema, different source&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;NodePort&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;iptables REDIRECT&lt;/td&gt;
&lt;td&gt;eBPF &lt;code&gt;bpf_host&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Same port, different kernel path&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  The Failures (And What Each One Taught Me)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Failure 1: I Upgraded and Went to Bed
&lt;/h3&gt;

&lt;p&gt;I ran the Cilium upgrade at 11 PM. The pods cycled green. I checked &lt;code&gt;kubectl get svc&lt;/code&gt; and &lt;code&gt;curl&lt;/code&gt;ed a NodePort service from my laptop. It worked.&lt;/p&gt;

&lt;p&gt;What I didn't check: whether &lt;strong&gt;Falco&lt;/strong&gt; was still emitting network connection events.&lt;/p&gt;

&lt;p&gt;Falco's default rules include this:&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="c1"&gt;# Falco rule: outbound network connection&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;rule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Outbound Connection&lt;/span&gt;
  &lt;span class="na"&gt;desc&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Detect outbound connections from pods&lt;/span&gt;
  &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="s"&gt;outbound and&lt;/span&gt;
    &lt;span class="s"&gt;fd.name != "" and&lt;/span&gt;
    &lt;span class="s"&gt;(fd.typechar = '4' or fd.typechar = '6')&lt;/span&gt;
  &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Outbound&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;connection&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;from&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;pod&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;(command=%proc.cmdline&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;connection=%fd.name)"&lt;/span&gt;
  &lt;span class="na"&gt;priority&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;NOTICE&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Falco detects these connections by reading &lt;code&gt;/proc/&amp;lt;pid&amp;gt;/fd/&lt;/code&gt; and correlating with conntrack state. When kube-proxy's iptables rules disappeared, Falco's &lt;code&gt;fd.name&lt;/code&gt; resolution for ClusterIP destinations broke — the eBPF path doesn't leave the same &lt;code&gt;/proc&lt;/code&gt; artifacts that Falco's syscall-based engine expects.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Result:&lt;/strong&gt; Falco emitted zero network alerts for 7 hours. Not because there was no traffic. Because it couldn't &lt;em&gt;see&lt;/em&gt; the traffic the same way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson 1: "Services work" is not "security monitoring works." Test both.&lt;/strong&gt;&lt;/p&gt;




&lt;h3&gt;
  
  
  Failure 2: My SIEM Couldn't Parse Hubble
&lt;/h3&gt;

&lt;p&gt;At 2:47 AM, my phone woke me up. Grafana showed a flat line for &lt;code&gt;network_flow_events_per_minute&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I checked the SIEM (a custom Fluent Bit → Kafka → ClickHouse pipeline I'd built). The Fluent Bit pods were running. The Kafka topics existed. The ClickHouse tables were healthy.&lt;/p&gt;

&lt;p&gt;But the &lt;code&gt;kubernetes.flow_logs&lt;/code&gt; stream had &lt;strong&gt;zero rows since 23:12&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Here's why:&lt;/p&gt;

&lt;p&gt;My SIEM consumed network flow logs from two sources:&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="c1"&gt;# /etc/fluent-bit/inputs.conf&lt;/span&gt;
&lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;INPUT&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="s"&gt;Name              tail&lt;/span&gt;
    &lt;span class="s"&gt;Path              /var/log/audit/kube-apiserver-audit.log&lt;/span&gt;
    &lt;span class="s"&gt;Parser            json&lt;/span&gt;
    &lt;span class="s"&gt;Tag               k8s.audit&lt;/span&gt;

&lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;INPUT&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="s"&gt;Name              tail&lt;/span&gt;
    &lt;span class="s"&gt;Path              /var/log/conntrack/flows.json&lt;/span&gt;
    &lt;span class="s"&gt;Parser            json&lt;/span&gt;
    &lt;span class="s"&gt;Tag               k8s.network-flows&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second input — &lt;code&gt;conntrack/flows.json&lt;/code&gt; — was populated by a small sidecar that read &lt;code&gt;/proc/net/nf_conntrack&lt;/code&gt; every 30 seconds and emitted JSON.&lt;/p&gt;

&lt;p&gt;With eBPF replacing conntrack entirely, &lt;code&gt;/proc/net/nf_conntrack&lt;/code&gt; was &lt;strong&gt;empty&lt;/strong&gt; on every node. Cilium uses its own BPF map for connection tracking, not the kernel's conntrack module.&lt;/p&gt;

&lt;p&gt;So my sidecar emitted nothing. Fluent Bit had nothing to tail. Kafka got empty batches. ClickHouse had nothing to insert.&lt;/p&gt;

&lt;p&gt;The SIEM was blind. Not broken — &lt;strong&gt;blind by design&lt;/strong&gt;, because I had built it around netfilter assumptions that eBPF invalidated.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson 2: eBPF isn't a drop-in replacement. It's a migration. Treat it like one.&lt;/strong&gt;&lt;/p&gt;




&lt;h3&gt;
  
  
  Failure 3: I Panic-Downgraded and Made It Worse
&lt;/h3&gt;

&lt;p&gt;At 3:30 AM, I decided to roll back.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm rollback cilium 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cilium reverted to the previous version. kube-proxy wasn't re-deployed automatically (I'd deleted the DaemonSet during the upgrade to "save resources").&lt;/p&gt;

&lt;p&gt;Now I had &lt;strong&gt;no kube-proxy&lt;/strong&gt; and &lt;strong&gt;no eBPF replacement&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;ClusterIP services stopped resolving entirely. DNS inside the cluster broke (CoreDNS is a ClusterIP service). My apps couldn't reach the API server. ArgoCD went into a crash loop.&lt;/p&gt;

&lt;p&gt;I had to:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;SSH into the Talos API (not SSH — &lt;code&gt;talosctl&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Re-apply the kube-proxy manifest manually&lt;/li&gt;
&lt;li&gt;Wait for iptables rules to populate&lt;/li&gt;
&lt;li&gt;Restart CoreDNS pods&lt;/li&gt;
&lt;li&gt;Fix ArgoCD's sync state&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Total downtime: &lt;strong&gt;94 minutes&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson 3: Don't delete the old path until the new path is fully monitored. I had eBPF working, but I hadn't validated monitoring. Deleting kube-proxy killed my safety net.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Fix (And What Actually Works)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1: Enable Hubble (Not Optional)
&lt;/h3&gt;

&lt;p&gt;I had installed Cilium with Hubble disabled to "save resources on the Pis." That was stupid. Hubble is the &lt;strong&gt;only&lt;/strong&gt; source of flow visibility in an eBPF-only cluster.&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="c1"&gt;# cilium-values.yaml&lt;/span&gt;
&lt;span class="na"&gt;hubble&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;relay&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;ui&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;metrics&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;dns:query&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;drop&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;tcp&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;flow&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;icmp&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;http&lt;/span&gt;
  &lt;span class="c1"&gt;# CRITICAL: Enable Hubble export for SIEM consumption&lt;/span&gt;
  &lt;span class="na"&gt;export&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;static&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
      &lt;span class="na"&gt;file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/var/log/hubble/flows.json&lt;/span&gt;
        &lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;JSON&lt;/span&gt;
      &lt;span class="na"&gt;fieldMask&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This writes flow logs to &lt;code&gt;/var/log/hubble/flows.json&lt;/code&gt; on each node in a structured JSON format.&lt;/p&gt;




&lt;h3&gt;
  
  
  Step 2: Rewrite the SIEM Pipeline
&lt;/h3&gt;

&lt;p&gt;I replaced the conntrack sidecar with a Hubble reader:&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="c1"&gt;# fluent-bit-daemonset.yaml&lt;/span&gt;
&lt;span class="na"&gt;volumeMounts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hubble-logs&lt;/span&gt;
    &lt;span class="na"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/var/log/hubble&lt;/span&gt;
    &lt;span class="na"&gt;readOnly&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hubble-logs&lt;/span&gt;
    &lt;span class="na"&gt;hostPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/var/log/hubble&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;DirectoryOrCreate&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And updated the Fluent Bit input:&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="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;INPUT&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="s"&gt;Name              tail&lt;/span&gt;
    &lt;span class="s"&gt;Path              /var/log/hubble/flows.json&lt;/span&gt;
    &lt;span class="s"&gt;Parser            json&lt;/span&gt;
    &lt;span class="s"&gt;Tag               k8s.network-flows&lt;/span&gt;
    &lt;span class="s"&gt;# Hubble JSON has nested objects; flatten them&lt;/span&gt;
    &lt;span class="s"&gt;Mem_Buf_Limit     50MB&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  Step 3: Schema Mapping (The Annoying Part)
&lt;/h3&gt;

&lt;p&gt;Conntrack JSON and Hubble JSON have &lt;strong&gt;completely different schemas&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Old (conntrack):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"src_ip"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"10.0.1.15"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"dst_ip"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"10.0.2.88"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"src_port"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;54321&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"dst_port"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;443&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"protocol"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"state"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ESTABLISHED"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"timestamp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-08-05T23:12:00Z"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;New (Hubble):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"flow"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"time"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-08-06T03:45:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"verdict"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"FORWARDED"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"ethernet"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"destination"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"IP"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"10.0.1.15"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"destination"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"10.0.2.88"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"ipVersion"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"IPv4"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"l4"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"TCP"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"source_port"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;54321&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"destination_port"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;443&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"identity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;12345&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"namespace"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"frontend"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"pod_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"api-xxx"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"destination"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"identity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;67890&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"namespace"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"backend"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"pod_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"db-yyy"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"event_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"sub_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"is_reply"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hubble gives you &lt;strong&gt;way more&lt;/strong&gt; — pod identity, namespace, Kubernetes labels, L7 protocol details, policy verdict. But you have to rewrite your SIEM parsers.&lt;/p&gt;

&lt;p&gt;I wrote a small Go transform (ironic, given how I learned it) to flatten Hubble JSON into the old ClickHouse schema, with extra columns for the new metadata:&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="c"&gt;// hubble-flatten/main.go&lt;/span&gt;
&lt;span class="k"&gt;package&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"encoding/json"&lt;/span&gt;
    &lt;span class="s"&gt;"fmt"&lt;/span&gt;
    &lt;span class="s"&gt;"os"&lt;/span&gt;
    &lt;span class="s"&gt;"time"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;HubbleFlow&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Flow&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;Time&lt;/span&gt;     &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"time"`&lt;/span&gt;
        &lt;span class="n"&gt;Verdict&lt;/span&gt;  &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"verdict"`&lt;/span&gt;
        &lt;span class="n"&gt;IP&lt;/span&gt;       &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;Source&lt;/span&gt;      &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"source"`&lt;/span&gt;
            &lt;span class="n"&gt;Destination&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"destination"`&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="s"&gt;`json:"IP"`&lt;/span&gt;
        &lt;span class="n"&gt;L4&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;TCP&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;SourcePort&lt;/span&gt;      &lt;span class="kt"&gt;uint32&lt;/span&gt; &lt;span class="s"&gt;`json:"source_port"`&lt;/span&gt;
                &lt;span class="n"&gt;DestinationPort&lt;/span&gt; &lt;span class="kt"&gt;uint32&lt;/span&gt; &lt;span class="s"&gt;`json:"destination_port"`&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="s"&gt;`json:"TCP"`&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="s"&gt;`json:"l4"`&lt;/span&gt;
        &lt;span class="n"&gt;Source&lt;/span&gt;      &lt;span class="n"&gt;HubIdentity&lt;/span&gt; &lt;span class="s"&gt;`json:"source"`&lt;/span&gt;
        &lt;span class="n"&gt;Destination&lt;/span&gt; &lt;span class="n"&gt;HubIdentity&lt;/span&gt; &lt;span class="s"&gt;`json:"destination"`&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="s"&gt;`json:"flow"`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;HubIdentity&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Identity&lt;/span&gt;  &lt;span class="kt"&gt;uint32&lt;/span&gt; &lt;span class="s"&gt;`json:"identity"`&lt;/span&gt;
    &lt;span class="n"&gt;Namespace&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"namespace"`&lt;/span&gt;
    &lt;span class="n"&gt;PodName&lt;/span&gt;   &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"pod_name"`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c"&gt;// Reads Hubble JSON from stdin, emits flat JSON to stdout&lt;/span&gt;
    &lt;span class="n"&gt;decoder&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewDecoder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Stdin&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;encoder&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewEncoder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Stdout&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;decoder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;More&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="n"&gt;HubbleFlow&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;decoder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&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;continue&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;flat&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="k"&gt;interface&lt;/span&gt;&lt;span class="p"&gt;{}{&lt;/span&gt;
            &lt;span class="s"&gt;"timestamp"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;        &lt;span class="n"&gt;parseTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Flow&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Time&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="s"&gt;"src_ip"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;           &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Flow&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IP&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s"&gt;"dst_ip"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;           &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Flow&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IP&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Destination&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s"&gt;"src_port"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;         &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Flow&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;L4&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TCP&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SourcePort&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s"&gt;"dst_port"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;         &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Flow&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;L4&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TCP&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DestinationPort&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s"&gt;"protocol"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;         &lt;span class="s"&gt;"tcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s"&gt;"verdict"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;          &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Flow&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Verdict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s"&gt;"src_namespace"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;    &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Flow&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Source&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Namespace&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s"&gt;"src_pod"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;          &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Flow&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Source&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PodName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s"&gt;"dst_namespace"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;    &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Flow&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Destination&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Namespace&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s"&gt;"dst_pod"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;          &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Flow&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Destination&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PodName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s"&gt;"src_identity"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;     &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Flow&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Source&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Identity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s"&gt;"dst_identity"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;     &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Flow&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Destination&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Identity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;encoder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;flat&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="n"&gt;parseTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;int64&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;t&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;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RFC3339Nano&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&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;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unix&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;h3&gt;
  
  
  Step 4: Replace Falco Network Rules with Hubble + Cilium Policy
&lt;/h3&gt;

&lt;p&gt;Falco's syscall-based network detection doesn't work well in eBPF-only clusters because:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;eBPF socket load balancing happens &lt;strong&gt;before&lt;/strong&gt; the syscall layer Falco hooks&lt;/li&gt;
&lt;li&gt;ClusterIP connections never appear as "outbound to external IP" because they're redirected at the socket layer&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Solution:&lt;/strong&gt; Use Cilium Network Policies for enforcement, Hubble for observability, and keep Falco for what it's good at (file integrity, process execution, privilege escalation).&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="c1"&gt;# Example: Instead of Falco detecting unauthorized DB connections,&lt;/span&gt;
&lt;span class="c1"&gt;# use Cilium NetworkPolicy to deny them at the kernel level&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cilium.io/v2&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;CiliumNetworkPolicy&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;backend-access-control&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;frontend&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;endpointSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;frontend-api&lt;/span&gt;
  &lt;span class="na"&gt;egressDeny&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;toEndpoints&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;k8s:io.kubernetes.pod.namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;backend&lt;/span&gt;
            &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres&lt;/span&gt;
      &lt;span class="na"&gt;toPorts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;5432"&lt;/span&gt;
          &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;http&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GET"&lt;/span&gt;
  &lt;span class="c1"&gt;# But allow the legitimate API service in backend&lt;/span&gt;
  &lt;span class="na"&gt;egress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;toEndpoints&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;k8s:io.kubernetes.pod.namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;backend&lt;/span&gt;
            &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;backend-api&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This enforces at eBPF TC layer — &lt;strong&gt;before the packet even leaves the pod's network namespace&lt;/strong&gt;. Falco could only tell you after it happened.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Real Numbers
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Performance Impact
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;kube-proxy (iptables)&lt;/th&gt;
&lt;th&gt;Cilium eBPF&lt;/th&gt;
&lt;th&gt;Delta&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Service Latency (p99)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;2.8ms&lt;/td&gt;
&lt;td&gt;1.1ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;-61%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;NodePort Throughput&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1.2 Gbps&lt;/td&gt;
&lt;td&gt;3.8 Gbps&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+217%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Connection Tracking Overhead&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;18% CPU (conntrack)&lt;/td&gt;
&lt;td&gt;4% CPU (BPF)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;-78%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;New Service Propagation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;30-60s (iptables sync)&lt;/td&gt;
&lt;td&gt;&amp;lt;1s (BPF map update)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;-98%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Memory (per node)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;185MB (conntrack table)&lt;/td&gt;
&lt;td&gt;42MB (BPF maps)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;-77%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Observability Impact
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Capability&lt;/th&gt;
&lt;th&gt;Before (conntrack)&lt;/th&gt;
&lt;th&gt;After (Hubble eBPF)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Pod-to-Pod visibility&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;IP only&lt;/td&gt;
&lt;td&gt;Pod name, namespace, labels, identity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;L7 protocol detection&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;HTTP method, path, headers (with L7 proxy)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Policy verdict&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;FORWARDED&lt;/code&gt;, &lt;code&gt;DROPPED&lt;/code&gt;, &lt;code&gt;AUDIT&lt;/code&gt; per packet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DNS visibility&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Query name, response code, TTL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Encryption&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No visibility&lt;/td&gt;
&lt;td&gt;WireGuard vs cleartext flagged per flow&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Legacy SIEM compatibility&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✅ Native&lt;/td&gt;
&lt;td&gt;❌ Requires Hubble export rewrite&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  The One Packet That Broke Everything
&lt;/h2&gt;

&lt;p&gt;Here's the specific failure that cost me 6 hours. I found it in the Hubble flow logs after I got everything working.&lt;/p&gt;

&lt;p&gt;At 23:12:47, my Fluent Bit-to-Kafka pod in the &lt;code&gt;logging&lt;/code&gt; namespace tried to connect to the Kafka bootstrap broker at &lt;code&gt;kafka.logging.svc.cluster.local:9092&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;With kube-proxy, this resolved to &lt;code&gt;10.43.120.15:9092&lt;/code&gt; via iptables DNAT. Falco saw the connection to &lt;code&gt;10.43.120.15:9092&lt;/code&gt; and logged it.&lt;/p&gt;

&lt;p&gt;With eBPF socket load balancing, Cilium's &lt;code&gt;bpf_sock&lt;/code&gt; program intercepted the DNS resolution and redirected the connection &lt;strong&gt;inside the kernel socket layer&lt;/strong&gt; directly to the Kafka pod's IP (&lt;code&gt;10.0.2.88:9092&lt;/code&gt;) without ever creating a conntrack entry or hitting iptables.&lt;/p&gt;

&lt;p&gt;Falco's syscall probes saw a connection to &lt;code&gt;10.0.2.88:9092&lt;/code&gt; but couldn't resolve that IP back to a Kubernetes service (there's no iptables rule mapping it anymore). So Falco discarded the event as "unknown destination."&lt;/p&gt;

&lt;p&gt;My SIEM's conntrack scraper saw nothing because conntrack was empty.&lt;/p&gt;

&lt;p&gt;Hubble saw everything — source pod, destination pod, protocol, policy verdict — but I hadn't enabled Hubble export yet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;That single Kafka bootstrap connection was the canary.&lt;/strong&gt; Every pod in the cluster was connecting to services via eBPF redirection, and my entire security stack was missing all of them.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why I'm Never Going Back
&lt;/h2&gt;

&lt;p&gt;Despite the 6-hour panic, I'm keeping eBPF. Here's why:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Performance is absurd&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;61% latency reduction on service-to-service calls. On Raspberry Pi 4 nodes, that's the difference between "usable" and "painful."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Observability is actually better — once you migrate&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Hubble flow logs tell me not just that pod A talked to pod B, but which Kubernetes identities were involved, whether a network policy allowed or dropped it, and what DNS query initiated it. Falco + conntrack could never give me that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Security enforcement is earlier&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Cilium Network Policies drop packets at the TC (Traffic Control) layer inside the kernel, before they ever hit the pod's network stack. iptables policies happen later. Earlier drop = smaller blast radius.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. No more iptables lock contention&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Adding a new service no longer triggers a 300ms &lt;code&gt;iptables-restore&lt;/code&gt; that freezes all network paths. BPF map updates are atomic and microsecond-fast.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Enable Hubble BEFORE deleting kube-proxy.&lt;/strong&gt; Not after. Not "to save resources." It's not optional.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Run both datapaths in parallel for 48 hours.&lt;/strong&gt; Cilium supports &lt;code&gt;kubeProxyReplacement=disabled&lt;/code&gt; with partial eBPF. I should have tested monitoring against both paths before committing.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Validate SIEM parsers against Hubble JSON before the switch.&lt;/strong&gt; Don't discover schema incompatibilities at 3 AM.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Don't delete the kube-proxy DaemonSet.&lt;/strong&gt; Scale it to zero replicas instead. If eBPF breaks, you can scale it back up in 30 seconds instead of manually re-applying manifests.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Replaced kube-proxy with Cilium eBPF for performance&lt;/li&gt;
&lt;li&gt;eBPF works great for traffic — breaks legacy security tools&lt;/li&gt;
&lt;li&gt;Falco's syscall-based network detection fails with socket-level load balancing&lt;/li&gt;
&lt;li&gt;SIEMs consuming conntrack data go blind — eBPF uses BPF maps, not netfilter&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enable Hubble export before the switch&lt;/strong&gt; — it's your only visibility source&lt;/li&gt;
&lt;li&gt;Hubble gives BETTER data (pod identity, policy verdict, L7) but requires parser rewrites&lt;/li&gt;
&lt;li&gt;Performance gains are real: -61% latency, +217% throughput, -78% CPU overhead&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Don't delete kube-proxy — scale to zero. Keep your escape hatch.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/beltagyy" rel="noopener noreferrer"&gt;beltagyy/homelab&lt;/a&gt; (Talos + Cilium configs will be public this week)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Author:&lt;/strong&gt; Mohamed ElBeltagy (@beltagyy) — Cloud Security &amp;amp; K8s engineer at Siemens&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Topic:&lt;/strong&gt; &lt;code&gt;#kubernetes&lt;/code&gt; &lt;code&gt;#ebpf&lt;/code&gt; &lt;code&gt;#cilium&lt;/code&gt; &lt;code&gt;#security&lt;/code&gt; &lt;code&gt;#devops&lt;/code&gt; &lt;code&gt;#homelab&lt;/code&gt; &lt;code&gt;#performance&lt;/code&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Next article: I wrote a custom eBPF program to detect lateral movement in Kubernetes — and ran it against my own cluster. Want to see what it found?&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>ebpf</category>
      <category>security</category>
      <category>homelab</category>
    </item>
    <item>
      <title>Great article for security concepts</title>
      <dc:creator>Le Beltagy</dc:creator>
      <pubDate>Sun, 02 Aug 2026 12:53:24 +0000</pubDate>
      <link>https://dev.to/le_beltagy/great-article-for-security-concepts-3l7o</link>
      <guid>https://dev.to/le_beltagy/great-article-for-security-concepts-3l7o</guid>
      <description>&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/le_beltagy/i-deployed-a-purple-team-inside-my-kubernetes-cluster-the-scanners-never-saw-it-coming-3igh" class="crayons-story__hidden-navigation-link"&gt;I Deployed a Purple Team Inside My Kubernetes Cluster. The Scanners Never Saw It Coming.&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/le_beltagy" class="crayons-avatar  crayons-avatar--l  "&gt;
            &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1898135%2F5ef7f7dd-7439-4956-856d-fc2c3a00f2ac.jpeg" alt="le_beltagy profile" class="crayons-avatar__image"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/le_beltagy" class="crayons-story__secondary fw-medium m:hidden"&gt;
              Le Beltagy
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                Le Beltagy
                
              
              &lt;div id="story-author-preview-content-4217624" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/le_beltagy" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&gt;
                        &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1898135%2F5ef7f7dd-7439-4956-856d-fc2c3a00f2ac.jpeg" class="crayons-avatar__image" alt=""&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;Le Beltagy&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/le_beltagy/i-deployed-a-purple-team-inside-my-kubernetes-cluster-the-scanners-never-saw-it-coming-3igh" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Jul 23&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/le_beltagy/i-deployed-a-purple-team-inside-my-kubernetes-cluster-the-scanners-never-saw-it-coming-3igh" id="article-link-4217624"&gt;
          I Deployed a Purple Team Inside My Kubernetes Cluster. The Scanners Never Saw It Coming.
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/kubernetes"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;kubernetes&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/security"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;security&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/go"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;go&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/purpleteam"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;purpleteam&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/le_beltagy/i-deployed-a-purple-team-inside-my-kubernetes-cluster-the-scanners-never-saw-it-coming-3igh" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/exploding-head-daceb38d627e6ae9b730f36a1e390fca556a4289d5a41abb2c35068ad3e2c4b5.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/multi-unicorn-b44d6f8c23cdd00964192bedc38af3e82463978aa611b4365bd33a0f1f4f3e97.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;18&lt;span class="hidden s:inline"&gt;&amp;nbsp;reactions&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/le_beltagy/i-deployed-a-purple-team-inside-my-kubernetes-cluster-the-scanners-never-saw-it-coming-3igh#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              1&lt;span class="hidden s:inline"&gt;&amp;nbsp;comment&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            5 min read
          &lt;/small&gt;
            
              &lt;span class="bm-initial crayons-icon c-btn__icon"&gt;
                

              &lt;/span&gt;
              &lt;span class="bm-success crayons-icon c-btn__icon"&gt;
                

              &lt;/span&gt;
            
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;


</description>
      <category>cybersecurity</category>
      <category>devops</category>
      <category>kubernetes</category>
      <category>security</category>
    </item>
    <item>
      <title>I Learned Go in 3 Weeks. Yesterday, My Code Merged into k9s.</title>
      <dc:creator>Le Beltagy</dc:creator>
      <pubDate>Sat, 01 Aug 2026 10:07:53 +0000</pubDate>
      <link>https://dev.to/le_beltagy/i-learned-go-in-3-weeks-yesterday-my-code-merged-into-k9s-2dg4</link>
      <guid>https://dev.to/le_beltagy/i-learned-go-in-3-weeks-yesterday-my-code-merged-into-k9s-2dg4</guid>
      <description>&lt;h1&gt;
  
  
  I Learned Go in 3 Weeks. Yesterday, My Code Merged into k9s.
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;From zero Go experience to a merged PR in the most popular Kubernetes terminal UI — how I misread HTTP/1.1 upgrades, broke my own test suite, and learned that &lt;code&gt;create&lt;/code&gt; and &lt;code&gt;get&lt;/code&gt; are not the same verb when Kubernetes 1.31 changes the rules underneath you.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;p&gt;It started with a single function I didn't write.&lt;/p&gt;

&lt;p&gt;I'd been contributing to &lt;code&gt;k9s&lt;/code&gt; for a week, trying to understand how a 58,000-star project structured its Go code. I was deep in &lt;code&gt;internal/dao/port_forwarder.go&lt;/code&gt;, staring at a block of code that checked whether a user was authorized to open a port-forward on a pod. The check was simple: does this service account have &lt;code&gt;create&lt;/code&gt; permission on &lt;code&gt;pods/portforward&lt;/code&gt;?&lt;/p&gt;

&lt;p&gt;I'd read the Kubernetes docs. Port-forward requires &lt;code&gt;create&lt;/code&gt;. Everyone said so. The code worked. I moved on.&lt;/p&gt;

&lt;p&gt;Then someone opened an issue: &lt;em&gt;"Port-forward fails on k8s 1.31 with restricted RBAC."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Kubernetes 1.31 introduced the &lt;code&gt;PortForwardWebsockets&lt;/code&gt; feature — a new code path that forwards ports over WebSockets instead of SPDY. And WebSockets, unlike SPDY, only require the &lt;code&gt;get&lt;/code&gt; verb on the &lt;code&gt;pods/portforward&lt;/code&gt; subresource.&lt;/p&gt;

&lt;p&gt;That was the bug. The code was checking &lt;code&gt;create&lt;/code&gt;. The new WebSocket path needed &lt;code&gt;get&lt;/code&gt;. Users with &lt;code&gt;get&lt;/code&gt;-only access couldn't port-forward in k9s, even though &lt;code&gt;kubectl&lt;/code&gt; worked fine for them.&lt;/p&gt;

&lt;p&gt;This is the story of how I tried to fix it, failed the first time, and learned more about Go, HTTP upgrades, and Kubernetes authorization in three days than in three weeks of tutorials.&lt;/p&gt;

&lt;p&gt;This is not a tutorial. This is an autopsy of a two-line fix that took me forty-eight hours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Not Just Use kubectl?
&lt;/h2&gt;

&lt;p&gt;I use &lt;code&gt;kubectl port-forward&lt;/code&gt; professionally. It works. It handles the WebSocket migration transparently. There's no bug report. No edge case. No problem.&lt;/p&gt;

&lt;p&gt;But k9s is not kubectl. k9s is a terminal UI that wraps &lt;code&gt;client-go&lt;/code&gt; and presents a unified interface for pod interaction. When k9s shows a pod and lets you press &lt;code&gt;Shift-F&lt;/code&gt; to forward a port, it first checks if you're allowed to do it. And that check was wrong.&lt;/p&gt;

&lt;p&gt;The issue wasn't academic. A user with this RBAC role:&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;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;apiGroups&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pods/portforward"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;verbs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;get"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;could run &lt;code&gt;kubectl port-forward&lt;/code&gt; successfully on Kubernetes 1.31+ (WebSocket path), but k9s would silently grey out the port-forward option, convinced they lacked permission.&lt;/p&gt;

&lt;p&gt;That user was real. The issue had reproduction steps. A real cluster, a real role, a real workflow broken by a verb mismatch I would never have noticed without reading the &lt;code&gt;client-go&lt;/code&gt; source.&lt;/p&gt;

&lt;h2&gt;
  
  
  The k9s Architecture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────────────────────────────────────────────────┐
│                         k9s TUI                              │
│                    (tview / terminal UI)                     │
│  User presses Shift-F on a pod                              │
└──────────────────────┬──────────────────────────────────────┘
                       │
                       ▼
┌─────────────────────────────────────────────────────────────┐
│              internal/dao/port_forwarder.go                  │
│                                                              │
│  1. Check pod is Running ( readiness gate )                 │
│  2. Authorize: can user create pods/portforward?            │
│         ▲                                                    │
│         │ THIS WAS THE BUG                                   │
│  3. If yes → open port-forward session                      │
│     If no  → grey out option / show error                   │
└──────────────────────┬──────────────────────────────────────┘
                       │
              ┌────────┴────────┐
              │                 │
              ▼                 ▼
    ┌─────────────┐     ┌─────────────┐
    │ SPDY path   │     │ WebSocket   │
    │ (legacy)    │     │ (k8s 1.31+) │
    │ needs       │     │ needs       │
    │ CREATE verb │     │ GET verb    │
    └─────────────┘     └─────────────┘
              │                 │
              └───────┬─────────┘
                      ▼
            ┌─────────────────┐
            │  K8s API Server │
            │  /api/v1/.../   │
            │  portforward    │
            └─────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The Decision I Missed
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Path&lt;/th&gt;
&lt;th&gt;Protocol&lt;/th&gt;
&lt;th&gt;Required Verb&lt;/th&gt;
&lt;th&gt;k9s Checked?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Legacy SPDY&lt;/td&gt;
&lt;td&gt;SPDY/HTTP/1.1 upgrade&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;create&lt;/code&gt; on &lt;code&gt;pods/portforward&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;✅ Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WebSocket&lt;/td&gt;
&lt;td&gt;WebSocket (RFC 6455)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;get&lt;/code&gt; on &lt;code&gt;pods/portforward&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;❌ No&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;k9s only checked for &lt;code&gt;create&lt;/code&gt;. It didn't know about the WebSocket path's &lt;code&gt;get&lt;/code&gt; requirement. So &lt;code&gt;get&lt;/code&gt;-only users were blocked even though they were authorized for the code path their cluster actually used.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Failures (And What Each One Taught Me)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Failure 1: I Changed One Word and Called It a Day
&lt;/h3&gt;

&lt;p&gt;My first fix was embarrassingly naive.&lt;/p&gt;

&lt;p&gt;I found the function in &lt;code&gt;port_forwarder.go&lt;/code&gt;. It built an &lt;code&gt;SelfSubjectAccessReview&lt;/code&gt; with verb &lt;code&gt;"create"&lt;/code&gt; and sent it to the API server. I changed it to &lt;code&gt;"get"&lt;/code&gt;. It compiled. I opened a draft PR.&lt;/p&gt;

&lt;p&gt;Within an hour, a maintainer commented: &lt;em&gt;"This breaks backward compatibility for clusters still on the SPDY path."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I had replaced one hardcoded verb with another. I didn't check SPDY. I didn't check both. I didn't even think about clusters running Kubernetes &amp;lt;1.31, or clusters where the WebSocket feature gate was disabled. I assumed "new = right."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root cause:&lt;/strong&gt; I treated a protocol negotiation problem like a string replacement problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; The code needed to check BOTH verbs independently. If &lt;code&gt;create&lt;/code&gt; is authorized, the user can port-forward via SPDY. If &lt;code&gt;get&lt;/code&gt; is authorized, they can port-forward via WebSocket. If either is authorized, k9s should allow the operation. The API server and &lt;code&gt;client-go&lt;/code&gt; handle which protocol actually gets used at connect time.&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="c"&gt;// What I wrote first (WRONG — only checks get)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;utils&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CheckPodPortFwd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;factory&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;factory&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Config&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&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;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"insufficient permission"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// What merged (RIGHT — checks create AND get independently)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;utils&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CheckPodPortFwd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;factory&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;factory&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Config&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;utils&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CheckPodPortFwdGet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;factory&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;factory&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Config&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&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;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"insufficient permission"&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;Actually, the final merged code was cleaner — it added a new &lt;code&gt;CheckPodPortFwdGet&lt;/code&gt; helper and called both from the port-forward check. The key insight: &lt;code&gt;create&lt;/code&gt; and &lt;code&gt;get&lt;/code&gt; are not alternatives that replace each other. They're independent capabilities for independent protocol paths. Both can coexist. Neither can be dropped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; When a platform supports multiple protocols, a fix for the new one cannot delete support for the old one. Test for coexistence, not replacement.&lt;/p&gt;

&lt;h3&gt;
  
  
  Failure 2: I Blew Up the Test Suite Because I Didn't Understand &lt;code&gt;t.Parallel()&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;k9s has a real test suite. Not toy tests — table-driven tests with ~50 test cases across multiple scenarios. I added a test that covered the new WebSocket path. It passed locally.&lt;/p&gt;

&lt;p&gt;Then CI failed.&lt;/p&gt;

&lt;p&gt;The error was a data race in the mock client. I'd instantiated a shared mock &lt;code&gt;RestClient&lt;/code&gt; outside the test loop to save setup code. Some tests ran with &lt;code&gt;t.Parallel()&lt;/code&gt;. Two parallel tests mutated the same mock's response state simultaneously.&lt;/p&gt;

&lt;p&gt;The race detector (&lt;code&gt;go test -race&lt;/code&gt;) caught it and failed the build.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root cause:&lt;/strong&gt; I thought I was being efficient by sharing mock setup. I was actually creating a concurrency hazard in a test file I didn't fully understand.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; I refactored each test case to build its own &lt;code&gt;RestClient&lt;/code&gt; mock inside the test closure. No shared state. The test file I added ended up being 223 lines — almost all of it test cases for every combination of permissions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pod not running&lt;/li&gt;
&lt;li&gt;No &lt;code&gt;get&lt;/code&gt; permission, no &lt;code&gt;create&lt;/code&gt; permission → blocked&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;create&lt;/code&gt;-only permission → allowed (legacy SPDY)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;get&lt;/code&gt;-only permission → allowed (WebSocket path, this was the fix)&lt;/li&gt;
&lt;li&gt;Both permissions → allowed
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// Part of the 223 lines of tests I ended up writing&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"get-only-portforward-allowed"&lt;/span&gt;&lt;span class="p"&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;runningPod&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;authorized&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&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="s"&gt;"selfsubjectaccessreviews"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;"pods"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;                   &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;"portforwardget"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;         &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;want&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="no"&gt;true&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;Lesson:&lt;/strong&gt; Parallel tests are not free. If you don't own the test infrastructure, assume shared state is forbidden until proven otherwise. A &lt;code&gt;data race&lt;/code&gt; in a test is a resume stain.&lt;/p&gt;

&lt;h3&gt;
  
  
  Failure 3: I Didn't Know What an HTTP/1.1 Upgrade Was
&lt;/h3&gt;

&lt;p&gt;The real bug was deeper than a verb string. I needed to understand WHY &lt;code&gt;get&lt;/code&gt; was sufficient for WebSockets but not for SPDY.&lt;/p&gt;

&lt;p&gt;SPDY (the legacy protocol) is initiated by an HTTP &lt;code&gt;POST&lt;/code&gt; request to the port-forward endpoint. HTTP &lt;code&gt;POST&lt;/code&gt; maps to the &lt;code&gt;create&lt;/code&gt; verb in Kubernetes authorization. So SPDY port-forwards require &lt;code&gt;create&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;WebSockets are initiated differently. They start with an HTTP &lt;code&gt;GET&lt;/code&gt; request that includes an &lt;code&gt;Upgrade: websocket&lt;/code&gt; header. The server responds with &lt;code&gt;101 Switching Protocols&lt;/code&gt;, and the connection becomes a WebSocket.&lt;/p&gt;

&lt;p&gt;Because the initial request is &lt;code&gt;GET&lt;/code&gt;, Kubernetes authorization maps it to the &lt;code&gt;get&lt;/code&gt; verb on the &lt;code&gt;pods/portforward&lt;/code&gt; subresource.&lt;/p&gt;

&lt;p&gt;I didn't know any of this when I opened the issue. I spent an evening reading:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;RFC 6455 (The WebSocket Protocol)&lt;/li&gt;
&lt;li&gt;Kubernetes &lt;code&gt;client-go&lt;/code&gt; source for &lt;code&gt;StreamWithContext&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;The KEP for &lt;code&gt;PortForwardWebsockets&lt;/code&gt; (KEP-4006)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;k9s&lt;/code&gt; own issue history for how it handles &lt;code&gt;client-go&lt;/code&gt; deprecation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Root cause:&lt;/strong&gt; I thought port-forward was "one API call." It's actually a protocol negotiation dance between &lt;code&gt;client-go&lt;/code&gt;, the API server, and the kubelet. The authorization model is protocol-dependent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; I didn't change the fix. The code was correct once I checked both verbs. But I did update the error message to indicate both acceptable verbs, so users who were blocked would know exactly which permissions they needed:&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;return&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"user is not authorized to create or get portforward %q"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&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;Lesson:&lt;/strong&gt; Before you fix a bug in a distributed system, understand the protocol it's built on. The symptom was a missing verb. The cause was a protocol upgrade. The fix that didn't understand the protocol would have broken the legacy path.&lt;/p&gt;

&lt;h2&gt;
  
  
  The PR
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stat&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Changed files&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Lines added&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;240&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Lines deleted&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Files touched&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;internal/dao/port_forwarder.go&lt;/code&gt;, &lt;code&gt;internal/dao/port_forwarder_test.go&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Tests added&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;6 test cases covering every permission combination&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Review rounds&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Time from first draft to merge&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;48 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The diff is small. The logic is simple. The effort was in understanding why the logic needed to exist at all — and proving it with tests that would catch someone else making my first-draft mistake.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PR title:&lt;/strong&gt; &lt;code&gt;fix(dao): allow port-forward with 'get' verb on pods/portforward for K8s 1.31+ WebSocket path&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;It merged cleanly. No follow-up fixes needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Learned About Contributing to Big Projects
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Start with the issue, not the code
&lt;/h3&gt;

&lt;p&gt;I read the original issue (#4144) three times before I touched a file. The reporter included their Kubernetes version, their RBAC role, and the exact error message. Without that reproduction, I would have never understood the WebSocket migration context.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Read the test file before the source file
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;port_forwarder_test.go&lt;/code&gt; taught me more about how k9s handles authorization than &lt;code&gt;port_forwarder.go&lt;/code&gt;. Tests are documentation that can't lie.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. &lt;code&gt;go test -race&lt;/code&gt; is not optional
&lt;/h3&gt;

&lt;p&gt;The race detector caught my bug before a maintainer did. It turned an embarrassing review comment into a private CI failure. Run it locally. Always.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Two-line fixes need two-hundred-line tests
&lt;/h3&gt;

&lt;p&gt;The production change was ~20 lines. The tests were 223 lines. That's the ratio in production Go. If your fix doesn't have a test that would have failed before the fix, your fix isn't done.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Backward compatibility is a constraint, not a suggestion
&lt;/h3&gt;

&lt;p&gt;My first draft worked on Kubernetes 1.31. It would have broken k9s for half the user base still on 1.30 or older. A real fix supports both paths simultaneously. This is what separates "it works on my machine" from "it merged into k9s."&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Matters (And Why It Doesn't)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  It matters because:
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;k9s has ~58,000 GitHub stars and thousands of daily users. My fix affects real people with real clusters.&lt;/li&gt;
&lt;li&gt;I learned Go by reading &lt;code&gt;client-go&lt;/code&gt; source and writing table-driven tests, not by building TodoMVC.&lt;/li&gt;
&lt;li&gt;I now understand Kubernetes authorization, HTTP upgrades, and protocol negotiation at a level I didn't before.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  It doesn't matter because:
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;It's a 20-line fix. Senior engineers at Google write diffs like this before coffee.&lt;/li&gt;
&lt;li&gt;I didn't architect a new subsystem. I didn't refactor the codebase. I fixed a verb check.&lt;/li&gt;
&lt;li&gt;The value isn't in the code. The value is in the proof that I can read an issue, understand the context, write a correct fix, survive review, and ship.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the signal I'm building.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next?
&lt;/h2&gt;

&lt;p&gt;I'm continuing to contribute to k9s. The codebase is complex enough to keep teaching me things — how &lt;code&gt;tview&lt;/code&gt; renders terminal UIs, how &lt;code&gt;k9s&lt;/code&gt; watchers avoid polling the API server, how the &lt;code&gt;dao&lt;/code&gt; layer abstracts &lt;code&gt;client-go&lt;/code&gt; operations.&lt;/p&gt;

&lt;p&gt;I also contributed to Checkov today — adding missing GCP taggable resources for Cloud SQL and GKE clusters. Small PR. Two lines. Merged in 20 minutes. That's what happens when you build the muscle: the second contribution is faster than the first.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;I started learning Go three weeks ago. I found a bug in k9s where port-forward authorization only checked the &lt;code&gt;create&lt;/code&gt; verb, missing the &lt;code&gt;get&lt;/code&gt; verb needed by Kubernetes 1.31's new WebSocket path.&lt;/p&gt;

&lt;p&gt;My first fix was wrong — I replaced &lt;code&gt;create&lt;/code&gt; with &lt;code&gt;get&lt;/code&gt; and nearly broke backward compatibility for legacy SPDY clusters. The final fix checks both verbs independently.&lt;/p&gt;

&lt;p&gt;I wrote 223 lines of table-driven tests to cover every permission combination. The PR merged in 48 hours. A &lt;code&gt;data race&lt;/code&gt; in my test taught me that parallel tests don't share mock state.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you're learning a language, don't build tutorial projects. Find a real bug in a real project and fix it. The test suite will teach you more than any course.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What's your first open-source contribution story? Drop it in the comments — especially if you also broke a test suite and lived to tell about it.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>kubernetes</category>
      <category>opensource</category>
      <category>beginners</category>
    </item>
    <item>
      <title>I Run Bare-Metal Kubernetes on $200 of Scrap Hardware (And Why I Burned 3 SD Cards Learning)</title>
      <dc:creator>Le Beltagy</dc:creator>
      <pubDate>Sat, 25 Jul 2026 17:55:01 +0000</pubDate>
      <link>https://dev.to/le_beltagy/i-run-bare-metal-kubernetes-on-200-of-scrap-hardware-and-why-i-burned-3-sd-cards-learning-34cb</link>
      <guid>https://dev.to/le_beltagy/i-run-bare-metal-kubernetes-on-200-of-scrap-hardware-and-why-i-burned-3-sd-cards-learning-34cb</guid>
      <description>&lt;h1&gt;
  
  
  I Run Bare-Metal Kubernetes on $200 of Scrap Hardware (And Why I Burned 3 SD Cards Learning)
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;From a bricked Raspberry Pi to a 4-node Talos Linux cluster running Cilium eBPF, ArgoCD, and Longhorn — every failure, every fix, and the homelab that now hosts my production side projects.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;p&gt;It started with a Dell OptiPlex I found in a recycling pile.&lt;/p&gt;

&lt;p&gt;Intel i5-6500, 16GB DDR4, a 256GB SATA SSD. Someone's discarded office PC. I wiped the Windows 10 sticker off the case, installed Proxmox, and told myself: &lt;em&gt;"This is going to be my cloud."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Three months later, that OptiPlex plus three Raspberry Pi 4s, a second-hand managed switch, and a drawer full of dead SD cards became a &lt;strong&gt;4-node bare-metal Kubernetes cluster&lt;/strong&gt; running:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Talos Linux&lt;/strong&gt; — immutable, API-driven OS built for K8s&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cilium&lt;/strong&gt; — eBPF-based CNI replacing kube-proxy and providing cluster mesh&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ArgoCD&lt;/strong&gt; — GitOps continuous delivery&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Longhorn&lt;/strong&gt; — distributed block storage across nodes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MetalLB&lt;/strong&gt; — bare-metal load balancing with BGP&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prometheus + Grafana&lt;/strong&gt; — monitoring stack pulling metrics from the cluster and my ISP's router&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Total cost: &lt;strong&gt;~$220&lt;/strong&gt; (OptiPlex was free, Pis were ~$60 each, switch was $35 on eBay).&lt;/p&gt;

&lt;p&gt;This is not a tutorial. This is an autopsy of every mistake I made building it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Not Just Use EKS?
&lt;/h2&gt;

&lt;p&gt;I run AWS infrastructure professionally. I can spin up an EKS cluster in 20 minutes with Terraform. I know what managed Kubernetes costs at scale.&lt;/p&gt;

&lt;p&gt;But I was hitting a wall:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. You can't break AWS&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In production, I follow well-architected patterns. VPCs, private subnets, IAM roles with least privilege, GuardDuty, Security Hub. Everything is safe. Everything is boring.&lt;/p&gt;

&lt;p&gt;In my homelab, I can delete the CNI and watch the cluster melt. I can spin up a privileged container with &lt;code&gt;hostNetwork: true&lt;/code&gt; and see what breaks. I can push a bad Cilium network policy and lock myself out of every node — which I did. Twice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The gap between "I passed CKA" and "I understand Kubernetes"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Certifications teach you how to use the API. They do not teach you what happens when etcd runs out of disk because Longhorn replicas ballooned and you didn't set volume quotas. They do not teach you that Cilium's eBPF kube-proxy replacement silently breaks certain firewall rules in ways that are invisible to &lt;code&gt;kubectl&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. If I can't self-host it, do I really know it?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is my rule. I don't put a technology on my CV unless I've operated it without a managed control plane. Talos, Cilium, ArgoCD — I wanted to feel them fail.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Architecture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────────────────────────────────────────────────┐
│                    DELL OPTIPLEX (Control Plane)             │
│                  Talos Linux · x86_64 · 16GB RAM             │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────┐  │
│  │  etcd (q=1) │  │ API Server  │  │  Controller Manager │  │
│  └─────────────┘  └─────────────┘  └─────────────────────┘  │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────┐  │
│  │  Scheduler  │  │ Cilium Agent│  │  Talos API Server   │  │
│  └─────────────┘  └─────────────┘  └─────────────────────┘  │
└─────────────────────────────────────────────────────────────┘
                              │
           ┌──────────────────┼──────────────────┐
           ▼                  ▼                  ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│   RPI-01 (arm64)│ │   RPI-02 (arm64)│ │   RPI-03 (arm64)│
│  4GB RAM · 64GB │ │  4GB RAM · 64GB │ │  4GB RAM · 64GB │
│   USB-SSD boot  │ │   USB-SSD boot  │ │   USB-SSD boot  │
│ ┌─────────────┐ │ │ ┌─────────────┐ │ │ ┌─────────────┐ │
│ │ Cilium Agent│ │ │ │ Cilium Agent│ │ │ │ Cilium Agent│ │
│ │ kubelet     │ │ │ │ kubelet     │ │ │ │ kubelet     │ │
│ │ Longhorn    │ │ │ │ Longhorn    │ │ │ │ Longhorn    │ │
│ │ (replica)   │ │ │ │ (replica)   │ │ │ │ (replica)   │ │
│ └─────────────┘ │ │ └─────────────┘ │ │ └─────────────┘ │
└─────────────────┘ └─────────────────┘ └─────────────────┘
           │                  │                  │
           └──────────────────┼──────────────────┘
                              ▼
                    ┌─────────────────┐
                    │  Netgear GS308  │
                    │  8-Port Gigabit │
                    └─────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Design Decisions
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Decision&lt;/th&gt;
&lt;th&gt;Chose&lt;/th&gt;
&lt;th&gt;Over&lt;/th&gt;
&lt;th&gt;Because&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;OS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Talos Linux&lt;/td&gt;
&lt;td&gt;Ubuntu + kubeadm&lt;/td&gt;
&lt;td&gt;Immutable, API-driven, minimal attack surface, no SSH&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CNI&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Cilium (eBPF)&lt;/td&gt;
&lt;td&gt;Flannel / Calico&lt;/td&gt;
&lt;td&gt;kube-proxy replacement, Hubble observability, cluster mesh ready&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Storage&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Longhorn&lt;/td&gt;
&lt;td&gt;NFS / local-path&lt;/td&gt;
&lt;td&gt;Distributed block storage, snapshots, cross-node replication&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;GitOps&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;ArgoCD&lt;/td&gt;
&lt;td&gt;Flux / manual&lt;/td&gt;
&lt;td&gt;UI for visualizing sync state, familiar to teams&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;MetalLB (L2 mode)&lt;/td&gt;
&lt;td&gt;NodePort&lt;/td&gt;
&lt;td&gt;True LoadBalancer IPs on bare metal, no cloud provider&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Boot&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;USB SSD (all nodes)&lt;/td&gt;
&lt;td&gt;SD cards&lt;/td&gt;
&lt;td&gt;SD cards die under etcd I/O. USB SSDs survive.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  The Failures (And What Each One Taught Me)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Failure 1: I Burned 3 SD Cards in 6 Weeks
&lt;/h3&gt;

&lt;p&gt;My first cluster booted from 64GB SanDisk Ultra SD cards. They were cheap. They were fast — for the first week.&lt;/p&gt;

&lt;p&gt;Then etcd started crashing. The logs showed &lt;code&gt;mvcc: database space exceeded&lt;/code&gt;. I ran &lt;code&gt;etcdctl defrag&lt;/code&gt; and it helped for a day. Then the Pi locked up. I rebooted. The SD card wasn't recognised anymore. Corrupted beyond repair.&lt;/p&gt;

&lt;p&gt;The second SD card died the same way. The third one survived longest because I moved &lt;code&gt;/var/lib/longhorn&lt;/code&gt; to an NFS share — but the card still degraded after 5 weeks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root cause:&lt;/strong&gt; Etcd does frequent small writes. SD cards use wear-leveling algorithms designed for photos (large sequential writes), not databases (random small writes). The flash translation layer dies.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; Every node now boots from a $25 USB3 SSD. Talos supports this natively — you flash the installer ISO, boot from USB, and install to the SSD. The difference is staggering. I've been running the current cluster for 8 months with zero storage-related crashes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; Storage media is not interchangeable. Understand your write patterns before you pick hardware.&lt;/p&gt;




&lt;h3&gt;
  
  
  Failure 2: A Bad CiliumNetworkPolicy Locked Me Out of Every Node
&lt;/h3&gt;

&lt;p&gt;I was experimenting with zero-trust network policies. I applied a CiliumNetworkPolicy that allowed ingress only from pods with label &lt;code&gt;app=frontend&lt;/code&gt;. I forgot to include the &lt;code&gt;kube-system&lt;/code&gt; namespace. I forgot to include Cilium's own health checks. I applied it with &lt;code&gt;kubectl apply -f&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Within 30 seconds, my SSH session to the control plane hung. &lt;code&gt;kubectl&lt;/code&gt; timed out. The Pi nodes showed as &lt;code&gt;NotReady&lt;/code&gt;. I had physically unplugged and rebooted everything before I could get back in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happened:&lt;/strong&gt; Cilium enforces policies at the eBPF level — before packets even reach iptables or the host network stack. A bad policy doesn't just drop traffic; it drops it invisibly. No logs in &lt;code&gt;dmesg&lt;/code&gt;. No &lt;code&gt;iptables -L&lt;/code&gt; entries. Just silence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; Talos has a recovery mechanism. You boot the node with a kernel parameter &lt;code&gt;talos.config=none&lt;/code&gt; and it starts without the network stack enabled. From there, you can mount the Talos EPHEMERAL partition, edit the CNI state, and reboot. But I didn't know this at the time.&lt;/p&gt;

&lt;p&gt;The real fix is procedural:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Always test network policies with &lt;code&gt;cilium policy trace&lt;/code&gt; before applying&lt;/li&gt;
&lt;li&gt;Always keep a &lt;code&gt;default-allow-all&lt;/code&gt; policy in Git, ready to apply&lt;/li&gt;
&lt;li&gt;Never apply network policies on Friday evening (learned the hard way)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; eBPF is powerful because it's invisible. That same invisibility makes debugging a nightmare. Test before you enforce.&lt;/p&gt;




&lt;h3&gt;
  
  
  Failure 3: Longhorn Filled My Disks and kubelet Evicted Everything
&lt;/h3&gt;

&lt;p&gt;I deployed a PostgreSQL database for MarketPulse with a 10Gi Longhorn volume. Longhorn replicates volumes across nodes for HA. I set replica count to 3.&lt;/p&gt;

&lt;p&gt;What I didn't know: Longhorn stores replicas as sparse files that grow as data is written. It also keeps snapshots for backups. My "10Gi" database had 3 replicas × 10Gi × 2 snapshots = 60Gi actual usage.&lt;/p&gt;

&lt;p&gt;Each Pi had a 120Gi SSD. Longhorn consumed 60Gi. Talos system overhead consumed 20Gi. The remaining 40Gi filled up with container images and logs.&lt;/p&gt;

&lt;p&gt;One morning, every pod was &lt;code&gt;Evicted&lt;/code&gt;. kubelet had hit the 85% disk pressure threshold and started killing pods to reclaim space. But Longhorn volumes are block devices — &lt;code&gt;kubelet&lt;/code&gt; can't delete them. The cluster entered a death spiral.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; I now enforce:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Volume quotas in Longhorn (max 2 replicas for non-critical workloads)&lt;/li&gt;
&lt;li&gt;Automatic snapshot cleanup (retain 2, delete older than 7 days)&lt;/li&gt;
&lt;li&gt;Node disk alerts in Prometheus at 70% usage, not 85%&lt;/li&gt;
&lt;li&gt;Container image garbage collection: &lt;code&gt;imageGCThreshold: 70&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; Distributed storage is not free storage. Replicas × snapshots × logs = real disk consumption. Do the math before you deploy.&lt;/p&gt;




&lt;h3&gt;
  
  
  Failure 4: MetalLB Gave Out IPs Already Used by My Router's DHCP Pool
&lt;/h3&gt;

&lt;p&gt;I installed MetalLB in Layer 2 mode and assigned it the range &lt;code&gt;192.168.1.100-192.168.1.150&lt;/code&gt;. I exposed a LoadBalancer service. It got &lt;code&gt;192.168.1.105&lt;/code&gt;. I could curl it from my laptop. Success!&lt;/p&gt;

&lt;p&gt;Three hours later, my wife's laptop couldn't reach the internet. It had been assigned &lt;code&gt;192.168.1.105&lt;/code&gt; by the router's DHCP pool. Same IP. Two MAC addresses. Chaos.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; I logged into my ISP router (which I hate but cannot replace), found the DHCP range, and moved MetalLB to &lt;code&gt;192.168.1.200-192.168.1.250&lt;/code&gt; — outside the DHCP pool.&lt;/p&gt;

&lt;p&gt;Better fix: I later configured MetalLB in &lt;strong&gt;BGP mode&lt;/strong&gt;. My router (running OpenWrt on a separate access point) peers with MetalLB and learns routes dynamically. No IP conflicts. No L2 ARP spoofing. True routing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; IP address management is not optional on bare metal. Your homelab is a real network. Treat it like one.&lt;/p&gt;




&lt;h3&gt;
  
  
  Failure 5: I Upgraded Talos and Lost the Cluster Because I Didn't Backup etcd
&lt;/h3&gt;

&lt;p&gt;Talos makes OS upgrades trivial: &lt;code&gt;talosctl upgrade --image ghcr.io/siderolabs/installer:v1.7.0&lt;/code&gt;. One command, rolling reboot, cluster stays up.&lt;/p&gt;

&lt;p&gt;Except I upgraded from v1.6.0 to v1.7.0 while the cluster was under disk pressure (see Failure 3). The control plane rebooted. etcd failed to start because the WAL files were corrupted from the previous disk-full event. The cluster had no quorum. I had no backup.&lt;/p&gt;

&lt;p&gt;I spent 6 hours trying to recover etcd with &lt;code&gt;etcdctl snapshot restore&lt;/code&gt;. The snapshot I had was 3 weeks old. I lost:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;All ArgoCD application definitions (I had them in Git, so I re-synced)&lt;/li&gt;
&lt;li&gt;All Prometheus metrics history&lt;/li&gt;
&lt;li&gt;All Longhorn volume states (the PVCs survived, but the volume attachments were inconsistent)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Automated etcd snapshots every 6 hours to S3 (yes, I back up my homelab to AWS — the irony is not lost on me)&lt;/li&gt;
&lt;li&gt;Pre-upgrade health checks: disk &amp;lt;70%, all nodes Ready, all Longhorn volumes healthy&lt;/li&gt;
&lt;li&gt;I wrote a tiny Go CLI that wraps &lt;code&gt;talosctl upgrade&lt;/code&gt; with these checks. It lives in my &lt;code&gt;homelab-tools&lt;/code&gt; repo.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; Immutable infrastructure doesn't mean unbreakable infrastructure. Back up your control plane state. Or learn etcd disaster recovery at 2 AM like I did.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Running on It Now?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Workload&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;th&gt;Resource Usage&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;kube-radar&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Public Go CLI for K8s RBAC wildcard detection&lt;/td&gt;
&lt;td&gt;50m CPU, 128Mi mem&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;job-digest&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Python job scraping pipeline&lt;/td&gt;
&lt;td&gt;200m CPU, 256Mi mem&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;MarketPulse API&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Go + FastAPI real-time analytics backend&lt;/td&gt;
&lt;td&gt;500m CPU, 512Mi mem&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PostgreSQL&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Primary DB for MarketPulse (Longhorn-backed)&lt;/td&gt;
&lt;td&gt;300m CPU, 512Mi mem&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Prometheus&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Cluster + app metrics&lt;/td&gt;
&lt;td&gt;400m CPU, 1Gi mem&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Grafana&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Dashboards for everything&lt;/td&gt;
&lt;td&gt;100m CPU, 128Mi mem&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ArgoCD&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;GitOps sync for all workloads&lt;/td&gt;
&lt;td&gt;200m CPU, 512Mi mem&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cilium Hubble&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Network flow observability&lt;/td&gt;
&lt;td&gt;100m CPU, 256Mi mem&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Longhorn&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Storage engine (daemonset)&lt;/td&gt;
&lt;td&gt;150m CPU, 512Mi mem&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The OptiPlex sits at ~60% CPU under normal load. The Pis hover at 40-70%. I can burst higher, but I know the limits.&lt;/p&gt;




&lt;h2&gt;
  
  
  3 Lessons That Transfer to Production
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Resource quotas are not bureaucracy
&lt;/h3&gt;

&lt;p&gt;In my homelab, I have no cost optimization team. When Longhorn eats all disk, I pay with crashed pods and 2 AM debugging. Setting &lt;code&gt;requests&lt;/code&gt; and &lt;code&gt;limits&lt;/code&gt;, configuring PVC sizes, defining retention policies — these are not "enterprise overhead." They are survival mechanisms.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Observability first, workload second
&lt;/h3&gt;

&lt;p&gt;I deployed Prometheus and Grafana before I deployed any application. Not because I'm disciplined — because I learned that debugging a bare-metal cluster without metrics is like performing surgery in the dark. Hubble shows me every dropped packet. Node exporter shows me disk I/O patterns I never would have guessed. In production, I'd push this to Thanos or Cortex. In my homelab, I run a 30-day retention and call it enough.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. GitOps is the only way I trust myself
&lt;/h3&gt;

&lt;p&gt;I have root access to every node. I can &lt;code&gt;kubectl exec&lt;/code&gt; into any pod. Without ArgoCD, I would absolutely make manual changes at 11 PM and forget them by morning. With ArgoCD, the cluster state is in Git. If I change something manually, the UI shows it as &lt;code&gt;OutOfSync&lt;/code&gt; and I feel shame. That shame keeps my cluster declarative.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Hardware List (If You Want to Replicate)
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Spec&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Control plane&lt;/td&gt;
&lt;td&gt;Dell OptiPlex 7050, i5-6500, 16GB&lt;/td&gt;
&lt;td&gt;Free (recycled)&lt;/td&gt;
&lt;td&gt;Any x86_64 box works. Needs VT-x.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Worker nodes&lt;/td&gt;
&lt;td&gt;3× Raspberry Pi 4 (8GB)&lt;/td&gt;
&lt;td&gt;~$60 each&lt;/td&gt;
&lt;td&gt;4GB works, 8GB is comfortable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Worker storage&lt;/td&gt;
&lt;td&gt;3× 120GB USB3 SSD&lt;/td&gt;
&lt;td&gt;~$25 each&lt;/td&gt;
&lt;td&gt;Avoid SD cards for etcd. Just don't.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Switch&lt;/td&gt;
&lt;td&gt;Netgear GS308 8-port gigabit&lt;/td&gt;
&lt;td&gt;$35 (eBay)&lt;/td&gt;
&lt;td&gt;Any managed switch for VLANs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cables&lt;/td&gt;
&lt;td&gt;Cat6 patch cables&lt;/td&gt;
&lt;td&gt;~$15&lt;/td&gt;
&lt;td&gt;Buy a pack&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~$220&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Pi 5 exists now and is faster. I'm staying on Pi 4 because arm64 compat is tested and I'm lazy.&lt;/p&gt;




&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;I built a 4-node bare-metal Kubernetes cluster on $220 of scrap hardware and Raspberry Pis. I use Talos Linux, Cilium eBPF, ArgoCD, Longhorn, and MetalLB.&lt;/p&gt;

&lt;p&gt;I burned SD cards, locked myself out with network policies, filled disks with storage replicas, caused IP conflicts, and corrupted etcd during an upgrade.&lt;/p&gt;

&lt;p&gt;Every failure taught me something I use in production. The cluster now runs my public projects, my monitoring, and my experiments.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you're learning Kubernetes, break it at home before you break it at work.&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;What's your homelab stack? Drop it in the comments — always looking for new failure modes to learn from.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>homelab</category>
      <category>devops</category>
      <category>talos</category>
    </item>
    <item>
      <title>I Deployed a Purple Team Inside My Kubernetes Cluster. The Scanners Never Saw It Coming.</title>
      <dc:creator>Le Beltagy</dc:creator>
      <pubDate>Thu, 23 Jul 2026 19:50:05 +0000</pubDate>
      <link>https://dev.to/le_beltagy/i-deployed-a-purple-team-inside-my-kubernetes-cluster-the-scanners-never-saw-it-coming-3igh</link>
      <guid>https://dev.to/le_beltagy/i-deployed-a-purple-team-inside-my-kubernetes-cluster-the-scanners-never-saw-it-coming-3igh</guid>
      <description>&lt;p&gt;Three security scanners gave my cluster a clean bill of health. Then I deployed an attacker into a DaemonSet and discovered what static analysis actually misses.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;p&gt;My homelab cluster is nothing exotic: a few Hyper-V VMs, a Raspberry Pi worker, and a Talos Linux control plane running the usual suspects: ArgoCD for GitOps, Cilium for CNI, Longhorn for storage. My workload is a typical cloud-native stack: a Go backend, a React frontend, PostgreSQL, the works.&lt;/p&gt;

&lt;p&gt;I did exactly what the security playbooks tell you to do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Trivy&lt;/strong&gt; for container image scanning&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;kube-bench&lt;/strong&gt; for CIS Kubernetes Benchmark compliance&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Polaris&lt;/strong&gt; for best-practice configuration checks&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every single tool reported green. I took a screenshot of the dashboard and felt safe.&lt;/p&gt;

&lt;p&gt;Then something felt wrong.&lt;/p&gt;

&lt;p&gt;A scanner audits what you &lt;strong&gt;declared&lt;/strong&gt;. It does not test what &lt;strong&gt;happens&lt;/strong&gt; when things go wrong. It is like a home inspector checking your door locks while ignoring the open window around the corner. I wanted a burglar on my side.&lt;/p&gt;

&lt;p&gt;So I built one.&lt;/p&gt;




&lt;h2&gt;
  
  
  Introducing NEMESIS
&lt;/h2&gt;

&lt;p&gt;NEMESIS is a Kubernetes-native purple team platform. It lives &lt;strong&gt;inside&lt;/strong&gt; the cluster as a DaemonSet, not outside as a scheduled scan. It does not read manifests and compare them against registries. It executes attack scenarios from the same pod network your real workloads use.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Stack
&lt;/h3&gt;

&lt;p&gt;The platform splits along two languages for very specific reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Python Controller&lt;/strong&gt; — The orchestration brain. Schedules attack scenarios, correlates events, and emits findings. Python was the right call here: rapid scenario development and rich security libraries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Go Attack Agent&lt;/strong&gt; — A DaemonSet on every node. Executes the actual attack primitives: token abuse, lateral movement, network probing. Go gives me a single static binary, sub-second cold-start, and zero dependency hell inside a distroless container.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Attack Engine
&lt;/h3&gt;

&lt;p&gt;Scenarios are not hard-coded exploits. They are a composable library of primitives:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Recon&lt;/strong&gt; — Enumerate service accounts, roles, secrets, and network policies from inside the pod&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Token Abuse&lt;/strong&gt; — Mount and abuse service account tokens against the API server&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lateral Movement&lt;/strong&gt; — Test east-west connectivity between namespaces with no external tooling&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exfil Probe&lt;/strong&gt; — DNS and HTTP egress tests to measure actual network policy enforcement&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  eBPF Integration
&lt;/h3&gt;

&lt;p&gt;Because the cluster runs Cilium, NEMESIS hooks into eBPF-based network telemetry. Every packet the attack agent sends is observable at the kernel layer. This means I do not just know &lt;em&gt;that&lt;/em&gt; an attack path exists; I have flow-level proof of exactly which packet traversed which interface.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Attack Chain That Scanners Missed
&lt;/h2&gt;

&lt;p&gt;Here is one scenario, step by step. I anonymized nothing else because the configuration is common.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Recon
&lt;/h3&gt;

&lt;p&gt;The attack agent starts inside a pod in the &lt;code&gt;default&lt;/code&gt; namespace. It enumerates service accounts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://kubernetes.default.svc/api/v1/namespaces/default/serviceaccounts &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /var/run/secrets/kubernetes.io/serviceaccount/token&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It finds the &lt;code&gt;default&lt;/code&gt; service account. Then it checks its bindings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://kubernetes.default.svc/apis/rbac.authorization.k8s.io/v1/namespaces/default/rolebindings
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Result: a &lt;code&gt;ClusterRoleBinding&lt;/code&gt; named &lt;code&gt;cluster-reader-default&lt;/code&gt; grants &lt;code&gt;cluster-reader&lt;/code&gt; to the default SA across &lt;strong&gt;all namespaces&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Token Abuse
&lt;/h3&gt;

&lt;p&gt;With cluster-reader permissions, the agent queries every namespace for secrets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://kubernetes.default.svc/api/v1/secrets &lt;span class="nt"&gt;--all-namespaces&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It finds a ConfigMap in the &lt;code&gt;backend&lt;/code&gt; namespace named &lt;code&gt;db-config&lt;/code&gt; containing a PostgreSQL connection string with a hard-coded password.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the scanner said:&lt;/strong&gt; "ConfigMap &lt;code&gt;db-config&lt;/code&gt; exists. No hardcoded secrets detected in source image."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What NEMESIS found:&lt;/strong&gt; The ConfigMap is readable by any pod using the default service account, which is every pod in the cluster that does not explicitly override it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Lateral Movement
&lt;/h3&gt;

&lt;p&gt;The agent lists pods across namespaces:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://kubernetes.default.svc/api/v1/pods &lt;span class="nt"&gt;--all-namespaces&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It identifies the backend API pod in the &lt;code&gt;backend&lt;/code&gt; namespace. It checks the &lt;code&gt;NetworkPolicy&lt;/code&gt; for that namespace:&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="c1"&gt;# backend-network-policy.yaml&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;networking.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;NetworkPolicy&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-deny&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;backend&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;podSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{}&lt;/span&gt;
  &lt;span class="na"&gt;policyTypes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Ingress&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What the scanner said:&lt;/strong&gt; "NetworkPolicy exists. Default-deny posture implemented."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What NEMESIS found:&lt;/strong&gt; The policy only specifies &lt;code&gt;Ingress&lt;/code&gt;. There is &lt;strong&gt;no Egress&lt;/strong&gt; policy. The backend pod can open outbound connections to anything.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Data Exfil Test
&lt;/h3&gt;

&lt;p&gt;The agent attempts a DNS lookup to an external domain controlled by the test harness:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nslookup exfil-test.nemesis.internal
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cilium's eBPF probe captures the packet at the kernel socket layer. The DNS query reaches the cluster DNS, which forwards it upstream. No policy blocked it.&lt;/p&gt;

&lt;p&gt;NEMESIS logs this as: &lt;strong&gt;"DNS exfiltration path confirmed — no egress filtering on namespace &lt;code&gt;backend&lt;/code&gt;."&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Gap: What You Declare vs. What You Get
&lt;/h2&gt;

&lt;p&gt;Out of 47 unique findings NEMESIS surfaced across three weeks of continuous testing, here is the breakdown versus what the traditional scanner suite caught:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Finding Category&lt;/th&gt;
&lt;th&gt;NEMESIS&lt;/th&gt;
&lt;th&gt;Scanner Suite&lt;/th&gt;
&lt;th&gt;Gap&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Lateral movement paths&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Service account token abuse&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;89%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Network exfiltration (DNS/HTTP)&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Network policy bypass&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runtime container escape&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;67%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Image CVEs&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Covered by scanner&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CIS misconfigurations&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Partial overlap&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Manifest best practices&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Covered by scanner&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;47&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;20&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~81% unique to NEMESIS&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;To be completely fair: the scanners caught every image vulnerability and most CIS misconfigurations. They are excellent at what they do. They just do not test &lt;strong&gt;runtime behavior&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why This Changes How You Think About K8s Security
&lt;/h2&gt;

&lt;p&gt;The industry has optimized for speed of detection. But detection speed means nothing if you are detecting the wrong things.&lt;/p&gt;

&lt;p&gt;Scanners answer: &lt;em&gt;"Is this configuration compliant?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Purple teams answer: &lt;em&gt;"What happens when someone already inside tries to move?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Both questions matter. But in a world where supply chain attacks, compromised CI pipelines, and insider threats are the dominant risk models, the second question is the one that actually keeps you awake.&lt;/p&gt;

&lt;p&gt;NEMESIS is not a replacement for scanning. It is an additional layer that validates your assumptions at runtime, continuously, from the same vantage point a real attacker would have.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Learned Building This
&lt;/h2&gt;

&lt;p&gt;The Go-vs-Python split was not philosophical. It was operational. The agent needs to start in under a second when a node scales up, handle network timeouts gracefully, and compile to a single binary that fits in a &lt;code&gt;scratch&lt;/code&gt; container. Go wins here without debate.&lt;/p&gt;

&lt;p&gt;The controller needs to parse YAML attack definitions, talk to the Kubernetes API, and integrate with Cilium's Hubble for flow data. Python's ecosystem made this painless.&lt;/p&gt;

&lt;p&gt;The hardest part was not the attack code. It was building the &lt;strong&gt;telemetry correlation&lt;/strong&gt;: matching an eBPF flow event to a specific attack scenario step so you can say with certainty: &lt;em&gt;"The DNS leak on step 4 of scenario &lt;code&gt;backend-exfil-001&lt;/code&gt; originated from pod &lt;code&gt;backend-api-7d9f4b8c5-x2k9m&lt;/code&gt; at 14:23:07 UTC."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Without that correlation, purple team findings are just noise.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where NEMESIS Goes Next
&lt;/h2&gt;

&lt;p&gt;The current milestone focuses on Kubernetes, but the architecture is generic. The same DaemonSet pattern applies to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Container runtime testing&lt;/strong&gt; (Docker socket abuse, privileged escalation)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloud metadata service abuse&lt;/strong&gt; (IMDSv2 bypass attempts on AWS/Azure nodes)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storage layer attacks&lt;/strong&gt; (Longhorn volume snapshot tampering, RWO bypass)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The goal is not to build a scanner with a different skin. The goal is to shift the security posture from &lt;em&gt;"we read your configuration"&lt;/em&gt; to &lt;em&gt;"we prove what an attacker can actually do."&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Try It Yourself
&lt;/h2&gt;

&lt;p&gt;If you want to see what your scanners are missing, the fastest path is deliberate:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Spin up a throwaway pod in your cluster&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;kubectl exec&lt;/code&gt; into it&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;curl --cacert /var/run/secrets/kubernetes.io/serviceaccount/ca.crt -H "Authorization: Bearer $(cat /var/run/secrets/kubernetes.io/serviceaccount/token)" https://kubernetes.default.svc/api/v1/namespaces/default/secrets&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;See what comes back&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you see more than an empty list, you have found your first gap without writing a single line of code.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;NEMESIS is an open-source Kubernetes-native purple team platform. If you want to follow the build, attack scenario library, or contribute, the repository is at &lt;a href="https://github.com/beltagyy/nemesis" rel="noopener noreferrer"&gt;github.com/beltagyy/nemesis&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Built with Go, Python, Cilium, and enough caffeine to make a kernel panic look calm.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>security</category>
      <category>go</category>
      <category>purpleteam</category>
    </item>
    <item>
      <title>I Built a Job-Scraping Bot to Escape Cloud Consulting (And It's Open Source)</title>
      <dc:creator>Le Beltagy</dc:creator>
      <pubDate>Wed, 22 Jul 2026 10:50:05 +0000</pubDate>
      <link>https://dev.to/le_beltagy/i-built-a-job-scraping-bot-to-escape-cloud-consulting-and-its-open-source-1m8a</link>
      <guid>https://dev.to/le_beltagy/i-built-a-job-scraping-bot-to-escape-cloud-consulting-and-its-open-source-1m8a</guid>
      <description>&lt;h1&gt;
  
  
  I Built a Job-Scraping Bot to Escape Cloud Consulting (And It's Open Source)
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;From manually copy-pasting cover letters to a Python pipeline that hunts Dutch tech roles while I sleep — the architecture, the failures, and why I need your help.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;p&gt;I had applied to &lt;strong&gt;73 jobs&lt;/strong&gt; in 4 weeks.&lt;/p&gt;

&lt;p&gt;Not casual browsing. I mean:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Custom cover letters per role&lt;/li&gt;
&lt;li&gt;CV tweaks for Platform Eng vs SRE vs Cloud Security&lt;/li&gt;
&lt;li&gt;LinkedIn cold messages to hiring managers&lt;/li&gt;
&lt;li&gt;Tracking everything in a Notion database&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result? Ghosted by 61. Rejected by 8. Phone screen with 4.&lt;/p&gt;

&lt;p&gt;Meanwhile, I was working full-time at Siemens, studying Go for kube-radar, and maintaining a homelab that was literally on fire one Tuesday because a Cilium policy update bricked the cluster network. I was burning out on the &lt;em&gt;process&lt;/em&gt; of job searching instead of actually &lt;em&gt;getting better&lt;/em&gt; at it.&lt;/p&gt;

&lt;p&gt;So I did what any engineer would do: I automated the pain away.&lt;/p&gt;

&lt;p&gt;That's how &lt;code&gt;job-digest&lt;/code&gt; started — a Python pipeline that scrapes job boards, filters by my exact criteria, ranks roles by match quality, and delivers a daily summary to my Telegram. It didn't get me hired overnight. But it got me &lt;strong&gt;interviewed by companies I actually wanted to work for&lt;/strong&gt; — and it taught me more about web scraping, async Python, and data pipelines than any course.&lt;/p&gt;

&lt;p&gt;This is the story of building it. The code is public. I want collaborators. If you're job-hunting in tech and you know Python, read on.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why This Problem?
&lt;/h2&gt;

&lt;p&gt;Cloud consulting taught me how to build systems. It did not teach me how to sell myself.&lt;/p&gt;

&lt;p&gt;When I decided to relocate to Amsterdam 🇳🇱 and target backend/platform roles, I faced three problems no recruiter warns you about:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The signal-to-noise ratio is brutal&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Job boards are flooded with "Cloud Engineer" roles that are actually MSP helpdesk jobs, "DevOps" roles that mean "you're the only ops person for 40 devs," and "Kubernetes" roles at companies running Docker Compose on a single EC2 instance.&lt;/p&gt;

&lt;p&gt;I needed filtering that understood &lt;em&gt;my&lt;/em&gt; stack: Go, Kubernetes internals, Cilium/eBPF, Azure + AWS multi-cloud, security compliance (NIS2/DORA).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Timing asymmetry&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The best roles at Adyen, Booking.com, ASML — they go live on Tuesday morning and have 200 applicants by Thursday afternoon. I was checking boards manually on Sunday evenings. I was always late.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Context switching kills deep work&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every 20-minute "let me check LinkedIn" session destroyed my flow state. I wanted &lt;em&gt;one&lt;/em&gt; daily digest at 7 AM. Read it on the train. Apply to the top 3 roles. Then close the tab and write Go code.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Architecture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────┐    ┌──────────────┐    ┌──────────────┐    ┌─────────────┐
│  Scrapers   │───►│   Enricher   │───►│    Ranker    │───►│   Notifier  │
│ (async)     │    │ (LLM + rules)│    │ (scoring)    │    │ (Telegram)  │
└─────────────┘    └──────────────┘    └──────────────┘    └─────────────┘
     │                                              │
     ▼                                              ▼
 LinkedIn Jobs                              SQLite cache
 Indeed                                     (dedup + history)
 Glassdoor
 We Work Remotely
 RemoteOK
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Design Decisions
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Decision&lt;/th&gt;
&lt;th&gt;Chose&lt;/th&gt;
&lt;th&gt;Over&lt;/th&gt;
&lt;th&gt;Because&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Language&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Python 3.11 + asyncio&lt;/td&gt;
&lt;td&gt;Go&lt;/td&gt;
&lt;td&gt;Rapid prototyping, BeautifulSoup/httpx ecosystem, LLM SDKs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Storage&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;SQLite&lt;/td&gt;
&lt;td&gt;PostgreSQL&lt;/td&gt;
&lt;td&gt;Zero infra, portable, single file. WAL mode for concurrent reads.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Scraping&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;httpx + BeautifulSoup&lt;/td&gt;
&lt;td&gt;Selenium/Playwright&lt;/td&gt;
&lt;td&gt;Faster, lighter, cheaper. JavaScript-rendered boards get headless fallback.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Rate limiting&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Adaptive backoff (exponential + jitter)&lt;/td&gt;
&lt;td&gt;Fixed delays&lt;/td&gt;
&lt;td&gt;Respects robots.txt without being slower than necessary.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LLM enrichment&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;OpenAI GPT-4o-mini&lt;/td&gt;
&lt;td&gt;Local LLM&lt;/td&gt;
&lt;td&gt;Cost: ~$0.02 per 100 jobs. Quality worth it for extraction.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Delivery&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Telegram Bot API&lt;/td&gt;
&lt;td&gt;Email&lt;/td&gt;
&lt;td&gt;I live in Telegram. Push notification = instant awareness.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Scheduling&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;cron on homelab RPi&lt;/td&gt;
&lt;td&gt;GitHub Actions&lt;/td&gt;
&lt;td&gt;Free, persistent, no cold start. Runs at 06:00 CET daily.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  The Pipeline (Simplified)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# 1. COLLECT — async scrapers, one per source
&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;scrape_linkedin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;location&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;RawJob&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="c1"&gt;# httpx + BeautifulSoup
&lt;/span&gt;    &lt;span class="c1"&gt;# Extract: title, company, location, description, salary, url, posted_at
&lt;/span&gt;    &lt;span class="c1"&gt;# Returns list of RawJob dataclasses
&lt;/span&gt;
&lt;span class="c1"&gt;# 2. NORMALIZE — unify schema across sources
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;RawJob&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Job&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Map "Amsterdam, North Holland" → "Amsterdam"
&lt;/span&gt;    &lt;span class="c1"&gt;# Extract seniority: Junior / Mid / Senior / Staff from title
&lt;/span&gt;    &lt;span class="c1"&gt;# Detect remote policy: onsite / hybrid / remote
&lt;/span&gt;    &lt;span class="c1"&gt;# Parse salary ranges from description text (regex + LLM fallback)
&lt;/span&gt;
&lt;span class="c1"&gt;# 3. FILTER — hard rules (cheap, deterministic)
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;passes_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Job&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;location&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;TARGET_CITIES&lt;/span&gt;  &lt;span class="c1"&gt;# Amsterdam, Berlin, Dublin, etc.
&lt;/span&gt;        &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;seniority&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Senior&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Staff&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Principal&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kw&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&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;kw&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;TECH_KEYWORDS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;company&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;BLOCKLIST&lt;/span&gt;  &lt;span class="c1"&gt;# agencies, body shops
&lt;/span&gt;    &lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# 4. ENRICH — LLM judges fit quality
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;enrich_with_llm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Job&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;EnrichedJob&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
    Role: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; at &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;company&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;
    Description excerpt: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;

    Candidate profile: 6 years cloud/platform, K8s internals (Cilium, Talos),
    Go learner, Azure+AWS, security compliance (NIS2/DORA).
    Target: Platform Engineer or Backend Engineer in Amsterdam/Berlin/Dublin.

    Tasks:
    1. Is this role genuinely backend/platform work (not IT support, not MSP)?
    2. Does the tech stack mention K8s, Go, distributed systems, or cloud-native?
    3. Visa sponsorship likely? (big tech yes, small local shop probably no)
    4. Score 0-100 for fit.
    5. Is it worth applying? yes/no/maybe.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;openai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;EnrichedJob&lt;/span&gt;&lt;span class="p"&gt;(...,&lt;/span&gt; &lt;span class="n"&gt;llm_score&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;verdict&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# 5. RANK — composite score
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;EnrichedJob&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;llm_score&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
        &lt;span class="nf"&gt;salary_score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;salary_max&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
        &lt;span class="nf"&gt;company_tier_score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;company&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;  &lt;span class="c1"&gt;# Tier 1 = Adyen/Booking/ASML/etc.
&lt;/span&gt;        &lt;span class="nf"&gt;recency_score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;posted_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.1&lt;/span&gt;        &lt;span class="c1"&gt;# Fresh postings score higher
&lt;/span&gt;    &lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# 6. NOTIFY — formatted Telegram message
&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;send_digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;EnrichedJob&lt;/span&gt;&lt;span class="p"&gt;]):&lt;/span&gt;
    &lt;span class="n"&gt;msg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;format_telegram&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;  &lt;span class="c1"&gt;# top 10 only
&lt;/span&gt;    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;telegram_bot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chat_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;MY_CHAT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The Telegram Output
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;🔍 Daily Job Digest — 2026-07-22
18 new roles | 4 match your filters | Top 3 below

━━━━━━━━━━━━━━━━━━━━
🥇 94 pts — Adyen — Platform Engineer
   📍 Amsterdam 🇳🇱  |  💰 €90-120K  |  🏠 Hybrid
   🛠 Go · K8s · Kafka · Terraform
   📝 "Build internal primitives for developer platforms..."
   ✅ LLM: "Strong match. Go-heavy platform team. Sponsors HSM."
   🔗 https://careers.adyen.com/jobs/...

🥈 88 pts — Booking.com — SRE
   📍 Amsterdam 🇳🇱  |  💰 €85-110K  |  🏠 Hybrid
   🛠 K8s · Prometheus · Python/Go · Cilium
   ✅ LLM: "Excellent infra depth match. Large K8s fleet."

🥉 81 pts — N26 — Platform Engineer
   📍 Berlin 🇩🇪  |  💰 €80-110K  |  🏠 Hybrid
   🛠 Go · K8s · Terraform · Microservices
   ✅ LLM: "Good fit. Fintech, EU Blue Card likely."

📊 Actions: /apply N26  |  /dismiss Adyen  |  /details Booking
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  5 Failures Before It Worked
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Failure 1: LinkedIn Rate-Limited Me in 6 Minutes
&lt;/h3&gt;

&lt;p&gt;My first scraper used a single httpx session with a 2-second delay between requests. I was proud of the backoff. LinkedIn was not impressed.&lt;/p&gt;

&lt;p&gt;They returned a 429 after ~30 requests and started serving CAPTCHA pages. I added:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Rotating User-Agent headers (from a pool of 50 real browser strings)&lt;/li&gt;
&lt;li&gt;Proxy rotation via free proxy lists (terrible idea — 80% were dead)&lt;/li&gt;
&lt;li&gt;Randomized delays (1-5 seconds with jitter)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fix that actually worked: &lt;strong&gt;scrape LinkedIn's public job search JSON endpoint instead of HTML parsing.&lt;/strong&gt; It returns structured data at &lt;code&gt;/jobs-guest/jobs/api/jobs&lt;/code&gt; and is significantly more stable. I also dropped proxy rotation and just respected a 5-second delay. LinkedIn is aggressive but fair.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; Don't fight the frontend. Find the API the frontend uses.&lt;/p&gt;

&lt;h3&gt;
  
  
  Failure 2: The LLM Hallucinated a Role at Google That Didn't Exist
&lt;/h3&gt;

&lt;p&gt;I blindly trusted GPT-4o-mini's extraction for the first week. It invented a "Kubernetes Platform Engineer" role at Google Amsterdam with a salary of €500K. The URL 404'd. I had already told my wife about it.&lt;/p&gt;

&lt;p&gt;The fix: every scraped job gets a &lt;strong&gt;live URL validation&lt;/strong&gt; (HEAD request, follow redirects). If the URL doesn't return 200, it's dropped before enrichment. LLMs are pattern matchers, not fact checkers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; LLMs are enrichment, not validation. Never trust their facts without verification.&lt;/p&gt;

&lt;h3&gt;
  
  
  Failure 3: I Applied to the Same Job 4 Times
&lt;/h3&gt;

&lt;p&gt;My SQLite schema initially keyed jobs by &lt;code&gt;(title, company)&lt;/code&gt;. Then a company reposted the same role with a slightly different title. I applied twice. The second time, the recruiter replied: "You already applied last week."&lt;/p&gt;

&lt;p&gt;The fix: &lt;strong&gt;content hashing.&lt;/strong&gt; I now hash the normalized job description (after removing whitespace and dates). Same description = same job, even if the title changes. Deduplication is fuzzy, not exact.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; Job boards are messy. Exact matching is naive.&lt;/p&gt;

&lt;h3&gt;
  
  
  Failure 4: Running It on My Laptop Meant It Died When I Closed the Lid
&lt;/h3&gt;

&lt;p&gt;I initially ran the script manually: &lt;code&gt;python main.py&lt;/code&gt;, wait 8 minutes, get results. But I kept forgetting. And when I closed my MacBook, the script died.&lt;/p&gt;

&lt;p&gt;The fix: a headless Raspberry Pi 4 in my homelab runs a cron job at 06:00 CET. It has a persistent SQLite file on an external SSD. The Pi also runs my DNS (Pi-hole), my monitoring (Prometheus), and now my job search. One device, many duties.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; Automations that depend on you remembering to run them are not automations.&lt;/p&gt;

&lt;h3&gt;
  
  
  Failure 5: I Got 18 "Matches" and Felt Overwhelmed
&lt;/h3&gt;

&lt;p&gt;Even with filtering and scoring, getting 18 "good" roles in one digest meant I applied to none. Decision paralysis. I would read all 18, open 12 tabs, and close the laptop exhausted.&lt;/p&gt;

&lt;p&gt;The fix: &lt;strong&gt;the digest shows top 3 by default.&lt;/strong&gt; To see 4-10, you reply &lt;code&gt;/more&lt;/code&gt;. To see the rest, &lt;code&gt;/all&lt;/code&gt;. This tiny UX change meant I actually applied to the top 3 instead of bookmarking 12.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; More data is not better. More decisions is worse.&lt;/p&gt;




&lt;h2&gt;
  
  
  Results (So Far)
&lt;/h2&gt;

&lt;p&gt;I won't claim this got me hired. I'm still interviewing. But here's what changed:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Time spent browsing jobs/day&lt;/td&gt;
&lt;td&gt;45 min&lt;/td&gt;
&lt;td&gt;5 min reading digest&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Applications/week&lt;/td&gt;
&lt;td&gt;~8 (sporadic)&lt;/td&gt;
&lt;td&gt;~12 (consistent)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Roles matching my stack&lt;/td&gt;
&lt;td&gt;~30% of browsed&lt;/td&gt;
&lt;td&gt;~85% of digest&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time-to-apply after posting&lt;/td&gt;
&lt;td&gt;3-4 days&lt;/td&gt;
&lt;td&gt;&amp;lt;24 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Relevant recruiter conversations/month&lt;/td&gt;
&lt;td&gt;1-2&lt;/td&gt;
&lt;td&gt;4-5&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The real win: &lt;strong&gt;mental bandwidth.&lt;/strong&gt; I stopped treating job search as a thing I do when I'm tired at 10 PM. It became a system that runs without me.&lt;/p&gt;

&lt;p&gt;Companies where the pipeline surfaced roles I wouldn't have found manually:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A stealth-mode fintech in Amsterdam hiring Go platform engineers (not on LinkedIn, found via We Work Remotely)&lt;/li&gt;
&lt;li&gt;A CDN company in Berlin rebuilding their edge on Cilium + eBPF (mentioned in a blog post the scraper indexed)&lt;/li&gt;
&lt;li&gt;A Series B startup in Dublin with ex-Google SREs building a K8s-native security product (literally my exact niche)&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The Code
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/beltagyy/job-digest.git
&lt;span class="nb"&gt;cd &lt;/span&gt;job-digest
python &lt;span class="nt"&gt;-m&lt;/span&gt; venv .venv
&lt;span class="nb"&gt;source&lt;/span&gt; .venv/bin/activate
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt

&lt;span class="nb"&gt;cp &lt;/span&gt;config.example.yaml config.yaml
&lt;span class="c"&gt;# Edit: add your OpenAI key, Telegram bot token, target cities, tech keywords&lt;/span&gt;
python main.py &lt;span class="nt"&gt;--run-now&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What you get:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Modular scrapers (add a new source in ~50 lines)&lt;/li&gt;
&lt;li&gt;SQLite cache with WAL mode&lt;/li&gt;
&lt;li&gt;LLM enrichment with prompt templates&lt;/li&gt;
&lt;li&gt;Telegram bot interface with simple commands&lt;/li&gt;
&lt;li&gt;Docker support for headless deployment&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What you need:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Python 3.11+, an OpenAI API key (~$5/month at my volume)&lt;/li&gt;
&lt;li&gt;A Telegram bot (free via &lt;a class="mentioned-user" href="https://dev.to/botfather"&gt;@botfather&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;A server that runs cron (RPi, VPS, old laptop)&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  I Need Collaborators
&lt;/h2&gt;

&lt;p&gt;This project helps me, but it's built for &lt;strong&gt;my&lt;/strong&gt; profile and &lt;strong&gt;my&lt;/strong&gt; targets. To make it useful for others, I need help with:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. More scrapers&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;AngelList / Wellfound (startup roles)&lt;/li&gt;
&lt;li&gt;Hacker News "Who is Hiring?" (monthly thread parser)&lt;/li&gt;
&lt;li&gt;Greenhouse / Workday ATS backends (many companies use these and have predictable JSON APIs)&lt;/li&gt;
&lt;li&gt;Country-specific boards (StepStone.de, Indeed.nl, IrishJobs.ie)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;2. Better filtering&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Visually parse "years of experience" requirements and flag "possibly underqualified" vs "definitely qualified"&lt;/li&gt;
&lt;li&gt;Detect visa sponsorship mentions from description text (pattern-based, not just LLM)&lt;/li&gt;
&lt;li&gt;Filter out roles from recruiting agencies vs direct employers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;3. Smarter enrichment&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Local LLM option (Ollama/Llama 3.1) for people who don't want OpenAI costs&lt;/li&gt;
&lt;li&gt;Company research: pull funding stage, Glassdoor rating, team size from public APIs&lt;/li&gt;
&lt;li&gt;Salary benchmarking: compare offered range to levels.fyi / Glassdoor data&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;4. Alternative frontends&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Discord bot (many communities live there)&lt;/li&gt;
&lt;li&gt;Email digest (for non-Telegram users)&lt;/li&gt;
&lt;li&gt;Web dashboard (Streamlit or similar) for browsing history and stats&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;5. Testing &amp;amp; reliability&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Scrapers break when job boards change HTML. We need snapshot tests (save HTML, assert extraction still works).&lt;/li&gt;
&lt;li&gt;Mock LLM responses for unit tests (expensive to hit OpenAI in CI).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If any of this sounds like you — especially if you're job-hunting in EU tech and want a tool that actually understands your stack — &lt;strong&gt;open an issue or PR.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Even if you're not technical: try it, tell me what job board you want added, and I'll prioritize it.&lt;/p&gt;




&lt;h2&gt;
  
  
  3 Lessons That Transfer Beyond Job Searching
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Automate the boring, not the important
&lt;/h3&gt;

&lt;p&gt;I automated &lt;em&gt;discovery&lt;/em&gt; and &lt;em&gt;filtering&lt;/em&gt;. I did not automate &lt;em&gt;applying&lt;/em&gt; or &lt;em&gt;interviewing&lt;/em&gt;. The bot finds roles. I still write every cover letter by hand and research every company before applying. The pipeline handles noise; I handle signal.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Your side project's tech stack should teach you something
&lt;/h3&gt;

&lt;p&gt;I chose Python + asyncio because I already knew Python but had never built a production async pipeline. I learned:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;asyncio.gather&lt;/code&gt; for parallel scrapers&lt;/li&gt;
&lt;li&gt;SQLite WAL mode and connection pooling&lt;/li&gt;
&lt;li&gt;Prompt engineering for structured LLM output (JSON mode, function calling)&lt;/li&gt;
&lt;li&gt;Rate limiting and backpressure&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your side project doesn't stretch you, it's just work you don't get paid for.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Ship before it's perfect
&lt;/h3&gt;

&lt;p&gt;The first version didn't have LLM enrichment. It was just a list of job titles and URLs sorted by posting date. It was ugly. It still saved me 30 minutes a day.&lt;/p&gt;

&lt;p&gt;I added scoring later. Then filtering. Then the Telegram bot. Each increment made it better, but the core value (stop manually browsing job boards) existed on day 1.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Core pipeline (scrape → filter → notify)&lt;/td&gt;
&lt;td&gt;✅ shipped&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LLM enrichment&lt;/td&gt;
&lt;td&gt;✅ shipped&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SQLite dedup + history&lt;/td&gt;
&lt;td&gt;✅ shipped&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;More scrapers (HN, Wellfound, StepStone)&lt;/td&gt;
&lt;td&gt;🔄 in progress&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Company enrichment (funding, Glassdoor)&lt;/td&gt;
&lt;td&gt;📋 planned&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Web dashboard&lt;/td&gt;
&lt;td&gt;📋 planned&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Resume-to-job matching score&lt;/td&gt;
&lt;td&gt;📋 planned&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;**&lt;br&gt;
🚀 Try it:** &lt;a href="https://github.com/beltagyy/job-digest" rel="noopener noreferrer"&gt;https://github.com/beltagyy/job-digest&lt;/a&gt;&lt;br&gt;
**&lt;br&gt;
🤝 Collaborate:** Open an issue with the tag &lt;code&gt;help-wanted&lt;/code&gt; or DM me &lt;/p&gt;

&lt;h2&gt;
  
  
  **
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;If you're job-hunting right now — what's the most frustrating part? Drop it in the comments. If it's common enough, I'll add it to the pipeline.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;I applied to 73 jobs in 4 weeks, got exhausted by the process, and built a Python bot (&lt;code&gt;job-digest&lt;/code&gt;) to automate job-board scraping, filtering, and ranking.&lt;/li&gt;
&lt;li&gt;It's a pipeline: async scrapers → LLM enrichment → scoring → daily Telegram digest. Runs on a Raspberry Pi at 6 AM.&lt;/li&gt;
&lt;li&gt;It cut my daily job-browsing time from 45 minutes to 5, and surfaced roles at companies I actually wanted (Adyen, Booking, N26) that I would have missed manually.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open source and looking for collaborators:&lt;/strong&gt; &lt;code&gt;github.com/beltagyy/job-digest&lt;/code&gt;. I need help with scrapers, filtering rules, local-LLM support, and a web dashboard.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>python</category>
      <category>career</category>
      <category>opensource</category>
      <category>automation</category>
    </item>
    <item>
      <title>I Learned Go by Hacking Kubernetes RBAC Security (And Nearly Quit 5 Times)</title>
      <dc:creator>Le Beltagy</dc:creator>
      <pubDate>Sun, 19 Jul 2026 22:03:03 +0000</pubDate>
      <link>https://dev.to/le_beltagy/i-learned-go-by-hacking-kubernetes-rbac-security-and-nearly-quit-5-times-3pnp</link>
      <guid>https://dev.to/le_beltagy/i-learned-go-by-hacking-kubernetes-rbac-security-and-nearly-quit-5-times-3pnp</guid>
      <description>&lt;h1&gt;
  
  
  I Learned Go by Hacking Kubernetes RBAC Security (And Nearly Quit 5 Times)
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;From zero Go experience to shipping kube-radar — a Kubernetes security CLI that detects RBAC wildcard violations. The story, the architecture, the mistakes, and why I did it the hard way.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;p&gt;I manage Kubernetes clusters at Siemens. Not "I run kubectl apply from my laptop" manage — I mean Talos Linux on bare metal, Cilium replacing kube-proxy, eBPF policies doing deep packet inspection, ArgoCD running GitOps end-to-end. The kind of infra where one bad RoleBinding can cost you the crown jewels.&lt;/p&gt;

&lt;p&gt;But here's the problem: &lt;strong&gt;I had never written production Go.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;My code was Python — automation, Lambdas, Flask services, AI pipelines. My Go experience was exactly one Pulumi IaC program where I mostly copy-pasted from examples and hoped &lt;code&gt;go build&lt;/code&gt; didn't yell at me.&lt;/p&gt;

&lt;p&gt;I wanted to become what I call a &lt;strong&gt;"backend + cloud-native mega engineer"&lt;/strong&gt; — someone who can architect a distributed system &lt;em&gt;and&lt;/em&gt; ship the code that runs inside it. Not just configure the cluster, but write the controller. Not just deploy the service, but design the API.&lt;/p&gt;

&lt;p&gt;So I made a deal with myself:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Build one real, public, useful project in Go. Ship it. Then use it as proof.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's how &lt;code&gt;kube-radar&lt;/code&gt; started.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Project?
&lt;/h2&gt;

&lt;p&gt;Kubernetes RBAC is a nightmare to audit.&lt;/p&gt;

&lt;p&gt;If you've ever done a security review on a real cluster, you know the pain:&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;rbac.authorization.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ClusterRole&lt;/span&gt;
&lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;apiGroups&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;verbs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a &lt;strong&gt;cluster-admin equivalent&lt;/strong&gt; disguised as a single rule. Anyone bound to this can do &lt;em&gt;anything&lt;/em&gt; in your cluster. And unlike &lt;code&gt;cluster-admin&lt;/code&gt;, it doesn't show up in obvious places like &lt;code&gt;kubectl get clusterrolebindings&lt;/code&gt; when you're scanning for privilege escalation.&lt;/p&gt;

&lt;p&gt;Wildcard rules (&lt;code&gt;verbs: ["*"]&lt;/code&gt;, &lt;code&gt;resources: ["*"]&lt;/code&gt;, &lt;code&gt;apiGroups: ["*"]&lt;/code&gt;) are the silent killers of K8s security posture. They slip past most CNAPP tools because they're &lt;em&gt;technically&lt;/em&gt; valid — they just give away the kingdom.&lt;/p&gt;

&lt;p&gt;I wanted a tool that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Scans&lt;/strong&gt; any cluster's RBAC in seconds&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flags&lt;/strong&gt; wildcard over-permissions with severity&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tracks&lt;/strong&gt; findings across scans (is this new, or did someone just accept it?)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gates CI/CD&lt;/strong&gt; with exit codes — fail the build when someone commits a wildcard rule&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reports&lt;/strong&gt; in SARIF for GitHub Advanced Security integration&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And I wanted to build it from scratch in a language I'd never shipped in. Because if I can ship this, I can ship anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Learning Curve (Or: How I Nearly Gave Up 5 Times)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Attempt 1: The Cathedral Plan
&lt;/h3&gt;

&lt;p&gt;I sat down and wrote a 5,000-word design document.&lt;/p&gt;

&lt;p&gt;I'm not kidding:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Full SQLite schema with WAL mode, migrations, and ON DELETE CASCADE&lt;/li&gt;
&lt;li&gt;Fingerprinting strategy with SHA-256 canonical hashing&lt;/li&gt;
&lt;li&gt;Cobra CLI with 6 subcommands&lt;/li&gt;
&lt;li&gt;Rule engine with Go interfaces and a plugin registry&lt;/li&gt;
&lt;li&gt;Diff engine with set operations across snapshots&lt;/li&gt;
&lt;li&gt;.goreleaser cross-compilation for 4 platforms&lt;/li&gt;
&lt;li&gt;SARIF 2.1.0 schema validation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It was beautiful. It was also &lt;strong&gt;unbuildable&lt;/strong&gt; for someone learning Go from zero. After 3 weeks of wrestling with &lt;code&gt;database/sql&lt;/code&gt; semantics and SQLite driver imports, I had a schema but no working code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson 1: A perfect design you can't ship is worthless.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Attempt 2: Copy-Paste Panic
&lt;/h3&gt;

&lt;p&gt;I found a GitHub repo that did something vaguely similar. I copy-pasted Go client-go List calls, cobra wiring, even error wrapping. It compiled! It ran! It... panic'd on startup because I shadowed &lt;code&gt;err&lt;/code&gt; in an &lt;code&gt;if&lt;/code&gt; block and the outer function didn't know.&lt;/p&gt;

&lt;p&gt;Go's error-as-values model broke my brain. In Python, I'd raise an exception and catch it somewhere. In Go, the error is just a return value you need to handle &lt;em&gt;every single time&lt;/em&gt;. My code was 40% error handling by line count.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson 2: You can't paste your way to understanding.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Attempt 3: The Interface Rabbit Hole
&lt;/h3&gt;

&lt;p&gt;The design doc said "Rule interface type." I spent a full day understanding why &lt;code&gt;interface{}&lt;/code&gt; isn't the same as a typed interface, why empty interfaces are a smell unless you need them, and why Go composition beats inheritance so completely.&lt;/p&gt;

&lt;p&gt;It clicked when I realized: a Rule in my analyzer isn't a class. It's a contract.&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;type&lt;/span&gt; &lt;span class="n"&gt;Rule&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;Describe&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;Description&lt;/span&gt;
    &lt;span class="n"&gt;Evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Snapshot&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Finding&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;KR001 (wildcard verbs), KR002 (wildcard resources), KR003 (wildcard apiGroups) — all three are just structs that implement those three methods. No inheritance tree. No base class. Just behavior.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson 3: Go interfaces are small, implicit, and compositional. Stop thinking in classes.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Attempt 4: client-go vs. Kubernetes API
&lt;/h3&gt;

&lt;p&gt;Here's something nobody tells you: &lt;code&gt;kubectl&lt;/code&gt; does a &lt;em&gt;lot&lt;/em&gt; of heavy lifting that &lt;code&gt;client-go&lt;/code&gt; doesn't.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;kubectl config current-context&lt;/code&gt; uses shell-level kubeconfig resolution. &lt;code&gt;client-go&lt;/code&gt;'s &lt;code&gt;clientcmd.BuildConfigFromFlags&lt;/code&gt; doesn't. It took me 2 hours to figure out why my code was connecting to the wrong cluster — &lt;code&gt;client-go&lt;/code&gt; reads the kubeconfig file but doesn't use the &lt;code&gt;current-context&lt;/code&gt; field unless you explicitly load 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="c"&gt;// This does NOT use your shell's "kubectl config current-context"&lt;/span&gt;
&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;clientcmd&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BuildConfigFromFlags&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kubeconfigPath&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;// This DOES — it loads the kubeconfig and resolves context, cluster, user&lt;/span&gt;
&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;clientcmd&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewNonInteractiveDeferredLoadingClientConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;clientcmd&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ClientConfigLoadingRules&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ExplicitPath&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;kubeconfigPath&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;clientcmd&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ConfigOverrides&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;CurrentContext&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;contextName&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ClientConfig&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That single discovery changed how I build the &lt;code&gt;kube-radar scan&lt;/code&gt; command.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson 4: client-go is lower-level than kubectl in ways that matter.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Attempt 5: Determinism
&lt;/h3&gt;

&lt;p&gt;My first version printed findings in random order every time. Tests flaked. Diff was impossible. Fingerprints changed because I included rule index in the hash.&lt;/p&gt;

&lt;p&gt;The fix was a lesson in Go's core philosophy: if the language doesn't guarantee something, you must enforce it yourself. Go map iteration is &lt;em&gt;deliberately&lt;/em&gt; randomized. So you sort. Always.&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="n"&gt;sort&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;findings&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;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="kt"&gt;int&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;findings&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Severity&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;findings&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Severity&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;findings&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Severity&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;findings&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Severity&lt;/span&gt; &lt;span class="c"&gt;// desc&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;findings&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fingerprint&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;findings&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fingerprint&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;Lesson 5: Randomness is a feature of the language. Determinism is your job.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture
&lt;/h2&gt;

&lt;p&gt;Here's what I shipped — and more importantly, &lt;strong&gt;why&lt;/strong&gt; the architecture looks like this:&lt;/p&gt;

&lt;h3&gt;
  
  
  Design Decisions
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Decision&lt;/th&gt;
&lt;th&gt;Chose&lt;/th&gt;
&lt;th&gt;Over&lt;/th&gt;
&lt;th&gt;Because&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SQLite driver&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;modernc.org/sqlite&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;mattn/go-sqlite3&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Zero CGO = trivial cross-compilation. 2× slower, irrelevant at this scale.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;State&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Local SQLite&lt;/td&gt;
&lt;td&gt;Stateless linter&lt;/td&gt;
&lt;td&gt;Diff/baseline is the differentiator; stateless tools already exist&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Role handling&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Unified &lt;code&gt;RoleLike&lt;/code&gt; type&lt;/td&gt;
&lt;td&gt;Separate Role/ClusterRole types&lt;/td&gt;
&lt;td&gt;Rules written once; kind preserved for reporting severity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Migrations&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Hand-rolled (~20 lines)&lt;/td&gt;
&lt;td&gt;goose/golang-migrate&lt;/td&gt;
&lt;td&gt;Zero deps, more learning value. Revisit when migrations exceed ~10 files.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Persistence&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Per-machine SQLite DB&lt;/td&gt;
&lt;td&gt;Centralized API/DB&lt;/td&gt;
&lt;td&gt;CLI-first tool; no server to maintain, no network dependency&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Fingerprinting&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;SHA-256 canonical content hash&lt;/td&gt;
&lt;td&gt;UUID per finding&lt;/td&gt;
&lt;td&gt;Stable across rescans; "same problem" = "same fingerprint"&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  The Fingerprint System (The Most Underrated Part)
&lt;/h3&gt;

&lt;p&gt;This is where most security tools fail: they generate "new findings" every scan because of random IDs or timestamps. My fingering system:&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="c"&gt;// v1|&amp;lt;rule_id&amp;gt;|&amp;lt;kind&amp;gt;|&amp;lt;namespace&amp;gt;|&amp;lt;name&amp;gt;|&amp;lt;canonical_rule&amp;gt;&lt;/span&gt;
&lt;span class="n"&gt;canonical_rule&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sort&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;and&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;apiGroups&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="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;sort&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;and&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resources&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="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;sort&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;and&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;verbs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;fp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v1&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;KR001&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;ClusterRole&lt;/span&gt;&lt;span class="o"&gt;||&lt;/span&gt;&lt;span class="n"&gt;admin&lt;/span&gt;&lt;span class="o"&gt;|*|*|*&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="m"&gt;16&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;Key properties:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stable across rescans&lt;/strong&gt;: Same cluster state → same fingerprint&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Survives reordering&lt;/strong&gt;: If someone reorders rules in a Role, the fingerprint doesn't change&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Detects narrowing&lt;/strong&gt;: &lt;code&gt;verbs: ["*"]&lt;/code&gt; → &lt;code&gt;verbs: ["*"]&lt;/code&gt; + &lt;code&gt;resources: ["pods"]&lt;/code&gt; is a &lt;em&gt;different&lt;/em&gt; finding (content changed)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This makes the &lt;code&gt;diff&lt;/code&gt; feature in v0.2 trivial: it's just set arithmetic on fingerprint maps.&lt;/p&gt;

&lt;h3&gt;
  
  
  Exit Codes as CI Contract
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0 — Clean scan, no new findings ≥ --fail-on
1 — New unacknowledged findings ≥ --fail-on severity threshold
2 — Operational error (unreachable cluster, bad flags, DB failure)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why only 3 codes? Because CI pipelines need simple logic: &lt;code&gt;kube-radar scan &amp;amp;&amp;amp; deploy || fail&lt;/code&gt; works with zero tooling.&lt;/p&gt;

&lt;h2&gt;
  
  
  What It Looks Like
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;kube-radar scan &lt;span class="nt"&gt;--context&lt;/span&gt; homelab &lt;span class="nt"&gt;--fail-on&lt;/span&gt; medium

RULE    | KIND        | NAMESPACE | NAME          | DETAIL
&lt;span class="nt"&gt;--------&lt;/span&gt;|-------------|-----------|---------------|------------------
KR001   | ClusterRole | -         | admin-legacy  | Wildcard verbs: &lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;
KR002   | Role        | default   | app-service   | Wildcard resources: &lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;
KR003   | ClusterRole | -         | cluster-admin | Wildcard apiGroups: &lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;

3 findings &lt;span class="o"&gt;(&lt;/span&gt;1 Critical, 1 Medium, 1 Low&lt;span class="o"&gt;)&lt;/span&gt;  &lt;span class="nb"&gt;exit &lt;/span&gt;code: 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;exit code: 1&lt;/code&gt; means CI fails. That means the wildcard rule &lt;strong&gt;does not get deployed&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  3 Lessons That Transfer Beyond This Project
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Design the hard thing in writing first
&lt;/h3&gt;

&lt;p&gt;My 5,000-word design doc wasn't a waste. It was a map. When I cut the scope to v0.1 (no SQLite, no diff, no SARIF), I knew exactly what each deferred feature looked like because I'd already designed it. The doc became the roadmap.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Build for yourself, then write about the pain
&lt;/h3&gt;

&lt;p&gt;Every feature in &lt;code&gt;kube-radar&lt;/code&gt; solves a problem I actually have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Can't find wildcard rules quickly → the scan command&lt;/li&gt;
&lt;li&gt;Can't tell if a finding is new → the diff feature (v0.2)&lt;/li&gt;
&lt;li&gt;Can't baseline accepted risk → acks (v0.2)&lt;/li&gt;
&lt;li&gt;Can't integrate with GitHub Security → SARIF (v0.2)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Tooling built from real pain is better than tooling built for a portfolio.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. A shipped v0.1 beats a perfect v2.0
&lt;/h3&gt;

&lt;p&gt;I had to fight my own instinct to build everything at once. v0.1 is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;1 command (&lt;code&gt;scan&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;3 rules (KR001-003)&lt;/li&gt;
&lt;li&gt;Table output only&lt;/li&gt;
&lt;li&gt;Fingerprints computed but not yet persisted&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But it &lt;strong&gt;works&lt;/strong&gt;. It already catches real violations. It already fails CI builds. Everything else is additive.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Roadmap
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Version&lt;/th&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;v0.1&lt;/strong&gt; ✅&lt;/td&gt;
&lt;td&gt;Scan, 3 rules, table output, fingerprints, exit codes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;v0.2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;SQLite store, diff, history, acks/baselines, JSON + SARIF&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;v0.3&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Full rule pack: cluster-admin bindings, secrets read, pods/exec, impersonate, escalate/bind&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;v1.0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Multi-context fleet scan, config file, goreleaser&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;v2.0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Controller mode: watch RBAC, Prometheus metrics, admission warnings&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/beltagyy/kube-radar.git
&lt;span class="nb"&gt;cd &lt;/span&gt;kube-radar
go build &lt;span class="nt"&gt;-o&lt;/span&gt; kube-radar ./cmd/kube-radar
./kube-radar scan &lt;span class="nt"&gt;--kubeconfig&lt;/span&gt; ~/.kube/config &lt;span class="nt"&gt;--fail-on&lt;/span&gt; medium
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or grab a release binary (when I cut v0.1.0 — soon).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I need from you:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Does it work on your cluster?&lt;/li&gt;
&lt;li&gt;What's missing for your use case?&lt;/li&gt;
&lt;li&gt;Are there wildcard variants I'm not catching?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every issue helps me learn.&lt;/p&gt;

&lt;h2&gt;
  
  
  About This Series
&lt;/h2&gt;

&lt;p&gt;This is post 1 in the &lt;strong&gt;"kube-radar: Building a Security CLI in Go"&lt;/strong&gt; series. Next up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Deep-dive: Why I chose SQLite over in-memory state&lt;/li&gt;
&lt;li&gt;Deep-dive: Designing deterministic fingerprints for security findings&lt;/li&gt;
&lt;li&gt;Deep-dive: The Go interfaces that made the rule engine testable&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're learning Go from a cloud background like I am — or if you're a backend engineer who wants to understand K8s security internals — follow along.&lt;/p&gt;




&lt;p&gt;*Questions? I'm &lt;a href="https://github.com/beltagyy" rel="noopener noreferrer"&gt;@beltagyy&lt;/a&gt; on GitHub and &lt;a href="https://dev.to/le_beltagy"&gt;beltagyy&lt;/a&gt; on DEV.&lt;/p&gt;

</description>
      <category>go</category>
      <category>kubernetes</category>
      <category>security</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Processing 1 Million Events Per Day Without Breaking the Bank (Or Your Database)</title>
      <dc:creator>Le Beltagy</dc:creator>
      <pubDate>Sun, 19 Jul 2026 09:27:51 +0000</pubDate>
      <link>https://dev.to/le_beltagy/processing-1-million-events-per-day-without-breaking-the-bank-or-your-database-3i30</link>
      <guid>https://dev.to/le_beltagy/processing-1-million-events-per-day-without-breaking-the-bank-or-your-database-3i30</guid>
      <description>&lt;h1&gt;
  
  
  Processing 1 Million Events Per Day Without Breaking the Bank (Or Your Database)
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;The real-world math behind event batching, and why it changed everything for me&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;VehicleMetrics receives sensor data from vehicles in real-time.&lt;/p&gt;

&lt;p&gt;Each vehicle sends: &lt;strong&gt;10 GPS updates/second + 100 sensor readings/second = 110 messages/second per vehicle&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;With 50 vehicles: &lt;strong&gt;5,500 messages/second = 473 million messages per day&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I needed to:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Ingest fast&lt;/strong&gt; (&amp;lt; 50ms response time)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Store reliably&lt;/strong&gt; (no data loss)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not go bankrupt&lt;/strong&gt; (costs matter at scale)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not break the database&lt;/strong&gt; (PostgreSQL can only write so fast)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is where most people fail. They think: "Just insert each event into the database."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wrong.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Naive Approach (That Destroys Databases)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# ❌ NAIVE: Direct insert per event
&lt;/span&gt;&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/api/vehicles/{vehicle_id}/sensor-data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;ingest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vehicle_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;SensorData&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INSERT INTO sensor_data VALUES (...)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;vehicle_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;speed&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;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;created&lt;/span&gt;&lt;span class="sh"&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;The math:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;5,500 events/second × 60 = 330,000 inserts/minute&lt;/li&gt;
&lt;li&gt;Each insert: ~1ms database overhead&lt;/li&gt;
&lt;li&gt;PostgreSQL write throughput: ~10,000 inserts/second max&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Result: Queue builds. Latency explodes. System crashes.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What Actually Works: Batch Processing
&lt;/h2&gt;

&lt;p&gt;Instead of inserting individually, &lt;strong&gt;batch events and write them together&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# ✅ REAL: Batch events before writing
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;gather&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;EventBatcher&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;batch_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flush_interval&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;batch_size&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;batch_size&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;flush_interval&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;flush_interval&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;queue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Lock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lock&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;batch_size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_flush&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_flush&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;

        &lt;span class="c1"&gt;# Batch write to database
&lt;/span&gt;        &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;[:]&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;queue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;

        &lt;span class="c1"&gt;# Single multi-row insert
&lt;/span&gt;        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;INSERT INTO sensor_data (vehicle_id, timestamp, speed, sensors)
               VALUES &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(%s, %s, %s, %s)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
            &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;val&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;val&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;vehicle_id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;timestamp&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;speed&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;sensors&lt;/span&gt;&lt;span class="sh"&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="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Flushed &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; events&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;batcher&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;EventBatcher&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;batch_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flush_interval&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/api/vehicles/{vehicle_id}/sensor-data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;ingest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vehicle_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;SensorData&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;vehicle_id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;vehicle_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;timestamp&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;speed&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;speed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;sensors&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sensors&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;# Add to batch (doesn't block)
&lt;/span&gt;    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;batcher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Return immediately (&amp;lt; 5ms)
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;queued&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Flush periodically
&lt;/span&gt;&lt;span class="nd"&gt;@app.on_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;startup&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;flush_periodically&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;batcher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_flush&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;The math changes:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Instead of 5,500 individual inserts/second&lt;/li&gt;
&lt;li&gt;We do 6 batch inserts/second (5,500 ÷ 1,000 batch size)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Database load: 5,500 events written, but only 6 inserts executed&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Result: Database stays relaxed. Latency stays low. We scale.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The Real Numbers
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Before Batching:
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Throughput: 50 rps (bottlenecked by database)
Latency: 2,500ms (queued inserts waiting)
Database CPU: 95% (constant write pressure)
Cost: $150/month RDS (need bigger instance)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  After Batching:
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Throughput: 1,000+ rps (limited by API, not database)
Latency: 18ms (batch waits 5 seconds max)
Database CPU: 15% (batches are efficient)
Cost: $100/month RDS (smaller instance works fine)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;That's 20x throughput improvement + 33% cost reduction.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Batching Trade-offs
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Trade-off 1: Latency vs Throughput
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Option C: Real-time + Batch split
# - Real-time data: Redis stream (for dashboard)
# - Persistent data: Batch to database (for analytics)
&lt;/span&gt;
&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/api/vehicles/{vehicle_id}/sensor-data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;ingest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vehicle_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;SensorData&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# Real-time: Update Redis immediately
&lt;/span&gt;    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;vehicle:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;vehicle_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:latest&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;mapping&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;speed&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;speed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;timestamp&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timestamp&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="c1"&gt;# Persistent: Add to batch (writes later)
&lt;/span&gt;    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;batcher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;vehicle_id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;vehicle_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;timestamp&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;speed&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;speed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;sensors&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sensors&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;accepted&lt;/span&gt;&lt;span class="sh"&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;Result:&lt;/strong&gt; Dashboard gets data instantly (Redis), analytics get data within 1-5 seconds (database batch).&lt;/p&gt;




&lt;h2&gt;
  
  
  Advanced: Batch Size Selection
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Formula:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;batch_size = (target_latency × events_per_second) / batches_per_second

Example:
- Target latency: 5 seconds (acceptable delay)
- Events per second: 5,500
- Desired batches per second: 10 (don't stress DB)

batch_size = (5 × 5,500) / 10 = 2,750
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The Real Cost Analysis
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Without Batching:
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;RDS Instance&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;db.t3.large ($300/month)&lt;/span&gt;
&lt;span class="na"&gt;Kinesis&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10 shards ($300/month)&lt;/span&gt;
&lt;span class="na"&gt;Lambda&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Heavy ($50/month)&lt;/span&gt;
&lt;span class="na"&gt;EC2 API servers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10 instances ($500/month)&lt;/span&gt;

&lt;span class="na"&gt;Total&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;~$1,150/month&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  With Batching:
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;RDS Instance&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;db.t3.small ($100/month)&lt;/span&gt;
&lt;span class="na"&gt;Kinesis&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1 shard ($30/month)&lt;/span&gt;
&lt;span class="na"&gt;Lambda&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Minimal ($5/month)&lt;/span&gt;
&lt;span class="na"&gt;EC2 API servers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2 instances ($100/month)&lt;/span&gt;

&lt;span class="na"&gt;Total&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;~$235/month&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Batching saves 80% on infrastructure costs.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Don't insert events one-at-a-time&lt;/strong&gt; (database will hate you)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Batch 500-2,000 events&lt;/strong&gt; (sweet spot)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flush every 1-5 seconds&lt;/strong&gt; (or when batch is full)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Split real-time (Redis) from persistent (batch)&lt;/strong&gt; (best of both)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Result: 20x throughput, 80% cost savings&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The real lesson: &lt;strong&gt;One tiny optimization (batching) can change your entire infrastructure.&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/beltagyy/vehicle-metrics" rel="noopener noreferrer"&gt;beltagyy/vehicle-metrics&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Author:&lt;/strong&gt; Mohamed ElBeltagy (@beltagyy)&lt;br&gt;
&lt;strong&gt;Topic:&lt;/strong&gt; Real-Time Data | Performance | Scale&lt;/p&gt;

</description>
      <category>performance</category>
      <category>database</category>
      <category>scalability</category>
      <category>optimization</category>
    </item>
    <item>
      <title>Kubernetes for Solo Developers: Stop Overthinking It (And Start Using It)</title>
      <dc:creator>Le Beltagy</dc:creator>
      <pubDate>Sat, 18 Jul 2026 13:10:33 +0000</pubDate>
      <link>https://dev.to/le_beltagy/kubernetes-for-solo-developers-stop-overthinking-it-and-start-using-it-3m1e</link>
      <guid>https://dev.to/le_beltagy/kubernetes-for-solo-developers-stop-overthinking-it-and-start-using-it-3m1e</guid>
      <description>&lt;h1&gt;
  
  
  Kubernetes for Solo Developers: Stop Overthinking It (And Start Using It)
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;Why a solo developer should absolutely be running their production system on Kubernetes, and why it's not as scary as you think&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Myth
&lt;/h2&gt;

&lt;p&gt;Most developers think Kubernetes is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;❌ Too complex&lt;/li&gt;
&lt;li&gt;❌ For big companies only&lt;/li&gt;
&lt;li&gt;❌ Overkill for solo projects&lt;/li&gt;
&lt;li&gt;❌ Requires DevOps expertise&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;I thought that too.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Then I actually deployed VehicleMetrics on EKS and realized:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Kubernetes solved more problems for me than it created.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Why I Actually Use Kubernetes (Solo Dev)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Problem 1: Manual Deployments Are Terrifying
&lt;/h3&gt;

&lt;p&gt;Before Kubernetes:&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="c"&gt;# SSH into server&lt;/span&gt;
ssh ubuntu@prod-server

&lt;span class="c"&gt;# Stop old app&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl stop myapp

&lt;span class="c"&gt;# Deploy new code&lt;/span&gt;
&lt;span class="nb"&gt;cd&lt;/span&gt; /app &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git pull

&lt;span class="c"&gt;# Start new app&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl start myapp

&lt;span class="c"&gt;# Pray nothing broke&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With Kubernetes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; deployment.yaml
&lt;span class="c"&gt;# Automatic rolling update, zero downtime&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;That's worth it alone.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Problem 2: When Your App Crashes
&lt;/h3&gt;

&lt;p&gt;Kubernetes automatically restarts crashed pods. No 3 AM alerts for me.&lt;/p&gt;

&lt;h3&gt;
  
  
  Problem 3: Scaling During Peak Hours
&lt;/h3&gt;

&lt;p&gt;Kubernetes scales automatically. No manual provisioning needed.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Setup (It's Actually Simple)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1: Create EKS Cluster
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_eks_cluster"&lt;/span&gt; &lt;span class="s2"&gt;"main"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"vehicle-metrics"&lt;/span&gt;
  &lt;span class="nx"&gt;role_arn&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_iam_role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cluster&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
  &lt;span class="nx"&gt;version&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"1.28"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_eks_node_group"&lt;/span&gt; &lt;span class="s2"&gt;"main"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;cluster_name&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_eks_cluster&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;node_group_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"main"&lt;/span&gt;
  &lt;span class="nx"&gt;node_role_arn&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_iam_role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;

  &lt;span class="nx"&gt;scaling_config&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;desired_size&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
    &lt;span class="nx"&gt;max_size&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;
    &lt;span class="nx"&gt;min_size&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;instance_types&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"t3.medium"&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;h3&gt;
  
  
  Step 2: Configure kubectl
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws eks update-kubeconfig &lt;span class="nt"&gt;--name&lt;/span&gt; vehicle-metrics &lt;span class="nt"&gt;--region&lt;/span&gt; us-east-1
kubectl get nodes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 3: Deploy
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; deployment.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Day-to-Day Operations
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Deploy New Version
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker build &lt;span class="nt"&gt;-t&lt;/span&gt; vehicle-metrics/api:v1.2.3 &lt;span class="nb"&gt;.&lt;/span&gt;
docker push vehicle-metrics/api:v1.2.3

kubectl &lt;span class="nb"&gt;set &lt;/span&gt;image deployment/api-ingestion &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;api&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;vehicle-metrics/api:v1.2.3

&lt;span class="c"&gt;# If something breaks, rollback&lt;/span&gt;
kubectl rollout undo deployment/api-ingestion
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Check Logs
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl logs &lt;span class="nt"&gt;-f&lt;/span&gt; deployment/api-ingestion
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  SSH Into Pod
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; pod/api-ingestion-xyz &lt;span class="nt"&gt;--&lt;/span&gt; /bin/bash
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Cost Comparison
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Manual VMs:&lt;/strong&gt; ~$300/month&lt;br&gt;
&lt;strong&gt;Kubernetes (EKS):&lt;/strong&gt; ~$263/month&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Kubernetes is cheaper AND more reliable.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What Kubernetes Solves
&lt;/h2&gt;

&lt;p&gt;✅ Automatic pod restarts&lt;br&gt;
✅ Zero-downtime deployments&lt;br&gt;
✅ Automatic scaling&lt;br&gt;
✅ Health checks&lt;br&gt;
✅ Secrets management&lt;br&gt;
✅ Load balancing&lt;/p&gt;




&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Solo developers should use Kubernetes&lt;/li&gt;
&lt;li&gt;Setup takes 1 hour (Terraform + kubectl)&lt;/li&gt;
&lt;li&gt;Day-to-day: one command to deploy&lt;/li&gt;
&lt;li&gt;Automatic scaling, restarts, rollbacks&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Stop overthinking. Start using it.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/beltagyy/vehicle-metrics" rel="noopener noreferrer"&gt;beltagyy/vehicle-metrics&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Author:&lt;/strong&gt; Mohamed ElBeltagy (@beltagyy)&lt;br&gt;
&lt;strong&gt;Topic:&lt;/strong&gt; Kubernetes | DevOps | Solo Development&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>devops</category>
      <category>docker</category>
      <category>eks</category>
    </item>
  </channel>
</rss>
