Introduction : de la théorie à la pratique#
Dans le premier article consacré à Flatcar Container Linux, on a posé les bases théoriques de ce système d’exploitation immuable : un répertoire /usr en lecture seule, un partitionnement A/B permettant des mises à jour atomiques avec bascule automatique en cas d’échec, et un provisioning déterministe assuré par Ignition dès l’initramfs.
La théorie c’est bien, mais la pratique c’est encore mieux ! L’objectif de cette seconde partie est de déployer une première machine virtuelle Flatcar Container Linux sur Proxmox VE de manière totalement déclarative avec OpenTofu.
Plutôt que d’écrire un fichier Butane à la main, il existe le provider communautaire community-terraform-providers/ignition combiné au provider bpg/proxmox pour déployer une machine virtuelle. Cela permet de générer la configuration Ignition directement en HCL, en profitant des variables, des fonctions de templating, le tout directement dans mon langage d’infrastructure as code préféré !
Pour rendre cet exercice un minimum concret, je vous propose de déployer le service DNS Blocky permettant notamment de bloquer les publicités et domaines indésirables grâce à des listes de fichiers hosts à définir. Celui-ci repose sur un conteneur et un fichier YAML permettant de le configurer, c’est donc un candidat idéal pour ce cas de figure.
Prérequis et architecture#
Avant d’écrire les premières lignes de code, voici ce dont vous aurez besoin pour réaliser ce déploiement :
- Un nœud Proxmox VE fonctionnel (8.4 et plus), cela va de soi ;
- Le binaire
tofu(version 1.10 ou supérieure) ; - Une paire de clés SSH chargée dans votre agent SSH local notamment pour se connecter à l’hyperviseur Proxmox et injecter la configuration Ignition dans le Datastore ;
- Une adresse IP libre sur votre réseau local à attribuer au serveur DNS ;
- Un utilisateur et un token d’API Proxmox disposant des droits
Sys.AuditetSys.Modifysur le chemin/, ces privilèges étant exigés par l’endpoint utilisé pour télécharger l’image Flatcar ; - Le type de contenu
importactivé sur le datastore qui recevra l’image Flatcar.
Les deux derniers points sont les plus faciles à oublier et se traduisent par des erreurs HTTP 403 et 500 au premier tofu apply. Les commandes pveum et pvesm correspondantes sont détaillées dans le README.md du dépôt présenté juste après.
Une petite précision importante avant d’aller plus loin : du point de vue du projet Flatcar, Proxmox VE fait partie des plateformes supportées par la communauté. Cela signifie que l’image dédiée est bien produite à chaque release, mais qu’elle ne passe pas par la batterie de tests automatisés officiels de l’équipe Flatcar.
En pratique, cela fonctionne très bien, mais il est préférable de le savoir avant de partir tête baissée en production.
Arborescence du projet#
L’ensemble du code présenté dans cet article est disponible dans ce dépôt GitHub :
Pour garder une structure claire et maintenable, les responsabilités sont découpées en plusieurs fichiers :
├── README.md
├── backend.tf
├── encryption.tf
├── files/
│ └── blocky.yml
├── ignition.tf
├── image.tf
├── main.tf
├── outputs.tf
├── providers.tf
├── terraform.tfvars.example
└── variables.tfOn retrouve la configuration du chiffrement de l’état dans encryption.tf, la déclaration du backend dans backend.tf, les déclarations de versions et des providers dans providers.tf, le téléchargement de l’image Flatcar dans image.tf, toute la génération de la configuration dans ignition.tf, et enfin l’instanciation d’Ignition à travers un snippet et de la machine virtuelle dans main.tf.
Toutes les valeurs par défaut se trouvent dans terraform.tfvars.example. Bien évidemment, libre à vous de modifier ces valeurs !
Configurer les providers et ajouter une couche de sécurité#
On entre dans le vif du sujet avec le fichier providers.tf où la version du provider ignition est volontairement épinglée en ~> 2.6.0 et non sur la dernière branche 2.7.x. On y reviendra dans la section consacrée à Ignition, mais cela est lié à la version de la spécification JSON générée par le provider.
Le provider ignition ne nécessite aucun paramètre additionnel contrairement au provider proxmox où la variable proxmox_api_token doit être passée en variable d’environnement au travers d’un fichier .envrc pour se faciliter la vie :
export TF_VAR_proxmox_api_token="tofu@pve!provider=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"À côté de cela, le fichier encryption.tf ajoute le chiffrement de l’état mais aussi du plan.
En effet, lorsqu’on manipule Ignition avec OpenTofu, l’intégralité de la configuration rendue en JSON se retrouve enregistrée en clair dans le fichier d’état ou tfstate. Même si l’on n’injecte ici qu’une clé publique SSH, il est très fréquent d’y passer des mots de passe, des certificats TLS ou encore des tokens…
Avec OpenTofu, et c’est pourquoi je le préfère largement à Terraform, cette fonctionnalité arrive par défaut.
Enfin, même avec un backend local utilisé pour ce type de projet en mode Lab, le fichier .tfstate est intégralement chiffré et peut même être stocké dans un dépôt Git privé sans risque de fuite de secrets. Dans un contexte de production, je vous recommande tout de même de coupler ce chiffrement avec un backend distant pour des questions de concurrence notamment.
L’image Flatcar#
Maintenant que les providers sont prêts, il faut récupérer l’image de Flatcar Container Linux adaptée à Proxmox VE. Pas besoin de se connecter en SSH sur l’hyperviseur pour faire un wget à la main : la ressource proxmox_download_file dans image.tf s’en charge directement :
locals {
flatcar_image_url = "https://${var.flatcar_channel}.release.flatcar-linux.net/amd64-usr/${var.flatcar_version}/flatcar_production_proxmoxve_image.img"
}
resource "proxmox_download_file" "flatcar" {
[...]
content_type = "import"
url = local.flatcar_image_url
file_name = "flatcar_production_proxmoxve_image-${var.flatcar_channel}.qcow2"
overwrite = false
[...]
}Cette image peut être choisie notamment avec les variables flatcar_channel (qui accepte stable, beta, alpha ou lts) et flatcar_version.
Deux détails importants :
D’abord l’attribut
file_name: l’image publiée par Flatcar s’appelleflatcar_production_proxmoxve_image.imgalors qu’il s’agit en réalité d’une image au format QCOW2. Or, Proxmox VE se fie strictement à l’extension du fichier pour déterminer le format d’importation et refusera tout simplement un.img. On le renomme donc en.qcow2;Ensuite
overwrite = false: comme l’URL pointe par défaut sur la versioncurrent, le fichier distant change à chaque publication de Flatcar. Laisser cet attribut àtruedéclencherait un nouveau téléchargement dès qu’une nouvelle version est disponible.
C’est ici que le lien avec la première partie prend tout son sens : cette image de base ne sert qu’à instancier la machine au tout premier démarrage. Une fois la machine virtuelle démarrée, c’est le service update_engine interne à Flatcar qui assure les mises à jour sur la partition passive.
Vous n’avez donc pas besoin de télécharger chaque nouvelle image ni de redéployer vos machines pour suivre le fil des correctifs de sécurité de l’OS.
Ignition et sa configuration#
On passe au cœur du déploiement avec le fichier ignition.tf. Le provider community-terraform-providers/ignition a une particularité : il ne possède aucune ressource, uniquement des data sources.
C’est un provider purement logique qui assemble des structures de données et restitue un JSON conforme à la spécification Ignition, sans aucune erreur de virgule ou d’accolades mal fermées…
Petit point de vigilance sur les versions : la version 2.7.x du provider génère un JSON estampillé avec la spécification Ignition 3.6.0. Or, la branche stable actuelle de Flatcar (version 4757.2.2 au moment de l’écriture de l’article) embarque Ignition 2.24.0, qui supporte la spécification jusqu’à la version 3.5.0.
Si vous utilisez la version 2.7.x du provider, Ignition refusera la configuration dans l’initramfs et la machine n’ira pas plus loin.
Vous avez donc compris pourquoi le provider possède la règle ~> 2.6.0 plutôt que la dernière version.
Voici un récapitulatif des data sources mises à contribution dans le fichier ignition.tf :
| Data source | Rôle dans la configuration |
|---|---|
ignition_user | Configure l’utilisateur système core et lui associe la clé publique SSH |
ignition_directory | Crée le répertoire /etc/blocky sur le système de fichiers racine |
ignition_file | Écrit les fichiers de configuration (/etc/hostname, réseau, resolved, Blocky, update.conf) |
ignition_link | Crée le lien symbolique /etc/localtime pour définir le fuseau horaire |
ignition_systemd_unit | Déclare et active l’unité systemd blocky.service |
ignition_config | Fusionne l’ensemble des éléments précédents dans le document JSON final |
On va passer en revue les parties principales du fichier ignition.tf, en commençant par l’utilisateur par défaut core et le nom de la machine :
data "ignition_user" "core" {
name = "core"
ssh_authorized_keys = [trimspace(file(pathexpand(var.ssh_public_key_path)))]
}
data "ignition_file" "hostname" {
path = "/etc/hostname"
mode = 420 # 0644
contents {
source = "data:;base64,${base64encode("${var.vm_name}\n")}"
}
}
[...]Les permissions de fichiers (mode) s’expriment en notation décimale dans le provider : 420 correspond à 0644 en octal (0o644), et 493 correspond à 0755 (0o755).
On configure ensuite le réseau via un fichier /etc/systemd/network/00-static.network :
data "ignition_file" "network" {
path = "/etc/systemd/network/00-static.network"
mode = 420
contents {
source = "data:;base64,${base64encode(<<-EOT
[Match]
Name=en*
[Network]
Address=${var.vm_ip_address}
Gateway=${var.vm_gateway}
DNS=${var.host_dns_server}
EOT
)}"
}
}Un fichier supplémentaire est nécessaire. En effet, Flatcar démarre systemd-resolved avec son stub listener, qui écoute déjà sur 127.0.0.53:53. Or, Docker cherchera à publier le port 53 sur 0.0.0.0, ce qui recouvre cette adresse : la publication échoue et le conteneur Blocky ne démarrera jamais. On désactive donc le stub listener à l’aide d’une configuration spécifique comme celle-ci :
data "ignition_file" "resolved_stub" {
path = "/etc/systemd/resolved.conf.d/10-disable-stub.conf"
mode = 420
contents {
source = "data:;base64,${base64encode(<<-EOT
[Resolve]
DNSStubListener=no
EOT
)}"
}
}systemd-resolved continue d’assurer la résolution pour la machine elle-même via /run/systemd/resolve/resolv.conf, il cesse simplement d’occuper le port 53.
Vient ensuite la configuration de Blocky et de son service systemd. Le fichier files/blocky.yml est injecté avec la fonction templatefile(), puis monté dans le conteneur au sein du blocky.service :
data "ignition_file" "blocky_config" {
path = "/etc/blocky/config.yml"
mode = 420
contents {
source = "data:;base64,${base64encode(templatefile("${path.module}/files/blocky.yml", {
dns_port = local.blocky_dns_port
http_port = local.blocky_http_port
}))}"
}
}
data "ignition_systemd_unit" "blocky" {
name = "blocky.service"
enabled = true
content = <<-EOT
[Unit]
Description=Blocky DNS
After=docker.service network-online.target systemd-resolved.service
[...]
[Service]
Restart=always
RestartSec=5
ExecStartPre=-/usr/bin/docker rm -f blocky
ExecStartPre=/usr/bin/docker pull ${local.blocky_image}
ExecStart=/usr/bin/docker run --name blocky --rm \
-p ${local.blocky_dns_port}:${local.blocky_dns_port}/udp \
-p ${local.blocky_dns_port}:${local.blocky_dns_port}/tcp \
-p ${local.blocky_http_port}:${local.blocky_http_port}/tcp \
-v /etc/blocky/config.yml:/app/config.yml:ro \
${local.blocky_image}
[...]
EOT
}Avant d’assembler le tout, on ajoute un dernier fichier : /etc/flatcar/update.conf. Dans la première partie, on expliquait que Flatcar télécharge ses mises à jour automatiquement en tâche de fond et bascule d’une partition à l’autre au redémarrage.
Pour éviter que le serveur DNS ne redémarre en pleine journée dès qu’une mise à jour est prête, on configure locksmithd pour n’autoriser le redémarrage automatique que le dimanche matin à 04h00 sur une fenêtre d’une heure :
data "ignition_file" "update_conf" {
path = "/etc/flatcar/update.conf"
mode = 420
overwrite = true
contents {
source = "data:;base64,${base64encode(<<-EOT
GROUP=${var.flatcar_channel}
REBOOT_STRATEGY=reboot
LOCKSMITHD_REBOOT_WINDOW_START=Sun 04:00
LOCKSMITHD_REBOOT_WINDOW_LENGTH=1h
EOT
)}"
}
}Notez la présence de overwrite = true sur ce fichier. En effet, /etc/flatcar/update.conf existe déjà dans l’image de base de Flatcar. Sans cet attribut, Ignition refuserait d’écraser le fichier existant.
Il ne reste plus qu’à regrouper toutes ces briques dans la data source ignition_config :
data "ignition_config" "blocky" {
users = [data.ignition_user.core.rendered]
directories = [data.ignition_directory.blocky.rendered]
files = [
data.ignition_file.hostname.rendered,
data.ignition_file.network.rendered,
[...]
]
links = [data.ignition_link.timezone.rendered]
systemd = [data.ignition_systemd_unit.blocky.rendered]
}L’attribut data.ignition_config.blocky.rendered contient désormais le document JSON complet, exactement comme celui que l’on obtenait avec le binaire butane dans la première partie. On peut d’ailleurs inspecter un extrait du résultat produit à l’aide de la commande tofu console si besoin :
{
"ignition": { "version": "3.5.0" },
"passwd": {
"users": [{ "name": "core", "sshAuthorizedKeys": ["ssh-ed25519 AAAA..."] }]
},
"storage": {
"files": [
{
"contents": { "source": "data:;base64,YmxvY2t5Cg==" },
"mode": 420,
"path": "/etc/hostname"
}
[...]
]
},
"systemd": { "units": [...] }
}Comme évoqué dans le premier article, ce JSON sera évalué de façon atomique dans l’initramfs : si la moindre directive est invalide, la machine refusera de démarrer plutôt que de se retrouver dans un état semi-configuré.
L’heure du déploiement#
Tout est prêt pour créer la machine virtuelle dans main.tf. La première étape consiste à injecter le JSON Ignition généré sous forme de snippet sur le nœud Proxmox, puis à l’associer à la machine virtuelle.
Attention toutefois, il y a quelques points à avoir en tête par rapport au code :
Le premier concerne le démarrage : l’image Flatcar exige l’UEFI. Il faut donc basculer le BIOS de la machine sur
ovmfavec un disque EFI pour éviter des erreurs lors du boot ;Le deuxième porte sur le disque système : il est importé directement depuis l’image téléchargée à l’étape précédente. On lui donne une taille supérieure à celle de l’image, et Flatcar se charge au premier démarrage d’étendre tout seul sa partition racine pour occuper l’espace disponible ;
Le dernier est le pont entre Proxmox et Ignition. Proxmox attache à la machine virtuelle un petit lecteur de type cloud-init, et l’image Proxmox de Flatcar sait lire le
user-datapour le transmettre à Ignition. Il est donc impératif de faire le lien via le champinitialization.user_data_file_idau sein de cette ressource.
Mais tout d’abord, la configuration et les secrets doivent être mis en place :
cp terraform.tfvars.example terraform.tfvars
export TF_VAR_proxmox_api_token="tofu@pve!provider=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
export TF_VAR_encryption_passphrase="a-long-random-passphrase"
tofu init && tofu validate
tofu planVoici un extrait des lignes principales retournées par tofu plan :
OpenTofu will perform the following actions:
# proxmox_download_file.flatcar will be created
+ resource "proxmox_download_file" "flatcar" {
+ content_type = "import"
+ file_name = "flatcar_production_proxmoxve_image-stable.qcow2"
+ url = "https://stable.release.flatcar-linux.net/amd64-usr/current/flatcar_production_proxmoxve_image.img"
[...]
}
# proxmox_virtual_environment_file.ignition will be created
+ resource "proxmox_virtual_environment_file" "ignition" {
+ content_type = "snippets"
+ source_raw {
+ file_name = "blocky-ignition.json"
[...]
}
}
# proxmox_virtual_environment_vm.blocky will be created
+ resource "proxmox_virtual_environment_vm" "blocky" {
[...]
+ name = "blocky"
+ vm_id = 1000
[...]
}
Plan: 3 to add, 0 to change, 0 to destroy.Bien qu’assez long, le plan est clair : ces trois ressources sont prêtes à être déployées.
$ tofu apply
[...]
Apply complete! Resources: 3 added, 0 changed, 0 destroyed.
Outputs:
blocky_api_url = "http://192.168.1.100:4000"
ssh_command = "ssh [email protected]"
vm_ip_address = "192.168.1.100"En quelques minutes (surtout la première fois avec le téléchargement de l’image), la machine virtuelle est créée, démarrée et configurée.
Quelques étapes pour valider le tout !#
Une fois l’instance déployée, on peut vérifier que tout est bien configuré comme prévu ! Pour cela, rien de tel qu’une connexion SSH avec ssh [email protected] :
$ cat /etc/os-release
NAME="Flatcar Container Linux by Kinvolk"
ID="flatcar"
ID_LIKE="coreos"
VERSION="4757.2.2"
[...]
$ sudo touch /usr/test
touch: cannot touch '/usr/test': Read-only file systemComme vous pouvez le constater, impossible de toucher au /usr, on ne s’est pas trompé d’OS. :-)
Il est possible de regarder l’état du service update_engine ainsi que du conteneur Blocky avec les commandes dédiées :
$ update_engine_client -status
CURRENT_OP=UPDATE_STATUS_IDLE
[...]
$ systemctl is-active blocky.service
active
$ docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Status}}"
NAMES IMAGE STATUS
blocky ghcr.io/0xerr0r/blocky:v0.35.0 Up 2 minutes (healthy)En dehors de la machine virtuelle, on peut également tester la résolution DNS :
$ dig +short @192.168.1.100 blog.filador.ch
188.114.96.12
[...]
$ dig +short @192.168.1.100 pagead2.googlesyndication.com
0.0.0.0Mon blog est bien résolu, tandis que le domaine publicitaire renvoie 0.0.0.0 grâce à la liste de StevenBlack. Le serveur DNS est pleinement opérationnel !
Le modèle “cattle” en pratique : modifier et recréer#
Que se passe-t-il maintenant si l’on souhaite modifier la configuration de Blocky, par exemple pour ajouter une nouvelle liste de blocage dans files/blocky.yml ?
Il faut toujours garder en tête la règle d’or d’Ignition : il ne s’exécute qu’une seule et unique fois, lors du tout premier démarrage de la machine virtuelle. Si vous vous contentez de modifier le fichier snippet sur Proxmox VE sans recréer la machine virtuelle, il ne se passera strictement rien, même en redémarrant la VM.
C’est précisément le rôle du bloc lifecycle glissé à la fin de la ressource proxmox_virtual_environment_vm dans main.tf :
lifecycle {
replace_triggered_by = [
proxmox_virtual_environment_file.ignition,
]
ignore_changes = [
disk[0].import_from,
]
}Grâce à la directive replace_triggered_by, dès que le contenu du JSON Ignition évolue (modification d’une liste dans files/blocky.yml, changement de version de l’image de Blocky, ajout d’une unité systemd ou d’une clé SSH), la ressource proxmox_virtual_environment_file.ignition est remplacée, ce qui déclenche automatiquement le remplacement complet de la machine virtuelle lors du tofu plan :
$ tofu plan
[...]
# proxmox_virtual_environment_file.ignition must be replaced
-/+ resource "proxmox_virtual_environment_file" "ignition" {
~ id = "local:snippets/blocky-ignition.json" -> (known after apply)
[...]
}
# proxmox_virtual_environment_vm.blocky will be replaced due to changes in replace_triggered_by
-/+ resource "proxmox_virtual_environment_vm" "blocky" {
~ id = "1000" -> (known after apply)
name = "blocky"
[...]
}
Plan: 2 to add, 0 to change, 2 to destroy.C’est l’illustration concrète du modèle “cattle” : on ne se connecte jamais sur la machine pour éditer /etc/blocky/config.yml à la main et relancer le service. On modifie la source de vérité dans Git, et OpenTofu détruit l’ancienne machine pour la remplacer en quelques secondes par une instance neuve, propre et garantie sans dérive de configuration.
Conclusion et ouverture#
En quelques fichiers HCL, on dispose désormais d’une machine virtuelle Flatcar Container Linux parfaitement reproductible sur Proxmox, qui déploie un service DNS Blocky fonctionnel sans la moindre action manuelle, tout cela accompagné des mises à jour automatiques avec une fenêtre de maintenance.
On retrouve exactement la même philosophie et la même rigueur déclarative que celles mises en œuvre avec Talos Linux et Cluster API sur les clusters Kubernetes, mais appliquées ici aux services d’infrastructure qui les entourent.
Pour aller plus loin, je vous encourage à regarder les extensions comme keepalived.sysext dans le cas d’un cluster à plusieurs machines avec la mise en place d’une VIP pour assurer la haute disponibilité ou d’utiliser Flatcar comme remplaçant de Talos Linux, à vous de voir !




