↓Skip to main content

Kairos — Immutable Kubernetes OS

·9 mins
Proxmox Linux Kubernetes Proxmox Linux Kairos Immutable Containers Kubernetes K3s
Chrisando Wagner
Author
Chrisando Wagner
25+ years across the full estate — Microsoft/VMware, OpenStack/Red Hat, cloud-native Kubernetes. These days that’s reference architectures, FinOps governance, and regulatory guardrails other teams build against. When I’m not debating gateway API controllers, I’m automating something in my homelab.
72 - This article is part of a series.

Kairos
#

Kairos is a cloud-native Linux meta-distribution for running Kubernetes. It brings the power of the public cloud to your on-premises environment. https://kairos.io/

Kairos is a Cloud Native Computing Foundation CNCF sandbox project.

Architecture
#

Kairos is designed to be a lightweight, secure, and scalable solution for deploying Kubernetes clusters. It uses a combination of containerized components and a lightweight operating system to provide a highly available and fault-tolerant platform.

Immutable
#

Kairos is an immutable platform, meaning that once a node is provisioned, it cannot be modified. This ensures that the node is always in a known state and reduces the risk of configuration drift.

Kairos after installation will create the following partitions:

  • A state partition that stores the container images, which are going to be booted (active and passive, stored in .img format which are loopback mounted)
  • A recovery partition that stores the container images, used for recovery (in .squashfs format)
  • An OEM partition (optional) that stores user configuration and cloud-config files
  • A persistent partition to keep the data across reboots

Kairos-installation-partitioning

Container based
#

Kairos delivers its entire OS as a container image. A container-based operating system is an OS that is shipped via containers. The image being booted is the container, which contains all the required pieces in order to boot (Kernel, Initrd, Init system). There is no real container runtime running the image. The container is used to construct an image internally that is then used to boot the system in an A/B fashion, so there is no overhead introduced. The system being booted is actually a snapshot of the container.

  • Single-image The OS is a single container image which contains all the OS components, including Kernel and Initrd.
  • Tamper-proof upgrades Upgrades are atomic, A/B swaps with fallback mechanisms and automatic boot assessment.
  • Distributed via container registries Bootable images are standard OCI artifacts that can be hosted in any container registry.
  • Platform Engineer-friendly Adapt the infrastructure to your needs by plugging images into your already-existing workflow pipeline. Customizing an immutable OS becomes as easy as writing a Dockerfile.

A/B Upgrades

A/B Upgrades
Upgrades are atomic operations that can be triggered manually or via Kubernetes. The node will create a transition image that will be swapped for the Active system, and the Active system becomes Passive. This ensures tamper-proof upgrades and automated fallback and boot assessment strategies are in place to automatically boot from the fallback system. The recovery image can be furthermore exploited to completely automatize node recovery.

Installations
#

Overview

hadron-k0s-arc.drawio.svg

Installation Steps
#

  1. Download the Kairos image from the official source.
  2. Create a 4 virtual servers.
  3. Install Kairos.

Lets boot our control plane “Master” node using the kairos image.

Connect to the web installer interface http://{ip}:8080

web installer interface

Master node cloud config

#cloud-config

hostname: cluster1-cp
users:
- name: cw # Change to your own user
  passwd: kairos # Change to your own password
  groups:
    - admin # This user needs to be part of the admin group
  ssh_authorized_keys:
    - yourssh key

kubevip:
  eip: "172.16.20.1"
  
k3s:
  enabled: true
  args:
    - --disable=traefik,servicelb,disable-network-policy
    - --flannel-backend=none 
    - --kubelet-arg containerd=/run/k3s/containerd/containerd.sock 
    - --kubelet-arg cloud-provider=external 
    - --tls-san 172.16.20.1  
    - --kube-controller-manager-arg bind-address=0.0.0.0 
    - --kube-proxy-arg metrics-bind-address=0.0.0.0 
    - --kube-scheduler-arg bind-address=0.0.0.0
 

No lets get our token from our master node and deploy our worker nodes.

➜ ssh [email protected]
The authenticity of host '172.16.20.2 (172.16.20.2)' can't be established.
ED25519 key fingerprint is SHA256:MFxIb6xGD/gI95IHPZiJbJmb0ov/QoBe+RpBgSAoB3M.
This key is not known by any other names.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added '172.16.20.2' (ED25519) to the list of known hosts.
Welcome to Kairos!

Refer to https://kairos.io for documentation.
Welcome to Kairos!

Refer to https://kairos.io for documentation.
-sh-5.3$ sudo su
bash-5.3# cat /var/lib/rancher/k3s/server/node-token
K10d37a3a7166e68c3943624af9b87b375306d5c5443bd721fbd72afe8c7bbca5a9::server:ae149a0ed13e47d3ee65b57378f02d5d

Worker nodes

With the master node up and running, we can configure the worker nodes.

#cloud-config

hostname: cluster1-wrk-{{ trunc 4 .MachineID }}
users:
- name: cw # Change to your own user
  passwd: kairos # Change to your own password
  groups:
    - admin # This user needs to be part of the admin group
  ssh_authorized_keys:
    - your ssh key

k3s-agent:
  enabled: true
  args:
  - --with-node-id # will configure the agent to use the node ID to communicate with the master node
  env:
      K3S_TOKEN: "K10d37a3a7166e68c3943624af9b87b375306d5c5443bd721fbd72afe8c7bbca5a9::server:ae149a0ed13e47d3ee65b57378f02d5d" # /var/lib/rancher/k3s/server/node-token from the master node
      K3S_URL: https://172.16.20.2:6443 # Same IP that you use to log into your master node

Retrieve kubeconfig file from master node.

scp [email protected]:/etc/rancher/k3s/k3s.yaml /home/username/.kube/config

Check status of the cluster

✗  k get nodes
NAME                     STATUS     ROLES           AGE   VERSION
cluster1-cp              NotReady   control-plane   57m   v1.34.2+k3s1
cluster1-wrk1-6189201e   NotReady   <none>          29m   v1.34.2+k3s1
cluster1-wrk2-61f99182   NotReady   <none>          30m   v1.34.2+k3s1
cluster1-wrk3-6332a29e   NotReady   <none>          28m   v1.34.2+k3s1

Gateway API
#

Install CRD’s
#

k apply --server-side -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.4.1/experimental-install.yaml

We using the experimental branch to enable tls routing

Cilium
#

Install cilium cli using app store or homebrew

brew install cilium-cli
cilium version
  cilium-cli: v0.18.8 compiled with go1.25.3 on darwin/arm64
  cilium image (default): v1.18.2
  cilium image (stable): v1.18.4
  cilium image (running): 1.18.4

Install Cilium using cli
#

Create values.yaml file

# values.yaml
k8sServiceHost: 172.16.10.2
k8sServicePort: 6443

kubeProxyReplacement: true

l2announcements:
  enabled: true

externalIPs:
  enabled: true

k8sClientRateLimit:
  qps: 50
  burst: 200

operator:
  # replicas: 1  # Uncomment this if you only have one node
  rollOutPods: true

rollOutCiliumPods: true
ingressController:
  enabled: false

gatewayAPI:
  enabled: true

hubble:
  relay:
    enabled: true
  ui:
    enabled: true

bgpControlPlane:
  enabled: true

Install using cli

cilium install --version 1.18.4 --values values.yaml

Check status

➜ cilium status --wait
   /¯¯\
/¯¯\__/¯¯\    Cilium:             OK
\__/¯¯\__/    Operator:           OK
/¯¯\__/¯¯\    Envoy DaemonSet:    OK
\__/¯¯\__/    Hubble Relay:       OK
   \__/       ClusterMesh:        disabled

DaemonSet              cilium                   Desired: 4, Ready: 4/4, Available: 4/4
DaemonSet              cilium-envoy             Desired: 4, Ready: 4/4, Available: 4/4Deployment             cilium-operator          Desired: 1, Ready: 1/1, Available: 1/1
Deployment             hubble-relay             Desired: 1, Ready: 1/1, Available: 1/1
Deployment             hubble-ui                Desired: 1, Ready: 1/1, Available: 1/1
Containers:            cilium                   Running: 4
                      cilium-envoy             Running: 4
                      cilium-operator          Running: 1
                      clustermesh-apiserver
                      hubble-relay             Running: 1
                      hubble-ui                Running: 1
Cluster Pods:          5/5 managed by Cilium
Helm chart version:    1.18.4
Image versions         cilium             quay.io/cilium/cilium:v1.18.4@sha256:49d87af187eeeb9e9e3ec2bc
6bd372261a0b5cb2d845659463ba7cc10fe9e45f: 4                       cilium-envoy       quay.io/cilium/cilium-envoy:v1.34.10-1762597008-ff7ae7d623be0
0078865cff1b0672cc5d9bfc6d5@sha256:1deb6709afcb5523579bf1abbc3255adf9e354565a88c4a9162c8d9cb1d77ab5: 4                       cilium-operator    quay.io/cilium/operator-generic:v1.18.4@sha256:1b22b9ff28affd
f574378a70dade4ef835b00b080c2ee2418530809dd62c3012: 1                       hubble-relay       quay.io/cilium/hubble-relay:v1.18.4@sha256:6d350cb1c84b847adb
152173debef1f774126c69de21a5921a1e6a23b8779723: 1                       hubble-ui          quay.io/cilium/hubble-ui-backend:v0.13.3@sha256:db1454e45dc39
ca41fbf7cad31eec95d99e5b9949c39daaad0fa81ef29d56953: 1                       hubble-ui          quay.io/cilium/hubble-ui:v0.13.3@sha256:661d5de7050182d495c64
97ff0b007a7a1e379648e60830dd68c4d78ae21761d: 1

At this stage you might want to taint your control plan nodes to not run any workloads as k3s by default allows workloads to run on control plan nodes.

k taint node cluster1-cp CriticalAddonsOnly=true:NoExecute
k config set-context --current --namespace=homelab

Lets create our l2 Announcement policy

#l2.yaml
---
apiVersion: cilium.io/v2alpha1
kind: CiliumL2AnnouncementPolicy
metadata:
  name: default-l2-announcement-policy
  namespace: kube-system
spec:
  interfaces:
    - "^ens[0-9]*"
  externalIPs: true
  loadBalancerIPs: true
---

Make sure to use the correct network interface.

Now lets create our ip pool file.

# ippool.yaml
---
apiVersion: cilium.io/v2
kind: CiliumLoadBalancerIPPool
metadata:
  name: first-pool
spec:
  blocks:
  #- cidr: 172.16.10.1/24
  - start: 172.16.2.1
  - stop: 172.16.2.200

Apply these manifests.

k apply -f l2.yaml -f ippool.yaml

Before we create our gateway lets install cert-manager to manage our certs.

Cert-Manager
#

We will install cert-manager enabling gateway api support

# Add helm repo
helm repo add jetstack https://charts.jetstack.io --force-update

# Install
helm upgrade --install \
  cert-manager jetstack/cert-manager \
  --namespace cert-manager \
  --create-namespace \
  --set crds.enabled=true --set "extraArgs={--feature-gates=ExperimentalGatewayAPISupport=true,--dns01-recursive-nameservers=1.1.1.1:53\,9.9.9.9:53,--dns01-recursive-nameservers-only}"

Setup and configure cert-manager to your requirements.

Example of ClusterIssuer

#ClusterIssuer.yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt
  namespace: cert-manager
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: [email protected]
    privateKeySecretRef:
      name: letsencrypt
    solvers:
      - dns01:
          cloudflare:
            email: [email protected]
            apiTokenSecretRef:
              name: cloudflare-token-secret
              key: cloudflare-token
        selector:
          dnsZones:
            - "c1.wagner.org.za"

Now that we have cert-manager setup we can procceed.

Create gateway
#

#gateway.yaml
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: gateway
  namespace: kube-system
  labels:
    gateway: gateway
spec:
  gatewayClassName: cilium
  addresses:
  - type: IPAddress
    value:  172.16.2.1
  listeners:
    - name: https
      protocol: HTTPS
      port: 443
      allowedRoutes:
        namespaces:
          from: All
      tls:
        mode: Terminate
        certificateRefs:
          - kind: Secret
            name: wagner-tls
            namespace: kube-system

Apply it

k apply -f gateway.yaml

Check status

➜ k get gateway -n kube-system
NAME      CLASS    ADDRESS        PROGRAMMED   AGE
gateway   cilium   172.16.2.1   True         19s

Add the following to your DNS server for name resourslutions or to your hosts file.

172.16.2.1     *.apps.c1.wagner.org.za

Now lets create a route for hubble ui

#hubble-ui-httproute.yaml
apiVersion: gateway.networking.k8s.io/v1beta1
kind: HTTPRoute
metadata:
  name: hubble
  namespace: kube-system
spec:
  parentRefs:
    - name: gateway
      namespace: kube-system
      sectionName: https
  hostnames:
    - "hubble.apps.c1.wagner.org.za"
  rules:
    - matches:
        - path:
            type: PathPrefix
            value: /
      backendRefs:
        - kind: Service
          name: hubble-ui
          port: 80

Apply the route

k apply -f hubble-ui-httproute.yaml

Check routes.

➜ k get httproutes.gateway.networking.k8s.io -n kube-system
NAME     HOSTNAMES                          AGE
hubble   ["hubble.apps.c1.wagner.org.za"]   9s

eBGP
#

Currently we are not able to route to our services as our network has now way of discovering these routes. We will now deploy BGP sharing and have cilium advertise our services to our router (Mikrotik).

Create bpg-peering manifest.

#bgp-peering-policy.yaml
apiVersion: "cilium.io/v2alpha1"
kind: CiliumBGPPeeringPolicy
metadata:
 name: bgp-peering-policy
spec:
 nodeSelector:
   matchLabels:
     bgp_enabled: "true"
 virtualRouters:
 - localASN: 64512
   exportPodCIDR: true
   neighbors:
    - peerAddress: '192.168.7.254/32'
      peerASN: 64512
   serviceSelector:
     matchExpressions:
       - {key: somekey, operator: NotIn, values: ['never-used-value']}

As you can see we only deploying to nodes labled bpg_enabled=true Lets lable our worker nodes

➜ k label nodes cluster1-wrk1-6189201e cluster1-wrk2-61f99182 cluster1-wrk3-6332a29e bgp_enabled=true
node/cluster1-wrk1-6189201e labeled
node/cluster1-wrk2-61f99182 labeled
node/cluster1-wrk3-6332a29e labeled

Now lets create our bgp policy.

k apply -f bgp-peering-policy.yaml

Now configure your route to match the bgp settings to your worker nodes in my example 192.168.50.2/32, 192.168.50.3/32 and 192.168.50.4/32.

Check status

➜ cilium bgp peers
Node                     Local AS   Peer AS   Peer Address    Session State   Uptime   Family         Received   Advertised
cluster1-cp              64512      64512     192.168.7.254   established     2m10s    ipv4/unicast   11         2
                                                                                      ipv6/unicast   0          0
cluster1-wrk1-6189201e   64512      64512     192.168.7.254   established     1m59s    ipv4/unicast   11         2
                                                                                      ipv6/unicast   0          0
cluster1-wrk2-61f99182   64512      64512     192.168.7.254   established     1m46s    ipv4/unicast   11         2
                                                                                      ipv6/unicast   0          0
cluster1-wrk3-6332a29e   64512      64512     192.168.7.254   established     1m39s    ipv4/unicast   11         2
                                                                                      ipv6/unicast   0          0
                                                                            

If the status is active and doesnt change to established check your router config and make sure you have bgp peering partners configured for each worker node.

Test connection

✗  curl -v https://hubble.apps.c1.wagner.org.za
* Host hubble.apps.c1.wagner.org.za:443 was resolved.
* IPv6: (none)
* IPv4: 172.16.2.1
*   Trying 172.16.2.1:443...
* Connected to hubble.apps.c1.wagner.org.za (172.16.2.1) port 443
* ALPN: curl offers h2,http/1.1
* (304) (OUT), TLS handshake, Client hello (1):
*  CAfile: /etc/ssl/cert.pem
*  CApath: none
* (304) (IN), TLS handshake, Server hello (2):
* (304) (IN), TLS handshake, Unknown (8):
* (304) (IN), TLS handshake, Certificate (11):
* (304) (IN), TLS handshake, CERT verify (15):
* (304) (IN), TLS handshake, Finished (20):
* (304) (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / AEAD-CHACHA20-POLY1305-SHA256 / [blank] / UNDEF
* ALPN: server did not agree on a protocol. Uses default.
* Server certificate:
*  subject: CN=*.c1.wagner.org.za
*  start date: Dec 31 11:27:10 2025 GMT
*  expire date: Mar 31 11:27:09 2026 GMT
*  subjectAltName: host "hubble.apps.c1.wagner.org.za" matched cert's "*.apps.c1.wagner.org.za"
*  issuer: C=US; O=Let's Encrypt; CN=R13
*  SSL certificate verify ok.
* using HTTP/1.x
> GET / HTTP/1.1
> Host: hubble.apps.c1.wagner.org.za
> User-Agent: curl/8.7.1
> Accept: */*
>
* Request completely sent off
< HTTP/1.1 200 OK
< server: envoy
< date: Wed, 31 Dec 2025 13:17:52 GMT
< content-type: text/html
< content-length: 702
< last-modified: Wed, 03 Sep 2025 09:25:19 GMT
< etag: "68b8097f-2be"
< accept-ranges: bytes
< x-envoy-upstream-service-time: 2
<
* Connection #0 to host hubble.apps.c1.wagner.org.za left intact
<!doctype html><html><head><meta charset="utf-8"/><title>Hubble UI</title><base href="/"/><meta name="color-scheme" content="only light"/><meta http-equiv="X-UA-Compatible" content="IE=edge"/><meta name="viewport" content="width=device-width,user-scalable=0,initial-scale=1,minimum-scale=1,maximum-scale=1"/><link rel="icon" type="image/png" sizes="32x32" href="favicon-32x32.png"/><link rel="icon" type="image/png" sizes="16x16" href="favicon-16x16.png"/><link rel="shortcut icon" href="favicon.ico"/><script defer="defer" src="bundle.main.046b68211246343ab6d5.js"></script><link href="bundle.main.250ac4c5d4e736844d1e.css" re

Using cilium cli.

cilium connectivity test

Check out k3s — Cilium Dual-Stack with eBGP and Gateway API

72 - This article is part of a series.