Aller au contenu
Infrastructure as Code medium

Provisioners Packer

29 min de lecture

logo packer

Ce guide vous apprend à configurer et personnaliser vos images avec les provisioners Packer. Vous saurez installer des paquets, copier des fichiers, exécuter des scripts et utiliser Ansible pour automatiser la configuration de vos images machine.

  • Exécuter des scripts shell sur l’image en construction
  • Transférer des fichiers vers l’image
  • Lancer des commandes sur votre machine locale (pré/post build)
  • Intégrer Ansible pour un provisioning avancé
  • Combiner plusieurs provisioners de manière efficace

Prérequis : Packer installé (guide d’installation), connaissance des templates HCL2 (syntaxe HCL2) et des variables (variables et fonctions).

Un provisioner est un bloc de configuration Packer qui modifie l’image après son démarrage initial. Si le builder crée la machine (VM ou conteneur), le provisioner la configure : installation de paquets, copie de fichiers, exécution de scripts, etc.

Pensez aux provisioners comme à un chef cuisinier qui reçoit les ingrédients (l’image de base fournie par le builder) et les transforme en plat fini (l’image finale configurée). Chaque provisioner est une étape de la recette.

Les provisioners s’exécutent dans l’ordre où ils apparaissent dans le bloc build. Cette séquentialité est importante : vous ne pouvez pas utiliser un fichier uploadé avant de l’avoir transféré, ni exécuter un script Python avant d’avoir installé Python.

ProvisionerExécutionUsage principal
shellSur l’image distanteScripts, installation de paquets
fileTransfert vers l’imageConfiguration, scripts, données
shell-localSur la machine PackerPréparation, génération, nettoyage
ansibleContrôle depuis PackerConfiguration complexe, idempotence
breakpointPause le buildDebugging interactif

Le provisioner shell est le plus utilisé. Il exécute des commandes ou scripts sur l’image en construction, via SSH (pour les VMs) ou exec (pour les conteneurs Docker). C’est l’équivalent de se connecter à la machine et de taper des commandes.

La méthode la plus simple pour exécuter quelques commandes. Les commandes sont concaténées dans un script temporaire, uploadé vers l’image et exécuté. Elles partagent le même contexte shell, ce qui permet de définir des variables et de changer de répertoire.

01-shell-inline.pkr.hcl
build {
sources = ["source.docker.alpine"]
# Provisioner shell avec commandes inline
# Les commandes sont exécutées séquentiellement dans le même shell
provisioner "shell" {
inline = [
"echo '=== Début du provisioning ==='",
"apk update",
"apk add --no-cache curl jq vim",
"echo '=== Vérification des installations ==='",
"curl --version | head -1",
"jq --version",
"echo '=== Provisioning terminé ==='"
]
}
}

Le tableau suivant décrit les options principales du provisioner shell :

OptionTypeDescription
inlineliste de chaînesCommandes à exécuter (mutuellement exclusif avec script)
scriptchaîneChemin vers un script à uploader et exécuter
scriptsliste de chaînesPlusieurs scripts, exécutés dans l’ordre
environment_varsliste de chaînesVariables d’environnement au format KEY=value
inline_shebangchaîneShebang pour les commandes inline (défaut : /bin/sh -e)
execute_commandchaîneCommande d’exécution personnalisée
valid_exit_codesliste d’entiersCodes de retour acceptés (défaut : [0])

Pour des scripts plus complexes, séparez-les dans des fichiers. Cette approche améliore la lisibilité, permet la réutilisation et facilite les tests locaux. Le script est uploadé vers l’image puis exécuté.

Voici un exemple de template qui utilise des scripts externes :

02-shell-scripts.pkr.hcl
variable "environment" {
type = string
default = "development"
description = "Environnement cible (development, staging, production)"
}
build {
sources = ["source.docker.alpine"]
# Provisioner shell avec script externe
# Le script est uploadé puis exécuté sur la cible
provisioner "shell" {
script = "scripts/setup.sh"
# Variables d'environnement passées au script
environment_vars = [
"ENVIRONMENT=${var.environment}",
"BUILD_DATE=${timestamp()}",
"PACKER_BUILD_NAME=${build.name}"
]
}
# Second provisioner avec plusieurs scripts
# Les scripts sont exécutés dans l'ordre indiqué
provisioner "shell" {
scripts = [
"scripts/install-tools.sh",
"scripts/cleanup.sh"
]
}
}

Voici le script setup.sh utilisé. Notez l’utilisation du flag -e pour arrêter à la première erreur :

scripts/setup.sh
#!/bin/sh
# Script de configuration initiale
set -e # Arrêter en cas d'erreur
echo "=== Configuration de l'environnement ==="
echo "Environnement: ${ENVIRONMENT:-non défini}"
echo "Date du build: ${BUILD_DATE:-non défini}"
# Mise à jour des paquets
apk update
# Configuration spécifique à l'environnement
case "${ENVIRONMENT}" in
production)
echo ">>> Configuration production..."
;;
staging)
echo ">>> Configuration staging..."
;;
development)
echo ">>> Configuration développement..."
;;
esac
echo "=== Configuration terminée ==="

Packer injecte automatiquement certaines variables d’environnement dans chaque provisioner shell. Vous pouvez les utiliser dans vos scripts pour adapter le comportement selon le contexte :

VariableDescription
PACKER_BUILD_NAMENom du build Packer en cours
PACKER_BUILDER_TYPEType du builder (ex: docker, qemu, amazon-ebs)
PACKER_HTTP_ADDRAdresse du serveur HTTP Packer (si activé)

Ces variables sont utiles pour créer des scripts génériques qui s’adaptent au contexte. Par exemple, vous pourriez installer des outils de debugging uniquement pour les builds de développement.

Par défaut, si une commande retourne un code différent de 0, le provisioner échoue et le build s’arrête. Ce comportement est voulu : une erreur silencieuse créerait une image incomplète ou défectueuse.

Pour les commandes qui peuvent légitimement échouer (comme grep qui retourne 1 si aucune correspondance), utilisez valid_exit_codes :

provisioner "shell" {
inline = [
"grep 'pattern' /etc/config || true", # Méthode 1 : ignorer l'erreur
]
# Méthode 2 : déclarer les codes acceptés
valid_exit_codes = [0, 1]
}

Si votre provisioning nécessite un redémarrage (mise à jour du noyau, changement de configuration système), Packer sait gérer cette situation. Utilisez expect_disconnect et pause_before :

provisioner "shell" {
expect_disconnect = true
inline = [
"apt-get update && apt-get upgrade -y",
"reboot"
]
}
# Attendre que la machine redémarre avant de continuer
provisioner "shell" {
pause_before = "30s"
inline = [
"echo 'Machine redémarrée, on continue...'"
]
}

Le provisioner file transfère des fichiers ou répertoires de votre machine locale vers l’image en construction. Il est essentiel pour déployer des fichiers de configuration, des scripts ou des données statiques.

L’usage basique transfère un fichier unique. Le fichier source doit exister sur votre machine locale :

03-file-upload.pkr.hcl
build {
sources = ["source.docker.alpine"]
# Upload d'un fichier unique
provisioner "file" {
source = "files/config.json"
destination = "/tmp/config.json"
}
# Déplacer vers l'emplacement final avec les bonnes permissions
# (le provisioner file upload vers l'utilisateur de connexion)
provisioner "shell" {
inline = [
"mkdir -p /etc/myapp",
"mv /tmp/config.json /etc/myapp/config.json",
"chmod 644 /etc/myapp/config.json"
]
}
}

Le provisioner file peut aussi transférer des répertoires entiers. Attention au slash final dans le chemin source — il change le comportement :

SourceDestinationRésultat
files/app/app/Crée /app/app/ avec le contenu
files/app//app/Copie le contenu dans /app/ directement
# SANS slash : copie le répertoire lui-même
provisioner "file" {
source = "files/app" # Pas de slash final
destination = "/opt/" # Crée /opt/app/
}
# AVEC slash : copie le contenu du répertoire
provisioner "file" {
source = "files/app/" # Avec slash final
destination = "/app/" # Copie le contenu dans /app/
}

Au lieu d’uploader un fichier existant, vous pouvez générer le contenu dynamiquement avec l’attribut content. C’est utile pour injecter des variables de build :

variable "app_version" {
type = string
default = "1.0.0"
}
provisioner "file" {
content = jsonencode({
version = var.app_version
buildDate = timestamp()
builder = "packer"
})
destination = "/app/build-info.json"
}

Si un fichier est créé pendant le build (par exemple par un provisioner shell-local), il n’existe pas au moment de la validation. Utilisez l’attribut generated = true pour demander à Packer de ne vérifier l’existence qu’au moment de l’upload :

# Ce provisioner crée le fichier localement
provisioner "shell-local" {
inline = [
"echo '{\"generated\": true}' > /tmp/dynamic-config.json"
]
}
# Upload d'un fichier qui n'existait pas avant le build
provisioner "file" {
source = "/tmp/dynamic-config.json"
destination = "/app/config.json"
generated = true # Ne vérifie pas l'existence au validate
}

Le provisioner file peut aussi télécharger des fichiers depuis l’image vers votre machine locale. Changez simplement la direction :

provisioner "file" {
direction = "download"
source = "/var/log/install.log"
destination = "./build-logs/install.log"
}

Cette fonctionnalité est utile pour récupérer des logs de build, des artefacts générés ou des diagnostics.

Le provisioner shell-local exécute des commandes sur votre machine locale (celle qui exécute Packer), pas sur l’image en construction. Il est utile pour :

  • Préparer des fichiers avant de les uploader
  • Générer des configurations dynamiques
  • Nettoyer des fichiers temporaires après le build
  • Notifier des systèmes externes (Slack, CI/CD)

L’exemple suivant génère un fichier de métadonnées localement, puis l’uploade vers l’image :

04-shell-local.pkr.hcl
locals {
build_id = substr(uuidv4(), 0, 8)
build_dir = "/tmp/packer-build-${local.build_id}"
}
build {
sources = ["source.docker.alpine"]
# Exécuté LOCALEMENT avant de provisionner l'image
provisioner "shell-local" {
inline = [
"echo '=== Préparation locale ==='",
"echo 'Build ID: ${local.build_id}'",
"mkdir -p ${local.build_dir}",
"echo '{\"build_id\": \"${local.build_id}\"}' > ${local.build_dir}/metadata.json"
]
}
# Upload du fichier généré
provisioner "file" {
source = "${local.build_dir}/metadata.json"
destination = "/tmp/metadata.json"
generated = true
}
# Nettoyage local après le build
provisioner "shell-local" {
inline = [
"echo '=== Nettoyage local ==='",
"rm -rf ${local.build_dir}"
]
}
}

Avec l’option only_on, vous pouvez exécuter des commandes uniquement sur certains systèmes d’exploitation (celui qui exécute Packer) :

# Commandes Linux/macOS uniquement
provisioner "shell-local" {
only_on = ["linux", "darwin"]
inline = [
"uname -a",
"id"
]
}
# Commandes Windows uniquement
provisioner "shell-local" {
only_on = ["windows"]
inline = [
"echo %USERNAME%"
]
}

Le tableau suivant résume les différences entre shell et shell-local :

Aspectshellshell-local
Où s’exécuteSur l’image en constructionSur la machine Packer
Accès aux fichiersFichiers de l’imageFichiers locaux
Variables d’envPassées via environment_varsEnvironnement local
Usage typiqueConfiguration de l’imagePréparation/nettoyage
CommunicateurSSH, WinRM, execAucun (local)

Le provisioner ansible permet d’utiliser des playbooks Ansible pour configurer l’image. Ansible est particulièrement adapté pour :

  • Les configurations complexes et répétables
  • L’idempotence (ré-exécution sans effet secondaire)
  • La réutilisation de rôles existants
  • La gestion de configuration avancée

Le provisioner Ansible n’est pas inclus par défaut. Déclarez-le dans le bloc packer :

05-ansible.pkr.hcl
packer {
required_version = ">= 1.12.0"
required_plugins {
docker = {
version = ">= 1.0.0"
source = "github.com/hashicorp/docker"
}
ansible = {
version = ">= 1.1.0"
source = "github.com/hashicorp/ansible"
}
}
}

Exécutez packer init pour installer le plugin :

Fenêtre de terminal
packer init template.pkr.hcl

Voici un exemple complet utilisant Ansible avec Docker :

variable "environment" {
type = string
default = "development"
}
variable "app_version" {
type = string
default = "1.0.0"
}
source "docker" "ubuntu" {
image = "ubuntu:22.04"
commit = true
run_command = [
"-d", "-i", "-t",
"--name", "packer-ansible",
"{{.Image}}",
"/bin/bash"
]
}
build {
sources = ["source.docker.ubuntu"]
# Installer Python d'abord (prérequis Ansible)
provisioner "shell" {
inline = [
"apt-get update",
"apt-get install -y python3 python3-apt",
"ln -sf /usr/bin/python3 /usr/bin/python"
]
}
# Provisioner Ansible
provisioner "ansible" {
playbook_file = "ansible/playbook.yml"
extra_arguments = [
"--extra-vars",
"app_version=${var.app_version} environment=${var.environment}"
]
ansible_env_vars = [
"ANSIBLE_HOST_KEY_CHECKING=False",
"ANSIBLE_NOCOLOR=True"
]
groups = ["webservers"]
}
}

Le provisioner Ansible offre de nombreuses options de configuration :

OptionDescription
playbook_fileChemin du playbook à exécuter (obligatoire)
extra_argumentsArguments supplémentaires pour ansible-playbook
ansible_env_varsVariables d’environnement pour Ansible
groupsGroupes d’inventaire pour l’hôte
userUtilisateur Ansible (défaut : utilisateur Packer)
galaxy_fileFichier requirements.yml pour les rôles Galaxy
use_proxyUtiliser le proxy SSH Packer (défaut : true)

Voici un playbook compatible avec l’exemple précédent :

ansible/playbook.yml
---
- name: Configuration de l'image de base
hosts: all
become: true
vars:
app_version: "1.0.0"
environment: "development"
packages:
- curl
- wget
- jq
- vim
- git
tasks:
- name: Afficher les informations du build
ansible.builtin.debug:
msg: "Version: {{ app_version }} - Environnement: {{ environment }}"
- name: Installation des paquets de base
ansible.builtin.apt:
name: "{{ packages }}"
state: present
update_cache: true
- name: Création du répertoire application
ansible.builtin.file:
path: /app
state: directory
mode: "0755"
- name: Création du fichier de version
ansible.builtin.copy:
content: |
APP_VERSION={{ app_version }}
ENVIRONMENT={{ environment }}
dest: /app/version.env
mode: "0644"

Si Ansible ne fonctionne pas comme prévu, activez le mode verbose :

provisioner "ansible" {
playbook_file = "ansible/playbook.yml"
extra_arguments = ["-vvvv"] # Mode très verbose
}

Certaines options sont disponibles pour tous les provisioners. Elles permettent de contrôler l’exécution, gérer les erreurs et personnaliser le comportement.

Ces options contrôlent le timing d’exécution et la gestion des erreurs :

provisioner "shell" {
# Attendre 10 secondes avant d'exécuter
pause_before = "10s"
# Timeout après 5 minutes
timeout = "5m"
# Réessayer 3 fois en cas d'échec
max_retries = 3
inline = ["echo 'Executed'"]
}
OptionTypeDescription
pause_beforeduréeAttente avant l’exécution
timeoutduréeTemps maximum d’exécution
max_retriesentierNombre de tentatives en cas d’échec

Avec only, vous pouvez limiter un provisioner à certains builders. C’est utile quand vous avez plusieurs sources :

source "docker" "alpine" {
# ...
}
source "qemu" "ubuntu" {
# ...
}
build {
sources = ["source.docker.alpine", "source.qemu.ubuntu"]
# Uniquement pour Docker
provisioner "shell" {
only = ["docker.alpine"]
inline = ["echo 'Docker only'"]
}
# Uniquement pour QEMU
provisioner "shell" {
only = ["qemu.ubuntu"]
inline = ["echo 'QEMU only'"]
}
# Pour tous
provisioner "shell" {
inline = ["echo 'Both builders'"]
}
}

L’option override permet de personnaliser les options d’un provisioner selon le builder :

provisioner "shell" {
inline = ["echo 'Default script'"]
override = {
"docker.alpine" = {
inline = ["apk add --no-cache curl"]
}
"qemu.ubuntu" = {
inline = ["apt-get install -y curl"]
}
}
}

Les provisioners s’exécutent dans l’ordre où ils apparaissent dans le bloc build. Cette séquentialité permet de construire une logique de provisioning cohérente.

Un workflow de provisioning bien structuré suit généralement ces phases :

  1. Préparation locale (shell-local)

    Générer des fichiers de configuration, télécharger des dépendances, préparer l’environnement.

  2. Upload des fichiers (file)

    Transférer les configurations, scripts et données vers l’image.

  3. Installation des dépendances (shell)

    Mettre à jour le système et installer les paquets requis.

  4. Configuration (shell ou ansible)

    Configurer les services, créer les utilisateurs, appliquer les paramètres.

  5. Validation (shell)

    Vérifier que tout fonctionne : services démarrés, fichiers présents, permissions correctes.

  6. Nettoyage (shell)

    Supprimer les fichiers temporaires, vider les caches, réduire la taille de l’image.

  7. Nettoyage local (shell-local)

    Supprimer les fichiers temporaires sur la machine Packer.

Voici un exemple qui suit ce workflow :

06-exemple-complet.pkr.hcl
variable "environment" {
type = string
default = "development"
}
variable "app_version" {
type = string
default = "1.0.0"
}
locals {
build_id = substr(uuidv4(), 0, 8)
full_version = "${var.app_version}-${local.build_id}"
env_config = {
development = { debug = true, log_level = "debug" }
staging = { debug = false, log_level = "info" }
production = { debug = false, log_level = "warn" }
}
config = local.env_config[var.environment]
}
source "docker" "alpine" {
image = "alpine:3.19"
commit = true
}
build {
name = "complete-demo"
sources = ["source.docker.alpine"]
# Phase 1 : Préparation locale
provisioner "shell-local" {
inline = [
"mkdir -p /tmp/packer-${local.build_id}",
"cat > /tmp/packer-${local.build_id}/config.json << 'EOF'",
jsonencode({
version = local.full_version
environment = var.environment
debug = local.config.debug
log_level = local.config.log_level
}),
"EOF"
]
}
# Phase 2 : Installation des paquets
provisioner "shell" {
inline = [
"apk update",
"apk add --no-cache curl jq vim git"
]
}
# Phase 3 : Upload des fichiers
provisioner "file" {
source = "/tmp/packer-${local.build_id}/config.json"
destination = "/tmp/config.json"
generated = true
}
# Phase 4 : Configuration
provisioner "shell" {
environment_vars = [
"APP_VERSION=${local.full_version}",
"APP_ENV=${var.environment}"
]
inline = [
"mkdir -p /app/config",
"mv /tmp/config.json /app/config/",
"cat > /app/.env << EOF",
"APP_VERSION=$APP_VERSION",
"APP_ENV=$APP_ENV",
"EOF"
]
}
# Phase 5 : Validation
provisioner "shell" {
inline = [
"echo '=== Validation ==='",
"jq . /app/config/config.json",
"cat /app/.env",
"test -f /app/.env && echo 'OK: .env présent'"
]
}
# Phase 6 : Nettoyage image
provisioner "shell" {
inline = [
"rm -rf /tmp/* /var/cache/apk/*"
]
}
# Phase 7 : Nettoyage local
provisioner "shell-local" {
inline = [
"rm -rf /tmp/packer-${local.build_id}"
]
}
}

Pour debugger un build, utilisez le provisioner breakpoint. Il met en pause le build et attend que vous appuyiez sur Entrée pour continuer :

provisioner "shell" {
inline = ["apt-get update"]
}
# Pause ici pour inspecter l'état
provisioner "breakpoint" {
note = "Vérifiez que les paquets sont installés avant de continuer"
}
provisioner "shell" {
inline = ["apt-get install -y nginx"]
}

Pendant la pause, vous pouvez vous connecter à la machine (via SSH ou docker exec) pour inspecter son état.

Cette section couvre les problèmes courants rencontrés avec les provisioners.

Symptôme : Le build réussit mais l’image n’est pas configurée correctement.

Cause : Le script n’utilise pas set -e et les erreurs sont ignorées.

Solution : Ajoutez set -e au début de vos scripts ou utilisez inline_shebang = "/bin/sh -e".

Symptôme : Erreur “Bad source” lors de la validation.

Cause : Le fichier n’existe pas ou le chemin est incorrect.

Solutions :

  • Vérifiez que le fichier existe
  • Utilisez generated = true si le fichier est créé pendant le build
  • Vérifiez le chemin relatif par rapport au répertoire d’exécution de Packer

Symptôme : Erreur de permission lors de l’upload vers /etc/ ou /usr/.

Cause : Le provisioner file utilise l’utilisateur de connexion (souvent non-root).

Solution : Uploadez vers /tmp puis utilisez shell avec sudo pour déplacer le fichier.

Symptôme : Le build reste bloqué sur “Gathering Facts”.

Cause : Problème de proxy SSH.

Solution : Ajoutez use_proxy = false dans le provisioner Ansible.

ProblèmeSolution rapide
Script bash dans shinline_shebang = "/bin/bash -e"
Fichier généré non trouvégenerated = true
Permission deniedUpload vers /tmp puis sudo mv
Ansible freezeuse_proxy = false
Commande interactiveAjouter -y ou équivalent
  • Les provisioners configurent l’image après sa création par le builder
  • shell exécute des commandes sur l’image distante — utilisez set -e pour capturer les erreurs
  • file transfère des fichiers — attention au slash final pour les répertoires
  • shell-local exécute des commandes localement — idéal pour la préparation et le nettoyage
  • ansible permet des configurations complexes et idempotentes — nécessite Python sur la cible
  • Les provisioners s’exécutent dans l’ordre où ils apparaissent dans le template
  • Utilisez generated = true pour les fichiers créés pendant le build
  • Le workflow recommandé : préparation → upload → installation → configuration → validation → nettoyage

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.