Kairos, l’OS immutable pour déployer Kubernetes
Les lecteurs réguliers de ce blog connaissent mon attachement profond à Talos. J’ai la chance d’être dans l’équipe s’occupant des machines Talos de Lucca, je l’ai également présenté dans le cadre de ma VAE et j’ai même eu la chance d’être ambassadeur Sidero. Si vous ne connaissez pas encore cette incroyable solution, je ne peux que vous encourager à consulter leur site, à lire mes articles précédents, visionner les rediffusions de mes talks, ou m’aborder dans la rue en prononçant le T-word pour que je vous en parle pendant des heures (à vos risques et périls).
Mais le ticket d’entrée peut sembler un peu élevé pour certains. Il est important de mesurer l’ampleur du changement de paradigme que représente Talos, et de bien peser le pour et le contre avant de se lancer dans l’aventure Talos (et même Kubernetes en général d’ailleurs).
Et si vous n’êtes pas encore convaincus par la philosophie API-Only de Talos, ou si vous cherchez une alternative plus proche de Linux “classique”, alors cet article est fait pour vous car nous n’allons pas parler de Talos, mais de Kairos, un OS immuable open-source conçu pour déployer des clusters Kubernetes de manière simple et efficace.
Pourquoi s’intéresser à Kairos ?
À l’inverse de Talos (OS minimaliste sans SSH, ni Systemd), Kairos adopte une approche “Linux classique” en permettant de bâtir un système immuable à partir de distributions existantes (openSUSE, Ubuntu, Debian, Rocky Linux, etc.) tout en conservant leurs écosystèmes. Là où Talos fournit un environnement Kubernetes clé en main, Kairos laisse le choix de s’appuyer sur des briques familières comme SystemD, k3s/k0s, et Cloud-Init (via Yip).
Il y a quand même quelques points à noter au sujet des distributions supportées par Kairos :
- Les mises à jour atomiques A/B se font au travers d’images signées (et non via le gestionnaire de paquets de la distribution).
- L’OS est en lecture seule, ce qui empêche toute dérive de configuration (donc adieu
apt-get installouyum install), on en reparle plus tard. - Les workflows d’automatisation héritent de Cloud-Init (en réalité, il s’agit d’une réimplémentation appelée Yip) et des pipelines OCI, ce qui simplifie l’intégration avec des environnements déjà outillés (CI/CD, GitOps, Proxmox, etc.).
Cette souplesse en fait une option intéressante pour les équipes qui veulent bénéficier de l’immutabilité sans renoncer à l’usage d’une distribution Linux déjà maîtrisée.
L’immutabilité avec Kairos
L’OS est distribué comme une image de conteneur OCI signée qui s’installe en lecture seule sur un système à double partition (active et passive). Lors d’une mise à jour, la nouvelle version s’écrit sur la partition inactive avant que le bootloader ne bascule, permettant ainsi un rollback immédiat si la nouvelle image pose problème (système A/B).
La doc officielle précise l’arborescence suivante :
/usr/local - persistent ( partition label COS_PERSISTENT)
/oem - persistent ( partition label COS_OEM)
/var - ephemeral
/etc - ephemeral
/srv - ephemeral
/ immutable
Seules les partitions COS_PERSISTENT et COS_OEM survivent aux redéploiements ; le reste est reconstruit à chaque boot, ce qui évite la dérive de configuration. L’image root peut être construite localement ou directement récupérée depuis un registre OCI, et les upgrades passent par sudo kairos-agent upgrade --source <type>:<address> qui tire l’artefact, vérifie la signature, synchronise la partition passive et redémarre.
Ce partitionnement, couplé à la validation des artefacts décrite dans la documentation Kairos architecture container, permet d’appliquer des updates reproductibles sans se soucier de la configuration historique d’un nœud.
Configuration de Kairos
Kairos utilise un système de “stages” pour organiser les différentes étapes de configuration et d’installation. Chaque stage peut contenir plusieurs actions qui seront exécutées dans un ordre spécifique, nous avons aussi la possibilité de définir des actions durant des phases spécifiques du boot (initramfs, boot, post-boot, etc.).

name: "Disable QEMU tools"
stages:
boot.after:
- name: "Disable QEMU"
if: |
grep -iE "qemu|kvm|Virtual Machine" /sys/class/dmi/id/product_name && \
( [ -e "/sbin/systemctl" ] || [ -e "/usr/bin/systemctl" ] || [ -e "/usr/sbin/systemctl" ] || [ -e "/usr/bin/systemctl" ] )
commands:
- systemctl stop qemu-guest-agent
Ici par exemple, on désactive le service qemu-guest-agent durant la phase boot.after si la machine est détectée comme étant une VM QEMU/KVM. Pouvoir exécuter des actions conditionnelles à chaque étape du boot est un vrai plus dans la flexibilité apportée par Kairos (en revanche, ça peut donner des configurations un peu verbeuses si on abuse des conditions).
Déployer un cluster Kubernetes avec Kairos - the dirty but easy way
Sachant que Kairos est compatible avec k3s et k0s, j’ai commencé par la partie k0s, ma préférée (surtout car je reproche à k3s d’embarquer trop de composants inutiles comme Traefik, Flannel …).
#cloud-config
users:
- name: "kairos"
groups: [ "admin", "wheel" ]
ssh_authorized_keys:
- github:qjoly
# enable debug logging
debug: true
k0s:
enabled: true
Le fait de pouvoir spécifier mes clés SSH directement via Github est un vrai plus, j’apprécie beaucoup cette fonctionnalité.
[root@localhost k0s]# k0s kubectl get nodes -A
No resources found
[root@localhost k0s]# k0s token create --role=worker
H4sIAAAAAAAC/2xVUY+rOtJ8n1+RP3DOtUOY7ybS97AkdggJzsG47eA3sDmTgCEMYRKG1f731eTOlXalfWt3taqsVqsq7y6y7G+Xa7ua3fGLcR+3oexvq5cfs+969TKbzWam7IfL74vJh/JH/jGcr/1l+Pxh8yFfzQ4pGg4pXnOwkbgEGy4jSEFHCaLAnxga1jWOUuABJ2yTKNlpRL0UokAjJ5MpGRUYLMiZxuvlL0l5VYAMJabveet6W1luEecKukOBuygPdSVqyjk89omjgUWWcGlZ4miYCCo50IUA/xxj3Sl33lvZZdCMVSnou5I80KC9OHRHSaQnVNeWgCE9dYyT6Mxb8yjfur8x/Z+YgKgtiX5PpFxr5KukpkwSfrIbqgup/azBKqZdJk5cx7X09NzSHHAQE7sBj4Zrx6KsYgRArhNJZQIEpRARi+hzNkX0/rUTAOZnTcSN5KFAy7Vxu56DzGIYiVIUZVVAjDKLrHITR9neSLo54LfPBNlfJjzH8anzchzsikmnqjWj/tqhcIne4rw8Ra9ivpiKivd24o+C6GE/p43YyFZT/mqc5XZi2/IfnUhldGWYNUomE2yvD14Pj+N2fOSKy7h59HElE1AdKy/LX7zinajMCGqJcnDrWEQsqyxXMGSMSJrK4GK3UbRu0L0g485u6sWR8rulDNj61h8JPxmgcCT+yZBlm9KgTrxkwbe1n6Nbb0A2UPtH3pwfMZE8EbFvkPw8SnaPp93egr6YuQ8qjHu1jR/p1gpL6IetZSZD7pSzUYEcHLDLCs9CLNjeVmcUy47Dhr1niL4acn6IudwaGT1sHR3zpNuKGn1KTIcipO/SScUB76Ryk6p4qJEbjeu4FMHVeme/VEvN5sm9bGlkKuoViL4zr9smyrKkpgGfdzyvo2Bd44ARG8RS1jy0NLksPQAZWOTWSdMFQKJWOL2DGq8TlS04RJAADwDe7gJ2D47IKBUnGtNTUkdpvJUeqx/7vOKfUr55mhjf1I8FnBgIEQQgLOQ1PSaN7Y069zkY307B1w1OvKaBQCz40pdzMlfODaqRY9G6KSZ8k7x1icXXT10/MNtGoYLdxJuI5RDFAr/5ijBXKCrj7XizJ+KxOfMk6B7q5btouV82vijr8ZKizpPQ5WWzvAskydrtcOakyD37cZhYyzfn/Khcz7FtgPz5MMA6A284Bz6PcYwkPl8MdNoAH/dTMHKUjWrDkPZYppU76kbeUoA9qx3WJPqI59eFFTrOv7zIC3oJQ2wpW+v2jKwajiXxfx2QDaAeFYQ7T06BLtRwNaGNJN0hc7kttKKv1ulTmXTZYdL+Yd41ObIXUWMukO8X4M8BZLL3uk2GEk/ONSuJvtmNOwkXvKabBItKZ6ZyoZTnHgTnWmHJ6vFkNlSvKx0Wm9jjjh1Uyxvj3BG87iMGSssGjfnkzjJ0qamsZi7qDMlwTnCWhjaTWz/UJJv0iTsD0aupeMUbdi2qeL+f80qC9H4JtP/2Zirq5C1BMpXEbVNggSTw9OVDcv3/p8Hfyv5e9qvZeRi62+qPP/By/hO//vkT/8Sv3up1sfBeZrM2b8rVrEa3F3Nth3Ic/gqKv+rvoPhOjefUV+Pj9nx9FKUrhx/F9Trchj7v/pvto+/LdvjxN9OzWV9au5qtr+3vy9tL15e/y75sTXlbzf75r5cv1qf4N8n/oH8KP78wXOuyXc16m9+v/c939zn69e9F/3+mWZb48fLvAAAA//+MBOjgBAcAAA==
Fait intéressant, le token généré est en fait un fichier kubeconfig compressé et encodé en base64. Si on le décode, on obtient quelque chose comme ça :
# Après un `echo $token | base64 -D | gunzip -
apiVersion: v1
clusters:
- cluster:
certificate-authority-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURBRENDQWVpZ0F3SUJBZ0lVQzQxWUc1TEhFMC9PVFRjbUVHV1FqanlrdjdRd0RRWUpLb1pJaHZjTkFRRUwKQlFBd0dERVdNQlFHQTFVRUF4TU5hM1ZpWlhKdVpYUmxjeTFqWVRBZUZ3MHlOVEV3TWpneU1USXpNREJhRncwegpOVEV3TWpZeU1USXpNREJhTUJneEZqQVVCZ05WQkFNVERXdDFZbVZ5Ym1WMFpYTXRZMkV3Z2dFaU1BMEdDU3FHClNJYjNEUUVCQVFVQUE0SUJEd0F3Z2dFS0FvSUJBUUN5YmJRcVRHT09CclIrRUVYMUxEWWF0YjBEcWc4YjlzR0YKcVFDL1gyQ0dPcHhMMXp3a1BIbzZSWncxZnlrdTlQZG1aeXJ6T24zbjRrdzRwbEZtK2FmTDVnZFR6cldRdzNGeApTSVJoN1NmWVQzUGowRktwOGxwaWRVMmwrMjVQUWpNei9PRjRpTjcxUW90aUlCMTJNYjdRWUtYNEVFSVBidGJJCm0vbExIdDk4OFRvdFNUNCsrOERXcUFUOE5XcE9nSFBkQ3Q4RGk5a0srcUVmUk5ORmhwMEVRQTM5c0VyOVNvMzIKdUZic25UWHMrWGMwSGdTdEFudkVYVHRlWldJb0lUL1lYb3dUMTNKdjh0MVpRUDNqY0F6cEhwT2VGcVJwdkJOaQpGTk0yV1FtbHFqVlVWRU1IVWlzWjRHZ0lxclpRVTBod3h5eW9ZN2QvenFJcjF3b0FqN3pGQWdNQkFBR2pRakJBCk1BNEdBMVVkRHdFQi93UUVBd0lCQmpBUEJnTlZIUk1CQWY4RUJUQURBUUgvTUIwR0ExVWREZ1FXQkJSMGV3NkwKajRyVVg3ZEc5ckw4UXNUTTBBUTdUakFOQmdrcWhraUc5dzBCQVFzRkFBT0NBUUVBV2E2WlltWmVxbnlzMERDQgpQd1oyZkw1NGJHWUIzRmJNaUJMT1g5WENlbWFVMGxsdXE3N2N3VUZrUk9qTnR5em5TekxiS0p3VUpaem9vT0VEClI1YlVTa3duLzNnRDhaOWlrR1dmUE8wcUNpcUg1aUR2M1M0V1hicUpZcURxKzBxR0YxWDN0Z3NYZWlOZmVsSUUKNkl1ZEJuM2o4dTZMaVJUS3BrVUtMdFNCZnh0dWtOeE5PL0dBUkxWUHI3VzBZbWtocHdJVFI0cis4ZWF6dlZXeQpYLzZ5L2pma0diTk1RT055bU52UUVQK3pDY0Q3V2ZNeEZsdDlXTlB6SDQ1TjZYcjlHVVhrUTRRZW1VNkxXcDFZCjZHbDM3RlNLWnRmcllOU3puMUFFem0xazlhVHlScjdZNlJpcEY1aE1YSHdYVG5HZEYzZXRlcUJ6cjRjRmNobjMKK2RjVUV3PT0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo=
server: https://192.168.1.163:6443
name: k0s
contexts:
- context:
cluster: k0s
user: kubelet-bootstrap
name: k0s
current-context: k0s
kind: Config
preferences: {}
users:
- name: kubelet-bootstrap
user:
token: rdavor.qlyx5kf4r7cm9e1w
À partir de là, il suffit de l’injecter dans notre Cloud-Init pour que le nœud se joigne automatiquement au cluster k0s en tant que worker.
stages:
initramfs:
- name: "Setup hostname"
hostname: "node-{{ trunc 4 .MachineID }}"
users:
- name: "kairos"
groups: [ "admin", "wheel" ]
ssh_authorized_keys:
- github:qjoly
k0s-worker:
enabled: true
args:
- --token-file /etc/k0s/token
write_files:
- path: /etc/k0s/token
permissions: 0644
content: |
H4sIAAAAAAAC/2xVUY+rOtJ8n1+RP3DOtUOY7ybS97AkdggJzsG47eA3sDmTgCEMYRKG1f731eTOlXalfWt3taqsVqsq7y6y7G+Xa7ua3fGLcR+3oexvq5cfs+969TKbzWam7IfL74vJh/JH/jGcr/1l+Pxh8yFfzQ4pGg4pXnOwkbgEGy4jSEFHCaLAnxga1jWOUuABJ2yTKNlpRL0UokAjJ5MpGRUYLMiZxuvlL0l5VYAMJabveet6W1luEecKukOBuygPdSVqyjk89omjgUWWcGlZ4miYCCo50IUA/xxj3Sl33lvZZdCMVSnou5I80KC9OHRHSaQnVNeWgCE9dYyT6Mxb8yjfur8x/Z+YgKgtiX5PpFxr5KukpkwSfrIbqgup/azBKqZdJk5cx7X09NzSHHAQE7sBj4Zrx6KsYgRArhNJZQIEpRARi+hzNkX0/rUTAOZnTcSN5KFAy7Vxu56DzGIYiVIUZVVAjDKLrHITR9neSLo54LfPBNlfJjzH8anzchzsikmnqjWj/tqhcIne4rw8Ra9ivpiKivd24o+C6GE/p43YyFZT/mqc5XZi2/IfnUhldGWYNUomE2yvD14Pj+N2fOSKy7h59HElE1AdKy/LX7zinajMCGqJcnDrWEQsqyxXMGSMSJrK4GK3UbRu0L0g485u6sWR8rulDNj61h8JPxmgcCT+yZBlm9KgTrxkwbe1n6Nbb0A2UPtH3pwfMZE8EbFvkPw8SnaPp93egr6YuQ8qjHu1jR/p1gpL6IetZSZD7pSzUYEcHLDLCs9CLNjeVmcUy47Dhr1niL4acn6IudwaGT1sHR3zpNuKGn1KTIcipO/SScUB76Ryk6p4qJEbjeu4FMHVeme/VEvN5sm9bGlkKuoViL4zr9smyrKkpgGfdzyvo2Bd44ARG8RS1jy0NLksPQAZWOTWSdMFQKJWOL2DGq8TlS04RJAADwDe7gJ2D47IKBUnGtNTUkdpvJUeqx/7vOKfUr55mhjf1I8FnBgIEQQgLOQ1PSaN7Y069zkY307B1w1OvKaBQCz40pdzMlfODaqRY9G6KSZ8k7x1icXXT10/MNtGoYLdxJuI5RDFAr/5ijBXKCrj7XizJ+KxOfMk6B7q5btouV82vijr8ZKizpPQ5WWzvAskydrtcOakyD37cZhYyzfn/Khcz7FtgPz5MMA6A284Bz6PcYwkPl8MdNoAH/dTMHKUjWrDkPZYppU76kbeUoA9qx3WJPqI59eFFTrOv7zIC3oJQ2wpW+v2jKwajiXxfx2QDaAeFYQ7T06BLtRwNaGNJN0hc7kttKKv1ulTmXTZYdL+Yd41ObIXUWMukO8X4M8BZLL3uk2GEk/ONSuJvtmNOwkXvKabBItKZ6ZyoZTnHgTnWmHJ6vFkNlSvKx0Wm9jjjh1Uyxvj3BG87iMGSssGjfnkzjJ0qamsZi7qDMlwTnCWhjaTWz/UJJv0iTsD0aupeMUbdi2qeL+f80qC9H4JtP/2Zirq5C1BMpXEbVNggSTw9OVDcv3/p8Hfyv5e9qvZeRi62+qPP/By/hO//vkT/8Sv3up1sfBeZrM2b8rVrEa3F3Nth3Ic/gqKv+rvoPhOjefUV+Pj9nx9FKUrhx/F9Trchj7v/pvto+/LdvjxN9OzWV9au5qtr+3vy9tL15e/y75sTXlbzf75r5cv1qf4N8n/oH8KP78wXOuyXc16m9+v/c939zn69e9F/3+mWZb48fLvAAAA//+MBOjgBAcAAA==
Le petit problème avec ce setup est que l’endpoint de l’API-Server est directement codé en dur dans le token. Si jamais je veux spécifier un loadbalancer ou une VIP, je suis obligé de le décoder, le modifier, puis le ré-encoder. Le workflow n’est donc pas optimal et l’automatisation est pénible.

Heureusement, il existe une autre méthode qu’on va voir un peu plus tard (spoiler: ne vous attachez pas trop à k0s, il faudra qu’on migre vers k3s pour cette méthode). Mais avant ça, on va devoir s’attaquer à une autre forme d’automatisation avec Kairos : le Cloud-Init.
Cloud-Init avec Kairos
Je vais déjà clarifier un point important : Kairos n’utilise pas Cloud-Init tel qu’on le connait habituellement. Il utilise en fait une intégration personnalisée nommée yip. Grâce à ce projet, la configuration ne se base pas uniquement sur des scripts shell et possède un format plus simple à écrire (grâce à une grosse abstraction gérée par l’agent kairos).
C’est à ce moment que je vais passer par du Terraform OpenTofu pour éviter de devoir me connecter en SSH à mon Proxmox à chaque fois que je veux déployer un nouveau nœud.
Je vous donne ici un exemple minimaliste mais vous pourrez retrouver le code complet sur mon dépôt GitHub.
resource "proxmox_virtual_environment_vm" "kairos" {
count = var.vm_count
name = "${var.vm_name_prefix}-${count.index + 1}"
description = "Kairos VM ${count.index + 1}"
tags = ["terraform", "Kairos"]
node_name = var.node_name
agent {
enabled = true
}
stop_on_destroy = true
boot_order = ["scsi0", "ide3"]
startup {
order = "${3 + count.index}"
up_delay = "60"
down_delay = "60"
}
cdrom {
file_id = var.kairos_iso_file_id
}
cpu {
cores = 2
type = "x86-64-v2-AES"
}
memory {
dedicated = 3072
floating = 2048
}
disk {
datastore_id = "local-lvm"
interface = "scsi0"
}
network_device {
bridge = "vmbr0"
}
operating_system {
type = "l26"
}
serial_device {}
initialization {
user_data_file_id = proxmox_virtual_environment_file.cloud_init_userdata.id
}
}
resource "proxmox_virtual_environment_file" "cloud_init_userdata" {
content_type = "snippets"
datastore_id = "local"
node_name = var.node_name
source_raw {
data = <<-EOF
#cloud-config
stages:
initramfs:
- name: "Setup hostname"
hostname: "node-{{ trunc 4 .MachineID }}"
users:
- name: "kairos"
groups: [ "admin", "wheel" ]
ssh_authorized_keys:
- github:qjoly
debug: true
k0s:
enabled: true
install:
reboot: true
auto: true
EOF
file_name = "user-data-cloud-config.yaml"
}
}
Une fois le code appliqué, les VMs se créent automatiquement dans Proxmox, bootent sur l’ISO Kairos, récupèrent le Cloud-Init via le CD-ROM virtuel, puis s’installent toutes seules et redémarrent.
Astuce
Si jamais votre configuration est invalide, Kairos ne vous permettra pas de le constater au boot. Vous pouvez néanmoins vérifier la validité de votre Cloud-Init en utilisant la commande ci-dessous.
kairos validate /oem/95_userdata/userdata
Nickel, on a donc un moyen simple de créer nos VMs Kairos automatiquement en passant une configuration via Cloud-Init. Mais comment faire pour automatiser le join d’un nœud worker k0s/k3s sans avoir à gérer un token codé en dur dans le Cloud-Init ?
Déployer un cluster Kubernetes avec Kairos - the right way
Il y a un composant que je n’ai pas encore mentionné : edgevpn. C’est un outil qui permet de créer des réseaux privés virtuels (VPN) entre les différents nœuds de votre cluster Kubernetes.
La feature qui nous intéresse est l’auto-discovery des nodes via un service de type DHT (Distributed Hash Table) couplé à du mDNS (Multicast DNS).
Réseau P2P et auto-discovery
Kairos s’appuie sur un réseau P2P maison pour que les nœuds se découvrent et se coordonnent tout seuls, sans serveur d’orchestration extérieur. En activant auto.enable, chaque machine rejoint un overlay libp2p piloté par EdgeVPN à l’aide d’un token partagé (qui embarque les secrets nécessaires au bootstrap).
Concrètement, le mesh passe par trois étapes :
- découverte via mDNS en LAN ou DHT pour les environnements distribués ;
- constitution d’un réseau gossip qui stocke un ledger commun (tokens d’adhésion, topologie, métadonnées) ;
- établissement d’une connectivité complète au travers d’une interface VPN optionnelle.
Une fois le tunnel monté, Kairos distribue automatiquement des VirtualIPs, ce qui facilite autant l’exposition de l’API Kubernetes que l’usage de KubeVIP. Ce même overlay peut être réutilisé par vos workloads : il suffit de cibler l’interface virtuelle pour bénéficier d’un chiffrement de bout en bout sans configuration réseau exotique.
Pour des environnements plus hybrides, les contrôleurs Entangle permettent même de prolonger ce mesh dans des clusters existants afin de fédérer des services. Toute la mécanique est détaillée dans la documentation réseau de Kairos, mais l’idée clé reste la même : zéro configuration manuelle, ajout/suppression de nœuds à la volée, et une couche réseau résiliente pensée pour l’edge.
Auto-discovery avec Kairos
Grâce à ces deux technologies, les machines d’un même réseau local peuvent partager des informations en clé-valeur sans avoir besoin d’un serveur centralisé. Kairos a su tirer parti de ces fonctionnalités pour permettre aux nœuds d’un cluster Kubernetes de se découvrir automatiquement et de s’auto-configurer sans intervention manuelle. En revanche, ça n’est compatible qu’avec k3s pour le moment.
Pour cela, il suffit de le spécifier dans la configuration :
auto:
enable: true
p2p:
network_token: "${var.p2p_network_token}" # Token partagé entre les nœuds du cluster
auto:
ha:
enable: true
master_nodes: 2
Dans master_nodes, on spécifie le nombre de nœuds maîtres que notre machine va attendre avant de procéder à l’installation du cluster Kubernetes (donc 2, plus elle-même, pour un total de 3).
S’il y a déjà suffisamment de nœuds maîtres dans le réseau local, la machine va directement procéder à l’installation en tant que nœud worker.
Si on possède plusieurs clusters dans le même réseau local, il faudra les différencier en utilisant des network_id: "" différents.
En appliquant cette nouvelle configuration dans notre Cloud-Init (via un petit tofu apply dans mon cas), les nœuds s’installent automatiquement et se joignent au cluster Kubernetes sans que j’aie à intervenir (pas de token à gérer, pas d’IP à spécifier, rien du tout).
Voici un extrait des logs de kairos-agent durant le processus d’auto-discovery et d’installation :
Nov 02 11:01:35 node-1c67 kairos-provider[1346]: Applying role 'auto'
Nov 02 11:01:35 node-1c67 kairos-provider[1346]: Role loaded. Applying auto
Nov 02 11:01:35 node-1c67 kairos-provider[1346]: Active nodes:[12D3KooWC5J67j9ZBdD43gvHUJVtK3GdkFZgJqtCp44kLibZZrfb 12D3KooWMJEa4Pj51Tyh1SJgQuoqzdi2jxu9GaZN>
Nov 02 11:01:35 node-1c67 kairos-provider[1346]: Advertizing nodes:[71439af32f8f4ded89df8ee6e04c0930-node-7143 c36e5f71e646449eaf1182d7a766a058-node-c36e fe>
Nov 02 11:01:35 node-1c67 kairos-provider[1346]: <1c67200d175e43809a806488aedfc940-node-1c67> not a leader, leader is 'f0a5a8a0f0c04cde8c3181d6caa5fcc8-node>
Nov 02 11:01:35 node-1c67 kairos-provider[1346]: Roles assigned
Nov 02 11:01:35 node-1c67 kairos-provider[1346]: Applying role 'master/ha'
Nov 02 11:01:35 node-1c67 kairos-provider[1346]: Role loaded. Applying master/ha
Nov 02 11:01:35 node-1c67 kairos-provider[1346]: Starting Master(master/ha)
Nov 02 11:01:35 node-1c67 kairos-provider[1346]: Checking role assignment
Nov 02 11:01:35 node-1c67 kairos-provider[1346]: Determining K8s distro
Nov 02 11:01:35 node-1c67 kairos-provider[1346]: Verifying sentinel file
Nov 02 11:01:35 node-1c67 kairos-provider[1346]: Checking HA
Suite à cela, il ne reste plus qu’à vérifier que tout fonctionne correctement.
[root@node-1c67 ~]# kubectl get nodes
NAME STATUS ROLES AGE VERSION
node-1c67 Ready control-plane,etcd 8m48s v1.34.1+k3s1
node-7143 Ready control-plane,etcd 9m49s v1.34.1+k3s1
node-c36e-b2fa755e Ready <none> 9m5s v1.34.1+k3s1
node-f0a5 Ready control-plane,etcd 8m44s v1.34.1+k3s1
node-feb6-0d4a2413 Ready <none> 9m8s v1.34.1+k3s1
Et voilà, nous avons un cluster Kubernetes fonctionnel déployé automatiquement avec Kairos, sans avoir à gérer de tokens ou d’interventions manuelles. C’est pas magnifique ça ?
Kairos Operator
Mais une fois que le cluster est en place, comment on gère les mises à jour et les opérations Day-2 ? C’est là qu’intervient le Kairos Operator !
Depuis la 3.5 c’est clairement la méthode recommandée pour piloter les upgrades et les opérations Day-2 directement depuis Kubernetes. Le principe est simple : deux CRDs, NodeOp pour exécuter tout et n’importe quoi sur un nœud (Kairos ou pas), et NodeOpUpgrade qui encapsule les scripts d’upgrade Kairos prêts à l’emploi.
Le déploiement ne demande pas grand-chose :
kubectl apply -k https://github.com/kairos-io/kairos-operator/config/default
Une fois l’operator en place, tous les nœuds Kairos reçoivent automatiquement le label kairos.io/managed=true, pratique pour cibler uniquement l’infra immuable dans un cluster hybride. On peut aussi embarquer l’operator directement dans l’installation en ajoutant le bundle officiel :
bundles:
- targets:
- run://quay.io/kairos/community-bundles:kairos-operator_latest
Remarque
Petite apparté : les bundles sont des extensions qui rajoutent des fichiers supplémentaires dans le filesystem Kairos. Ils peuvent contenir des binaires, des scripts ou des manifests Kubernetes déployés dans les dossiers k3s/k0s pour qu’ils soient appliqués automatiquement au démarrage.
Pour installer un bundle, on peut passer par la configuration Cloud-Init comme montré ci-dessus, ou utiliser la commande kairos-agent sysext install qui télécharge et installe le bundle à chaud (en prenant en argument une image OCI, un lien HTTP(S) ou un chemin local).
Pour lancer un upgrade, on crée un NodeOpUpgrade qui pointe vers l’image Kairos souhaitée, avec la possibilité de jouer finement sur la concurrence ou l’arrêt en cas d’échec :
apiVersion: operator.kairos.io/v1alpha1
kind: NodeOpUpgrade
metadata:
name: kairos-upgrade
namespace: default
spec:
image: quay.io/kairos/opensuse:tumbleweed-latest-standard-amd64-generic-v3.5.6-k3s-v1.34.1-k3s1
nodeSelector:
matchLabels:
kairos.io/managed: "true"
concurrency: 1
stopOnFailure: true
Il est aussi possible de créer des NodeOp personnalisés pour exécuter des commandes arbitraires sur les nœuds Kairos. Par exemple, pour redémarrer le service k3s sur tous les nœuds gérés :
apiVersion: operator.kairos.io/v1alpha1
kind: NodeOp
metadata:
name: example-nodeop
namespace: default
spec:
# NodeSelector to target specific nodes (optional)
nodeSelector:
matchLabels:
kairos.io/managed: "true"
# The container image to run on each node
image: busybox:latest
# The command to execute in the container
command:
- sh
- -c
- |
echo "Running on node $(hostname)"
ls -la /host/etc/kairos-release
cat /host/etc/kairos-release
# Path where the node's root filesystem will be mounted (defaults to /host)
hostMountPath: /host
cordon: true
drainOptions:
enabled: true
force: false
gracePeriodSeconds: 30
ignoreDaemonSets: true
deleteEmptyDirData: false
timeoutSeconds: 300
rebootOnSuccess: true
backoffLimit: 3
concurrency: 1
stopOnFailure: true
Cela s’apparente un peu aux Jobs Kubernetes, mais dans lequel on peut aussi choisir de cordonner et drainer les nœuds avant d’exécuter la commande.
Entangle
Il faut absolument que j’ouvre une petite parenthèse sur Entangle, les développeurs de Kairos ont créé un projet pour créer du Mesh entre deux clusters Kubernetes. L’idée : deux opérateurs Kubernetes (entangle et entangle-proxy) qui recyclent le même système P2P (libp2p) pour connecter des nodes Kairos (équivalent à KubeSpan pour Talos).

Dans la pratique, on commence par générer un network_token (le même format que pour EdgeVPN, on verra ça plus tard), puis :
- on installe les CRDs via
helm install kairos-crd kairos/kairos-crds, - on déploie
entanglesur les clusters que l’on veut relier, - on ajoute
entangle-proxysur le cluster “maître” qui va pousser ou recevoir des manifests à distance.
Ensuite, tout se pilote avec de simples ressources Kubernetes. Un Secret embarque le token P2P, puis l’objet Entanglement décrit le service à exposer :
---
apiVersion: entangle.kairos.io/v1alpha1
kind: Entanglement
metadata:
name: test
namespace: default
spec:
serviceUUID: "foo"
secretRef: "mysecret"
host: "127.0.0.1"
port: "8080"
inbound: true
serviceSpec:
ports:
- port: 8080
protocol: TCP
type: ClusterIP
Je vais pas rentrer dans le détail ici (ça pourrait être l’objet d’un article à part entière), mais le concept est vraiment intéressant pour fédérer des clusters Kubernetes distants sans se casser la tête.
Conclusion
Au final, je suis assez agréablement surpris par Kairos qui semble assez prométteur pour toute personne voulant utiliser un OS Immutable orienté Kubernetes sans sauter le pas avec des OS API-Only comme Talos. Le fait de pouvoir s’appuyer sur des outils connus (k0s/k3s, Cloud-Init, etc.) tout en bénéficiant d’une approche moderne de l’infra immuable est très user-friendly.
Je trouve aussi très bien que les développeurs aient investit du temps dans l’écosystème Entangle, Kairos Operator, Yip ou Auroraboot (que je n’ai pas abordé pour le déploiement PXE).
