<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
    <title>dune.at &#x2F; notes</title>
    <subtitle>Infrastructure, homelab and self-hosting notes — mostly things I had to figure out the hard way.</subtitle>
    <link rel="self" type="application/atom+xml" href="https://blog.dune.at/atom.xml"/>
    <link rel="alternate" type="text/html" href="https://blog.dune.at"/>
    <generator uri="https://www.getzola.org/">Zola</generator>
    <updated>2026-06-04T00:00:00+00:00</updated>
    <id>https://blog.dune.at/atom.xml</id>
    <entry xml:lang="en">
        <title>The cloud image shipped an iptables rule that silently kills all pod networking</title>
        <published>2026-06-04T00:00:00+00:00</published>
        <updated>2026-06-04T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://blog.dune.at/cloud-ubuntu-forward-reject-breaks-pods/"/>
        <id>https://blog.dune.at/cloud-ubuntu-forward-reject-breaks-pods/</id>
        
        <content type="html" xml:base="https://blog.dune.at/cloud-ubuntu-forward-reject-breaks-pods/">&lt;p&gt;Companion to the API-address bug: same cluster, same morning, different way for a
&quot;healthy&quot; node to have completely broken pod networking. This one is the stock
cloud OS image fighting your CNI, and it&#x27;s invisible until pods try to talk to
each other.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-symptom&quot;&gt;The symptom&lt;&#x2F;h2&gt;
&lt;p&gt;k3s installed cleanly. Nodes &lt;code&gt;Ready&lt;&#x2F;code&gt;. But pods couldn&#x27;t reach Services, DNS
lookups timed out, and anything multi-pod CrashLooped. Node-level networking
(SSH, the API on the host) was perfectly fine. Only &lt;strong&gt;forwarded&lt;&#x2F;strong&gt; traffic —
pod-to-pod, pod-to-Service — was dead.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-cause-a-default-reject-host-firewall&quot;&gt;The cause: a default-REJECT host firewall&lt;&#x2F;h2&gt;
&lt;p&gt;The cloud&#x27;s Ubuntu image ships a preconfigured host firewall. The &lt;code&gt;FORWARD&lt;&#x2F;code&gt;
chain ends in a single blanket rule:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;-A FORWARD -j REJECT --reject-with icmp-host-prohibited
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Here&#x27;s why that&#x27;s fatal to Kubernetes specifically. Every pod-to-pod and
pod-to-Service packet is &lt;strong&gt;routed&lt;&#x2F;strong&gt;, not delivered locally — so it traverses the
&lt;code&gt;FORWARD&lt;&#x2F;code&gt; chain. A CNI like flannel assumes it owns &lt;code&gt;FORWARD&lt;&#x2F;code&gt; and inserts its own
ACCEPT rules. But the image&#x27;s blanket REJECT is already there, and on a fresh
boot it wins: each forwarded packet hits &quot;host prohibited&quot; and is dropped. The
node looks healthy because nothing the &lt;em&gt;host&lt;&#x2F;em&gt; does is forwarded; only the pods
suffer.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-fix&quot;&gt;The fix&lt;&#x2F;h2&gt;
&lt;p&gt;Two parts, and the reasoning behind each matters more than the commands:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;1. Remove the blanket &lt;code&gt;FORWARD REJECT&lt;&#x2F;code&gt;.&lt;&#x2F;strong&gt; Let k3s &#x2F; flannel &#x2F; kube-proxy manage
the &lt;code&gt;FORWARD&lt;&#x2F;code&gt; chain, which is what they expect to do:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;# idempotent — only acts if the rule is present
iptables -D FORWARD -j REJECT --reject-with icmp-host-prohibited
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;strong&gt;2. Accept intra-cluster traffic on &lt;code&gt;INPUT&lt;&#x2F;code&gt;&lt;&#x2F;strong&gt; so node-to-node control traffic
isn&#x27;t blocked either — the internal VCN range, the pod CIDR, the service CIDR,
and flannel&#x27;s VXLAN port:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;iptables -I INPUT -s &amp;lt;vcn-cidr&amp;gt;      -j ACCEPT   # node↔node: etcd, kubelet, …
iptables -I INPUT -s &amp;lt;pod-cidr&amp;gt;      -j ACCEPT   # pod network
iptables -I INPUT -s &amp;lt;service-cidr&amp;gt;  -j ACCEPT   # service network
iptables -I INPUT -p udp --dport 8472 -j ACCEPT  # flannel VXLAN
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Then persist (&lt;code&gt;netfilter-persistent save&lt;&#x2F;code&gt;) so a reboot doesn&#x27;t reinstate the
breakage.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-decision-behind-it-don-t-run-two-firewalls&quot;&gt;The decision behind it: don&#x27;t run two firewalls&lt;&#x2F;h2&gt;
&lt;p&gt;It&#x27;s tempting to keep the host firewall &lt;em&gt;and&lt;&#x2F;em&gt; the cloud one. Don&#x27;t. The cloud
provider already gives you a network security layer (security lists &#x2F; security
groups) at the VCN edge — that is your real perimeter, and it&#x27;s the one you can
reason about centrally. The host iptables rules the image ships are redundant
with it and actively conflict with the CNI. &lt;strong&gt;Keep the cloud security list as the
perimeter; strip the host rules that collide with Kubernetes.&lt;&#x2F;strong&gt; One firewall, in
one place, that the CNI is allowed to manage.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways&lt;&#x2F;h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;A green node says nothing about pod networking.&lt;&#x2F;strong&gt; Node &lt;code&gt;Ready&lt;&#x2F;code&gt; only proves
the kubelet is happy. If multi-pod workloads fail, check &lt;code&gt;iptables -L FORWARD&lt;&#x2F;code&gt;
before you touch the CNI config.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Default-REJECT host firewalls and CNIs are incompatible by construction.&lt;&#x2F;strong&gt;
Any image (cloud or otherwise) that ends &lt;code&gt;FORWARD&lt;&#x2F;code&gt; in REJECT will break
forwarding the moment a CNI relies on it. This isn&#x27;t Oracle-specific; it&#x27;s
&quot;preconfigured host firewall meets Kubernetes.&quot;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Let the cloud layer be the firewall.&lt;&#x2F;strong&gt; Centralize the perimeter at the
provider&#x27;s security list and let kube-proxy&#x2F;flannel own the host chains.&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;The frustrating part is how &lt;em&gt;healthy&lt;&#x2F;em&gt; everything looks. The fix is one deleted
rule — but only once you know to suspect the OS image rather than your manifests.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>After a DNS cutover, my cluster kept resolving the old IP — and one config slip took out all DNS</title>
        <published>2026-06-04T00:00:00+00:00</published>
        <updated>2026-06-04T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://blog.dune.at/coredns-stale-ip-after-dns-cutover/"/>
        <id>https://blog.dune.at/coredns-stale-ip-after-dns-cutover/</id>
        
        <content type="html" xml:base="https://blog.dune.at/coredns-stale-ip-after-dns-cutover/">&lt;p&gt;Migrating apps onto the cluster meant cutting their public DNS over to the new
ingress. From the outside, instant and clean — every name resolved to the new IP.
From &lt;em&gt;inside&lt;&#x2F;em&gt; the cluster, two problems surfaced that, between them, cost a real
outage. Writing both down because they&#x27;re the kind of thing you only debug once
if someone tells you first.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;problem-1-in-cluster-dns-lags-the-cutover&quot;&gt;Problem 1: in-cluster DNS lags the cutover&lt;&#x2F;h2&gt;
&lt;p&gt;I pointed the public records at the new ingress and watched the new certificates
fail to issue. cert-manager&#x27;s HTTP-01 flow includes an &lt;strong&gt;in-cluster self-check&lt;&#x2F;strong&gt;:
before asking the CA to validate, it resolves the hostname &lt;em&gt;from inside the
cluster&lt;&#x2F;em&gt; and confirms the challenge is reachable. That self-check kept resolving
the &lt;strong&gt;old&lt;&#x2F;strong&gt; IP.&lt;&#x2F;p&gt;
&lt;p&gt;Why: in-cluster resolution goes through CoreDNS, which forwards to an upstream
resolver — and upstream caches and propagation lag behind your authoritative
change. Externally you&#x27;ve cut over; internally CoreDNS is still handing back the
previous address for the length of the TTL (and then some). The self-check hits a
dead endpoint and the cert never issues.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;problem-2-no-hairpin-to-your-own-public-ip-anyway&quot;&gt;Problem 2: no hairpin to your own public IP anyway&lt;&#x2F;h2&gt;
&lt;p&gt;Even once the old record expired, in-cluster clients pointed at the &lt;strong&gt;new public
IP&lt;&#x2F;strong&gt; still couldn&#x27;t reach the ingress. Many clouds don&#x27;t provide NAT &lt;strong&gt;hairpin&lt;&#x2F;strong&gt; —
a pod cannot reach its own cluster&#x27;s public ingress IP by going &quot;out and back
in.&quot; From inside, the only address that actually works is the ingress&#x27;s
&lt;strong&gt;private&#x2F;internal&lt;&#x2F;strong&gt; IP.&lt;&#x2F;p&gt;
&lt;p&gt;So both problems have the same answer: &lt;strong&gt;in-cluster clients should resolve these
hostnames to the internal ingress address, not the public one.&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-fix-a-coredns-override-pointing-at-the-internal-ip&quot;&gt;The fix: a CoreDNS override pointing at the internal IP&lt;&#x2F;h2&gt;
&lt;p&gt;k3s ships CoreDNS with a &lt;code&gt;coredns-custom&lt;&#x2F;code&gt; ConfigMap for exactly this. Resolve the
affected hostnames to the ingress&#x27;s internal address for in-cluster lookups,
while the public DNS keeps serving the public IP to the outside world:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;app-example.server: |
  app.example.com:53 {
    hosts {
      10.0.0.10 app.example.com
      fallthrough
    }
    forward . &#x2F;etc&#x2F;resolv.conf
  }
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;code&gt;fallthrough&lt;&#x2F;code&gt; means anything that &lt;em&gt;isn&#x27;t&lt;&#x2F;em&gt; one of these names falls through to
normal forwarding, so you only override the specific hostnames.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-mistake-that-turned-a-fix-into-an-outage&quot;&gt;The mistake that turned a fix into an outage&lt;&#x2F;h2&gt;
&lt;p&gt;The &lt;code&gt;coredns-custom&lt;&#x2F;code&gt; ConfigMap recognizes two kinds of keys, and the difference
is not cosmetic:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;&amp;lt;name&amp;gt;.server&lt;&#x2F;code&gt;&lt;&#x2F;strong&gt; — adds a &lt;strong&gt;new, dedicated zone block&lt;&#x2F;strong&gt;. Self-contained.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;&amp;lt;name&amp;gt;.override&lt;&#x2F;code&gt;&lt;&#x2F;strong&gt; — &lt;strong&gt;appends&lt;&#x2F;strong&gt; its content &lt;strong&gt;into the main server block&lt;&#x2F;strong&gt;.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;I first wrote it as &lt;code&gt;.override&lt;&#x2F;code&gt;. That appended a &lt;strong&gt;second &lt;code&gt;hosts&lt;&#x2F;code&gt; plugin&lt;&#x2F;strong&gt; into
CoreDNS&#x27;s main block — and you may only have &lt;strong&gt;one&lt;&#x2F;strong&gt; &lt;code&gt;hosts&lt;&#x2F;code&gt; plugin per block.
Invalid config. CoreDNS refused to load and &lt;strong&gt;CrashLooped cluster-wide&lt;&#x2F;strong&gt;. Not
&quot;the override didn&#x27;t work&quot; — &lt;em&gt;all&lt;&#x2F;em&gt; DNS in the cluster went down, because the main
block is what every normal lookup uses too.&lt;&#x2F;p&gt;
&lt;p&gt;The rule I now follow: &lt;strong&gt;anything that can&#x27;t legally appear twice in a block
(&lt;code&gt;hosts&lt;&#x2F;code&gt;, and friends) must go in a &lt;code&gt;.server&lt;&#x2F;code&gt; zone block, never &lt;code&gt;.override&lt;&#x2F;code&gt;.&lt;&#x2F;strong&gt;
&lt;code&gt;.override&lt;&#x2F;code&gt; is only safe for directives that are additive to the main block.
Switching the key from &lt;code&gt;.override&lt;&#x2F;code&gt; to &lt;code&gt;.server&lt;&#x2F;code&gt; fixed both the outage and the
original cutover problem in one move.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways&lt;&#x2F;h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;In-cluster DNS does not cut over when public DNS does.&lt;&#x2F;strong&gt; It trails by the
upstream cache&#x2F;TTL. If an in-cluster self-check (cert-manager HTTP-01, health
probes, service-to-service calls) breaks right after a cutover, suspect stale
internal resolution first.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Don&#x27;t assume hairpin NAT exists.&lt;&#x2F;strong&gt; On many clouds a pod can&#x27;t reach its own
public ingress IP. Point in-cluster clients at the &lt;strong&gt;internal&lt;&#x2F;strong&gt; address
deliberately, via CoreDNS — don&#x27;t wait for propagation to &quot;fix&quot; it.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;.server&lt;&#x2F;code&gt; vs &lt;code&gt;.override&lt;&#x2F;code&gt; is a sharp edge.&lt;&#x2F;strong&gt; &lt;code&gt;.override&lt;&#x2F;code&gt; mutates the main
CoreDNS block; duplicating a single-instance plugin like &lt;code&gt;hosts&lt;&#x2F;code&gt; there takes
down &lt;strong&gt;all&lt;&#x2F;strong&gt; cluster DNS. Use a &lt;code&gt;.server&lt;&#x2F;code&gt; zone block for anything self-contained.&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;Two subtle networking facts and one config footgun, stacked. Each is obvious in
hindsight; together they made &quot;I just changed a DNS record&quot; cost an afternoon.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>How and why I built a portable HA k3s cluster on a free-tier cloud</title>
        <published>2026-06-04T00:00:00+00:00</published>
        <updated>2026-06-04T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://blog.dune.at/how-and-why-i-built-the-cluster/"/>
        <id>https://blog.dune.at/how-and-why-i-built-the-cluster/</id>
        
        <content type="html" xml:base="https://blog.dune.at/how-and-why-i-built-the-cluster/">&lt;p&gt;The other posts here are war stories — single bugs, dug out one at a time. This
one is the map they hang on: what the cluster actually is, and &lt;em&gt;why&lt;&#x2F;em&gt; each piece
is the way it is. If you (or the agent reading on your behalf) are about to build
something similar, start here, then follow the links into the details.&lt;&#x2F;p&gt;
&lt;p&gt;The whole design falls out of three constraints, so I&#x27;ll state them first because
every decision below is downstream of them:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;It&#x27;s a learning vehicle.&lt;&#x2F;strong&gt; The point is to understand the machinery, not to
rent it by the click. That biases every choice toward &quot;do it by hand in code&quot;
over &quot;click the managed button.&quot;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;The stakes are low.&lt;&#x2F;strong&gt; Everything running on it is self-used or
friends-and-family. Nobody is paying. So &quot;what if the cloud reclaims it&quot; turns
from a business risk into a &lt;em&gt;feature to practise&lt;&#x2F;em&gt; — rehearse the rebuild.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;The budget is ~zero.&lt;&#x2F;strong&gt; It targets a free-tier ARM allocation. That ceiling
(cores, RAM, and a fixed storage pool) shapes the node count, the storage
choice, and why it&#x27;s k3s and not something heavier.&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;h2 id=&quot;why-k3s-and-why-three-servers&quot;&gt;Why k3s, and why three servers&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;strong&gt;k3s over managed Kubernetes:&lt;&#x2F;strong&gt; a managed control plane hides exactly the parts
I wanted to learn, and costs money the budget doesn&#x27;t have. &lt;strong&gt;k3s over kubeadm:&lt;&#x2F;strong&gt;
it&#x27;s lightweight, arm-native, and fits free-tier RAM — kubeadm&#x27;s footprint
fights the ceiling for no learning gain at this size.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Three server nodes with embedded etcd, not one.&lt;&#x2F;strong&gt; A single node is simpler, but
control-plane HA is one of the main things I wanted to learn, and the free pool is
just big enough to carve three small servers (one per availability domain) with
etcd quorum. The honest scope line: &lt;strong&gt;stateless apps reschedule cheaply, so HA
there is free; a HA &lt;em&gt;database&lt;&#x2F;em&gt; is genuinely advanced, so stateful services run
single-replica and lean on backups.&lt;&#x2F;strong&gt; Chasing stateful HA in a learning lab is
where you burn weeks for little.&lt;&#x2F;p&gt;
&lt;p&gt;In front of the three servers sits a small &lt;strong&gt;L4 load balancer&lt;&#x2F;strong&gt; (HAProxy on a
tiny node) so there&#x27;s a single API&#x2F;ingress entrypoint that doesn&#x27;t pin to one
server. It&#x27;s currently a single box — a known SPOF, deliberately deferred; HA of
the LB itself is a later exercise.&lt;&#x2F;p&gt;
&lt;p&gt;Two of the nastiest surprises bringing this up are their own posts:
&lt;a href=&quot;&#x2F;k3s-advertise-address-public-ip&quot;&gt;the API server advertised a public IP&lt;&#x2F;a&gt; and
&lt;a href=&quot;&#x2F;cloud-ubuntu-forward-reject-breaks-pods&quot;&gt;the cloud image&#x27;s firewall silently killed pod networking&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-iac-split-provision-configure-deploy-reconcile&quot;&gt;The IaC split: provision &#x2F; configure &#x2F; deploy &#x2F; reconcile&lt;&#x2F;h2&gt;
&lt;p&gt;Four layers, each with one job, all living in one git repo as the source of truth:&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Layer&lt;&#x2F;th&gt;&lt;th&gt;Tool&lt;&#x2F;th&gt;&lt;th&gt;Job&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Provision&lt;&#x2F;strong&gt;&lt;&#x2F;td&gt;&lt;td&gt;OpenTofu&lt;&#x2F;td&gt;&lt;td&gt;create the VMs, network, firewall rules&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Configure&lt;&#x2F;strong&gt;&lt;&#x2F;td&gt;&lt;td&gt;Ansible&lt;&#x2F;td&gt;&lt;td&gt;OS prep, host firewall, install k3s, bootstrap HA&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Deploy&lt;&#x2F;strong&gt;&lt;&#x2F;td&gt;&lt;td&gt;k8s manifests&lt;&#x2F;td&gt;&lt;td&gt;the apps and platform add-ons&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Reconcile&lt;&#x2F;strong&gt;&lt;&#x2F;td&gt;&lt;td&gt;Argo CD&lt;&#x2F;td&gt;&lt;td&gt;keep the cluster matching git (GitOps)&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;The split is the point. OpenTofu owns &lt;em&gt;what exists&lt;&#x2F;em&gt;; Ansible owns &lt;em&gt;what&#x27;s on the
box&lt;&#x2F;em&gt;; manifests own &lt;em&gt;what runs&lt;&#x2F;em&gt;; Argo CD owns &lt;em&gt;staying that way&lt;&#x2F;em&gt;. The decision
that made this tractable: &lt;strong&gt;secrets never live in git&lt;&#x2F;strong&gt; — the cluster token is
generated on the primary at bootstrap and handed to the joiners in memory; app
credentials are Kubernetes Secrets created out of band. Git holds the shape, not
the keys.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;two-ingress-planes-and-why-the-dashboards-aren-t-public&quot;&gt;Two ingress planes — and why the dashboards aren&#x27;t public&lt;&#x2F;h2&gt;
&lt;p&gt;This is the design choice I&#x27;m happiest with. There are &lt;strong&gt;two&lt;&#x2F;strong&gt; ways into the
cluster, and which one an app gets depends on who it&#x27;s for:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Public plane&lt;&#x2F;strong&gt; — Traefik (the k3s default) + cert-manager + Let&#x27;s Encrypt,
with the L4 LB out front and a wildcard subdomain pointing at it. TLS is
per-host via the &lt;strong&gt;HTTP-01&lt;&#x2F;strong&gt; challenge solved through Traefik — no DNS-01, no
DNS API token to hold. This is for things genuinely meant for the internet (the
blog you&#x27;re reading).&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Private plane&lt;&#x2F;strong&gt; — a &lt;strong&gt;Tailscale ingress class&lt;&#x2F;strong&gt;. Anything I apply with it
becomes reachable &lt;em&gt;only on my tailnet&lt;&#x2F;em&gt;, never on the public internet, with no
port open at the edge.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Why bother with two? Because the admin tooling has weak or no authentication, and
the safest auth is &lt;em&gt;not being reachable&lt;&#x2F;em&gt;. The cluster dashboards — the Kubernetes
GUI, the storage UI, the GitOps UI, the uptime dashboard — all go on the &lt;strong&gt;private
plane&lt;&#x2F;strong&gt;. The storage UI in particular ships with &lt;em&gt;no login at all&lt;&#x2F;em&gt;; tailnet-only
is its entire security model. Putting an unauthenticated admin panel on the public
internet behind a &quot;nobody will find it&quot; URL is exactly the mistake this avoids.&lt;&#x2F;p&gt;
&lt;p&gt;The cutover from old infra onto the public plane had its own trap, worth reading
before you migrate anything stateful with TLS:
&lt;a href=&quot;&#x2F;coredns-stale-ip-after-dns-cutover&quot;&gt;the cluster kept resolving the old IP and one config slip took out all DNS&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;storage-replicated-where-it-must-be-free-where-it-can-be&quot;&gt;Storage: replicated where it must be, free where it can be&lt;&#x2F;h2&gt;
&lt;p&gt;Two tiers, chosen against that fixed storage pool:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Longhorn&lt;&#x2F;strong&gt; for anything that needs to survive a node dying — it replicates
volumes across nodes so a stateful pod can reschedule with its data. That&#x27;s the
whole reason it&#x27;s here: single-replica DBs plus &lt;em&gt;replicated storage&lt;&#x2F;em&gt; is a
reasonable durability story without chasing database-level HA.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Node-local space&lt;&#x2F;strong&gt; for anything reproducible or disposable, because the free
storage pool is capped and external block volumes pile billable storage on top
of a pool that&#x27;s already maxed (the &lt;a href=&quot;&#x2F;oci-47gb-floor&quot;&gt;free-tier storage floor&lt;&#x2F;a&gt;
is its own surprise).&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Longhorn also taught me that its &quot;used&quot; gauge is not filesystem usage —
&lt;a href=&quot;&#x2F;longhorn-actualsize-isnt-disk-usage&quot;&gt;a story about thin provisioning and TRIM&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;observability-alerting-identity-failover&quot;&gt;Observability, alerting, identity, failover&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Observability:&lt;&#x2F;strong&gt; I ran kube-prometheus-stack for metrics, with alerts pushed
to a notification topic so the cluster could page me without an in-cluster
notifier to babysit — and learned the hard way how fast a tiny cluster fills a
metrics volume (&lt;a href=&quot;&#x2F;observability-cardinality-1gb-in-hours&quot;&gt;its own post&lt;&#x2F;a&gt;). That
cost is exactly why I &lt;a href=&quot;&#x2F;i-deleted-my-observability-stack&quot;&gt;tore the whole stack out for a leaner Beszel + Gatus
setup&lt;&#x2F;a&gt;; full Prometheus is more observability
than a friends-and-family cluster needs to carry.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Identity:&lt;&#x2F;strong&gt; a single self-hosted IdP (Zitadel) as the one place accounts live,
rather than per-app logins scattered around.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Failover:&lt;&#x2F;strong&gt; a &lt;em&gt;documented manual runbook&lt;&#x2F;em&gt;, not automation — provision the
standby with OpenTofu against a second cloud, repoint DNS. For friends-and-family
stakes, manual is the right scope; auto-failover is a lot of machinery for no
payers. The stability model is &lt;strong&gt;fast designed recovery&lt;&#x2F;strong&gt;, not &quot;it never fails.&quot;&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;the-bill-and-the-north-star&quot;&gt;The bill, and the north star&lt;&#x2F;h2&gt;
&lt;p&gt;The point of all this is to collapse a handful of paid VMs down to a free-tier
cluster plus cheap object storage for backups — landing the running cost near the
storage floor, a euro or so a month. The rule that keeps it there: &lt;strong&gt;only ever
provision free-tier shapes, and set a near-zero budget alert as a tripwire&lt;&#x2F;strong&gt;,
because the free tier has no hard spend cap.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;if-you-re-building-this-too&quot;&gt;If you&#x27;re building this too&lt;&#x2F;h2&gt;
&lt;p&gt;The decisions that mattered most, distilled:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Let your constraints pick your architecture.&lt;&#x2F;strong&gt; Free-tier + low-stakes +
learning is what justifies k3s, manual failover, single-replica DBs, and
doing it all in code. Different constraints, different cluster.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;One git repo, four layers, no secrets in it.&lt;&#x2F;strong&gt; Provision &#x2F; configure &#x2F; deploy
&#x2F; reconcile each own one thing; keys are injected, never committed.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Default admin tooling to &quot;not reachable.&quot;&lt;&#x2F;strong&gt; A private network plane for
anything with weak auth beats a public URL you hope nobody guesses.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Replicate storage only where data can&#x27;t be regenerated.&lt;&#x2F;strong&gt; Everything else
runs on disposable local space — especially when the storage pool is capped.&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;Everything above is the &lt;em&gt;why&lt;&#x2F;em&gt;. The linked posts are the &lt;em&gt;what broke and how I
fixed it&lt;&#x2F;em&gt;. Together they&#x27;re the version of this project I wish I&#x27;d been handed on
day one.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>I built the full observability stack, verified it, then deleted it</title>
        <published>2026-06-04T00:00:00+00:00</published>
        <updated>2026-06-04T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://blog.dune.at/i-deleted-my-observability-stack/"/>
        <id>https://blog.dune.at/i-deleted-my-observability-stack/</id>
        
        <content type="html" xml:base="https://blog.dune.at/i-deleted-my-observability-stack/">&lt;p&gt;A few days ago I stood up the textbook self-hosted observability stack on my
&lt;a href=&quot;&#x2F;how-and-why-i-built-the-cluster&quot;&gt;k3s cluster&lt;&#x2F;a&gt;: Prometheus for metrics, Grafana
for dashboards, Loki for logs, alerts wired to push notifications. It worked. Dashboards rendered, a test alert hit my
phone, I marked the issue done. Then I tore the whole thing out and replaced it
with two small tools. This is the reasoning, because &quot;I built the impressive
thing and then deleted it&quot; is a more useful story than most build logs.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-pushed-me-over&quot;&gt;What pushed me over&lt;&#x2F;h2&gt;
&lt;p&gt;Two days after it went live, the Prometheus volume filled and ingestion stopped —
the cluster went quietly blind right when I&#x27;d have wanted it most. That incident
is &lt;a href=&quot;&#x2F;observability-cardinality-1gb-in-hours&quot;&gt;its own post&lt;&#x2F;a&gt;; the short version is
that the stack was the &lt;strong&gt;heaviest tenant on the cluster&lt;&#x2F;strong&gt;, running on CPU-bound
single-core nodes, and its appetite was the direct cause of the outage. Two days
of firefighting to keep the monitoring from being the thing that needed
monitoring.&lt;&#x2F;p&gt;
&lt;p&gt;Then the honest question: &lt;strong&gt;how much of this depth do I actually use?&lt;&#x2F;strong&gt; Almost
none. I barely opened the deep metric explorers; I&#x27;d never written a serious
Grafana query against my own data. I was paying — in CPU, in disk, in incidents —
for capability I didn&#x27;t touch. That fails the only test that matters in a
self-hosted lab: &lt;strong&gt;don&#x27;t run what you don&#x27;t use.&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-replacement-two-engines-not-a-platform&quot;&gt;The replacement: two engines, not a platform&lt;&#x2F;h2&gt;
&lt;p&gt;I split &quot;monitoring&quot; back into the two questions it actually answers, and picked
the lightest tool for each:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Resource vitals&lt;&#x2F;strong&gt; — CPU, memory, &lt;strong&gt;disk&lt;&#x2F;strong&gt;, temperature, per host and
container — handled by &lt;strong&gt;Beszel&lt;&#x2F;strong&gt;: a lightweight agent on each host reporting to
a small hub. Node-level, cheap, always-on.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Availability + heartbeats&lt;&#x2F;strong&gt; — &lt;strong&gt;Gatus&lt;&#x2F;strong&gt;: a black-box prober that hits the real
URLs of the things I care about and pages me when one stops answering, plus
dead-man checks for jobs that should run on a schedule.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Delivery for both is a single push channel — &lt;strong&gt;ntfy&lt;&#x2F;strong&gt; — one templated line per
alert: &lt;em&gt;what broke, and where&lt;&#x2F;em&gt;. No dashboards-as-a-product, no query language, no
log warehouse. If I need deep diagnosis, I open the cluster GUI or reach for
&lt;code&gt;kubectl&lt;&#x2F;code&gt; on demand — I don&#x27;t keep a metrics firehose running 24&#x2F;7 on the off
chance.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-insight-that-reshaped-it-a-monitor-can-t-certify-itself&quot;&gt;The insight that reshaped it: a monitor can&#x27;t certify itself&lt;&#x2F;h2&gt;
&lt;p&gt;This is the part worth stealing. My in-cluster stack could go &lt;strong&gt;falsely green&lt;&#x2F;strong&gt; —
and on the incident day, an in-cluster heartbeat &lt;em&gt;would&lt;&#x2F;em&gt; have, because the thing
checking was inside the thing that was failing. A monitor that lives with what it
monitors can&#x27;t tell you the patient is dead; it dies with it.&lt;&#x2F;p&gt;
&lt;p&gt;So the availability prober runs &lt;strong&gt;off the cluster, on a different provider&lt;&#x2F;strong&gt;, and
checks the &lt;strong&gt;public URLs from outside&lt;&#x2F;strong&gt; — the same path a real user takes. That
black-box, outside-in check is the actual fix for the blind spot, not a bigger
in-cluster stack. I run two such watchers in different places and have them
&lt;strong&gt;cross-watch each other&lt;&#x2F;strong&gt;, with a free external dead-man service as the apex
backstop for the rare case where both are down at once.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;pull-first-with-exactly-one-exception&quot;&gt;Pull-first, with exactly one exception&lt;&#x2F;h2&gt;
&lt;p&gt;A principle that fell out of this: &lt;strong&gt;prefer pulling over pushing.&lt;&#x2F;strong&gt; A watcher that
reaches out to probe needs no inbound hole in anything it watches — it fits a
&quot;no open ports&quot; posture by construction. The &lt;em&gt;only&lt;&#x2F;em&gt; push in the design is backup
&lt;strong&gt;heartbeats&lt;&#x2F;strong&gt;: a cron job has no endpoint to probe, so it has to announce &quot;I
ran.&quot; Those ride a private network, and the semantics are simply &lt;em&gt;missed = noticed&lt;&#x2F;em&gt;.
One deliberate exception, justified; everything else pulls.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-i-gave-up-on-purpose&quot;&gt;What I gave up, on purpose&lt;&#x2F;h2&gt;
&lt;p&gt;Honesty about the trade-off, because it&#x27;s real: I lost &lt;strong&gt;per-pod metrics inside
the cluster&lt;&#x2F;strong&gt;. The lightweight agent sees the node, not each container, on this
runtime. I decided that&#x27;s acceptable:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;user-facing health is covered by the outside-in app probes;&lt;&#x2F;li&gt;
&lt;li&gt;node pressure is covered by the vitals agent;&lt;&#x2F;li&gt;
&lt;li&gt;deep, occasional diagnosis is on-demand via the cluster GUI &#x2F; &lt;code&gt;kubectl&lt;&#x2F;code&gt;.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;And it&#x27;s recoverable: if I ever genuinely want long-term cluster metrics back, the
move is a &lt;strong&gt;hosted free-tier metrics backend&lt;&#x2F;strong&gt; (Grafana Cloud&#x27;s free tier) fed by
a &lt;em&gt;curated, low-cardinality&lt;&#x2F;em&gt; remote-write — the lesson from the incident baked in — not a full self-hosted
stack returning to eat the cluster again. Deferred until I actually want it.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;things-i-considered-and-rejected&quot;&gt;Things I considered and rejected&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Netdata&lt;&#x2F;strong&gt; (the one tool that &lt;em&gt;does&lt;&#x2F;em&gt; see per-pod via cgroups): ruled out — too
heavy for single-core nodes, and it had nearly taken down another box of mine
before. The per-pod visibility wasn&#x27;t worth the weight for something I rarely use.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Folding host vitals into the cluster Prometheus&lt;&#x2F;strong&gt; (my own first instinct):
rejected — it keeps the heavy stack &lt;em&gt;and&lt;&#x2F;em&gt; couples the host vitals to the very
cluster that goes blind when it&#x27;s sick.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;A second alerting brain off-cluster with alert inhibition:&lt;&#x2F;strong&gt; cut. It adds a
public silence API (another hole) and, worse, inhibition can &lt;strong&gt;silently swallow
a real page&lt;&#x2F;strong&gt;. Plain grouping, severity, and long repeat intervals get the
&quot;don&#x27;t spam me&quot; outcome without a rule that can eat an alert you needed.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways&lt;&#x2F;h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Match the monitoring to what you&#x27;ll actually look at.&lt;&#x2F;strong&gt; Impressive ≠ useful.
The heavy stack failed the &quot;do I use this&quot; test; deleting it was the upgrade.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;A monitor can&#x27;t be inside what it monitors.&lt;&#x2F;strong&gt; Outside-in, black-box probes
from a different failure domain are the only checks that don&#x27;t go falsely green.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Pull by default; justify every push.&lt;&#x2F;strong&gt; Probing needs no inbound holes. The
one push here (job heartbeats) earns its exception because cron has no endpoint.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Name your trade-off out loud.&lt;&#x2F;strong&gt; I gave up per-pod metrics knowingly, with a
documented path to get them back cheaply if I ever care. That&#x27;s a decision, not
an accident.&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;The fanciest version of this cluster ran a full observability platform. The
better version runs two small tools and a notification topic — and is no longer
at risk of being knocked over by its own monitoring.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>k3s advertised a public IP for the API server, and every pod lost the cluster</title>
        <published>2026-06-04T00:00:00+00:00</published>
        <updated>2026-06-04T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://blog.dune.at/k3s-advertise-address-public-ip/"/>
        <id>https://blog.dune.at/k3s-advertise-address-public-ip/</id>
        
        <content type="html" xml:base="https://blog.dune.at/k3s-advertise-address-public-ip/">&lt;p&gt;This was the single worst bug of the whole build, so it goes first for the next
person — or the next agent — standing up HA k3s on dual-homed cloud VMs.&lt;&#x2F;p&gt;
&lt;p&gt;The cluster came up. Three server nodes, all &lt;code&gt;Ready&lt;&#x2F;code&gt;, etcd healthy, &lt;code&gt;kubectl get nodes&lt;&#x2F;code&gt; clean. And then nothing that needed the API &lt;em&gt;from inside the cluster&lt;&#x2F;em&gt;
worked. CoreDNS CrashLooping. Service accounts failing. Controllers timing out
talking to &lt;code&gt;kubernetes.default&lt;&#x2F;code&gt;. The control plane was fine; the cluster&#x27;s own
clients couldn&#x27;t reach it.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-symptom-precisely&quot;&gt;The symptom, precisely&lt;&#x2F;h2&gt;
&lt;p&gt;Every cloud VM here has two addresses: a &lt;strong&gt;public&lt;&#x2F;strong&gt; IP on the internet-facing
NIC and a &lt;strong&gt;private&lt;&#x2F;strong&gt; IP on the internal virtual network (the &lt;code&gt;10.x&lt;&#x2F;code&gt; range the
nodes share). Pods route to the API server through the in-cluster Service
&lt;code&gt;kubernetes.default&lt;&#x2F;code&gt; — a ClusterIP whose Endpoints are the real addresses of the
API servers.&lt;&#x2F;p&gt;
&lt;p&gt;I looked at those Endpoints. They were the nodes&#x27; &lt;strong&gt;public&lt;&#x2F;strong&gt; IPs.&lt;&#x2F;p&gt;
&lt;p&gt;That is the whole bug. Pods live on the pod&#x2F;private network. The cloud&#x27;s
security rules and the lack of NAT hairpin mean a pod &lt;strong&gt;cannot&lt;&#x2F;strong&gt; reach a node&#x27;s
public IP from inside. So every in-cluster client was being told &quot;the API server
is at &lt;code&gt;&amp;lt;public-ip&amp;gt;:6443&lt;&#x2F;code&gt;&quot; — an address it has no route to. External &lt;code&gt;kubectl&lt;&#x2F;code&gt;
worked (it comes in from the internet); in-cluster traffic died.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;why-k3s-does-this&quot;&gt;Why k3s does this&lt;&#x2F;h2&gt;
&lt;p&gt;By default k3s picks its advertise address from the node&#x27;s &lt;strong&gt;default route&lt;&#x2F;strong&gt;. On
a dual-homed cloud node the default route goes out the public NIC, so k3s
advertises the public IP — and that address is what lands in the &lt;code&gt;kubernetes&lt;&#x2F;code&gt;
Service Endpoints that every pod uses. On a single-homed box you&#x27;d never see it.
On a cloud VM with a public NIC it&#x27;s the default, and it&#x27;s wrong for in-cluster
traffic.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-fix-pin-every-address-explicitly&quot;&gt;The fix: pin every address explicitly&lt;&#x2F;h2&gt;
&lt;p&gt;Don&#x27;t let k3s guess. Spell out which address is for what. On both the
&lt;code&gt;cluster-init&lt;&#x2F;code&gt; node and every joining server:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;--node-ip            &amp;lt;private-ip&amp;gt;   # what this node IS on the cluster network
--advertise-address  &amp;lt;private-ip&amp;gt;   # what goes into the kubernetes Service — THE fix
--node-external-ip   &amp;lt;public-ip&amp;gt;    # public reachability, kept out of in-cluster routing
--tls-san            &amp;lt;public-ip&amp;gt;    # so the API cert is valid for external kubectl too
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The decision in one line: &lt;strong&gt;private IP for everything in-cluster
(&lt;code&gt;--node-ip&lt;&#x2F;code&gt;, &lt;code&gt;--advertise-address&lt;&#x2F;code&gt;); public IP only for things that face
outward (&lt;code&gt;--node-external-ip&lt;&#x2F;code&gt;, &lt;code&gt;--tls-san&lt;&#x2F;code&gt;).&lt;&#x2F;strong&gt; After re-installing with those
flags, the &lt;code&gt;kubernetes&lt;&#x2F;code&gt; Endpoints showed the &lt;code&gt;10.x&lt;&#x2F;code&gt; addresses, CoreDNS settled,
and the controllers reconnected.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;takeaways-for-anyone-or-anything-building-this&quot;&gt;Takeaways for anyone (or anything) building this&lt;&#x2F;h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;On any multi-homed cloud node, never let the CNI&#x2F;distro auto-detect the
advertise address.&lt;&#x2F;strong&gt; The default route is the public NIC, and the public IP is
exactly the wrong thing to put in the &lt;code&gt;kubernetes&lt;&#x2F;code&gt; Service.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;The tell is the Endpoints, not the node status.&lt;&#x2F;strong&gt; Nodes go &lt;code&gt;Ready&lt;&#x2F;code&gt; because
the kubelet reaches the API fine over localhost&#x2F;public. The breakage is purely
in &lt;em&gt;pod → API&lt;&#x2F;em&gt; routing. Check what &lt;code&gt;kubernetes.default&lt;&#x2F;code&gt;&#x27;s Endpoints actually
resolve to.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Separate the two jobs of an IP.&lt;&#x2F;strong&gt; Internal identity (&lt;code&gt;node-ip&lt;&#x2F;code&gt;,
&lt;code&gt;advertise-address&lt;&#x2F;code&gt;) and external reachability (&lt;code&gt;node-external-ip&lt;&#x2F;code&gt;, &lt;code&gt;tls-san&lt;&#x2F;code&gt;)
are different concerns. Conflating them is what bites you.&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;If your freshly-built cluster is green but its own pods can&#x27;t talk to the API,
this is almost certainly it. Look at the Endpoints first.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Longhorn said 3 GB used. The filesystem said 0.9. Nobody was lying.</title>
        <published>2026-06-04T00:00:00+00:00</published>
        <updated>2026-06-04T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://blog.dune.at/longhorn-actualsize-isnt-disk-usage/"/>
        <id>https://blog.dune.at/longhorn-actualsize-isnt-disk-usage/</id>
        
        <content type="html" xml:base="https://blog.dune.at/longhorn-actualsize-isnt-disk-usage/">&lt;p&gt;Yesterday I fixed a real disk-full on my cluster&#x27;s Prometheus volume — expanded it to
3 GB, trimmed the metrics flooding it. Today I opened the Longhorn UI and saw the volume
at &lt;strong&gt;2.97 GB of 3 GB used&lt;&#x2F;strong&gt; again. Less than a day later. Stomach drop.&lt;&#x2F;p&gt;
&lt;p&gt;It wasn&#x27;t full. Here&#x27;s the gotcha, because it&#x27;ll catch anyone running thin-provisioned
storage.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;actualsize-is-not-disk-usage&quot;&gt;actualSize is not disk usage&lt;&#x2F;h2&gt;
&lt;p&gt;The number Longhorn shows you — &lt;code&gt;actualSize&lt;&#x2F;code&gt; — is &lt;strong&gt;how many blocks the volume&#x27;s replicas
have touched on disk&lt;&#x2F;strong&gt;, including Longhorn&#x27;s own internal snapshots. It is &lt;em&gt;not&lt;&#x2F;em&gt; how full the
filesystem is.&lt;&#x2F;p&gt;
&lt;p&gt;I checked what Prometheus actually had on the filesystem:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;blocks (persisted):  619 MB
write-ahead log:     233 MB
head chunks:          27 MB
-----------------------------
real data:          ~880 MB   of a 3 GB volume  (29%)
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;29%. The pod had 24 hours of uptime, zero restarts, no write errors. Nothing was wrong with
the filesystem at all. So where did 2.97 GB come from?&lt;&#x2F;p&gt;
&lt;h2 id=&quot;two-hoarders&quot;&gt;Two hoarders&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;strong&gt;A leftover snapshot.&lt;&#x2F;strong&gt; When you expand a Longhorn volume it auto-creates a system snapshot
(&lt;code&gt;expand-&amp;lt;size&amp;gt;&lt;&#x2F;code&gt;). Mine was ~1 GB and Longhorn hadn&#x27;t coalesced it — it just sat in the
replica chain, counting against &lt;code&gt;actualSize&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Thin-provisioning lag.&lt;&#x2F;strong&gt; This is the one worth internalising. When Prometheus deletes data —
old blocks aged out by retention, the high-cardinality series I trimmed yesterday — the
&lt;em&gt;filesystem&lt;&#x2F;em&gt; marks those blocks free. But the &lt;em&gt;block layer underneath&lt;&#x2F;em&gt; doesn&#x27;t get told. Those
blocks stay &quot;allocated&quot; as far as Longhorn is concerned until something issues a &lt;strong&gt;TRIM&lt;&#x2F;strong&gt;
(&lt;code&gt;fstrim&lt;&#x2F;code&gt;) to hand them back. Another ~1 GB of &quot;used&quot; that was actually free.&lt;&#x2F;p&gt;
&lt;p&gt;So: ~880 MB real data + ~1 GB stale snapshot + ~1 GB un-trimmed free blocks ≈ the 2.97 GB that
scared me. None of it filesystem pressure.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-fix-is-to-take-out-the-trash-not-buy-a-bigger-bin&quot;&gt;The fix is to take out the trash, not buy a bigger bin&lt;&#x2F;h2&gt;
&lt;ol&gt;
&lt;li&gt;Delete the leftover expand snapshot.&lt;&#x2F;li&gt;
&lt;li&gt;Turn on Longhorn&#x27;s &lt;em&gt;&quot;remove snapshots during filesystem trim&quot;&lt;&#x2F;em&gt; so a trim also coalesces
removed snapshots in one pass.&lt;&#x2F;li&gt;
&lt;li&gt;Run a filesystem trim.&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;pre&gt;&lt;code&gt;actualSize: 3.19 GB  →  0.98 GB
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;One pass, ~2.2 GB reclaimed, Prometheus didn&#x27;t even notice (online trim, no restart). 0.98 GB
matches the real data — the hoarders are gone.&lt;&#x2F;p&gt;
&lt;p&gt;Then the part that matters more than the cleanup: a &lt;strong&gt;weekly Longhorn RecurringJob&lt;&#x2F;strong&gt; with task
&lt;code&gt;filesystem-trim&lt;&#x2F;code&gt;, so freed blocks get released automatically and I never watch this gauge
again.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-takeaway-for-thin-provisioned-storage&quot;&gt;The takeaway for thin-provisioned storage&lt;&#x2F;h2&gt;
&lt;p&gt;When your storage layer screams &quot;nearly full,&quot; check the &lt;em&gt;filesystem&lt;&#x2F;em&gt; before you add disk:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;actualSize&lt;&#x2F;code&gt; (or equivalent) ≠ filesystem usage.&lt;&#x2F;strong&gt; It counts snapshots and un-reclaimed
blocks. Look at what the workload actually wrote.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Thin provisioning doesn&#x27;t shrink on its own.&lt;&#x2F;strong&gt; Deleting data frees it for the filesystem,
not for the block layer. Schedule periodic &lt;code&gt;fstrim&lt;&#x2F;code&gt; — or your &quot;usage&quot; only ever goes up.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Volume operations leave snapshots.&lt;&#x2F;strong&gt; Expansions and rebuilds drop system snapshots that
linger unless you (or a job) clean them.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Yesterday&#x27;s disk-full was real and I gave it more disk. Today&#x27;s was the storage layer hoarding
free space — and the answer was a trim job, not a bigger volume. Worth knowing which one you&#x27;re
looking at before you reach for the disk slider.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>A static blog on Kubernetes with no registry, no database, and no persistent volume</title>
        <published>2026-06-04T00:00:00+00:00</published>
        <updated>2026-06-04T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://blog.dune.at/static-blog-on-k8s-no-registry-no-pv/"/>
        <id>https://blog.dune.at/static-blog-on-k8s-no-registry-no-pv/</id>
        
        <content type="html" xml:base="https://blog.dune.at/static-blog-on-k8s-no-registry-no-pv/">&lt;p&gt;This is the post about the site you&#x27;re reading. I wanted to publish a static
blog on the cluster without three things the &quot;obvious&quot; path drags in: a container
registry to push a custom image to, a CI pipeline to build that image, and a
persistent volume to hold the rendered site. None of them are necessary when the
content is &lt;strong&gt;fully reproducible from git&lt;&#x2F;strong&gt;. Here&#x27;s the shape, and why each piece
is missing on purpose.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-idea-the-pod-builds-itself-at-startup&quot;&gt;The idea: the pod builds itself at startup&lt;&#x2F;h2&gt;
&lt;p&gt;There is no custom image. The running Pod assembles the site from git every time
it starts, using a chain of &lt;strong&gt;initContainers&lt;&#x2F;strong&gt; over a shared scratch volume,
then a stock web server serves the result:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;clone&lt;&#x2F;strong&gt; — a stock &lt;code&gt;git&lt;&#x2F;code&gt; image shallow-clones the repo into an &lt;code&gt;emptyDir&lt;&#x2F;code&gt;.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;build&lt;&#x2F;strong&gt; — a stock static-site-generator image renders the site, in place,
inside that clone.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;copy&lt;&#x2F;strong&gt; — a tiny image copies the rendered output into a second &lt;code&gt;emptyDir&lt;&#x2F;code&gt;.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;serve&lt;&#x2F;strong&gt; — a stock &lt;code&gt;nginx&lt;&#x2F;code&gt; image serves that second volume, read-only.&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;pre&gt;&lt;code&gt;initContainers:  git-clone  →  ssg-build  →  copy-output
                      └──── emptyDir: src ────┘     │
                                                emptyDir: site
container:       nginx  (mounts emptyDir: site, readOnly)
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Publishing is just &lt;code&gt;git push&lt;&#x2F;code&gt;. CI does &lt;strong&gt;one&lt;&#x2F;strong&gt; thing: &lt;code&gt;kubectl rollout restart&lt;&#x2F;code&gt;
the Deployment. New Pods come up, re-run the init chain, and serve the latest
commit. The &quot;build&quot; happens in the Pod, at start, from upstream images.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;why-each-thing-is-absent-the-actual-decisions&quot;&gt;Why each thing is absent — the actual decisions&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;strong&gt;No registry.&lt;&#x2F;strong&gt; You only need a registry if you&#x27;re baking a &lt;em&gt;custom&lt;&#x2F;em&gt; image. Here
every image is stock and upstream (&lt;code&gt;git&lt;&#x2F;code&gt;, the SSG, &lt;code&gt;nginx&lt;&#x2F;code&gt;); the only thing that
varies — your content — is injected at runtime by cloning it. Nothing to build,
nothing to push, nothing to store, nothing to keep patched yourself.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;No persistent volume.&lt;&#x2F;strong&gt; &lt;code&gt;emptyDir&lt;&#x2F;code&gt; is exactly right when the data is
disposable and reproducible. The site is regenerated from git on every start, so
there is nothing worth persisting. The Pod is genuinely stateless: kill it, it
rebuilds identically. PVs exist to keep data a Pod &lt;em&gt;can&#x27;t&lt;&#x2F;em&gt; regenerate; this Pod
can regenerate everything.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;No CI image build.&lt;&#x2F;strong&gt; Because the build runs in the Pod, CI has no image step.
It triggers a rollout and stops. The cluster, not the CI runner, is the build
host — which also means the build environment is the same upstream image every
time, pinned by tag.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;gotchas-worth-keeping&quot;&gt;Gotchas worth keeping&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Build into the SSG&#x27;s default output dir, not a mounted path.&lt;&#x2F;strong&gt; Some
generators wipe the output directory before writing (a &lt;code&gt;--force&lt;&#x2F;code&gt;-style clean).
If that directory is a mount point, the wipe fails. So build &lt;em&gt;inside&lt;&#x2F;em&gt; the cloned
tree and &lt;code&gt;copy&lt;&#x2F;code&gt; the result into the served volume as a separate step — that&#x27;s
why there are two &lt;code&gt;emptyDir&lt;&#x2F;code&gt;s and a copy stage, not one shared mount.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Replicas each rebuild independently.&lt;&#x2F;strong&gt; With two replicas and a rolling
restart, each Pod clones and builds on its own. There&#x27;s a few seconds of version
skew mid-rollout. For a blog that&#x27;s fine; for anything transactional it wouldn&#x27;t
be.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Private repo = read-only token in a Secret.&lt;&#x2F;strong&gt; The clone uses a scoped,
read-only credential pulled from a Kubernetes Secret. No write access, nothing
baked into an image.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;the-honest-trade-offs&quot;&gt;The honest trade-offs&lt;&#x2F;h2&gt;
&lt;p&gt;This is the right tool only when the build is cheap and the content is git-native.
What you pay for the simplicity:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Slower Pod start&lt;&#x2F;strong&gt; — every start pays clone + build, instead of pulling a
prebuilt image.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;No build gate&lt;&#x2F;strong&gt; — a broken commit produces a broken build &lt;em&gt;inside&lt;&#x2F;em&gt; the Pod.
If that matters, also build in CI (purely as a validation step) or gate the
rollout on a readiness probe so a failed build never serves.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Doesn&#x27;t scale to heavy builds or huge sites&lt;&#x2F;strong&gt; — a multi-minute build on every
Pod start is the wrong place to be; that&#x27;s when a real image pipeline earns its
keep.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;takeaway&quot;&gt;Takeaway&lt;&#x2F;h2&gt;
&lt;p&gt;When your artifact is fully reproducible from git, you can collapse the whole
registry + PV + CI-image stack into &lt;strong&gt;stock images + initContainers + &lt;code&gt;rollout restart&lt;&#x2F;code&gt;&lt;&#x2F;strong&gt;. The Pod becomes the build host and git becomes the only source of
truth. It&#x27;s not the answer for every workload — but for a static site it removes
three moving parts you&#x27;d otherwise own, patch, and debug forever.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>My 3-node lab filled 1GB in hours. My old monitoring box would&#x27;ve taken years.</title>
        <published>2026-06-03T00:00:00+00:00</published>
        <updated>2026-06-03T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://blog.dune.at/observability-cardinality-1gb-in-hours/"/>
        <id>https://blog.dune.at/observability-cardinality-1gb-in-hours/</id>
        
        <content type="html" xml:base="https://blog.dune.at/observability-cardinality-1gb-in-hours/">&lt;p&gt;I come from monitoring, not observability. Fifteen years of Nagios, then Zabbix,
then check_mk. In that world a gigabyte of disk is a &lt;em&gt;lot&lt;&#x2F;em&gt;. I&#x27;ve run boxes that
watched a few hundred hosts and didn&#x27;t fill 1GB in a year.&lt;&#x2F;p&gt;
&lt;p&gt;So when I gave Prometheus a 1GB volume on my three-node k3s lab, it felt
generous. It was empty by lunchtime. Not &quot;getting full&quot; — &lt;strong&gt;full&lt;&#x2F;strong&gt;, write errors,
ingestion stopped, my brand-new alerting blind. Hours, not years.&lt;&#x2F;p&gt;
&lt;p&gt;Here&#x27;s what I learned pulling it apart, written for the version of me who still
thinks in checks.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-old-world-you-store-the-answers&quot;&gt;The old world: you store the answers&lt;&#x2F;h2&gt;
&lt;p&gt;In monitoring, &lt;em&gt;you&lt;&#x2F;em&gt; write the questions up front. &quot;Is disk over 90%?&quot; &quot;Is the
service responding?&quot; &quot;Is latency above 200ms?&quot; The system runs your checks and
stores the &lt;strong&gt;results&lt;&#x2F;strong&gt; — often just a state (OK &#x2F; WARN &#x2F; CRIT), or one
pre-aggregated number per check, frequently into a round-robin database that
overwrites old data at a fixed resolution.&lt;&#x2F;p&gt;
&lt;p&gt;Your footprint is bounded by &lt;em&gt;how many checks you bothered to define&lt;&#x2F;em&gt;. That&#x27;s why
1GB lasts years: you only ever wrote down what you chose to ask.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-new-world-you-store-the-raw-material&quot;&gt;The new world: you store the raw material&lt;&#x2F;h2&gt;
&lt;p&gt;Prometheus inverts it. The philosophy is &lt;em&gt;collect everything at full detail now,
decide the questions later&lt;&#x2F;em&gt;. You don&#x27;t store &quot;API latency&quot; — you store API
latency broken down by HTTP verb × resource type × response code × scope ×
instance, and pre-bucketed into a dozen-plus latency ranges, &lt;strong&gt;every combination
kept as its own stream&lt;&#x2F;strong&gt;, so that six months from now you can ask a question you
haven&#x27;t thought of yet without having pre-defined the check.&lt;&#x2F;p&gt;
&lt;p&gt;The unit of cost is the &lt;strong&gt;series&lt;&#x2F;strong&gt;: one unique metric name &lt;em&gt;plus its exact set of
labels&lt;&#x2F;em&gt;. These are three different series:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;apiserver_request_duration_seconds_bucket{verb=&amp;quot;GET&amp;quot;,  resource=&amp;quot;pods&amp;quot;, le=&amp;quot;0.1&amp;quot;}
apiserver_request_duration_seconds_bucket{verb=&amp;quot;GET&amp;quot;,  resource=&amp;quot;pods&amp;quot;, le=&amp;quot;0.5&amp;quot;}
apiserver_request_duration_seconds_bucket{verb=&amp;quot;POST&amp;quot;, resource=&amp;quot;cm&amp;quot;,   le=&amp;quot;0.1&amp;quot;}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Change any label — a different verb, a different bucket boundary &lt;code&gt;le&lt;&#x2F;code&gt; — and it&#x27;s a
brand-new stream that gets a fresh value written to disk every scrape. My lab had
&lt;strong&gt;300,000 of them&lt;&#x2F;strong&gt;. Every 60 seconds it wrote 300,000 numbers to disk. That&#x27;s
the gigabyte.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;71-of-it-was-histograms-i-never-looked-at&quot;&gt;71% of it was histograms I never looked at&lt;&#x2F;h2&gt;
&lt;p&gt;When I asked Prometheus what was actually in there, the answer was blunt:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;92% of all series came from just two scrape jobs (the kubelet and the apiserver).&lt;&#x2F;li&gt;
&lt;li&gt;71% of the entire database was histogram &lt;strong&gt;buckets&lt;&#x2F;strong&gt;.&lt;&#x2F;li&gt;
&lt;li&gt;A single metric — &lt;code&gt;apiserver_request_duration_seconds_bucket&lt;&#x2F;code&gt; — was 44,608
series on its own.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Histograms are the multiplier. To hand you a p99 at query time without storing
every individual request, Prometheus pre-counts: requests faster than 5ms, than
10ms, than 25ms… one counter per boundary, per label combination. One &lt;em&gt;logical&lt;&#x2F;em&gt;
metric becomes dozens of series, times every verb and resource. There were 397
distinct bucket boundaries live in my tiny cluster.&lt;&#x2F;p&gt;
&lt;p&gt;And nothing I&#x27;d built — not one dashboard, not one alert — ever read them. They
were there because the default install ships dashboards and SLO rules that &lt;em&gt;might&lt;&#x2F;em&gt;
want them, on the assumption you&#x27;re running a cluster big enough to care.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-k3s-twist-i-was-collecting-it-all-several-times-over&quot;&gt;The k3s twist: I was collecting it all several times over&lt;&#x2F;h2&gt;
&lt;p&gt;Then the part that actually surprised me. That 44,608-series histogram? It was
being scraped from &lt;strong&gt;six&lt;&#x2F;strong&gt; different endpoints.&lt;&#x2F;p&gt;
&lt;p&gt;k3s is famous for collapsing the Kubernetes control plane — apiserver, etcd,
scheduler, controller-manager, &lt;em&gt;and&lt;&#x2F;em&gt; the kubelet — into a &lt;strong&gt;single process&lt;&#x2F;strong&gt; per
server node. Convenient. But kube-prometheus-stack is built for &quot;real&quot; clusters
where those are separate things on separate endpoints. So it scrapes the apiserver
on &lt;code&gt;:6443&lt;&#x2F;code&gt;, and it &lt;em&gt;also&lt;&#x2F;em&gt; scrapes each node&#x27;s kubelet on &lt;code&gt;:10250&lt;&#x2F;code&gt; — and on k3s the
kubelet endpoint serves the &lt;strong&gt;whole shared process registry&lt;&#x2F;strong&gt;. The apiserver and
etcd histograms come out of the kubelet port too.&lt;&#x2F;p&gt;
&lt;p&gt;Three server nodes × two endpoints each = the fattest metric set in Kubernetes,
collected six times. I&#x27;d even &quot;disabled&quot; the etcd and scheduler scrape jobs
earlier — did nothing, because those metrics were never coming from those jobs.
They were leaking in through the kubelet.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-mental-flip&quot;&gt;The mental flip&lt;&#x2F;h2&gt;
&lt;p&gt;The thing I had backwards: in monitoring, the discipline is &lt;em&gt;add the checks you
need&lt;&#x2F;em&gt;. In observability, the firehose is &lt;strong&gt;on by default&lt;&#x2F;strong&gt;, and the discipline is
&lt;em&gt;drop the dimensions you don&#x27;t&lt;&#x2F;em&gt;. The master resource isn&#x27;t &quot;number of metrics&quot; or
&quot;number of hosts&quot; — it&#x27;s &lt;strong&gt;cardinality&lt;&#x2F;strong&gt;, the count of distinct label
combinations. One metric can be 1 series or 50,000 depending entirely on its
labels.&lt;&#x2F;p&gt;
&lt;p&gt;So the fix wasn&#x27;t a bigger disk (I bumped it to 3GB in the heat of the incident;
that only bought time). The fix was a scrape-time drop list: throw away the
control-plane histogram buckets nothing reads, and stop the kubelet endpoint from
re-serving the apiserver&#x27;s metrics. Keep the cheap stuff — the request &lt;em&gt;counts&lt;&#x2F;em&gt;
for rates and error ratios, the node and pod and volume gauges I actually alert
on. That cut ~70% of the series. On the trimmed set, the original 1GB would have
been fine.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;if-you-re-coming-from-monitoring-too&quot;&gt;If you&#x27;re coming from monitoring too&lt;&#x2F;h2&gt;
&lt;p&gt;Three things I wish I&#x27;d known on day one:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;A &quot;series&quot; is the billable unit, and labels mint them.&lt;&#x2F;strong&gt; Before you keep a
metric, multiply its label cardinalities together. That&#x27;s how many streams it
costs.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Histograms are not one metric.&lt;&#x2F;strong&gt; Every &lt;code&gt;_bucket&lt;&#x2F;code&gt; is a series per boundary per
label combo. Keep them only where you&#x27;ll genuinely open a percentile graph.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Match the scrape to your topology.&lt;&#x2F;strong&gt; On k3s, the collapsed control plane means
the stock chart double-counts. Trim it, or you pay for the same data many times.&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;The observability world gives you answers to questions you didn&#x27;t know to ask.
It&#x27;s genuinely powerful. It just bills you up front, in disk, for the privilege —
and unlike the monitoring box in the corner, it will absolutely take you up on a
gigabyte by lunchtime.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>The log shipper that broke every other pod</title>
        <published>2026-06-02T00:00:00+00:00</published>
        <updated>2026-06-02T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://blog.dune.at/log-shipper-inotify-ceiling/"/>
        <id>https://blog.dune.at/log-shipper-inotify-ceiling/</id>
        
        <content type="html" xml:base="https://blog.dune.at/log-shipper-inotify-ceiling/">&lt;p&gt;I added monitoring to my k3s cluster — kube-prometheus-stack for metrics, Loki +
Alloy for logs. Textbook LGTM-light, sized down for tiny nodes. It came up clean.
Within the hour, apps I hadn&#x27;t touched started throwing errors.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-symptom&quot;&gt;The symptom&lt;&#x2F;h2&gt;
&lt;p&gt;First one app, then another, then a third — all logging the same line:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;failed to create fsnotify watcher: too many open files
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&quot;Too many open files&quot; reads like a file-descriptor leak. But three &lt;em&gt;unrelated&lt;&#x2F;em&gt;
apps — none of which I&#x27;d deployed or changed — all hitting it at the same moment?
A leak lives in one process. This was something they &lt;strong&gt;share&lt;&#x2F;strong&gt; running out.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-they-share&quot;&gt;What they share&lt;&#x2F;h2&gt;
&lt;p&gt;inotify. It&#x27;s the Linux mechanism for watching files and directories for changes;
anything that does config hot-reload or log tailing uses it. Creating a watcher
calls &lt;code&gt;inotify_init()&lt;&#x2F;code&gt;, which consumes one inotify &lt;em&gt;instance&lt;&#x2F;em&gt;, and the kernel caps
instances per user:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;$ cat &#x2F;proc&#x2F;sys&#x2F;fs&#x2F;inotify&#x2F;max_user_instances
128
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;128 - Here&#x27;s the part that bites on Kubernetes: instances are counted &lt;strong&gt;per real
UID on the host kernel&lt;&#x2F;strong&gt;, not per container or per pod. Most containers run as
root, so every root container on a node draws from the &lt;em&gt;same&lt;&#x2F;em&gt; 128-instance budget.&lt;&#x2F;p&gt;
&lt;p&gt;I counted what was actually in use on the node:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;$ sudo find &#x2F;proc&#x2F;[0-9]*&#x2F;fd -lname &amp;#39;anon_inode:inotify&amp;#39; | wc -l
138
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;138 — over the ceiling. New &lt;code&gt;inotify_init()&lt;&#x2F;code&gt; calls were failing with &lt;code&gt;EMFILE&lt;&#x2F;code&gt;,
which Go&#x27;s fsnotify library surfaces, unhelpfully, as &quot;too many open files.&quot;&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-pushed-it-over&quot;&gt;What pushed it over&lt;&#x2F;h2&gt;
&lt;p&gt;The log shipper. Alloy — like Promtail, Fluent Bit, Filebeat — tails log files,
and file-tailers lean hard on inotify to notice new lines and new files. Dropping
a log-shipper DaemonSet onto every node added a hungry consumer of a resource the
&lt;em&gt;whole node&lt;&#x2F;em&gt; shares, and tipped a node already sitting near 128 over the edge. The
monitoring stack didn&#x27;t break itself — it looked perfectly healthy. It starved
everything else, and the errors landed on innocent pods.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-fix&quot;&gt;The fix&lt;&#x2F;h2&gt;
&lt;p&gt;Raise the ceiling. 128 is an ancient default sized for a desktop, not a container
host running dozens of pods:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;# &#x2F;etc&#x2F;sysctl.d&#x2F;90-inotify.conf
fs.inotify.max_user_instances = 1024
fs.inotify.max_user_watches  = 524288
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;pre&gt;&lt;code&gt;sudo sysctl --system
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Errors stopped cluster-wide within a minute. Then I put it in the Ansible base
role, so a rebuilt node inherits it instead of rediscovering this at 9pm.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-lesson&quot;&gt;The lesson&lt;&#x2F;h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;&quot;Too many open files&quot; isn&#x27;t always about files.&lt;&#x2F;strong&gt; For &lt;code&gt;inotify_init()&lt;&#x2F;code&gt; it
means you&#x27;ve hit &lt;code&gt;max_user_instances&lt;&#x2F;code&gt; — a per-UID kernel limit, not the
per-process fd limit (&lt;code&gt;ulimit -n&lt;&#x2F;code&gt;) you&#x27;d reach for first.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;A log shipper is a node-shared-resource hog, and inotify is the resource.&lt;&#x2F;strong&gt;
Before you roll a tailer onto nodes with the stock &lt;code&gt;max_user_instances=128&lt;&#x2F;code&gt;,
raise it. The failure mode is mean: the thing you deployed looks fine while
everything around it quietly fails to start watchers.&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;Bump the limit before the log shipper, not after the pager goes off.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Oracle Cloud&#x27;s invisible 47 GB floor</title>
        <published>2026-05-31T00:00:00+00:00</published>
        <updated>2026-05-31T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://blog.dune.at/oci-47gb-floor/"/>
        <id>https://blog.dune.at/oci-47gb-floor/</id>
        
        <content type="html" xml:base="https://blog.dune.at/oci-47gb-floor/">&lt;p&gt;Oracle Cloud&#x27;s Always-Free tier is genuinely generous: &lt;strong&gt;4 ARM cores, 24 GB RAM,
and &quot;200 GB of block storage.&quot;&lt;&#x2F;strong&gt; I used it to build a 3-node HA k3s cluster plus
a small load-balancer node. Then my budget alert started twitching.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-surprise&quot;&gt;The surprise&lt;&#x2F;h2&gt;
&lt;p&gt;I had &lt;strong&gt;5 instances&lt;&#x2F;strong&gt;. I checked my block usage:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;47 GB  server-1     47 GB  edge
47 GB  server-2     47 GB  oracle-monitor
47 GB  server-3
──────────────────────────────────────────
235 GB total  &#x2F;  200 GB free  →  35 GB OVER
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Every instance — even the tiny 1 GB AMD micros — has a &lt;strong&gt;47 GB boot volume&lt;&#x2F;strong&gt;, and
boot volumes count against the same 200 GB pool. The OS uses ~5 GB; the other ~42
is just… there. Five instances and you&#x27;re over budget, paying a euro or so a month
for storage you&#x27;re not using.&lt;&#x2F;p&gt;
&lt;p&gt;So the obvious question: &lt;strong&gt;can I shrink those boot volumes?&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;h2 id=&quot;dead-end-1-shrink-the-boot-volume&quot;&gt;Dead end #1 — shrink the boot volume&lt;&#x2F;h2&gt;
&lt;p&gt;No. OCI volumes can only &lt;strong&gt;grow&lt;&#x2F;strong&gt;, never shrink. And the docs are explicit:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;For Linux images, the custom boot volume size must be larger than the image&#x27;s
default boot volume size or 50 GB, whichever is higher.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;So if you &lt;em&gt;customize&lt;&#x2F;em&gt;, the floor is &lt;strong&gt;50 GB&lt;&#x2F;strong&gt; — bigger, not smaller. The only way
to sit below 50 is to take the image&#x27;s own default (47 GB) and not touch it. You
literally cannot request less.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;dead-end-2-the-famous-qemu-img-trick&quot;&gt;Dead end #2 — the famous &lt;code&gt;qemu-img&lt;&#x2F;code&gt; trick&lt;&#x2F;h2&gt;
&lt;p&gt;The community classic: attach a blank block volume, write a tiny cloud image onto
it with &lt;code&gt;qemu-img convert&lt;&#x2F;code&gt;, boot from that. Clever — but &lt;strong&gt;OCI block volumes have
a 50 GB minimum&lt;&#x2F;strong&gt; (that&#x27;s why every guide says &quot;add a &lt;em&gt;50 GB&lt;&#x2F;em&gt; volume&quot;). So this
hands you a 50 GB volume, which is &lt;em&gt;bigger&lt;&#x2F;em&gt; than the 47 GB default. It lets you run
a different&#x2F;leaner OS, but it reclaims &lt;strong&gt;zero&lt;&#x2F;strong&gt; quota.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;dead-end-3-import-a-minimal-image-the-one-that-should-work&quot;&gt;Dead end #3 — import a minimal image (the one that &lt;em&gt;should&lt;&#x2F;em&gt; work)&lt;&#x2F;h2&gt;
&lt;p&gt;Here&#x27;s where it got interesting. That same rule has an asymmetry: a boot volume
&lt;em&gt;can&lt;&#x2F;em&gt; be under 50 GB &lt;strong&gt;if the image&#x27;s own default is under 50&lt;&#x2F;strong&gt;. Real minimal cloud
images are tiny — the Debian &lt;code&gt;genericcloud&lt;&#x2F;code&gt; arm64 image is a &lt;strong&gt;3 GiB&lt;&#x2F;strong&gt; virtual disk
(326 MB download). So: import that, and surely you get a ~3 GB boot volume?&lt;&#x2F;p&gt;
&lt;p&gt;I tested it instead of guessing. Imported the 3 GiB Debian image via Object
Storage and asked the API what size OCI assigned it:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;imported_image_size_mb = &amp;quot;47694&amp;quot;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;strong&gt;47694 MB ≈ 46.6 GB — the &lt;em&gt;exact&lt;&#x2F;em&gt; same number as Oracle&#x27;s own Ubuntu image.&lt;&#x2F;strong&gt;
OCI pads every imported image &lt;em&gt;up&lt;&#x2F;em&gt; to its floor. The 3 GB image became a 47 GB
image on import. Dead end confirmed, by experiment.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-conclusion&quot;&gt;The conclusion&lt;&#x2F;h2&gt;
&lt;p&gt;There is a &lt;strong&gt;universal ~47 GB floor&lt;&#x2F;strong&gt; on OCI instances. No knob, no trick, no
slim image gets under it:&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Approach&lt;&#x2F;th&gt;&lt;th&gt;Result&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Custom boot volume size&lt;&#x2F;td&gt;&lt;td&gt;floored at 50 GB&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;qemu-img&lt;&#x2F;code&gt; → block volume&lt;&#x2F;td&gt;&lt;td&gt;block volumes floored at 50 GB&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Import a 3 GB minimal image&lt;&#x2F;td&gt;&lt;td&gt;&lt;strong&gt;clamped to 47 GB on import&lt;&#x2F;strong&gt;&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;h2 id=&quot;what-to-actually-do&quot;&gt;What to actually do&lt;&#x2F;h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Plan your instance count around it.&lt;&#x2F;strong&gt; Each instance ≈ 47 GB. The 200 GB free
pool realistically fits &lt;strong&gt;~4 instances&lt;&#x2F;strong&gt;. Want more (I wanted 5, for HA)? Budget
~€0.025&#x2F;GB-month for the overage — about &lt;strong&gt;€1&#x2F;month&lt;&#x2F;strong&gt;. It&#x27;s a floor, not waste.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Or run fewer, bigger boxes.&lt;&#x2F;strong&gt; This is why most &quot;ultimate free-tier&quot; guides
build &lt;em&gt;one&lt;&#x2F;em&gt; instance with all 4 cores and 200 GB — one boot volume, lots of
headroom. You trade HA for €0.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;For Kubernetes PVs, use &lt;code&gt;local-path&lt;&#x2F;code&gt;,&lt;&#x2F;strong&gt; which carves from the ~42 GB of free
space &lt;em&gt;already inside&lt;&#x2F;em&gt; each boot volume. Don&#x27;t add OCI block-volume PVs — those
pile billable storage on top of a pool you&#x27;ve already maxed.&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;The free tier is still a fantastic deal. Just know that &quot;200 GB&quot; really means
&quot;~4 instances,&quot; and that the 47 GB floor is real, universal, and — now —
empirically proven.&lt;&#x2F;p&gt;
</content>
        
    </entry>
</feed>
