Aller au contenu
Conteneurs & Orchestration medium

Quadlet : exécuter des conteneurs Podman comme services systemd

18 min de lecture

logo podman

Quadlet transforme des fichiers déclaratifs en unités systemd pour vos conteneurs Podman. Vous décrivez l’intention (image, ports, volumes), systemd gère le cycle de vie (démarrage, restart, logs).

À la fin de ce cours, vous saurez :

  • Démarrer un conteneur comme un service systemd
  • Gérer logs, restart et dépendances
  • Structurer un mini-stack (volume + réseau + conteneur)
  • Choisir entre rootless et rootful

Avant Quadlet, vous aviez deux options pour exécuter des conteneurs au démarrage :

  1. podman generate systemd : génère une unité .service avec la commande podman run complète. Problème : toute modification nécessite de régénérer le fichier.

  2. Écrire un .service à la main : fragile, difficile à maintenir, pas de gestion native des volumes/réseaux.

Quadlet apporte une approche déclarative :

ApprocheMaintenabilitéFonctionnalités
Script shell + cronFragileAucune
.service manuelMoyenneBasiques
podman generate systemdMoyenneComplètes mais figées
QuadletExcellenteComplètes et évolutives

Quadlet est maintenant l’approche recommandée par Red Hat pour la production.

  • Linux avec systemd
  • Podman 4.4+ (Quadlet intégré depuis cette version)
  • Connaissances de base de systemd (systemctl, journalctl)

Quadlet est un générateur systemd. Il s’exécute automatiquement :

  • Au boot du système
  • Après chaque systemctl daemon-reload

Il lit vos fichiers .container, .volume, .network et génère les unités .service correspondantes.

Quadlet : conversion .container vers .service systemd

~/.config/containers/systemd/
├── nginx.container
├── data.volume
└── app-network.network

Commandes avec --user :

Fenêtre de terminal
systemctl --user daemon-reload
systemctl --user start nginx
journalctl --user -u nginx

Quadlet reconnait plusieurs types de fichiers, chacun correspondant à un type de ressource Podman. L’extension du fichier détermine ce que Quadlet va créer :

ExtensionCe qu’il définitÉquivalent Podman
.containerUn conteneurpodman run
.volumeUn volume nommépodman volume create
.networkUn réseaupodman network create
.podUn podpodman pod create
.imageUne image à pullpodman pull
.kubeUn play kubepodman kube play
.buildUn build d’imagepodman build

Dans la pratique, vous utiliserez principalement .container, .volume et .network. Les autres types couvrent des cas d’usage plus spécifiques comme l’intégration avec des manifests Kubernetes (.kube) ou le build d’images en CI/CD (.build).

Pour inspecter l’unité systemd générée à partir de votre fichier Quadlet :

Fenêtre de terminal
# Rootless
systemctl --user cat nginx.service
# Rootful
systemctl cat nginx.service

Créons un serveur nginx comme service systemd.

  1. Créer le répertoire Quadlet (rootless)

    Fenêtre de terminal
    mkdir -p ~/.config/containers/systemd
  2. Créer le fichier nginx.container

    Fenêtre de terminal
    cat > ~/.config/containers/systemd/nginx.container << 'EOF'
    [Unit]
    Description=Serveur web Nginx
    [Container]
    Image=docker.io/library/nginx:alpine
    PublishPort=8080:80
    [Service]
    Restart=always
    [Install]
    WantedBy=default.target
    EOF
  3. Recharger systemd

    Fenêtre de terminal
    systemctl --user daemon-reload
  4. Démarrer le service

    Fenêtre de terminal
    systemctl --user start nginx
  5. Vérifier

    Fenêtre de terminal
    systemctl --user status nginx
    Résultat
    ● nginx.service - Serveur web Nginx
    Loaded: loaded (/home/bob/.config/containers/systemd/nginx.container; generated)
    Active: active (running) since Thu 2026-02-13 10:30:00 CET; 5s ago
    Main PID: 12345 (conmon)

Testez avec curl http://localhost:8080 — vous devriez voir la page d’accueil nginx.

Fenêtre de terminal
systemctl --user enable nginx

Un fichier .container se compose de 4 sections :

exemple.container
[Unit]
Description=Description du service
After=network-online.target
[Container]
Image=docker.io/library/nginx:alpine
PublishPort=8080:80
Volume=data:/var/www:Z
Environment=MA_VAR=valeur
ReadOnly=true
[Service]
Restart=always
TimeoutStartSec=300
[Install]
WantedBy=default.target

Cette section utilise la syntaxe standard systemd pour définir les métadonnées et les dépendances du service. Les directives les plus utiles :

DirectiveUsage
Description=Description affichée dans systemctl status
After=Démarre après ces unités
Requires=Dépendance forte (si l’autre échoue, celui-ci aussi)
Wants=Dépendance faible

Astuce dépendances : utilisez Requires= pour les dépendances critiques (base de données pour une application) et Wants= pour les dépendances optionnelles (monitoring, logs). After= ne garantit que l’ordre de démarrage, pas que le service dépendant est fonctionnel.

C’est le cœur de votre fichier Quadlet. Chaque directive correspond à une option de podman run, mais avec une syntaxe déclarative plus lisible :

Directive QuadletOption podman runExemple
Image=(image)nginx:alpine
PublishPort=-p8180:80
Volume=-vdata:/app:Z
Environment=-eDEBUG=true
EnvironmentFile=--env-file/etc/myapp/env
User=--user1000:1000
ReadOnly=--read-onlytrue
AddCapability=--cap-addNET_BIND_SERVICE
DropCapability=--cap-dropALL
SecurityLabelDisable=--security-opt label=disabletrue
Network=--networkapp-network.network
PodmanArgs=(options supplémentaires)--init --userns=keep-id
Exec=(commande)/bin/sh -c "sleep infinity"
AutoUpdate=label auto-updateregistry

Deux directives essentielles :

  • PodmanArgs= : permet de passer des options podman run non supportées directement par Quadlet. Utilisez-le pour --userns=keep-id (rootless) ou --security-opt.
  • Network= : référence un fichier .network du même répertoire. Quadlet gère automatiquement l’ordre de création.

Cette section contrôle le comportement systemd du service. Les paramètres les plus importants concernent la gestion des redémarrages et les timeouts :

DirectiveUsageValeur typique
Restart=Politique de restartalways, on-failure
RestartSec=Délai entre restarts5s
TimeoutStartSec=Timeout au démarrage300 (pour pull d’image)
TimeoutStopSec=Timeout à l’arrêt30

Pourquoi ces valeurs ?

  • Restart=always : en production, vous voulez que le service redémarre automatiquement après un crash ou un reboot.
  • RestartSec=5s : évite les boucles de redémarrage rapide en cas de problème persistant.
  • TimeoutStartSec=300 : 5 minutes pour permettre le pull d’images lourdes (plusieurs Go).

Définit quand le service doit démarrer :

[Install]
WantedBy=default.target # rootless
# ou
WantedBy=multi-user.target # rootful

Voici un service nginx prêt pour la production, avec les bonnes pratiques de sécurité :

~/.config/containers/systemd/nginx-prod.container
[Unit]
Description=Nginx production
After=network-online.target
[Container]
Image=docker.io/library/nginx:alpine
PublishPort=8080:80
# Sécurité : filesystem read-only + tmpfs pour les dossiers d'écriture
ReadOnly=true
Tmpfs=/tmp
Tmpfs=/var/cache/nginx
Tmpfs=/var/run
# Sécurité : capabilities minimales
DropCapability=ALL
AddCapability=NET_BIND_SERVICE
AddCapability=CHOWN
AddCapability=SETGID
AddCapability=SETUID
# Options supplémentaires via PodmanArgs
PodmanArgs=--security-opt no-new-privileges --init
# Volume pour les données (lecture seule)
Volume=web-content:/usr/share/nginx/html:ro,Z
[Service]
Restart=always
RestartSec=5s
TimeoutStartSec=300
TimeoutStopSec=30
[Install]
WantedBy=default.target

Vérifications :

Fenêtre de terminal
# Recharger et démarrer
systemctl --user daemon-reload
systemctl --user start nginx-prod
# Vérifier le statut
systemctl --user status nginx-prod
# Voir l'unité générée
systemctl --user cat nginx-prod.service
# Tester le restart automatique
podman kill $(podman ps -q --filter name=systemd-nginx-prod)
sleep 10
systemctl --user status nginx-prod # Doit être "active (running)"

En mode rootless, quelques points d’attention :

Fenêtre de terminal
# Créer le répertoire
mkdir -p ~/.config/containers/systemd
# Vos fichiers doivent être ici
ls ~/.config/containers/systemd/

En rootless, vous ne pouvez pas exposer de ports < 1024 sans configuration spéciale :

# ❌ Ne fonctionne pas en rootless
PublishPort=80:80
# ✅ Port > 1024
PublishPort=8080:80

Problème classique : le conteneur n’a pas accès aux fichiers de l’hôte.

Solution 1 : utiliser PodmanArgs=--userns=keep-id

[Container]
Image=nginx:alpine
Volume=./html:/usr/share/nginx/html:Z
PodmanArgs=--userns=keep-id

Solution 2 : utiliser le suffixe :U (chown automatique)

[Container]
Volume=myvolume:/data:U

Le mode rootless est le choix par défaut pour la sécurité (pas de privilèges root). Cependant, certaines situations nécessitent le mode rootful :

SituationRootlessRootful
Dev local
Service utilisateur
Port < 1024 nécessaire
Accès hardware (GPU, USB)
Performance réseau critique
Service système partagé

Règle de décision : commencez toujours en rootless. Basculez en rootful uniquement si vous avez une contrainte technique (port 80/443, GPU) ou organisationnelle (service partagé entre utilisateurs). Le passage en rootful se fait en plaçant vos fichiers dans /etc/containers/systemd/ au lieu de ~/.config/containers/systemd/.

Créons une stack complète : PostgreSQL + application web, avec volume et réseau dédiés.

  • Répertoire~/.config/containers/systemd/
    • app-network.network
    • pgdata.volume
    • postgres.container
    • webapp.container
app-network.network
[Unit]
Description=Réseau applicatif
[Network]
Subnet=10.89.0.0/24
Gateway=10.89.0.1
pgdata.volume
[Unit]
Description=Données PostgreSQL
[Volume]
# Options par défaut suffisent
postgres.container
[Unit]
Description=Base de données PostgreSQL
After=app-network.network pgdata.volume
[Container]
Image=docker.io/library/postgres:16-alpine
Network=app-network.network
Volume=pgdata.volume:/var/lib/postgresql/data:Z
# Variables d'environnement
Environment=POSTGRES_USER=app
Environment=POSTGRES_PASSWORD=secret
Environment=POSTGRES_DB=myapp
# Healthcheck
HealthCmd=pg_isready -U app -d myapp
HealthInterval=10s
HealthTimeout=5s
HealthRetries=3
[Service]
Restart=always
TimeoutStartSec=300
[Install]
WantedBy=default.target
webapp.container
[Unit]
Description=Application web
After=postgres.container
Requires=postgres.container
[Container]
Image=docker.io/myapp:latest
Network=app-network.network
PublishPort=8080:3000
Environment=DATABASE_URL=postgres://app:secret@systemd-postgres:5432/myapp
PodmanArgs=--init --userns=keep-id
[Service]
Restart=always
RestartSec=5s
[Install]
WantedBy=default.target
Fenêtre de terminal
# Recharger
systemctl --user daemon-reload
# Démarrer (l'ordre est géré par les dépendances)
systemctl --user start webapp
# Vérifier
systemctl --user status app-network pgdata postgres webapp

Cette stack illustre plusieurs mécanismes Quadlet importants :

DirectiveCe qu’elle fait
After=postgres.containerDémarre après PostgreSQL
Requires=postgres.containerSi PostgreSQL échoue, webapp s’arrête
Network=app-network.networkUtilise le réseau Quadlet
Volume=pgdata.volume:/pathUtilise le volume Quadlet

Comment Quadlet gère les ressources : quand vous référencez app-network.network dans Network=, Quadlet sait que c’est un fichier du même répertoire. Il crée le réseau systemd-app-network avant de démarrer le conteneur. Même logique pour les volumes.

Attention : les fichiers .network et .volume ne créent pas de services systemd visibles avec systemctl status. Ils créent les ressources Podman sous-jacentes (vérifiez avec podman network ls et podman volume ls).

Un bon nommage facilite le debug et la maintenance à long terme :

  • Fichiers : noms explicites (postgres.container, pas db.container). Quand vous aurez 10 services, vous regretterez les noms génériques.
  • Descriptions : rédigez-les pour systemctl status. “Base de données PostgreSQL 16 - production” est plus utile que “db”.
  • Un service = un fichier : Quadlet gère les dépendances entre fichiers. Évitez de tout mettre dans un seul fichier complexe.
[Service]
# Toujours redémarrer en production
Restart=always
# Délai entre les redémarrages (évite les boucles)
RestartSec=5s
# Timeout pour le pull d'image (images lourdes)
TimeoutStartSec=300
# Timeout pour l'arrêt propre
TimeoutStopSec=30
[Unit]
# Dépendance forte : si postgres échoue, webapp ne démarre pas
Requires=postgres.container
# Ordre : webapp démarre APRÈS postgres
After=postgres.container
# Dépendance faible : si le réseau n'existe pas, on essaie quand même
Wants=app-network.network
[Container]
# Filesystem en lecture seule
ReadOnly=true
Tmpfs=/tmp
# Capabilities minimales
DropCapability=ALL
AddCapability=NET_BIND_SERVICE
# Bloquer l'escalade de privilèges
PodmanArgs=--security-opt no-new-privileges --init

L’un des grands avantages de Quadlet : vos conteneurs bénéficient automatiquement de l’infrastructure de logs systemd. Plus besoin de podman logs — utilisez journalctl :

Fenêtre de terminal
# Statut rapide (conteneur actif ? PID ? mémoire ?)
systemctl --user status webapp
# Logs en temps réel (équivalent de podman logs -f)
journalctl --user -u webapp -f
# Logs depuis le dernier boot (utile après un redémarrage)
journalctl --user -u webapp -b
# Logs des 10 dernières minutes (debug incident récent)
journalctl --user -u webapp --since "10 minutes ago"

Avantage journalctl : les logs persistent après l’arrêt du conteneur et sont automatiquement rotatés par systemd.

  1. Vérifiez le répertoire

    Fenêtre de terminal
    # Rootless
    ls ~/.config/containers/systemd/
    # Rootful
    ls /etc/containers/systemd/
  2. Vérifiez l’extension

    Le fichier doit se terminer par .container, .volume, .network, etc.

  3. Rechargez systemd

    Fenêtre de terminal
    systemctl --user daemon-reload
  4. Vérifiez que l’unité existe

    Fenêtre de terminal
    systemctl --user list-unit-files | grep monservice

Cause 1 : image inexistante

Fenêtre de terminal
# Vérifier les logs
journalctl --user -u monservice -n 50
# Si l'image n'existe pas, la pull manuellement
podman pull docker.io/library/nginx:alpine

Cause 2 : port déjà utilisé

Fenêtre de terminal
# Vérifier quel processus utilise le port
ss -tlnp | grep 8080

Cause 3 : problème de permissions

Fenêtre de terminal
# Logs détaillés
journalctl --user -u monservice --no-pager
# Chercher "permission denied"

Solution 1 : ajouter --userns=keep-id

[Container]
PodmanArgs=--userns=keep-id
Volume=./data:/app/data:Z

Solution 2 : utiliser le suffixe :U

[Container]
Volume=myvolume:/app/data:U

Solution 3 : sur SELinux, ajouter :Z

[Container]
Volume=./data:/app/data:Z

”Le conteneur ne se connecte pas à la base de données”

Section intitulée « ”Le conteneur ne se connecte pas à la base de données” »

Vérifiez le nommage : Quadlet préfixe les conteneurs avec systemd-

# ❌ Incorrect
Environment=DATABASE_HOST=postgres
# ✅ Correct
Environment=DATABASE_HOST=systemd-postgres
Fenêtre de terminal
# Voir l'unité générée
systemctl --user cat monservice.service
# Tester la génération Quadlet manuellement
/usr/libexec/podman/quadlet --dryrun --user
# Voir les logs systemd pour Quadlet
journalctl --user -u systemd-generator -b

Ces templates couvrent les cas d’usage courants. Copiez-les, adaptez Image= et PublishPort=, et vous avez un service fonctionnel.

Le minimum viable pour démarrer rapidement. Adapté au développement local :

simple.container
[Unit]
Description=Mon service
[Container]
Image=docker.io/library/nginx:alpine
PublishPort=8080:80
[Service]
Restart=always
[Install]
WantedBy=default.target

Pour la production : filesystem read-only, capabilities minimales, pas d’escalade de privilèges. Utilisez ce template comme base pour tout service exposé au réseau :

hardened.container
[Unit]
Description=Service durci
[Container]
Image=docker.io/library/nginx:alpine
PublishPort=8080:80
ReadOnly=true
Tmpfs=/tmp
Tmpfs=/var/run
DropCapability=ALL
AddCapability=NET_BIND_SERVICE
PodmanArgs=--security-opt no-new-privileges --init --userns=keep-id
[Service]
Restart=always
RestartSec=5s
TimeoutStartSec=300
[Install]
WantedBy=default.target

Pour les services stateful (bases de données, caches). Ce template nécessite des fichiers .network et .volume complémentaires dans le même répertoire :

stateful.container
[Unit]
Description=Service avec données persistantes
After=mynetwork.network mydata.volume
[Container]
Image=docker.io/library/postgres:16-alpine
Network=mynetwork.network
Volume=mydata.volume:/var/lib/postgresql/data:Z
Environment=POSTGRES_PASSWORD=secret
HealthCmd=pg_isready
HealthInterval=30s
[Service]
Restart=always
TimeoutStartSec=300
[Install]
WantedBy=default.target
  • Quadlet = fichiers déclaratifs (.container, .volume, .network) → systemd gère le cycle de vie
  • Répertoires : ~/.config/containers/systemd/ (rootless) ou /etc/containers/systemd/ (rootful)
  • Workflow : créer fichier → daemon-reloadstartstatus
  • Dépendances : After= pour l’ordre, Requires= pour la criticité
  • Nommage : Quadlet préfixe avec systemd- (important pour les connexions réseau)
  • Debug : systemctl cat pour voir l’unité générée, journalctl pour les logs

Ce site vous est utile ?

Sachez que moins de 1% des lecteurs soutiennent ce site.

Je maintiens +700 guides gratuits, sans pub ni tracing. Aujourd'hui, ce site ne couvre même pas mes frais d'hébergement, d'électricité, de matériel, de logiciels, mais surtout de cafés.

Un soutien régulier, même symbolique, m'aide à garder ces ressources gratuites et à continuer de produire des guides de qualité. Merci pour votre appui.