Aller au contenu
Mon homelab de zéro (Partie 11) : haute disponibilité, Dokploy et deux autorités pour une seule ressource
Photo by Ian Taylor / Unsplash
  1. Articles/

Mon homelab de zéro (Partie 11) : haute disponibilité, Dokploy et deux autorités pour une seule ressource

·3644 mots·18 mins·
Sommaire
Mon homelab de zéro - Cet article fait partie d'une série.
Partie 11: Cet article

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 :

  1. 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.
  2. 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.
note

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.

note

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:48pve01 arrête le conteneur (extinction)
19:41:02CRM : pve01 online => unknown
19:41:52Fencing → recover ct:200 from pve01 to pve02
19:41:58Conteneur 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 destroy

OpenTofu 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.

note

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 rules

Proxmox 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"
}
note

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:45pve01 arrête le conteneur
19:55:02CRM : pve01 online => unknown
19:55:52Fencing → recover ct:200 to pve02
19:55:59Dé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:52pve01 unknown => online et relocate ct:200 to pve01 (même seconde)
19:57:12relocatestarted (node = pve01)
19:58:58Démarré sur pve01

2 min 06, dont ~1 min 45 de transfert et de redémarrage.

note

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 destroy

OpenTofu 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]
}
note

LE principe de la phase. Quand une ressource passe sous HA, deux attributs cessent d’appartenir au code : 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.

note

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,snippets
note

vendor_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: 300
note

Pourquoi 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       5432

Traefik é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.

note

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.5Gi

Linux 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 %.

note

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.
ServiceNœud préféréRépliqué versPeut redémarrer sur
runner (ct:200)pve01pve02, pve03les 3
dokploy (vm:201)pve02pve01, pve03les 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é.

note

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 plan sur 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-encrypted et secret_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
DokployTraefik + Postgres + interface healthy, port 3000
Template Debian+ qemu-guest-agent via cloud-init vendor_data
Stockage localcontenu snippets activé
Mécanisme HAvalidé 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 !

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 11: Cet article

Articles connexes