Dans la partie 10, on a fabriqué les images de base et adopté un vrai workflow de branches. À la toute fin, une question était restée en suspens : “si pve01 crash, le runner CI redémarre-t-il ailleurs ?” La réponse était non, et cette phase 5 est celle qui transforme ce “non” en “oui, en 1 min 14, prouvé chronomètre en main”.
Deux sujets : d’abord rendre un service réellement résilient (réplication ZFS + haute disponibilité, validé par un test de bascule en conditions réelles : on éteint un nœud pour de vrai), puis déployer Dokploy, le premier vrai service applicatif du lab.
Et le fil rouge : quand une ressource passe sous HA, deux attributs cessent d’appartenir au code : où elle tourne, et si elle tourne. OpenTofu et le gestionnaire de HA deviennent deux autorités sur la même ressource, et il faut leur apprendre à ne pas se marcher dessus.
La haute disponibilité n’est pas une case à cocher#
Reprenons la question de départ : pourquoi le runner ne survit-il pas à la perte de pve01 ? Pour deux raisons cumulées :
- Le HA n’est pas configuré. Proxmox ne redémarre une ressource ailleurs que si elle est déclarée dans son gestionnaire de haute disponibilité. Sans ça, un conteneur (ou une VM d’ailleurs) sur un nœud mort reste mort.
- Même avec le HA, ça ne suffirait pas. Le disque du conteneur vit dans le pool ZFS local de pve01. Les autres nœuds ont bien un pool du même nom, mais vide. Redémarrer le conteneur sur pve02 sans ses données ne mène nulle part.
Le point central de la phase. La haute disponibilité n’est pas une case à cocher, c’est une chaîne : cluster quorate → pools de même nom sur chaque nœud → réplication des données → déclaration HA. Chaque maillon est nécessaire, aucun n’est suffisant seul. C’est toute la différence entre “j’ai activé le HA” et “j’ai vérifié que mon service redémarre vraiment ailleurs”.
On va donc construire la chaîne, maillon par maillon, sur le runner, qui fait un excellent banc d’essai : pas de données critiques, des pipelines qui peuvent attendre.
Partie 1 : rendre le runner résilient#
La réplication ZFS#
Premier maillon : les données doivent exister ailleurs avant la panne. C’est le rôle de la réplication ZFS, qui envoie régulièrement des snapshots incrémentaux du pool local vers les autres nœuds. En OpenTofu :
# tofu/stacks/core/runner_ha.tf (réplication)
locals {
# Tous les nœuds sauf celui qui héberge le runner
runner_replica_targets = [for n in var.cluster_nodes : n if n != var.runner_node]
}
resource "proxmox_virtual_environment_replication" "runner" {
for_each = { for idx, node in local.runner_replica_targets : node => idx }
id = "${proxmox_virtual_environment_container.gitlab_runner.vm_id}-${each.value}"
type = "local"
target = each.key
schedule = var.replication_schedule
comment = "Managed by OpenTofu"
}Deux jobs, un par nœud de destination : le local calcule “tous les nœuds sauf celui du runner” et le for_each crée un job par cible. Pour le runner sur pve01, ça donne un job 200-0 vers pve02 et un 200-1 vers pve03. La réplication est unidirectionnelle, donc il faut bien une copie par destination.
La variable replication_schedule vaut */15 (toutes les 15 minutes) par défaut : c’est elle qui définit le RPO (Recovery Point Objective), au pire on perd 15 minutes de données. C’est un compromis à prendre mais c’est largement suffisant pour un runner CI.
Un réflexe pris depuis la phase 4 : inspecter le schéma avant d’écrire. Le schéma révèle que guest, jobnum et source sont computed : le provider les dérive de l’id (au format <vmid>-<jobnum>) et de l’emplacement réel du guest. Les définir soi-même produit une erreur Invalid Configuration for Read-Only Attribute.
tofu providers schema -json \
| jq '.provider_schemas[].resource_schemas["proxmox_virtual_environment_replication"].block.attributes'Déclarer le service au HA#
Deuxième maillon : dire au cluster que ce conteneur doit être géré en haute disponibilité. C’est le rôle de la ressource haresource, le maillon qui, concrètement, met ct:200 sous la responsabilité du gestionnaire HA :
# tofu/stacks/core/runner_ha.tf (déclaration HA)
resource "proxmox_virtual_environment_haresource" "runner" {
resource_id = "ct:${proxmox_virtual_environment_container.gitlab_runner.vm_id}"
type = "ct"
state = "started"
max_restart = 1
max_relocate = 1
comment = "Managed by OpenTofu"
depends_on = [proxmox_virtual_environment_replication.runner]
}Le state = "started" dit “le HA doit maintenir ce service démarré”, et le depends_on garantit que la réplication existe avant la mise sous HA : inutile de déclarer résilient un service dont les données ne sont encore nulle part ailleurs.
Test de bascule n°1 : sans règle d’affinité#
À ce stade, la chaîne minimale est en place : les données sont répliquées, le service est déclaré au HA. Mais aucune préférence de nœud n’est encore posée. Testons ce socle : on éteint pve01 franchement et on regarde les logs du CRM (le gestionnaire de ressources du cluster) :
| Horodatage | Événement |
|---|---|
| 19:40:48 | pve01 arrête le conteneur (extinction) |
| 19:41:02 | CRM : pve01 online => unknown |
| 19:41:52 | Fencing → recover ct:200 from pve01 to pve02 |
| 19:41:58 | Conteneur démarré sur pve02 |
Bascule en 1 min 10. Service opérationnel sur pve02, même IP, même hostname, up 0 min. La chaîne fonctionne.
Mais : au redémarrage de pve01, le conteneur reste sur pve02. C’est voulu côté Proxmox (une bascule inverse serait une deuxième interruption), mais ça pose un vrai problème côté infrastructure-as-code. Lequel ? C’est l’accroc suivant.
Deux autorités pour une ressource#
Avec le conteneur sur pve02 alors que le code dit pve01, un simple tofu plan donne des sueurs froides :
# proxmox_virtual_environment_container.gitlab_runner has been deleted
Plan: 2 to add, 0 to change, 1 to destroyOpenTofu propose de recréer le conteneur sur pve01. Appliquer aurait détruit le runner en fonctionnement, ou créé un conflit d’ID.
La cause est subtile : le provider bpg identifie une ressource par le couple nœud + vmid. Le state dit “CT 200 sur pve01”, le provider va regarder sur pve01, ne trouve rien… et conclut que la ressource a été supprimée.
Et ignore_changes = [node_name] n’y peut rien. On pourrait croire qu’ignorer le changement de nœud règle le problème. Non : ignore_changes agit sur la comparaison d’attributs, pas sur la détection d’existence, qui a lieu avant. Au moment où OpenTofu se demande “existe-t-elle encore ?”, il regarde au mauvais endroit et répond “non”. C’est une limitation réelle du provider, à connaître avant de mettre quoi que ce soit sous HA : le HA déplace des ressources, et OpenTofu ne sait pas les suivre.
La parade est élégante : une règle de préférence de nœud (node-affinity), qu’on va poser juste après. Puisque le service reviendra tout seul sur son nœud préféré, la réalité converge vers ce que déclare le code. La dérive du state devient temporaire (le temps du retour) au lieu d’être permanente. On ne corrige pas la limitation du provider, on organise l’infra pour qu’elle ne se manifeste pas.
Les groupes HA n’existent plus en PVE 9#
Reste à poser cette préférence de nœud, pour que le runner rentre chez lui (pve01) dès qu’il le peut. Première tentative avec proxmox_virtual_environment_hagroup… et échec immédiat :
HTTP 500 - Reason: cannot create group: ha groups have been migrated to rulesProxmox VE 9 a remplacé les groupes HA par un système de règles. Le provider expose encore hagroup pour les clusters plus anciens, mais un cluster en version 9.2 le refuse net. La ressource de remplacement est proxmox_virtual_environment_harule, avec deux types : node-affinity (préférence de nœuds) et resource-affinity (garder ensemble ou séparer des services). C’est le premier qui remplace le groupe :
# tofu/stacks/core/runner_ha.tf (règle d'affinité)
resource "proxmox_virtual_environment_harule" "runner" {
rule = "runner-home"
type = "node-affinity"
resources = [proxmox_virtual_environment_haresource.runner.resource_id]
nodes = {
(var.runner_node) = 100
}
strict = false
comment = "Managed by OpenTofu"
}strict = false est capital. La règle exprime une préférence, pas une contrainte. Avec strict = true, le service ne pourrait tourner que sur les nœuds listés et donc ne redémarrerait nulle part si tous ces nœuds tombaient, annulant tout l’intérêt du HA. En false, pve01 est préféré, mais le service peut atterrir n’importe où en cas de panne.
Autre différence avec l’ancien système : plus de no_failback. Le retour au nœud préféré est le comportement par défaut des règles d’affinité, exactement ce qu’on cherchait.
Test de bascule n°2 : avec règle d’affinité#
On rejoue la panne, règle d’affinité en place cette fois.
Bascule (panne de pve01) :
| Horodatage | Événement |
|---|---|
| 19:54:45 | pve01 arrête le conteneur |
| 19:55:02 | CRM : pve01 online => unknown |
| 19:55:52 | Fencing → recover ct:200 to pve02 |
| 19:55:59 | Démarré sur pve02 |
→ 1 min 14. À noter : le CRM attend 50 secondes entre la détection et le fencing. C’est délibéré : on ne veut pas déplacer un service pour une perte réseau passagère de quelques secondes.
Retour automatique (pve01 revient) :
| Horodatage | Événement |
|---|---|
| 19:56:52 | pve01 unknown => online et relocate ct:200 to pve01 (même seconde) |
| 19:57:12 | relocate → started (node = pve01) |
| 19:58:58 | Démarré sur pve01 |
→ 2 min 06, dont ~1 min 45 de transfert et de redémarrage.
Le chiffre le plus parlant : la relocalisation est déclenchée dans la même seconde où le nœud repasse online. La règle d’affinité agit sans délai, contrairement au fencing qui, lui, temporise 50 secondes. Deux mécanismes, deux logiques de timing.
Bilan : indisponibilité d’environ 1 min 15, convergence complète en environ 3 min. Pour un homelab, c’est excellent, et surtout, c’est mesuré, pas supposé.
started aussi appartient au HA#
Un dernier piège, capté pendant les ~1 min 45 de démarrage sur pve01. Un tofu plan lancé à cet instant précis montrait :
~ started = false -> true
Plan: 0 to add, 1 to change, 0 to destroyOpenTofu voulait démarrer une VM que le HA était déjà en train de démarrer. Un apply à ce moment aurait fait entrer les deux autorités en conflit direct. Ce cas précis, c’est celui que le lifecycle déjà posé sur le runner en partie 9 anticipait : à l’époque “au cas où”, il trouve ici sa justification concrète, incident réel à l’appui :
# tofu/stacks/core/runner.tf (bloc lifecycle du conteneur runner, posé en partie 9)
lifecycle {
ignore_changes = [node_name, started]
}LE principe de la phase. Quand une ressource passe sous HA, deux attributs cessent d’appartenir au code : où elle tourne (node_name) et si elle tourne (started). OpenTofu déclare qu’elle doit exister et être hautement disponible, le cluster décide du reste. Sans ces ignore_changes, l’IaC entre périodiquement en conflit avec le gestionnaire HA : à chaque bascule, à chaque retour.
Partie 2 : Dokploy, le premier vrai service#
Le mécanisme HA validé, on peut déployer quelque chose qui compte : Dokploy, une plateforme de déploiement d’applications (façon Heroku auto-hébergé), qui servira de socle applicatif au lab.
Une VM, pas un LXC#
Décision déjà tranchée en partie 10 : Dokploy tourne dans une VM. Il s’appuie sur Docker Swarm, qui manipule des réseaux overlay, iptables et des modules noyau, mal supportés dans un conteneur non privilégié. Même avec nesting=true, on passerait son temps à contourner. LXC pour les services simples, VM dès qu’il y a une couche d’orchestration à l’intérieur.
Dimensionnement et clonage#
4 Go de RAM, 2 vCPU, 40 Go de disque, IP 192.168.3.51, sur pve02, pour répartir la charge (le runner occupant déjà pve01). La VM est un clone du template Debian 9000 créé précédemment :
# tofu/stacks/core/dokploy.tf (extrait)
clone {
# Template lives on runner_node; this clone crosses nodes
node_name = var.runner_node
vm_id = proxmox_virtual_environment_vm.debian_template.vm_id
full = true
}full = true : un clone complet, pas lié. Un clone lié dépendrait en permanence du template 9000 (qu’on ne pourrait alors plus supprimer, et qui est attaché à pve01). Le clone complet est autonome, donc migrable et répliquable : indispensable pour le HA.
Le nœud source du clone, un piège du stockage non partagé. Première tentative sans préciser node_name dans le bloc clone : HTTP 500 - unable to find configuration file for VM 9000 on node 'pve02'. Le provider cherchait le template sur le nœud de destination (pve02). Comme local n’est pas partagé, il faut désigner explicitement la source (pve01). Encore une conséquence du “local n’est pas partagé” qui traverse toute la série.
L’agent qu’on attend pendant 15 minutes#
Voici l’accroc le plus déroutant de la phase. La création de la VM restait bloquée :
proxmox_virtual_environment_vm.dokploy: Still creating... [1m30s elapsed]…alors que la VM répondait déjà au ping et au SSH. Elle était debout, mais OpenTofu refusait de considérer la création terminée.
La cause : agent { enabled = true } est une promesse faite au provider. Il attend que l’agent QEMU réponde pour récupérer l’IP de la VM, avec un timeout de 15 minutes. Or les images cloud officielles n’embarquent pas qemu-guest-agent. La promesse ne pouvait donc jamais être tenue. Pire : ce blocage touchait aussi le refresh, donc même un simple tofu plan restait suspendu.
Le correctif propre : installer l’agent via cloud-init, ce qui bénéficie à toutes les VM futures :
# tofu/stacks/core/cloud_init.tf
locals {
vendor_data = <<-EOT
#cloud-config
package_update: true
packages:
- qemu-guest-agent
runcmd:
- systemctl enable --now qemu-guest-agent
EOT
}
resource "proxmox_virtual_environment_file" "vendor_data" {
for_each = toset(var.cluster_nodes)
node_name = each.value
content_type = "snippets"
datastore_id = "local"
source_raw {
data = local.vendor_data
file_name = "vendor-data-agent.yaml"
}
}Côté VM, ce snippet est branché via initialization { vendor_data_file_id = ... } : la VM Dokploy pointe vers la copie du snippet présente sur son propre nœud.
Prérequis, comme pour import en partie 10 : activer le contenu snippets sur le stockage.
pvesm set local --content iso,vztmpl,backup,import,snippetsvendor_data plutôt que user_data. cloud-init distingue les deux : vendor_data porte la configuration commune à toutes les VM (ici, l’agent), tandis que user_data doit rester libre pour la configuration spécifique à chaque VM. Mélanger les deux, c’est se priver du user_data là où on en aura besoin.
Résultat : recréation de la VM, 1 min 45 de création (le temps que cloud-init installe le paquet), puis qemu-guest-agent: active sans intervention. Le chemin est validé depuis zéro.
Installer Dokploy : le script officiel, encadré par Ansible#
Deux approches possibles pour installer Dokploy :
- Réimplémenter chaque étape en tâches Ansible, plus “propre” en apparence.
- Utiliser le script d’installation officiel (
curl | sh), une boîte noire.
J’ai choisi le script officiel, encadré par Ansible. Le rôle vérifie l’état avant d’agir et ne (re)lance l’installeur que si Dokploy est absent du Swarm :
# ansible/roles/dokploy/tasks/main.yml (extrait)
- name: Check whether Dokploy is already deployed
ansible.builtin.command: docker service ls --filter name=dokploy --format '{{ "{{" }}.Name{{ "}}" }}'
register: dokploy_service
changed_when: false
failed_when: false
when: dokploy_docker.rc == 0
- name: Download installer
ansible.builtin.get_url:
url: "{{ dokploy_install_url }}"
dest: /tmp/dokploy-install.sh
mode: "0755"
when: dokploy_service.stdout | default('') is not search('dokploy')
- name: Run installer
ansible.builtin.command: /tmp/dokploy-install.sh
when: dokploy_service.stdout | default('') is not search('dokploy')
changed_when: true
- name: Wait for the web interface
ansible.builtin.wait_for:
port: "{{ dokploy_port }}"
delay: 5
timeout: 300Pourquoi ne pas tout réimplémenter en Ansible. Le script Dokploy installe Docker, initialise un Swarm, déploie Postgres/Redis/Traefik/l’interface et écrit sa configuration. Réimplémenter tout ça, ce serait réécrire un installeur qui évoluera sans moi : obsolète et silencieusement faux à la première mise à jour amont. Et l’apprentissage serait maigre : j’apprendrais l’agencement interne d’un produit tiers, pas un concept transposable.
Ce qui est formateur et utile, c’est de rendre l’exécution idempotente et vérifiable, et ça, c’est le vrai travail d’Ansible. Le rôle détecte Docker, puis la présence du service dokploy dans Swarm, et ne (re)lance l’installeur que s’il est absent. Idempotence validée : changed=0 et tâches skipped au second passage.
Deux accrocs mineurs au passage :
- Le silence prolongé du module
command: Ansible ne remonte la sortie qu’à la fin de la commande, donc 5 à 10 minutes sans savoir si l’installeur travaille ou s’il est bloqué. Acceptable pour un installeur tiers ; pour du code à soi, on découperait en étapes visibles. - Le conflit Jinja2 / Docker : le
--format '{{ ".Name" }}'de Docker (visible ci-dessus) échoue si on l’écrit naïvement, parce qu’Ansible interprète les{{ }}avant Docker. D’où l’échappement'{{ "{{" }}.Name{{ "}}" }}'dans le rôle. En commande ad-hoc, mieux vaut carrément éviter les templates Docker.
Le résultat, une fois l’installeur passé :
ID NAME MODE REPLICAS IMAGE
... dokploy replicated 1/1 dokploy/dokploy:v0.29.13
... dokploy-postgres replicated 1/1 postgres:16
CONTAINER IMAGE STATUS PORTS
... traefik:v3.6.7 Up 2 minutes 80->80, 443->443
... dokploy Up (healthy) 3000->3000
... postgres:16 Up 5 minutes 5432Traefik écoute déjà sur les ports 80 et 443, ça servira dès la phase des certificats.
Le premier écart assumé au “tout en code”#
Il faut le dire franchement : le compte administrateur Dokploy est créé à la main, dans l’interface web.
Ce n’est pas un échec, c’est une amorce hors-git assumée. Beaucoup d’applications exigent un premier compte interactif, sans API pour l’automatiser. La bonne pratique est celle appliquée ici : le documenter, ranger les identifiants dans SOPS et savoir que cette étape sera à refaire manuellement lors d’une reconstruction. Même statut que la clé age ou le token GitLab : un point d’amorçage qui ne peut pas vivre dans git et qu’on assume en le traçant.
Un piège de lecture : available, pas used#
Proxmox affichait 100 % de RAM utilisée sur la VM Dokploy. Panique ? Non. Vu de l’intérieur :
total used free buff/cache available
Mem: 3.8Gi 1.3Gi 176Mi 2.6Gi 2.5GiLinux remplit délibérément la RAM libre avec du cache disque, libérable instantanément dès qu’une application en a besoin. Proxmox, lui, voit des pages touchées sans distinguer le cache du reste, d’où le faux 100 %.
La métrique qui compte, c’est available. Ici, 2,5 Go réellement disponibles sur 3,8. Seuil d’alerte utile : sous ~500 Mo d’available. C’est un piège de lecture classique en virtualisation. Détail relevé au passage : la VM n’a pas de swap (courant sur les images cloud), donc un pic mémoire déclencherait l’OOM killer au lieu de ralentir. À ajouter via cloud-init si le besoin se présente.
HA sur Dokploy : le patron s’applique#
Reste à mettre Dokploy sous HA. Et là, bonne nouvelle : c’est le patron déjà validé sur le runner, à quelques paramètres près : type = "vm", resource_id = "vm:201" et le nœud préféré pve02.
Première réplication : 66 secondes par cible pour une VM de 40 Go (~3,3 Go réellement utilisés). Les réplications suivantes ne transfèrent que les blocs modifiés : quelques secondes. Tenable même sur du 1 GbE. (Accroc mineur : pvesr prend un verrou global pendant une réplication, can't lock file '/var/lock/pvesr.lck'. C’est de la contention normale, pas une erreur.)
État final des deux services sous HA :
service ct:200 (pve01, started)
service vm:201 (pve02, started)Deux services hautement disponibles, sur deux nœuds différents, chacun répliqué vers les deux autres.
Une confusion fréquente : HA sur 3, réplication vers 2#
“Du coup, on ne fait le HA qu’entre 2 hôtes ?” Non, et c’est une confusion classique. Ce sont deux mécanismes distincts :
- La réplication crée deux jobs (un par destination), parce que ce sont des copies unidirectionnelles.
- Le HA n’a qu’une ressource, et peut redémarrer le service n’importe où, puisque les données sont déjà partout.
| Service | Nœud préféré | Répliqué vers | Peut redémarrer sur |
|---|---|---|---|
| runner (ct:200) | pve01 | pve02, pve03 | les 3 |
| dokploy (vm:201) | pve02 | pve01, pve03 | les 3 |
Seule la perte simultanée de deux nœuds serait fatale, mais le cluster perdrait alors le quorum de toute façon.
Une dette de conception, identifiée mais pas encore payée#
runner_ha.tf et dokploy_ha.tf sont désormais quasi identiques : même réplication ×2, même haresource, même harule à un ou deux paramètres près. C’est typiquement le moment où un module OpenTofu commencerait à se justifier : un module ha_service paramétré par type, id et nœud préféré.
Décision : pas encore. Avec seulement deux occurrences, la duplication reste plus lisible qu’une abstraction. Mais quand Talos ajoutera trois VM dans la prochaine partie, la question se posera pour de bon. C’est une bonne illustration du principe “attendre la troisième occurrence avant d’abstraire” : abstraire trop tôt coûte souvent plus cher que la répétition qu’on cherche à éviter.
À quoi sert vraiment le pipeline aujourd’hui ?#
Un constat honnête pour finir : l’apply se fait toujours en local, depuis mon poste. Le job tofu-apply du pipeline, lui, n’a jamais rien à faire. Alors, à quoi sert la CI en l’état ?
Elle apporte deux choses réelles :
- Le
plansur merge request = un test de portabilité. C’est exactement lui qui avait révélé toutes les dépendances implicites à mon poste de travail. - Les garde-fous :
sops-encryptedetsecret_detection, qui tournent à chaque fois.
Ce qui est inutile en l’état, c’est le job apply, présent “au cas où”. On y remédiera dès la partie 12, quand on basculera vraiment l’apply dans le pipeline.
L’état des lieux après cette phase#
| Élément | État |
|---|---|
| Runner GitLab (ct:200) | pve01, répliqué ×2, HA + affinité runner-home |
| VM Dokploy (vm:201) | pve02, répliqué ×2, HA + affinité dokploy-home |
| Dokploy | Traefik + Postgres + interface healthy, port 3000 |
| Template Debian | + qemu-guest-agent via cloud-init vendor_data |
Stockage local | contenu snippets activé |
| Mécanisme HA | validé par 2 tests de bascule réels |
| Secrets | + identifiants admin Dokploy |
Deux services réellement résilients, un mécanisme de bascule mesuré et non plus supposé, et le premier vrai service applicatif du lab qui tourne. Mais surtout, une leçon que je ne suis pas près d’oublier : le HA n’est pas une case à cocher, c’est une chaîne qu’on ne connaît vraiment qu’après avoir débranché un nœud pour de bon.
Et maintenant ?#
Le lab a des fondations solides et un premier service. La partie 12 attaque le gros morceau : Talos et Kubernetes, trois VM combinant control-plane et worker, Cilium pour le réseau, Longhorn pour le stockage, ArgoCD pour le GitOps. Et, comme annoncé, la bascule du workflow sur le pipeline : à partir de là, on applique depuis GitLab, plus depuis mon poste.
À bientôt !




