Nous avons récemment accompagné un client dans la migration de ses clusters Kubernetes vers Talos, un OS immuable taillé pour Kubernetes (nous l’apprécions particulièrement pour des clusters managés hautement sécurisés, on-premise ou en cloud privé).
Avec une infrastructure composée d’une cinquantaine de gros serveurs bare-metal, nous faisions face à un enjeu clé : comment installer efficacement l’OS Talos sur autant de serveurs, aussi bien pour la première installation que pour les futures mises à jour (fréquentes lorsqu’on adopte une approche avec OS immuable) ?
Historiquement, notre client utilisait MaaS, mais celui-ci n’étant pas compatible avec Talos et le client n’y étant pas particulièrement attaché, plutôt que réaliser une intégration complexe des deux outils, nous avons exploré d’autres options.
Utiliser Omni, bien qu’un excellent choix, a été écarté en raison de son coût trop élevé pour ce projet. L’option consistant à démarrer manuellement les .ISO Talos via les BMC nous semblait par ailleurs beaucoup trop chronophage pour autant de machines.
=> Nous avons donc opté pour une approche plus pragmatique : le démarrage de l’installateur Talos en PXE, avec une petite touche de modernité. Au programme : un cocktail savoureux de Docker et de GitOps, avec une pincée de technologies plus anciennes comme dnsmasq, iPXE et TFTP.
Notre objectif : bootstrap Talos avec une infrastructure PXE en version GitOps et Cloud Native
La mise en place d’une infrastructure PXE suit généralement un schéma classique : configuration d’un serveur et installation des différents services nécessaires. Certes, nous aurions pu utiliser un outil comme Ansible pour automatiser ce déploiement, mais - petit secret entre nous - ce n’est pas vraiment ma tasse de thé.
Et si nous sortions des sentiers battus ? Avec Kubernetes, nous apprécions particulièrement la capacité à déclencher un déploiement d’un simple commit. Les conteneurs nous ont habitués à travailler avec des environnements immuables, minimalistes, spécialisés et versionnés. Appliquons ces principes à notre infrastructure PXE, tout en gardant une approche pragmatique et élégante.
💡 Tous les exemples de ce billet sont disponibles sur ce dépôt Git.
Mais au fait, c’est quoi “PXE” ?
Sans entrer dans les détails, il s’agit d’une technologie permettant de démarrer des systèmes d’exploitation via le réseau. Elle s’appuie sur DHCP pour la configuration automatique des paramètres réseau et sur TFTP pour le transfert des fichiers.
Notre objectif est de démarrer l’image d’installation de Talos pour obtenir des nœuds en mode maintenance, prêts à être intégrés dans un cluster Kubernetes Talos.
Pour ce faire, nous utiliserons Dnsmasq, véritable couteau suisse de l’infrastructure PXE, qui intègre à lui seul un serveur DHCP et un serveur TFTP dans un unique service.
Nous exploiterons également iPXE, un firmware de démarrage PXE aux capacités étendues. Ses fonctionnalités de chargement HTTP(S) et de scripting nous seront particulièrement précieuses. Plutôt que de flasher ce firmware directement sur les cartes réseau, nous le chargerons via le firmware d’origine des serveurs, en utilisant un processus PXE classique.

Notre inventaire de machines
Commençons par définir un format permettant de décrire la configuration souhaitée pour chaque machine. Celui-ci doit inclure :
- Le hostname
- La configuration réseau
- La version de l’installateur Talos et son schematicID associé
- Les arguments à passer au kernel Linux
- Un critère unique d’identification (dans notre cas, le numéro de série)
Pour structurer ces informations, nous utilisons une arborescence de fichiers YAML organisée comme suit :
nodes/
├── _defaults.yaml
├── k8s-dev
│ ├── node-01.yaml
│ ├── node-02.yaml
│ ├── node-03.yaml
│ ├── node-04.yaml
│ ├── node-05.yaml
│ ├── node-06.yaml
│ └── _defaults.yaml
└── k8s-prod
├── node-07.yaml
├── node-08.yaml
├── node-09.yaml
├── node-10.yaml
├── node-11.yaml
├── node-12.yaml
└── _defaults.yaml
La profondeur de l’arborescence est arbitraire. Dans notre exemple, nous avons simplement choisi de regrouper les machines par cluster. Tout fichier YAML dont le nom ne commence pas par un “_” représente une machine.
Les fichiers _defaults.yaml servent à définir des valeurs par défaut pour les machines, avec une évaluation récursive de ces valeurs en remontant l’arborescence.
Par exemple, avec le contenu des fichiers suivants :
nodes/_defaults.yaml :
schematicID: 376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba
talosVersion: v1.9.5
kernelArgs: talos.platform=metal console=tty0 init_on_alloc=1 slab_nomerge pti=on consoleblank=0 nvme_core.io_timeout=4294967295 printk.devkmsg=on ima_template=ima-ng ima_appraise=fix ima_hash=sha512
dns0: 1.1.1.1
dns1: 8.8.8.8
ntp: fr.pool.ntp.org
install: false
nodes/k8s-dev/_defaults.yaml :
netmask: 255.255.255.0
gateway: 192.168.100.1
vlan: 1234
interface: ens1
nodes/k8s-dev/node-01.yaml :
serial: ABCDEFGHIJ01
ip: 192.168.100.1
install: true
Les valeurs effectives pour le nœud “node-01” seront la fusion de tous ces fichiers :
schematicID: 376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba
talosVersion: v1.9.5
kernelArgs: talos.platform=metal console=tty0 init_on_alloc=1 slab_nomerge pti=on consoleblank=0 nvme_core.io_timeout=4294967295 printk.devkmsg=on ima_template=ima-ng ima_appraise=fix ima_hash=sha512
dns0: 1.1.1.1
dns1: 8.8.8.8
ntp: fr.pool.ntp.org
netmask: 255.255.255.0
gateway: 192.168.100.1
vlan: 1234
interface: ens1
serial: ABCDEFGHIJ01
ip: 192.168.100.1
install: true
Générer la configuration Dnsmasq et iPXE
Il faut maintenant transformer ces fichiers YAML en configuration Dnsmasq et iPXE. Pour faire ce genre de choses, j’aime utiliser Gomplate. En deux mots, ce logiciel permet produire le rendu de template “Go” (similaires à ceux d’Helm) à partir d’un environnement fourni (des variables d’environnement ou autre datasource).
Son utilisation de base est simple, comme le montre cet exemple tiré de la documentation du projet :
$ echo "Hello, {{ .Env.USER }}" | gomplate
Hello, hairyhenderson
Gomplate ne disposant pas nativement d’un moyen de lire notre arborescence YAML, nous avons développé un petit script Python pour convertir celle-ci en JSON “aplati” compatible avec celui-ci :
#!/usr/bin/env python
import argparse
import os
import pathlib
import json
import functools
import yaml
@functools.cache
def get_defaults(directory, root):
"""Compute the defaults from the provided directory and parents."""
try:
with open(directory.joinpath("_defaults.yaml")) as fyaml:
yml_data = yaml.safe_load(fyaml)
except OSError:
yml_data = {}
if directory != root: # Stop recursion when reaching root directory
return get_defaults(directory.parent, root) | yml_data
else:
return yml_data
def walk_files(root):
for dirpath, dirnames, filenames in root.walk():
for fn in filenames:
if not fn.startswith("_"):
yield dirpath.joinpath(fn)
def main(args):
data = []
for fullname in walk_files(args.directory):
filename = (
str(fullname.relative_to(args.directory).parent) + "/" + fullname.stem
)
if args.filter is not None and not filename.startswith(args.filter):
continue
with open(fullname) as fyaml:
yml_data = yaml.safe_load(fyaml)
yml_data = get_defaults(fullname.parent, args.directory) | yml_data
yml_data["hostname"] = fullname.stem
yml_data["filename"] = filename
data.append(yml_data)
print(json.dumps(data))
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("directory", type=pathlib.Path)
parser.add_argument("-f", "--filter")
args = parser.parse_args()
main(args)
Voici le template Dnsmasq (dnsmasq.conf). Bien qu’il ne contienne pas d’instructions de templating, nous le traitons comme tel par souci de simplicité :
enable-tftp
tftp-root=/tftproot
tftp-single-port
dhcp-vendorclass=BIOS,PXEClient:Arch:00000
dhcp-vendorclass=UEFI,PXEClient:Arch:00007
dhcp-vendorclass=UEFI64,PXEClient:Arch:00009
dhcp-range=192.168.42.100,192.168.42.200,255.255.255.0,12h
dhcp-option=3,192.168.42.1
dhcp-option=option:dns-server,1.1.1.1,8.8.8.8
# 1st stage: pxe rom boot on ipxe
dhcp-boot=net:BIOS,ipxe.pxe,192.168.42.10,192.168.42.10
dhcp-boot=net:UEFI,ipxe.efi,192.168.42.10,192.168.42.10
dhcp-boot=net:UEFI64,ipxe.efi,192.168.42.10,192.168.42.10
# 2nd stage: ipxe load ipxe.conf script
dhcp-match=set:ipxe,175
dhcp-boot=tag:ipxe,ipxe.conf,192.168.42.10,192.168.42.10
On notera une petite particularité dans l’utilisation d’iPXE ici : le serveur DHCP adapte sa réponse en fonction de l’élément qui l’interroge (firmware PXE ou iPXE), comme illustré dans ce diagramme :

Cette approche en deux temps permet au firmware PXE de charger iPXE, qui à son tour charge son script de configuration spécifique à chaque machine.
Voici le template du script iPXE qui définit la configuration par machine :
#!ipxe
dhcp
goto node_${serial} || exit # or ${board-serial} depending on used hardware
# Default behavior (non install mode) is to exit iPXE script
{{ range (datasource "nodes" | jsonArray) }}
{{- if .install }}
# {{ .filename }}
:node_{{ .serial }}
{{- $ipArg := printf "ip=%s::%s:%s:%s:%s.%d::%s:%s:%s" .ip .gateway .netmask .hostname .interface .vlan .dns0 .dns1 .ntp }}
{{- $vlanArg := printf "vlan=%s.%d:%s" .interface .vlan .interface }}
{{- $kernelArgs := printf "%s %s %s" $ipArg $vlanArg .kernelArgs }}
imgfree
kernel https://pxe.factory.talos.dev/image/{{ .schematicID }}/{{ .talosVersion }}/kernel-amd64 {{ $kernelArgs }}
initrd https://pxe.factory.talos.dev/image/{{ .schematicID }}/{{ .talosVersion }}/initramfs-amd64.xz
boot
{{- end }}
{{ end }}
Quelques détails intéressants concernant ce script : nous utilisons l’instruction goto avec un label dynamique basé sur une variable pour sauter vers la configuration spécifique de la machine exécutant le script.
Dans notre exemple, on utilise la variable ${serial} qui correspond au numéro de série du châssis de la machine.
Un souci est apparu lors des tests chez notre client : certaines machines partageaient le même numéro de châssis. Il s’agissait en effet de serveurs Supermicro “multi-node”, regroupant 4 cartes mères dans un châssis unique mutualisant la ventilation et l’alimentation.
Cette situation posait problème car elle empêchait la configuration individualisée des machines. La solution habituelle sur PXE consiste à utiliser les adresses MAC, mais cette approche présente un inconvénient majeur : en présence de plusieurs interfaces réseau, la multiplicité des adresses MAC crée une ambiguïté.
Nous avons donc cherché à utiliser le numéro de série de la carte mère. Bien que la documentation d’iPXE ne mentionnait pas cette possibilité, en explorant le code source pour implémenter nous-mêmes cette fonctionnalité, nous avons découvert qu'elle existait déjà via la variable ${board-serial}. Une belle surprise qui nous a fait gagner un temps précieux !
Enrober le tout dans une image de conteneur Docker
Une fois notre inventaire YAML, nos scripts et nos templates en place, nous pouvons créer une image Docker pour notre infrastructure PXE, avec tous les éléments nécessaires.
La construction de l’image se fait en 3 étapes :
- Construction de binaires iPXE à partir des sources
- Génération de la configuration à partir des templates
- Création de l’image finale qui sera déployée sur notre serveur PXE
Construction de binaires iPXE à partir des sources
Nous devons compiler nos propres binaires iPXE car la dernière version officielle date de 2020 et les distributions Linux sont restées figées sur cette version. De plus, nous avons besoin d’activer le support HTTPS pour pouvoir télécharger les images Talos depuis la Factory du projet.
Voici les instructions Dockerfile correspondantes :
FROM ubuntu:noble AS builder-ipxe
ARG iPXE_VERSION=7e64e9b6703e6dd363c063d545a5fe63bbc70011
RUN apt-get -y -qq --force-yes update && \
apt-get -y -qq --force-yes install curl build-essential liblzma-dev genisoimage
RUN curl -L https://github.com/ipxe/ipxe/archive/${iPXE_VERSION}.tar.gz | tar -xz
WORKDIR /ipxe-${iPXE_VERSION}/src
RUN sed -i 's/^#undef[\t ]DOWNLOAD_PROTO_HTTPS.*$/#define DOWNLOAD_PROTO_HTTPS/g' config/general.h
RUN mkdir /built
RUN make -j$(nproc) bin/ipxe.pxe && cp bin/ipxe.pxe /built
RUN make -j$(nproc) bin-x86_64-efi/ipxe.efi && cp bin-x86_64-efi/ipxe.efi /built
Génération de la configuration à partir des templates
Comme évoqué plus tôt, nous utilisons Gomplate pour générer nos fichiers de configuration. Cette étape est réalisée dans un stage de construction dédié afin de ne pas alourdir inutilement l’image finale :
FROM python:3.13-slim AS config-renderer
COPY ./nodes /nodes
COPY ./merge.py /merge.py
COPY ./templates /templates
COPY --from=gomplate/gomplate:v4.3 /gomplate /bin/gomplate
RUN pip install pyyaml
RUN ./merge.py /nodes/ | gomplate --datasource nodes=stdin://nodes.json --output-dir=/rendered --input-dir=/templates
Construction de l’image finale
Enfin, nous construisons l’image finale qui sera déployée sur notre serveur PXE. Cette dernière étape consiste à installer Dnsmasq et à copier les binaires iPXE et les fichiers de configuration générés :
FROM debian:bookworm AS runtime
RUN apt-get -y -qq --force-yes update && apt-get -y -qq --force-yes install -y dnsmasq
RUN mkdir /tftproot
COPY --from=builder-ipxe /built/ipxe.pxe /tftproot/
COPY --from=builder-ipxe /built/ipxe.efi /tftproot/
COPY --from=config-renderer /rendered/ipxe.conf /tftproot/
COPY --from=config-renderer /rendered/dnsmasq.conf /dnsmasq.conf
EXPOSE 67/udp
EXPOSE 69/udp
CMD ["dnsmasq", "--conf-file=/dnsmasq.conf", "--keep-in-foreground", "--user=root", "--log-facility=-", "--port=0"]
Déployer l’image avec la CI de Gitlab
La dernière étape consiste à automatiser le déploiement. Notre objectif est de construire et déployer l’image à chaque modification du dépôt Git.
Notre client utilisant Gitlab, nous nous sommes appuyés sur sa CI.
Le pipeline se compose de deux jobs : construction et déploiement. Le job de déploiement présente une particularité : la machine cible étant inaccessible depuis les runners GitLab standards du client, nous avons installé un “project runner” directement sur la machine PXE. Celui-ci est configuré pour exposer la socket Docker aux conteneurs des jobs :
[runners.docker]
tls_verify = false
image = "ubuntu:latest"
privileged = false
disable_entrypoint_overwrite = false
oom_kill_disable = false
disable_cache = false
volumes = ["/cache", "/var/run/docker.sock:/var/run/docker.sock" ] # <--
shm_size = 0
network_mtu = 0
Cette configuration permet à la commande docker dans le job d’interagir directement avec le daemon Docker de notre machine PXE pour procéder au déploiement.
Voici notre fichier .gitlab-ci.yml final :
stages:
- build
- deploy
build:
image: docker:24
stage: build
before_script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
script:
- docker context create project-builder
- docker buildx create project-builder --driver docker-container --use
- docker buildx build --push -t $CI_REGISTRY_IMAGE:$CI_PIPELINE_IID
--cache-to type=registry,ref=$CI_REGISTRY_IMAGE/cache,mode=max
--cache-from type=registry,ref=$CI_REGISTRY_IMAGE/cache .
deploy:
image: docker:24
stage: deploy
before_script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
script:
- docker pull $CI_REGISTRY_IMAGE:$CI_PIPELINE_IID
- docker stop -t 2 $CI_PROJECT_NAME || true
- docker rm $CI_PROJECT_NAME || true
- docker run --detach --privileged --net=host --name $CI_PROJECT_NAME $CI_REGISTRY_IMAGE:$CI_PIPELINE_IID
- sleep 2 && docker logs $CI_PROJECT_NAME
tags:
- deploy
Conclusion
Et voilà ! Nous avons transformé un bon vieux serveur PXE en une infrastructure moderne, conteneurisée et GitOps-ready.
Plus besoin de se connecter en SSH pour modifier la configuration - un simple commit permet d’ajouter ou modifier la configuration d’un nœud Talos Kubernetes !
Cette approche nous permet de maintenir cette plateforme beaucoup plus sereinement. Il n’est plus nécessaire de documenter chaque modification dans un wiki poussiéreux ou de se demander qui a fait quoi et quand… Tout est centralisé sur le dépôt Git !
Ne ratez pas nos prochains articles DevOps et Cloud Native! Suivez Enix sur Linkedin!