Aller au contenu
CI/CD & Automatisation medium

Container API : manipuler les conteneurs avec Dagger

21 min de lecture

Logo Dagger

L’API Container est le cœur de Dagger. Elle permet de manipuler des conteneurs comme des objets : charger une image, exécuter des commandes, configurer l’environnement, puis récupérer les résultats. Ce guide couvre toutes les méthodes essentielles pour construire des pipelines CI/CD robustes.

Dans ce guide, vous apprendrez à :

  • Charger des images avec from_() et comprendre le système de cache
  • Exécuter des commandes avec with_exec() (simple et chaîné)
  • Configurer les variables d’environnement avec with_env_variable()
  • Définir le contexte d’exécution (with_workdir(), with_user())
  • Récupérer les sorties (stdout(), stderr(), exit_code())
  • Appliquer le pattern chaînage fluent pour des pipelines lisibles

La méthode from_() charge une image Docker de base depuis un registre (Docker Hub par défaut).

container = dag.container().from_("python:3.12-slim")
@function
async def image_info(self, image: str = "python:3.12-slim") -> str:
"""Charge une image et affiche ses informations."""
return await (
dag.container()
.from_(image)
.with_exec(["cat", "/etc/os-release"])
.stdout()
)

Tester la fonction :

Fenêtre de terminal
dagger call image-info --image="alpine:latest"

Sortie attendue :

NAME="Alpine Linux"
ID=alpine
VERSION_ID=3.21.3
...

Vous pouvez construire dynamiquement le tag de l’image :

@function
async def python_version(self, version: str = "3.12") -> str:
"""Vérifie la version Python dans un conteneur."""
image = f"python:{version}-slim"
return await (
dag.container()
.from_(image)
.with_exec(["python", "--version"])
.stdout()
)
Fenêtre de terminal
dagger call python-version --version=3.11
# Sortie : Python 3.11.14

Pour un registre privé avec authentification :

container = (
dag.container()
.with_registry_auth("registry.example.com", "username", dag.secret("REGISTRY_PASSWORD"))
.from_("registry.example.com/myapp:latest")
)

La méthode with_exec() exécute une commande dans le conteneur. Elle prend une liste d’arguments (pas une chaîne).

container = (
dag.container()
.from_("alpine:latest")
.with_exec(["echo", "Hello, Dagger!"])
)

Chaque with_exec() ajoute une couche au conteneur. Les commandes s’exécutent séquentiellement :

@function
async def multi_exec(self) -> str:
"""Installe curl et affiche sa version."""
return await (
dag.container()
.from_("alpine:latest")
.with_exec(["apk", "add", "--no-cache", "curl"])
.with_exec(["curl", "--version"])
.stdout()
)

Avantage du chaînage : chaque étape est cachée séparément. Si vous modifiez la dernière commande, les précédentes ne sont pas réexécutées.

Pour les pipes, redirections ou substitutions, utilisez sh -c :

@function
async def shell_command(self) -> str:
"""Compte les lignes avec un pipe."""
return await (
dag.container()
.from_("alpine:latest")
.with_exec(["sh", "-c", "echo 'ligne1\nligne2\nligne3' | wc -l"])
.stdout()
)
Fenêtre de terminal
dagger call shell-command
# Sortie : 3

Variables d’environnement avec with_env_variable()

Section intitulée « Variables d’environnement avec with_env_variable() »
@function
async def env_simple(self, name: str = "Dagger") -> str:
"""Définit et utilise une variable d'environnement."""
return await (
dag.container()
.from_("alpine:latest")
.with_env_variable("GREETING", f"Hello, {name}!")
.with_exec(["sh", "-c", "echo $GREETING"])
.stdout()
)
Fenêtre de terminal
dagger call env-simple --name="DevOps"
# Sortie : Hello, DevOps!

Vous pouvez définir autant de variables que nécessaire en chaînant les appels. Chaque with_env_variable() crée une nouvelle couche avec la variable définie. Les variables sont accessibles par toutes les commandes suivantes dans la chaîne.

@function
async def env_multiple(self) -> str:
"""Définit plusieurs variables d'environnement."""
return await (
dag.container()
.from_("alpine:latest")
.with_env_variable("APP_NAME", "my-app")
.with_env_variable("APP_ENV", "development")
.with_env_variable("APP_DEBUG", "true")
.with_exec(["sh", "-c", "echo $APP_NAME - $APP_ENV debug=$APP_DEBUG"])
.stdout()
)

Parfois, vous voulez construire une variable à partir d’autres variables existantes. Le paramètre expand=True active l’expansion des variables dans la valeur, similaire à ce que fait le shell. Sans ce paramètre, la chaîne ${BASE_PATH} serait interprétée littéralement.

@function
async def env_expand(self) -> str:
"""Utilise l'expansion de variables."""
return await (
dag.container()
.from_("alpine:latest")
.with_env_variable("BASE_PATH", "/app")
.with_env_variable("FULL_PATH", "${BASE_PATH}/config", expand=True)
.with_exec(["sh", "-c", "echo $FULL_PATH"])
.stdout()
)
/app/config
dagger call env-expand

Par défaut, les commandes s’exécutent à la racine du conteneur (/) avec l’utilisateur root. Dans un vrai pipeline, vous voulez souvent travailler dans un répertoire dédié (/app) avec un utilisateur non-root pour des raisons de sécurité. Dagger offre deux méthodes pour configurer ce contexte.

La méthode with_workdir() définit le répertoire courant pour les commandes suivantes. C’est l’équivalent du WORKDIR dans un Dockerfile ou du cd dans un script shell. Toutes les commandes chaînées après with_workdir() s’exécutent depuis ce répertoire.

@function
async def workdir_demo(self) -> str:
"""Définit le répertoire de travail."""
return await (
dag.container()
.from_("alpine:latest")
.with_exec(["mkdir", "-p", "/app/src"])
.with_workdir("/app/src")
.with_exec(["pwd"])
.stdout()
)
/app/src
dagger call workdir-demo

La méthode with_user() change l’utilisateur qui exécute les commandes suivantes. C’est l’équivalent de USER dans un Dockerfile. Cette pratique est recommandée pour la sécurité : ne pas exécuter vos applications en tant que root limite l’impact d’une éventuelle compromission.

@function
async def user_demo(self) -> str:
"""Change l'utilisateur d'exécution."""
return await (
dag.container()
.from_("alpine:latest")
.with_exec(["adduser", "-D", "myuser"])
.with_user("myuser")
.with_exec(["whoami"])
.stdout()
)
Fenêtre de terminal
dagger call user-demo
# Sortie : myuser

Dans un pipeline réaliste, vous combinez généralement les trois : un utilisateur dédié, un répertoire de travail propre et des variables d’environnement pour la configuration. L’ordre des appels est important : créez d’abord l’utilisateur, puis les répertoires avec les bonnes permissions, et enfin configurez le contexte d’exécution.

@function
async def context_full(self) -> str:
"""Combine tous les contextes."""
return await (
dag.container()
.from_("alpine:latest")
.with_exec(["adduser", "-D", "appuser"])
.with_exec(["mkdir", "-p", "/home/appuser/app"])
.with_exec(["chown", "-R", "appuser:appuser", "/home/appuser/app"])
.with_user("appuser")
.with_workdir("/home/appuser/app")
.with_env_variable("APP_HOME", "/home/appuser/app")
.with_exec(["sh", "-c", "echo User: $(whoami), Dir: $(pwd), Home: $APP_HOME"])
.stdout()
)

Après avoir exécuté des commandes, vous devez récupérer leurs résultats. Dagger sépare proprement la sortie standard (stdout), la sortie d’erreur (stderr) et le code de retour (exit_code). Cette séparation facilite le parsing des résultats et la détection des erreurs.

La méthode stdout() récupère tout ce que la commande a écrit sur sa sortie standard. C’est la méthode la plus utilisée pour obtenir le résultat d’une commande. Elle retourne une chaîne de caractères (souvent avec un \n final).

result = await container.with_exec(["echo", "Hello"]).stdout()
# result = "Hello\n"

La méthode stderr() récupère la sortie d’erreur de la commande. Les messages d’erreur, warnings et logs de debug sont souvent écrits sur stderr. Cette méthode est utile pour analyser pourquoi une commande a échoué ou pour capturer les messages de diagnostic.

@function
async def output_stderr(self) -> str:
"""Récupère la sortie d'erreur."""
return await (
dag.container()
.from_("alpine:latest")
.with_exec(["sh", "-c", "echo 'Erreur simulée' >&2"])
.stderr()
)

Par défaut, with_exec() échoue si le code de sortie est non-zéro. Pour capturer le code sans échouer, utilisez expect=dagger.ReturnType.ANY :

import dagger
@function
async def output_exit_code(self) -> int:
"""Récupère le code de sortie."""
return await (
dag.container()
.from_("alpine:latest")
.with_exec(["sh", "-c", "exit 42"], expect=dagger.ReturnType.ANY)
.exit_code()
)
Fenêtre de terminal
dagger call output-exit-code
# Sortie : 42

Le chaînage fluent (ou pattern builder) est une technique de programmation où chaque méthode retourne l’objet modifié, permettant d’enchaîner les appels sur une seule ligne. En Dagger, ce pattern est fondamental : vous construisez progressivement votre conteneur en chaînant les configurations, puis vous l’exécutez avec une méthode terminale.

Ce pattern offre deux avantages majeurs : la lisibilité (chaque ligne décrit une action) et la réutilisabilité (vous pouvez créer des bases partagées entre plusieurs fonctions).

Plutôt que de dupliquer la configuration de base dans chaque fonction, extrayez-la dans une fonction dédiée. Cette fonction retourne un Container (sans await) que d’autres fonctions peuvent étendre. C’est particulièrement utile quand plusieurs pipelines partagent la même image et les mêmes variables.

@function
def base_python(self, version: str = "3.12") -> dagger.Container:
"""Crée un conteneur Python de base réutilisable."""
return (
dag.container()
.from_(f"python:{version}-slim")
.with_env_variable("PYTHONDONTWRITEBYTECODE", "1")
.with_env_variable("PYTHONUNBUFFERED", "1")
.with_workdir("/app")
)

Une fois la base définie, vous pouvez l’étendre dans d’autres fonctions. Appelez simplement la méthode de base et continuez la chaîne. Chaque fonction peut ajouter ses propres commandes tout en bénéficiant de la configuration partagée.

@function
async def build_chain(self, version: str = "3.12") -> str:
"""Construit un conteneur par étapes chaînées."""
return await (
self.base_python(version)
.with_exec(["pip", "install", "--no-cache-dir", "requests"])
.with_exec(["python", "-c", "import requests; print(requests.__version__)"])
.stdout()
)
Fenêtre de terminal
dagger call build-chain --version=3.12
# Sortie : 2.32.5

Regroups ici les principaux bénéfices de cette approche comparée à l’écriture de scripts linéaires ou de configurations YAML :

AspectBénéfice
RéutilisationLa base est définie une fois, utilisée partout
LisibilitéChaque méthode décrit une action claire
CacheChaque étape est cachée séparément
TestabilitéChaque fonction est testable indépendamment
TypageL’IDE propose l’autocomplétion

Mettons en pratique tout ce que nous avons vu avec un pipeline de lint complet. Dans ce TP, vous allez créer une fonction qui analyse du code Python avec ruff, un linter ultra-rapide écrit en Rust. Ce cas d’usage réel illustre comment combiner images, commandes, montages de fichiers et récupération des sorties.

Le projet suit la structure standard d’un module Dagger Python. Le code source à analyser se trouve dans src/, et le pipeline Dagger dans .dagger/ :

  • Répertoirecontainer-api/
    • pyproject.toml
    • Répertoiresrc/
      • Répertoirehello_container/
        • init .py
        • bad_code.py
    • Répertoire.dagger/
      • Répertoiresrc/
        • Répertoirecontainer_api/
          • main.py

Cette fonction monte un répertoire source dans le conteneur et exécute ruff pour vérifier le code. Notez l’utilisation de with_mounted_directory() pour passer les fichiers locaux au conteneur, et de expect=dagger.ReturnType.ANY pour ne pas échouer si ruff trouve des erreurs de style.

@function
async def lint_check(self, source: dagger.Directory) -> str:
"""Vérifie le code Python avec ruff."""
return await (
dag.container()
.from_("python:3.12-slim")
.with_exec(["pip", "install", "--no-cache-dir", "ruff"])
.with_mounted_directory("/app", source)
.with_workdir("/app")
.with_exec(["ruff", "check", "."], expect=dagger.ReturnType.ANY)
.stdout()
)

Depuis la racine du projet, appelez la fonction en lui passant le répertoire source. Dagger monte automatiquement le contenu de ./src dans le conteneur à l’emplacement /app.

Fenêtre de terminal
dagger call lint-check --source=./src

Sortie avec erreurs :

F401 [*] `os` imported but unused
--> hello_container/bad_code.py:3:8
F401 [*] `sys` imported but unused
--> hello_container/bad_code.py:4:8
F841 Local variable `x` is assigned to but never used
--> hello_container/bad_code.py:13:5
Found 3 errors.

Un vrai pipeline de lint combine souvent plusieurs vérifications : analyse statique (erreurs, imports inutilisés) et formatage (style, indentation). Cette fonction montre comment réutiliser une base commune pour exécuter deux vérifications et combiner leurs résultats.

@function
async def lint_full(self, source: dagger.Directory) -> str:
"""Pipeline lint complet: check + format."""
base = (
dag.container()
.from_("python:3.12-slim")
.with_exec(["pip", "install", "--no-cache-dir", "ruff"])
.with_mounted_directory("/app", source)
.with_workdir("/app")
)
# Check
check_result = await (
base
.with_exec(["ruff", "check", ".", "--output-format=text"],
expect=dagger.ReturnType.ANY)
.stdout()
)
# Format check
format_result = await (
base
.with_exec(["ruff", "format", "--check", "."],
expect=dagger.ReturnType.ANY)
.stdout()
)
return f"=== LINT CHECK ===\n{check_result}\n=== FORMAT CHECK ===\n{format_result}"

Certaines pratiques courantes nuisent aux performances, à la maintenabilité ou au debugging de vos pipelines. Voici les erreurs les plus fréquentes et comment les corriger.

Regrouper toutes les commandes dans un seul sh -c désactive le cache granulaire de Dagger. Si une seule commande change, tout est réexécuté. De plus, en cas d’échec, vous ne savez pas quelle commande a échoué.

# ❌ Mauvais cache, difficile à debugger
.with_exec(["sh", "-c", "pip install ruff && ruff check . && ruff format ."])
# ✅ Chaque étape cachée séparément
.with_exec(["pip", "install", "ruff"])
.with_exec(["ruff", "check", "."])
.with_exec(["ruff", "format", "--check", "."])

Les chemins en dur comme /home/john/project rendent votre pipeline inutilisable par d’autres développeurs. Utilisez des paramètres avec des valeurs par défaut pour rester flexible.

# ❌ Non réutilisable
.with_workdir("/home/john/project")
# ✅ Paramétrable
def my_function(self, workdir: str = "/app"):
return dag.container().with_workdir(workdir)

3. Oublier expect pour les commandes qui peuvent échouer

Section intitulée « 3. Oublier expect pour les commandes qui peuvent échouer »

Par défaut, Dagger échoue si une commande retourne un code non-zéro. Pour les outils d’analyse (lint, tests), vous voulez souvent capturer le résultat même en cas d’erreur pour l’afficher à l’utilisateur.

# ❌ Échoue si ruff trouve des erreurs
.with_exec(["ruff", "check", "."])
# ✅ Capture le résultat même si erreurs
.with_exec(["ruff", "check", "."], expect=dagger.ReturnType.ANY)

Oublier await sur une méthode asynchrone est une erreur subtile : le code s’exécute sans erreur mais retourne un objet coroutine au lieu du résultat réel. Utilisez toujours await avec stdout(), stderr(), sync() et exit_code().

# ❌ Oublier await
result = container.stdout() # Retourne un coroutine, pas le résultat
# ✅ Toujours await pour les méthodes terminales
result = await container.stdout()

Les erreurs suivantes sont fréquentes lors de la prise en main de l’API Container. Ce tableau vous aide à identifier rapidement la cause et la solution.

ProblèmeCause probableSolution
TypeError: with_exec() argument must be a listChaîne passée au lieu d’une listeUtiliser ["cmd", "arg1"]
exit code: 1 inattenduCommande échouéeAjouter expect=dagger.ReturnType.ANY pour capturer l’erreur
Variable non définie dans shellPas de sh -cUtiliser ["sh", "-c", "echo $VAR"]
Permission deniedMauvais utilisateurVérifier with_user() et permissions
No such file or directoryworkdir inexistantCréer le répertoire avec with_exec(["mkdir", "-p", ...])
Image non trouvéeTag incorrectVérifier le format image:tag
  1. from_() charge une image — le underscore évite le conflit avec le mot-clé Python
  2. with_exec() prend une liste d’arguments, pas une chaîne
  3. Chaînez plusieurs with_exec() pour un meilleur cache
  4. sh -c est nécessaire pour les pipes et redirections shell
  5. with_env_variable() avec expand=True permet de référencer d’autres variables
  6. stdout(), stderr(), exit_code() récupèrent les résultats
  7. expect=dagger.ReturnType.ANY pour ne pas échouer sur un code non-zéro
  8. Pattern builder : retourner Container pour créer des bases réutilisables

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.