Objectif 🎯
Dans la section précédente, nous avons vu les prérequis via l’installation de notre propre KMS ainsi que la création d’images Talos personnalisées pour nos control planes et workers, avec les extensions nécessaires pour Tailscale et Longhorn.
Pour rappel, l’objectif est de déployer Talos en mode Zero Trust, via le réseau privé Tailnet, i.e. qu’à aucun moment l’endpoint API Talos ou Kubernetes ne sera exposé sur Internet. Seul les control planes seront concernés, dans le sens ou les workers seront déjà accessibles au travers ces derniers. Ils doivent être initialisés avec une configuration Talos qui leur permette de rejoindre le cluster via Tailnet dès le premier boot. Il faudra pour cela utiliser le champ user_data de l’API Hetzner Cloud, qui sert habituellement à injecter un fichier cloud-init. Une fois le nœud connecté au réseau Tailnet, on réapplique la configuration Talos pour finaliser l’installation du cluster et bootstrapper Kubernetes dans la foulée.
Nous allons maintenant voir comment déployer un cluster Talos sur Hetzner Cloud au travers de 2 méthodes différentes au choix :
- Via Talhelper qui est une solution simple d’approche, mélangeant déclaratif et impératif.
- Via Terraform avec les providers officiels hcloud et talos. Plus avancée, mais réutilisable, avec une configuration Hcloud et Talos unifiée et seule commande à exécuter, c’est la méthode recommandée dans ce guide pour une approche entièrement GitOps.
Talhelper
Talhelper permet de générer les fichiers de configurations Talos pour chaque nœud, tout en étant GitOps friendly. Le mode opératoire :
- Génération des clients secrets.
- Définition du schéma de cluster Talos.
- Génération des fichiers de configuration Talos depuis ce schéma.
- Création l’infra physique via l’utilitaire
hcloud(mode impératif), en injectant les fichiers de configuration Talos générés précédemment dans le champuser_data. - Générer les commandes pour réappliquer les configs et bootstrapper kube.
Création de la config Talos en mode déclaratif
Selon l’architecture cible décrite ici, le fichier talconfig.yaml ressemblerait à ça :
clusterName: ohmytalos-devtalosVersion: v1.12.2kubernetesVersion: v1.35.0endpoint: https://ohmytalos-dev-control-plane-nbg1:6443controlPlane: talosImageURL: factory.talos.dev/hcloud-installer/4a0d65c669d46663f377e7161e50cfd570c401f26fd9e7bda34a0216b6f1922b extensionServices: - name: tailscale environment: - TS_AUTHKEY=${TS_AUTHKEY} volumes: - name: STATE encryption: provider: luks2 keys: - nodeID: {} slot: 0 - name: EPHEMERAL encryption: provider: luks2 keys: - static: passphrase: ${VOLUME_ENCRYPTION_PASSPHRASE} slot: 0 lockToState: trueworker: talosImageURL: factory.talos.dev/hcloud-installer/613e1592b2da41ae5e265e8789429f22e121aab91cb4deb6bc3c0b6262961245 volumes: - name: STATE encryption: provider: luks2 keys: - nodeID: {} slot: 0 - name: EPHEMERAL encryption: provider: luks2 keys: - static: passphrase: ${VOLUME_ENCRYPTION_PASSPHRASE} slot: 0 lockToState: truecniConfig: name: noneclusterPodNets: - 10.42.0.0/16clusterSvcNets: - 10.43.0.0/16nodes: - hostname: control-plane-nbg1 ipAddress: ohmytalos-dev-control-plane-nbg1 installDisk: /dev/sda controlPlane: true ignoreHostname: true - hostname: control-plane-fsn1 ipAddress: ohmytalos-dev-control-plane-fsn1 installDisk: /dev/sda controlPlane: true ignoreHostname: true - hostname: control-plane-hel1 ipAddress: ohmytalos-dev-control-plane-hel1 installDisk: /dev/sda controlPlane: true ignoreHostname: true - hostname: worker ipAddress: 10.0.1.1, 10.0.1.2, 10.0.1.3 installDisk: /dev/sda ignoreHostname: true nodeLabels: node.longhorn.io/create-default-disk: config nodeAnnotations: node.longhorn.io/default-disks-config: '[{"allowScheduling":true,"name":"system","path":"/var/lib/longhorn","tags":["local"]}]' node.longhorn.io/default-node-tags: '["worker"]' patches: - |- - op: add path: /machine/kubelet/extraConfig value: imageGCHighThresholdPercent: 55 imageGCLowThresholdPercent: 50 - hostname: storage ipAddress: 10.0.2.1, 10.0.2.2 installDisk: /dev/sda ignoreHostname: true nodeLabels: node.kubernetes.io/exclude-from-external-load-balancers: "true" node.kubernetes.io/role: storage node.longhorn.io/create-default-disk: config nodeAnnotations: node.longhorn.io/default-disks-config: '[{"allowScheduling":true,"name":"system","path":"/var/lib/longhorn","tags":["local"]},{"allowScheduling":true,"name":"volume","path":"/var/mnt/longhorn","tags":["volume"]}]' node.longhorn.io/default-node-tags: '["storage"]' userVolumes: - name: longhorn encryption: provider: luks2 keys: - static: passphrase: ${VOLUME_ENCRYPTION_PASSPHRASE} slot: 0 lockToState: true provisioning: diskSelector: match: disk.dev_path == '/dev/sdb' grow: true minSize: 40Gi patches: - |- - op: add path: /machine/kubelet/extraConfig value: imageGCHighThresholdPercent: 55 imageGCLowThresholdPercent: 50 registerWithTaints: - key: node-role.kubernetes.io/storage effect: NoSchedulepatches: - |- - op: add path: /cluster/proxy value: disabled: true - op: add path: /cluster/externalCloudProvider value: enabled: trueRajouter le fichier suivant pour les variables d’environnement secrètes :
TS_AUTHKEY: tskey-auth-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxVOLUME_ENCRYPTION_PASSPHRASE: supersecretpassphraseLancer les commandes suivantes pour générer les fichiers de conf pour chaque pool de nœud.
talhelper gensecret > talsecret.sops.yamlsops -e -i talsecret.sops.yamlsops -e -i talenv.sops.yamltalhelper genconfigCréation infrastructure en mode impératif
On passe ensuite à la partie fastidieuse pour créer notre cluster physique en mode impératif via le CLI Hetzner Cloud. Assurez-vous d’être sur le bon contexte de projet via hcloud context.
# networkhcloud network create --name ohmytalos --ip-range 10.0.0.0/10hcloud network add-subnet --type cloud --network-zone eu-central --ip-range 10.0.0.0/24 ohmytaloshcloud network add-subnet --type cloud --network-zone eu-central --ip-range 10.0.1.0/24 ohmytaloshcloud network add-subnet --type cloud --network-zone eu-central --ip-range 10.0.2.0/24 ohmytaloshcloud firewall create --name ohmytalos
CP_SNAPSHOT_ID=$(hcloud image list -t snapshot -l name=cp,arch=amd64 -o columns=id | tail -n 1)WK_SNAPSHOT_ID=$(hcloud image list -t snapshot -l name=wk,arch=amd64 -o columns=id | tail -n 1)
# control planeshcloud server create --name ohmytalos-dev-control-plane-nbg1 --image $CP_SNAPSHOT_ID --type cx23 --location nbg1 --firewall ohmytalos --user-data-from-file clusterconfig/ohmytalos-dev-control-plane-nbg1.yamlhcloud server attach-to-network --network ohmytalos --ip 10.0.0.2 ohmytalos-dev-control-plane-nbg1
# commandes similaires à chaque node...# pensez à créer les 2 volumes et à les rattacher aux serveurs de nœuds de storageNote
Noter --user-data-from-file qui permet d’injecter le fichier de configuration Talos. Il sera appliqué au premier boot de la machine, l’inscrivant dans le réseau Tailnet.
Une fois les machines démarrées, vous devriez voir les 3 control planes apparaître dans votre Tailnet. Approuvez-les si nécessaire, puis lancez les commandes générées via talhelper gencommand apply, talhelper gencommand bootstrap, talhelper gencommand kubeconfig pour finaliser l’installation du cluster et bootstrapper Kubernetes. Cela devrait donner quelque chose comme :
talosctl apply-config --talosconfig=./clusterconfig/talosconfig --nodes=10.0.0.2 --file=./clusterconfig/ohmytalos-dev-control-plane-nbg1.yaml;talosctl apply-config --talosconfig=./clusterconfig/talosconfig --nodes=10.0.0.3 --file=./clusterconfig/ohmytalos-dev-control-plane-fsn1.yaml;talosctl apply-config --talosconfig=./clusterconfig/talosconfig --nodes=10.0.0.4 --file=./clusterconfig/ohmytalos-dev-control-plane-hel1.yaml;talosctl apply-config --talosconfig=./clusterconfig/talosconfig --nodes=10.0.1.1 --file=./clusterconfig/ohmytalos-dev-worker.yaml;talosctl apply-config --talosconfig=./clusterconfig/talosconfig --nodes=10.0.1.2 --file=./clusterconfig/ohmytalos-dev-worker.yaml;talosctl apply-config --talosconfig=./clusterconfig/talosconfig --nodes=10.0.1.3 --file=./clusterconfig/ohmytalos-dev-worker.yaml;talosctl apply-config --talosconfig=./clusterconfig/talosconfig --nodes=10.0.2.1 --file=./clusterconfig/ohmytalos-dev-storage.yaml;talosctl apply-config --talosconfig=./clusterconfig/talosconfig --nodes=10.0.2.2 --file=./clusterconfig/ohmytalos-dev-storage.yaml;
talosctl bootstrap --talosconfig=./clusterconfig/talosconfig --nodes=10.0.0.2;talosctl kubeconfig --talosconfig=./clusterconfig/talosconfig --nodes=10.0.0.2;Et voilà le cluster est prêt ! Vous pouvez maintenant tester un kubectl get nodes pour vous assurer de la présence de tous vos nodes. Ils seront en status NotReady pour l’instant, car nous n’avons pas encore installé de CNI (setté à none dans la config).
Conclusion sur Talhelper
Cette solution est intéressante dans le sens où l’on n’a pas à mettre les mains dans du code Terraform pur et dur, l’approche est donc plus directe. En échange, c’est forcément un peu plus impératif et manuel, même s’il n’est pas compliqué de se concocter un petit script bash pour automatiser au moins la création de l’infra physique Hcloud. Idéal en tout cas si l’on a déjà une infra Talos physique existante accessible.
L’autre inconvénient ici est que l’on est un peu obligé de décrire l’infrastructure deux fois, une via le schéma talconfig, et l’autre via les commandes Hcloud impératives (type de serveur, location, network, image, etc.).
Terraform
Nous le ferons avec l’aide du provider hcloud pour la création de l’infra en interrogeant l’API de Hetzner Cloud. Il sera utilisé en conjonction avec le provider talos qui gérera la configuration du cluster et le bootstrapping de Kubernetes. Le but ici est d’avoir un kube fonctionnel en une passe joignable dès la fin de terraform apply.
Pour la mise en place de l’infrastructure post-kube (CNI, Longhorn, ingress, monitoring), de nombreux guides basculent rapidement vers FluxCD, mais je ne suis pas très fan de cette approche. Je préfère garder la gestion de l’entièreté de l’infra avec Terraform/OpenTofu, et réserver FluxCD pour le déploiement des applications métiers uniquement. Le but étant de bien séparer les responsabilités entre équipe Infra / Dev.
De plus je divise aussi la partie infra en 2 modules Terraform distincts, avec séparation de la couche “physique” (Hcloud et config Talos) de la couche “logique” dédiée aux manifests Kubernetes. Cela simplifie grandement la maintenance du code Terraform ainsi que le process de mise à jour de chaque composant, car cela fonctionne de manière très différente dans chaque module. Voyer ça comme monter une baraque :
- Fondations et charpentes : Hcloud et cluster Talos via Terraform
- Murs et toiture : CNI, stockage, base de données, ingress, monitoring via Terraform
- Aménagement intérieur : déploiement applicatif métier via FluxCD
Cette section s’attaque à la partie 1.
Remark (terraform-hcloud-kubernetes)
Il est à noter que le module hcloud officiel de déploiement de Talos, mélange l’étape 1 et 2 en un seul module. Il va même plus loin en injectant carrément les manifests généré par Helm dans la config Talos, ce qui a la fâcheuse conséquence de générer un state terraform énorme.
Mais il a le mérite d’être blindé et testé par la communauté, à défaut d’être simpliste techniquement. À l’écriture de ce guide, il a aussi 2 autres inconvénients : pas de gestion de volumes hcloud ni de schematic image par node pool. Mais il possède un autoscaler préconfiguré, pour ceux ayant ce genre de besoin.
Le sujet de l’autoscaling ne sera pas abordé dans ce guide, mais il pourra bien sûr être assez aisément intégré dans votre module terraform en installant son chart dédié 😊.
Initialisation
Reprenons le projet de l’étape précédente et préparons la structure suivante :
├── clusters│ └── dev-hcloud│ ├── .envrc│ ├── cluster.tf│ ├── outputs.tf│ ├── terraform.tf│ └── variables.tf├── modules│ └── hcloud│ ├── firewalls.tf│ ├── locals.tf│ ├── network.tf│ ├── outputs.tf│ ├── servers.tf│ ├── talos.tf│ ├── terraform.tf│ ├── variables.tf│ └── volumes.tf└── packer ├── .envrc ├── hcloud.pkr.hcl ├── mise.toml ├── schematic-cp.yaml └── schematic-wk.yamlLe module modules/hcloud contiendra toute la logique pour créer les ressources Hetzner Cloud et installer Talos sous forme de nodes pools. Il pourra être réutilisé ainsi autant que nécessaire sur différents clusters (prod, staging, etc.). Le répertoire clusters/dev-hcloud contiendra uniquement la configuration spécifique à notre cluster en cours.
State Terraform
Pour l’hébergement de notre state Terraform, nous allons prendre un bucket S3 ohmytalos-dev dédié au cluster (ici OVH mais cela doit être plus ou moins identique chez n’importe quel autre provider).
Vu que ce n’est pas beaucoup plus compliqué, nous allons chiffrer le state via SSE-C. Au niveau du projet terraform en lui-même, seuls deux variables seront nécessaires, le token API Hetzner Cloud et le token Tailscale.
Danger
Le state contiendra les infos les plus sensibles de notre infra, comme le talosconfig, les machines config, etc. Toutefois l’impact restera mitigé en cas de fuite ici puisque le endpoint sera inaccessible en dehors du réseau Tailnet, ce qui nous laissera le temps de réagir en renouvelant toutes les clés de notre PKI.
Le token API Hetzner ne sera pas dans le state, étant uniquement utilisé localement pour l’accès API via le provider. Il y aura toutefois le token Tailnet, donc assurez-vous que l’enregistrement d’un device soit exclusivement approuvable par un admin Tailscale, ou encore mieux utiliser Tailnet Lock pour les plus paranos.
Allez sur votre Vaultwarden et créez les clés suivantes dans une collection dédiée Terraform - Hcloud Talos (noter son GUID collectionId pour la suite) :
| Nom de la clé | Description |
|---|---|
terraform_state_s3 | Les identifiants d’accès au bucket S3, à générer côté OVH via un utilisateur S3 dédié, indiquer sa clé d’accès en username et la clé secrète en password |
terraform_state_sse_c | La clé de chiffrement SSE-C, utiliser la commande openssl rand -base64 32 pour générer la clé |
hcloud_token | Le token API Hetzner Cloud, à générer dans votre projet Hcloud |
ts_auth_key | le token d’authentification Tailscale, à générer dans votre compte Tailscale |
volume_encryption_passphrase | la passphrase de chiffrement des volumes |
Exporter vos variables comme suit :
BW_SESSION="$(bw unlock --raw)"COLLECTION_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxxITEMS=$(bw list items --collectionid $COLLECTION_ID --session $BW_SESSION)
bw_field() { echo "$ITEMS" | jq -r --arg field "$1" --arg name "$2" \ '.[] | select(.name==$name) | .login[$field]'}
export AWS_ACCESS_KEY=$(bw_field username terraform_state_s3)export AWS_SECRET_KEY=$(bw_field password terraform_state_s3)export AWS_SSE_CUSTOMER_KEY=$(bw_field password terraform_state_sse_c)
export TF_VAR_hcloud_token=$(bw_field password hcloud_token)export TF_VAR_ts_auth_key=$(bw_field password ts_auth_key)export TF_VAR_volume_encryption_passphrase=$(bw_field password volume_encryption_passphrase)Tip (perf)
Petite astuce ici, nous récupérons une seule fois la liste des items de la collection dans une variable ITEMS pour éviter d’appeler bw à chaque variable, ce qui serait bien trop lent.
Toutes les variables d’env TF_VAR_* seront automatiquement reconnues par Terraform comme des variables d’entrée.
Assurer vous toujours de lancer bw sync après maj d’une variable secrète, puis placez-vous dans le répertoire clusters/dev-hcloud et lancer un direnv allow. Le mot de passe maître du vault vous sera demandé. Assurez-vous que vos variables d’env sont bien chargées.
Dans le répertoire modules/hcloud, on ajoute les dépendances aux providers hcloud et talos et on prépare la variable token Hetzner Cloud.
terraform { required_version = ">= 1.13.0" required_providers { hcloud = { source = "hetznercloud/hcloud" version = ">= 1.52.0" } talos = { source = "siderolabs/talos" version = ">= 0.10.0" } }}
provider "hcloud" { token = var.hcloud_token}variable "hcloud_token" { type = string sensitive = true}Côté config cluster, créer les fichiers terraform.tf pour initialiser le backend S3 et variables.tf pour nos 3 variables d’entrée. On prépare le module dans cluster.tf.
terraform { required_version = ">= 1.13.0"
backend "s3" { endpoints = { s3 = "https://s3.gra.io.cloud.ovh.net" } skip_credentials_validation = true skip_region_validation = true skip_requesting_account_id = true skip_s3_checksum = true region = "gra" bucket = "ohmytalos-dev" key = "terraform/hcloud.tfstate" encrypt = true }}variable "hcloud_token" { type = string sensitive = true}
variable "ts_auth_key" { type = string sensitive = true}
variable "volume_encryption_passphrase" { type = string sensitive = true}module "hcloud_talos" { source = "../../modules/hcloud"
hcloud_token = var.hcloud_token}Lancer un premier terraform init pour initialiser le backend S3 puis un terraform plan pour vérifier que tout est ok.
Variables
Le plus simple pour démarrer est d’exprimer la meilleur API de ce que l’on a besoin concrètement pour la création de notre cluster. On va donc définir toutes les variables d’entrée du module hcloud dans variables.tf.
variable "hcloud_token" { type = string sensitive = true}
variable "talos_version" { type = string}
variable "talos_endpoints" { type = list(string) default = []}
variable "talos_snapshots" { type = list(object({ name = string schematic_id = string }))}
variable "cluster_endpoint" { type = string default = null}
variable "kubernetes_version" { type = string}
variable "cluster_name" { type = string}
variable "network_zone" { type = string}
variable "network_ipv4_cidr" { type = string}
variable "existing_network_id" { type = string default = null}
variable "firewall_kube_api_source" { type = list(string) default = [ "127.0.0.1", "::1" ]}
variable "firewall_talos_api_source" { type = list(string) default = [ "127.0.0.1", "::1" ]}
variable "config_patches" { type = list(string) default = []}
variable "control_planes_image_name" { type = string}
variable "control_planes_placement_group" { type = string}
variable "control_planes_config_patches" { type = list(string) default = []}
variable "control_planes_ipv4_cidr" { type = string}
variable "control_planes" { type = list(object({ name = string server_type = string location = string ip = string }))}
variable "worker_nodepools" { type = list(object({ name = string server_type = string location = string ipv4_cidr = string placement_group = optional(string) image_name = optional(string) config_patches = optional(list(string)) config_patches_apply = optional(list(string)) nodes = list(object({ name = string ip = string server_type = optional(string) location = optional(string) })) volumes = optional(list(object({ name = string size = number }))) }))}| Nom | Description |
|---|---|
talos_version | La version de Talos à utiliser sur les images d’installation. Ne déclenche aucune mise à jour, permet surtout d’afficher la liste des commandes talosctl à exécuter pour mettre à jour avec la bonne version d’image pour chaque nœud. |
talos_endpoints | Liste des endpoints pour l’accès à l’API des nœuds Talos. Pour l’apply de la configuration, seul le 1er endpoint sera utilisé. Si vide, le hostname du 1er control plane sera utilisé, permettant au MagicDNS de Tailscale de s’exprimer. |
talos_snapshots | Définition des snapshots disponibles créés précédemment via packer. |
kubernetes_version | Version vanilla de kubernetes à installer au bootstrap. Attention, au contraire de la version talos dont la maj est manuelle, celle-ci déclenche un mise à jour directement, il est recommandé d’appliquer node par node via l’utilisation de l’argument target de terraform, ou d’utiliser talosctl upgrade-k8s. |
cluster_endpoint | Le endpoint d’accès à Kube API server. Si vide utilise le hostname généré du 1er control plane. Est exporté dans la génération du kubeconfig. |
cluster_name | Le nom du cluster kube. Servira de préfixe au niveau des hostnames de l’ensemble des nœuds. |
existing_network_id | ID Hcloud du réseau existant à utiliser. Si vide création d’un nouveau réseau de même nom que le cluster. |
network_zone | Zone géographique du réseau Hcloud en cas de création. |
network_ipv4_cidr | Plage CIDR IPv4 du réseau Hcloud en cas de création. C’est ici que l’on utilisera 10.0.0.0/10, laissant la place à d’autres réseaux possible dans le même projet. |
firewall_talos_api_source | Liste des IPs à autoriser sur l’API Talos port 50000. Uniquement valable pour l’accès externe. Du fait du mode Zero Trust, on laissera la valeur par défaut (aucun accès). |
firewall_kube_api_source | Liste des IPs à autoriser sur l’API Kube port 6443. Uniquement valable pour l’accès externe. Du fait du mode Zero Trust, on laissera la valeur par défaut (aucun accès). |
config_patches | Liste des patchs de config Talos à appliquer sur l’ensemble des nœuds. Notamment utile pour la configuration du réseau du cluster (pods et service CIDR, CNI, etc.). |
control_planes_image_name | Nom de l’image snapshot à utiliser pour l’installation des panneaux de contrôles. |
control_planes_placement_group | Groupe de placement des serveurs de panneau de contrôle pour optimiser la disponibilité. Inutile si locations différentes. |
control_planes_config_patches | Liste des patchs de config Talos à appliquer sur les panneaux de contrôles. Utile pour la configuration de l’extension Tailscale. |
control_planes_ipv4_cidr | CIDR du Subnet dédié aux nœuds de panneau de contrôle. |
control_planes | Définition des panneaux de contrôle, au nombre obligatoirement impair pour le quorum. Possibilité de définir hostname, différent emplacement géographique, type de serveur, adresse IP privée selon la plage CIDR pré-définie. |
worker_nodepools | Définition des pools de nœuds de travail. |
worker_nodepools.*.name | Nom du pool. Utilisé dans les hostnames des nœuds du même pool. |
worker_nodepools.*.server_type | Type de serveur Hcloud par défaut à utiliser pour les nœuds du pool. Peut être surchargé sur chaque node. |
worker_nodepools.*.location | Emplacement géographique par défaut des nœuds du pool. Peut être surchargé sur chaque node. |
worker_nodepools.*.image_name | Nom de l’image snapshot à utiliser pour l’installation des nœuds de ce pool. |
worker_nodepools.*.placement_group | Groupe de placement des nœuds de travail pour optimiser la disponibilité. Limite le pool à 10 nœuds max. |
worker_nodepools.*.config_patches | Liste des patchs de config Talos à appliquer sur les nœuds du pool. Notamment pour la configuration des labels, volumes, teintes, etc. |
worker_nodepools.*.config_patches_apply | Liste des patchs de config Talos à appliquer sur les nœuds du pool uniquement après que la machine soit créée et bootée au moment du apply. Utile pour les configs dépendant de conditions post-installation, typiquement le montage d’un volume externe. |
worker_nodepools.*.ipv4_cidr | CIDR du subnet dédié au pool. |
worker_nodepools.*.nodes | Liste des nœuds de travail dans le pool, identifiés par leur nom de suffixe et leur adresse IP privées. Possibilité d’y surcharger le type de serveur et la localisation. |
worker_nodepools.*.volumes | Liste des volumes Hcloud à créer et rattacher à chaque nœud du pool. Déclarer ici 2 volumes sur un pool de 3 nœud équivaut à créer 6 volumes au total, chaque nœud ayant 2 volumes montés. |
Déclaration
Ceci fait, voilà comment on peut utiliser ces variables pour déclarer notre cluster complet selon notre architecture cible, découpé en un fichier Terraform unique et plusieurs fichiers de config yaml.
module "hcloud_talos" { source = "../../modules/hcloud"
hcloud_token = var.hcloud_token
talos_version = "v1.12.2" talos_snapshots = [ { name = "cp", schematic_id = "4a0d65c669d46663f377e7161e50cfd570c401f26fd9e7bda34a0216b6f1922b" }, { name = "wk", schematic_id = "613e1592b2da41ae5e265e8789429f22e121aab91cb4deb6bc3c0b6262961245" }, ]
kubernetes_version = "v1.35.0" cluster_name = "ohmytalos-dev" network_zone = "eu-central" network_ipv4_cidr = "10.0.0.0/10"
config_patches = [ file("${path.module}/patches/volume-state.yaml"), templatefile("${path.module}/patches/volume-ephemeral.yaml", { volume_encryption_passphrase = var.volume_encryption_passphrase }), file("${path.module}/patches/config-cluster.yaml"), ]
control_planes_ipv4_cidr = "10.0.0.0/24" control_planes_image_name = "cp" control_planes_config_patches = [ templatefile("${path.module}/patches/extension-tailscale.yaml", { ts_auth_key = var.ts_auth_key }), yamlencode({ cluster = { extraManifests = [ "https://raw.githubusercontent.com/alex1989hu/kubelet-serving-cert-approver/main/deploy/standalone-install.yaml" ] } }) ]
control_planes = [ { name = "nbg1" server_type = "cpx22" location = "nbg1" ip = "10.0.0.2" }, { name = "fsn1" server_type = "cpx22" location = "fsn1" ip = "10.0.0.3" }, { name = "hel1" server_type = "cpx22" location = "hel1" ip = "10.0.0.4" } ]
worker_nodepools = [ { name = "worker" server_type = "cpx32" location = "nbg1" image_name = "wk" placement_group = "workers" ipv4_cidr = "10.0.1.0/24" nodes = [ { name = "ndk", ip = "10.0.1.1" }, { name = "opb", ip = "10.0.1.2" }, { name = "ozi", ip = "10.0.1.3" } ] config_patches = [ file("${path.module}/patches/config-machine-worker.yaml") ] }, { name = "storage" server_type = "cpx32" location = "nbg1" image_name = "wk" placement_group = "storages" ipv4_cidr = "10.0.2.0/24" nodes = [ { name = "vsh", ip = "10.0.2.1" }, { name = "wty", ip = "10.0.2.2" } ] volumes = [ { name = "longhorn", size = 80 } ] config_patches = [ file("${path.module}/patches/config-machine-storage.yaml") ] config_patches_apply = [ templatefile("${path.module}/patches/volume-longhorn.yaml", { volume_encryption_passphrase = var.volume_encryption_passphrase }) ] } ]}Explanation
Plutôt limpide ! Avantage par rapport à Talhelper, la configuration Talos se greffe en fonction de l’infra physique, et non l’inverse. On peut ainsi plus facilement raisonner en terme d’infra (type de serveur, emplacement, réseau, etc.) et de config (cluster, stockage, extensions, etc.) de manière séparée, et tout se fait en une seule fois. Reste plus qu’à écrire la logique…
Points notables :
talos_snapshots: On précise les 2 snapshots disponibles créées précédemment via packer.config_patches: On applique les patchs communs à l’ensemble des nœuds, notamment la configuration du réseau du cluster (CIDR pods et services, CNI, etc.) et chiffrement des volumes systèmes.control_planes_config_patches: On y configure l’extension Tailscale ainsi que le contrôleur d’approbation automatique des certificats kubelet du fait de l’activationrotate-server-certificates.control_planes: On déclare les 3 panneaux de contrôle, chacun dans un emplacement géographique différent pour la résilience.worker_nodepools: Et enfin tous nos nodepools, chacun dans leur propre subnet. Je choisis de nommer les nœuds des différents pools selon un suffixe aléatoire fixe. Libre à vous de faire autrement. On utiliseplacement_grouppour optimiser la disponibilité des nœuds en cas de défaillance (limité jusqu’à 10). Préciser le volume externe à utiliser pour chaque nœud du pool de storage. Utiliserconfig_patches_applypour appliquer la configuration de volume Longhorn uniquement après que le volume soit montée.
cluster: externalCloudProvider: enabled: true network: cni: name: none podSubnets: - 10.42.0.0/16 serviceSubnets: - 10.43.0.0/16 proxy: disabled: truemachine: kubelet: extraConfig: imageGCLowThresholdPercent: 50 imageGCHighThresholdPercent: 55Explanation
Du fait de l’utilisation du CCM de Hcloud, activer externalCloudProvider. Désactiver le CNI et le proxy kube par défaut, à remplacer plus tard par Cilium. Préciser les CIDR des pods et services, le CIDR utilisé pour la communication entre pods impliquera la création automatique d’une route native pour chaque nœud en 10.42.x.0/24 par le CCM Hcloud. Enfin, configurer le kubelet pour une gestion par défaut moins agressive du garbage collector d’images afin de laisser la place aux volumes locaux de longhorn.
machine: nodeLabels: node.longhorn.io/create-default-disk: config nodeAnnotations: node.longhorn.io/default-disks-config: '[{"allowScheduling":true,"name":"system","path":"/var/lib/longhorn","tags":["local"]}]' node.longhorn.io/default-node-tags: '["worker"]'machine: nodeLabels: node.kubernetes.io/exclude-from-external-load-balancers: "true" node.kubernetes.io/role: storage node.longhorn.io/create-default-disk: config nodeAnnotations: node.longhorn.io/default-disks-config: '[{"allowScheduling":true,"name":"system","path":"/var/lib/longhorn","tags":["local"]},{"allowScheduling":true,"name":"volume","path":"/var/mnt/longhorn","tags":["volume"]}]' node.longhorn.io/default-node-tags: '["storage"]' kubelet: extraConfig: registerWithTaints: - key: node-role.kubernetes.io/storage effect: NoScheduleExplanation
Principalement de la config Longhorn, déjà expliqué au chapitre précédent, des labels et des teintes pour s’assurer de ne pas scheduler de pods de workloads génériques sur les nœuds de storage, et des labels pour les identifier plus facilement. Load Balancer seulement sur les nœuds web frontaux.
apiVersion: v1alpha1kind: VolumeConfigname: STATEencryption: provider: luks2 keys: - slot: 0 nodeID: {}apiVersion: v1alpha1kind: VolumeConfigname: EPHEMERALencryption: provider: luks2 keys: - slot: 0 static: passphrase: ${volume_encryption_passphrase} lockToState: trueapiVersion: v1alpha1kind: UserVolumeConfigname: longhornprovisioning: diskSelector: match: disk.dev_path == '/dev/sdb' grow: true minSize: 40Giencryption: provider: luks2 keys: - slot: 0 static: passphrase: ${volume_longhorn_passphrase} lockToState: trueExplanation
Du chiffrement + configuration volume externe Longhorn pour le pool de storage avec agrandissement automatique.
apiVersion: v1alpha1kind: ExtensionServiceConfigname: tailscaleenvironment: - TS_AUTHKEY=${ts_auth_key}Explanation
Le token d’authentification Tailscale pour la connexion au réseau Tailnet pour les panneaux de contrôle.
Implémentation
locals { talos_snapshots = { for s in var.talos_snapshots : s.name => s.schematic_id } talos_endpoints = length(var.talos_endpoints) > 0 ? var.talos_endpoints : [for s in local.control_planes : "${var.cluster_name}-${s.name}"]
machine_base_config = { kubelet = { nodeIP = { validSubnets = concat( [ var.control_planes_ipv4_cidr, ], [ for s in hcloud_network_subnet.worker : s.ip_range ] ) } extraArgs = { "rotate-server-certificates" = true } } features = { hostDNS = { enabled = true forwardKubeDNSToHost = true resolveMemberNames = true } } time = { servers = [ "ntp1.hetzner.de", "ntp2.hetzner.com", "ntp3.hetzner.net", "time.cloudflare.com" ] } }
machine_control_plane_config = merge(local.machine_base_config, { features = merge(local.machine_base_config.features, { kubernetesTalosAPIAccess = { enabled = true allowedRoles = [ "os:reader", "os:etcd:backup" ] allowedKubernetesNamespaces = [ "kube-system", ] } }) })
cluster_control_plane_config = { controllerManager = { extraArgs = { "bind-address" = "0.0.0.0" } } etcd = { advertisedSubnets = [ var.control_planes_ipv4_cidr ] extraArgs = { "listen-metrics-urls" = "http://0.0.0.0:2381" } } scheduler = { extraArgs = { "bind-address" = "0.0.0.0" } } }
control_planes = [ for i, s in var.control_planes : { name = "control-plane-${s.name}" server_type = s.server_type location = s.location machine_type = "controlplane" firewall_ids = [hcloud_firewall.talos_api.id, hcloud_firewall.kube_api.id] private_ipv4 = s.ip placement_group_id = var.control_planes_placement_group != null ? hcloud_placement_group.this[var.control_planes_placement_group].id : null image_name = coalesce(var.control_planes_image_name, "default") machine_config = local.machine_control_plane_config cluster_config = local.cluster_control_plane_config config_patches = var.control_planes_config_patches config_patches_apply = [] } ] workers = flatten([ for i, np in var.worker_nodepools : [ for index, n in np.nodes : { name = "${np.name}-${n.name}" server_type = coalesce(n.server_type, np.server_type) location = coalesce(n.location, np.location) machine_type = "worker" firewall_ids = [hcloud_firewall.talos_api.id] private_ipv4 = n.ip placement_group_id = np.placement_group != null ? hcloud_placement_group.this[np.placement_group].id : null image_name = coalesce(np.image_name, "default") machine_config = local.machine_base_config cluster_config = {} config_patches = coalesce(np.config_patches, []) config_patches_apply = coalesce(np.config_patches_apply, []) volumes = coalesce(np.volumes, []) } ] ])
servers = [for s in concat(local.control_planes, local.workers) : merge(s, { config_patches = concat( [ yamlencode({ machine = merge( s.machine_config, { install = { image = "factory.talos.dev/hcloud-installer/${local.talos_snapshots[s.image_name]}:${var.talos_version}" } } ) cluster = s.cluster_config }) ], var.config_patches, coalesce(s.config_patches, []) ) })]
volumes = flatten([ for i, s in local.workers : [ for v in s.volumes : { server_name = s.name location = s.location name = "${s.name}-${v.name}" size = v.size } ] ])}Explanation
Ce fichier ne sert essentiellement qu’à construire des structures de données complexes à partir des variables d’entrée, pour simplifier la logique dans les autres fichiers. On y retrouve la configuration Talos de base, celle spécifique aux panneaux de contrôle, la configuration du cluster, et la construction des listes de serveurs et volumes à créer.
resource "hcloud_network" "this" { count = var.existing_network_id == null ? 1 : 0 name = var.cluster_name ip_range = var.network_ipv4_cidr}
resource "hcloud_network_subnet" "control_plane" { network_id = coalesce(var.existing_network_id, hcloud_network.this[0].id) type = "cloud" network_zone = var.network_zone ip_range = var.control_planes_ipv4_cidr}
resource "hcloud_network_subnet" "worker" { for_each = { for np in var.worker_nodepools : np.name => np } network_id = coalesce(var.existing_network_id, hcloud_network.this[0].id) type = "cloud" network_zone = var.network_zone ip_range = each.value.ipv4_cidr}Explanation
Tout commence par la création de l’architecture réseau. Cela consiste juste à un réseau principal, avec un subnet dédié aux panneaux de contrôle et un subnet par pool de nœuds de travail.
resource "hcloud_firewall" "talos_api" { name = "${var.cluster_name}-talos-api"
rule { description = "Allow Incoming Talos API Traffic" direction = "in" protocol = "tcp" port = "50000" source_ips = var.firewall_talos_api_source }}
resource "hcloud_firewall" "kube_api" { name = "${var.cluster_name}-kube-api"
rule { description = "Allow Incoming Requests to Kube API Server" direction = "in" protocol = "tcp" port = "6443" source_ips = var.firewall_kube_api_source }}Explanation
Ensuite, on définit les pare-feux pour d’éventuel accès externes aux API Talos et Kube. Par défaut tout est bloqué, on n’autorise que l’adresse de loopback.
data "hcloud_image" "talos_x86_snapshot" { for_each = local.talos_snapshots with_selector = "version=${var.talos_version},name=${each.key}" with_architecture = "x86" most_recent = true}
data "hcloud_image" "talos_arm_snapshot" { for_each = local.talos_snapshots with_selector = "version=${var.talos_version},name=${each.key}" with_architecture = "arm" most_recent = true}
resource "hcloud_server" "this" { for_each = { for s in local.servers : s.name => s } name = "${var.cluster_name}-${each.key}" server_type = each.value.server_type location = each.value.location image = substr(each.value.server_type, 0, 3) == "cax" ? data.hcloud_image.talos_arm_snapshot[each.value.image_name].id : data.hcloud_image.talos_x86_snapshot[each.value.image_name].id
placement_group_id = each.value.placement_group_id firewall_ids = each.value.firewall_ids
network { network_id = coalesce(var.existing_network_id, hcloud_network.this[0].id) ip = each.value.private_ipv4 alias_ips = [] }
user_data = data.talos_machine_configuration.this[each.value.name].machine_configuration
labels = { type = each.value.machine_type }
depends_on = [ hcloud_network_subnet.control_plane, hcloud_network_subnet.worker ]
lifecycle { ignore_changes = [ user_data, image ] }}
resource "hcloud_placement_group" "this" { for_each = { for pg in distinct(concat( compact([ var.control_planes_placement_group ]), [ for s in var.worker_nodepools : s.placement_group if s.placement_group != null ]) ) : pg => pg } name = "${var.cluster_name}-${each.value}" type = "spread"}Explanation
Ensuite, on crée les serveurs dans leur groupe de placement, si défini. Les serveurs sont directement :
- Bloqués par le pare-feu avant démarrage.
- Branchés au bon réseau avec la bonne IP privée qui déterminera le subnet.
- Initialisés avec la bonne configuration Talos via le
user_data.
On choisit l’image snapshot adaptée à l’architecture cible.
resource "hcloud_volume" "this" { for_each = { for v in local.volumes : v.name => v if v.size >= 10 } name = "${var.cluster_name}-${each.key}" size = each.value.size location = each.value.location}
resource "hcloud_volume_attachment" "this" { for_each = { for v in local.volumes : v.name => v if v.size >= 10 } volume_id = hcloud_volume.this[each.key].id server_id = hcloud_server.this[each.value.server_name].id}Explanation
On crée et l’on attache les volumes externes sur chaque nœud du pool dont lesdits volumes ont été définis.
resource "talos_machine_secrets" "this" { talos_version = var.talos_version}
data "talos_client_configuration" "this" { cluster_name = var.cluster_name client_configuration = talos_machine_secrets.this.client_configuration endpoints = local.talos_endpoints nodes = [ for s in local.servers : s.private_ipv4 ]}
data "talos_machine_configuration" "this" { for_each = { for m in local.servers : m.name => m } cluster_name = var.cluster_name kubernetes_version = var.kubernetes_version machine_type = each.value.machine_type cluster_endpoint = coalesce( var.cluster_endpoint, "https://${var.cluster_name}-${local.control_planes[0].name}:6443" ) machine_secrets = talos_machine_secrets.this.machine_secrets talos_version = var.talos_version docs = false examples = false config_patches = each.value.config_patches}
resource "talos_machine_configuration_apply" "this" { for_each = { for s in local.servers : s.name => s } client_configuration = talos_machine_secrets.this.client_configuration machine_configuration_input = data.talos_machine_configuration.this[each.value.name].machine_configuration endpoint = local.talos_endpoints[0] node = each.value.private_ipv4 config_patches = each.value.config_patches_apply depends_on = [ hcloud_server.this, hcloud_volume_attachment.this, ]}
resource "talos_machine_bootstrap" "this" { client_configuration = talos_machine_secrets.this.client_configuration endpoint = local.talos_endpoints[0] node = local.servers[0].private_ipv4 depends_on = [ talos_machine_configuration_apply.this ]}Explanation
Toute la configuration talos générée et à injecter dans les serveurs Hcloud. On reconnait certaines étapes de Talhelper :
- Génération des secrets, injectés dans le state terraform.
- Génération de la configuration client talosconfig pour les accès via apply.
- Génération de la configuration machine pour chaque nœud, en fonction de son type (panneau de contrôle ou nœud de travail), avec les patchs communs et spécifiques. Sera injecté dans le
user_datades serveurs Hcloud. - Application de la configuration sur chaque nœud, via le 1er endpoint Talos, une fois les serveurs en ligne. On oublie pas le
depends_onpour s’assurer que les serveurs sont bien créés avant. - Bootstrap du cluster kube depuis le 1er panneau de contrôle disponible. À effectuer en dernière étape finale après l’application des configurations.
output "talosconfig" { value = data.talos_client_configuration.this.talos_config sensitive = true}
output "kubeconfig_command" { description = "Command to get kubeconfig from talos." value = "talosctl -n ${join(",", [for s in local.control_planes : s.private_ipv4])} kubeconfig"}
output "health_command" { description = "Command to check health of the cluster. Be sure that CNI part is up and running." value = "talosctl -n ${local.control_planes[0].private_ipv4} health --control-plane-nodes ${join(",", [for s in local.control_planes : s.private_ipv4])} --worker-nodes ${join(",", [for s in local.workers : s.private_ipv4])}"}
output "upgrade_command" { description = "Command to upgrade the cluster." value = [for k, v in { for s in concat(local.control_planes, local.workers) : s.image_name => s.private_ipv4... } : "talosctl -n ${join(",", v)} upgrade --image factory.talos.dev/hcloud-installer/${local.talos_snapshots[k]}:${var.talos_version}" ]}Explanation
Similairement à Talhelper, on permet l’exportation du talosconfig, ainsi que la génération des commandes utiles pour récupérer le kubeconfig, vérifier la santé du cluster et effectuer les mises à jour de Talos sur chaque nœud avec les bonnes images.
On n’oublie pas de reporter les outputs dans le module terraform principal pour les exporter dans le state.
output "talosconfig" { value = module.hcloud_talos.talosconfig sensitive = true}
output "kubeconfig_command" { value = module.hcloud_talos.kubeconfig_command}
output "health_command" { value = module.hcloud_talos.health_command}
output "upgrade_command" { value = module.hcloud_talos.upgrade_command}Et voilà, c’est l’heure du grand test, lancer terraform apply et prier ! Encore une fois, si l’approbation des nœuds des control planes au réseau Tailnet est manuel, penser à accepter sur l’admin Tailscale les nouveaux devices qui devraient apparaître après la création des serveurs, i.e. au moment où terraform lance les opérations module.hcloud_talos.talos_machine_configuration_apply.this.
Tip
Si Tailnet Lock est actif, utiliser les commandes tailscale lock status pour choper le nodekey puis tailscale lock sign nodekey:xxx.
Le terraform apply terminé, il ne reste plus qu’à récupérer le talosconfig et le kubeconfig :
terraform output -raw talosconfig > ~/.talos/configtalosctl -n 10.0.0.2 kubeconfigUtiliser talosctl -n 10.0.0.2 dashboard pour accéder au TUI Talos qui devrait afficher l’ensemble des composants cœur de kube healthy (kubelet, api-server, controller-manager, scheduler), mais en état not ready à gauche.
Lancer kubectl get nodes -o wide ou plutôt kgno -o wide pour vérifier l’état des nœuds. Après quelques minutes, ils devraient tous apparaître en état NotReady, attendant bien sagement l’installation du CNI.
NAME STATUS ROLES AGE VERSION INTERNAL-IP EXTERNAL-IP OS-IMAGE KERNEL-VERSION CONTAINER-RUNTIMEohmytalos-dev-control-plane-nbg1 NotReady control-plane 6m46s v1.35.0 10.0.0.2 <none> Talos (v1.12.2) 6.18.5-talos containerd://2.1.6ohmytalos-dev-control-plane-fsn1 NotReady control-plane 4m9s v1.35.0 10.0.0.3 <none> Talos (v1.12.2) 6.18.5-talos containerd://2.1.6ohmytalos-dev-control-plane-hel1 NotReady control-plane 6m39s v1.35.0 10.0.0.4 <none> Talos (v1.12.2) 6.18.5-talos containerd://2.1.6ohmytalos-dev-worker-ndk NotReady <none> 6m1s v1.35.0 10.0.1.1 <none> Talos (v1.12.2) 6.18.5-talos containerd://2.1.6ohmytalos-dev-worker-opb NotReady <none> 5m43s v1.35.0 10.0.1.2 <none> Talos (v1.12.2) 6.18.5-talos containerd://2.1.6ohmytalos-dev-worker-ozi NotReady <none> 5m43s v1.35.0 10.0.1.3 <none> Talos (v1.12.2) 6.18.5-talos containerd://2.1.6ohmytalos-dev-storage-vsh NotReady <none> 5m22s v1.35.0 10.0.2.1 <none> Talos (v1.12.2) 6.18.5-talos containerd://2.1.6ohmytalos-dev-storage-wty NotReady <none> 6m6s v1.35.0 10.0.2.2 <none> Talos (v1.12.2) 6.18.5-talos containerd://2.1.6Ne lancez pas la commande talosctl -n 10.0.0.2 health pour le moment, puisque le cluster n’est pas encore opérationnel, bien que physiquement accessible.
Conclusion
Nous en avons terminé sur la création du cluster physique, suite à la prochaine section pour l’installation de l’infra logicielle kube.