Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a042099223 | ||
|
|
b06572f376 | ||
|
|
0beb470cde | ||
|
|
dffba69632 | ||
|
|
2785900d4f | ||
|
|
90451c07c3 | ||
|
|
d844a331be | ||
|
|
634c579ac5 | ||
|
|
e416769280 | ||
|
|
28bc4a8c13 | ||
|
|
a222745af2 | ||
|
|
f2ca91361c | ||
|
|
0501815481 | ||
|
|
a24e95ff7c | ||
|
|
5f87c0dabf | ||
|
|
511d4a7718 | ||
|
|
4784b46dfa | ||
|
|
61d870afcd | ||
|
|
81f032031b | ||
|
|
246ba7bf07 | ||
|
|
a4c8a3215f | ||
|
|
1ac0e63ddf | ||
|
|
c14cb6b00b | ||
|
|
cd29c474c8 | ||
|
|
495b650d25 | ||
|
|
5c10bad790 | ||
|
|
f92688be0f | ||
|
|
10d1ea8f9b | ||
|
|
b86691b3bf | ||
|
|
1632d1a3f7 | ||
|
|
6ab502e9d9 | ||
|
|
838d360a90 | ||
|
|
648c7b94dc | ||
|
|
576371646f | ||
|
|
3e25cf0b48 | ||
|
|
79391d97ef | ||
|
|
091b3b8e29 | ||
|
|
64c29c71db | ||
|
|
d7523ba978 | ||
|
|
400f4abf95 | ||
|
|
3240e029fa | ||
|
|
57768b598f | ||
|
|
2fac832345 | ||
|
|
bd6934ee8e | ||
|
|
e41c25b64f | ||
|
|
ce1c7e36bb | ||
|
|
6a4fdcb6ff | ||
|
|
07ce7c58ef |
@@ -92,7 +92,7 @@ jobs:
|
||||
|
||||
Deploy-Staging:
|
||||
needs: [Check-Rebuild, Build]
|
||||
if: always() && needs.Check-Rebuild.result == 'success' && (needs.Build.result == 'skipped' || needs.Build.result == 'success')
|
||||
if: always()
|
||||
runs-on: docker
|
||||
container:
|
||||
volumes:
|
||||
@@ -106,9 +106,53 @@ jobs:
|
||||
- name: Launch Blog Deployment
|
||||
run: |
|
||||
cd /blog
|
||||
docker compose down ${CONTAINER_NAME}
|
||||
|
||||
echo "- Stopping existing ${CONTAINER_NAME}..."
|
||||
docker compose down ${CONTAINER_NAME}
|
||||
|
||||
echo "- Starting ${CONTAINER_NAME}..."
|
||||
docker compose up -d ${CONTAINER_NAME}
|
||||
sleep 60
|
||||
|
||||
echo "- Waiting for ${CONTAINER_NAME} to become healthy..."
|
||||
|
||||
timeout=300
|
||||
elapsed=0
|
||||
|
||||
while true; do
|
||||
status=$(docker inspect \
|
||||
--format='{{.State.Health.Status}}' \
|
||||
${CONTAINER_NAME})
|
||||
|
||||
echo "Health status: ${status}"
|
||||
|
||||
case "$status" in
|
||||
healthy)
|
||||
echo "✅ ${CONTAINER_NAME} is healthy"
|
||||
break
|
||||
;;
|
||||
|
||||
unhealthy)
|
||||
echo "❌ ${CONTAINER_NAME} is unhealthy"
|
||||
echo "- Displaying container logs"
|
||||
docker compose logs ${CONTAINER_NAME}
|
||||
exit 1
|
||||
;;
|
||||
|
||||
*)
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$elapsed" -ge "$timeout" ]; then
|
||||
echo "❌ Timed out waiting for ${CONTAINER_NAME}"
|
||||
echo "- Displaying container logs"
|
||||
docker compose logs ${CONTAINER_NAME}
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sleep 2
|
||||
elapsed=$((elapsed + 2))
|
||||
done
|
||||
|
||||
echo "- Displaying container logs"
|
||||
docker compose logs ${CONTAINER_NAME}
|
||||
|
||||
@@ -161,9 +205,53 @@ jobs:
|
||||
- name: Launch Blog Deployment
|
||||
run: |
|
||||
cd /blog
|
||||
docker compose down ${CONTAINER_NAME}
|
||||
|
||||
echo "- Stopping existing ${CONTAINER_NAME}..."
|
||||
docker compose down ${CONTAINER_NAME}
|
||||
|
||||
echo "- Starting ${CONTAINER_NAME}..."
|
||||
docker compose up -d ${CONTAINER_NAME}
|
||||
sleep 60
|
||||
|
||||
echo "- Waiting for ${CONTAINER_NAME} to become healthy..."
|
||||
|
||||
timeout=300
|
||||
elapsed=0
|
||||
|
||||
while true; do
|
||||
status=$(docker inspect \
|
||||
--format='{{.State.Health.Status}}' \
|
||||
${CONTAINER_NAME})
|
||||
|
||||
echo "Health status: ${status}"
|
||||
|
||||
case "$status" in
|
||||
healthy)
|
||||
echo "✅ ${CONTAINER_NAME} is healthy"
|
||||
break
|
||||
;;
|
||||
|
||||
unhealthy)
|
||||
echo "❌ ${CONTAINER_NAME} is unhealthy"
|
||||
echo "- Displaying container logs"
|
||||
docker compose logs ${CONTAINER_NAME}
|
||||
exit 1
|
||||
;;
|
||||
|
||||
*)
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$elapsed" -ge "$timeout" ]; then
|
||||
echo "❌ Timed out waiting for ${CONTAINER_NAME}"
|
||||
echo "- Displaying container logs"
|
||||
docker compose logs ${CONTAINER_NAME}
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sleep 2
|
||||
elapsed=$((elapsed + 2))
|
||||
done
|
||||
|
||||
echo "- Displaying container logs"
|
||||
docker compose logs ${CONTAINER_NAME}
|
||||
|
||||
@@ -194,7 +282,10 @@ jobs:
|
||||
steps:
|
||||
- name: Remove Old Docker Image
|
||||
run: |
|
||||
docker image rm $(docker image ls ${DOCKER_IMAGE} 2> /dev/null | awk '$NF != "U" && NR>1 {print $2}')
|
||||
IMAGE_IDS=$(docker image ls "${DOCKER_IMAGE}" 2>/dev/null | awk '$NF != "U" && NR>1 {print $2}')
|
||||
if [ -n "$IMAGE_IDS" ]; then
|
||||
docker image rm $IMAGE_IDS
|
||||
fi
|
||||
|
||||
Notify:
|
||||
needs: [Check-Rebuild, Build, Deploy-Staging, Test-Staging, Merge, Deploy-Production, Test-Production, Clean]
|
||||
|
||||
@@ -93,7 +93,7 @@ jobs:
|
||||
needs:
|
||||
- Check-Rebuild
|
||||
- Build
|
||||
if: always() && needs.Check-Rebuild.result == 'success' && (needs.Build.result == 'skipped' || needs.Build.result == 'success')
|
||||
if: always()
|
||||
runs-on: docker
|
||||
container:
|
||||
volumes:
|
||||
@@ -107,12 +107,58 @@ jobs:
|
||||
- name: Launch Blog Test Deployment
|
||||
run: |
|
||||
cd /blog
|
||||
docker compose down ${CONTAINER_NAME}
|
||||
BLOG_TEST_BRANCH=${{ gitea.ref_name }} docker compose up -d ${CONTAINER_NAME}
|
||||
sleep 5
|
||||
|
||||
echo "- Stopping existing ${CONTAINER_NAME}..."
|
||||
docker compose down ${CONTAINER_NAME}
|
||||
|
||||
echo "- Starting ${CONTAINER_NAME}..."
|
||||
BLOG_TEST_BRANCH=${{ gitea.ref_name }} \
|
||||
docker compose up -d ${CONTAINER_NAME}
|
||||
|
||||
echo "- Waiting for ${CONTAINER_NAME} to become healthy..."
|
||||
|
||||
timeout=300
|
||||
elapsed=0
|
||||
|
||||
while true; do
|
||||
status=$(docker inspect \
|
||||
--format='{{.State.Health.Status}}' \
|
||||
${CONTAINER_NAME})
|
||||
|
||||
echo "Health status: ${status}"
|
||||
|
||||
case "$status" in
|
||||
healthy)
|
||||
echo "✅ ${CONTAINER_NAME} is healthy"
|
||||
break
|
||||
;;
|
||||
|
||||
unhealthy)
|
||||
echo "❌ ${CONTAINER_NAME} is unhealthy"
|
||||
echo "- Displaying container logs"
|
||||
docker compose logs ${CONTAINER_NAME}
|
||||
exit 1
|
||||
;;
|
||||
|
||||
*)
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$elapsed" -ge "$timeout" ]; then
|
||||
echo "❌ Timed out waiting for ${CONTAINER_NAME}"
|
||||
echo "- Displaying container logs"
|
||||
docker compose logs ${CONTAINER_NAME}
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sleep 2
|
||||
elapsed=$((elapsed + 2))
|
||||
done
|
||||
|
||||
echo "- Displaying container logs"
|
||||
docker compose logs ${CONTAINER_NAME}
|
||||
|
||||
|
||||
Test:
|
||||
needs: Deploy-Test
|
||||
runs-on: ubuntu
|
||||
|
||||
|
After Width: | Height: | Size: 49 KiB |
@@ -9,6 +9,7 @@ tags:
|
||||
- cloud-init
|
||||
categories:
|
||||
- homelab
|
||||
image: cover-1.webp
|
||||
---
|
||||
## Intro
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ tags:
|
||||
- cloud-init
|
||||
categories:
|
||||
- homelab
|
||||
image: cover-1.webp
|
||||
---
|
||||
## Intro
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 47 KiB |
@@ -8,6 +8,7 @@ tags:
|
||||
- opnsense
|
||||
categories:
|
||||
- homelab
|
||||
image: cover-10.webp
|
||||
---
|
||||
## Intro
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ tags:
|
||||
- opnsense
|
||||
categories:
|
||||
- homelab
|
||||
image: cover-10.webp
|
||||
---
|
||||
## Intro
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 53 KiB |
@@ -8,6 +8,7 @@ tags:
|
||||
- proxmox
|
||||
categories:
|
||||
- homelab
|
||||
image: cover-11.webp
|
||||
---
|
||||
|
||||
## Intro
|
||||
|
||||
@@ -8,6 +8,7 @@ tags:
|
||||
- proxmox
|
||||
categories:
|
||||
- homelab
|
||||
image: cover-11.webp
|
||||
---
|
||||
|
||||
## Intro
|
||||
|
||||
|
After Width: | Height: | Size: 40 KiB |
@@ -10,6 +10,7 @@ tags:
|
||||
- high-availability
|
||||
categories:
|
||||
- homelab
|
||||
image: cover-12.webp
|
||||
---
|
||||
## Intro
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ tags:
|
||||
- high-availability
|
||||
categories:
|
||||
- homelab
|
||||
image: cover-12.webp
|
||||
---
|
||||
## Intro
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 59 KiB |
@@ -14,6 +14,7 @@ tags:
|
||||
- dnsmasq
|
||||
categories:
|
||||
- homelab
|
||||
image: cover-13.webp
|
||||
---
|
||||
|
||||
## Intro
|
||||
|
||||
@@ -14,6 +14,7 @@ tags:
|
||||
- dnsmasq
|
||||
categories:
|
||||
- homelab
|
||||
image: cover-13.webp
|
||||
---
|
||||
|
||||
## Intro
|
||||
|
||||
|
After Width: | Height: | Size: 45 KiB |
@@ -10,6 +10,7 @@ tags:
|
||||
- ceph
|
||||
categories:
|
||||
- homelab
|
||||
image: cover-14.webp
|
||||
---
|
||||
|
||||
## Intro
|
||||
|
||||
@@ -10,6 +10,7 @@ tags:
|
||||
- ceph
|
||||
categories:
|
||||
- homelab
|
||||
image: cover-14.webp
|
||||
---
|
||||
|
||||
## Intro
|
||||
|
||||
|
After Width: | Height: | Size: 56 KiB |
@@ -10,6 +10,7 @@ tags:
|
||||
- proxmox
|
||||
categories:
|
||||
- homelab
|
||||
image: cover-15.webp
|
||||
---
|
||||
## Intro
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
slug: migration-opnsense-proxmox-highly-available
|
||||
title: Migration to my OPNsense HA Cluster in Proxmox VE
|
||||
title: Migration of my OPNsense HA Cluster in Proxmox VE
|
||||
description: The detailed steps of the migration from my OPNsense physical box to a highly available cluster of VM in Proxmox VE.
|
||||
date: 2025-11-20
|
||||
draft: false
|
||||
@@ -10,6 +10,7 @@ tags:
|
||||
- proxmox
|
||||
categories:
|
||||
- homelab
|
||||
image: cover-15.webp
|
||||
---
|
||||
## Intro
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 53 KiB |
@@ -8,10 +8,11 @@ tags:
|
||||
- docker
|
||||
- proxmox
|
||||
- opnsense
|
||||
- treafik
|
||||
- gitea
|
||||
- traefik
|
||||
categories:
|
||||
- homelab
|
||||
image: cover-16.webp
|
||||
---
|
||||
## Intro
|
||||
|
||||
|
||||
@@ -8,10 +8,11 @@ tags:
|
||||
- docker
|
||||
- proxmox
|
||||
- opnsense
|
||||
- treafik
|
||||
- gitea
|
||||
- traefik
|
||||
categories:
|
||||
- homelab
|
||||
image: cover-16.webp
|
||||
---
|
||||
## Intro
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 42 KiB |
@@ -12,6 +12,7 @@ tags:
|
||||
- docker
|
||||
categories:
|
||||
- homelab
|
||||
image: cover-17.webp
|
||||
---
|
||||
## Intro
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ tags:
|
||||
- docker
|
||||
categories:
|
||||
- homelab
|
||||
image: cover-17.webp
|
||||
---
|
||||
## Intro
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 66 KiB |
@@ -8,6 +8,7 @@ tags:
|
||||
- truenas
|
||||
categories:
|
||||
- homelab
|
||||
image: cover-18.webp
|
||||
---
|
||||
## Introduction
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ tags:
|
||||
- truenas
|
||||
categories:
|
||||
- homelab
|
||||
image: cover-18.webp
|
||||
---
|
||||
## Introduction
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 40 KiB |
@@ -11,6 +11,7 @@ tags:
|
||||
- high-availability
|
||||
categories:
|
||||
- homelab
|
||||
image: cover-19.webp
|
||||
---
|
||||
## Intro
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ tags:
|
||||
- high-availability
|
||||
categories:
|
||||
- homelab
|
||||
image: cover-19.webp
|
||||
---
|
||||
## Intro
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 35 KiB |
@@ -12,6 +12,7 @@ tags:
|
||||
- docker
|
||||
categories:
|
||||
- blog
|
||||
image: cover-2.webp
|
||||
---
|
||||
## 💡 Intro
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ tags:
|
||||
- docker
|
||||
categories:
|
||||
- blog
|
||||
image: cover-2.webp
|
||||
---
|
||||
## 💡 Intro
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 39 KiB |
@@ -0,0 +1,312 @@
|
||||
---
|
||||
slug: automating-proxmox-update-ansible
|
||||
title: Automatiser les mises à jour de Proxmox VE avec Ansible
|
||||
description: Automatisez les mises à jour d’un cluster Proxmox VE avec Ansible, incluant les vérifications Ceph, les redémarrages progressifs et les rapports Ntfy.
|
||||
date: 2026-06-09
|
||||
draft: false
|
||||
tags:
|
||||
- proxmox
|
||||
- ansible
|
||||
- semaphore-ui
|
||||
- ntfy
|
||||
categories:
|
||||
- homelab
|
||||
image: cover-20.webp
|
||||
---
|
||||
## Intro
|
||||
|
||||
Dans mon homelab, les mises à jour font partie de ces choses faciles à repousser.
|
||||
|
||||
Pas parce qu’elles sont compliquées, mais parce qu’elles sont manuelles. Je dois me connecter au bon système, vérifier l’état, appliquer les mises à jour, redémarrer si nécessaire, vérifier que tout revient correctement, puis répéter le même processus pour le composant suivant.
|
||||
|
||||
Et comme c’est manuel, je le garde généralement pour plus tard.
|
||||
|
||||
Quand Proxmox VE 9.1 est sorti, je voulais déjà mettre à jour mon cluster, mais pas manuellement. Puis Proxmox VE 9.2 est devenu disponible il y a quelques jours, et je n’avais toujours pas construit de processus propre autour de cela. C’était un bon déclencheur pour enfin commencer à automatiser les mises à jour des parties importantes de mon homelab.
|
||||
|
||||
L’objectif plus large est de simplifier et d’automatiser le patching de plusieurs composants clés :
|
||||
|
||||
- Proxmox VE
|
||||
- OPNsense
|
||||
- TrueNAS
|
||||
|
||||
J’ai décidé de commencer par Proxmox parce qu’il est central dans le lab, et parce qu’un workflow de mise à jour progressive est un bon candidat pour l’automatisation.
|
||||
|
||||
---
|
||||
## Les Outils Utilisés
|
||||
|
||||
Le processus de mise à jour est construit autour de quelques composants que j’utilise déjà dans le lab.
|
||||
|
||||
[Proxmox VE](https://www.proxmox.com/en/proxmox-virtual-environment/overview) est ma plateforme de virtualisation. Le cluster utilise aussi Ceph, donc avant de toucher à un nœud, je veux m’assurer que le cluster est en bonne santé et que Ceph remonte `HEALTH_OK`.
|
||||
|
||||
[Ansible](https://docs.ansible.com/) est utilisé pour décrire le workflow de mise à jour sous forme de playbook.
|
||||
|
||||
[Semaphore UI](https://semaphoreui.com/) est utilisé pour exécuter le playbook depuis une interface web et le planifier.
|
||||
|
||||
[Ntfy](https://ntfy.sh/) est utilisé pour les notifications. Si les mises à jour sont planifiées, j’ai besoin de savoir quand quelque chose se passe, surtout si le cluster n’est pas prêt ou si une mise à jour échoue.
|
||||
|
||||
---
|
||||
## Création d’un Topic Ntfy Dédié
|
||||
|
||||
Avant de planifier quoi que ce soit, je voulais un canal de notification dédié au homelab.
|
||||
|
||||
J’ai créé un topic `homelab` dans Ntfy et un utilisateur dédié nommé `semaphore` avec un accès en écriture seule à ce topic.
|
||||
|
||||
```bash
|
||||
ntfy user add semaphore
|
||||
ntfy access semaphore homelab wo
|
||||
```
|
||||
|
||||
L’idée est que Semaphore a uniquement besoin de publier des messages. Il n’a pas besoin d’un accès en lecture.
|
||||
|
||||
J’ai aussi ajouté le topic sur mon téléphone mobile afin de pouvoir recevoir des notifications lorsque l’automatisation s’exécute.
|
||||
|
||||
Dans Semaphore, j’ai créé un groupe de variables nommé `Ntfy Homelab` pour stocker les valeurs nécessaires aux playbooks :
|
||||
|
||||
- `ntfy_url`
|
||||
- `ntfy_topic`
|
||||
- `ntfy_user`
|
||||
- `NTFY_PASSWORD`
|
||||
|
||||
Le mot de passe est stocké comme variable d’environnement dans l’onglet `Secrets`.
|
||||
|
||||

|
||||
|
||||
---
|
||||
## Conception du Workflow de Mise à Jour Proxmox
|
||||
|
||||
Pour Proxmox, je ne voulais pas d’un playbook qui exécute simplement `apt upgrade` sur tous les nœuds. À la place, il fait les actions suivantes :
|
||||
|
||||
- Vérifier la santé du cluster
|
||||
- Arrêter et envoyer une notification Ntfy si le cluster n’est pas prêt
|
||||
- Pour chaque nœud, vérifier si des mises à jour sont disponibles, et si oui :
|
||||
- Activer le mode maintenance
|
||||
- Attendre que les LXC et les VM quittent le nœud
|
||||
- Mettre à jour les paquets
|
||||
- Désactiver le rééquilibrage Ceph
|
||||
- Redémarrer le nœud
|
||||
- Activer le rééquilibrage Ceph
|
||||
- Désactiver le mode maintenance
|
||||
- Attendre que Ceph soit en bonne santé
|
||||
- Envoyer un rapport Ntfy final
|
||||
|
||||
Le playbook complet est disponible sur mon [dépôt Homelab](https://github.com/Vezpi/Homelab/blob/main/ansible/proxmox/update_proxmox.yml)
|
||||
|
||||
---
|
||||
## Détails du Workflow
|
||||
|
||||
Avant de démarrer la mise à jour progressive, le playbook vérifie :
|
||||
|
||||
- Le quorum du cluster Proxmox
|
||||
- La santé de Ceph
|
||||
|
||||
Si l’une de ces vérifications échoue, le playbook s’arrête et envoie une notification Ntfy au lieu d’essayer de continuer.
|
||||
|
||||
```yaml
|
||||
- name: Verify cluster quorum
|
||||
ansible.builtin.command: pvecm status
|
||||
register: quorum_status
|
||||
changed_when: false
|
||||
failed_when: quorum_status.stdout is not search('Quorate:\\s*Yes')
|
||||
|
||||
- name: Verify Ceph health
|
||||
ansible.builtin.command: ceph health
|
||||
register: ceph_health
|
||||
changed_when: false
|
||||
failed_when: "'HEALTH_OK' not in ceph_health.stdout"
|
||||
```
|
||||
|
||||
C’est une partie importante de l’automatisation. Une mise à jour planifiée ne doit pas continuer aveuglément si le cluster n’est pas dans un bon état.
|
||||
|
||||
Le playbook met à jour les nœuds Proxmox avec `serial: 1`.
|
||||
|
||||
Cela signifie qu’un seul nœud est traité à la fois, ce qui est exactement ce que je veux pour une mise à jour de cluster.
|
||||
|
||||
Pour chaque nœud, le playbook commence par rafraîchir les dépôts et vérifie si des mises à jour sont disponibles en utilisant le mode check d’Ansible.
|
||||
|
||||
```yaml
|
||||
- name: Refresh repositories
|
||||
ansible.builtin.apt:
|
||||
update_cache: true
|
||||
|
||||
- name: Check if updates are available
|
||||
ansible.builtin.apt:
|
||||
upgrade: dist
|
||||
check_mode: true
|
||||
register: apt_check
|
||||
```
|
||||
|
||||
Si aucune mise à jour n’est disponible pour un nœud, la partie lourde du workflow est ignorée.
|
||||
|
||||
Si des mises à jour sont disponibles, le playbook stocke la version actuelle de Proxmox, active le mode maintenance, attend que les invités quittent le nœud, applique les mises à jour, redémarre le nœud, puis attend que Ceph soit de nouveau en bonne santé.
|
||||
|
||||
```yaml
|
||||
- name: Enable maintenance mode
|
||||
ansible.builtin.command: >
|
||||
ha-manager crm-command node-maintenance enable {{ inventory_hostname_short }}
|
||||
```
|
||||
|
||||
Une fois le mode maintenance activé, le playbook attend qu’il ne reste plus aucun LXC en cours d’exécution sur le nœud :
|
||||
|
||||
```yaml
|
||||
- name: Wait for LXCs to leave node
|
||||
ansible.builtin.shell: |
|
||||
pct list | awk 'NR>1 && $2=="running" {count++} END {print count+0}'
|
||||
register: lxc_count
|
||||
changed_when: false
|
||||
until: lxc_count.stdout | int == 0
|
||||
retries: 60
|
||||
delay: 15
|
||||
```
|
||||
|
||||
Il fait la même chose pour les VM en cours d’exécution :
|
||||
|
||||
```yaml
|
||||
- name: Wait for VMs to leave node
|
||||
ansible.builtin.shell: |
|
||||
qm list | awk 'NR>1 && $3=="running" {count++} END {print count+0}'
|
||||
register: vm_count
|
||||
changed_when: false
|
||||
until: vm_count.stdout | int == 0
|
||||
retries: 60
|
||||
delay: 15
|
||||
```
|
||||
|
||||
Une fois que le nœud est vide, la mise à niveau des paquets peut s’exécuter :
|
||||
|
||||
```yaml
|
||||
- name: Update packages
|
||||
ansible.builtin.apt:
|
||||
upgrade: full
|
||||
autoremove: true
|
||||
autoclean: true
|
||||
```
|
||||
|
||||
Avant de redémarrer, le playbook définit `noout` sur les OSD Ceph :
|
||||
|
||||
```yaml
|
||||
- name: Disable Ceph rebalancing
|
||||
ansible.builtin.command: ceph osd set noout
|
||||
```
|
||||
|
||||
Puis le nœud est redémarré :
|
||||
|
||||
```yaml
|
||||
- name: Reboot node
|
||||
ansible.builtin.reboot:
|
||||
reboot_timeout: 900
|
||||
post_reboot_delay: 30
|
||||
```
|
||||
|
||||
Après le redémarrage, le rééquilibrage Ceph est réactivé, le mode maintenance est désactivé, et le playbook attend que Ceph revienne à `HEALTH_OK`.
|
||||
|
||||
```yaml
|
||||
- name: Enable Ceph rebalancing
|
||||
ansible.builtin.command: ceph osd unset noout
|
||||
|
||||
- name: Disable maintenance mode
|
||||
ansible.builtin.command: >
|
||||
ha-manager crm-command node-maintenance disable {{ inventory_hostname_short }}
|
||||
|
||||
- name: Wait for Ceph to be healthy
|
||||
ansible.builtin.command: ceph health
|
||||
register: ceph_status
|
||||
changed_when: false
|
||||
until: "'HEALTH_OK' in ceph_status.stdout"
|
||||
retries: 60
|
||||
delay: 15
|
||||
delegate_to: "{{ groups['nodes'][0] }}"
|
||||
```
|
||||
|
||||
Le résultat est une mise à jour progressive contrôlée au lieu d’une procédure manuelle nœud par nœud.
|
||||
|
||||
---
|
||||
## Envoi d’un Rapport de Mise à Jour
|
||||
|
||||
À la fin du workflow, le playbook envoie un rapport via Ntfy. Il détermine d’abord si au moins un nœud a été mis à jour :
|
||||
|
||||
```yaml
|
||||
- name: Determine if updates occurred
|
||||
ansible.builtin.set_fact:
|
||||
updates_performed: "{{ groups['nodes'] | map('extract', hostvars) | selectattr('update_report', 'defined') | list | length > 0 }}"
|
||||
```
|
||||
|
||||
Ensuite, il envoie un message au topic `homelab`.
|
||||
|
||||
Si aucune mise à jour n’était disponible, la notification l’indique et utilise une priorité plus basse.
|
||||
|
||||
Si des mises à jour ont été appliquées, la notification liste les nœuds mis à jour et affiche la version de Proxmox avant et après la mise à jour.
|
||||
|
||||
La logique de rapport est basée sur le fact `update_report` sauvegardé pendant la mise à jour du nœud :
|
||||
|
||||
```yaml
|
||||
- name: Save update report
|
||||
ansible.builtin.set_fact:
|
||||
update_report:
|
||||
old: "{{ pve_old_version.stdout }}"
|
||||
new: "{{ pve_new_version.stdout }}"
|
||||
```
|
||||
|
||||
Le corps de la notification construit ensuite un résumé à partir de tous les nœuds :
|
||||
|
||||
```yaml
|
||||
body: |
|
||||
{% set updated_nodes = [] %}
|
||||
{% for node in groups['nodes'] %}
|
||||
{% if hostvars[node].update_report is defined %}
|
||||
{% set _ = updated_nodes.append(node) %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if not updates_performed %}
|
||||
No updates available on the cluster.
|
||||
{% else %}
|
||||
The following nodes were updated:
|
||||
{% for node in updated_nodes %}
|
||||
{% if hostvars[node].update_report.old == hostvars[node].update_report.new %}
|
||||
- {{ hostvars[node].inventory_hostname_short }}: version {{ hostvars[node].update_report.old }} (unchanged)
|
||||
{% else %}
|
||||
- {{ hostvars[node].inventory_hostname_short }}: version {{ hostvars[node].update_report.old }} → {{ hostvars[node].update_report.new }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
Cela rend la tâche planifiée beaucoup plus facile à considérer comme fiable. Je n’ai pas besoin d’ouvrir Semaphore à chaque fois pour savoir ce qui s’est passé.
|
||||
|
||||
---
|
||||
## Exécuter le Playbook depuis Semaphore
|
||||
|
||||
Une fois le playbook prêt, je l’ai poussé dans le dépôt et j’ai configuré un modèle de tâche Semaphore pour l’exécuter.
|
||||
|
||||

|
||||
|
||||
À partir de là, je pouvais lancer le workflow et le regarder agir sur le cluster.
|
||||
|
||||
Pendant l’exécution, le nœud cible entre en mode maintenance et les workloads en cours d’exécution sont migrés hors de celui-ci.
|
||||
|
||||

|
||||
|
||||
C’est à ce moment-là que l’automatisation devient vraiment utile. Le playbook n’applique pas seulement les mises à jour. Il prend aussi en charge les étapes opérationnelles autour de la mise à jour.
|
||||
|
||||
---
|
||||
## Planification de la Mise à Jour
|
||||
|
||||
Après avoir affiné le playbook et validé le workflow, j’ai créé une planification dans Semaphore.
|
||||
|
||||
Dans `Schedule`, j’ai cliqué sur `New Schedule`, sélectionné `Cron`, donné un nom, sélectionné une planification hebdomadaire, puis choisi le vendredi à 4h00 UTC.
|
||||
|
||||

|
||||
|
||||
À ce stade, le processus de mise à jour de Proxmox n’est plus quelque chose dont je dois me souvenir pour le faire manuellement.
|
||||
|
||||
Il s’exécute selon une planification, vérifie l’état du cluster avant de faire quoi que ce soit, met à jour un nœud à la fois, et envoie une notification avec le résultat.
|
||||
|
||||
---
|
||||
## Conclusion
|
||||
|
||||
Ce projet est parti d’un problème simple : je ne mettais pas mon homelab à jour régulièrement parce que le processus était encore trop manuel.
|
||||
|
||||
L’automatisation des mises à jour Proxmox était la première étape importante. La partie importante n’était pas seulement d’exécuter les mises à niveau de paquets, mais de les entourer des vérifications et des étapes opérationnelles qui ont du sens pour un cluster Proxmox avec Ceph.
|
||||
|
||||
Semaphore me donne une façon propre d’exécuter et de planifier le playbook. Ansible décrit le processus de manière répétable. Ntfy boucle la boucle en me disant ce qui s’est passé.
|
||||
|
||||
Les prochaines étapes logiques sont de continuer avec la même approche pour les autres composants clés du lab : OPNsense et TrueNAS.
|
||||
@@ -0,0 +1,312 @@
|
||||
---
|
||||
slug: automating-proxmox-update-ansible
|
||||
title: Automating Proxmox VE Updates with Ansible
|
||||
description: Automate Proxmox VE cluster updates using Ansible, including Ceph checks, rolling reboots and Ntfy reports.
|
||||
date: 2026-06-09
|
||||
draft: false
|
||||
tags:
|
||||
- proxmox
|
||||
- ansible
|
||||
- semaphore-ui
|
||||
- ntfy
|
||||
categories:
|
||||
- homelab
|
||||
image: cover-20.webp
|
||||
---
|
||||
## Intro
|
||||
|
||||
In my homelab, updates are one of those things that are easy to postpone.
|
||||
|
||||
Not because they are complicated, but because they are manual. I need to connect to the right system, check the state, apply the updates, reboot if needed, verify that everything comes back correctly, and then repeat the same process for the next component.
|
||||
|
||||
And because it is manual, I usually keep it for later.
|
||||
|
||||
When Proxmox VE 9.1 came out, I already wanted to update my cluster, but not manually. Then Proxmox VE 9.2 became available few days ago, and I still had not built a clean process around it. That was a good trigger to finally start automating updates for the important parts of my homelab.
|
||||
|
||||
The larger goal is to simplify and automate patching for several key components:
|
||||
|
||||
- Proxmox VE
|
||||
- OPNsense
|
||||
- TrueNAS
|
||||
|
||||
I decided to start with Proxmox because it is central to the lab, and because a rolling update workflow is a good candidate for automation.
|
||||
|
||||
---
|
||||
## The Tools Involved
|
||||
|
||||
The update process is built around a few components I already use in the lab.
|
||||
|
||||
[Proxmox VE](https://www.proxmox.com/en/proxmox-virtual-environment/overview) is my virtualization platform. The cluster also uses Ceph, so before touching a node, I want to make sure the cluster is healthy and that Ceph is reporting `HEALTH_OK`.
|
||||
|
||||
[Ansible](https://docs.ansible.com/) is used to describe the update workflow as a playbook.
|
||||
|
||||
[Semaphore UI](https://semaphoreui.com/) is used to run the playbook from a web interface and schedule it.
|
||||
|
||||
[Ntfy](https://ntfy.sh/) is used for notifications. If updates are scheduled, I need to know when something happens, especially if the cluster is not ready or if an update fails.
|
||||
|
||||
---
|
||||
## Creating a Dedicated Ntfy Topic
|
||||
|
||||
Before scheduling anything, I wanted a notification channel dedicated to the homelab.
|
||||
|
||||
I created a `homelab` topic in Ntfy and a dedicated user named `semaphore` with write-only access to this topic.
|
||||
|
||||
```bash
|
||||
ntfy user add semaphore
|
||||
ntfy access semaphore homelab wo
|
||||
```
|
||||
|
||||
The idea is that Semaphore only needs to publish messages. It does not need read access.
|
||||
|
||||
I also added the topic on my mobile phone so I can receive notifications when the automation runs.
|
||||
|
||||
In Semaphore, I created a variable group named `Ntfy Homelab` to store the values needed by the playbooks:
|
||||
|
||||
- `ntfy_url`
|
||||
- `ntfy_topic`
|
||||
- `ntfy_user`
|
||||
- `NTFY_PASSWORD`
|
||||
|
||||
The password is stored as an environment variable in the `Secrets` tab.
|
||||
|
||||

|
||||
|
||||
---
|
||||
## Designing the Proxmox Update Workflow
|
||||
|
||||
For Proxmox, I did not want a playbook that simply runs `apt upgrade` on all nodes. Instead, it doing the following:
|
||||
|
||||
- Check cluster health
|
||||
- Stop and send a Ntfy notification if the cluster is not ready
|
||||
- For each node, check if updates are available, if so:
|
||||
- Enable maintenance mode
|
||||
- Wait for LXCs and VMs to leave the node
|
||||
- Update packages
|
||||
- Disable Ceph rebalancing
|
||||
- Reboot the node
|
||||
- Enable Ceph rebalancing
|
||||
- Disable maintenance mode
|
||||
- Wait for Ceph to be healthy
|
||||
- Send a final Ntfy report
|
||||
|
||||
The full playbook is available on my [Homelab repo](https://github.com/Vezpi/Homelab/blob/main/ansible/proxmox/update_proxmox.yml)
|
||||
|
||||
---
|
||||
## Workflow Details
|
||||
|
||||
Before starting the rolling update, the playbook checks:
|
||||
|
||||
- Proxmox cluster quorum
|
||||
- Ceph health
|
||||
|
||||
If one of these checks fails, the playbook stops and sends a Ntfy notification instead of trying to continue.
|
||||
|
||||
```yaml
|
||||
- name: Verify cluster quorum
|
||||
ansible.builtin.command: pvecm status
|
||||
register: quorum_status
|
||||
changed_when: false
|
||||
failed_when: quorum_status.stdout is not search('Quorate:\\s*Yes')
|
||||
|
||||
- name: Verify Ceph health
|
||||
ansible.builtin.command: ceph health
|
||||
register: ceph_health
|
||||
changed_when: false
|
||||
failed_when: "'HEALTH_OK' not in ceph_health.stdout"
|
||||
```
|
||||
|
||||
This is an important part of the automation. A scheduled update should not blindly continue if the cluster is not in a good state.
|
||||
|
||||
The playbook updates the Proxmox nodes with `serial: 1`.
|
||||
|
||||
That means only one node is handled at a time, which is exactly what I want for a cluster update.
|
||||
|
||||
For each node, the playbook first refreshes the repositories and checks if updates are available using Ansible check mode.
|
||||
|
||||
```yaml
|
||||
- name: Refresh repositories
|
||||
ansible.builtin.apt:
|
||||
update_cache: true
|
||||
|
||||
- name: Check if updates are available
|
||||
ansible.builtin.apt:
|
||||
upgrade: dist
|
||||
check_mode: true
|
||||
register: apt_check
|
||||
```
|
||||
|
||||
If no updates are available for a node, the heavy part of the workflow is skipped.
|
||||
|
||||
If updates are available, the playbook stores the current Proxmox version, enables maintenance mode, waits for guests to leave the node, applies the updates, reboots the node, and then waits for Ceph to be healthy again.
|
||||
|
||||
```yaml
|
||||
- name: Enable maintenance mode
|
||||
ansible.builtin.command: >
|
||||
ha-manager crm-command node-maintenance enable {{ inventory_hostname_short }}
|
||||
```
|
||||
|
||||
After maintenance mode is enabled, the playbook waits until no running LXC remains on the node:
|
||||
|
||||
```yaml
|
||||
- name: Wait for LXCs to leave node
|
||||
ansible.builtin.shell: |
|
||||
pct list | awk 'NR>1 && $2=="running" {count++} END {print count+0}'
|
||||
register: lxc_count
|
||||
changed_when: false
|
||||
until: lxc_count.stdout | int == 0
|
||||
retries: 60
|
||||
delay: 15
|
||||
```
|
||||
|
||||
It does the same for running VMs:
|
||||
|
||||
```yaml
|
||||
- name: Wait for VMs to leave node
|
||||
ansible.builtin.shell: |
|
||||
qm list | awk 'NR>1 && $3=="running" {count++} END {print count+0}'
|
||||
register: vm_count
|
||||
changed_when: false
|
||||
until: vm_count.stdout | int == 0
|
||||
retries: 60
|
||||
delay: 15
|
||||
```
|
||||
|
||||
Once the node is empty, the package upgrade can run:
|
||||
|
||||
```yaml
|
||||
- name: Update packages
|
||||
ansible.builtin.apt:
|
||||
upgrade: full
|
||||
autoremove: true
|
||||
autoclean: true
|
||||
```
|
||||
|
||||
Before rebooting, the playbook sets Ceph OSD `noout`:
|
||||
|
||||
```yaml
|
||||
- name: Disable Ceph rebalancing
|
||||
ansible.builtin.command: ceph osd set noout
|
||||
```
|
||||
|
||||
Then the node is rebooted:
|
||||
|
||||
```yaml
|
||||
- name: Reboot node
|
||||
ansible.builtin.reboot:
|
||||
reboot_timeout: 900
|
||||
post_reboot_delay: 30
|
||||
```
|
||||
|
||||
After the reboot, Ceph rebalancing is enabled again, maintenance mode is disabled, and the playbook waits for Ceph to return to `HEALTH_OK`.
|
||||
|
||||
```yaml
|
||||
- name: Enable Ceph rebalancing
|
||||
ansible.builtin.command: ceph osd unset noout
|
||||
|
||||
- name: Disable maintenance mode
|
||||
ansible.builtin.command: >
|
||||
ha-manager crm-command node-maintenance disable {{ inventory_hostname_short }}
|
||||
|
||||
- name: Wait for Ceph to be healthy
|
||||
ansible.builtin.command: ceph health
|
||||
register: ceph_status
|
||||
changed_when: false
|
||||
until: "'HEALTH_OK' in ceph_status.stdout"
|
||||
retries: 60
|
||||
delay: 15
|
||||
delegate_to: "{{ groups['nodes'][0] }}"
|
||||
```
|
||||
|
||||
The result is a controlled rolling update instead of a manual node-by-node procedure.
|
||||
|
||||
---
|
||||
## Sending an Update Report
|
||||
|
||||
At the end of the workflow, the playbook sends a report through Ntfy. It first determines if at least one node was updated:
|
||||
|
||||
```yaml
|
||||
- name: Determine if updates occurred
|
||||
ansible.builtin.set_fact:
|
||||
updates_performed: "{{ groups['nodes'] | map('extract', hostvars) | selectattr('update_report', 'defined') | list | length > 0 }}"
|
||||
```
|
||||
|
||||
Then it sends a message to the `homelab` topic.
|
||||
|
||||
If no updates were available, the notification says so and uses a lower priority.
|
||||
|
||||
If updates were applied, the notification lists the updated nodes and shows the Proxmox version before and after the update.
|
||||
|
||||
The report logic is based on the `update_report` fact saved during the node update:
|
||||
|
||||
```yaml
|
||||
- name: Save update report
|
||||
ansible.builtin.set_fact:
|
||||
update_report:
|
||||
old: "{{ pve_old_version.stdout }}"
|
||||
new: "{{ pve_new_version.stdout }}"
|
||||
```
|
||||
|
||||
The notification body then builds a summary from all nodes:
|
||||
|
||||
```yaml
|
||||
body: |
|
||||
{% set updated_nodes = [] %}
|
||||
{% for node in groups['nodes'] %}
|
||||
{% if hostvars[node].update_report is defined %}
|
||||
{% set _ = updated_nodes.append(node) %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if not updates_performed %}
|
||||
No updates available on the cluster.
|
||||
{% else %}
|
||||
The following nodes were updated:
|
||||
{% for node in updated_nodes %}
|
||||
{% if hostvars[node].update_report.old == hostvars[node].update_report.new %}
|
||||
- {{ hostvars[node].inventory_hostname_short }}: version {{ hostvars[node].update_report.old }} (unchanged)
|
||||
{% else %}
|
||||
- {{ hostvars[node].inventory_hostname_short }}: version {{ hostvars[node].update_report.old }} → {{ hostvars[node].update_report.new }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
This makes the scheduled job much easier to trust. I do not need to open Semaphore every time to know what happened.
|
||||
|
||||
---
|
||||
## Running the Playbook from Semaphore
|
||||
|
||||
Once the playbook was ready, I pushed it to the repository and configured a Semaphore task template to run it.
|
||||
|
||||

|
||||
|
||||
From there, I could launch the workflow and watch it act on the cluster.
|
||||
|
||||
During execution, the target node enters maintenance mode and the running workloads are migrated away from it.
|
||||
|
||||

|
||||
|
||||
This is the point where the automation becomes really useful. The playbook is not only applying updates. It is also taking care of the operational steps around the update.
|
||||
|
||||
---
|
||||
## Scheduling the Update
|
||||
|
||||
After refining the playbook and validating the workflow, I created a schedule in Semaphore.
|
||||
|
||||
In `Schedule`, I clicked `New Schedule`, selected `Cron`, gave it a name, selected a weekly schedule, and picked Friday at 4:00 AM UTC.
|
||||
|
||||

|
||||
|
||||
At this point, the Proxmox update process is no longer something I need to remember to do manually.
|
||||
|
||||
It runs on a schedule, checks the state of the cluster before doing anything, updates one node at a time, and sends a notification with the result.
|
||||
|
||||
---
|
||||
## Conclusion
|
||||
|
||||
This project started with a simple problem: I was not updating my homelab regularly because the process was still too manual.
|
||||
|
||||
Automating Proxmox updates was the first milestone. The important part was not only running package upgrades, but wrapping them in the checks and operational steps that make sense for a Proxmox cluster with Ceph.
|
||||
|
||||
Semaphore gives me a clean way to run and schedule the playbook. Ansible describes the process in a repeatable way. Ntfy closes the loop by telling me what happened.
|
||||
|
||||
The next logical steps are to continue the same approach for the other key components of the lab: OPNsense and TrueNAS.
|
||||
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 42 KiB |
@@ -0,0 +1,454 @@
|
||||
---
|
||||
slug: automating-opnsense-update-ansible
|
||||
title: Automatiser les mises à jour d'OPNsense avec Ansible
|
||||
description: Automatiser les mises à jour d'un cluster HA OPNsense dans un homelab avec Ansible, en utilisant des vérifications CARP, des snapshots Proxmox et des notifications Ntfy.
|
||||
date: 2026-08-21
|
||||
draft: false
|
||||
tags:
|
||||
- opnsense
|
||||
- ansible
|
||||
- semaphore-ui
|
||||
- ntfy
|
||||
- proxmox
|
||||
categories:
|
||||
- homelab
|
||||
image: cover-21.webp
|
||||
---
|
||||
## Intro
|
||||
|
||||
Dans mon homelab, la plupart des composants de l'infrastructure sont déjà suffisamment redondants pour tolérer les opérations de maintenance, mais le processus de mise à jour lui-même restait encore trop manuel. Proxmox a été le premier élément que j'ai automatisé dans ce [billet]({{< ref "post/20-automating-proxmox-update-ansible" >}}). Une fois ce workflow exécuté par Semaphore selon un calendrier, OPNsense est devenu la prochaine cible logique.
|
||||
|
||||
Mon installation OPNsense est un cluster HA composé de deux nœuds. Le nœud maître, `cerbere-head1`, fonctionne sur Proxmox. Le nœud de secours, `cerbere-head2`, fonctionne sur TrueNAS. Cette séparation est volontaire, car je veux que le réseau puisse survivre à une maintenance ou à une panne du cluster Proxmox.
|
||||
|
||||
L'objectif est simple : créer un playbook Ansible capable de mettre à jour ou de faire évoluer le cluster HA OPNsense de manière sûre, dans le bon ordre, avec des vérifications avant toute modification et une notification à la fin.
|
||||
|
||||
---
|
||||
## Stratégie de mise à jour
|
||||
|
||||
OPNsense expose une API qui permet de vérifier l'état du système, l'état du firmware, les services et l'état des adresses IP virtuelles CARP. Ansible pilote l'automatisation au moyen d'appels API, tandis que Semaphore UI sert de contrôleur pour exécuter le playbook manuellement ou selon un calendrier. Ntfy est utilisé pour le rapport final et les notifications d'échec.
|
||||
|
||||
Pour le nœud hébergé sur Proxmox, j'utilise également la [collection Ansible community.proxmox](https://docs.ansible.com/projects/ansible/latest/collections/community/proxmox/index.html). Elle permet au playbook de créer un snapshot de la VM avant de mettre à jour le nœud maître du firewall et de revenir à ce snapshot si nécessaire.
|
||||
|
||||
Le point important est que les deux nœuds OPNsense ne sont pas traités exactement de la même manière. Le nœud de secours fonctionne sur TrueNAS, le playbook le met donc à jour sans créer de snapshot d'hyperviseur. Le nœud maître fonctionne sur Proxmox, le playbook crée donc un snapshot avant de démarrer l'opération sur le firmware.
|
||||
|
||||
---
|
||||
## Création d'un utilisateur API dans OPNsense
|
||||
|
||||
Pour permettre à Ansible d'interagir avec OPNsense, je crée un utilisateur dédié sur le nœud maître.
|
||||
|
||||
Cet utilisateur s'appelle `automation`, avec un mot de passe aléatoire et uniquement les privilèges nécessaires au playbook :
|
||||
|
||||
- `Interface: Virtual IPS: Status`
|
||||
- `System: Firmware`
|
||||
- `System: Status`
|
||||
- `Status: Services`
|
||||
|
||||
Comme il s'agit d'un cluster HA, la synchronisation entre les deux nœuds OPNsense gère la création de l'utilisateur sur le nœud de secours.
|
||||
|
||||
Après avoir créé l'utilisateur, je génère une clé API.
|
||||
|
||||

|
||||
La clé API OPNsense est générée depuis l'utilisateur dédié à l'automatisation.
|
||||
|
||||
Le fichier téléchargé contient la `key` et le `secret` de l'API. Dans l'interface OPNsense, la clé reste visible dans l'onglet `ApiKeys`, mais le secret ne l'est plus.
|
||||
|
||||
Je teste d'abord les appels API avec Bruno depuis VS Code. Une fois les appels de base fonctionnels, je charge les identifiants dans Semaphore.
|
||||
|
||||
---
|
||||
## Préparation de Semaphore UI
|
||||
|
||||
Dans Semaphore, je crée une entrée dans le magasin de clés nommée `OPNsense automation`, qui contient la clé et le secret de l'API.
|
||||
|
||||
Je crée ensuite un inventaire pour les nœuds OPNsense :
|
||||
|
||||
```yaml
|
||||
---
|
||||
all:
|
||||
children:
|
||||
opnsense:
|
||||
vars:
|
||||
ansible_connection: local
|
||||
children:
|
||||
opnsense_backup:
|
||||
hosts:
|
||||
cerbere-head2:
|
||||
ansible_host: 192.168.88.3
|
||||
main_role: BACKUP
|
||||
hypervisor: TrueNAS
|
||||
opnsense_master:
|
||||
hosts:
|
||||
cerbere-head1:
|
||||
ansible_host: 192.168.88.2
|
||||
main_role: MASTER
|
||||
hypervisor: Proxmox
|
||||
proxmox_vmid: 122
|
||||
```
|
||||
|
||||
Le playbook s'exécute localement depuis Semaphore et communique avec chaque firewall via l'API OPNsense.
|
||||
|
||||
Je crée également un groupe de variables nommé `OPNsense automation API`, avec les identifiants API et quelques variables partagées :
|
||||
|
||||
- `OPNSENSE_API_KEY`
|
||||
- `OPNSENSE_API_SECRET`
|
||||
- `opnsense_api_key`
|
||||
- `opnsense_api_secret`
|
||||
- `opnsense_https_port`
|
||||
- `opnsense_host`
|
||||
|
||||
Le port HTTPS est configuré sur `4443`, et l'hôte est construit à partir de l'adresse présente dans l'inventaire et de ce port.
|
||||
|
||||
Enfin, je crée le modèle de tâche Semaphore.
|
||||
|
||||

|
||||
Le modèle de tâche Semaphore utilisé pour exécuter le playbook de mise à jour OPNsense.
|
||||
|
||||
Avant d'aller plus loin, je vérifie qu'Ansible peut interroger les deux nœuds :
|
||||
|
||||
```yaml
|
||||
- name: Check node availability
|
||||
ansible.builtin.uri:
|
||||
url: "https://{{ opnsense_host }}/api/core/system/status"
|
||||
method: GET
|
||||
user: "{{ opnsense_api_key }}"
|
||||
password: "{{ opnsense_api_secret }}"
|
||||
force_basic_auth: true
|
||||
validate_certs: false
|
||||
```
|
||||
|
||||
À ce stade, l'automatisation peut atteindre les deux nœuds et s'authentifier auprès de leur API.
|
||||
|
||||
---
|
||||
## Rendre la maintenance CARP utilisable depuis l'API
|
||||
|
||||
La maintenance CARP est un élément important du workflow.
|
||||
|
||||
Avant de mettre à jour un nœud, je veux le placer en mode maintenance afin que les adresses IP virtuelles ne soient pas actives sur le nœud en cours de mise à jour. C'est particulièrement important lors de la mise à jour du nœud maître, car le nœud de secours doit prendre le relais proprement avant le début de l'opération.
|
||||
|
||||
Pendant les tests de l'API, l'endpoint de maintenance CARP renvoyait `403 Forbidden` :
|
||||
|
||||
```text
|
||||
POST https://{{opnsense_host}}/api/diagnostics/interface/carp_status/maintenance
|
||||
```
|
||||
|
||||
Les privilèges avaient bien été accordés dans l'interface Web, le problème semblait donc lié à la définition de l'ACL. J'ai modifié manuellement le fichier ACL d'OPNsense afin d'ajouter le wildcard au motif de l'API :
|
||||
|
||||
```xml
|
||||
<pattern>api/diagnostics/interface/carp_status/*</pattern>
|
||||
```
|
||||
|
||||
Après avoir redémarré le système, l'endpoint a renvoyé une réponse correcte.
|
||||
|
||||
J'ai créé une petite [PR](https://github.com/opnsense/core/pull/10428) dans le projet OPNsense pour corriger le problème. Elle a été rapidement fusionnée dans la branche `master` de `opnsense/core`. C'était ma première contribution à ce projet.
|
||||
|
||||

|
||||
La petite correction de l'ACL OPNsense a été fusionnée en amont.
|
||||
|
||||
Plus tard, OPNsense [26.1.11](https://forum.opnsense.org/index.php?topic=52257.0) est sorti avec cette correction. J'ai alors pu tester le playbook complet sans dépendre de la modification manuelle de l'ACL.
|
||||
|
||||
---
|
||||
## Conception du workflow du playbook
|
||||
|
||||
Le playbook suit un ordre simple :
|
||||
|
||||
- Vérifier l'état des deux nœuds
|
||||
- Mettre d'abord à jour le nœud de secours
|
||||
- Mettre ensuite à jour le nœud maître
|
||||
- Envoyer une notification finale
|
||||
|
||||
Cet ordre est important. Le nœud de secours est mis à jour en premier, car le maître reste actif. Ensuite, avant de mettre à jour le maître, le playbook active le mode maintenance CARP afin de permettre au nœud de secours de prendre le relais.
|
||||
|
||||
Le playbook prend également en charge plusieurs actions au moyen d'un sondage Semaphore.
|
||||
|
||||

|
||||
Le sondage Semaphore me permet de choisir entre une vérification, une mise à jour et une évolution de version.
|
||||
|
||||
C'est nécessaire, car OPNsense n'expose pas les mises à jour et les évolutions de version exactement de la même manière. Les variables correspondant à la version cible et au redémarrage requis diffèrent selon l'action choisie. Le playbook résout ces différences avant de déterminer quoi faire.
|
||||
|
||||
---
|
||||
## Vérifications du firmware et de CARP
|
||||
|
||||
La première phase s'exécute sur les deux nœuds OPNsense.
|
||||
|
||||
Elle récupère des informations au moyen de plusieurs appels API :
|
||||
|
||||
- État du système
|
||||
- Vérification du firmware
|
||||
- État du firmware
|
||||
- État des adresses IP virtuelles CARP
|
||||
|
||||
Le playbook enregistre le résultat sous forme de facts réutilisables plus tard :
|
||||
|
||||
```yaml
|
||||
- name: Store node facts
|
||||
ansible.builtin.set_fact:
|
||||
firmware_action: "{{ opnsense_action | default('update')}}"
|
||||
firmware_status: "{{ _firmware_status.json.status }}"
|
||||
firmware_status_msg: "{{ _firmware_status.json.status_msg }}"
|
||||
firmware_current_version: "{{ _firmware_status.json.product.product_version | default('unknown') }}"
|
||||
firmware_product_series: "{{ _firmware_status.json.product.product_series | default('unknown') }}"
|
||||
firmware_update_version: "{{ _firmware_status.json.upgrade_packages | selectattr('name', 'equalto', 'opnsense') | map(attribute='new_version') | first | default('') }}"
|
||||
firmware_upgrade_version: "{{ _firmware_status.json.upgrade_major_version | default('unknown') }}"
|
||||
firmware_upgrade_message: "{{ _firmware_status.json.upgrade_major_message | regex_replace('<[^>]+>', ' ') | regex_replace('\\s{2,}', ' ') | trim | default('') }}"
|
||||
firmware_up_to_date: "{{ _firmware_status.json.status_msg == 'There are no updates available on the selected mirror.' }}"
|
||||
needs_reboot: "{{ (_firmware_status.json.upgrade_needs_reboot == '1') if _firmware_status.json.status == 'upgrade' else (_firmware_status.json.needs_reboot == '1') }}"
|
||||
total_vips: "{{ _vip_status.json.rowCount }}"
|
||||
mismatched_vips: "{{ _vip_status.json.rows | rejectattr('status', 'equalto', main_role) | list | length }}"
|
||||
in_maintenance: "{{ _vip_status.json.carp.maintenancemode }}"
|
||||
```
|
||||
|
||||
Le playbook détermine ensuite s'il s'agit d'une version précise ou d'une série de versions :
|
||||
|
||||
```yaml
|
||||
- name: Resolve update-vs-upgrade specifics
|
||||
ansible.builtin.set_fact:
|
||||
firmware_target_kind: "{{ 'series' if firmware_status == 'upgrade' else 'version' }}"
|
||||
firmware_target_value: "{{ firmware_upgrade_version if firmware_status == 'upgrade' else firmware_update_version }}"
|
||||
```
|
||||
|
||||
Cela simplifie la gestion du reste du playbook. Il peut ensuite vérifier que le nœud a atteint la `version` ou la `series` attendue sans dupliquer toute la logique.
|
||||
|
||||
La première phase valide également plusieurs conditions avant de continuer :
|
||||
|
||||
- Le nœud ne doit pas être déjà en mode maintenance CARP
|
||||
- Les adresses IP virtuelles doivent correspondre au rôle attendu
|
||||
- Au moins une adresse IP virtuelle doit être gérée
|
||||
|
||||
Si l'une de ces vérifications échoue, le playbook s'arrête et envoie une notification Ntfy.
|
||||
|
||||
## Gestion correcte des conditions de non-exécution
|
||||
|
||||
L'un des aspects les plus délicats n'est pas la mise à jour elle-même, mais la décision de ne pas mettre à jour.
|
||||
|
||||
Après une première exécution réussie, il est possible que le nœud de secours soit déjà à jour alors que le maître ne l'est pas encore. L'exécution suivante ne doit donc pas mettre à jour le nœud de secours une nouvelle fois s'il possède déjà la version ciblée par le maître.
|
||||
|
||||
J'ajoute une condition de non-exécution pour ce cas :
|
||||
|
||||
```yaml
|
||||
- name: Backup already updated
|
||||
ansible.builtin.set_fact:
|
||||
skip_update: true
|
||||
firmware_status: "skipped"
|
||||
delegate_to: "{{ groups['opnsense_backup'][0] }}"
|
||||
delegate_facts: true
|
||||
run_once: true
|
||||
when: >-
|
||||
(hostvars[groups['opnsense_backup'][0]].firmware_current_version
|
||||
if hostvars[groups['opnsense_master'][0]].firmware_target_kind == 'version'
|
||||
else hostvars[groups['opnsense_backup'][0]].firmware_product_series)
|
||||
== hostvars[groups['opnsense_master'][0]].firmware_target_value
|
||||
```
|
||||
|
||||
Je généralise ensuite ce comportement.
|
||||
|
||||
Le playbook ignore un nœud dans les cas suivants :
|
||||
|
||||
- Aucune mise à jour n'est disponible
|
||||
- Une évolution de version est disponible, mais l'action demandée est une mise à jour
|
||||
- Une mise à jour est disponible, mais l'action demandée est une évolution de version
|
||||
- Le nœud de secours possède déjà la version ou la série ciblée par le maître
|
||||
|
||||
La notification finale est ainsi beaucoup plus claire, car un nœud ignoré n'est pas considéré comme une erreur. Elle indique simplement qu'aucune action n'était nécessaire.
|
||||
|
||||
## Mise à jour du nœud de secours
|
||||
|
||||
Le nœud de secours fonctionne sur TrueNAS, cette phase ne crée donc pas de snapshot d'hyperviseur.
|
||||
|
||||
Le playbook active le mode maintenance CARP, déclenche l'opération sur le firmware, attend le début de la mise à jour, attend le redémarrage du nœud si nécessaire, puis attend que celui-ci soit de nouveau disponible.
|
||||
|
||||
La partie correspondante ressemble à ceci :
|
||||
|
||||
```yaml
|
||||
- name: Trigger firmware {{ firmware_action }}
|
||||
ansible.builtin.uri:
|
||||
url: "https://{{ opnsense_host }}/api/core/firmware/{{ firmware_action }}"
|
||||
method: POST
|
||||
user: "{{ opnsense_api_key }}"
|
||||
password: "{{ opnsense_api_secret }}"
|
||||
force_basic_auth: true
|
||||
validate_certs: false
|
||||
```
|
||||
|
||||
Si un redémarrage est nécessaire, le playbook attend que le port HTTPS ne soit plus accessible :
|
||||
|
||||
```yaml
|
||||
- name: Wait for node to reboot after the {{ firmware_action }}
|
||||
ansible.builtin.wait_for:
|
||||
host: "{{ ansible_host }}"
|
||||
port: "{{ opnsense_https_port }}"
|
||||
state: stopped
|
||||
timeout: 3600
|
||||
when: needs_reboot
|
||||
```
|
||||
|
||||
Il attend ensuite que le nœud soit de nouveau accessible :
|
||||
|
||||
```yaml
|
||||
- name: Wait for node to come back online
|
||||
ansible.builtin.wait_for:
|
||||
host: "{{ ansible_host }}"
|
||||
port: "{{ opnsense_https_port }}"
|
||||
state: started
|
||||
timeout: 5400
|
||||
delay: 30
|
||||
when: needs_reboot
|
||||
```
|
||||
|
||||
Enfin, il vérifie que la version du firmware ou la série du produit correspond à la cible attendue.
|
||||
|
||||
```yaml
|
||||
- name: Check firmware version
|
||||
ansible.builtin.uri:
|
||||
url: "https://{{ opnsense_host }}/api/core/firmware/status"
|
||||
method: GET
|
||||
user: "{{ opnsense_api_key }}"
|
||||
password: "{{ opnsense_api_secret }}"
|
||||
force_basic_auth: true
|
||||
validate_certs: false
|
||||
register: _post_firmware_status
|
||||
until: _post_firmware_status.json.product['product_' ~ firmware_target_kind] | default('unknown') == firmware_target_value
|
||||
retries: 240
|
||||
delay: 15
|
||||
```
|
||||
|
||||
Cette vérification permet de confirmer de manière fiable que la mise à jour ou l'évolution de version a bien atteint la cible attendue.
|
||||
|
||||
## Mise à jour du nœud maître avec un snapshot Proxmox
|
||||
|
||||
Le nœud maître bénéficie de protections supplémentaires.
|
||||
|
||||
Comme il fonctionne sur Proxmox, le playbook crée un snapshot de la VM avant d'activer le mode maintenance CARP et de démarrer l'opération sur le firmware.
|
||||
|
||||
Pour cela, je crée un utilisateur et un token Proxmox dédiés à Semaphore :
|
||||
|
||||
```bash
|
||||
pveum user add semaphore@pve
|
||||
pveum user token add semaphore@pve opnsense -expire 0 -privsep 0
|
||||
```
|
||||
|
||||
Je crée ensuite un rôle limité :
|
||||
|
||||
```bash
|
||||
pveum role add SemaphoreOpnsenseUpdate -privs "\
|
||||
VM.Audit \
|
||||
VM.PowerMgmt \
|
||||
VM.Snapshot \
|
||||
VM.Snapshot.Rollback \
|
||||
"
|
||||
```
|
||||
|
||||
Le rôle est attribué uniquement à la VM OPNsense :
|
||||
|
||||
```bash
|
||||
pveum aclmod /vms/122 -user semaphore@pve -role SemaphoreOpnsenseUpdate
|
||||
```
|
||||
|
||||
J'aime cette approche, car Semaphore ne peut agir que sur la VM concernée par ce workflow. Il ne dispose pas de permissions étendues sur l'ensemble de l'environnement Proxmox.
|
||||
|
||||
Dans Semaphore, j'ajoute un autre groupe de variables pour les identifiants de l'API Proxmox :
|
||||
|
||||
- `PROXMOX_HOST`
|
||||
- `PROXMOX_PORT`
|
||||
- `PROXMOX_TOKEN_ID`
|
||||
- `PROXMOX_USER`
|
||||
- `PROXMOX_TOKEN_SECRET`
|
||||
|
||||
Pour utiliser les modules Proxmox, j'ajoute un fichier `requirements.yml` à côté du playbook :
|
||||
|
||||
```yaml
|
||||
---
|
||||
collections:
|
||||
- name: community.proxmox
|
||||
version: "2.0.0"
|
||||
```
|
||||
|
||||
La collection Proxmox nécessite également la bibliothèque Python `proxmoxer`. J'ajoute donc un fichier `requirements.txt` à côté du fichier `docker-compose.yml` de Semaphore :
|
||||
|
||||
```text
|
||||
proxmoxer>=2.3
|
||||
```
|
||||
|
||||
Je le monte ensuite dans le conteneur Semaphore :
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- /appli/docker/semaphore/requirements.txt:/etc/semaphore/requirements.txt
|
||||
```
|
||||
|
||||
Après avoir redéployé Semaphore, le playbook peut créer le snapshot :
|
||||
|
||||
```yaml
|
||||
- name: Take Proxmox VM snapshot
|
||||
community.proxmox.proxmox_snap:
|
||||
vmid: "{{ proxmox_vmid }}"
|
||||
state: present
|
||||
snapname: "{{ proxmox_snap_name }}"
|
||||
description: "Pre-firmware-{{ firmware_action }}: {{ firmware_current_version }} → {{ firmware_target_value }}"
|
||||
```
|
||||
|
||||
Si quelque chose échoue pendant la mise à jour du maître, le bloc de récupération restaure la VM depuis le snapshot créé avant la mise à jour et envoie une notification Ntfy de priorité élevée.
|
||||
|
||||
## Notification finale
|
||||
|
||||
Au début, j'utilisais trop d'assertions pour piloter la logique de notification. Cela fonctionne pour les échecs, mais ce n'est pas le bon modèle pour les situations normales comme l'absence de mise à jour disponible.
|
||||
|
||||
Le bloc de récupération doit uniquement gérer les véritables échecs. Les situations normales doivent atteindre la phase de notification finale.
|
||||
|
||||
La phase finale s'exécute sur `localhost` et compare les facts collectés sur les nœuds maître et de secours. Elle gère les deux cas suivants :
|
||||
|
||||
- Les deux nœuds ont effectué la même opération
|
||||
- Chaque nœud possède un résultat différent
|
||||
|
||||
Le corps de la notification est généré à partir des variables d'hôte des nœuds maître et de secours :
|
||||
|
||||
```yaml
|
||||
body: |
|
||||
{% if same_operation %}
|
||||
{% if m.skip_update | default(false) %}
|
||||
Les deux nœuds sont déjà en {{ m.firmware_current_version }}, aucune action effectuée.
|
||||
{% else %}
|
||||
Cluster OPNsense : {{ m.firmware_current_version }} → {{ m.firmware_target_value }} ({{ m.firmware_status }})
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% if m.skip_update | default(false) %}
|
||||
MAÎTRE ({{ master }}) : déjà en {{ m.firmware_current_version }}, aucune action effectuée.
|
||||
{% else %}
|
||||
MAÎTRE ({{ master }}) : {{ m.firmware_current_version }} → {{ m.firmware_target_value }} ({{ m.firmware_status }})
|
||||
{% endif %}
|
||||
{% if b.skip_update | default(false) %}
|
||||
SECOURS ({{ backup }}) : déjà en {{ b.firmware_current_version }}, aucune action effectuée.
|
||||
{% else %}
|
||||
SECOURS ({{ backup }}) : {{ b.firmware_current_version }} → {{ b.firmware_target_value }} ({{ b.firmware_status }})
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
La priorité et le tag de la notification changent également selon qu'une action a été effectuée ou que les deux nœuds sont déjà à jour.
|
||||
|
||||
J'obtiens ainsi un rapport utile sans transformer une exécution sans action en erreur.
|
||||
|
||||
## Workflow final
|
||||
|
||||
Le workflow terminé est divisé en quatre phases :
|
||||
|
||||
- Vérification du firmware sur tous les nœuds
|
||||
- Mise à jour du nœud de secours sur TrueNAS
|
||||
- Mise à jour du nœud maître sur Proxmox avec un snapshot
|
||||
- Envoi d'une notification Ntfy
|
||||
|
||||
Le nœud de secours est mis à jour en premier. Le nœud maître est mis à jour en second, avec la création d'un snapshot Proxmox avant l'opération sur le firmware. L'état CARP est vérifié avant le début du workflow et le mode maintenance est utilisé pendant les mises à jour des nœuds.
|
||||
|
||||
Le playbook prend en charge les scénarios de mise à jour, d'évolution de version et de vérification au moyen du sondage Semaphore. Il sait également ignorer un nœud lorsqu'il n'y a rien à faire ou lorsque l'action demandée ne correspond pas à ce que signale OPNsense.
|
||||
|
||||
Plus important encore, le workflow s'exécute désormais de bout en bout et signale le résultat.
|
||||
|
||||
Le playbook Ansible est disponible [ici](https://github.com/Vezpi/Homelab/blob/main/ansible/opnsense/update_opnsense_ha_cluster.yml).
|
||||
|
||||
## Conclusion
|
||||
|
||||
Cette automatisation est partie d'une idée simple : ne plus mettre OPNsense à jour manuellement.
|
||||
|
||||
En pratique, le sujet s'est révélé plus intéressant qu'un simple appel à l'endpoint du firmware. Le playbook devait comprendre l'état HA, gérer différemment les mises à jour et les évolutions de version, mettre les nœuds à jour dans le bon ordre, protéger le maître hébergé sur Proxmox avec un snapshot et signaler l'état final sans considérer les exécutions sans action comme des échecs.
|
||||
|
||||
Le résultat s'intègre beaucoup mieux au reste de l'automatisation de mon homelab. Semaphore fournit un point d'entrée reproductible, Ansible gère la logique, OPNsense expose son état via son API, Proxmox fournit un point de restauration pour le maître et Ntfy m'indique ce qui s'est passé.
|
||||
|
||||
C'est une tâche de maintenance manuelle de moins à oublier, et un élément de plus du homelab capable de prendre soin de lui-même.
|
||||
@@ -0,0 +1,454 @@
|
||||
---
|
||||
slug: automating-opnsense-update-ansible
|
||||
title: Automating OPNsense HA updates with Ansible
|
||||
description: Automating OPNsense HA updates in a homelab using Ansible, using CARP checks, Proxmox snapshots and Ntfy notifications.
|
||||
date: 2026-08-21
|
||||
draft: false
|
||||
tags:
|
||||
- opnsense
|
||||
- ansible
|
||||
- semaphore-ui
|
||||
- ntfy
|
||||
- proxmox
|
||||
categories:
|
||||
- homelab
|
||||
image: cover-21.webp
|
||||
---
|
||||
## Intro
|
||||
|
||||
In my homelab, most of the infrastructure is already redundant enough to tolerate maintenance, but the update process itself was still too manual. Proxmox was the first part I automated in that [post]({{< ref "post/20-automating-proxmox-update-ansible" >}}), and once that workflow was running from Semaphore on a schedule, the next logical target was OPNsense.
|
||||
|
||||
My OPNsense setup is an HA cluster with two nodes. The master node, `cerbere-head1`, runs on Proxmox. The backup node, `cerbere-head2`, runs on TrueNAS. That split is intentional, because I want the network to survive maintenance or outages on the Proxmox cluster.
|
||||
|
||||
The goal is simple: create an Ansible playbook able to update or upgrade the OPNsense HA cluster safely, in the right order, with checks before touching anything, and a notification at the end.
|
||||
|
||||
---
|
||||
## Update strategy
|
||||
|
||||
OPNsense exposes an API that can be used to check system status, firmware status, services and CARP virtual IP state. Ansible drives the automation with API calls, while Semaphore UI is used as the controller to run the playbook manually or from a schedule. Ntfy is used for the final report and for failure notifications.
|
||||
|
||||
For the Proxmox hosted node, I also use the [community.proxmox Ansible collection](https://docs.ansible.com/projects/ansible/latest/collections/community/proxmox/index.html). This allows the playbook to create a VM snapshot before updating the master firewall node and roll back to it if needed.
|
||||
|
||||
The important detail is that both OPNsense nodes are not treated exactly the same. The backup node runs on TrueNAS, so the playbook updates it without taking a hypervisor snapshot. The master node runs on Proxmox, so the playbook takes a snapshot before starting the firmware operation.
|
||||
|
||||
---
|
||||
## Creating an API user in OPNsense
|
||||
|
||||
To let Ansible interact with OPNsense, I create a dedicated user on the master node.
|
||||
|
||||
The user is called `automation`, with a scrambled password and only the privileges needed by the playbook:
|
||||
|
||||
- `Interface: Virtual IPS: Status`
|
||||
- `System: Firmware`
|
||||
- `System: Status`
|
||||
- `Status: Services`
|
||||
|
||||
Because this is an HA cluster, the synchronization between both OPNsense nodes handles the user creation on the backup node.
|
||||
|
||||
After creating the user, I generate an API key.
|
||||
|
||||

|
||||
The OPNsense API key is generated from the dedicated automation user.
|
||||
|
||||
The downloaded file contains the API `key` and `secret`. In the OPNsense UI, the key remains visible in the `ApiKeys` tab, but the secret does not.
|
||||
|
||||
I first test the API calls with Bruno from VS Code. Once the basic calls are working, I load the credentials into Semaphore.
|
||||
|
||||
---
|
||||
## Preparing Semaphore UI
|
||||
|
||||
In Semaphore, I create a key store entry named `OPNsense automation`, containing the API key and secret.
|
||||
|
||||
Then I create an inventory for the OPNsense nodes:
|
||||
|
||||
```yaml
|
||||
---
|
||||
all:
|
||||
children:
|
||||
opnsense:
|
||||
vars:
|
||||
ansible_connection: local
|
||||
children:
|
||||
opnsense_backup:
|
||||
hosts:
|
||||
cerbere-head2:
|
||||
ansible_host: 192.168.88.3
|
||||
main_role: BACKUP
|
||||
hypervisor: TrueNAS
|
||||
opnsense_master:
|
||||
hosts:
|
||||
cerbere-head1:
|
||||
ansible_host: 192.168.88.2
|
||||
main_role: MASTER
|
||||
hypervisor: Proxmox
|
||||
proxmox_vmid: 122
|
||||
```
|
||||
|
||||
The playbook runs locally from Semaphore and talks to each firewall through the OPNsense API.
|
||||
|
||||
I also create a variable group named `OPNsense automation API` with the API credentials and a few shared variables:
|
||||
|
||||
- `OPNSENSE_API_KEY`
|
||||
- `OPNSENSE_API_SECRET`
|
||||
- `opnsense_api_key`
|
||||
- `opnsense_api_secret`
|
||||
- `opnsense_https_port`
|
||||
- `opnsense_host`
|
||||
|
||||
The HTTPS port is set to `4443`, and the host is built from the inventory address and this port.
|
||||
|
||||
Finally, I create the Semaphore task template.
|
||||
|
||||

|
||||
The Semaphore task template used to run the OPNsense HA update playbook.
|
||||
|
||||
Before going further, I validate that Ansible could query both nodes:
|
||||
|
||||
```yaml
|
||||
- name: Check node availability
|
||||
ansible.builtin.uri:
|
||||
url: "https://{{ opnsense_host }}/api/core/system/status"
|
||||
method: GET
|
||||
user: "{{ opnsense_api_key }}"
|
||||
password: "{{ opnsense_api_secret }}"
|
||||
force_basic_auth: true
|
||||
validate_certs: false
|
||||
```
|
||||
|
||||
At that point, the automation can reach both nodes and authenticate against the API.
|
||||
|
||||
---
|
||||
## Making CARP maintenance usable from the API
|
||||
|
||||
One important part of the workflow is CARP maintenance mode.
|
||||
|
||||
Before updating a node, I want to put it into maintenance mode so the virtual IPs are not active on the node being updated. This is especially important when updating the master node, because the backup must take over cleanly before the update starts.
|
||||
|
||||
During API testing, the CARP maintenance endpoint returned `403 Forbidden`:
|
||||
|
||||
```text
|
||||
POST https://{{opnsense_host}}/api/diagnostics/interface/carp_status/maintenance
|
||||
```
|
||||
|
||||
The privileges were granted in the WebUI, so the issue looked related to the ACL definition. I manually adjusted the OPNsense ACL file by changing the API pattern to include the wildcard:
|
||||
|
||||
```xml
|
||||
<pattern>api/diagnostics/interface/carp_status/*</pattern>
|
||||
```
|
||||
|
||||
After rebooting the system, the endpoint returned a proper response.
|
||||
|
||||
I created a small [PR](https://github.com/opnsense/core/pull/10428) in the OPNsense project to fix this, and it was merged quickly into the `opnsense/core` `master` branch, my first contribution to that project.
|
||||
|
||||

|
||||
The small OPNsense ACL fix was merged upstream.
|
||||
|
||||
Later, OPNsense [26.1.11](https://forum.opnsense.org/index.php?topic=52257.0) was released and included the fix. That allowed me to test the full playbook without relying on the manual ACL change.
|
||||
|
||||
---
|
||||
## Designing the playbook workflow
|
||||
|
||||
The playbook follows a simple order:
|
||||
|
||||
- Check both nodes status
|
||||
- Update the backup node first
|
||||
- Update the master node second
|
||||
- Send a final notification
|
||||
|
||||
That order is important. The backup node is updated first because the master is still active. Then, before updating the master, the playbook enables CARP maintenance mode to let the backup take over.
|
||||
|
||||
The playbook also supports different actions through a Semaphore survey.
|
||||
|
||||

|
||||
The Semaphore survey lets me choose between check, update and upgrade.
|
||||
|
||||
This is needed because OPNsense does not expose updates and upgrades in exactly the same way. The variables for the target version and the reboot requirement differ between an update and an upgrade, so the playbook resolves those differences before deciding what to do.
|
||||
|
||||
---
|
||||
## Firmware and CARP checks
|
||||
|
||||
The first phase runs on both OPNsense nodes.
|
||||
|
||||
It gathers information from several API calls:
|
||||
|
||||
- System status
|
||||
- Firmware check
|
||||
- Firmware status
|
||||
- CARP virtual IP status
|
||||
|
||||
The playbook stores the result as facts that can be reused later:
|
||||
|
||||
```yaml
|
||||
- name: Store node facts
|
||||
ansible.builtin.set_fact:
|
||||
firmware_action: "{{ opnsense_action | default('update')}}"
|
||||
firmware_status: "{{ _firmware_status.json.status }}"
|
||||
firmware_status_msg: "{{ _firmware_status.json.status_msg }}"
|
||||
firmware_current_version: "{{ _firmware_status.json.product.product_version | default('unknown') }}"
|
||||
firmware_product_series: "{{ _firmware_status.json.product.product_series | default('unknown') }}"
|
||||
firmware_update_version: "{{ _firmware_status.json.upgrade_packages | selectattr('name', 'equalto', 'opnsense') | map(attribute='new_version') | first | default('') }}"
|
||||
firmware_upgrade_version: "{{ _firmware_status.json.upgrade_major_version | default('unknown') }}"
|
||||
firmware_upgrade_message: "{{ _firmware_status.json.upgrade_major_message | regex_replace('<[^>]+>', ' ') | regex_replace('\\s{2,}', ' ') | trim | default('') }}"
|
||||
firmware_up_to_date: "{{ _firmware_status.json.status_msg == 'There are no updates available on the selected mirror.' }}"
|
||||
needs_reboot: "{{ (_firmware_status.json.upgrade_needs_reboot == '1') if _firmware_status.json.status == 'upgrade' else (_firmware_status.json.needs_reboot == '1') }}"
|
||||
total_vips: "{{ _vip_status.json.rowCount }}"
|
||||
mismatched_vips: "{{ _vip_status.json.rows | rejectattr('status', 'equalto', main_role) | list | length }}"
|
||||
in_maintenance: "{{ _vip_status.json.carp.maintenancemode }}"
|
||||
```
|
||||
|
||||
Then it resolves whether the target is a regular version or a product series:
|
||||
|
||||
```yaml
|
||||
- name: Resolve update-vs-upgrade specifics
|
||||
ansible.builtin.set_fact:
|
||||
firmware_target_kind: "{{ 'series' if firmware_status == 'upgrade' else 'version' }}"
|
||||
firmware_target_value: "{{ firmware_upgrade_version if firmware_status == 'upgrade' else firmware_update_version }}"
|
||||
```
|
||||
|
||||
This makes the rest of the playbook easier to manage. It can later check whether the node reached the expected `version` or `series` without duplicating the whole logic.
|
||||
|
||||
The first phase also validates a few conditions before proceeding:
|
||||
|
||||
- The node must not already be in CARP maintenance mode
|
||||
- The VIPs must match the expected role
|
||||
- At least one VIP must be managed
|
||||
|
||||
If one of these checks fails, the playbook aborts and sends a Ntfy notification.
|
||||
|
||||
## Handling skip logic properly
|
||||
|
||||
One of the trickiest parts is not the update itself, but deciding when not to update.
|
||||
|
||||
After the first successful test, the backup node is already updated while the master is not. The next run should not update the backup again if it is already at the version the master is targeting.
|
||||
|
||||
I add skip logic for that case:
|
||||
|
||||
```yaml
|
||||
- name: Backup already updated
|
||||
ansible.builtin.set_fact:
|
||||
skip_update: true
|
||||
firmware_status: "skipped"
|
||||
delegate_to: "{{ groups['opnsense_backup'][0] }}"
|
||||
delegate_facts: true
|
||||
run_once: true
|
||||
when: >-
|
||||
(hostvars[groups['opnsense_backup'][0]].firmware_current_version
|
||||
if hostvars[groups['opnsense_master'][0]].firmware_target_kind == 'version'
|
||||
else hostvars[groups['opnsense_backup'][0]].firmware_product_series)
|
||||
== hostvars[groups['opnsense_master'][0]].firmware_target_value
|
||||
```
|
||||
|
||||
Then I generalize the skip behavior.
|
||||
|
||||
The playbook skips a node when:
|
||||
|
||||
- No updates are available
|
||||
- An upgrade is available but the requested action is update
|
||||
- An update is available but the requested action is upgrade
|
||||
- The backup node is already at the target version or series of the master
|
||||
|
||||
This makes the final notification much cleaner, because a skipped node is not treated as an error. It is simply reported as no action needed.
|
||||
|
||||
## Updating the backup node
|
||||
|
||||
The backup node runs on TrueNAS, so this phase does not create a hypervisor snapshot.
|
||||
|
||||
The playbook enables CARP maintenance mode, triggers the firmware action, waits for the update to start, waits for the node to reboot if required, then waits for it to come back online.
|
||||
|
||||
The relevant part looks like this:
|
||||
|
||||
```yaml
|
||||
- name: Trigger firmware {{ firmware_action }}
|
||||
ansible.builtin.uri:
|
||||
url: "https://{{ opnsense_host }}/api/core/firmware/{{ firmware_action }}"
|
||||
method: POST
|
||||
user: "{{ opnsense_api_key }}"
|
||||
password: "{{ opnsense_api_secret }}"
|
||||
force_basic_auth: true
|
||||
validate_certs: false
|
||||
```
|
||||
|
||||
If a reboot is required, the playbook waits for the HTTPS port to go down:
|
||||
|
||||
```yaml
|
||||
- name: Wait for node to reboot after the {{ firmware_action }}
|
||||
ansible.builtin.wait_for:
|
||||
host: "{{ ansible_host }}"
|
||||
port: "{{ opnsense_https_port }}"
|
||||
state: stopped
|
||||
timeout: 3600
|
||||
when: needs_reboot
|
||||
```
|
||||
|
||||
Then it waits for the node to come back:
|
||||
|
||||
```yaml
|
||||
- name: Wait for node to come back online
|
||||
ansible.builtin.wait_for:
|
||||
host: "{{ ansible_host }}"
|
||||
port: "{{ opnsense_https_port }}"
|
||||
state: started
|
||||
timeout: 5400
|
||||
delay: 30
|
||||
when: needs_reboot
|
||||
```
|
||||
|
||||
Finally, it checks that the firmware version or product series matches the expected target.
|
||||
|
||||
```yaml
|
||||
- name: Check firmware version
|
||||
ansible.builtin.uri:
|
||||
url: "https://{{ opnsense_host }}/api/core/firmware/status"
|
||||
method: GET
|
||||
user: "{{ opnsense_api_key }}"
|
||||
password: "{{ opnsense_api_secret }}"
|
||||
force_basic_auth: true
|
||||
validate_certs: false
|
||||
register: _post_firmware_status
|
||||
until: _post_firmware_status.json.product['product_' ~ firmware_target_kind] | default('unknown') == firmware_target_value
|
||||
retries: 240
|
||||
delay: 15
|
||||
```
|
||||
|
||||
That check is what gives the playbook a reliable confirmation that the update or upgrade actually reached the expected target.
|
||||
|
||||
## Updating the master node with a Proxmox snapshot
|
||||
|
||||
The master node is handled with more protection.
|
||||
|
||||
Because it runs on Proxmox, the playbook creates a VM snapshot before enabling CARP maintenance mode and starting the firmware action.
|
||||
|
||||
For this, I create a dedicated Proxmox user and token for Semaphore:
|
||||
|
||||
```bash
|
||||
pveum user add semaphore@pve
|
||||
pveum user token add semaphore@pve opnsense -expire 0 -privsep 0
|
||||
```
|
||||
|
||||
Then I create a limited role:
|
||||
|
||||
```bash
|
||||
pveum role add SemaphoreOpnsenseUpdate -privs "\
|
||||
VM.Audit \
|
||||
VM.PowerMgmt \
|
||||
VM.Snapshot \
|
||||
VM.Snapshot.Rollback \
|
||||
"
|
||||
```
|
||||
|
||||
The role is assigned only to the OPNsense VM:
|
||||
|
||||
```bash
|
||||
pveum aclmod /vms/122 -user semaphore@pve -role SemaphoreOpnsenseUpdate
|
||||
```
|
||||
|
||||
I like this approach because Semaphore can only operate on the one VM involved in this workflow. It does not get broad permissions on the whole Proxmox environment.
|
||||
|
||||
In Semaphore, I add another variable group for the Proxmox API credentials:
|
||||
|
||||
- `PROXMOX_HOST`
|
||||
- `PROXMOX_PORT`
|
||||
- `PROXMOX_TOKEN_ID`
|
||||
- `PROXMOX_USER`
|
||||
- `PROXMOX_TOKEN_SECRET`
|
||||
|
||||
To use the Proxmox modules, I added a `requirements.yml` next to the playbook:
|
||||
|
||||
```yaml
|
||||
---
|
||||
collections:
|
||||
- name: community.proxmox
|
||||
version: "2.0.0"
|
||||
```
|
||||
|
||||
The Proxmox collection also requires the `proxmoxer` Python library, so I add a `requirements.txt` next to the Semaphore `docker-compose.yml`:
|
||||
|
||||
```text
|
||||
proxmoxer>=2.3
|
||||
```
|
||||
|
||||
Then I mounted it into the Semaphore container:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- /appli/docker/semaphore/requirements.txt:/etc/semaphore/requirements.txt
|
||||
```
|
||||
|
||||
After redeploying Semaphore, the playbook can create the snapshot:
|
||||
|
||||
```yaml
|
||||
- name: Take Proxmox VM snapshot
|
||||
community.proxmox.proxmox_snap:
|
||||
vmid: "{{ proxmox_vmid }}"
|
||||
state: present
|
||||
snapname: "{{ proxmox_snap_name }}"
|
||||
description: "Pre-firmware-{{ firmware_action }}: {{ firmware_current_version }} → {{ firmware_target_value }}"
|
||||
```
|
||||
|
||||
If something fails during the master update, the rescue block rolls the VM back to the pre-update snapshot and sends a high priority Ntfy notification.
|
||||
|
||||
## Final notification
|
||||
|
||||
At first, I used assertions too much to drive the reporting logic. That works for failures, but it is not the right model for normal cases like no updates available.
|
||||
|
||||
The rescue block should only handle real failures. Normal situations should reach the final notification phase.
|
||||
|
||||
The final phase runs on `localhost` and compares the facts collected from the master and backup nodes. It handles both cases:
|
||||
|
||||
- Both nodes had the same operation
|
||||
- Each node had a different result
|
||||
|
||||
The notification body is generated from the master and backup host variables:
|
||||
|
||||
```yaml
|
||||
body: |
|
||||
{% if same_operation %}
|
||||
{% if m.skip_update | default(false) %}
|
||||
Both nodes already on {{ m.firmware_current_version }}, no action taken.
|
||||
{% else %}
|
||||
OPNsense cluster: {{ m.firmware_current_version }} → {{ m.firmware_target_value }} ({{ m.firmware_status }})
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% if m.skip_update | default(false) %}
|
||||
MASTER ({{ master }}): already on {{ m.firmware_current_version }}, no action taken.
|
||||
{% else %}
|
||||
MASTER ({{ master }}): {{ m.firmware_current_version }} → {{ m.firmware_target_value }} ({{ m.firmware_status }})
|
||||
{% endif %}
|
||||
{% if b.skip_update | default(false) %}
|
||||
BACKUP ({{ backup }}): already on {{ b.firmware_current_version }}, no action taken.
|
||||
{% else %}
|
||||
BACKUP ({{ backup }}): {{ b.firmware_current_version }} → {{ b.firmware_target_value }} ({{ b.firmware_status }})
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
The notification priority and tag also change depending on whether an action is performed or both nodes are already up to date.
|
||||
|
||||
This gives me a useful report without turning a no-op run into an error.
|
||||
|
||||
## The final workflow
|
||||
|
||||
The finished workflow is split into four phases:
|
||||
|
||||
- Firmware check on all nodes
|
||||
- Update the backup node on TrueNAS
|
||||
- Update the master node on Proxmox with a snapshot
|
||||
- Send a Ntfy notification
|
||||
|
||||
The backup node is updated first. The master node is updated second, with a Proxmox snapshot taken before the firmware action. CARP status is checked before the workflow starts, and maintenance mode is used during node updates.
|
||||
|
||||
The playbook can handle update, upgrade and check scenarios through the Semaphore survey. It also knows when to skip a node because there is nothing to do or because the requested action does not match what OPNsense reports.
|
||||
|
||||
Most importantly, the workflow now completes end to end and reports the result.
|
||||
|
||||
The Ansible playbook can be found [here](https://github.com/Vezpi/Homelab/blob/main/ansible/opnsense/update_opnsense_ha_cluster.yml).
|
||||
|
||||
## Conclusion
|
||||
|
||||
This automation started as a simple idea: stop updating OPNsense manually.
|
||||
|
||||
In practice, it became more interesting than just calling the firmware endpoint. The playbook needed to understand the HA state, handle updates and upgrades differently, update nodes in the right order, protect the Proxmox hosted master with a snapshot, and report the final state without treating normal no-op cases as failures.
|
||||
|
||||
The result is a workflow that fits much better with the rest of my homelab automation. Semaphore gives me a repeatable entry point, Ansible handles the logic, OPNsense exposes the state through its API, Proxmox provides a rollback point for the master node, and Ntfy tells me what happened.
|
||||
|
||||
It is one less manual maintenance task to forget, and one more piece of the homelab that can take care of itself.
|
||||
|
After Width: | Height: | Size: 52 KiB |
@@ -0,0 +1,216 @@
|
||||
---
|
||||
slug: automate-cloud-init-template-rebuild
|
||||
title: Automatiser la mise à jour d'un template Cloud-Init Proxmox avec Ansible
|
||||
description: J'ai automatisé la reconstruction de mon template cloud-init Ubuntu dans Proxmox avec Ansible pour accélérer et fiabiliser le déploiement de VM Kubernetes.
|
||||
date: 2026-09-13
|
||||
draft: false
|
||||
tags:
|
||||
- proxmox
|
||||
- cloud-init
|
||||
- ansible
|
||||
- semaphore-ui
|
||||
categories:
|
||||
- homelab
|
||||
image: cover-22.webp
|
||||
---
|
||||
## Introduction
|
||||
|
||||
J'avais déjà un template cloud-init Ubuntu dans Proxmox, basé sur la configuration décrite dans [cet article précédent]({{< ref "post/1-proxmox-cloud-init-vm-template" >}}). Il fonctionnait bien, notamment lorsque je devais déployer plusieurs machines virtuelles avec Terraform.
|
||||
|
||||
Il restait cependant un problème. Chaque nouvelle VM devait encore mettre à jour ses paquets après sa création. Lorsque je déployais trois VM, l'ensemble du processus prenait plus de six minutes, dont environ la moitié était consacrée à la mise à jour des paquets.
|
||||
|
||||
Je vais utiliser ce template assez souvent au cours des prochains mois pour créer des clusters Kubernetes. Mettre à jour chaque VM après chaque déploiement deviendrait rapidement répétitif, j'ai donc décidé d'automatiser la reconstruction du template.
|
||||
|
||||
L'objectif est simple : reconstruire régulièrement le template cloud-init Ubuntu avec la dernière cloud image, afin que les nouvelles VM démarrent à partir d'une base plus récente.
|
||||
|
||||
## L'approche
|
||||
|
||||
La solution est un playbook Ansible exécuté via Semaphore UI. Le playbook s'exécute sur mes nœuds Proxmox et prend en charge tout le cycle de vie du template :
|
||||
|
||||
- Vérifier si le template existe déjà.
|
||||
- Vérifier si la cloud image Ubuntu actuelle est disponible.
|
||||
- Trouver l'hôte Proxmox qui doit effectuer la reconstruction.
|
||||
- Télécharger la dernière image lorsque cela est nécessaire.
|
||||
- Détruire l'ancien template lorsque l'image a changé.
|
||||
- Recréer la VM et la convertir à nouveau en template.
|
||||
|
||||
Le playbook utilise le VMID `900` et cible actuellement la cloud image Ubuntu `noble` :
|
||||
|
||||
📝 Le playbook final complet est disponible sur [GitHub](https://github.com/Vezpi/Homelab/blob/main/ansible/proxmox/cloudinit_template.yml).
|
||||
|
||||
```yaml
|
||||
vars:
|
||||
template_vmid: 900
|
||||
template_os: ubuntu
|
||||
os_release: noble
|
||||
|
||||
template_name: "{{template_os}}-{{ os_release }}-cloud"
|
||||
|
||||
image_filename: "{{ os_release }}-server-cloudimg-amd64.img"
|
||||
image_url: "https://cloud-images.ubuntu.com/{{ os_release }}/current/{{ image_filename }}"
|
||||
image_dest: "/var/lib/vz/template/iso/{{ image_filename }}"
|
||||
|
||||
iso_storage: local
|
||||
vm_storage: ceph-workload
|
||||
|
||||
template_memory: 2048
|
||||
template_cores: 1
|
||||
```
|
||||
|
||||
Conserver ces valeurs dans des variables facilite l'adaptation du playbook à une autre version d'Ubuntu ou à un autre template.
|
||||
|
||||
## Trouver le bon hôte Proxmox
|
||||
|
||||
Ma première exécution a échoué car le template n'existait pas sur l'hôte Proxmox sélectionné dans Semaphore UI. Cette erreur a clairement montré la limitation : le playbook ne pouvait pas supposer que le template ou son image était disponible sur un nœud précis.
|
||||
|
||||
Dans le cluster Proxmox, l'image peut être présente sur un hôte alors qu'un autre dispose de davantage d'espace libre. J'ai donc ajouté un petit processus de sélection.
|
||||
|
||||
Tout d'abord, chaque nœud vérifie si le VMID du template existe et si la cloud image est présente localement. Il enregistre également l'espace libre disponible sur le système de fichiers racine :
|
||||
|
||||
```yaml
|
||||
- name: Check whether the template VMID already exists
|
||||
ansible.builtin.command: qm status {{ template_vmid }}
|
||||
register: template_status
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Check if the cloud image file exists
|
||||
ansible.builtin.stat:
|
||||
path: "{{ image_dest }}"
|
||||
register: image_exists
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Set candidate information
|
||||
ansible.builtin.set_fact:
|
||||
has_image_file: "{{ image_exists.stat.exists }}"
|
||||
root_free_space: "{{ (ansible_facts['mounts'] | selectattr('mount', 'equalto', '/') | first).size_available }}"
|
||||
```
|
||||
|
||||
L'hôte cible est ensuite sélectionné en deux étapes. Un nœud qui possède déjà l'image est privilégié. Si aucun nœud ne la possède, le playbook choisit celui qui dispose du plus grand espace disponible :
|
||||
|
||||
```yaml
|
||||
- name: Select target host
|
||||
ansible.builtin.set_fact:
|
||||
target_host: >-
|
||||
{{ (ansible_play_hosts
|
||||
| map('extract', hostvars)
|
||||
| selectattr('has_image_file', 'equalto', true)
|
||||
| sort(attribute='root_free_space', reverse=true)
|
||||
| map(attribute='inventory_hostname')
|
||||
| first)
|
||||
| default(ansible_play_hosts
|
||||
| map('extract', hostvars)
|
||||
| sort(attribute='root_free_space', reverse=true)
|
||||
| map(attribute='inventory_hostname')
|
||||
| first,
|
||||
true) }}
|
||||
run_once: true
|
||||
```
|
||||
|
||||
Cela évite de lier l'automatisation à un nœud Proxmox spécifique et fournit également un emplacement de repli cohérent pour le téléchargement de l'image.
|
||||
|
||||
## Télécharger l'image et reconstruire le template
|
||||
|
||||
Une fois l'hôte cible sélectionné, Ansible télécharge l'image uniquement lorsqu'elle n'est pas déjà disponible :
|
||||
|
||||
```yaml
|
||||
- name: Download the current Ubuntu cloud image
|
||||
ansible.builtin.get_url:
|
||||
url: "{{ image_url }}"
|
||||
dest: "{{ image_dest }}"
|
||||
force: false
|
||||
mode: "0644"
|
||||
register: image_download
|
||||
when: inventory_hostname == target_host
|
||||
```
|
||||
|
||||
Le résultat de cette tâche est important. Le template existant est détruit uniquement lorsqu'une nouvelle image a été téléchargée. Si le template est absent, le playbook le reconstruit également :
|
||||
|
||||
```yaml
|
||||
- name: Destroy existing template if present
|
||||
ansible.builtin.command: qm destroy {{ template_vmid }} --purge
|
||||
when:
|
||||
- template_status.rc == 0
|
||||
- image_download.changed
|
||||
```
|
||||
|
||||
La VM est ensuite créée avec la même configuration principale que mon template d'origine : deux gigaoctets de mémoire, un cœur CPU, UEFI, un disque EFI, une interface réseau VirtIO et une console série.
|
||||
|
||||
```yaml
|
||||
- name: Create the base VM shell
|
||||
ansible.builtin.command: >
|
||||
qm create {{ template_vmid }}
|
||||
--memory {{ template_memory }}
|
||||
--core {{ template_cores }}
|
||||
--net0 virtio,bridge=vmbr0
|
||||
--scsihw virtio-scsi-pci
|
||||
--bios ovmf
|
||||
--machine q35
|
||||
--efidisk0 {{ vm_storage }}:0,pre-enrolled-keys=0
|
||||
--name {{ template_name }}
|
||||
|
||||
- name: Import the cloud image as the primary disk
|
||||
ansible.builtin.command: qm set {{ template_vmid }} --scsi0 {{ vm_storage }}:0,import-from={{ image_dest }}
|
||||
|
||||
- name: Attach a cloud-init drive
|
||||
ansible.builtin.command: qm set {{ template_vmid }} --scsi1 {{ vm_storage }}:cloudinit
|
||||
|
||||
- name: Set boot order to the primary disk
|
||||
ansible.builtin.command: qm set {{ template_vmid }} --boot order=scsi0
|
||||
|
||||
- name: Add serial console for cloud-init consoles
|
||||
ansible.builtin.command: qm set {{ template_vmid }} --serial0 socket --vga serial0
|
||||
|
||||
- name: Convert the VM to a template
|
||||
ansible.builtin.command: qm template {{ template_vmid }}
|
||||
```
|
||||
|
||||
Le bloc de reconstruction s'exécute uniquement sur l'hôte sélectionné et seulement lorsque l'image a changé ou que le template n'existe pas. Les exécutions répétées restent ainsi sans effet lorsqu'il n'y a rien à mettre à jour.
|
||||
|
||||
## L'exécuter avec Semaphore UI
|
||||
|
||||
J'ai créé une tâche dans Semaphore UI pour exécuter le playbook Ansible. Le premier lancement a mis en évidence la dépendance à un hôte défini en dur. Après avoir ajouté la logique de sélection de l'hôte, le playbook s'est comporté comme prévu.
|
||||
|
||||
Semaphore UI me permet de lancer facilement la reconstruction manuellement lorsque cela est nécessaire. Plus important encore, il me permet de planifier la tâche : je l'ai donc configurée pour s'exécuter chaque semaine.
|
||||
|
||||
Le template est ainsi automatiquement actualisé, sans que je doive me souvenir de télécharger une nouvelle image Ubuntu avant de créer un cluster.
|
||||
|
||||
## Notifications
|
||||
|
||||
J'ai également ajouté une tâche de notification optionnelle à la fin du playbook. Elle envoie un message via Ntfy lorsque le template a été mis à jour :
|
||||
|
||||
```yaml
|
||||
- name: Send notification
|
||||
ansible.builtin.uri:
|
||||
url: "{{ ntfy_url }}/{{ ntfy_topic }}"
|
||||
method: POST
|
||||
user: "{{ ntfy_user }}"
|
||||
password: "{{ lookup('env', 'NTFY_PASSWORD') }}"
|
||||
force_basic_auth: true
|
||||
body: |
|
||||
The {{ template_os | capitalize }} {{ os_release }} cloud-init template has been successfully updated on {{ inventory_hostname }}.
|
||||
headers:
|
||||
Title: "Cloud-init template updated"
|
||||
Priority: "min"
|
||||
Tags: "white_check_mark"
|
||||
when: ntfy_url is defined
|
||||
```
|
||||
|
||||
La notification est optionnelle car la tâche est conditionnée par `ntfy_url is defined`. Je peux donc utiliser le même playbook sans configurer Ntfy, tout en recevant une confirmation discrète lorsque les notifications sont activées.
|
||||
|
||||
## Résultats
|
||||
|
||||
Après avoir fusionné le playbook dans la branche principale et planifié son exécution hebdomadaire, mon template cloud-init reste automatiquement à jour.
|
||||
|
||||
J'ai ensuite déployé les mêmes trois VM avec mon projet Terraform. La durée du déploiement est passée de plus de six minutes à environ trois minutes. L'étape de mise à jour des paquets n'a plus besoin d'effectuer tout le travail sur chaque nouvelle VM, car le template contient déjà une cloud image Ubuntu plus récente.
|
||||
|
||||
C'est particulièrement utile pour mes prochains clusters Kubernetes. Terraform peut continuer à créer les VM à partir du même template, tandis qu'Ansible se charge de le maintenir à jour en arrière-plan.
|
||||
|
||||
## Conclusion
|
||||
|
||||
Le template cloud-init initial a résolu le problème de la création de VM Proxmox cohérentes. Il manquait toutefois un moyen de le maintenir à jour sans le reconstruire manuellement.
|
||||
|
||||
Le playbook Ansible gère désormais ce cycle de vie et fonctionne sur l'ensemble de mon cluster Proxmox, au lieu de supposer qu'un seul nœud contient toujours le template. Semaphore UI fournit à la fois l'exécution manuelle et la planification hebdomadaire, tandis que Ntfy m'envoie une simple confirmation lorsqu'une mise à jour réussit.
|
||||
|
||||
Le résultat est un déploiement de VM plus rapide et une base beaucoup plus pratique pour créer des clusters Kubernetes.
|
||||
@@ -0,0 +1,216 @@
|
||||
---
|
||||
slug: automate-cloud-init-template-rebuild
|
||||
title: Automating Proxmox Cloud-Init Template Updates with Ansible
|
||||
description: I automated the rebuild of my Ubuntu cloud-init template in Proxmox with Ansible to keep Kubernetes VM deployments fast and up to date.
|
||||
date: 2026-09-13
|
||||
draft: false
|
||||
tags:
|
||||
- proxmox
|
||||
- cloud-init
|
||||
- ansible
|
||||
- semaphore-ui
|
||||
categories:
|
||||
- homelab
|
||||
image: cover-22.webp
|
||||
---
|
||||
## Intro
|
||||
|
||||
I already had an Ubuntu cloud-init template in Proxmox, based on the setup described in [this previous article]({{< ref "post/1-proxmox-cloud-init-vm-template" >}}). It worked well, especially when I needed to deploy several virtual machines with Terraform.
|
||||
|
||||
There was one problem, though. Every new VM still had to update its packages after being created. When I deployed three VMs, the whole process took more than six minutes, and about half of that time was spent updating packages.
|
||||
|
||||
I am going to use this template quite often over the next few months to create Kubernetes clusters. Updating every VM after each deployment would quickly become repetitive, so I decided to automate the template rebuild instead.
|
||||
|
||||
The goal is simple: regularly rebuild the Ubuntu cloud-init template with the latest cloud image, so newly created VMs start from a more up-to-date baseline.
|
||||
|
||||
## The Approach
|
||||
|
||||
The solution is an Ansible playbook executed through Semaphore UI. The playbook runs against my Proxmox nodes and takes care of the complete lifecycle of the template:
|
||||
|
||||
- Check whether the template already exists.
|
||||
- Check whether the current Ubuntu cloud image is available.
|
||||
- Find the Proxmox host that should handle the rebuild.
|
||||
- Download the latest image when necessary.
|
||||
- Destroy the old template when the image has changed.
|
||||
- Recreate the VM and convert it back into a template.
|
||||
|
||||
The playbook uses VMID `900` and currently targets the Ubuntu `noble` cloud image:
|
||||
|
||||
📝 The complete final playbook is available on [GitHub](https://github.com/Vezpi/Homelab/blob/main/ansible/proxmox/cloudinit_template.yml).
|
||||
|
||||
```yaml
|
||||
vars:
|
||||
template_vmid: 900
|
||||
template_os: ubuntu
|
||||
os_release: noble
|
||||
|
||||
template_name: "{{template_os}}-{{ os_release }}-cloud"
|
||||
|
||||
image_filename: "{{ os_release }}-server-cloudimg-amd64.img"
|
||||
image_url: "https://cloud-images.ubuntu.com/{{ os_release }}/current/{{ image_filename }}"
|
||||
image_dest: "/var/lib/vz/template/iso/{{ image_filename }}"
|
||||
|
||||
iso_storage: local
|
||||
vm_storage: ceph-workload
|
||||
|
||||
template_memory: 2048
|
||||
template_cores: 1
|
||||
```
|
||||
|
||||
Keeping these values in variables makes it easier to adapt the playbook for another Ubuntu release or another template later.
|
||||
|
||||
## Finding the Right Proxmox Host
|
||||
|
||||
My first execution failed because the template did not exist on the Proxmox host selected in Semaphore UI. That made the limitation clear: the playbook could not assume that the template or its image was available on one particular node.
|
||||
|
||||
The Proxmox cluster can contain the image on one host while another host has more free space. I therefore added a small selection process.
|
||||
|
||||
First, each node checks whether the template VMID exists and whether the cloud image is present locally. It also records the free space available on the root filesystem:
|
||||
|
||||
```yaml
|
||||
- name: Check whether the template VMID already exists
|
||||
ansible.builtin.command: qm status {{ template_vmid }}
|
||||
register: template_status
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Check if the cloud image file exists
|
||||
ansible.builtin.stat:
|
||||
path: "{{ image_dest }}"
|
||||
register: image_exists
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Set candidate information
|
||||
ansible.builtin.set_fact:
|
||||
has_image_file: "{{ image_exists.stat.exists }}"
|
||||
root_free_space: "{{ (ansible_facts['mounts'] | selectattr('mount', 'equalto', '/') | first).size_available }}"
|
||||
```
|
||||
|
||||
The target host is then selected in two steps. A node that already has the image is preferred. If no node has it, the playbook chooses the node with the most available space:
|
||||
|
||||
```yaml
|
||||
- name: Select target host
|
||||
ansible.builtin.set_fact:
|
||||
target_host: >-
|
||||
{{ (ansible_play_hosts
|
||||
| map('extract', hostvars)
|
||||
| selectattr('has_image_file', 'equalto', true)
|
||||
| sort(attribute='root_free_space', reverse=true)
|
||||
| map(attribute='inventory_hostname')
|
||||
| first)
|
||||
| default(ansible_play_hosts
|
||||
| map('extract', hostvars)
|
||||
| sort(attribute='root_free_space', reverse=true)
|
||||
| map(attribute='inventory_hostname')
|
||||
| first,
|
||||
true) }}
|
||||
run_once: true
|
||||
```
|
||||
|
||||
This avoids tying the automation to a specific Proxmox node and also gives the image download a sensible fallback location.
|
||||
|
||||
## Downloading the Image and Rebuilding the Template
|
||||
|
||||
Once the target host is selected, Ansible downloads the image only when it is not already available:
|
||||
|
||||
```yaml
|
||||
- name: Download the current Ubuntu cloud image
|
||||
ansible.builtin.get_url:
|
||||
url: "{{ image_url }}"
|
||||
dest: "{{ image_dest }}"
|
||||
force: false
|
||||
mode: "0644"
|
||||
register: image_download
|
||||
when: inventory_hostname == target_host
|
||||
```
|
||||
|
||||
The result of this task is important. The existing template is destroyed only when a new image was downloaded. If the template is missing, the playbook also rebuilds it:
|
||||
|
||||
```yaml
|
||||
- name: Destroy existing template if present
|
||||
ansible.builtin.command: qm destroy {{ template_vmid }} --purge
|
||||
when:
|
||||
- template_status.rc == 0
|
||||
- image_download.changed
|
||||
```
|
||||
|
||||
The VM is then created with the same core configuration as my original template: two gigabytes of memory, one CPU core, UEFI, an EFI disk, a VirtIO network interface, and a serial console.
|
||||
|
||||
```yaml
|
||||
- name: Create the base VM shell
|
||||
ansible.builtin.command: >
|
||||
qm create {{ template_vmid }}
|
||||
--memory {{ template_memory }}
|
||||
--core {{ template_cores }}
|
||||
--net0 virtio,bridge=vmbr0
|
||||
--scsihw virtio-scsi-pci
|
||||
--bios ovmf
|
||||
--machine q35
|
||||
--efidisk0 {{ vm_storage }}:0,pre-enrolled-keys=0
|
||||
--name {{ template_name }}
|
||||
|
||||
- name: Import the cloud image as the primary disk
|
||||
ansible.builtin.command: qm set {{ template_vmid }} --scsi0 {{ vm_storage }}:0,import-from={{ image_dest }}
|
||||
|
||||
- name: Attach a cloud-init drive
|
||||
ansible.builtin.command: qm set {{ template_vmid }} --scsi1 {{ vm_storage }}:cloudinit
|
||||
|
||||
- name: Set boot order to the primary disk
|
||||
ansible.builtin.command: qm set {{ template_vmid }} --boot order=scsi0
|
||||
|
||||
- name: Add serial console for cloud-init consoles
|
||||
ansible.builtin.command: qm set {{ template_vmid }} --serial0 socket --vga serial0
|
||||
|
||||
- name: Convert the VM to a template
|
||||
ansible.builtin.command: qm template {{ template_vmid }}
|
||||
```
|
||||
|
||||
The rebuild block runs only on the selected host and only when the image changed or the template does not exist. This keeps repeated executions harmless when there is nothing to update.
|
||||
|
||||
## Running It Through Semaphore UI
|
||||
|
||||
I created a task in Semaphore UI to run the Ansible playbook. The first launch exposed the hard-coded host assumption, and after adding the host selection logic the playbook behaved as expected.
|
||||
|
||||
Using Semaphore UI gives me a convenient way to launch the rebuild manually when needed. More importantly, it lets me schedule the task, so I configured it to run weekly.
|
||||
|
||||
The template is therefore refreshed automatically without requiring me to remember to download a new Ubuntu image before creating a cluster.
|
||||
|
||||
## Notifications
|
||||
|
||||
I also added an optional notification task at the end of the playbook. It sends a message through Ntfy when the template has been updated:
|
||||
|
||||
```yaml
|
||||
- name: Send notification
|
||||
ansible.builtin.uri:
|
||||
url: "{{ ntfy_url }}/{{ ntfy_topic }}"
|
||||
method: POST
|
||||
user: "{{ ntfy_user }}"
|
||||
password: "{{ lookup('env', 'NTFY_PASSWORD') }}"
|
||||
force_basic_auth: true
|
||||
body: |
|
||||
The {{ template_os | capitalize }} {{ os_release }} cloud-init template has been successfully updated on {{ inventory_hostname }}.
|
||||
headers:
|
||||
Title: "Cloud-init template updated"
|
||||
Priority: "min"
|
||||
Tags: "white_check_mark"
|
||||
when: ntfy_url is defined
|
||||
```
|
||||
|
||||
The notification is optional because the task is guarded by `ntfy_url is defined`. I can therefore use the same playbook without configuring Ntfy, while still receiving a quiet confirmation when notifications are enabled.
|
||||
|
||||
## Results
|
||||
|
||||
After merging the playbook into the main branch and scheduling it weekly, my cloud-init template stays up to date automatically.
|
||||
|
||||
I then deployed the same three VMs with my Terraform project. The deployment time dropped from more than six minutes to about three minutes. The package update step no longer needs to do all the work on every newly created VM because the template already contains a more recent Ubuntu cloud image.
|
||||
|
||||
This is especially useful for my next Kubernetes clusters. Terraform can continue to create the VMs from the same template, while Ansible takes care of keeping that template fresh in the background.
|
||||
|
||||
## Conclusion
|
||||
|
||||
The initial cloud-init template solved the problem of creating consistent Proxmox VMs. The missing piece was keeping it current without manually rebuilding it.
|
||||
|
||||
The Ansible playbook now handles that lifecycle and works across my Proxmox cluster instead of assuming that one node always contains the template. Semaphore UI provides both manual execution and a weekly schedule, while Ntfy gives me a simple confirmation when an update succeeds.
|
||||
|
||||
The result is a smaller delay when deploying VMs and a much more convenient base for creating Kubernetes clusters.
|
||||
|
After Width: | Height: | Size: 54 KiB |
@@ -0,0 +1,165 @@
|
||||
---
|
||||
slug: install-haproxy-opnsense-replace-caddy
|
||||
title: Installer HAProxy sur OPNsense pour remplacer Caddy
|
||||
description: J’ai installé HAProxy sur mon cluster OPNsense et remplacé Caddy pour résoudre des erreurs SSL aléatoires tout en conservant le routage HTTPS et le failover.
|
||||
date: 2026-09-19
|
||||
draft: false
|
||||
tags:
|
||||
- opnsense
|
||||
- caddy
|
||||
- haproxy
|
||||
categories:
|
||||
- homelab
|
||||
image: cover-23.webp
|
||||
---
|
||||
|
||||
## Introduction
|
||||
|
||||
Mon cluster OPNsense est la porte d’entrée de mon homelab. Il reçoit le trafic HTTPS, décide de sa destination, puis le transmet soit aux services derrière Traefik, soit directement aux interfaces d’infrastructure comme Proxmox, TrueNAS et OPNsense lui-même.
|
||||
|
||||
Jusqu’à récemment, ce travail était assuré par Caddy. Il était simple à configurer et, surtout, il gérait les certificats sans rien me demander. Un élément d’infrastructure merveilleusement silencieux.
|
||||
|
||||
Puis j’ai mis OPNsense à niveau vers la version 26.1. Les requêtes vers les services gérés par Traefik ont commencé à échouer aléatoirement avec `SSL_ERROR_INTERNAL_ERROR_ALERT`. Pas toutes les requêtes, ni tous les services, mais suffisamment souvent pour rendre la configuration peu fiable.
|
||||
|
||||
Je voulais également recréer un endpoint Kubernetes que j’avais auparavant géré avec HAProxy. Plutôt que d’essayer de convaincre Caddy de mieux se comporter, j’ai donc profité de l’occasion pour le remplacer par HAProxy.
|
||||
|
||||
Voici l’histoire de cette migration, avec le moment où un simple reverse proxy devient un ensemble de dispatchers TCP, de maps, de certificats et d’un peu de configuration HAProxy en mode libre.
|
||||
|
||||
## Ce que j’attends du reverse proxy
|
||||
|
||||
La configuration répond à deux besoins de routage différents.
|
||||
|
||||
La plupart des applications tournent sur `dockerVM` (une VM qui exécute Docker, au cas où ce ne serait pas évident), derrière Traefik. Pour ces services, OPNsense doit inspecter le Server Name Indication TLS, puis transmettre la connexion chiffrée à Traefik. Traefik reste responsable de ses propres certificats TLS.
|
||||
|
||||
D’autres services sont des interfaces d’infrastructure auxquelles je veux seulement accéder depuis les réseaux internes ou via mon VPN :
|
||||
|
||||
- Proxmox
|
||||
- TrueNAS
|
||||
- L’interface Web d’OPNsense
|
||||
|
||||
Pour ces services, HAProxy termine TLS et transmet le trafic HTTP au backend approprié. Je dois également faire fonctionner la configuration sur les deux nœuds de mon cluster OPNsense HA.
|
||||
|
||||
Dans ma configuration précédente, Caddy gérait ces deux types de trafic grâce à ses fonctions de reverse proxy et de proxy Layer4. La configuration originale est décrite dans mon article sur [ma configuration OPNsense HA]({{< ref "post/13-opnsense-full-configuration" >}}).
|
||||
|
||||
## Préparer HAProxy avant la bascule
|
||||
|
||||
J’installe le plugin communautaire `os-haproxy` sur le nœud maître OPNsense et je construis la nouvelle configuration alors que Caddy sert encore le trafic de production. Cela rend la migration moins stressante. En théorie, du moins.
|
||||
|
||||
Je commence par définir les serveurs réels et les pools de backend. `dockerVM` reçoit un backend TCP pour le TLS passthrough et un backend HTTP pour les challenges ACME. Les services d’infrastructure reçoivent des backends HTTP avec TLS activé vers leurs serveurs en amont.
|
||||
|
||||
Le backend Proxmox contient les trois nœuds Proxmox, tandis que TrueNAS et l’interface Web locale d’OPNsense disposent chacun de leur propre backend.
|
||||
|
||||
⚠️ Une petite surprise apparaît pendant les tests : les interfaces TrueNAS et Proxmox restent bloquées sur leur écran de chargement pendant un long moment. Désactiver HTTP/2 dans leurs pools de backend résout le problème. Ce n’est pas une correction particulièrement spectaculaire, mais c’est exactement le genre de détail qui fait durer une migration plus longtemps que prévu.
|
||||
|
||||
## Un port, plusieurs types de HTTPS
|
||||
|
||||
Au début, je teste les services d’infrastructure sur des ports différents. Cela fonctionne, mais ce n’est pas l’objectif. Je veux que `proxmox.vezpi.com` et `nas.vezpi.com` partagent le même port HTTPS public.
|
||||
|
||||
HAProxy peut sélectionner un backend HTTP grâce à l’en-tête `Host` une fois TLS terminé. Cela me donne un frontend pour les services d’infrastructure, mais ne résout pas le TLS passthrough vers Traefik. Un flux TLS ne peut pas être terminé par HAProxy tout en étant transmis tel quel.
|
||||
|
||||
💡 La solution est un dispatcher TCP sur le port `443`.
|
||||
|
||||
HAProxy inspecte le TLS ClientHello, lit le nom d’hôte SNI et prend une première décision de routage sans terminer la connexion :
|
||||
|
||||
- Les domaines gérés par Traefik vont directement vers `dockerVM`.
|
||||
- Les domaines d’infrastructure vont vers un frontend HAProxy local qui gère le TLS offloading et sélectionne ensuite le backend HTTP final.
|
||||
|
||||
Le dispatcher a besoin d’un délai d’inspection afin de laisser à HAProxy le temps de lire le ClientHello :
|
||||
|
||||
```plaintext
|
||||
tcp-request inspect-delay 5s
|
||||
```
|
||||
|
||||
Cela suffit pour conserver le TLS intact jusqu’à Traefik, tout en permettant à HAProxy de router lui-même les interfaces d’infrastructure.
|
||||
|
||||
## Garder les services internes… internes
|
||||
|
||||
Exposer Proxmox ou TrueNAS sur internet serait une manière très efficace de me créer du travail pour plus tard. Le dispatcher doit donc également appliquer des règles d’accès.
|
||||
|
||||
Je définis mes réseaux internes, y compris le réseau VPN, dans une ACL. Les requêtes vers les noms d’hôte internes sont rejetées lorsque leur source ne correspond pas à cette ACL. Le même modèle fonctionne aussi pour les services internes hébergés sur `dockerVM`.
|
||||
|
||||
Au début, je crée une condition et une règle pour chaque nom d’hôte via l’interface du plugin. Cela convient pour deux ou trois services. Mon homelab possède cependant environ trente URLs, et je n’ai aucune envie de créer, nommer et maintenir une petite forêt de règles chaque fois que j’en ajoute une.
|
||||
|
||||
Les maps sont mieux adaptées. Chaque nom d’hôte correspond à une politique plutôt qu’à un backend directement, par exemple :
|
||||
|
||||
```plaintext
|
||||
cloud.vezpi.com EXTERNAL_DOCKERVM
|
||||
git.vezpi.com EXTERNAL_DOCKERVM
|
||||
pdf.vezpi.com INTERNAL_DOCKERVM
|
||||
proxmox.vezpi.com INTERNAL_INFRA
|
||||
nas.vezpi.com INTERNAL_INFRA
|
||||
```
|
||||
|
||||
Le dispatcher TCP lit cette map et route le trafic en fonction de la politique :
|
||||
|
||||
```plaintext
|
||||
tcp-request inspect-delay 5s
|
||||
|
||||
acl FROM_INTERNAL src 192.168.13.0/24 192.168.88.0/24 10.13.37.0/24 192.168.66.0/24
|
||||
|
||||
acl SERVICE_DEFINED req.ssl_sni,lower,map_dom(/tmp/haproxy/mapfiles/6a97dc93222c69.76162835.txt) -m found
|
||||
acl SERVICE_EXTERNAL_DOCKERVM req.ssl_sni,lower,map_dom(/tmp/haproxy/mapfiles/6a97dc93222c69.76162835.txt) -m str EXTERNAL_DOCKERVM
|
||||
acl SERVICE_INTERNAL_DOCKERVM req.ssl_sni,lower,map_dom(/tmp/haproxy/mapfiles/6a97dc93222c69.76162835.txt) -m str INTERNAL_DOCKERVM
|
||||
acl SERVICE_INTERNAL_INFRA req.ssl_sni,lower,map_dom(/tmp/haproxy/mapfiles/6a97dc93222c69.76162835.txt) -m str INTERNAL_INFRA
|
||||
|
||||
tcp-request content reject if !SERVICE_DEFINED
|
||||
tcp-request content reject if SERVICE_INTERNAL_DOCKERVM !FROM_INTERNAL
|
||||
tcp-request content reject if SERVICE_INTERNAL_INFRA !FROM_INTERNAL
|
||||
|
||||
use_backend BP_DOCKERVM_HTTPS if SERVICE_EXTERNAL_DOCKERVM || SERVICE_INTERNAL_DOCKERVM
|
||||
use_backend BP_INFRA_FORWARDER if SERVICE_INTERNAL_INFRA
|
||||
```
|
||||
|
||||
Le nom du fichier est généré par le plugin et n’est pas particulièrement mémorable. C’est à ce moment que la configuration devient moins agréable qu’avec Caddy. L’interface graphique du plugin ne permet pas d’utiliser directement une map pour l’inspection SNI ; j’utilise donc le champ `Option pass-through` du frontend pour écrire moi-même les directives HAProxy.
|
||||
|
||||
Cela fonctionne bien, mais j’aurais préféré rester entièrement dans les conditions et les règles proposées par l’interface graphique.
|
||||
|
||||
## Gérer le HTTP et les certificats
|
||||
|
||||
HAProxy doit également gérer le trafic HTTP sur le port `80`. Pour les requêtes classiques, il n’est pas nécessaire d’exposer un service HTTP. L’exception importante concerne le chemin des challenges ACME, qui doit atteindre Traefik pour les domaines qu’il gère.
|
||||
|
||||
Le dispatcher HTTP vérifie que l’hôte demandé est défini dans la même map, n’accepte que `/.well-known/acme-challenge/` pour les domaines hébergés sur Docker, puis transmet la requête au backend HTTP de `dockerVM`.
|
||||
|
||||
Caddy gérait auparavant mes certificats. HAProxy ne le fait pas, j’installe donc le plugin `os-acme-client` sur OPNsense.
|
||||
|
||||
J’utilise un challenge DNS-01 OVH. Le client ACME reçoit des identifiants ayant la permission de gérer les enregistrements TXT de ma zone DNS, puis émet les certificats pour les services d’infrastructure :
|
||||
|
||||
- `proxmox.vezpi.com`
|
||||
- `cerbere.vezpi.com`
|
||||
- `nas.vezpi.com`
|
||||
|
||||
Je commence par émettre un certificat de test auprès de l’autorité de certification staging de Let’s Encrypt, puis je passe à l’autorité de production. Une automation redémarre HAProxy lorsque les certificats sont renouvelés, et le plugin installe automatiquement une vérification quotidienne du renouvellement.
|
||||
|
||||
Traefik continue de gérer ses propres certificats. HAProxy n’a besoin de certificats que pour les services où il termine lui-même TLS.
|
||||
|
||||
## La migration proprement dite
|
||||
|
||||
Une fois HAProxy prêt, la bascule finale est courte et volontairement ennuyeuse.
|
||||
|
||||
Je me connecte au maître OPNsense via son adresse IP plutôt que par le nom d’hôte actuellement servi par Caddy. Je déplace ensuite le dispatcher TCP HAProxy de son port de test vers `443`, et le dispatcher HTTP vers `80`.
|
||||
|
||||
À ce moment-là, je sais que désactiver Caddy va temporairement supprimer l’accès à tous les services proxifiés. Je désactive donc Caddy, j’applique la configuration HAProxy et je commence à vérifier les chemins importants.
|
||||
|
||||
Les services internes restent inaccessibles depuis l’extérieur et accessibles via le VPN. Les services publics derrière Traefik sont de nouveau disponibles. Enfin, je supprime la règle du firewall qui exposait le port de test.
|
||||
|
||||
🎯 La migration fonctionne et l’erreur aléatoire `SSL_ERROR_INTERNAL_ERROR_ALERT` a disparu.
|
||||
|
||||
## Synchroniser le nœud de secours
|
||||
|
||||
Le reverse proxy fait partie de ma configuration OPNsense hautement disponible, le nœud de secours a donc lui aussi besoin de HAProxy.
|
||||
|
||||
J’installe et je démarre `os-haproxy` sur le nœud de secours. De retour sur le maître, j’ajoute `HAProxy Load Balancer` aux services synchronisés par la haute disponibilité OPNsense, puis je lance une synchronisation complète.
|
||||
|
||||
Le client ACME ne prend pas en charge la HA dans cette configuration. Ce compromis me convient, car il ne renouvelle les certificats que tous les deux mois environ.
|
||||
|
||||
✅ Après avoir testé à la fois un switchover et un failover, les services restent accessibles. Ce genre de test est beaucoup plus relaxant une fois que le trafic passe réellement par le nouveau proxy.
|
||||
|
||||
## Conclusion
|
||||
|
||||
HAProxy résout le problème SSL qui m’a poussé à abandonner Caddy et me fournit le routage au niveau TCP dont j’ai besoin dans mon homelab. Le dispatcher basé sur des maps rend également l’ajout de nouveaux noms d’hôte raisonnablement simple, qu’il s’agisse de services publics, de services internes sur `dockerVM` ou de services d’infrastructure.
|
||||
|
||||
Cette migration rend cependant le compromis très clair. Caddy est beaucoup plus simple à configurer et sa gestion des certificats est merveilleusement pratique. HAProxy est plus flexible, mais le plugin OPNsense demande davantage de composants, notamment lorsque l’on mélange inspection SNI, TLS passthrough, TLS offloading, maps et ACL.
|
||||
|
||||
Pour l’instant, cette complexité supplémentaire en vaut la peine. Mes services sont de nouveau accessibles sans erreurs SSL aléatoires, les règles d’accès sont conservées et la configuration résiste à un failover du firewall.
|
||||
|
||||
C’est plutôt un bon résultat pour quelque chose qui a commencé avec une erreur agaçante dans un navigateur.
|
||||
@@ -0,0 +1,165 @@
|
||||
---
|
||||
slug: install-haproxy-opnsense-replace-caddy
|
||||
title: Installing HAProxy on OPNsense to replace Caddy
|
||||
description: I installed HAProxy on my OPNsense cluster and replaced Caddy to resolve random SSL errors while keeping HTTPS routing and failover working.
|
||||
date: 2026-09-19
|
||||
draft: false
|
||||
tags:
|
||||
- opnsense
|
||||
- caddy
|
||||
- haproxy
|
||||
categories:
|
||||
- homelab
|
||||
image: cover-23.webp
|
||||
---
|
||||
|
||||
## Intro
|
||||
|
||||
My OPNsense cluster is the front door of my homelab. It receives HTTPS traffic, decides where it should go, and forwards it either to services behind Traefik or directly to infrastructure interfaces such as Proxmox, TrueNAS, and OPNsense itself.
|
||||
|
||||
Until recently, that job belonged to Caddy. It was easy to configure and, most importantly, it managed certificates without asking anything from me. A wonderfully quiet bit of infrastructure.
|
||||
|
||||
Then I upgraded OPNsense to version 26.1. Requests to services managed by Traefik started failing at random with `SSL_ERROR_INTERNAL_ERROR_ALERT`. Not every request, not every service, just often enough to make the setup untrustworthy.
|
||||
|
||||
I also wanted to recreate a Kubernetes endpoint that I had previously handled with HAProxy. So instead of trying to persuade Caddy to behave, I used the opportunity to replace it with HAProxy.
|
||||
|
||||
This is the story of that migration, including the part where a simple reverse proxy becomes a collection of TCP dispatchers, maps, certificates, and a little bit of free-form HAProxy configuration.
|
||||
|
||||
## What I Need from the Reverse Proxy
|
||||
|
||||
The setup has two different routing needs.
|
||||
|
||||
Most applications run on `dockerVM` (a VM running Docker if it isn't obvious), behind Traefik. For those services, OPNsense must inspect the TLS Server Name Indication, then pass the encrypted connection through to Traefik. Traefik remains responsible for its own TLS certificates.
|
||||
|
||||
Other services are infrastructure interfaces that I only want to reach from internal networks or through my VPN:
|
||||
|
||||
- Proxmox
|
||||
- TrueNAS
|
||||
- The OPNsense WebUI
|
||||
|
||||
For these, HAProxy terminates TLS and forwards HTTP traffic to the relevant backend. I also need the configuration to work on both nodes of my OPNsense HA cluster.
|
||||
|
||||
In my previous configuration, Caddy handled both styles of traffic through its reverse proxy and Layer4 proxy features. You can see the original setup in my article about [my OPNsense HA configuration]({{< ref "post/13-opnsense-full-configuration" >}}).
|
||||
|
||||
## Preparing HAProxy Before the Cutover
|
||||
|
||||
I install the community `os-haproxy` plugin on the master OPNsense node and build the new configuration while Caddy is still serving production traffic. This makes the migration less stressful. At least in theory.
|
||||
|
||||
I start by defining real servers and backend pools. `dockerVM` gets one TCP backend for HTTPS passthrough and one HTTP backend for ACME challenges. The infrastructure services get HTTP backends with TLS enabled toward their upstreams.
|
||||
|
||||
The Proxmox backend contains all three Proxmox nodes, while TrueNAS and the local OPNsense WebUI each have their own backend.
|
||||
|
||||
⚠️ One small surprise appears during testing: both the TrueNAS and Proxmox interfaces remain stuck on their loading screens for a long time. Disabling HTTP/2 in their backend pools solves it. Not an especially dramatic fix, but exactly the kind of detail that makes a migration take longer than expected.
|
||||
|
||||
## One Port, Several Kinds of HTTPS
|
||||
|
||||
At first, I test the infrastructure services on separate ports. That works, but it is not the goal. I want `proxmox.vezpi.com` and `nas.vezpi.com` to share the same public HTTPS port.
|
||||
|
||||
HAProxy can select an HTTP backend using the `Host` header once it terminates TLS. That gives me a frontend for the infrastructure services, but it does not solve TLS passthrough to Traefik. A TLS stream cannot be both terminated by HAProxy and passed through unchanged.
|
||||
|
||||
💡 The solution is a TCP dispatcher on port `443`.
|
||||
|
||||
HAProxy inspects the TLS ClientHello, reads the SNI hostname, and makes the first routing decision without terminating the connection:
|
||||
|
||||
- Domains managed by Traefik go directly to `dockerVM`.
|
||||
- Infrastructure domains go to a local HAProxy frontend that handles TLS offloading and selects the final HTTP backend.
|
||||
|
||||
The dispatcher needs an inspection delay so HAProxy has time to read the ClientHello:
|
||||
|
||||
```plaintext
|
||||
tcp-request inspect-delay 5s
|
||||
```
|
||||
|
||||
That is enough to keep TLS intact for Traefik while allowing HAProxy to route the infrastructure interfaces itself.
|
||||
|
||||
## Keeping Internal Services... Internal
|
||||
|
||||
Exposing Proxmox or TrueNAS to the internet would be a very efficient way to create future work for myself, so the dispatcher also needs to enforce access rules.
|
||||
|
||||
I define my internal networks, including the VPN network, in an ACL. Requests for internal-only hostnames are rejected when their source does not match that ACL. The same model also works for internal services hosted on `dockerVM`.
|
||||
|
||||
Initially, I create conditions and rules for each hostname through the plugin interface. That is fine for two or three services. My homelab has around thirty URLs, though, and I do not want to create, name, and maintain a small forest of rules every time I add one.
|
||||
|
||||
Maps are a better fit. Each hostname maps to a policy rather than directly to a backend, some examples:
|
||||
|
||||
```plaintext
|
||||
cloud.vezpi.com EXTERNAL_DOCKERVM
|
||||
git.vezpi.com EXTERNAL_DOCKERVM
|
||||
pdf.vezpi.com INTERNAL_DOCKERVM
|
||||
proxmox.vezpi.com INTERNAL_INFRA
|
||||
nas.vezpi.com INTERNAL_INFRA
|
||||
```
|
||||
|
||||
The TCP dispatcher reads that map and routes traffic according to the policy:
|
||||
|
||||
```plaintext
|
||||
tcp-request inspect-delay 5s
|
||||
|
||||
acl FROM_INTERNAL src 192.168.13.0/24 192.168.88.0/24 10.13.37.0/24 192.168.66.0/24
|
||||
|
||||
acl SERVICE_DEFINED req.ssl_sni,lower,map_dom(/tmp/haproxy/mapfiles/6a97dc93222c69.76162835.txt) -m found
|
||||
acl SERVICE_EXTERNAL_DOCKERVM req.ssl_sni,lower,map_dom(/tmp/haproxy/mapfiles/6a97dc93222c69.76162835.txt) -m str EXTERNAL_DOCKERVM
|
||||
acl SERVICE_INTERNAL_DOCKERVM req.ssl_sni,lower,map_dom(/tmp/haproxy/mapfiles/6a97dc93222c69.76162835.txt) -m str INTERNAL_DOCKERVM
|
||||
acl SERVICE_INTERNAL_INFRA req.ssl_sni,lower,map_dom(/tmp/haproxy/mapfiles/6a97dc93222c69.76162835.txt) -m str INTERNAL_INFRA
|
||||
|
||||
tcp-request content reject if !SERVICE_DEFINED
|
||||
tcp-request content reject if SERVICE_INTERNAL_DOCKERVM !FROM_INTERNAL
|
||||
tcp-request content reject if SERVICE_INTERNAL_INFRA !FROM_INTERNAL
|
||||
|
||||
use_backend BP_DOCKERVM_HTTPS if SERVICE_EXTERNAL_DOCKERVM || SERVICE_INTERNAL_DOCKERVM
|
||||
use_backend BP_INFRA_FORWARDER if SERVICE_INTERNAL_INFRA
|
||||
```
|
||||
|
||||
The filename is generated by the plugin and is not exactly memorable. This is the point where the configuration becomes less pleasant than Caddy. The plugin GUI cannot use a map for SNI inspection directly, so I use the frontend's `Option pass-through` field to write the HAProxy directives myself.
|
||||
|
||||
It works well, but I would have preferred to stay entirely within conditions and rules exposed by the GUI.
|
||||
|
||||
## Handling HTTP and Certificates
|
||||
|
||||
HAProxy also needs to handle HTTP traffic on port `80`. For normal requests, there is no need to expose an HTTP service. The important exception is the ACME challenge path, which must reach Traefik for the domains it manages.
|
||||
|
||||
The HTTP dispatcher checks that the requested host is defined in the same map, accepts only `/.well-known/acme-challenge/` for the Docker-hosted domains, then forwards the request to the HTTP backend on `dockerVM`.
|
||||
|
||||
Caddy used to manage certificates for me. HAProxy does not, so I install the `os-acme-client` plugin in OPNsense.
|
||||
|
||||
I use an OVH DNS-01 challenge. The ACME client receives credentials with permission to manage TXT records in my DNS zone, then issues certificates for the infrastructure services:
|
||||
|
||||
- `proxmox.vezpi.com`
|
||||
- `cerbere.vezpi.com`
|
||||
- `nas.vezpi.com`
|
||||
|
||||
I first issue a test certificate against the Let's Encrypt staging CA, then switch to the production CA. An automation restarts HAProxy when certificates are renewed, and the plugin installs a daily renewal check automatically.
|
||||
|
||||
Traefik continues to manage its own certificates. HAProxy only needs certificates for the services where it offloads TLS itself.
|
||||
|
||||
## The Actual Migration
|
||||
|
||||
Once HAProxy is ready, the final cutover is short and deliberately boring.
|
||||
|
||||
I connect to the OPNsense master through its IP address rather than through the hostname currently served by Caddy. Then I move the HAProxy TCP dispatcher from its test port to `443` and the HTTP dispatcher to `80`.
|
||||
|
||||
At that point, I know that disabling Caddy temporarily removes access to every proxied service. So I disable Caddy, apply the HAProxy configuration, and start checking the important paths.
|
||||
|
||||
Internal services remain unreachable from outside and accessible through the VPN. Public services behind Traefik are available again. Finally, I remove the firewall rule that had exposed the test port.
|
||||
|
||||
🎯 The migration works, and the random `SSL_ERROR_INTERNAL_ERROR_ALERT` is gone.
|
||||
|
||||
## Synchronizing the Backup Node
|
||||
|
||||
The reverse proxy is part of my OPNsense high-availability setup, so the backup node needs HAProxy too.
|
||||
|
||||
I install and start `os-haproxy` on the backup node. Back on the master, I add `HAProxy Load Balancer` to the services synchronized through OPNsense high availability, then run a full synchronization.
|
||||
|
||||
The ACME client itself does not support HA in this setup. I am comfortable with that compromise because it only renews certificates every couple of months.
|
||||
|
||||
✅ After testing both a switchover and a failover, the services remain reachable. That is the kind of test which is much more relaxing once the traffic is actually flowing through the new proxy.
|
||||
|
||||
## Conclusion
|
||||
|
||||
🚀 HAProxy solves the SSL issue that pushed me away from Caddy, and it gives me the TCP-level routing I need for the homelab. The map-based dispatcher also makes adding new hostnames manageable, whether they are public, internal on `dockerVM`, or infrastructure services.
|
||||
|
||||
Still, this migration makes the trade-off very clear. Caddy is far easier to configure and its certificate management is wonderfully simple. HAProxy is more flexible, but the OPNsense plugin requires more moving parts, especially when mixing SNI inspection, TLS passthrough, TLS offloading, maps, and ACLs.
|
||||
|
||||
For now, the extra complexity is worth it. My services are reachable again without random SSL errors, the access rules are preserved, and the configuration survives a firewall failover.
|
||||
|
||||
That is a pretty good outcome for something that started with one annoying browser error.
|
||||
|
After Width: | Height: | Size: 35 KiB |
@@ -0,0 +1,129 @@
|
||||
---
|
||||
slug: roadmap-migrate-docker-applications-kubernetes
|
||||
title: Roadmap pour migrer de Docker Compose vers Kubernetes
|
||||
description: Pourquoi je migre mes applications auto-hébergées d'une VM Docker vers Kubernetes et comment je compte le faire prudemment.
|
||||
date: 2026-09-27
|
||||
draft: false
|
||||
tags:
|
||||
- kubernetes
|
||||
- docker
|
||||
categories:
|
||||
- homelab
|
||||
image: cover-24.webp
|
||||
---
|
||||
|
||||
## Introduction
|
||||
|
||||
Depuis des années, mes applications auto-hébergées fonctionnent sous forme de conteneurs Docker gérés avec Docker Compose sur une seule machine virtuelle. Cela fonctionne suffisamment bien, ce qui rend précisément le changement un peu inconfortable.
|
||||
|
||||
Je ne suis pas poussé à migrer par un incident urgent en production. Il n'y a pas de panne mystérieuse ni de matériel sur le point de prendre feu. Je veux simplement apprendre Kubernetes par la pratique et faire évoluer mon homelab vers une plateforme qui me donnera davantage de liberté pour expérimenter.
|
||||
|
||||
😈 Oui, faire tourner un cluster Kubernetes dans un homelab est overkill. Je le sais. C'est aussi le but.
|
||||
|
||||
Cet article présente la feuille de route de cette migration, depuis la mise en place des fondations du cluster jusqu'à la mise hors service de l'ancienne VM Docker.
|
||||
|
||||
## Pourquoi Kubernetes dans un homelab
|
||||
|
||||
La configuration actuelle avec Docker Compose est simple : une VM héberge les applications et Compose décrit la manière dont les conteneurs doivent fonctionner. Pour un petit environnement auto-hébergé, c'est une solution parfaitement raisonnable.
|
||||
|
||||
Kubernetes ajoute une quantité considérable de complexité. Il faut prendre en compte plusieurs nœuds, un control plane, le réseau, le stockage, l'ingress, les certificats, la supervision et les sauvegardes. Je ne prétends pas qu'il s'agit de la manière la plus simple d'héberger mes services.
|
||||
|
||||
La motivation est l'apprentissage. Un homelab offre un endroit sûr pour comprendre le comportement de Kubernetes, tester différentes approches et faire des erreurs sans les contraintes d'un environnement de production. Le but n'est pas de prouver que Kubernetes est meilleur que Docker Compose pour toutes les charges de travail. Le but est d'acquérir une expérience pratique de Kubernetes.
|
||||
|
||||
Il y a une autre raison de faire les choses correctement : je veux rendre la plateforme reproductible. À terme, je veux pouvoir construire un cluster de validation temporaire, y tester des changements, puis reconstruire le cluster et ses applications grâce à l'automatisation avec Terraform et Ansible.
|
||||
|
||||
## La stratégie de migration
|
||||
|
||||
Je ne veux pas tout migrer en une seule opération massive. Cela rendrait le troubleshooting inutilement difficile et transformerait chaque petite erreur en potentielle interruption de service.
|
||||
|
||||
À la place, je découpe le travail en plusieurs étapes. Chaque étape ajoute une capacité importante à la plateforme avant d'y déplacer le groupe d'applications suivant.
|
||||
|
||||
Le cluster Kubernetes lui-même est d'abord bootstrapé manuellement. C'est volontaire. Avant d'automatiser le processus, je veux comprendre ce qui se passe lors de la création du cluster et du déploiement des applications. GitOps fait partie de l'objectif final, mais je le repousse à plus tard afin de pouvoir d'abord expérimenter la plateforme sans dissimuler le processus de déploiement derrière une couche d'automatisation supplémentaire.
|
||||
|
||||
## Étape 1 : construire les fondations du cluster
|
||||
|
||||
La première étape consiste à créer l'infrastructure sur laquelle Kubernetes fonctionnera. Je l'ai déjà fait il y a quelques mois dans [cet article]({{< ref "post/8-create-manual-kubernetes-cluster-kubeadm" >}}), mais cette fois je vais pousser l'automatisation un peu plus loin.
|
||||
|
||||
Je prévois d'utiliser Terraform pour provisionner les machines virtuelles des nœuds Kubernetes. Ansible préparera ensuite ces VMs. Le bootstrap initial du cluster et l'installation de la Container Network Interface seront effectués manuellement.
|
||||
|
||||
Cette approche permet de séparer clairement les responsabilités :
|
||||
|
||||
- Terraform crée les machines.
|
||||
- Ansible prépare les systèmes d'exploitation.
|
||||
- Le premier bootstrap Kubernetes reste manuel pour le moment.
|
||||
|
||||
Les fondations du cluster doivent être fiables avant d'y ajouter les applications. Si les nœuds et le réseau ne sont pas sains, déboguer un déploiement applicatif devient une manière particulièrement créative de gâcher une soirée.
|
||||
|
||||
## Étape 2 : ajouter le point d'entrée
|
||||
|
||||
Une fois le cluster en place, il doit pouvoir recevoir du trafic. L'étape suivante concerne donc l'API gateway et la gestion des certificats.
|
||||
|
||||
Je vais déployer une API gateway et `cert-manager`, puis une première application pour vérifier que l'ensemble du chemin fonctionne. Cette première application ne représente pas une migration complète. C'est un test de la plateforme : est-il possible de déployer une application, d'y accéder depuis l'extérieur du cluster et de la sécuriser avec un certificat ?
|
||||
|
||||
Cette étape est importante car elle valide davantage que le scheduling Kubernetes. Elle teste l'intégration entre le cluster, le réseau, l'accès externe et TLS.
|
||||
|
||||
## Étape 3 : stockage, sauvegardes et supervision
|
||||
|
||||
Avant de migrer quoi que ce soit d'important, je dois comprendre ce qui arrive aux données.
|
||||
|
||||
Le plan consiste à définir des storage classes pour les stockages Ceph et TrueNAS. J'ai également besoin d'une configuration de supervision de base et d'une stratégie de sauvegarde pour le cluster.
|
||||
|
||||
C'est à ce moment que le projet cesse de se limiter à faire tourner des conteneurs. Les workloads stateless sont relativement simples à déplacer. Les applications stateful nécessitent de décider où leurs données seront stockées, comment elles seront sauvegardées et comment le service pourra être restauré après une panne.
|
||||
|
||||
Je ne veux pas découvrir que la stratégie de sauvegarde est incomplète après avoir déplacé les applications qui comptent le plus.
|
||||
|
||||
## Étapes 4 à 6 : migrer progressivement les applications
|
||||
|
||||
Une fois les capacités de la plateforme en place, les applications peuvent être déplacées par groupes, selon leur niveau de risque.
|
||||
|
||||
### Applications à faible risque
|
||||
|
||||
Les premières migrations concerneront des applications stateless. Elles sont les meilleures candidates pour valider le processus de déploiement, car elles ne contiennent pas de données critiques et devraient être plus faciles à recréer.
|
||||
|
||||
### Applications à risque moyen et stateful
|
||||
|
||||
Viendront ensuite les services non vitaux et les applications dont les besoins en stockage sont plus complexes. Ces migrations aideront à valider les storage classes, les sauvegardes et les procédures de récupération dans des conditions plus réalistes.
|
||||
|
||||
### Applications critiques
|
||||
|
||||
Les applications vitales arriveront en dernier. À ce moment-là, le cluster aura déjà été éprouvé avec des workloads moins importants et les fondations de la supervision et des sauvegardes devraient être plus matures.
|
||||
|
||||
Cet ordre est volontairement prudent. La migration est aussi un projet d'apprentissage, mais je n'ai pas besoin de transformer chaque leçon en interruption de service.
|
||||
|
||||
## Étape 7 : améliorer la supervision et la reprise après sinistre
|
||||
|
||||
Une fois les applications migrées, la solution de supervision et de sauvegarde devra être revue.
|
||||
|
||||
La configuration initiale est une base, pas un état final. L'objectif est de tendre vers une solution de supervision plus complète et de mettre en place un plan de reprise après sinistre. Un cluster peut sembler sain tout en étant difficile à restaurer, c'est pourquoi le processus de récupération doit être considéré séparément de la supervision quotidienne.
|
||||
|
||||
## Étape 8 : automatiser le cycle de vie du cluster
|
||||
|
||||
L'étape importante suivante consiste à rendre la plateforme temporaire et reconstructible.
|
||||
|
||||
Je veux pouvoir créer un cluster de validation depuis zéro et reconstruire de manière fiable le cluster ainsi que ses applications. Terraform et Ansible seront utilisés pour automatiser ce processus.
|
||||
|
||||
C'est ici que le travail manuel réalisé au début devrait porter ses fruits. En comprenant d'abord les étapes, je peux automatiser un processus que je maîtrise réellement au lieu de convertir aveuglément des commandes en playbooks.
|
||||
|
||||
## Étape 9 : introduire GitOps
|
||||
|
||||
Je n'introduirai GitOps qu'une fois la plateforme et l'automatisation de son cycle de vie opérationnelles.
|
||||
|
||||
Le repository sera réorganisé pour prendre en charge ce mode de fonctionnement, avec l'état souhaité du cluster et des applications géré dans Git. L'objectif est de permettre un déploiement complet du cluster conforme à la stratégie GitOps.
|
||||
|
||||
GitOps est la destination, pas la ligne de départ. L'introduire trop tôt rendrait la première phase d'apprentissage plus difficile, notamment alors que l'architecture du cluster et les méthodes de déploiement des applications sont encore amenées à évoluer.
|
||||
|
||||
## Étape 10 : mettre la VM Docker hors service
|
||||
|
||||
La dernière étape ne prendra pas la forme d'un arrêt brutal. Je veux arrêter la VM Docker pendant un certain temps tout en faisant fonctionner les applications sur Kubernetes. Si tout reste stable et que la nouvelle plateforme se montre fiable, je pourrai finalement détruire l'ancienne VM.
|
||||
|
||||
Conserver l'ancien environnement pendant la transition fournit un filet de sécurité. Cela donne aussi une définition claire de la fin du projet : la migration n'est pas terminée simplement parce qu'une application démarre dans Kubernetes. Elle sera terminée lorsque j'aurai suffisamment confiance pour supprimer la plateforme précédente.
|
||||
|
||||
## Conclusion
|
||||
|
||||
🚀 C'est le plus grand projet de homelab que j'ai planifié jusqu'à présent, et probablement aussi le plus passionnant.
|
||||
|
||||
L'objectif technique est de migrer des applications auto-hébergées depuis une VM Docker Compose unique vers un cluster Kubernetes. L'objectif le plus important est d'apprendre à concevoir, exploiter, automatiser, superviser, sauvegarder et finalement reconstruire cette plateforme.
|
||||
|
||||
La feuille de route progresse de la compréhension vers la migration, puis de la migration vers l'automatisation. Je commence par un cluster manuel, j'ajoute le réseau et le stockage, je migre les applications selon leur niveau de risque, j'améliore la récupération, j'automatise le cycle de vie et je termine avec GitOps.
|
||||
|
||||
🔥 Cela représente beaucoup de travail pour un homelab. C'est précisément pour cela que ce projet devrait être une formidable occasion d'apprendre.
|
||||
@@ -0,0 +1,129 @@
|
||||
---
|
||||
slug: roadmap-migrate-docker-applications-kubernetes
|
||||
title: Roadmap of the Migration from Docker Compose to Kubernetes
|
||||
description: Why I am moving my self-hosted applications from one Docker VM to Kubernetes and how I plan to do it.
|
||||
date: 2026-09-27
|
||||
draft: false
|
||||
tags:
|
||||
- kubernetes
|
||||
- docker
|
||||
categories:
|
||||
- homelab
|
||||
image: cover-24.webp
|
||||
---
|
||||
|
||||
## Intro
|
||||
|
||||
For years, my self-hosted applications have been running as Docker containers managed with Docker Compose on a single virtual machine. It works well enough, which is precisely what makes changing it slightly uncomfortable.
|
||||
|
||||
There is no urgent production incident forcing me to migrate. No mysterious outage, no hardware that is about to catch fire. I simply want to learn Kubernetes through hands-on experience and move my homelab to a platform that gives me more room to experiment.
|
||||
|
||||
😈 Yes, running a Kubernetes cluster for a homelab is overkill. I know. That is also the point.
|
||||
|
||||
This article is the roadmap for the migration, from the first cluster foundation work to eventually decommissioning the old Docker VM.
|
||||
|
||||
## Why Kubernetes in a homelab
|
||||
|
||||
The current Docker Compose setup is simple: one VM hosts the applications, and Compose describes how the containers should run. For a small self-hosted environment, that is a perfectly reasonable solution.
|
||||
|
||||
Kubernetes adds a considerable amount of complexity. There are multiple nodes, a control plane, networking, storage, ingress, certificates, monitoring, and backups to think about. I am not pretending this is the simplest way to host my services.
|
||||
|
||||
The motivation is learning. A homelab gives me a safe place to understand how Kubernetes behaves, test different approaches, and make mistakes without the constraints of a production environment. The goal is not to prove that Kubernetes is better than Docker Compose for every workload. The goal is to gain practical experience with Kubernetes.
|
||||
|
||||
There is another reason for doing this properly: I want the platform to become reproducible. Eventually, I want to build a disposable validation cluster, test changes there, and rebuild the cluster and its applications through automation using Terraform and Ansible.
|
||||
|
||||
## The Migration Strategy
|
||||
|
||||
I do not want to migrate everything in one massive operation. That would make troubleshooting needlessly difficult and would turn every small mistake into a potential service outage.
|
||||
|
||||
Instead, I am breaking the work into milestones. Each milestone adds an important capability to the platform before the next group of applications moves onto it.
|
||||
|
||||
The Kubernetes cluster itself is being bootstrapped manually at first. This is intentional. Before automating the process, I want to understand what happens during cluster creation and application deployment. GitOps is part of the end goal, but I am postponing it so I can first experience the platform without hiding the deployment process behind another layer of automation.
|
||||
|
||||
## Milestone 1: Build the cluster foundation
|
||||
|
||||
The first step is to create the infrastructure on which Kubernetes will run. I've done that months ago in that [article]({{< ref "post/8-create-manual-kubernetes-cluster-kubeadm" >}}), this time I'll automate it a bit further.
|
||||
|
||||
I plan to use Terraform to provision the virtual machines for the Kubernetes nodes. Ansible will then prepare those VMs. The initial cluster bootstrap and the installation of the Container Network Interface will be performed manually.
|
||||
|
||||
This approach gives me a useful separation:
|
||||
|
||||
- Terraform creates the machines.
|
||||
- Ansible prepares the operating systems.
|
||||
- The first Kubernetes bootstrap remains manual for now.
|
||||
|
||||
The cluster foundation needs to be reliable before applications become part of the picture. If the nodes and network are not healthy, debugging an application deployment becomes a particularly creative way of wasting an evening.
|
||||
|
||||
## Milestone 2: Add the entry point
|
||||
|
||||
Once the cluster exists, it needs a way to receive traffic. The next milestone is therefore the API gateway and certificate management.
|
||||
|
||||
I will deploy an API gateway and `cert-manager`, then deploy a first application to validate that the complete path works. This first application is not meant to represent a complete migration. It is a platform test: can an application be deployed, reached from outside the cluster, and secured with a certificate?
|
||||
|
||||
This milestone is important because it validates more than Kubernetes scheduling. It tests the integration between the cluster, networking, external access, and TLS.
|
||||
|
||||
## Milestone 3: Storage, backup, and monitoring
|
||||
|
||||
Before migrating anything important, I need to understand what happens to data.
|
||||
|
||||
The plan is to define storage classes for both Ceph and TrueNAS storage. I also need a baseline monitoring setup and a backup strategy for the cluster.
|
||||
|
||||
This is the point where the project stops being only about running containers. Stateless workloads are relatively easy to move. Stateful applications require decisions about where their data lives, how it is backed up, and how the service could be restored after a failure.
|
||||
|
||||
I do not want to discover that the backup story is incomplete after moving the applications that matter most.
|
||||
|
||||
## Milestones 4 to 6: Migrate applications gradually
|
||||
|
||||
With the platform capabilities in place, applications can move in groups based on their risk.
|
||||
|
||||
### Low-risk applications
|
||||
|
||||
The first migrations will be stateless applications. They are the best candidates for validating the deployment process because they do not carry critical data and should be easier to recreate.
|
||||
|
||||
### Medium-risk and stateful applications
|
||||
|
||||
Next come non-vital services and applications with more involved storage requirements. These migrations will help validate the storage classes, backups, and recovery procedures in a more realistic way.
|
||||
|
||||
### Critical applications
|
||||
|
||||
Vital applications come last. By then, the cluster should have already been exercised with less important workloads, and the monitoring and backup foundations should be more mature.
|
||||
|
||||
This ordering is deliberately conservative. The migration is also a learning project, but I do not need to turn every lesson into an outage.
|
||||
|
||||
## Milestone 7: Improve monitoring and disaster recovery
|
||||
|
||||
After the applications are migrated, the monitoring and backup solution will need another pass.
|
||||
|
||||
The initial setup is a baseline, not the final state. The goal is to move toward a more complete monitoring solution and implement a disaster recovery plan. A cluster can look healthy while still being difficult to recover, so the recovery process needs to be considered separately from day-to-day monitoring.
|
||||
|
||||
## Milestone 8: Automate the cluster lifecycle
|
||||
|
||||
The next major step is to make the platform disposable.
|
||||
|
||||
I want to be able to create a validation cluster from scratch and reliably rebuild both the cluster and its applications. Terraform and Ansible will be used to automate this process.
|
||||
|
||||
This is where the early manual work should pay off. By understanding the steps first, I can automate a process I actually understand instead of blindly converting commands into playbooks.
|
||||
|
||||
## Milestone 9: Introduce GitOps
|
||||
|
||||
Only after the platform and lifecycle automation are working will I introduce GitOps.
|
||||
|
||||
The repository will be reorganized to support that operating model, with the desired cluster and application state managed through Git. The objective is to enable a complete cluster deployment aligned with the GitOps strategy.
|
||||
|
||||
GitOps is the destination, not the starting line. Introducing it too early would make the first learning phase more difficult, especially while the cluster architecture and application deployment patterns are still changing.
|
||||
|
||||
## Milestone 10: Decommission the Docker VM
|
||||
|
||||
The final step is not a dramatic switch-off. I want to stop the Docker VM for a period of time while running the applications on Kubernetes. If everything remains stable and the new platform proves reliable, I can finally destroy the old VM.
|
||||
|
||||
Keeping the old environment available during the transition provides a safety net. It also gives me a clear definition of done: the migration is not complete merely because an application starts in Kubernetes. It is complete when I am confident enough to remove the previous platform.
|
||||
|
||||
## Conclusion
|
||||
|
||||
🚀 This is the biggest homelab project I have planned so far, and probably the most exciting one too.
|
||||
|
||||
The technical goal is to migrate self-hosted applications from a single Docker Compose VM to a Kubernetes cluster. The more important goal is to learn how to design, operate, automate, monitor, back up, and eventually rebuild that platform.
|
||||
|
||||
The roadmap deliberately moves from understanding to migration, then from migration to automation. I start with a manual cluster, add networking and storage, migrate applications by risk, improve recovery, automate the lifecycle, and finish with GitOps.
|
||||
|
||||
🔥 It is a lot of work for a homelab. That is exactly why it should be a great learning project.
|
||||
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 310 KiB |
@@ -0,0 +1,157 @@
|
||||
---
|
||||
slug: automate-kubernetes-vms-terraform-ansible
|
||||
title: Automatiser le déploiement des VM Kubernetes avec Terraform et Ansible
|
||||
description: Je provisionne des VM Kubernetes sur Proxmox avec Terraform et les prépare avec Ansible via Semaphore, en utilisant l’état Terraform comme inventaire.
|
||||
date: 2026-10-14
|
||||
draft: false
|
||||
tags:
|
||||
- kubernetes
|
||||
- proxmox
|
||||
- terraform
|
||||
- ansible
|
||||
- semaphore-ui
|
||||
categories:
|
||||
- homelab
|
||||
image: cover-25.webp
|
||||
---
|
||||
|
||||
## Introduction
|
||||
|
||||
Il y a quelques mois, j’ai créé six VM sur Proxmox pour construire mon premier cluster Kubernetes. Terraform a créé les machines, mais j’ai préparé les nœuds manuellement, comme je l’expliquais dans [cet article précédent]({{< ref "post/8-create-manual-kubernetes-cluster-kubeadm" >}}).
|
||||
|
||||
C’était volontaire. Je voulais comprendre les étapes de préparation et de bootstrap avant de les automatiser.
|
||||
|
||||
ℹ️ Je travaille maintenant sur la [migration de Docker Compose vers Kubernetes]({{< ref "post/24-roadmap-migrate-docker-applications-kubernetes" >}}), et je veux automatiser le déploiement des VM et la préparation des nœuds. Pour l’instant, le bootstrap du cluster restera manuel, comme prévu dans la feuille de route.
|
||||
|
||||
🎯 L’objectif de ce projet est d’obtenir des VM prêtes pour Kubernetes, créées avec Terraform et configurées avec Ansible. J’exécute les deux via Semaphore UI, et l’état Terraform fournit l’inventaire à Ansible.
|
||||
|
||||
## Un projet Terraform dédié
|
||||
|
||||
Je crée un nouveau projet Terraform appelé `kubernetes_vms`, basé sur le travail déjà réalisé dans `terransible`. Il réutilise mon module `pve_vm` existant, présenté dans [cet article]({{< ref "post/7-terraform-create-proxmox-module" >}}).
|
||||
|
||||
Le déploiement précédent créait un master et un worker sur chaque nœud Proxmox. Cette fois, j’utilise `master_count` et `worker_count` pour définir le nombre de VM souhaité pour chaque rôle. Le nombre de VM ne dépend donc plus du déploiement d’une même paire sur chaque hôte.
|
||||
|
||||
La convention de nommage inclut aussi l’environnement et le rôle : `kub-<env>-<role><digit>`. Il est ainsi plus facile d’identifier la fonction de chaque VM dans l’interface Proxmox.
|
||||
|
||||
Le placement s’appuie sur la mémoire disponible sur les nœuds Proxmox en ligne. Les VM du control plane sont réparties sur des hôtes distincts disposant de suffisamment de mémoire libre, tandis que les workers sont placés en fonction de la capacité restante. Ce placement a lieu au moment du provisionnement, il ne s’agit pas d’un rééquilibrage continu des VM en cours d’exécution.
|
||||
|
||||
Une fois le placement calculé, le projet transmet les attributs de chaque VM au module :
|
||||
|
||||
```hcl
|
||||
module "pve_vm" {
|
||||
source = "../../modules/pve_vm"
|
||||
for_each = local.vm_list
|
||||
node_name = each.value.node_name
|
||||
vm_name = each.key
|
||||
vm_cpu = each.value.vm_cpu
|
||||
vm_ram = each.value.vm_ram
|
||||
vm_vlan = each.value.vm_vlan
|
||||
vm_tags = ["${var.vm_env}"]
|
||||
}
|
||||
```
|
||||
|
||||
✅ Je teste d’abord le déploiement depuis `bastion` avec un master et deux workers. Tout fonctionne comme prévu, je passe donc à l’exécution depuis Semaphore.
|
||||
|
||||
📝 Le [projet Terraform complet](https://github.com/Vezpi/Homelab/tree/main/terraform/projects/kubernetes_vms) est disponible dans mon dépôt homelab.
|
||||
|
||||
## Choisir les paramètres de déploiement dans Semaphore
|
||||
|
||||
Je crée un modèle de tâche Semaphore pour le projet Terraform et expose ses variables dans un formulaire. Après avoir remanié les paramètres, je peux choisir l’environnement ainsi que le nombre, le CPU, la RAM et le VLAN des masters et des workers.
|
||||
|
||||

|
||||
|
||||
Le projet utilise `vm_env` pour l’environnement, et des variables `master_*` et `worker_*` distinctes pour les paramètres de chaque rôle. Par exemple, `master_count` détermine le nombre de VM du control plane, tandis que `worker_ram` définit la mémoire des workers.
|
||||
|
||||
Il faut garder à l’esprit une différence d’unité : les valeurs des paramètres `master_ram` et `worker_ram` sont en GiB, tandis que les valeurs de repli dans `vm_attr` sont en MiB. Terraform convertit les paramètres en MiB en interne avant de calculer le placement et de créer les VM.
|
||||
|
||||
Pour les paramètres secrets, j’utilise les variables d’environnement de Semaphore. Terraform lit les variables d’entrée depuis des variables d’environnement préfixées par `TF_VAR_` ; la variable Terraform `proxmox_api_token` est donc fournie sous le nom `TF_VAR_proxmox_api_token`.
|
||||
|
||||
J’ai également besoin d’identifiants SSH pour cette configuration Semaphore, plutôt que de l’approche par clé SSH utilisée dans l’ancien projet `semaphore-vms`.
|
||||
|
||||
✅ Le provisionnement des VM fonctionne depuis Semaphore. Il me reste à faire cibler ces VM par Ansible sans maintenir une liste d’hôtes séparée.
|
||||
|
||||
## Utiliser l’état Terraform comme inventaire Ansible
|
||||
|
||||
Terraform connaît déjà les machines qu’il a créées. Je veux qu’Ansible utilise ces informations plutôt que de recopier les noms et les adresses des VM dans un autre inventaire.
|
||||
|
||||
Le projet Terraform déclare les groupes et les hôtes de l’inventaire avec le provider Ansible :
|
||||
|
||||
```hcl
|
||||
resource "ansible_group" "vm_groups" {
|
||||
for_each = local.roles
|
||||
name = each.key
|
||||
}
|
||||
|
||||
resource "ansible_host" "vm_hosts" {
|
||||
for_each = module.pve_vm
|
||||
name = each.key
|
||||
variables = {
|
||||
ansible_host = each.value.vm_ip
|
||||
}
|
||||
groups = [local.vm_list[each.key].vm_role]
|
||||
}
|
||||
```
|
||||
|
||||
Chaque hôte reçoit l’adresse IP renvoyée par le module VM et appartient au groupe correspondant à son rôle, `master` ou `worker`. Ces ressources d’inventaire sont enregistrées dans l’état Terraform.
|
||||
|
||||
Côté Ansible, j’utilise la collection `cloud.terraform` et son plugin d’inventaire `terraform_provider`. La configuration de l’inventaire pointe vers le répertoire du projet Terraform dans Semaphore :
|
||||
|
||||
```yaml
|
||||
---
|
||||
plugin: cloud.terraform.terraform_provider
|
||||
project_path:
|
||||
- /tmp/semaphore/project_1/repository_1_template_7/terraform/projects/kubernetes_vms
|
||||
```
|
||||
|
||||
Ce chemin est propre au checkout Semaphore de mon installation. Ce n’est pas un chemin générique à reprendre tel quel ailleurs ; une exécution locale doit pointer vers son propre répertoire de projet Terraform.
|
||||
|
||||
J’ajoute `cloud.terraform` à `collections/requirements.yml` pour que Semaphore puisse installer la collection. Avant d’écrire le rôle de préparation des nœuds, je crée un playbook simple qui se contente d’exécuter un ping Ansible sur les hôtes.
|
||||
|
||||
✅ Le test fonctionne. Semaphore peut construire l’inventaire à partir de l’état Terraform et Ansible peut joindre les VM provisionnées.
|
||||
|
||||
⚠️ Il y a toutefois une limite importante : dans cette configuration, `terraform.tfstate` se trouve dans le checkout temporaire du conteneur Semaphore. Si le conteneur est recréé, cet état peut être perdu. L’intégration de l’inventaire fonctionne, mais elle ne garantit pas la persistance de l’état. Il faudra mettre en place un stockage durable avant de s’appuyer sur ce workflow pour des reconstructions répétées.
|
||||
|
||||
## Préparer les nœuds avec Ansible
|
||||
|
||||
Une fois l’inventaire fonctionnel, je crée le rôle `k8s_node` et un petit playbook `k8s_node.yml` qui l’applique :
|
||||
|
||||
```yaml
|
||||
---
|
||||
- name: Prepare Kubernetes nodes
|
||||
hosts: all
|
||||
|
||||
roles:
|
||||
- role: k8s_node
|
||||
```
|
||||
|
||||
Le rôle est réparti dans plusieurs fichiers de tâches : paramètres système, paquets, containerd et pare-feu.
|
||||
|
||||
Les tâches système désactivent le swap à l’exécution et tentent de commenter les entrées de swap dans `/etc/fstab`. Elles chargent et rendent persistants les modules kernel `overlay` et `br_netfilter`, puis configurent le filtrage du bridge et le forwarding IPv4.
|
||||
|
||||
Les tâches liées aux paquets ajoutent le dépôt Kubernetes, installent `kubelet` et `kubeadm`, puis bloquent la mise à jour de ces paquets. Les tâches containerd installent le runtime, génèrent sa configuration si elle n’existe pas, activent `SystemdCgroup`, puis redémarrent et activent le service.
|
||||
|
||||
⚠️ Pour le pare-feu, le rôle applique des règles UFW selon les groupes de l’inventaire. Les nœuds du control plane reçoivent les règles correspondantes, et les workers celles qui leur sont destinées. Le sous-réseau source est configurable et le trafic entrant est refusé par défaut. Ces règles doivent correspondre au réseau utilisé par les nœuds ; elles ne constituent pas une configuration universelle pour tous les clusters.
|
||||
|
||||
Les groupes Terraform sont utiles au-delà du simple inventaire des machines. Ansible peut distinguer les rôles des nœuds sans autre correspondance à maintenir manuellement.
|
||||
|
||||
Je crée un modèle de tâche Semaphore appelé **Kubernetes VM Preparation** pour exécuter le playbook avec cet inventaire.
|
||||
|
||||

|
||||
|
||||
📝 Le [projet Ansible et son rôle](https://github.com/Vezpi/Homelab/tree/main/ansible/kubernetes) sont également disponibles dans le dépôt.
|
||||
|
||||
## Où je m’arrête pour l’instant
|
||||
|
||||
La sélection d’un environnement dans le formulaire fonctionne, mais la méthode actuelle ne me permet pas de gérer plusieurs environnements indépendamment. Modifier la variable d’environnement ne revient pas à attribuer à chaque environnement son propre état Terraform.
|
||||
|
||||
Les workspaces Terraform pourraient être une solution plus tard. Je n’ai pas implémenté ni testé cette approche dans ce projet ; pour l’instant, le workflow reste limité à un seul environnement à la fois.
|
||||
|
||||
J’arrête aussi l’automatisation avant le bootstrap du cluster. Le playbook prépare les nœuds ; il n’exécute ni `kubeadm init` ni `kubeadm join`, et il n’installe pas le CNI. À ce stade de la migration, ces étapes restent manuelles.
|
||||
|
||||
✅ À la fin du projet, je déploie six VM avec Terraform et les configure avec Ansible. Elles sont prêtes à accueillir la création du cluster, mais aucun cluster Kubernetes n’a encore été bootstrapé.
|
||||
|
||||
## Conclusion
|
||||
|
||||
🚀 J’ai maintenant six VM déployées avec Terraform et préparées avec Ansible via Semaphore. En utilisant l’état Terraform comme inventaire, je n’ai pas besoin de recopier leurs noms, leurs adresses et leurs rôles dans une liste d’hôtes séparée.
|
||||
|
||||
Le stockage temporaire de l’état et l’absence d’environnements indépendants restent à résoudre. Pour l’instant, je m’arrête aux machines préparées et je passe au bootstrap Kubernetes manuel, comme prévu dans la feuille de route de migration.
|
||||
@@ -0,0 +1,157 @@
|
||||
---
|
||||
slug: automate-kubernetes-vms-terraform-ansible
|
||||
title: Automating Kubernetes VM Deployment with Terraform and Ansible
|
||||
description: I provision Kubernetes VMs on Proxmox with Terraform and prepare them with Ansible through Semaphore, using Terraform state as inventory.
|
||||
date: 2026-10-14
|
||||
draft: false
|
||||
tags:
|
||||
- kubernetes
|
||||
- proxmox
|
||||
- terraform
|
||||
- ansible
|
||||
- semaphore-ui
|
||||
categories:
|
||||
- homelab
|
||||
image: cover-25.webp
|
||||
---
|
||||
|
||||
## Intro
|
||||
|
||||
Some months ago, I created six VMs in Proxmox to build my first Kubernetes cluster. Terraform created the machines, but I prepared the nodes manually, as described in [this previous article]({{< ref "post/8-create-manual-kubernetes-cluster-kubeadm" >}}).
|
||||
|
||||
That was intentional. I wanted to understand the preparation and bootstrap steps before putting them into automation.
|
||||
|
||||
ℹ️ Now I am working on the [migration from Docker Compose to Kubernetes]({{< ref "post/24-roadmap-migrate-docker-applications-kubernetes" >}}), and I want to automate the VM deployment and node preparation. The cluster bootstrap itself will remain manual for now, as planned in the roadmap.
|
||||
|
||||
🎯 The goal of this project is to end up with VMs ready for Kubernetes, using Terraform to create them and Ansible to configure them. I run both through Semaphore UI, with Terraform state providing the Ansible inventory.
|
||||
|
||||
## A dedicated Terraform project
|
||||
|
||||
I create a new Terraform project called `kubernetes_vms`, based on the work I already did on `terransible`. It reuses my existing `pve_vm` module, which I introduced in [this article]({{< ref "post/7-terraform-create-proxmox-module" >}}).
|
||||
|
||||
The earlier deployment created a master and a worker on each Proxmox node. This time, I use `master_count` and `worker_count` to define how many VMs I want for each role. The VM count no longer depends on deploying the same pair everywhere.
|
||||
|
||||
The naming convention also includes the environment and role: `kub-<env>-<role><digit>`. That makes the purpose of each VM easier to identify when looking at the Proxmox interface.
|
||||
|
||||
Placement uses the available RAM on the online Proxmox nodes. The control-plane VMs are spread across distinct hosts with enough free memory, while workers are placed according to the remaining capacity. This is placement during provisioning, not continuous rebalancing of running VMs.
|
||||
|
||||
Once the placement is calculated, the project passes each VM's attributes to the module:
|
||||
|
||||
```hcl
|
||||
module "pve_vm" {
|
||||
source = "../../modules/pve_vm"
|
||||
for_each = local.vm_list
|
||||
node_name = each.value.node_name
|
||||
vm_name = each.key
|
||||
vm_cpu = each.value.vm_cpu
|
||||
vm_ram = each.value.vm_ram
|
||||
vm_vlan = each.value.vm_vlan
|
||||
vm_tags = ["${var.vm_env}"]
|
||||
}
|
||||
```
|
||||
|
||||
✅ I first test the deployment from `bastion` with one master and two workers. It works as expected, so I move on to running it from Semaphore.
|
||||
|
||||
📝 The complete [Terraform project](https://github.com/Vezpi/Homelab/tree/main/terraform/projects/kubernetes_vms) is available in my homelab repository.
|
||||
|
||||
## Choosing the deployment settings in Semaphore
|
||||
|
||||
I create a Semaphore task template for the Terraform project and expose its variables through a survey. After refactoring the inputs, I can choose the environment, along with the count, CPU, RAM, and VLAN for both masters and workers.
|
||||
|
||||

|
||||
|
||||
The project uses `vm_env` for the environment, with separate `master_*` and `worker_*` variables for the role settings. For example, `master_count` controls the number of control-plane VMs, while `worker_ram` sets the memory for workers.
|
||||
|
||||
There is a unit difference worth keeping in mind: the `master_ram` and `worker_ram` overrides use GiB, but the fallback values in `vm_attr` use MiB. Terraform converts the overrides to MiB internally before calculating placement and creating the VMs.
|
||||
|
||||
For the secret inputs, I use Semaphore environment variables. Terraform reads input variables from environment variables prefixed with `TF_VAR_`, so the Terraform variable `proxmox_api_token` is supplied as `TF_VAR_proxmox_api_token`.
|
||||
|
||||
I also need SSH credentials for this Semaphore setup, rather than the SSH key approach I used in the earlier `semaphore-vms` project.
|
||||
|
||||
✅ VM provisioning works from Semaphore. The next part is getting Ansible to target those VMs without maintaining a separate host list.
|
||||
|
||||
## Using Terraform state as Ansible inventory
|
||||
|
||||
Terraform already knows which machines it created. I want Ansible to use that information instead of copying the VM names and addresses into another inventory.
|
||||
|
||||
The Terraform project declares the inventory groups and hosts through the Ansible provider:
|
||||
|
||||
```hcl
|
||||
resource "ansible_group" "vm_groups" {
|
||||
for_each = local.roles
|
||||
name = each.key
|
||||
}
|
||||
|
||||
resource "ansible_host" "vm_hosts" {
|
||||
for_each = module.pve_vm
|
||||
name = each.key
|
||||
variables = {
|
||||
ansible_host = each.value.vm_ip
|
||||
}
|
||||
groups = [local.vm_list[each.key].vm_role]
|
||||
}
|
||||
```
|
||||
|
||||
Each host gets the IP address returned by the VM module and belongs to its role group, `master` or `worker`. These inventory resources are stored in Terraform state.
|
||||
|
||||
On the Ansible side, I use the `cloud.terraform` collection and its `terraform_provider` inventory plugin. The inventory configuration points to the Terraform project directory in Semaphore:
|
||||
|
||||
```yaml
|
||||
---
|
||||
plugin: cloud.terraform.terraform_provider
|
||||
project_path:
|
||||
- /tmp/semaphore/project_1/repository_1_template_7/terraform/projects/kubernetes_vms
|
||||
```
|
||||
|
||||
This path is specific to my Semaphore checkout. It is not a generic path to copy into another installation; a local execution needs to point to its own Terraform project directory.
|
||||
|
||||
I add `cloud.terraform` to `collections/requirements.yml` so Semaphore can install the collection. Before writing the node preparation role, I create a simple playbook that only runs an Ansible ping against the hosts.
|
||||
|
||||
✅ That test works. Semaphore can use the Terraform state to build the inventory and Ansible can reach the provisioned VMs.
|
||||
|
||||
⚠️ There is an important limitation, though: in this setup, `terraform.tfstate` lives inside the Semaphore container's temporary checkout. Recreating the container can lose that state. The inventory integration works, but it does not solve state persistence. Durable state storage still needs to be addressed before relying on this workflow for repeated rebuilds.
|
||||
|
||||
## Preparing the nodes with Ansible
|
||||
|
||||
With the inventory working, I create the `k8s_node` role and a small `k8s_node.yml` playbook that applies it:
|
||||
|
||||
```yaml
|
||||
---
|
||||
- name: Prepare Kubernetes nodes
|
||||
hosts: all
|
||||
|
||||
roles:
|
||||
- role: k8s_node
|
||||
```
|
||||
|
||||
The role is split into task files for system settings, packages, containerd, and the firewall.
|
||||
|
||||
The system tasks disable swap at runtime and attempt to comment out swap entries in `/etc/fstab`. They load and persist the `overlay` and `br_netfilter` kernel modules, then configure the bridge filtering and IPv4 forwarding settings.
|
||||
|
||||
The package tasks add the Kubernetes package repository, install `kubelet` and `kubeadm`, and put those packages on hold. The containerd tasks install the runtime, generate its configuration when it is missing, enable `SystemdCgroup`, and restart and enable the service.
|
||||
|
||||
⚠️ For the firewall, the role applies UFW rules according to the inventory groups. Control-plane nodes receive the control-plane rules, while workers receive the worker rules. The source subnet is configurable, and incoming traffic is denied by default. These rules need to match the network used by the nodes; they are not a universal firewall configuration for every cluster.
|
||||
|
||||
The Terraform groups are useful here beyond just listing the machines. Ansible can distinguish the node roles without another manually maintained mapping.
|
||||
|
||||
I create a Semaphore task template called **Kubernetes VM Preparation** to run the playbook with that inventory.
|
||||
|
||||

|
||||
|
||||
📝 The [Ansible project and role](https://github.com/Vezpi/Homelab/tree/main/ansible/kubernetes) are also available in the repository.
|
||||
|
||||
## Where I stop for now
|
||||
|
||||
Selecting an environment in the survey works, but the current method does not let me manage multiple environments independently. Changing the environment input is not the same as giving each environment its own Terraform state.
|
||||
|
||||
Terraform workspaces might be a solution later. I have not implemented or tested that approach in this project, so for now the workflow remains limited to one environment at a time.
|
||||
|
||||
I also stop the automation before cluster bootstrap. The playbook prepares the nodes; it does not run `kubeadm init` or `kubeadm join`, and it does not install the CNI. Those steps remain manual for this stage of the migration.
|
||||
|
||||
✅ At the end of the project, I deploy six VMs with Terraform and configure them with Ansible. They are ready for cluster creation, but there is no bootstrapped Kubernetes cluster yet.
|
||||
|
||||
## Conclusion
|
||||
|
||||
🚀 I now have six VMs deployed with Terraform and prepared with Ansible through Semaphore. Using Terraform state as inventory means I do not need to copy their names, addresses, and roles into a separate host list.
|
||||
|
||||
The temporary state storage and lack of independent environments still need work. For now, I stop here with the prepared machines and move on to the manual Kubernetes bootstrap, as planned in the migration roadmap.
|
||||
|
After Width: | Height: | Size: 44 KiB |
@@ -10,6 +10,7 @@ tags:
|
||||
- cloud-init
|
||||
categories:
|
||||
- homelab
|
||||
image: cover-3.webp
|
||||
---
|
||||
## Intro
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ tags:
|
||||
- cloud-init
|
||||
categories:
|
||||
- homelab
|
||||
image: cover-3.webp
|
||||
---
|
||||
## Intro
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 53 KiB |
@@ -11,6 +11,7 @@ tags:
|
||||
- gitea-actions
|
||||
categories:
|
||||
- blog
|
||||
image: cover-4.webp
|
||||
---
|
||||
## Intro
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ tags:
|
||||
- gitea-actions
|
||||
categories:
|
||||
- blog
|
||||
image: cover-4.webp
|
||||
---
|
||||
## Intro
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 59 KiB |
@@ -11,6 +11,7 @@ tags:
|
||||
- ci-cd
|
||||
categories:
|
||||
- homelab
|
||||
image: cover-5.webp
|
||||
---
|
||||
## Intro
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ tags:
|
||||
- ci-cd
|
||||
categories:
|
||||
- homelab
|
||||
image: cover-5.webp
|
||||
---
|
||||
## Intro
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 49 KiB |
@@ -10,6 +10,7 @@ tags:
|
||||
- node-red
|
||||
categories:
|
||||
- automation
|
||||
image: cover-6.webp
|
||||
---
|
||||
## Intro
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ tags:
|
||||
- node-red
|
||||
categories:
|
||||
- automation
|
||||
image: cover-6.webp
|
||||
---
|
||||
## Intro
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 44 KiB |
@@ -10,6 +10,7 @@ tags:
|
||||
- cloud-init
|
||||
categories:
|
||||
- homelab
|
||||
image: cover-7.webp
|
||||
---
|
||||
## Intro
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ tags:
|
||||
- cloud-init
|
||||
categories:
|
||||
- homelab
|
||||
image: cover-7.webp
|
||||
---
|
||||
## Intro
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 56 KiB |
@@ -10,6 +10,7 @@ tags:
|
||||
- high-availability
|
||||
categories:
|
||||
- homelab
|
||||
image: cover-8.webp
|
||||
---
|
||||
|
||||
## Intro
|
||||
|
||||
@@ -10,6 +10,7 @@ tags:
|
||||
- high-availability
|
||||
categories:
|
||||
- homelab
|
||||
image: cover-8.webp
|
||||
---
|
||||
|
||||
## Intro
|
||||
|
||||
|
After Width: | Height: | Size: 46 KiB |
@@ -14,6 +14,7 @@ tags:
|
||||
- cert-manager
|
||||
categories:
|
||||
- homelab
|
||||
image: cover-9.webp
|
||||
---
|
||||
|
||||
## Intro
|
||||
|
||||
@@ -14,6 +14,7 @@ tags:
|
||||
- cert-manager
|
||||
categories:
|
||||
- homelab
|
||||
image: cover-9.webp
|
||||
---
|
||||
|
||||
## Intro
|
||||
|
||||
@@ -3,6 +3,8 @@ FROM nginx:stable
|
||||
ARG HUGO_VERSION
|
||||
ENV HUGO_VERSION=${HUGO_VERSION}
|
||||
ENV HUGO_DEST=/usr/share/nginx/html
|
||||
ENV DART_SASS_VERSION=1.105.1
|
||||
ENV PATH="/opt/dart-sass:${PATH}"
|
||||
|
||||
# Install dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
@@ -15,6 +17,10 @@ RUN apt-get update && apt-get install -y \
|
||||
RUN curl -sSL https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_Linux-64bit.tar.gz \
|
||||
| tar -xz -C /usr/local/bin hugo
|
||||
|
||||
# Download Dart Sass
|
||||
RUN curl -fsSL https://github.com/sass/dart-sass/releases/download/${DART_SASS_VERSION}/dart-sass-${DART_SASS_VERSION}-linux-x64.tar.gz \
|
||||
| tar -xz -C /opt
|
||||
|
||||
# Add entrypoint script
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
@@ -5,7 +5,8 @@ set -e
|
||||
REPO_URL="${REPO_URL:-https://git.vezpi.com/Vezpi/blog.git}"
|
||||
URL="${URL:-blog.vezpi.com}"
|
||||
BRANCH="${BRANCH:-preview}"
|
||||
CLONE_DIR="${CLONE_DIR:-/blog}"
|
||||
BLOG_DIR="${BLOG_DIR:-/blog}"
|
||||
HUGO_RESOURCES_DIR="${HUGO_RESOURCES_DIR:-/hugo-resources}"
|
||||
DRAFTS=""
|
||||
|
||||
# Add drafts for preview
|
||||
@@ -15,21 +16,27 @@ if [ "$BRANCH" = "preview" ]; then
|
||||
fi
|
||||
|
||||
# Clean blog dir
|
||||
rm -rf "$CLONE_DIR"
|
||||
rm -rf "$BLOG_DIR"
|
||||
|
||||
# Clone repo
|
||||
echo "- Cloning $REPO_URL (branch: $BRANCH)..."
|
||||
git clone --recurse-submodules --branch "$BRANCH" "$REPO_URL" "$CLONE_DIR"
|
||||
git clone --recurse-submodules --branch "$BRANCH" "$REPO_URL" "$BLOG_DIR"
|
||||
|
||||
# Patch references not yet fixed in Stack theme
|
||||
sed -i 's/\.Site\.Data/hugo.Data/g' "$CLONE_DIR/themes/stack/layouts/_partials/article/components/photoswipe.html"
|
||||
sed -i 's/LanguageDirection/Direction/g' "$CLONE_DIR/themes/stack/layouts/baseof.html"
|
||||
sed -i 's/\.LanguageCode/.Language.Locale/g' "$CLONE_DIR/themes/stack/layouts/baseof.html"
|
||||
sed -i 's/\.LanguageCode/.Locale/g' "$CLONE_DIR/themes/stack/layouts/rss.xml"
|
||||
# Restore persistent Hugo resources cache
|
||||
rm -rf "$BLOG_DIR/resources"
|
||||
ln -s "$HUGO_RESOURCES_DIR" "$BLOG_DIR/resources"
|
||||
|
||||
# Generate static files with hugo
|
||||
echo "- Building site with Hugo v$HUGO_VERSION in $HUGO_DEST..."
|
||||
hugo --source "$CLONE_DIR" --destination "$HUGO_DEST" --baseURL="https://${URL}" ${DRAFTS} --logLevel info --cleanDestinationDir --gc --panicOnWarning --printI18nWarnings
|
||||
hugo --source "$BLOG_DIR" \
|
||||
--destination "$HUGO_DEST" \
|
||||
--baseURL="https://${URL}" \
|
||||
${DRAFTS} \
|
||||
--logLevel info \
|
||||
--cleanDestinationDir \
|
||||
--gc \
|
||||
--panicOnWarning \
|
||||
--printI18nWarnings
|
||||
|
||||
# Start nginx
|
||||
echo "- Starting Nginx..."
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{{ $sass := resources.Get "scss/style.scss" }}
|
||||
{{ $opts := dict
|
||||
"transpiler" "dartsass"
|
||||
"silenceDeprecations" (slice "import" "global-builtin")
|
||||
}}
|
||||
{{ $style := $sass | toCSS $opts | minify | resources.Fingerprint "sha256" }}
|
||||
<link rel="stylesheet" href="{{ $style.RelPermalink }}">
|
||||