Featured image of post Déployer un cluster Kubernetes immuable avec Talos sur Proxmox

Déployer un cluster Kubernetes immuable avec Talos sur Proxmox

Provisionnement d'un cluster Talos Linux sur Proxmox avec OpenTofu, en préparation d'un nœud GPU

Série “Un cluster Kubernetes avec GPU pour faire de l’inférence LLM”

  1. Déployer un cluster Kubernetes immuable avec Talos sur Proxmox (cet article)
  2. Passer un GPU NVIDIA dans un cluster Kubernetes (En cours d’écriture)
  3. Piloter un cluster Kubernetes en GitOps avec Flux (En cours d’écriture)
  4. Inférence LLM auto-hébergée sur Kubernetes (En cours d’écriture)

Introduction

Après avoir déployé un cluster Kubernetes sur Proxmox avec Kubespray, j’ai voulu régler un point qui me gênait : la maintenance de l’OS sous le cluster. Dans cet article, je reprends le sujet depuis zéro avec Talos Linux, une distribution immuable dédiée à Kubernetes, que je provisionne entièrement avec OpenTofu.

Cet article est le premier d’une série dont l’objectif final est de faire tourner de l’inférence LLM sur un GPU passé au cluster. Le cluster décrit ici en est la base, et il contient donc déjà un nœud prévu pour le GPU.

Contexte

Mon précédent article détaillait le déploiement d’un cluster Kubernetes avec Kubespray. Le résultat était reproductible, mais l’automatisation s’arrêtait à l’OS : les trois VMs Debian restaient des systèmes classiques, avec leurs mises à jour de paquets, leur accès SSH et une configuration qui pouvait dériver entre deux lancements du playbook.

Avec Talos, l’OS n’est plus administrable directement : il n’y a ni shell, ni SSH, ni gestionnaire de paquets. La seule interface est une API gRPC, et toute la machine est décrite par un fichier de configuration YAML appliqué de manière déclarative. Pour modifier un nœud, je modifie sa configuration et Talos l’applique.

Talos n’est pas meilleur que Kubespray dans l’absolu. Je perds la possibilité d’intervenir à la main sur un nœud, mais en échange je sais qu’aucun nœud n’a été modifié en dehors du code.

Composants

ComposantVersionRôle
Proxmox9.1Hyperviseur
Talos Linux1.13.8OS des nœuds
Kubernetesv1.36.2Orchestrateur
OpenTofu1.11.5Création des VMs et configuration Talos
Provider bpg/proxmox0.114.0Pilotage de Proxmox
Provider siderolabs/talos0.12Pilotage de Talos
Composant GitLab CI OpenTofu4.5.0Pipeline de déploiement
Cilium1.20.2CNI, remplace kube-proxy

J’ai choisi OpenTofu plutôt que Terraform pour rester cohérent avec ma ligne sur les logiciels open source et leurs licences. Depuis que HashiCorp a passé Terraform sous licence BUSL en 2023, OpenTofu est le fork qui reste sous licence MPL 2.0. Son évolution est aussi davantage guidée par les demandes de fonctionnalités de la communauté, alors que celle de Terraform s’oriente vers les offres Enterprise et Cloud de HashiCorp, qui ne m’intéressent pas.

Talos Linux

Talos est une distribution Linux qui ne contient que ce qu’il faut pour faire tourner Kubernetes : un noyau, son propre système d’init, containerd et les composants du cluster. Quelques-unes de ses propriétés expliquent la plupart des choix faits dans la suite de l’article :

  • Le système de fichiers racine est en lecture seule, monté depuis une image squashfs. Les données qui doivent persister (etcd, images de conteneurs) sont écrites sous /var, sur une partition dédiée. Il n’y a donc aucun endroit où déposer un binaire ou modifier un fichier de configuration à la main.
  • Les mises à jour suivent un schéma A/B. La nouvelle version est installée à côté de la version en cours, et si la mise à jour échoue, le nœud peut redémarrer sur l’ancienne.
  • Il n’y a pas de systemd. Le kubelet, etcd, containerd et l’API Talos sont gérés par machined, et leur configuration vient entièrement de la machine config. Il n’est pas possible d’ajouter un service ou d’en surcharger un.
  • L’administration passe uniquement par une API gRPC, exposée sur le port 50000 de chaque nœud avec une authentification TLS mutuelle. Le fichier talosconfig utilisé par talosctl contient la CA du cluster et un certificat client, et sans ce fichier, un nœud Talos refuse toute connexion.

Toute la configuration d’un nœud tient donc dans un seul fichier YAML, la machine config, qui comporte deux grandes sections : machine.* pour le nœud lui-même (réseau, disques, kubelet, modules noyau) et cluster.* pour le cluster Kubernetes (CNI, kube-proxy, manifests additionnels).

Configuration machine et configuration cluster

Ces deux sections sont dans le même fichier, mais elles ne sont pas appliquées de la même manière :

  • machine.* est réconcilié en continu. Les contrôleurs de Talos surveillent la configuration et ajustent l’état du nœud. Un patch qui modifie machine.network ou machine.kubelet est appliqué immédiatement, parfois après le redémarrage du service concerné.
  • cluster.* n’est lu qu’au bootstrap. Au moment du talos_machine_bootstrap, Talos génère des manifests Kubernetes à partir de cette section et les applique au cluster. La section reste ensuite dans la configuration, mais elle n’est plus relue.

En pratique, modifier cluster.network.cni.name sur un cluster déjà démarré ne supprime pas le CNI en place, et Talos n’affiche aucune erreur ni aucun avertissement. Les manifests générés à partir de cluster.* ne sont rejoués, avec suppression de ceux qui ne sont plus générés, que pendant un talosctl upgrade-k8s.

Schéma de la machine config : la section machine est réconciliée en continu par les contrôleurs Talos, la section cluster n'est lue qu'au bootstrap pour générer les manifests, rejoués seulement par talosctl upgrade-k8s Schéma de la machine config : la section machine est réconciliée en continu par les contrôleurs Talos, la section cluster n'est lue qu'au bootstrap pour générer les manifests, rejoués seulement par talosctl upgrade-k8s

Les deux cycles de vie de la machine config

Architecture cible

RôleHostnameVM IDIPvCPURAMDisque
Control planetalos-controlplane-01121192.168.1.12124 GB50 GB
Workertalos-worker-01122192.168.1.12248 GB100 GB
Worker GPUtalos-gpu-01123192.168.1.123412 GB100 GB

L’API Kubernetes ne répond pas sur l’IP d’un nœud mais sur une VIP, 192.168.1.120. Cette adresse est réservée, et les nœuds commencent donc à .121.

📝 Note

Il n’y a qu’un seul control plane, comme dans mon article sur Kubespray, et pour la même raison : c’est un homelab. J’ai quand même configuré la VIP dès la création, car elle est inscrite dans la configuration de chaque nœud et la mettre en place plus tard obligerait à reconfigurer tout le cluster.

Un nœud GPU déclaré dès le départ

Le troisième nœud s’appelle talos-gpu-01, même si aucune carte graphique n’est utilisée dans cet article. Il se distingue déjà des deux autres sur trois points :

  • 12 GB de RAM au lieu de 8. Le passthrough PCI impose de désactiver le ballooning, donc cette mémoire sera réservée en permanence sur l’hôte. J’ai préféré dimensionner le nœud tout de suite en conséquence.
  • Une image Talos différente. Les modules NVIDIA sont fournis par des extensions système, qui sont intégrées à l’image au moment de sa construction. Ce nœud a donc sa propre image.
  • Un patch de configuration qui ne concerne que lui. Charger les modules NVIDIA sur un nœud sans carte produit une erreur à chaque démarrage.

Ce dernier point a une conséquence sur la façon d’organiser la configuration : dès qu’un nœud est différent des autres, une configuration par rôle ne suffit plus. Je détaille la solution dans la partie sur les patches.

Provisionnement avec OpenTofu

Les images : Talos Image Factory

Une image Talos ne peut pas être modifiée après coup. Pour ajouter qemu-guest-agent ou les modules NVIDIA, il faut une image qui les contient déjà, et c’est le rôle de l’Image Factory : on lui envoie une liste d’extensions, et elle renvoie un schematic ID, un hash qui identifie cette liste. Ce hash permet ensuite de construire l’URL de l’image pour n’importe quelle version de Talos.

SchematicExtensionsNœuds
ce4c9805...qemu-guest-agentcontrolplane-01, worker-01
a31d81ec...qemu-guest-agent, nvidia-open-gpu-kernel-modules, nvidia-container-toolkitgpu-01

L’image téléchargée est un disque brut compressé, nocloud-amd64.raw.xz. Elle change deux choses par rapport à mes déploiements précédents :

  • nocloud est la plateforme cible. Avec elle, Talos lit sa configuration réseau initiale sur le lecteur cloud-init fourni par Proxmox, ce qui rend le nœud joignable dès le premier démarrage, avant qu’il ait reçu sa machine config.
  • Il n’y a plus de template. Mes VMs Debian sont clonées depuis un template construit avec Packer, alors que les nœuds Talos importent directement cette image comme disque scsi0. Je n’ai donc plus de template à reconstruire pour ce cluster : pour changer de version de Talos, je modifie une variable.

Le téléchargement utilise un for_each sur les schematics réellement utilisés, ce qui évite de télécharger deux fois la même image quand plusieurs nœuds partagent le même schematic :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#image.tf
locals {
  talos_schematics = toset([
    for node in var.talos_nodes : coalesce(node.schematic_id, var.talos_image_factory_id)
  ])
}

resource "proxmox_download_file" "talos_image" {
  for_each = local.talos_schematics

  content_type            = "iso"
  datastore_id            = "local"
  file_name               = "talos-${var.talos_version}-${substr(each.key, 0, 8)}-nocloud-amd64.img"
  node_name               = var.proxmox_node
  url                     = "https://factory.talos.dev/image/${each.key}/v${var.talos_version}/nocloud-amd64.raw.xz"
  overwrite               = false
  decompression_algorithm = "zst"
}

L’image est téléchargée au format .raw.xz, alors que decompression_algorithm vaut zst. Le provider n’accepte que gz, lzo, zst et bz2, mais la décompression zst de Proxmox sait aussi traiter une archive xz. Ce comportement n’est pas documenté, je l’ai trouvé dans une discussion du provider bpg sur les images compressées en xz.

La topologie dans une variable

Toute la topologie du cluster est décrite dans une seule variable. Pour ajouter un nœud, il suffit d’ajouter un objet à cette liste :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#variables.tf
variable "talos_nodes" {
  type = list(object({
    hostname  = string
    ip        = string
    cores     = number
    memory    = number
    vm_id     = optional(number)
    disk_size = optional(number, 40)
    role      = optional(string, "controlplane")

    # Schematic Image Factory. `null` retombe sur var.talos_image_factory_id.
    schematic_id = optional(string)

    # Périphériques PCI passés au nœud (partie 2).
    hostpci = optional(list(object({
      device  = string
      mapping = string
      pcie    = optional(bool, true)
      rombar  = optional(bool, true)
      xvga    = optional(bool, false)
    })), [])
  }))

  validation {
    condition     = !contains([for node in var.talos_nodes : node.ip], var.talos_vip)
    error_message = "The Talos VIP must not collide with a node IP address."
  }
  ...
}

J’ai ajouté plus de validations que d’habitude sur cette variable, cinq au total :

  • role doit valoir controlplane ou worker
  • les hostnames doivent être uniques
  • les IPs doivent être uniques
  • il doit y avoir au moins un controlplane
  • la VIP ne doit pas correspondre à l’IP d’un nœud

Sans ces validations, chacune de ces erreurs apparaîtrait au milieu d’un apply, sous la forme d’une erreur d’API Proxmox peu explicite, avec une partie de l’infrastructure déjà créée. Avec elles, l’erreur est détectée au plan, avant toute modification. Une sixième validation arrive dans la partie 2, pour vérifier que chaque hostpci.mapping correspond bien à un mapping déclaré.

Les VMs

Une seule ressource crée les trois nœuds :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
#talos.tf
resource "proxmox_virtual_environment_vm" "talos" {
  depends_on = [
    proxmox_download_file.talos_image,
    proxmox_virtual_environment_pool.TALOS,
  ]
  for_each = { for node in var.talos_nodes : node.hostname => node }

  name      = each.key
  node_name = var.proxmox_node
  vm_id     = tonumber(split(".", each.value.ip)[3])
  machine   = "q35"
  ...

  bios = "ovmf"
  efi_disk {
    datastore_id = var.proxmox_datastore
    type         = "4m"
  }

  memory {
    dedicated = each.value.memory
    floating  = 0
  }

  disk {
    datastore_id = var.proxmox_datastore
    interface    = "scsi0"
    size         = each.value.disk_size
    file_id      = proxmox_download_file.talos_image[coalesce(each.value.schematic_id, var.talos_image_factory_id)].id
    discard      = "on"
    ssd          = true
    iothread     = true
    ...
  }

  initialization {
    datastore_id = var.proxmox_datastore
    ip_config {
      ipv4 {
        address = "${each.value.ip}/24"
        gateway = var.talos_gateway
      }
    }
    dns {
      servers = var.talos_nameservers
    }
  }
  ...
}
  • Il n’y a pas de bloc clone. Le disque système est l’image téléchargée plus haut, référencée par son file_id, et c’est ce qui remplace le template.
  • machine = "q35" et bios = "ovmf" avec un efi_disk. Les VMs démarrent en UEFI, et le firmware OVMF a besoin d’un disque EFI pour stocker ses variables.
  • Le vm_id est dérivé du dernier octet de l’IP. 192.168.1.123 donne la VM 123, ce qui me permet de faire directement le lien entre l’interface Proxmox et le réseau.
  • floating = 0 désactive le ballooning, ce qui est obligatoire pour le nœud GPU de la partie 2.
📝 Note

Le lecteur cloud-init d’un nœud Talos ne sert pas à configurer Talos. Il donne seulement une IP statique au nœud au premier démarrage, pour qu’il soit joignable en mode maintenance. Toute la configuration est ensuite appliquée par l’API Talos, via le provider siderolabs/talos.

Les VMs sont prêtes à démarrer, il reste maintenant à leur fournir leur configuration.

La configuration machine

Générer les secrets et la configuration de base

Le provider siderolabs/talos enchaîne six ressources, dans cet ordre :

1
2
3
4
5
6
talos_machine_secrets              # CA, clés et tokens du cluster
  └─> data.talos_client_configuration   # le talosconfig de talosctl
  └─> data.talos_machine_configuration  # une config de base, par rôle
        └─> talos_machine_configuration_apply  # appliquée par nœud, avec les patches
              └─> talos_machine_bootstrap      # init d'etcd, une seule fois
                    └─> talos_cluster_kubeconfig   # le kubeconfig admin

talos_machine_secrets génère tout le matériel cryptographique du cluster : les CA de Kubernetes, de Talos et d’etcd, ainsi que les tokens d’enrôlement des nœuds. Ces secrets ne sont écrits nulle part sur le disque, ils sont stockés dans le state OpenTofu et n’en sortent que par les outputs.

Le bootstrap et la récupération du kubeconfig n’ont pas de for_each, car ce sont des opérations qui concernent le cluster entier. Elles ciblent un seul control plane, le premier de la liste :

1
2
3
4
5
#talos.tf
locals {
  talos_controlplanes      = [for node in var.talos_nodes : node if node.role == "controlplane"]
  talos_first_controlplane = local.talos_controlplanes[0].ip
}

La ressource talos_machine_configuration_apply reçoit son argument node depuis la variable talos_nodes, et pas depuis un attribut de la VM. OpenTofu ne voit donc aucune dépendance entre les deux, et peut essayer d’appliquer la configuration à une IP qui ne répond pas encore. J’ai donc ajouté un depends_on explicite sur les VMs :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
#talos.tf
resource "talos_machine_configuration_apply" "this" {
  depends_on = [proxmox_virtual_environment_vm.talos]
  for_each   = { for node in var.talos_nodes : node.hostname => node }

  client_configuration        = talos_machine_secrets.this.client_configuration
  machine_configuration_input = data.talos_machine_configuration.this[each.value.role].machine_configuration
  node                        = each.value.ip

  config_patches = [
    for patch in local.talos_patch_files[each.key] :
    templatefile(patch, {
      hostname    = each.key
      node_ip     = each.value.ip
      role        = each.value.role
      vip         = var.talos_vip
      gateway     = var.talos_gateway
      nameservers = var.talos_nameservers
    })
  ]
}

Les patches, en trois niveaux

Une liste de patches par rôle ne suffit plus dès qu’un seul nœud a du matériel spécifique. J’ai donc organisé les patches en trois niveaux, du plus général au plus spécifique :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
tofu/patches/
├── common/                     # tous les nœuds
│   └── 40-kubelet.yaml.tftpl
├── controlplane/               # les control planes
│   ├── 10-network-vip.yaml.tftpl
│   ├── 20-cluster-cni-proxy.yaml.tftpl
│   └── 30-extra-manifests.yaml.tftpl
├── worker/                     # les workers
└── nodes/
    └── talos-gpu-01/           # ce nœud précis
        └── 30-nvidia-modules.yaml.tftpl

La liste des patches de chaque nœud est construite avec fileset :

1
2
3
4
5
6
7
8
9
#talos.tf
talos_patch_files = {
  for node in var.talos_nodes :
  node.hostname => concat(
    [for f in fileset("${path.module}/patches/common", "*.yaml.tftpl") : "${path.module}/patches/common/${f}"],
    [for f in fileset("${path.module}/patches/${node.role}", "*.yaml.tftpl") : "${path.module}/patches/${node.role}/${f}"],
    [for f in fileset("${path.module}/patches/nodes/${node.hostname}", "*.yaml.tftpl") : "${path.module}/patches/nodes/${node.hostname}/${f}"],
  )
}

Ce fonctionnement repose sur trois propriétés de fileset :

  • Les fichiers sont triés par ordre alphabétique, donc le préfixe numérique de chaque fichier fixe l’ordre de fusion. Pour une même clé, c’est le dernier patch qui l’emporte : le niveau nœud écrase le niveau rôle, qui écrase le niveau commun. Je laisse des trous dans la numérotation (10, 20, 30) pour pouvoir intercaler un patch plus tard.
  • Un répertoire absent renvoie un ensemble vide, sans erreur. Les trois niveaux sont donc facultatifs, ce qui est pratique puisque Git ne versionne pas les répertoires vides.
  • Ajouter un patch ne demande aucune modification du code. Il suffit de déposer un fichier dans le bon répertoire, talos.tf reste inchangé.

C’est le nœud GPU qui m’a amené à créer le niveau nodes/ : un patch NVIDIA placé sous worker/ serait aussi appliqué à talos-worker-01, dont l’image ne contient pas les extensions, et ce nœud échouerait à charger les modules à chaque démarrage.

La VIP

Talos gère la VIP lui-même, sans keepalived ni load balancer externe. Les control planes choisissent le nœud qui porte la VIP par une élection dans etcd : un seul la détient à un instant donné, et elle bascule automatiquement sur un autre control plane si ce nœud disparaît.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# patches/controlplane/10-network-vip.yaml.tftpl
machine:
  network:
    interfaces:
      - deviceSelector:
          physical: true
        dhcp: false
        addresses:
          - ${node_ip}/24
        routes:
          - network: 0.0.0.0/0
            gateway: ${gateway}
        vip:
          ip: ${vip}
    nameservers: ${jsonencode(nameservers)}

L’interface est redéclarée en entier, et pas seulement complétée avec une entrée vip. La machine config est prioritaire sur la configuration réseau fournie par la plateforme nocloud : un patch qui ne contiendrait que la VIP ferait disparaître l’adressage statique fourni par le lecteur cloud-init, et le nœud deviendrait injoignable.

cluster_endpoint pointe lui aussi sur la VIP, et pas sur l’IP d’un nœud. Cette valeur est inscrite dans la configuration de tous les nœuds et dans le kubeconfig : si elle désignait un nœud précis, la perte de ce nœud couperait l’accès à l’API, et l’ajout d’un control plane obligerait à reconfigurer tout le cluster pour changer l’endpoint.

CNI et kube-proxy désactivés

1
2
3
4
5
6
7
# patches/controlplane/20-cluster-cni-proxy.yaml.tftpl
cluster:
  network:
    cni:
      name: none
  proxy:
    disabled: true

Par défaut, Talos installe Flannel comme CNI et kube-proxy. Je les remplace tous les deux par Cilium, donc je les désactive.

J’ai choisi Cilium pour trois raisons : il remplace kube-proxy, il gère les L2 announcements, que j’utilise plus tard dans la série pour annoncer les IP des services LoadBalancer sur mon réseau local, et le tout repose sur eBPF, une technologie sur laquelle je veux m’appuyer pour les évolutions de mon homelab.

Ce choix a deux conséquences :

  • les nœuds restent dans l’état NotReady tant qu’aucun CNI n’est installé, ce qui est normal
  • sans kube-proxy, le CNI doit joindre l’API server directement, car la ClusterIP du service kubernetes.default n’est pas encore routable au moment où il démarre. C’est le rôle de KubePrism, que je présente dans la partie sur Cilium.

Ce patch est placé sur les control planes car ces manifests sont générés au bootstrap, depuis un control plane. Comme tout ce qui est sous cluster.*, il n’a d’effet qu’à ce moment-là : appliqué à un cluster déjà démarré, il ne supprime rien.

Certificats serving du kubelet

Par défaut, le kubelet expose son endpoint :10250 avec un certificat auto-signé qu’aucune CA du cluster ne reconnaît. D’où l’option --kubelet-insecure-tls présente dans la plupart des installations de metrics-server : le client ne vérifie pas le certificat du serveur, donc n’importe quelle machine capable de s’intercaler sur le réseau peut se faire passer pour un kubelet.

La première partie de la solution se trouve côté nœud :

1
2
3
4
5
# patches/common/40-kubelet.yaml.tftpl
machine:
  kubelet:
    extraArgs:
      rotate-server-certificates: true

Avec cette option, le kubelet ne génère plus de certificat auto-signé et envoie une CSR à l’API server, avec le signer kubernetes.io/kubelet-serving. Le problème est que Kubernetes n’approuve jamais automatiquement les CSR de ce signer. Le kube-controller-manager sait signer le certificat, mais aucun composant du cluster ne l’approuve : les CSR restent en Pending et le kubelet garde son certificat auto-signé. C’est un choix volontaire du projet Kubernetes, qui laisse cette décision à chaque environnement.

La seconde partie se trouve donc côté cluster :

1
2
3
4
5
# patches/controlplane/30-extra-manifests.yaml.tftpl
cluster:
  extraManifests:
    - https://raw.githubusercontent.com/alex1989hu/kubelet-serving-cert-approver/v0.11.0/deploy/standalone-install.yaml
    - https://github.com/kubernetes-sigs/metrics-server/releases/download/v0.9.0/components.yaml

kubelet-serving-cert-approver est un contrôleur qui approuve ces CSR. Il ne fait que les approuver : la signature reste faite par le kube-controller-manager, avec la clé de la CA du cluster. Avant d’approuver une demande, il vérifie qu’elle correspond bien à celle d’un kubelet :

  • l’organisation du sujet est system:nodes
  • le CN commence par system:node: et correspond exactement à l’utilisateur qui a soumis la CSR
  • les usages demandés sont ceux d’un certificat serveur
  • la demande ne contient pas de SAN de type email ou URI

En revanche, il ne vérifie pas que les adresses IP demandées dans les SAN appartiennent réellement au nœud, puisque son ClusterRole ne lui donne aucun accès aux objets Node. Un nœud déjà enrôlé dans le cluster peut donc obtenir un certificat valide pour l’IP d’un autre nœud.

Le gain de sécurité reste réel, mais il est limité : avant, n’importe quelle machine sur le réseau pouvait se faire passer pour un kubelet, alors qu’il faut maintenant disposer de l’identité d’un nœud du cluster. Dans mon cas, metrics-server vérifie désormais les certificats des kubelets avec la CA du cluster, sans --kubelet-insecure-tls.

Ces deux manifests doivent rester déployés en permanence, car la rotation des certificats est continue et chaque renouvellement produit une nouvelle CSR à approuver. Les URLs sont fixées sur une version précise, pour qu’un re-apply ne récupère pas un contenu différent et pour que Renovate puisse suivre les mises à jour.

La configuration est complète, je peux passer au déploiement.

Déploiement

Je ne lance pas d’apply en local. Le déploiement passe par un pipeline GitLab CI qui utilise le composant OpenTofu de GitLab :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#.gitlab-ci.yml
default:
  tags:
    - homelab

variables:
  TF_VAR_proxmox_endpoint: '${proxmox_endpoint}'
  TF_VAR_proxmox_api_token: '${proxmox_api_token}'
  TF_VAR_proxmox_ssh_private_key: '${proxmox_ssh_private_key}'

include:
  # renovate: datasource=gitlab-releases packageName=components/opentofu registryUrl=https://gitlab.com
  - component: $CI_SERVER_FQDN/components/opentofu/validate-plan-apply@4.5.0
    inputs:
      # renovate: datasource=gitlab-releases packageName=components/opentofu registryUrl=https://gitlab.com
      version: 4.5.0
      opentofu_version: 1.11.5
      root_dir: tofu/

stages: [validate, build, deploy]

workflow:
  rules:
    - changes:
        - tofu/**
    - when: never

Le template validate-plan-apply ajoute quatre jobs : fmt et validate dans le stage validate, plan dans build, puis apply dans deploy. Par défaut, apply n’existe que sur la branche principale et doit être lancé à la main : une merge request s’arrête donc au plan, et rien n’est appliqué tant que je n’ai pas déclenché le job.

Le reste de la configuration tient en cinq points :

  • Les jobs tournent sur mon propre runner, grâce au tag homelab : un runner GitLab avec l’executor Docker, hébergé sur mon Proxmox. Il est sur le même réseau que l’API Proxmox, que les runners partagés de GitLab ne pourraient pas joindre.
  • Le state est géré par GitLab. Le bloc backend "http" {} de providers.tf est vide, et c’est le composant qui renseigne l’adresse du state du projet et les identifiants du job au moment de l’exécution.
  • Les secrets restent dans GitLab. Le token de l’API Proxmox et la clé SSH sont des variables CI/CD, transmises à OpenTofu par les variables TF_VAR_*.
  • Le pipeline ne se déclenche que si tofu/ change, grâce à la règle workflow.
  • Les versions sont suivies par Renovate, grâce aux commentaires # renovate: placés au-dessus du composant.

Récupérer les accès

Les accès au cluster ne sont écrits nulle part sur le disque, ils sont dans le state, et il faut donc les extraire avec les outputs. Comme le state est stocké dans GitLab, je fais un tofu init local une seule fois, uniquement pour cette étape. GitLab fournit la commande d’init complète dans Operate > Terraform states, avec l’action Copy Terraform init command. Je récupère ensuite les deux fichiers :

1
2
tofu output -raw talosconfig > ../talos/config
tofu output -raw kubeconfig  > ../talos/kubeconfig

Le talosconfig généré par data.talos_client_configuration contient deux listes : les endpoints, auxquels talosctl se connecte réellement et qui ne contiennent que des control planes, et les nodes, qui sont les cibles par défaut des commandes et qui sont jointes en passant par un endpoint. C’est ce qui permet d’interroger un worker sans s’y connecter directement.

Par exemple, pour lister les services de talos-worker-01 :

1
talosctl --talosconfig talos/config -e 192.168.1.121 -n 192.168.1.122 services

talosctl ouvre la connexion vers le control plane 192.168.1.121, qui relaie la requête jusqu’au worker 192.168.1.122. Sans -e, ce sont les endpoints du talosconfig qui sont utilisés, et -n suffit pour changer de cible.

Schéma de talosctl qui se connecte au control plane 192.168.1.121 comme endpoint, qui relaie la requête vers le worker 192.168.1.122 désigné par -n Schéma de talosctl qui se connecte au control plane 192.168.1.121 comme endpoint, qui relaie la requête vers le worker 192.168.1.122 désigné par -n

Endpoint et node avec talosctl

Installer Cilium

Le cluster démarre sans CNI, les nœuds sont donc NotReady. J’installe Cilium avec Helm, en remplacement de kube-proxy :

1
2
3
4
5
6
helm repo add cilium https://helm.cilium.io/
helm repo update
helm install cilium cilium/cilium \
  --version 1.20.2 \
  --namespace kube-system \
  --values cilium-values.yaml
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#cilium-values.yaml
kubeProxyReplacement: true
k8sServiceHost: localhost
k8sServicePort: 7445

# Talos monte lui-même le cgroup2 et interdit à l'agent de le remonter.
cgroup:
  autoMount:
    enabled: false
  hostRoot: /sys/fs/cgroup

securityContext:
  capabilities:
    ciliumAgent:
      - CHOWN
      - KILL
      - NET_ADMIN
      - NET_RAW
      - IPC_LOCK
      - SYS_ADMIN
      - SYS_RESOURCE
      - DAC_OVERRIDE
      - FOWNER
      - SETGID
      - SETUID
    cleanCiliumState:
      - NET_ADMIN
      - SYS_ADMIN
      - SYS_RESOURCE

Les valeurs cgroup et securityContext sont spécifiques à Talos. Par défaut, le chart monte lui-même le cgroup2 et lance l’agent en mode privilégié, ce que Talos ne permet pas. J’ai repris la liste de capabilities de la documentation Talos pour Cilium plutôt que de la reconstruire.

Il reste le couple k8sServiceHost / k8sServicePort. On pourrait pointer Cilium sur la VIP en 6443, puisque c’est l’endpoint du cluster, mais ce n’est pas ce que recommande Talos. Cilium doit joindre l’API server avant que le réseau des pods existe, et la VIP dépend de l’élection etcd entre les control planes.

KubePrism répond à ce problème : c’est un load balancer local présent sur chaque nœud, qui écoute sur localhost:7445. Il choisit automatiquement parmi les endpoints disponibles (l’endpoint du cluster et chaque control plane), en écartant ceux qui ne répondent pas et en privilégiant les plus rapides. Il est activé par défaut, et la documentation Talos recommande de le donner comme endpoint aux CNI qui ont besoin de joindre l’API server, comme Cilium.

Schéma de l'accès à l'API : sur chaque nœud, l'agent Cilium passe par KubePrism sur localhost:7445, qui joint le kube-apiserver directement ou via la VIP, alors que kubectl passe par la VIP portée par élection etcd Schéma de l'accès à l'API : sur chaque nœud, l'agent Cilium passe par KubePrism sur localhost:7445, qui joint le kube-apiserver directement ou via la VIP, alors que kubectl passe par la VIP portée par élection etcd

Accès à l'API Kubernetes

Vérification

Une fois Cilium installé, je vérifie l’état du cluster côté Talos avec talosctl health. J’ai retiré de la sortie les lignes de progression, pour ne garder que le résultat de chaque vérification :

1
talosctl --talosconfig talos/config -n 192.168.1.121 health
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
discovered nodes: ["192.168.1.120" "192.168.1.123" "192.168.1.122"]
waiting for etcd to be healthy: OK
waiting for etcd members to be consistent across nodes: OK
waiting for etcd members to be control plane nodes: OK
waiting for apid to be ready: OK
waiting for all nodes memory sizes: OK
waiting for all nodes disk sizes: OK
waiting for no diagnostics: OK
waiting for kubelet to be healthy: OK
waiting for all nodes to finish boot sequence: OK
waiting for all k8s nodes to report: OK
waiting for all control plane static pods to be running: OK
waiting for all control plane components to be ready: OK
waiting for all k8s nodes to report ready: SKIP
waiting for kube-proxy to report ready: SKIP
waiting for coredns to report ready: SKIP
waiting for all k8s nodes to report schedulable: OK

La première adresse listée est la VIP et non 192.168.1.121 : le control plane qui la détient l’annonce parmi ses adresses, et elle est triée en premier.

Les trois SKIP viennent des choix faits dans les patches. talosctl health ne vérifie pas l’état Ready des nœuds ni CoreDNS quand le CNI est déclaré à none, puisque ces vérifications dépendent d’un CNI que Talos n’a pas installé lui-même, et il ignore kube-proxy quand son DaemonSet n’existe pas. L’état des nœuds se vérifie donc côté Kubernetes :

1
kubectl get nodes
1
2
3
4
NAME                    STATUS   ROLES           AGE   VERSION
talos-controlplane-01   Ready    control-plane   47d   v1.36.2
talos-gpu-01            Ready    <none>          47d   v1.36.2
talos-worker-01         Ready    <none>          47d   v1.36.2

Les trois nœuds sont Ready, ce qui confirme que Cilium fonctionne sur chacun d’eux.

Pièges rencontrés

La courbe d’apprentissage de Talos

La prise en main de Talos m’a demandé du temps. Sans shell ni SSH, aucun de mes réflexes habituels pour diagnostiquer un nœud ne s’applique, et il faut passer par talosctl pour tout ce qui se faisait avant directement sur la machine. Il faut donc apprendre une nouvelle CLI, de nouveaux concepts et un nouveau nommage.

Un premier déploiement sans le provider Talos

Mon premier déploiement n’utilisait pas le provider siderolabs/talos : OpenTofu créait les VMs, et je générais puis appliquais les configurations à la main avec talosctl. Le passage au provider a ramené toute la configuration des nœuds as code, avec le reste de l’infrastructure, et permet donc d’avoir un déploiement de bout en bout en un seul run OpenTofu.

Un seul schematic pour tous les nœuds

Au départ, tous les nœuds utilisaient le même schematic, celui qui contient les extensions NVIDIA. Les nœuds sans GPU remontaient alors des erreurs liées aux pilotes NVIDIA, puisqu’ils embarquaient des modules prévus pour une carte qu’ils n’ont pas. C’est ce qui m’a amené à associer à chaque nœud un schematic qui ne contient que les extensions dont il a besoin.

Conclusion

Le cluster est opérationnel, et par rapport à Kubespray, je n’ai plus d’OS à administrer : pas de mises à jour de paquets, pas de SSH ouvert, et la configuration des nœuds ne peut pas s’écarter de ce que décrit le code. Une mise à jour de Talos se résume à changer une version dans une variable, et chaque nœud bascule sur la nouvelle image.

Schéma du déploiement : merge sur main, pipeline GitLab CI (validate, plan, apply manuel), tofu apply qui crée les VMs Proxmox et configure Talos jusqu'au bootstrap d'etcd, puis récupération des accès, installation de Cilium et nœuds Ready Schéma du déploiement : merge sur main, pipeline GitLab CI (validate, plan, apply manuel), tofu apply qui crée les VMs Proxmox et configure Talos jusqu'au bootstrap d'etcd, puis récupération des accès, installation de Cilium et nœuds Ready

Résumé du workflow de déploiement

Une fois tout en place, j’ai surtout été surpris par la facilité et la rapidité avec lesquelles un cluster Kubernetes se monte. Là où Kubespray enchaînait près de 600 tâches Ansible, un seul run du pipeline crée les VMs, applique les configurations et bootstrappe le cluster, et il ne reste plus qu’à installer Cilium.

Le nœud talos-gpu-01 tourne déjà, avec ses 12 GB de RAM réservés et son image qui contient les modules NVIDIA, mais il ne voit encore aucune carte graphique. Faire passer le GPU de l’hôte Proxmox jusqu’aux pods est le sujet de la partie 2.

Liens utiles

Généré avec Hugo
Thème Stack conçu par Jimmy