Wat gebeurt er als je Kubernetes cluster geen internet kan bereiken? Ik bedoel geen trage verbinding. Ik bedoel helemaal geen verbinding. Schepen op zee. Afgelegen mijnbouwlocaties. Fabrieksvloeren met air-gapped netwerken. Militaire deployments.

Voor veel mensen klinkt dat exotisch, alsof het een probleem van iemand anders is. Ik behandel het als een basis-ontwerpeis, en ik leg uit waarom het mijn homelab beter maakt terwijl ik de kabel bijna nooit echt eruit trek.

Waarom dit ertoe doet: voorbij het technische

Kubernetes offline draaien dwingt je om een vraag te beantwoorden waar de meeste cloud-native guides straal langs lopen: waar ben je eigenlijk van afhankelijk?

Een standaard Kubernetes setup heeft overal onzichtbare dependencies:

  • Container registries (Docker Hub, gcr.io, quay.io)
  • Helm chart repositories
  • Certificate authorities
  • NTP servers
  • DNS resolvers
  • Package repositories
  • Telemetry endpoints

Geen van deze is een optionele extra. Het zijn dragende aannames die ingebakken zitten in hoe de meeste clusters werken, en meestal merk je ze pas op de dag dat er eentje wegvalt. Je cluster “werkt” totdat Docker Hub je rate-limit of je DNS forwarder omvalt, en dan werkt het ineens helemaal niet meer.

Dit is in de kern een soevereiniteit-probleem. Als je je infrastructuur niet kunt draaien zonder naar huis te bellen naar systemen die je niet controleert, bezit je het niet echt. Je huurt capaciteit en hoopt dat het contract blijft staan. Black boxes voelen als splinters, en een dependency die je niet ziet is het ergste soort black box.

Island mode: de architectuur

Ik ontwerp al mijn infrastructuur om “island mode capable” te zijn, in staat om volledig losgekoppeld van elk extern netwerk te functioneren. De eerlijke reden is niet dat ik verwacht op een onderzeeër te belanden. Het is dat de constraint betere architectuur afdwingt. Op het moment dat je besluit dat het cluster moet overleven met de kabel eruit, wordt elke slordige externe dependency iets dat je bewust moet aanpakken.

flowchart TD
    subgraph island["Island Mode Cluster"]
        subgraph internal["Interne Services"]
            Registry["Registry<br/>(Harbor)"]
            NTP["NTP<br/>(chrony)"]
            DNS["DNS<br/>(CoreDNS)"]
            CA["CA<br/>(cert-mgr)"]
            Helm["Helm<br/>(ChartMuseum)"]
            GitOps["GitOps<br/>(ArgoCD)"]
        end
        internal -->|"Alle dependencies lokaal opgelost"| workloads
        subgraph workloads["Workloads"]
            Apps["Apps draaien zonder externe calls"]
        end
    end
    island x--x|"Geen verbinding vereist"| Internet["Internet<br/>(optioneel)"]

Het doel is makkelijk op te schrijven en irritant om te bereiken: trek de netwerkkabel eruit en alles blijft draaien. Updates stoppen. Operaties gaan door. Ik bouw het component voor component op, want elk component is een eigen kleine dependency die je naar binnen moet halen.

Component 1: lokale container registry

Begin met de meest voor de hand liggende. Je cluster heeft images nodig. Verbonden pullt het on-demand van Docker Hub of gcr.io en denk je er nooit over na. Offline faalt elk van die requests, en een node die geen image kan pullen is een node die niets kan schedulen.

Fix: mirror alles lokaal.

Harbor is wat ik draai voor self-hosted registries. Het geeft je:

  • Pull-through caching (automatisch mirroren wanneer verbonden)
  • Replicatie van upstream registries
  • Vulnerability scanning
  • Access control
# Harbor replication policy - sync wanneer verbonden
apiVersion: goharbor.io/v1beta1
kind: HarborReplicationPolicy
metadata:
  name: docker-hub-mirror
spec:
  srcRegistry:
    url: https://registry-1.docker.io
  destRegistry:
    url: https://harbor.internal
  trigger:
    type: scheduled
    scheduledTrigger:
      cron: "0 2 * * *"  # Nachtelijk sync wanneer verbonden
  filters:
    - type: name
      value: "library/**"  # Alleen officiële images

Verbonden synct Harbor images op een schema. Losgekoppeld serveert het wat het al gecached heeft. Dat is het hele patroon dat zich herhaalt voor elk component hieronder: wees een cache als het kan, wees de source of truth als het moet.

Het stuk dat mensen vergeten: pre-pull je dependencies.

Een pull-through cache helpt alleen als de image er minstens één keer doorheen is gegaan. Voordat je offline gaat, zorg dat elke image die je workloads nodig hebben echt in de lokale registry staat:

# Lijst alle images die momenteel draaien
kubectl get pods -A -o jsonpath="{.items[*].spec.containers[*].image}" | \
  tr ' ' '\n' | sort | uniq

# Zorg dat ze allemaal gespiegeld zijn naar lokale registry

Component 2: lokale DNS

Leg de volgende dependency erbovenop. Kubernetes leunt op DNS voor service discovery, en CoreDNS handelt de cluster-interne namen prima af. Het probleem zit in alles buiten het cluster.

Probleem: elke pod die api.github.com of registry-1.docker.io probeert te resolven valt plat zonder upstream resolver, en een deel van je eigen tooling gaat er stilletjes van uit dat die namen werken.

Fix: leer CoreDNS om de externe namen te beantwoorden die jij belangrijk vindt, gericht op interne adressen:

apiVersion: v1
kind: ConfigMap
metadata:
  name: coredns
  namespace: kube-system
data:
  Corefile: |
    .:53 {
        errors
        health

        # Cluster-interne namen
        kubernetes cluster.local in-addr.arpa ip6.arpa {
          pods insecure
          fallthrough in-addr.arpa ip6.arpa
        }

        # Bekende externe services - resolve naar intern
        hosts /etc/coredns/custom.hosts {
          fallthrough
        }

        # Forward onbekend naar lokale resolver (of fail gracefully)
        forward . 10.0.0.1 {
          policy sequential
          health_check 5s
        }

        cache 30
        loop
        reload
        loadbalance
    }
  custom.hosts: |
    10.0.10.5  harbor.internal registry.internal
    10.0.10.6  git.internal gitlab.internal
    10.0.10.7  ntp.internal time.internal

Component 3: lokale tijdsynchronisatie

Deze bijt mensen die hem niet zagen aankomen. Tijdsynchronisatie doet er veel meer toe dan het lijkt. TLS certificaten valideren tegen de klok, dus een gedrifte node begint prima certificaten af te wijzen. Logs hebben timestamps nodig die je kunt vertrouwen als je een incident aan het correleren bent. Distributed systems hebben nodig dat de nodes het eens zijn over wat “nu” is.

Afgesneden van publieke NTP heb je een eigen tijdsbron nodig:

# Chrony als lokale NTP server
apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: chrony
  namespace: kube-system
spec:
  selector:
    matchLabels:
      app: chrony
  template:
    spec:
      hostNetwork: true
      containers:
      - name: chrony
        image: harbor.internal/infra/chrony:4.3
        volumeMounts:
        - name: chrony-conf
          mountPath: /etc/chrony
      volumes:
      - name: chrony-conf
        configMap:
          name: chrony-config
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: chrony-config
data:
  chrony.conf: |
    # Wanneer verbonden, gebruik publieke NTP
    server time.cloudflare.com iburst
    server pool.ntp.org iburst

    # Wanneer losgekoppeld, wees de tijdsbron
    local stratum 10
    allow 10.0.0.0/8

    # Drift file voor accuratesse wanneer losgekoppeld
    driftfile /var/lib/chrony/drift

De regel die het werk doet is local stratum 10. Die vertelt chrony om als tijdsbron te fungeren, zelfs als er geen upstream is om te volgen. De nodes syncen met elkaar en blijven onderling consistent, en dat is wat TLS en je logs eigenlijk willen.

Component 4: lokale certificate authority

TLS overal betekent certificaten overal, en certificaten moeten ergens vandaan komen. Verbonden kletst cert-manager met Let’s Encrypt en vergeet je dat het probleem bestaat. Offline kan Let’s Encrypt je niet bereiken voor de ACME challenge en zit je vast.

Fix: draai je eigen CA.

# Self-signed root CA
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: internal-ca
spec:
  ca:
    secretName: internal-ca-root

---
# Maak het root certificaat
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: internal-ca-root
  namespace: cert-manager
spec:
  isCA: true
  commonName: Internal Root CA
  secretName: internal-ca-root
  duration: 87600h  # 10 jaar
  privateKey:
    algorithm: ECDSA
    size: 256
  issuerRef:
    name: selfsigned
    kind: ClusterIssuer

Push de root CA naar elke node en client en vertrouw hem. Vanaf dat moment slaat cert-manager certificaten uit je eigen authority zonder ook maar één externe call. Je ruilt het gemak van publiek vertrouwen in voor volledige controle over je trust chain, en dat is de juiste ruil als de publieke CA je toch niet kan bereiken.

Component 5: GitOps zonder internet

Als je GitOps doet, synct ArgoCD waarschijnlijk van GitHub of GitLab.com, en offline verdwijnt die bron. Je hebt de Git server ook binnen het eiland nodig.

Optie 1: embedded GitLab

Draai GitLab in-cluster. Het is zwaar op resources, maar je krijgt alles in één doos.

Optie 2: Gitea

Lichtgewicht Git server, en een veel betere match voor edge hardware:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: gitea
spec:
  template:
    spec:
      containers:
      - name: gitea
        image: harbor.internal/gitea/gitea:1.21
        env:
        - name: GITEA__server__OFFLINE_MODE
          value: "true"

Wijs ArgoCD naar git.internal in plaats van github.com. Als je een verbinding hebt push je je wijzigingen als vanouds naar upstream, maar ArgoCD reconcilet hoe dan ook tegen de lokale kopie. De desired state van het cluster woont op het eiland, niet op de server van iemand anders.

Component 6: Helm charts offline

Laatste dependency in de keten. Helm charts komen normaal van remote repositories, dus offline heb je chart storage nodig die bij je woont.

ChartMuseum spreekt de Helm repository API:

# Push charts naar lokale museum wanneer verbonden
helm push mychart-1.0.0.tgz http://chartmuseum.internal

# ArgoCD gebruikt lokale charts
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: my-app
spec:
  source:
    repoURL: http://chartmuseum.internal
    chart: mychart
    targetRevision: 1.0.0

De pre-deployment checklist

Zes componenten verder wordt het pas echt. De failure mode die het meest pijn doet is de mode die je ontdekt de dag nadat je losgekoppeld bent, als niemand het kan fixen zonder een boot of een lange rit. Dus voordat je de kabel doorknipt, verifieer dat het eiland echt overeind blijft. Dit is het script dat ik draai, en ik heb het liever dat het nu tegen me schreeuwt dan dat een pod een image niet kan pullen op zee:

#!/bin/bash
# offline-readiness-check.sh

echo "=== Checking Offline Readiness ==="

# 1. Alle images lokaal beschikbaar
echo "Checking images..."
for image in $(kubectl get pods -A -o jsonpath="{..image}" | tr ' ' '\n' | sort -u); do
  if ! curl -s "https://harbor.internal/v2/${image}/manifests/latest" > /dev/null; then
    echo "MISSING: $image"
  fi
done

# 2. DNS resolvet intern
echo "Checking DNS..."
for name in harbor.internal git.internal ntp.internal; do
  if ! nslookup $name > /dev/null 2>&1; then
    echo "DNS FAIL: $name"
  fi
done

# 3. Tijd is gesynchroniseerd
echo "Checking NTP..."
chronyc tracking | grep -q "Leap status.*Normal" || echo "TIME SYNC ISSUE"

# 4. Certificaten zijn geldig
echo "Checking certificates..."
kubectl get certificates -A -o jsonpath='{range .items[*]}{.metadata.name}: {.status.conditions[0].type}{"\n"}{end}'

# 5. GitOps is synced
echo "Checking ArgoCD..."
argocd app list | grep -v "Synced.*Healthy" && echo "APPS NOT SYNCED"

echo "=== Check Complete ==="

Praktijkscenario’s

De stukken hierboven blijven abstract tot je ze neerzet op een plek waar de kabel echt niet reikt. Drie settings maken de constraints concreet.

Scenario 1: schip op zee

Een containerschip dat Kubernetes draait voor cargo management. Satellietconnectiviteit bestaat, maar het is duur en het valt weg als het weer omslaat.

Architectuur:

  • K3s voor een lage resource footprint
  • Harbor met een volledige mirror van elke benodigde image
  • Lokale GitOps met Gitea
  • Sync updates terwijl je in de haven ligt

De catch: updates landen in batches tijdens havenaanloop, en dat is misschien eens per paar weken. Het systeem moet er tussendoor keihard staan, want niemand pusht een hotfix midden op de Atlantische Oceaan.

Scenario 2: fabrieksvloer

Een productiefabriek die Kubernetes draait voor automatisering, waar beveiligingsbeleid internetconnectiviteit botweg verbiedt.

Architectuur:

  • Volledige Kubernetes met een air-gapped installatie
  • USB-gebaseerde updates, gesigned en geverifieerd
  • Een apart management netwerk voor de zeldzame updates die wel gebeuren

De catch: updates hebben iemand fysiek aanwezig nodig, dus elke wijziging moet de eerste keer goed zijn. Je rolt geen slechte deploy terug vanaf je laptop thuis.

Scenario 3: mijn homelab

Ik draai mijn eigen infrastructuur island-mode capable, en ik wacht niet op een ramp om het te rechtvaardigen. De discipline is het punt. Als het ontwerp uitgaat van geen internet, komt de architectuur er vanzelf schoner uit.

Wat het me echt oplevert:

  • Geen externe dependencies, dus geen externe failure modes om te erven
  • Snellere image pulls vanuit de lokale registry
  • Alles blijft werken tijdens ISP storingen, die hier vaker gebeuren dan me lief is
  • Infrastructuur die ik echt bezit in plaats van huur

De trade-offs

Ik ga niet doen alsof dit gratis is. Dat is het niet.

AspectVerbondenOffline
UpdatesContinuBatched
Vulnerability infoReal-timeVertraagd
Externe integratiesMakkelijkOnmogelijk
Operationele lastLagerHoger
SoevereiniteitGedeeltelijkCompleet

Er is hier geen universeel correcte kolom. Het juiste antwoord hangt af van wat jouw setup daadwerkelijk eist, en voor een homelab betaal ik graag de hogere operationele last voor de volledige soevereiniteit.

K3s: gebouwd voor de edge

K3s verdient een eigen sectie, want het is gebouwd voor precies dit soort plek:

  • Enkele binary, ~100MB
  • Embedded SQLite (geen externe etcd nodig)
  • Werkt op ARM (Raspberry Pi, edge devices)
  • Minimale dependencies
# Air-gapped K3s installatie
curl -sfL https://get.k3s.io > install.sh

# Op air-gapped machine
INSTALL_K3S_SKIP_DOWNLOAD=true \
INSTALL_K3S_EXEC="--private-registry /etc/rancher/k3s/registries.yaml" \
./install.sh

Wijs K3s naar een lokale registry en het draait volledig offline. Voor schepen, fabrieken en Raspberry Pi clusters in een kast is die combinatie moeilijk te verslaan.

Mijn aanbeveling

Als je één ding meeneemt uit dit alles, neem dan het eerste punt. De rest volgt eruit.

  1. Ontwerp voor offline, draai verbonden. Je koppelt waarschijnlijk niet vaak los, maar bouwen alsof het zou kunnen levert elke dag een beter cluster op.

  2. Mirror alles. Container images, Helm charts, Git repos. Als het buiten je netwerk leeft, haal er een kopie van naar binnen.

  3. Test disconnectie. Trek daadwerkelijk de kabel eruit en kijk wat breekt. De eerste keer dat je dit doet vind je een dependency waarvan je zwoer dat hij er niet was.

  4. Automatiseer de sync. Terwijl je verbonden bent, ververs de mirrors op een schema. Een handmatig proces is een proces dat niet draait in de week dat je het het hardst nodig had.

  5. Documenteer dependencies. Schrijf precies op wat je cluster van de buitenwereld nodig heeft, zodat toekomstige-jij het niet hoeft te reverse-engineeren tijdens een storing.


Island mode ging nooit over paranoia. Het gaat over je dependencies goed genoeg kennen om zonder ze te leven, en een cluster dat je offline kunt draaien is er een dat je echt begrijpt.