<?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: Tomasz Wostal</title>
    <description>The latest articles on DEV Community by Tomasz Wostal (@tomaszwostal).</description>
    <link>https://dev.to/tomaszwostal</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%2F1324404%2F1383df01-0987-4048-a4f5-ad834fb9645d.png</url>
      <title>DEV Community: Tomasz Wostal</title>
      <link>https://dev.to/tomaszwostal</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tomaszwostal"/>
    <language>en</language>
    <item>
      <title>I Let an AI Re-Platform My CI Pipeline. Here's What Broke.</title>
      <dc:creator>Tomasz Wostal</dc:creator>
      <pubDate>Sun, 02 Aug 2026 18:32:45 +0000</pubDate>
      <link>https://dev.to/tomaszwostal/i-let-an-ai-re-platform-my-ci-pipeline-heres-what-broke-26i8</link>
      <guid>https://dev.to/tomaszwostal/i-let-an-ai-re-platform-my-ci-pipeline-heres-what-broke-26i8</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://wostal.eu/blog/ai-replatform-ci-what-broke/" rel="noopener noreferrer"&gt;wostal.eu&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: I handed a CI re-platform — GitHub Actions → Argo Workflows, GitHub → Codeberg — to an AI agent. The rewrite looked correct and passed lint, but it didn't account for one thing: the runtime environment had changed completely. The old runner was an external VM; the new one is a pod inside the cluster, where Tailscale IPs aren't routable. Nobody ran the pipeline end-to-end, so every difference stayed as a landmine — six of them. The worst part wasn't the AI's mistake. It was that I spent hours blaming the network when the real culprit was a single missing newline in an SSH key.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is the companion to a different incident. While I was migrating my &lt;code&gt;homelab&lt;/code&gt; k3s control plane &lt;a href="https://wostal.eu/blog/homelab-grows-up-sqlite-to-etcd/" rel="noopener noreferrer"&gt;from SQLite to etcd&lt;/a&gt;, the pipeline that &lt;em&gt;ran&lt;/em&gt; that migration turned out to be broken in ways that taught me more than the migration itself.&lt;/p&gt;

&lt;p&gt;In this post I'll cover:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Where the mess came from&lt;/strong&gt; — an AI-assisted CI re-platform that nobody validated end-to-end&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The phantom CNI bug&lt;/strong&gt; — hours of chasing MTU, Tailscale, firewall, and conntrack&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The real culprit&lt;/strong&gt; — a single missing newline in an SSH key&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Why early failures hide later ones&lt;/strong&gt; — six latent bugs, surfaced one at a time&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The lesson about delegating migrations&lt;/strong&gt; — to a human or an AI&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where the mess came from
&lt;/h2&gt;

&lt;p&gt;The entire CI had just been migrated from &lt;strong&gt;GitHub Actions to Argo Workflows&lt;/strong&gt;, and from &lt;strong&gt;GitHub to Codeberg&lt;/strong&gt; — and that migration was handed to an AI agent 🙂.&lt;/p&gt;

&lt;p&gt;The re-platforming rewrote the logic roughly 1:1, but &lt;strong&gt;it didn't catch that the runtime environment had changed fundamentally&lt;/strong&gt;. The old runner (GitHub Actions / ARC) behaved one way; the new one is a &lt;strong&gt;pod in the cluster&lt;/strong&gt; — from which Tailscale IPs aren't routable, but private IPs are. Nobody (human or AI) ran the pipeline end-to-end, so all of those differences stayed as landmines.&lt;/p&gt;

&lt;p&gt;That's why there were &lt;strong&gt;six latent bugs&lt;/strong&gt; and the hang I'm about to describe — not random faults, but &lt;strong&gt;traces of a GHA→Argo translation that was never validated in the new environment.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The phantom CNI bug
&lt;/h2&gt;

&lt;p&gt;After the migration, it turned out the deploy pipeline (CI running in the cluster) had never passed end-to-end — &lt;code&gt;ansible-configure&lt;/code&gt; was &lt;strong&gt;hanging on &lt;code&gt;gather_facts&lt;/code&gt;&lt;/strong&gt;. I spent hours on it and &lt;strong&gt;was wrong repeatedly&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"It's MTU" → a test with MTU 1240 hung too.&lt;/li&gt;
&lt;li&gt;"It's the Tailscale overlay" → the private IP hung as well (in the test).&lt;/li&gt;
&lt;li&gt;"It's the firewall" → I opened the firewall, the public IP hung too.&lt;/li&gt;
&lt;li&gt;"It's asymmetric routing / conntrack in the CNI" → a &lt;code&gt;hostNetwork&lt;/code&gt; pod hung too.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of those tests pointed at the network. Every one of them was lying to me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real culprit
&lt;/h2&gt;

&lt;p&gt;Each test was &lt;strong&gt;contaminated by a single bug&lt;/strong&gt;: in the test pods, I mounted the SSH key with &lt;code&gt;cp&lt;/code&gt; from a secret — &lt;strong&gt;without a trailing newline&lt;/strong&gt;. That produces &lt;code&gt;error in libcrypto&lt;/code&gt; → the key never loads → &lt;code&gt;ssh&lt;/code&gt; behaves randomly (sometimes a hang, sometimes "permission denied"). I was chasing phantoms, because &lt;strong&gt;the test rig was broken, not the network.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The breakthrough only came when I did it &lt;strong&gt;properly&lt;/strong&gt;: &lt;code&gt;tcpdump&lt;/code&gt; + &lt;code&gt;conntrack&lt;/code&gt; on the node, and the key written &lt;strong&gt;exactly as CI writes it&lt;/strong&gt; (&lt;code&gt;printf '%s\n'&lt;/code&gt;). Then the picture was clean and consistent: from a pod, &lt;strong&gt;the private IP (10.0.1.x) works&lt;/strong&gt; (ssh + sftp + full &lt;code&gt;ansible -m setup&lt;/code&gt;), while the &lt;strong&gt;Tailscale IP (100.x) hangs&lt;/strong&gt; — because the pod has no route into the tailnet. The fix: CI uses the private IP (the firewall doesn't filter the private network anyway). It worked instantly.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Lesson #1: before you blame the network/CNI/MTU, prove that your test rig isn't broken.&lt;/strong&gt; One missing newline in a key cost me hours chasing a non-existent CNI problem. Reproduce &lt;strong&gt;identically to production&lt;/strong&gt; (here: exactly how CI writes the key), and for "packets vanish midway" use &lt;code&gt;tcpdump&lt;/code&gt;/&lt;code&gt;conntrack&lt;/code&gt;, not guesses. "Works from my laptop, not from a pod" isn't always the network — sometimes it's a test-rig artifact.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why early failures hide later ones
&lt;/h2&gt;

&lt;p&gt;Here's the second trap. The deploy pipeline never reached apply+ansible, so each subsequent bug only surfaced once the previous one started working. There were &lt;strong&gt;six&lt;/strong&gt; in total:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;missing &lt;code&gt;openssh&lt;/code&gt; in the runner image&lt;/li&gt;
&lt;li&gt;missing &lt;code&gt;python3&lt;/code&gt; (ansible needs it on the control node)&lt;/li&gt;
&lt;li&gt;a kubeconfig artifact that wasn't passed between steps&lt;/li&gt;
&lt;li&gt;an approve gate with &lt;code&gt;duration:0&lt;/code&gt; that didn't actually gate anything&lt;/li&gt;
&lt;li&gt;ansible reaching over Tailscale instead of the private network (the phantom-CNI bug above)&lt;/li&gt;
&lt;li&gt;git auth failing in &lt;code&gt;semantic-release&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Lesson #2: a pipeline that fails early masks every downstream bug.&lt;/strong&gt; You fix one, run again, and the next one — previously unreachable — finally executes and fails. The first green end-to-end run is the moment everything hidden surfaces at once. Plan for that run; don't assume "it lints, so it works."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The lesson about delegating migrations
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Lesson #3: re-platforming CI (GHA→Argo, GitHub→Codeberg) requires an end-to-end run — whether a human or an AI did it.&lt;/strong&gt; A migration rewritten "1:1" looks correct and passes lint, but it loses the &lt;strong&gt;runtime-environment differences&lt;/strong&gt;: an external runner vs an in-cluster pod, the GitHub API vs Forgejo. Those differences won't show up in any static review — only in one full pass. If you delegate such a migration (to an AI agent 🙂), plan a &lt;strong&gt;smoke-run of the whole path&lt;/strong&gt; immediately, or you'll discover the delta months later, at 3am.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This isn't an argument against using AI for migrations. The rewrite &lt;em&gt;was&lt;/em&gt; mostly correct, and it saved real time. It's an argument about where the human stays in the loop: not in re-typing the YAML, but in &lt;strong&gt;validating the thing actually runs in its new home&lt;/strong&gt;. The AI translated the language; it couldn't translate the environment, because it never executed there. Neither did I — until it was breaking.&lt;/p&gt;

&lt;h2&gt;
  
  
  The finale — full GitOps
&lt;/h2&gt;

&lt;p&gt;After the fixes, the pipeline runs the way it should: merge to main → sensor → deploy pipeline → &lt;code&gt;plan → [approve: suspend] → apply → ansible → release&lt;/code&gt;, with a manual gate (&lt;code&gt;argo resume&lt;/code&gt; = "after approval"). I validated it on a live merge — it cut release &lt;code&gt;v1.4.1&lt;/code&gt; cleanly. Control plane on etcd, DR (local + S3), config via GitOps with a human gate in the middle.&lt;/p&gt;

&lt;p&gt;Two incidents, one weekend. One taught me that defaults become landmines when a lab grows up. This one taught me that a migration isn't done until something has actually run end-to-end in its new environment — no matter how confident the thing that wrote it.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>ai</category>
      <category>cicd</category>
      <category>debugging</category>
    </item>
    <item>
      <title>When Your Homelab Grows Up: How SQLite Took Down My k3s Control Plane</title>
      <dc:creator>Tomasz Wostal</dc:creator>
      <pubDate>Sun, 02 Aug 2026 18:32:01 +0000</pubDate>
      <link>https://dev.to/tomaszwostal/when-your-homelab-grows-up-how-sqlite-took-down-my-k3s-control-plane-1kdp</link>
      <guid>https://dev.to/tomaszwostal/when-your-homelab-grows-up-how-sqlite-took-down-my-k3s-control-plane-1kdp</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://wostal.eu/blog/homelab-grows-up-sqlite-to-etcd/" rel="noopener noreferrer"&gt;wostal.eu&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: My Hetzner k3s lab quietly became a platform. Dozens of operators with leader-election leases hammered the default datastore — SQLite via &lt;code&gt;kine&lt;/code&gt; — until compaction entered a death-spiral: 1.36M rows, a 13.8 GB WAL that wouldn't checkpoint, CPU pinned at 99%, load average 79 on 8 cores. I stopped the bleeding by truncating the WAL, then migrated the control plane to embedded &lt;strong&gt;etcd&lt;/strong&gt; (7.5 GB SQLite → 313 MB etcd, load 79 → 5). This is the full postmortem — and the lessons.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is a war story, not a tutorial. It's about the moment a homelab stops being a homelab and starts behaving like production — without ever announcing it. The cluster in question, &lt;code&gt;homelab&lt;/code&gt;, is the &lt;a href="https://wostal.eu/blog/the-ultimate-homelab/" rel="noopener noreferrer"&gt;Hetzner k3s setup I wrote about previously&lt;/a&gt;. It started small. It did not stay small.&lt;/p&gt;

&lt;p&gt;In this post I'll cover:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;How an overgrown lab broke the default datastore&lt;/strong&gt; — the kine/SQLite compaction death-spiral&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The firefight&lt;/strong&gt; — measuring instead of guessing, and the fix that actually worked&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The permanent fix&lt;/strong&gt; — migrating the control plane to embedded etcd, and the honest caveats&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The meta-lesson&lt;/strong&gt; — how to recognize when your lab has become a platform&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A diagnostic runbook&lt;/strong&gt; — so next time it's minutes, not hours&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;There's a companion piece to this incident. The CI pipeline that &lt;em&gt;ran&lt;/em&gt; this etcd migration was itself freshly — and badly — migrated, and debugging it cost me hours over a single missing newline. I split that into its own post: &lt;a href="https://wostal.eu/blog/ai-replatform-ci-what-broke/" rel="noopener noreferrer"&gt;I Let an AI Re-Platform My CI Pipeline. Here's What Broke.&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Context: it's "just a homelab" — except it isn't
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;homelab&lt;/code&gt; began like any homelab: one k3s node on Hetzner, a few things to play with. The problem is that over months it &lt;em&gt;quietly became a platform&lt;/em&gt;. A single master node (&lt;code&gt;cx43&lt;/code&gt;, 8 vCPU / 16 GB, untainted, and also carrying Longhorn and workloads) now runs: ArgoCD, Kargo, Crossplane/Upbound, CloudNativePG, EMQX, Longhorn, trivy-operator, kubescape, Gatekeeper, Goldilocks/VPA, VictoriaMetrics, Loki, OpenTelemetry, Argo Workflows/Events/Rollouts, kgateway, and more.&lt;/p&gt;

&lt;p&gt;Each of those is a solid, production-grade operator. But all of them together hammer &lt;strong&gt;the control plane on k3s's default settings&lt;/strong&gt; — which means the datastore is &lt;strong&gt;SQLite&lt;/strong&gt;, accessed through &lt;code&gt;kine&lt;/code&gt;, a shim that translates the etcd API into SQL. This works beautifully… until the lab crosses an invisible threshold and starts behaving like a platform. At that point you get &lt;strong&gt;production-grade failure modes on lab-grade infrastructure&lt;/strong&gt;. That's what this post is about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 1 — The firefight: kine's compaction death-spiral
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The symptom
&lt;/h3&gt;

&lt;p&gt;The control plane was pinned: master at &lt;strong&gt;99% CPU&lt;/strong&gt;, ~42% of it in the kernel (sys), &lt;strong&gt;load average climbing 32 → 79&lt;/strong&gt; on 8 cores. The apiserver started spewing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"Failed to get storage metrics" storage_cluster_id="etcd-0" err="context deadline exceeded"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The datastore was so saturated it couldn't even answer a query about its own metrics.&lt;/p&gt;

&lt;h3&gt;
  
  
  The wrong leads (and the most important lesson: measure, don't guess)
&lt;/h3&gt;

&lt;p&gt;My first suspects were "obvious" — and &lt;strong&gt;all wrong&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Gatekeeper audit&lt;/strong&gt; ran with &lt;code&gt;audit-from-cache=false&lt;/code&gt; every 60s (full live LISTs from the apiserver). I disabled it and measured: CPU moved by noise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Goldilocks/VPA&lt;/strong&gt; — the recommender was writing ~7 checkpoints/s across 142 VPAs. Disabled it → CPU unchanged.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Lesson #1:&lt;/strong&gt; verify every "it's definitely X" by scaling it to zero and measuring. Cutting individual API clients didn't move CPU, because the bottleneck wasn't any client — it was the &lt;strong&gt;datastore engine itself&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  The actual root cause
&lt;/h3&gt;

&lt;p&gt;Only by getting onto the node (SSH over Tailscale — public SSH is firewalled) did the truth show up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;state.db:      7.5 GB
state.db-wal: 13.8 GB   ← WAL larger than the DB and NOT checkpointing
select count(*) from kine → 1,361,474 rows
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Broken down by key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/registry/leases/cnpg-system/...      55,709 revisions
/registry/leases/monitoring/...       55,684
/registry/leases/upbound-system/...   55,654
/registry/leases/kgateway-system/...  55,621
... (every leader-election lease ~55,000 dead revisions)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The mechanism: leader-election leases are updated &lt;strong&gt;every ~2 seconds&lt;/strong&gt; by each operator. kine is supposed to delete old revisions (compaction). Here, &lt;strong&gt;~55,000 dead revisions accumulated per lease&lt;/strong&gt; (≈31 hours with no compaction) → 1.36M rows → a 7.5 GB database.&lt;/p&gt;

&lt;p&gt;This is the &lt;strong&gt;classic kine death-spiral&lt;/strong&gt;: the table grew so large that the compaction query itself began to time out — so compaction never caught up, so the table kept growing. A feedback loop. My own remediation from the day before added fuel (a full trivy rescan, deleting 876 reports, a CNPG resync after moving pods).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Dozens of operators (leader-election leases every ~2s)
   → high write rate to kine/SQLite
   → dead revisions pile up  ←──────────────┐
   → kine table grows (rows in the millions)│
   → compaction query times out             │
   → old revisions never deleted ───────────┘   (feedback loop)

side effect: CPU 99% · load 79 · storage metric timeouts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;(the original post has this as an interactive diagram)&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Lesson #2:&lt;/strong&gt; on a saturated datastore, every "cleanup" action generates writes that add to the spiral. Tidying up is the opposite of helping.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  The fix
&lt;/h3&gt;

&lt;p&gt;A clean window only exists with k3s stopped (the only moment kine isn't holding the database open):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl stop k3s
apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; sqlite3                                &lt;span class="c"&gt;# NOT installed by default&lt;/span&gt;
sqlite3 state.db &lt;span class="s2"&gt;"PRAGMA wal_checkpoint(TRUNCATE);"&lt;/span&gt;   &lt;span class="c"&gt;# WAL 13.8 GB → 0&lt;/span&gt;
systemctl start k3s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The trap I fell into:&lt;/strong&gt; I first ran &lt;code&gt;PRAGMA quick_check&lt;/code&gt; before the checkpoint — an integrity scan that churned forever on a 7.5 GB database and &lt;strong&gt;blocked&lt;/strong&gt; the operation I actually needed, needlessly extending the downtime.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson #3:&lt;/strong&gt; on a large, degraded database, skip the expensive full scans — do the minimum required to reach your goal.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  The moment of terror (and Lesson #4)
&lt;/h3&gt;

&lt;p&gt;Right after the restart, load &lt;strong&gt;jumped to 79&lt;/strong&gt; — it looked like the fix had failed. It was a &lt;strong&gt;reconciliation storm&lt;/strong&gt;: after the apiserver restarts, every controller and ArgoCD simultaneously rebuilds its cache with full LISTs. Layered on top of compaction catching up, that produced a temporary spike.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Lesson #4:&lt;/strong&gt; a control-plane restart always triggers a thundering herd. Don't read the peak as failure — give it a few minutes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And indeed — once the WAL was truncated, &lt;strong&gt;kine's compaction came back to life&lt;/strong&gt; and started eating the backlog:&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;kine rows&lt;/th&gt;
&lt;th&gt;load (1-min)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;start&lt;/td&gt;
&lt;td&gt;1,361,474&lt;/td&gt;
&lt;td&gt;79&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;+5 min&lt;/td&gt;
&lt;td&gt;646,895&lt;/td&gt;
&lt;td&gt;36&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;+10 min&lt;/td&gt;
&lt;td&gt;14,565&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3.0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;End result: &lt;strong&gt;CPU 99% → 30%, load 79 → 3, rows 1.36M → ~14k, storage timeouts → 0.&lt;/strong&gt; A 99% keyspace reduction; a healthy control plane with headroom.&lt;/p&gt;

&lt;h3&gt;
  
  
  What remained
&lt;/h3&gt;

&lt;p&gt;The WAL grew to &lt;strong&gt;16.7 GB&lt;/strong&gt; during the compaction storm (a high-water mark from 1.35M DELETEs), and &lt;code&gt;state.db&lt;/code&gt; was still 7.5 GB despite holding only 14k rows — SQLite doesn't release space without &lt;code&gt;VACUUM&lt;/code&gt;. Worth cleaning in a window, but not urgent (disk at 47/150 GB).&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The most important lesson of Part 1:&lt;/strong&gt; this was a band-aid. The compaction death-spiral &lt;em&gt;will&lt;/em&gt; come back under load, because kine-on-SQLite is fragile at this write profile. We put out the fire — we didn't remove the fuel.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Part 2 — The permanent fix: moving to etcd
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why SQLite/kine hit a wall &lt;em&gt;here&lt;/em&gt;
&lt;/h3&gt;

&lt;p&gt;Not because SQLite is "bad" — because an &lt;strong&gt;overgrown homelab&lt;/strong&gt; generates a profile kine can't tolerate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;lots of writes&lt;/strong&gt; (leases every 2s × dozens of operators, VPA checkpoints, events),&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;lots of objects and revisions&lt;/strong&gt; to compact,&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;one process, one writer&lt;/strong&gt; — kine serializes everything into SQLite, and compaction is a single point that can choke.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This isn't "production at millions of users" scale. It's &lt;strong&gt;overgrown-lab&lt;/strong&gt; scale — and that's exactly the boundary where the default datastore stops being enough.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why etcd
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;native to the apiserver (no SQL translation layer), MVCC, range-ready from an in-memory tree,&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;real, working compaction&lt;/strong&gt; plus defragmentation — no lease death-spiral,&lt;/li&gt;
&lt;li&gt;better write concurrency.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The honest caveats (Lesson #5: etcd is not magic)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;etcd is extremely sensitive to fsync latency&lt;/strong&gt; → it needs a fast, &lt;strong&gt;dedicated local disk&lt;/strong&gt; (NVMe), never a networked Hetzner Volume.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"let's just add a second master" is an antipattern&lt;/strong&gt; — etcd needs an &lt;strong&gt;odd quorum&lt;/strong&gt; (1/3/5). The target is 3 nodes, not 2.&lt;/li&gt;
&lt;li&gt;migrating a bloated SQLite carries risk (etcd quota) — so &lt;strong&gt;compact/VACUUM first, then migrate&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;in the migration playbook I found &lt;strong&gt;two real bugs&lt;/strong&gt; before running anything: detecting etcd by directory alone (the master has an empty &lt;code&gt;db/etcd/name&lt;/code&gt; stub from provisioning day → a false "already migrated"), and a &lt;code&gt;copy&lt;/code&gt; task that would overwrite the &lt;strong&gt;existing&lt;/strong&gt; &lt;code&gt;/etc/rancher/k3s/config.yaml&lt;/code&gt;, breaking the master on restart.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Lesson #6:&lt;/strong&gt; read the real state of the node before you trust the assumptions baked into your automation.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  The plan (two phases)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Phase 1&lt;/strong&gt; — in-place &lt;code&gt;--cluster-init&lt;/code&gt; on the existing master (k3s performs a one-time SQLite → embedded etcd migration). No Terraform, no new nodes. Backup → migrate → verify &lt;code&gt;readyz&lt;/code&gt; → etcd snapshots to S3 (Hetzner Object Storage).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phase 2&lt;/strong&gt; — HA: 3 control-plane nodes (a &lt;em&gt;separate&lt;/em&gt; Terraform resource — &lt;strong&gt;not&lt;/strong&gt; refactoring the existing master onto &lt;code&gt;count&lt;/code&gt;, because changing its address in state would destroy the live master), a stable API endpoint (6443 on the LB), &lt;code&gt;NoSchedule&lt;/code&gt; on the masters, and moving Longhorn off the control plane.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Part 3 — Execution log (the real work)
&lt;/h2&gt;

&lt;p&gt;I keep this section as a live journal — so you see not just the analysis, but the actual execution.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1 — fixing the migration playbook (done)
&lt;/h3&gt;

&lt;p&gt;Before touching the cluster, a pre-flight on the live node caught &lt;strong&gt;two bugs in the migration playbook&lt;/strong&gt; (confirming Lesson #6):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;False "already migrated."&lt;/strong&gt; etcd detection checked for the &lt;code&gt;db/etcd&lt;/code&gt; directory — but a SQLite cluster keeps a &lt;strong&gt;vestigial stub&lt;/strong&gt; there (just a &lt;code&gt;name&lt;/code&gt; file from provisioning day). The playbook would skip the migration as done. Fix: check &lt;code&gt;db/etcd/member&lt;/code&gt; (which exists only after real etcd initialization).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Config overwrite.&lt;/strong&gt; A task used &lt;code&gt;copy&lt;/code&gt; on &lt;code&gt;/etc/rancher/k3s/config.yaml&lt;/code&gt; — which &lt;strong&gt;already exists&lt;/strong&gt; with &lt;code&gt;node-ip&lt;/code&gt;/&lt;code&gt;flannel-iface&lt;/code&gt;/&lt;code&gt;tls-san&lt;/code&gt;. Overwriting it would break the node on restart. Fix: &lt;strong&gt;merge&lt;/strong&gt; &lt;code&gt;cluster-init: true&lt;/code&gt; via &lt;code&gt;lineinfile&lt;/code&gt; (idempotently, with a file backup), leaving the rest untouched.&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Lesson #7:&lt;/strong&gt; migration automation written "dry" almost always assumes a clean, textbook node state. A real node, months into its life, has stubs, hand-edited files, and leftovers. A pre-flight that reads the actual state is cheaper than debugging a migration halfway through.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Step 2 — VACUUM + WAL reset (skipped — deliberately)
&lt;/h3&gt;

&lt;p&gt;Originally planned as a separate window. &lt;strong&gt;Skipped&lt;/strong&gt;, because it became moot: after compaction recovered, the logical data was already small (~15k rows), and the 7.5 GB &lt;code&gt;state.db&lt;/code&gt; was just dead pages. The etcd migration (Step 3) reads only the &lt;em&gt;current&lt;/em&gt; state, so the fresh etcd is born small regardless of the SQLite file size — VACUUM would have been wasted downtime.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Lesson #8:&lt;/strong&gt; don't do cosmetic cleanup right before an operation that renders it irrelevant.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Step 3 — migration to etcd, Phase 1 (done)
&lt;/h3&gt;

&lt;p&gt;In-place &lt;code&gt;--cluster-init&lt;/code&gt; on the existing master via the fixed playbook (locally, over Tailscale, in a maintenance window): stop k3s → &lt;code&gt;wal_checkpoint(TRUNCATE)&lt;/code&gt; → back up &lt;code&gt;state.db&lt;/code&gt;/token/tls on the node → merge &lt;code&gt;cluster-init&lt;/code&gt; into &lt;code&gt;config.yaml&lt;/code&gt; → start (k3s does the one-time SQLite → embedded etcd migration) → verify &lt;code&gt;db/etcd/member&lt;/code&gt; + &lt;code&gt;readyz&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Result:&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;Old kine (baseline)&lt;/th&gt;
&lt;th&gt;Death-spiral&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;etcd (post-migration)&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Master CPU&lt;/td&gt;
&lt;td&gt;85%&lt;/td&gt;
&lt;td&gt;99%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;37%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Load&lt;/td&gt;
&lt;td&gt;~12&lt;/td&gt;
&lt;td&gt;79&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~5&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Datastore&lt;/td&gt;
&lt;td&gt;7.5 GB sqlite&lt;/td&gt;
&lt;td&gt;7.5 GB + 13.8 GB WAL&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;313 MB etcd, stable&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storage-metric timeouts&lt;/td&gt;
&lt;td&gt;sporadic&lt;/td&gt;
&lt;td&gt;12 / 3 min&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The node role now reads &lt;code&gt;control-plane,etcd,master&lt;/code&gt;. etcd doesn't balloon (307 → 313 MB) and has working MVCC compaction — &lt;strong&gt;the compaction death-spiral is structurally impossible&lt;/strong&gt;. The pre-migration backup sits on the node (&lt;code&gt;/root/k3s-pre-etcd-backup/&lt;/code&gt;), and k3s removed the original &lt;code&gt;state.db&lt;/code&gt; after the successful conversion.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Lesson #9:&lt;/strong&gt; the momentary CPU figure (37%) looks similar to a freshly restarted kine — but that's not the point. etcd's value is &lt;strong&gt;durability&lt;/strong&gt;: kine under load would fall back into the compaction death-spiral; etcd can't. Measure &lt;em&gt;resilience&lt;/em&gt;, not just a CPU snapshot.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Step 4 — etcd snapshots to S3 (done)
&lt;/h3&gt;

&lt;p&gt;Off-site DR for the new datastore: bucket &lt;code&gt;homelab-etcd-snapshots&lt;/code&gt; (Hetzner Object Storage) + &lt;code&gt;--etcd-s3&lt;/code&gt; in &lt;code&gt;config.yaml&lt;/code&gt; (creds chmod 600, schedule every 12h, retention 5). Validated with a manual snapshot (landed in the bucket, 147 MB) and a k3s restart (&lt;code&gt;readyz&lt;/code&gt; in &lt;strong&gt;4s&lt;/strong&gt; — etcd starts instantly, none of the SQLite-style storm). Codified: bucket in Terraform + an opt-in playbook (creds via &lt;code&gt;-e&lt;/code&gt;, never in git). Nice surprise: k3s already takes &lt;strong&gt;local&lt;/strong&gt; snapshots by default — S3 just adds an off-node layer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Phase 2 — HA + dedicated control plane (later)
&lt;/h3&gt;

&lt;p&gt;3 etcd nodes (odd quorum), a stable API endpoint, &lt;code&gt;NoSchedule&lt;/code&gt; on the masters, Longhorn off the control plane. This will cut CPU further and add redundancy.&lt;/p&gt;

&lt;h2&gt;
  
  
  The meta-lesson: recognize when a lab becomes a platform
&lt;/h2&gt;

&lt;p&gt;The most important takeaway isn't about SQLite or etcd. It's that &lt;strong&gt;a homelab crosses the line silently&lt;/strong&gt;. k3s's defaults — SQLite, one untainted master, everything co-located — are perfect to start with and harmless for a long time. But once you pile on ArgoCD, Crossplane, CloudNativePG, Longhorn, two observability stacks, and half a dozen operators with leader-election, &lt;strong&gt;those same defaults become production-grade landmines&lt;/strong&gt;. The outage looks production-grade while the foundation is still lab-grade.&lt;/p&gt;

&lt;p&gt;Signs your lab just grew up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the number of operators with leader-election keeps rising (each one = a lease every 2s = compaction load),&lt;/li&gt;
&lt;li&gt;GitOps appears, managing everything (reconciliation storms on restart),&lt;/li&gt;
&lt;li&gt;the control plane shares a node and a disk with storage.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's when it's time to &lt;strong&gt;pull the control plane out of the defaults&lt;/strong&gt;: etcd instead of SQLite, a dedicated/tainted master, snapshots, monitoring of datastore size and compaction — before the death-spiral does it for you at 3am.&lt;/p&gt;

&lt;h2&gt;
  
  
  Appendix: diagnostic runbook
&lt;/h2&gt;

&lt;p&gt;If the symptoms ever return — minutes instead of hours:&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;# datastore + WAL size (red flag: WAL in GB / not shrinking)&lt;/span&gt;
&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-la&lt;/span&gt; /var/lib/rancher/k3s/server/db/state.db&lt;span class="k"&gt;*&lt;/span&gt;

&lt;span class="c"&gt;# kine row count (healthy: ~10–30k; spiral: hundreds of thousands / millions)&lt;/span&gt;
sqlite3 &lt;span class="nt"&gt;-readonly&lt;/span&gt; /var/lib/rancher/k3s/server/db/state.db &lt;span class="s2"&gt;"select count(*) from kine;"&lt;/span&gt;

&lt;span class="c"&gt;# which keys are ballooning (usually leader-election leases)&lt;/span&gt;
sqlite3 &lt;span class="nt"&gt;-readonly&lt;/span&gt; /var/lib/rancher/k3s/server/db/state.db &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"select name, count(*) c from kine group by name order by c desc limit 10;"&lt;/span&gt;

&lt;span class="c"&gt;# is compaction working / is storage timing out&lt;/span&gt;
journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; k3s &lt;span class="nt"&gt;--since&lt;/span&gt; &lt;span class="s2"&gt;"3 min ago"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-iE&lt;/span&gt; &lt;span class="s2"&gt;"compact|Failed to get storage metrics"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're running an overgrown lab on k3s defaults, do yourself a favor: check your &lt;code&gt;state.db&lt;/code&gt; size &lt;em&gt;before&lt;/em&gt; it checks you.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>sqlite</category>
      <category>sre</category>
      <category>homelab</category>
    </item>
  </channel>
</rss>
