Cluster Kubernetes (k8s)

Rôle

Cluster Kubernetes on-premise de l'infrastructure Indio, destiné à héberger de futures charges de travail conteneurisées. Provisionné avec kubeadm (control-plane HA à 3 nœuds), containerd comme runtime et Calico comme CNI (réseau overlay des pods + network policies). Projet Terraform+Ansible dédié (/root/k8s, repo indio/iac/k8s), volontairement séparé du reste de l'IaC applicative — à la demande explicite du user ("c'est un projet en dehors du projet actuel").

Architecture

  • 6 nœuds Rocky Linux 9.6, zone service (seg-service), clones du template tmpl-rocky96-hardened :
Rôle VM IP CPU/RAM/Disque
control-plane INDSERV019 10.100.2.149 2 vCPU / 4 Go / 40 Go
control-plane INDSERV020 10.100.2.150 2 vCPU / 4 Go / 40 Go
control-plane INDSERV021 10.100.2.151 2 vCPU / 4 Go / 40 Go
worker INDSERV022 10.100.2.152 4 vCPU / 8 Go / 80 Go
worker INDSERV023 10.100.2.153 4 vCPU / 8 Go / 80 Go
worker INDSERV024 10.100.2.154 4 vCPU / 8 Go / 80 Go

(Specs vérifiées directement dans terraform.tfstate le 2026-07-19.)

  • Point d'entrée de l'API control-plane : VIP HAProxy 10.100.2.130:6443 (service k8s-api), utilisée comme controlPlaneEndpoint par kubeadm pour permettre un control-plane HA à 3 nœuds.
  • Runtime conteneur containerd (pas de Docker). CNI Calico v3.30.7 (déployé via l'opérateur Tigera). Kubernetes v1.33.13.
  • Réseau pod 192.168.0.0/16 (défaut Calico), réseau service 10.96.0.0/12 (défaut kubeadm, non surchargé).
  • Registres d'images miroités via Nexus (un hostname public par port HAProxy dédié, cf. Nexus pour la création des dépôts) : docker.io→8083, registry.k8s.io→8084, ghcr.io→8085, quay.io→8086.
  • Manifests Calico (opérateur Tigera + custom resources) servis depuis un dépôt Nexus raw hosted k8s-manifests, pas depuis raw.githubusercontent.com en direct (host bloqué en sortie depuis le périmètre proxy/FortiGate — voir Procédure manuelle).

Structure du dépôt

Fichier / dossier Contenu
main.tf Ressource unique vsphere_virtual_machine.k8s, for_each sur var.nodes
variables.tf Défauts (folder, template, gateway, DNS, domaine) + liste nodes (6 objets name/cpu/ram/disk0_gb)
versions.tf Providers hashicorp/vsphere >= 2.6.0, e-breuninger/netbox >= 5.0.0, < 6.0.0
ansible/site.yml Orchestration des 6 rôles
ansible/inventory.ini Groupes k8s_control_plane/k8s_workers/k8s
ansible/group_vars/k8s.yml Proxy/no_proxy, miroirs registry, versions kube/Calico, subnets
ansible/roles/common Prérequis OS kubeadm
ansible/roles/containerd Runtime + miroirs registry
ansible/roles/kube-pkgs kubelet/kubeadm/kubectl
ansible/roles/kubeadm-init Init du 1er control-plane
ansible/roles/kubeadm-join Join des 5 autres nœuds
ansible/roles/calico CNI

Topologie

flowchart TD
    VIP["VIP HAProxy<br/>10.100.2.130:6443<br/>service k8s-api"]

    subgraph CP["Control-plane"]
        CP1["INDSERV019<br/>10.100.2.149"]
        CP2["INDSERV020<br/>10.100.2.150"]
        CP3["INDSERV021<br/>10.100.2.151"]
    end

    subgraph WK["Workers"]
        WK1["INDSERV022<br/>10.100.2.152"]
        WK2["INDSERV023<br/>10.100.2.153"]
        WK3["INDSERV024<br/>10.100.2.154"]
    end

    VIP --> CP1 & CP2 & CP3
    WK1 -.->|kubelet| VIP
    WK2 -.->|kubelet| VIP
    WK3 -.->|kubelet| VIP
    CP1 <-->|etcd raft| CP2
    CP2 <-->|etcd raft| CP3
    CP1 <-->|etcd raft| CP3

    subgraph REG["Nexus — miroirs registry"]
        R1["docker-proxy :8083"]
        R2["k8s-proxy :8084"]
        R3["ghcr-proxy :8085"]
        R4["quay-proxy :8086"]
    end

    CP1 & CP2 & CP3 & WK1 & WK2 & WK3 -->|containerd| REG

Séquence de déploiement Ansible

flowchart LR
    A["common<br/>swap off, sysctl, SELinux permissive,<br/>firewalld"] --> B["containerd<br/>dépôt Nexus, config.toml,<br/>drop-in no_proxy"]
    B --> C["kube-pkgs<br/>kubelet/kubeadm/kubectl 1.33.13,<br/>drop-in no_proxy"]
    C --> D["kubeadm-init<br/>1er control-plane<br/>seulement"]
    D --> E["kubeadm-join<br/>2 autres CP<br/>+ 3 workers"]
    E --> F["calico<br/>1er control-plane :<br/>opérateur Tigera + CR"]

Provisioning Terraform

  • main.tf : ressource unique vsphere_virtual_machine.k8s, for_each sur var.nodes (liste de 6 objets name/cpu/ram/disk0_gb, coalesce() avec les défauts de variables.tf si un champ n'est pas renseigné pour un nœud donné).
  • L'IP de chaque nœud est résolue via data "netbox_ip_addresses" (provider e-breuninger/netbox), une instance par nœud (for_each sur le même ensemble), filtrée sur dns_name = lower("<nom>.infra.indio")c'est une lecture, pas une réservation : l'IP doit déjà exister côté NetBox avant l'apply (voir Procédure manuelle).
  • Variables clés (variables.tf) : vsphere_folder par défaut seg-service, template_name = tmpl-rocky96-hardened, vm_gateway = 10.100.2.190, vm_dns = [10.100.2.241, 10.100.2.242] (FreeIPA), vm_domain = infra.indio.
  • lifecycle.ignore_changes sur annotation, clone[0].template_uuid, clone[0].customize, disk[0].io_share_count — évite une recréation si vCenter renvoie ces attributs avec un format légèrement différent après coup.
  • Sortie vms : map nom de VM → adresse IP.

Configuration Ansible

Playbook ansible/site.yml, inventaire ansible/inventory.ini (groupes k8s_control_plane, k8s_workers, k8s). Six rôles appliqués dans l'ordre (voir diagramme ci-dessus) :

  1. common — prérequis officiels kubeadm : désactivation du swap (et retrait de l'entrée fstab), modules noyau overlay/br_netfilter (chargés + persistés dans /etc/modules-load.d), sysctl (bridge-nf-call-iptables, bridge-nf-call-ip6tables, ip_forward), SELinux en mode permissive (requis par la documentation kubeadm sur RHEL/Rocky), ouverture firewalld : ports communs à tous les nœuds (kubelet 10250, BGP Calico 179, VXLAN Calico 4789/udp, Typha 5473 — les control-plane n'hébergent aucun replica Typha et doivent donc le joindre à travers le réseau), règle riche autorisant le protocole IP-in-IP (protocol value="4", utilisé par Calico pour le trafic inter-nœuds), puis ports spécifiques control-plane (6443, 2379-2380, 10259, 10257) et workers (NodePort 30000-32767).
  2. containerd — dépôt Docker CE via Nexus, installation de containerd.io, génération de /etc/containerd/config.toml (SystemdCgroup + miroirs registry via hosts.toml), drop-in systemd dédié pour le no_proxy.
  3. kube-pkgs — dépôt Kubernetes via Nexus, installation de kubelet/kubeadm/kubectl épinglés à kube_version (1.33.13, disable_excludes: indio-kubernetes pour contourner l'exclusion de version par défaut du dépôt), drop-in systemd no_proxy pour kubelet, activation du service (démarrage réel différé jusqu'à kubeadm init/join).
  4. kubeadm-init — ne s'exécute que sur le premier control-plane (groups['k8s_control_plane'][0]) : génère kubeadm-config.yaml, exécute kubeadm init --config=... --upload-certs, configure le kubeconfig pour indio-adm et root, régénère un jeton de join worker (kubeadm token create --print-join-command) et une certificate-key control-plane (kubeadm init phase upload-certs --upload-certs), exposés comme facts partagés (k8s_join_worker_cmd/k8s_join_cp_cmd) pour le rôle suivant.
  5. kubeadm-join — fait rejoindre les 2 autres control-plane et les 3 workers, en excluant le premier control-plane via des conditions when (comparaison à groups['k8s_control_plane'][0]) plutôt qu'un pattern hosts dynamique ; --node-name {{ inventory_hostname | lower }} (kubeadm exige un nom de nœud en minuscules, contrairement au nom vCenter/NetBox en majuscules).
  6. calico — sur le premier control-plane uniquement : télécharge les manifests (opérateur Tigera + custom resources) depuis Nexus, aligne le cidr des custom resources sur k8s_pod_subnet, applique l'opérateur puis les CR en --server-side --force-conflicts (avec retry sur l'apply des CR), attend que tous les nœuds soient Ready.

Variables (group_vars/k8s.yml) : proxy Squid (10.100.10.4:3128), k8s_no_proxy (CIDR internes cluster + domaines internes exclus du proxy), miroirs registry Nexus, versions kube/Calico, endpoint API, subnets pod/service.

Procédure manuelle

Deux étapes réalisées hors Terraform/Ansible, à reproduire pour tout futur rebuild complet :

  1. Réservation IP NetBox préalable — la data source netbox_ip_addresses de main.tf échoue en no result si les 6 FQDN (indserv019.infra.indio...indserv024.infra.indio) n'ont pas déjà une IP réservée côté NetBox. Script dédié idempotent : python3 /root/netbox/seed/add_k8s_vms.py (réutilise le pattern get_or_create, 2 rôles fonctionnels kubernetes-control-plane/kubernetes-worker) — voir NetBox. Ne jamais rejouer seed/populate.py en entier pour cet effet.
  2. Upload manuel des manifests Calico vers Nexustigera-operator.yaml et custom-resources.yaml (v3.30.7) ont été récupérés une fois depuis un poste disposant d'un accès Internet direct, puis uploadés tels quels (mêmes octets que l'upstream officiel) dans le dépôt Nexus raw hosted k8s-manifests (chemin calico/v3.30.7/), car raw.githubusercontent.com est injoignable depuis le périmètre proxy Squid/FortiGate. La création du dépôt Nexus lui-même (type raw hosted) suit le même mécanisme que les autres dépôts internes — voir Nexus.

Procédure de déploiement

python3 /root/netbox/seed/add_k8s_vms.py   # réservation IP préalable (obligatoire)
terraform apply                             # crée les 6 VM nues
cd ansible && ansible-playbook site.yml     # common -> containerd -> kube-pkgs -> kubeadm-init -> kubeadm-join -> calico

Vérification finale intégrée au rôle calico : attente que tous les nœuds soient à l'état Ready (jusqu'à 30 tentatives, 20 s d'intervalle).

Contrôle de santé / Vérification

kubectl --kubeconfig ~/.kube/config get nodes -o wide     # 6/6 Ready attendu
kubectl get pods -A                                        # 100% Running/Completed attendu
kubectl -n tigera-operator get deployment tigera-operator  # Available

Dernier rebuild complet vérifié le 2026-07-15 : 6/6 nœuds Ready, aucun pod résiduel bloqué (ContainerCreating/Pending).

Points d'attention

Piège proxy Squid / CIDR internes (rencontré, corrigé)

Le drop-in systemd global (indio-baseline-config) route kubelet et containerd à travers le proxy Squid, dont le no_proxy par défaut ignore les CIDR internes au cluster (ClusterIP 10.96.0.0/12, pods 192.168.0.0/16). Squid refuse alors le CONNECT vers ces IP (403), que les clients CNI/Kubernetes journalisent à tort comme un refus RBAC (Forbidden) — signal trompeur : kubectl auth can-i répond "yes" et un curl manuel en session SSH réussit, car ni l'un ni l'autre n'hérite du proxy systemd. Symptôme distinctif : calico-node (DaemonSet, pas de sandbox CNI à créer pour lui-même) démarre normalement pendant que tout le reste (coredns, calico-apiserver, calico-kube-controllers...) reste bloqué en ContainerCreating — c'est containerd (pas kubelet) qui exécute le binaire CNI en sous-processus, donc c'est son environnement proxy à lui qui compte. Corrigé par des drop-in no_proxy dédiés sur kubelet et containerd séparément.

  • Version Calico v3.30.x imposée par la compatibilité avec Kubernetes 1.33 (v3.29.x n'est testé/supporté que jusqu'à la 1.32 selon la documentation Tigera).
  • CRD Calico appliquées en --server-side --force-conflicts : leur taille dépasse la limite de l'annotation kubectl.kubernetes.io/last-applied-configuration utilisée par un apply classique ; retry nécessaire car le déploiement tigera-operator peut devenir Available avant que ses CRD ne soient Established côté apiserver.
  • kubeadm init peut timeouter au tout premier essai sur un control-plane à 2 vCPU/4 Go (pull d'images à froid + démarrage simultané etcd/apiserver/scheduler/controller-manager) : /etc/kubernetes/admin.conf existe alors mais le cluster est à moitié initialisé (RBAC bootstrap jamais fait). Le rôle ne détecte l'état "déjà initialisé" que via stat admin.conf, donc un simple re-run ne suffit pas — nécessite kubeadm reset -f + nettoyage (/etc/cni/net.d, ~/.kube) avant de relancer.
  • Jetons de join régénérés à chaque exécution du playbook (pas de token statique conservé) — kubeadm-join doit tourner dans le même run que kubeadm-init (facts partagés en mémoire, pas persistés sur disque).
  • Manifests Calico mirrorés une seule fois sur Nexus (upload manuel) — toute montée de version Calico future nécessite de répéter cette étape manuelle.
  • Registres Docker Nexus (8083-8087) et leur health-check dédié (/v2/ + 401) sont documentés sur la page Nexus — non dupliqué ici.