↓ Aller au contenu
Mon homelab de zéro (Partie 12) : Talos et Kubernetes, le retour de l'œuf et de la poule
Logo Talos Linux
  1. Articles/

Mon homelab de zéro (Partie 12) : Talos et Kubernetes, le retour de l'œuf et de la poule

·4374 mots·21 mins·
Sommaire
Mon homelab de zéro - Cet article fait partie d'une série.
Partie 12: Cet article

C’est la phase la plus ambitieuse de toute la série : monter un cluster Kubernetes complet et utilisable : Talos comme OS, Cilium pour le réseau, Longhorn pour le stockage, ArgoCD pour le GitOps, le tout entièrement décrit as code. On part du cluster Proxmox, des images Talos de la partie 10, des services résilients de la partie 11 pour arriver à trois nœuds Ready, un stockage persistant testé et une boucle GitOps opérationnelle.

Et comme c’est aussi la phase où l’on bascule enfin sur le pipeline (le premier apply déclenché depuis GitLab, comme promis), autant dire que ça bouge.

Le fil rouge, cette fois, c’est l’ordre de bootstrap. Presque chaque obstacle de la phase vient du même endroit : une ressource dépend de quelque chose qui n’existe pas encore au moment où on la déclare. La CRD qu’ArgoCD n’a pas encore créée, le CNI qui doit venir après le cluster mais avant tout le reste, les extensions Talos qui exigent un upgrade après le téléchargement de l’image. L’infrastructure-as-code déclaratif suppose qu’on puisse décrire l’état final mais certaines dépendances imposent malgré tout une séquence.

Trois décisions avant de commencer
#

Pas de HA Proxmox sur les VM Talos
#

Contrairement au runner et à Dokploy de la partie 11, les VM Talos ne sont ni répliquées ni déclarées en HA. Ça peut surprendre : on vient justement de bâtir tout un mécanisme de résilience. Mais ici, il serait non seulement inutile, mais dangereux :

  1. Le HA Proxmox redémarre une VM depuis le dernier snapshot répliqué, avec jusqu’à 15 minutes de retard. Réintroduire un membre etcd avec une vision périmée du cluster risque de corrompre le quorum.
  2. Kubernetes fait déjà ce travail : un nœud perdu, les deux membres etcd restants gardent le quorum et les pods sont replanifiés.
  3. Empiler deux mécanismes de résilience qui ne se parlent pas, c’est prendre le risque que le plus bas (Proxmox) casse le plus haut (Kubernetes).

La conséquence est libératrice : les VM Talos sont jetables. Une VM perdue est simplement recréée depuis OpenTofu et rejoint le cluster. C’est la promesse même de Talos : un OS immuable et déclaratif.

On bascule sur le pipeline
#

C’est effectif : le premier apply réel de cette phase est déclenché depuis GitLab, comme annoncé en fin de partie 11. Le cycle mis en place en partie 10 va enfin jusqu’au bout : branche → MR → lecture du plan → merge → clic manuel sur apply. On applique depuis la CI, plus depuis mon poste.

On reste dans le stack core
#

J’ai hésité à créer un stack OpenTofu séparé pour Talos. Finalement non : ça aurait imposé de faire transiter les valeurs de core vers talos (outputs + data sources), pour un bénéfice nul. La séparation de stacks se justifie quand les cycles de vie divergent, ou quand plusieurs personnes travaillent en parallèle et aucun des deux n’est le cas ici.

Étape 1 : les VM Talos
#

Trois VM, une par nœud physique (un membre etcd par machine). La liste est générée depuis cluster_nodes :

# tofu/stacks/core/talos_vms.tf
locals {
  talos_nodes = {
    for idx, node in var.cluster_nodes : "talos0${idx + 1}" => {
      pve_node = node
      vm_id    = var.talos_vm_id_base + idx
      ip       = cidrhost(var.talos_subnet, var.talos_ip_offset + idx)
    }
  }
}

4 vCPU, 8 Go de RAM, 60 Go de disque, IP .52 à .54, image importée depuis le nœud local (le stockage local n’étant pas partagé, d’où la duplication des images sur les trois nœuds en partie 10).

note

Un piège de version, dès le provider Talos. La dernière version publiée sur GitHub était v0.12.0-alpha.5. Or les alphas ne sont pas marquées comme pré-versions sur l’API GitHub : un filtre select(.prerelease == false) les laisse donc passer, et on épingle une alpha sans le vouloir. La dernière vraie stable était v0.11.0. Vérifier, ne pas se fier au premier résultat de l’API.

Étape 2 : configuration machine et VIP
#

C’est ici que Talos prend tout son sens. La configuration du cluster tient en cinq ressources du provider Talos, qui ne peuvent exister que dans cet ordre, chacune dépendant de la précédente :

  1. talos_machine_secrets : génère la PKI et les tokens du cluster. Tout le reste en dépend.
  2. talos_machine_configuration : produit la configuration machine commune aux trois nœuds, à partir de ces secrets.
  3. talos_machine_configuration_apply : pousse cette configuration sur chaque nœud, avec son patch propre (IP, réseau).
  4. talos_machine_bootstrap : initialise etcd sur un seul nœud. Les deux autres rejoignent.
  5. talos_cluster_kubeconfig : récupère les identifiants pour parler au cluster.

Première illustration du fil rouge : pas de configuration sans secrets, pas de bootstrap sans configuration appliquée, pas de kubeconfig sans cluster bootstrappé. Voici d’abord la configuration commune :

# tofu/stacks/core/talos_config.tf (configuration commune)
resource "talos_machine_secrets" "this" {}

data "talos_machine_configuration" "controlplane" {
  cluster_name     = var.talos_cluster_name
  cluster_endpoint = "https://${var.talos_vip}:6443"
  machine_type     = "controlplane"
  machine_secrets  = talos_machine_secrets.this.machine_secrets

  config_patches = [
    yamlencode({
      machine = {
        install = {
          disk = "/dev/sda"
        }
        # ... (disque Longhorn : voir l'étape 4)
      }
      cluster = {
        network = {
          cni = { name = "none" }
        }
        proxy = { disabled = true }
      }
    }),
  ]
}

Puis l’application nœud par nœud, le bootstrap et la récupération des identifiants :

# tofu/stacks/core/talos_config.tf (patch par nœud, bootstrap, credentials)
resource "talos_machine_configuration_apply" "nodes" {
  for_each = local.talos_nodes

  client_configuration        = talos_machine_secrets.this.client_configuration
  machine_configuration_input = data.talos_machine_configuration.controlplane.machine_configuration
  node                        = each.value.ip

  config_patches = [
    yamlencode({
      machine = {
        network = {
          interfaces = [{
            interface = "eth0"
            dhcp      = false
            addresses = ["${each.value.ip}/24"]
            routes    = [{ network = "0.0.0.0/0", gateway = var.runner_gateway }]
            vip       = { ip = var.talos_vip }
          }]
          nameservers = var.lab_dns_servers
        }
      }
    }),
  ]

  depends_on = [proxmox_virtual_environment_vm.talos]
}

resource "talos_machine_bootstrap" "this" {
  client_configuration = talos_machine_secrets.this.client_configuration
  node                 = local.talos_nodes["talos01"].ip

  depends_on = [talos_machine_configuration_apply.nodes]
}

resource "talos_cluster_kubeconfig" "this" {
  client_configuration = talos_machine_secrets.this.client_configuration
  node                 = local.talos_nodes["talos01"].ip

  depends_on = [talos_machine_bootstrap.this]
}

Ce qu’il faut retenir de ces deux blocs :

  • cni = { name = "none" } + proxy = { disabled = true } (configuration commune) : par défaut, Talos installerait flannel et kube-proxy. On désactive les deux, parce que Cilium va les remplacer. Conséquence assumée : les nœuds restent NotReady jusqu’à l’installation du CNI (étape 3).
  • La VIP .55 (pour Virtual IP, une adresse IP virtuelle) apparaît deux fois. Dans la configuration commune, c’est le cluster_endpoint : l’adresse par laquelle tout le monde joint l’API Kubernetes, Cilium compris (on le verra à l’étape 3). Dans le patch par nœud, elle est déclarée sur eth0 de chaque control-plane, et Talos gère l’élection : un seul nœud la porte à la fois, et elle migre automatiquement en cas de panne.
  • Un seul nœud bootstrappé (talos01). On ne bootstrappe jamais plus d’un nœud, sous peine de créer deux clusters concurrents. Et ce sont les depends_on en cascade qui verrouillent l’ordre des cinq étapes : sans eux, OpenTofu pourrait lancer le bootstrap avant que la configuration soit appliquée.
  • Les secrets vivent dans le state, chiffré côté client. C’est exactement le cas d’usage qui justifiait le choix d’OpenTofu dès la partie 5 : la PKI complète du cluster dans un state, illisible sans la passphrase.

Accroc 1 : Talos fusionne les patches, il ne les remplace pas
#

* [networking.os.device.addresses] "eth0": invalid CIDR address: PLACEHOLDER/24

Erreur de conception de ma part : je pensais que le patch par nœud remplacerait la liste d’interfaces du patch de base. En réalité, Talos fusionne les deux, et mon placeholder du patch de base survivait à la fusion. Le correctif est une règle simple : la configuration réseau ne doit exister qu’à un seul endroit (le patch par nœud). C’est pour ça que, dans le bloc plus haut, la configuration commune ne contient aucune section network.

Accroc 2 : le hostname vient déjà de cloud-init
#

* static hostname is already set in v1alpha1 config

Proxmox génère automatiquement des métadonnées cloud-init contenant le nom de la VM comme hostname. Talos, en mode nocloud, les lit et positionne déjà talos01… pendant que mon patch tentait de le refaire. Le correctif est le même que pour l’accroc précédent : retirer le hostname de mon patch et laisser cloud-init le poser. Deux fois de suite, la même règle : une information à un seul endroit.

note

Cette erreur est en réalité une bonne nouvelle. Elle prouve que le canal cloud-init fonctionne de bout en bout, ce qui explique aussi pourquoi les VM répondaient déjà aux bonnes IP dès le mode maintenance. Un “conflit” qui confirme qu’un mécanisme marche vaut mieux qu’un silence qui laisse dans le doute.

Résultat : un boot en 27 secondes
#

service[etcd](Running): Health check successful
rendered new static pod {"id": "kube-apiserver"}
enabled shared IP {"operator": "vip", "ip": "192.168.3.55"}
assigned address {"address": "192.168.3.55/32", "link": "eth0"}
sent gratuitous ARP
boot sequence: done: 27.301258027s

La VIP fonctionne : talos01 la porte et l’annonce par ARP. Le cluster est debout, mais pas encore utilisable : sans CNI, les nœuds restent NotReady. C’est l’objet de l’étape suivante.

note

Un 401 qui est la bonne réponse. Un curl sur la VIP renvoie 401 Unauthorized, et c’est exactement ce qu’on veut voir. L’API a répondu, elle a juste refusé une requête sans certificat client. Une VIP non fonctionnelle donnerait un timeout, pas un 401. En sécurité comme en réseau, savoir lire un code d’erreur “positif” évite bien des fausses pistes.

Étape 3 : Cilium
#

Les nœuds sont NotReady, il leur faut un CNI (Container Network Interface). Sur Talos, Cilium demande quelques réglages spécifiques :

# tofu/stacks/core/cilium.tf
provider "helm" {
  kubernetes {
    host                   = talos_cluster_kubeconfig.this.kubernetes_client_configuration.host
    client_certificate     = base64decode(talos_cluster_kubeconfig.this.kubernetes_client_configuration.client_certificate)
    client_key             = base64decode(talos_cluster_kubeconfig.this.kubernetes_client_configuration.client_key)
    cluster_ca_certificate = base64decode(talos_cluster_kubeconfig.this.kubernetes_client_configuration.ca_certificate)
  }
}

resource "helm_release" "cilium" {
  name       = "cilium"
  repository = "https://helm.cilium.io"
  chart      = "cilium"
  version    = var.cilium_version
  namespace  = "kube-system"

  values = [yamlencode({
    ipam = { mode = "kubernetes" }

    kubeProxyReplacement = true
    k8sServiceHost       = var.talos_vip
    k8sServicePort       = 6443

    securityContext = {
      capabilities = {
        ciliumAgent      = ["CHOWN", "KILL", "NET_ADMIN", "NET_RAW", "IPC_LOCK", "SYS_ADMIN", "SYS_RESOURCE", "PERFMON", "BPF", "DAC_OVERRIDE", "FOWNER", "SETGID", "SETUID"]
        cleanCiliumState = ["NET_ADMIN", "SYS_ADMIN", "SYS_RESOURCE"]
      }
    }

    cgroup = {
      autoMount = { enabled = false }
      hostRoot  = "/sys/fs/cgroup"
    }

    hubble = {
      enabled = true
      relay   = { enabled = true }
      ui      = { enabled = true }
    }

  })]

  depends_on = [talos_machine_bootstrap.this]
}

Avant même les valeurs Helm, notez le provider : il est configuré à partir du kubeconfig produit précédemment. Le fil rouge continue, mais d’un cran plus haut : ce n’est plus une ressource qui dépend d’une autre, c’est un provider qui dépend d’une ressource créée dans le même dépôt.

Les réglages qui comptent :

  • kubeProxyReplacement = true + k8sServiceHost pointant vers var.talos_vip : puisqu’il n’y a plus de kube-proxy pour router le service kubernetes, Cilium doit joindre l’API directement, via la VIP de l’étape 2. La boucle est bouclée.
  • securityContext.capabilities : Talos n’accorde aucun privilège implicite, il faut énumérer les capacités dont l’agent a besoin. C’est verbeux, mais c’est exactement ce qu’on attend d’un OS qui verrouille tout par défaut.
  • cgroup.autoMount.enabled = false : Talos a déjà monté le cgroup, et de toute façon Cilium ne pourrait pas le faire lui-même sur un système de fichiers en lecture seule.
  • Hubble activé (relay et ui) : l’observabilité réseau de Cilium, qui servira dans la partie suivante.

Accroc : Talos taint les control-planes par défaut
#

Le cluster paraît sain… mais hubble-relay et hubble-ui restent obstinément en Pending :

0/3 nodes are available: 3 node(s) had untolerated taint(s)
Taints: node-role.kubernetes.io/control-plane:NoSchedule

Les pods qui fonctionnaient (cilium, coredns) le faisaient grâce à des tolérations explicites. Hubble, lui, n’en a pas. Or j’ai prévu des nœuds combinés (control-plane + worker sur les mêmes machines) : c’est tout l’intérêt d’un cluster à 3 nœuds sur 3 machines. Il manquait un réglage dans la configuration Talos :

# tofu/stacks/core/talos_config.tf (ajout à la configuration commune)
cluster = {
  allowSchedulingOnControlPlanes = true
  # ... (network et proxy inchangés)
}

Et comme toujours avec Talos, modifier la configuration commune ne suffit pas : il faut la réappliquer aux trois nœuds, donc repasser par talos_machine_configuration_apply. Chaque correctif de configuration refait le chemin complet.

note

Talos applique par défaut la séparation control-plane / worker. Un cluster “combiné” n’est pas le comportement natif : il faut le demander explicitement. Et le symptôme est discret : le cluster paraît en bonne santé, seuls certains pods restent bloqués. La contrepartie, à assumer : les charges applicatives partagent désormais les nœuds avec etcd et l’apiserver, donc un pod gourmand peut affecter le control plane. Réflexe à prendre : poser des resources.limits sur ce qu’on déploie.

Une fois la configuration réappliquée, hubble-relay et hubble-ui trouvent enfin où se poser, et les trois nœuds basculent en Ready :

NAME      STATUS   ROLES           AGE   VERSION
talos01   Ready    control-plane   50d   v1.36.0
talos02   Ready    control-plane   50d   v1.36.0
talos03   Ready    control-plane   50d   v1.36.0

Sortie prise bien plus tard, d’où les 50 jours d’ancienneté : le cluster tourne sans broncher depuis. Notez que les trois nœuds portent uniquement le rôle control-plane : il n’y a pas de rôle worker à afficher, c’est précisément le principe des nœuds combinés qu’on vient de débloquer.

Le cluster est cette fois réellement utilisable : on peut lui confier du stockage.

Étape 4 : préparer le terrain pour Longhorn
#

Un mot d’abord sur le pourquoi. Dans Kubernetes, un pod est jetable : il peut être tué, recréé, replanifié sur un autre nœud à tout moment, et tout ce qu’il avait écrit sur son disque local part avec lui. Très bien pour un service sans état, mais dès qu’une application doit garder quelque chose (une base de données, des fichiers déposés par les utilisateurs, la configuration d’un outil), il lui faut un volume qui survit au pod et qui le suit quand il change de machine.

C’est le rôle d’une StorageClass et d’un pilote CSI. N’ayant pas de NAS (pour l’instant), je choisis Longhorn : il transforme l’espace disque des nœuds eux-mêmes en stockage bloc distribué, réplique chaque volume sur plusieurs machines et le rattache automatiquement là où le pod atterrit. Le stockage vit donc dans le cluster, sans dépendance extérieure.

Reste qu’il a besoin de trois choses absentes du cluster : l’extension iscsi-tools (attachement des volumes par iSCSI), l’extension util-linux-tools, et un chemin inscriptible (le root filesystem de Talos est en lecture seule).

Un nouveau schematic Talos
#

Les extensions se demandent à l’Image Factory, comme en partie 10, mais avec la liste enrichie :

curl -sX POST --data-binary @- https://factory.talos.dev/schematics << 'EOF'
customization:
  systemExtensions:
    officialExtensions:
      - siderolabs/qemu-guest-agent
      - siderolabs/iscsi-tools
      - siderolabs/util-linux-tools
EOF
# -> {"id":"53513e54bb39202f35694412577a6bc53d484744d35a126e5d42ef34785c0d83"}

Nouvel identifiant, puisque la liste d’extensions a changé : c’est tout l’intérêt d’un hash déterministe, une configuration différente donne forcément un id différent.

Le disque dédié à Longhorn, lui, est un second disque de 50 Go par VM (scsi1 → /dev/sdb), partitionné et monté par Talos :

# tofu/stacks/core/talos_config.tf (ajout à la configuration commune, section machine)
disks = [{
  device = "/dev/sdb"
  partitions = [{
    mountpoint = "/var/lib/longhorn"
  }]
}]
kubelet = {
  extraMounts = [{
    destination = "/var/lib/longhorn"
    type        = "bind"
    source      = "/var/lib/longhorn"
    options     = ["bind", "rshared", "rw"]
  }]
}

Choix : un disque dédié plutôt que le partage du disque système : ça isole les données et permet de le redimensionner sans toucher à l’OS.

Accroc majeur : OpenTofu détruit avant de recréer
#

Changer de schematic change l’URL de l’image → OpenTofu marque les images “must be replaced”. Sauf que le nouveau téléchargement a échoué en timeout (4,2 Go × 3 nœuds, et la Factory génère l’image à la première demande) :

timeout while waiting for task ... to complete, and the content type 'import'
is not supported by the Proxmox VE version 8.0.0

(Le message est trompeur : la mention “Proxmox VE 8.0.0” est une erreur secondaire du provider. La vraie cause, c’est le timeout.) Résultat : les images ont été supprimées des trois nœuds sans être remplacées. Le cluster tournait encore (les VM avaient déjà leur disque), mais les images sources n’existaient plus.

note

OpenTofu détruit avant de recréer. C’est le comportement par défaut, et il est universel : si la création échoue, on se retrouve avec rien au lieu de l’ancienne version. Le remède est standard, et il tient en deux morceaux indissociables :

# Le nom porte le schematic : deux variantes peuvent coexister
file_name = "talos-${var.talos_version}-${substr(var.talos_schematic_id, 0, 8)}-nocloud-amd64.raw"

upload_timeout = 3600

lifecycle {
  create_before_destroy = true
}

create_before_destroy seul ne suffit pas : deux fichiers de même nom ne peuvent pas coexister. Il faut aussi un nom versionné (ici, par le schematic). L’un ne fonctionne pas sans l’autre.

Si ces lignes vous disent quelque chose, c’est normal : la ressource Talos montrée en partie 10 les contient déjà, parce que je vous y ai présenté le code dans son état final. Elles sont nées ici, de cet incident.

L’upgrade Talos, volontairement hors du code
#

Point crucial et contre-intuitif : import_from ne s’applique qu’à la création. Les VM existantes gardent le disque système construit depuis l’ancienne image. Télécharger la nouvelle image ne suffit donc pas : il faut mettre à jour les nœuds un par un.

talosctl -n 192.168.3.54 upgrade \
  --image factory.talos.dev/nocloud-installer/53513e54bb39202f35694412577a6bc53d484744d35a126e5d42ef34785c0d83:v1.13.7
note

Certaines opérations se documentent, elles ne s’automatisent pas. L’upgrade Talos est un processus long (drain, reboot, retour dans le cluster) que le provider gère mal. Le faire nœud par nœud permet de vérifier entre chaque, exactement comme le flash BIOS de la partie 7. Règle de sécurité absolue : un seul nœud à la fois. Avec 3 membres etcd, en perdre un est sans danger ; deux simultanément casserait le quorum.

Résultat par nœud : 1 min 27, avec node drained puis node uncordoned automatiques : Talos coordonne son upgrade avec Kubernetes. Et détail élégant, le schematic est ensuite exposé comme une extension : on voit d’un coup d’œil quelle configuration tourne sur chaque nœud.

NAME               VERSION
qemu-guest-agent   11.0.2
iscsi-tools        v0.2.0
util-linux-tools   2.42.2
schematic          53513e54bb39202f35694412577a6bc53d484744d35a126e5d42ef34785c0d83

Étape 5 : Longhorn
#

Place au stockage persistant lui-même :

# tofu/stacks/core/longhorn.tf
resource "kubernetes_namespace" "longhorn" {
  metadata {
    name = "longhorn-system"

    labels = {
      "pod-security.kubernetes.io/enforce" = "privileged"
      "pod-security.kubernetes.io/audit"   = "privileged"
      "pod-security.kubernetes.io/warn"    = "privileged"
    }
  }
}

resource "helm_release" "longhorn" {
  name       = "longhorn"
  repository = "https://charts.longhorn.io"
  chart      = "longhorn"
  version    = var.longhorn_version
  namespace  = kubernetes_namespace.longhorn.metadata[0].name

  timeout = 900

  values = [yamlencode({
    defaultSettings = {
      defaultDataPath     = "/var/lib/longhorn"
      defaultReplicaCount = 2
      defaultDataLocality = "best-effort"
    }

    persistence = {
      defaultClass             = true
      defaultClassReplicaCount = 2
    }

    csi = {
      kubeletRootDir = "/var/lib/kubelet"
    }

  })]

  depends_on = [helm_release.cilium]
}
  • Le label privileged est obligatoire, sur les trois axes (enforce, audit, warn) : Talos applique la Pod Security Admission par défaut, et Longhorn a besoin de privilèges élevés pour gérer le stockage.
  • defaultDataPath = "/var/lib/longhorn" : c’est exactement le point de montage préparé à l’étape 4, sur le second disque. Le disque, le montage et le chemin de Longhorn ne font qu’un.
  • defaultReplicaCount = 2 plutôt que 3 : avec trois nœuds, deux répliques suffisent à survivre à une panne tout en économisant l’espace.
  • persistence.defaultClass = true : Longhorn devient la StorageClass par défaut du cluster, donc un PVC sans storageClassName atterrit chez lui.
  • Version 1.11.3 plutôt que la 1.12.0 la plus récente : Longhorn touche aux données, c’est le composant où une régression coûte le plus cher. On y va prudemment.

Le test qui compte
#

note

Une StorageClass qui existe ne prouve rien. Il faut vérifier qu’un volume peut être provisionné ET attaché : c’est là, et seulement là, que les problèmes iSCSI se révèlent.

Un PVC, plus un pod qui écrit un fichier dedans → Bound, Running, fichier lisible. Toute la chaîne fonctionne, de la StorageClass au montage réel. Observation au passage : le pod de test déclenche un avertissement Pod Security (restricted:latest) : Talos applique ce profil par défaut sur les namespaces non étiquetés, ici en mode warn (non bloquant). Les futurs déploiements devront le respecter, ou vivre dans un namespace étiqueté.

Étape 6 : ArgoCD et le pattern app-of-apps
#

Dernier maillon : la boucle GitOps. Les manifestes vivent dans le même dépôt (argocd/apps/), cohérent avec le choix du monorepo : les secrets étant chiffrés, un accès en lecture ne les expose pas. ArgoCD y accède via un deploy token GitLab : lecture seule, limité à ce dépôt, révocable, rangé dans SOPS et exporté en TF_VAR_* par tofu/env.sh.

# tofu/stacks/core/argocd.tf (extrait)
resource "helm_release" "argocd" {
  name       = "argo-cd"
  repository = "https://argoproj.github.io/argo-helm"
  chart      = "argo-cd"
  version    = var.argocd_version
  namespace  = kubernetes_namespace.argocd.metadata[0].name

  values = [yamlencode({
    global = {
      domain = var.argocd_domain
    }

    configs = {
      params = {
        "server.insecure" = true
      }
      repositories = {
        kentrowlab = {
          url      = var.gitlab_repo_url
          username = var.gitlab_deploy_username
          password = var.gitlab_deploy_token
        }
      }
    }

    dex = {
      enabled = false
    }

  })]

  depends_on = [helm_release.cilium]
}

Deux choix qui anticipent la suite : server.insecure parce que le TLS sera terminé par l’ingress en partie 13, et dex désactivé faute de fournisseur SSO pour l’instant. Autrement dit, on évite de monter un certificat auto-signé et une authentification maison qu’il faudrait démonter dans deux semaines.

L’accroc le plus structurant de la phase
#

Error: API did not recognize GroupVersionKind from manifest (CRD may not be installed)
no matches for kind "Application" in group "argoproj.io"

La CRD Application n’existe qu’après l’installation d’ArgoCD. Et c’est là tout le problème : depends_on n’y peut rien : la validation du manifeste précède l’exécution du plan.

Première tentative de contournement : déclarer l’application racine via l’option additionalApplications du chart Helm. Échec silencieux : l’option a été retirée des versions récentes. Le chart s’installe, et… aucune application n’apparaît, sans la moindre erreur.

La solution, c’est le bootstrap en deux temps :

  1. Premier apply → ArgoCD est installé, la CRD Application est créée.
  2. Second apply → le kubernetes_manifest peut enfin valider et créer l’application racine.
# tofu/stacks/core/argocd.tf
resource "kubernetes_manifest" "root_app" {
  manifest = {
    apiVersion = "argoproj.io/v1alpha1"
    kind       = "Application"
    metadata = {
      name      = "root"
      namespace = kubernetes_namespace.argocd.metadata[0].name
    }
    spec = {
      project = "default"
      source = {
        repoURL        = var.gitlab_repo_url
        targetRevision = "main"
        path           = "argocd/apps"
      }
      destination = {
        server    = "https://kubernetes.default.svc"
        namespace = kubernetes_namespace.argocd.metadata[0].name
      }
      syncPolicy = {
        automated = {
          prune    = true
          selfHeal = true
        }
      }
    }
  }

  depends_on = [helm_release.argocd]
}

Le syncPolicy mérite un mot : avec prune, ce qui disparaît du dépôt disparaît du cluster, et avec selfHeal, toute modification faite à la main est ramenée à ce que dit le dépôt. C’est le dépôt qui a toujours raison.

note

kubernetes_manifest est inutilisable pour une ressource dont la CRD est installée dans le même apply. C’est une limitation connue du provider Kubernetes, et le schéma se retrouve partout : CRD puis ressource, opérateur puis objet géré. C’est une contrainte structurelle de l’IaC déclaratif face à des API extensibles, pas un défaut de configuration. Corollaire à connaître : kubernetes_manifest joint l’API pendant le plan, donc le plan devient dépendant de la disponibilité du cluster.

C’est l’illustration parfaite du fil rouge de la phase : on voudrait tout décrire d’un coup, mais l’ordre de bootstrap s’impose.

Résultat
#

NAME   SYNC STATUS   HEALTH STATUS
root   Synced        Healthy

La boucle GitOps est opérationnelle. ArgoCD lit le dépôt, le deploy token fonctionne, et surtout : ajouter un fichier dans argocd/apps/ suffira désormais à déployer une application, sans toucher à OpenTofu. La frontière entre “infra” (OpenTofu) et “applicatif” (GitOps via ArgoCD) est posée.

Le Taskfile : régénérer les credentials
#

Le kubeconfig et le talosconfig traînaient dans /tmp. Deux options : les ranger dans SOPS, ou les régénérer à la demande depuis les outputs OpenTofu. J’ai choisi la régénération :

# Taskfile.yml (extrait)
tasks:
  kubeconfig:
    desc: Write kubeconfig from the encrypted OpenTofu state
    cmds:
      - mkdir -p {{dir .KUBECONFIG_PATH}}
      - cd tofu/stacks/{{.STACK}} && tofu output -raw kubeconfig > {{.KUBECONFIG_PATH}}
      - chmod 600 {{.KUBECONFIG_PATH}}
      - echo "export KUBECONFIG={{.KUBECONFIG_PATH}}"

  configs:
    desc: Refresh both cluster credentials
    cmds:
      - task: kubeconfig
      - task: talosconfig

Une commande suffit :

$ task configs
task: [kubeconfig] cd tofu/stacks/core && tofu output -raw kubeconfig > ~/.kube/kentrowlab.yaml
task: [kubeconfig] chmod 600 ~/.kube/kentrowlab.yaml
export KUBECONFIG=~/.kube/kentrowlab.yaml
task: [talosconfig] cd tofu/stacks/core && tofu output -raw talosconfig > ~/.talos/kentrowlab.yaml
task: [talosconfig] chmod 600 ~/.talos/kentrowlab.yaml
export TALOSCONFIG=~/.talos/kentrowlab.yaml

Les deux fichiers sortent directement du state chiffré, en 600, et la tâche rappelle les deux export à faire.

Le state est déjà la source de vérité ; dupliquer des certificats client créerait deux endroits à révoquer en cas de fuite. (Accroc mineur : umask n’existe pas dans le shell interne de Task, mvdan/sh, une implémentation Go partielle. Il a donc été remplacé par un chmod explicite.)

L’état des lieux après cette phase
#

ÉlémentVersionÉtat
Talosv1.13.73 nœuds, extensions iscsi-tools + util-linux-tools
Kubernetesv1.36.03 control-plane combinés, VIP .55
Cilium1.20.0CNI, kube-proxy remplacé, Hubble
Longhorn1.11.3StorageClass par défaut, testée
ArgoCD10.2.2app-of-apps Synced / Healthy

Un cluster Kubernetes complet, décrit en code, avec du réseau moderne, du stockage persistant vérifié et une boucle GitOps. Et une leçon qui vaut bien au-delà de Kubernetes : le déclaratif décrit un état final, mais il ne dispense pas de comprendre l’ordre dans lequel les choses doivent naître.

Ce qui se prépare pour la partie 13
#

Deux décisions sont déjà actées pour la suite :

  • Le SSO généralisé. Sans lui, les comptes locaux s’accumulent (Proxmox, Dokploy, ArgoCD, Grafana, Longhorn), chacun avec son mot de passe dans SOPS. Le fournisseur OIDC penche vers Authentik (plus léger que Keycloak et il fait aussi proxy d’authentification). Proxmox, ArgoCD, Grafana et Netbird supportent tous OIDC nativement.
  • L’accès distant via Netbird auto-hébergé : du WireGuard open source, qui s’intègre à l’IdP OIDC.
note

Un piège de poule et d’œuf à traiter en partie 13. Si le serveur Netbird tourne dans le lab qu’il sert à atteindre, on perd tout accès distant dès que le lab tombe : exactement le même problème que pour PBS ou le monitoring. Trois options sur la table : un petit VPS externe pour le plan de contrôle, Tailscale en secours, ou l’assumer. À trancher le moment venu.

Et maintenant ?
#

Le lab a un cluster Kubernetes digne de ce nom. La partie 13 attaque l’exposition et les services : Technitium pour un DNS split-horizon, des certificats Let’s Encrypt via ACME DNS-01 (API OVH), Traefik en ingress, Authentik pour le SSO, Netbird pour l’accès distant, et enfin l’observabilité (Grafana, Prometheus, Loki). Bref, tout ce qui transforme un cluster en plateforme réellement utilisable.

À bientôt !

Kentrow
Auteur
Kentrow
Partage de notes et astuces IT : réseaux, serveurs, DevOps, sécurité, homelab et plus encore.
Mon homelab de zéro - Cet article fait partie d'une série.
Partie 12: Cet article

Articles connexes