Ansible : créer votre premier playbook pour un VPS
Installez Ansible avec pipx sur Ubuntu 24.04, créez l’inventaire et sécurisez un VPS neuf. Corrigez aussi « Permission denied » et les erreurs sudo.
Ce que vous allez construire
Une machine de contrôle avec Ansible installé, et un ou plusieurs VPS Ubuntu 24.04 neufs, contenant uniquement l’image standard. À la fin, vous disposerez d’un fichier d’inventaire qui répertorie vos serveurs, d’un ping ad hoc qui prouve que l’authentification fonctionne de bout en bout, et d’un playbook qui exécute sous forme de code toute la checklist de préparation d’un nouveau VPS : un utilisateur de déploiement avec votre clé SSH, un sshd renforcé, fail2ban, les mises à niveau automatiques et un pare-feu qui autorise OpenSSH avant de tout refuser. Utilisez-le avec un serveur ou vingt. Exécutez-le deux fois : la deuxième exécution ne modifie rien. C’est précisément l’objectif.
Après quinze ans de provisioning de VPS, je peux vous décrire le schéma réel : tout le monde configure les cinq premiers serveurs à la main, puis perd un week-end sur le sixième parce que personne ne se souvient de ce qui a été fait sur les cinq premiers. Ce guide approfondit la gestion de plusieurs serveurs Linux. Consultez-le dès que vous vous surprenez à saisir le même apt install dans trois terminaux.
Ce qu’est réellement Ansible, en un paragraphe
Ansible fonctionne sans agent. Aucun daemon n’est installé sur les serveurs qu’il gère : la machine de contrôle se connecte en SSH standard, copie un petit module Python sur la cible, l’exécute, lit le JSON qu’il affiche, puis le supprime. La seule exigence côté cible est python3, présent sur toutes les images Ubuntu standard. Le terme important est idempotent. Il signifie simplement qu’une tâche décrit un état, et non une action. state: present pour un paquet signifie « vérifier qu’il est installé », et non « exécuter l’installeur ». Si l’état est déjà conforme, Ansible ne modifie rien et le signale comme ok au lieu de changed. Cette propriété constitue l’essentiel du produit. Elle permet de réexécuter un playbook sans risque. Ces réexécutions sûres transforment un script shell en infrastructure.
Prérequis et points à connaître
- Une machine de contrôle : votre ordinateur portable ou un petit VPS. Je pars du principe que vous utilisez Ubuntu 24.04 ; macOS fonctionne de la même manière une fois pipx installé avec Homebrew.
- Un ou plusieurs VPS cibles sous Ubuntu 24.04 sur KVM, accessibles avec root. Rien n’est installé sur ces machines.
- Une authentification SSH par clé vers chaque cible. Ansible utilise exactement les mêmes identifiants que votre commande
ssh. Sissh root@hostdemande un mot de passe, Ansible échoue. - Sous Ubuntu 24.04,
pip install ansibleéchoue avecerror: externally-managed-environment. C’est une politique volontaire de la distribution, pas une panne. Utilisez pipx. - Les espaces dans YAML font partie de la syntaxe. Une indentation incorrecte produit
mapping values are not allowed in this context, et la présence d’une tabulation est fatale. - Laissez une session SSH fonctionnelle ouverte sur chaque cible pendant que le playbook sécurise sshd. Chaque verrouillage dont j’ai aidé un client à sortir impliquait la fermeture de la dernière session « pour tester depuis une session propre ».
Étape 1 : installer Ansible sur la machine de contrôle avec pipx, pas avec pip
L’instinct classique consiste à exécuter pip3 install ansible. Sur une image 24.04 réellement vierge, cette commande échoue une étape plus tôt avec Command 'pip3' not found, but can be installed with: sudo apt install python3-pip, et installer pip ne fait que vous mener au véritable blocage :
pip3 install ansibleerror: externally-managed-environment
× This environment is externally managed
╰─> To install Python packages system-wide, try apt install
python3-xyz, where xyz is the package you are trying to
install.Ubuntu 24.04 marque le Python système comme géré en externe (PEP 668). pip ne peut donc pas entrer en conflit avec apt pour les mêmes fichiers. N’utilisez pas --break-system-packages : le nom de cette option est explicite. La solution propre est pipx. Il fournit à Ansible son propre virtualenv isolé et place les binaires dans votre PATH :
sudo apt update && sudo apt install -y pipx
pipx ensurepath
pipx install --include-deps ansibleOuvrez un nouveau shell après pipx ensurepath pour que la modification du PATH soit prise en compte. --include-deps n’est pas décoratif : le paquet ansible ne fournit aucun console script, ansible, ansible-playbook et les autres sont des points d’entrée de sa dépendance ansible-core. Sans cette option, pipx refuse donc l’installation avec No apps associated with package ansible or its dependencies. Installez également le paquet ansible, et non ansible-core seul. Le paquet complet inclut les collections communautaires, et ce playbook utilise des modules de deux d’entre elles (ansible.posix et community.general).
ansible --versionLe résultat correct commence par une ligne telle que ansible [core 2.19.x] et indique le Python utilisé ; toute version actuelle du cœur convient pour tout ce qui suit. ansible: command not found signifie plutôt que ~/.local/bin n’est pas encore dans votre PATH : ouvrez un nouveau shell ou utilisez source ~/.bashrc.
C’est toute l’installation. Les machines cibles ne reçoivent rien.
Étape 2 : accès par clé SSH à chaque cible
ssh-keygen -t ed25519 -C "ansible control"
ssh-copy-id root@10.0.0.10
ssh-copy-id root@10.0.0.20Vérifiez ensuite l’accès, une fois par hôte :
ssh root@10.0.0.10 true && echo okCette ligne a deux fonctions : elle confirme que l’authentification par clé fonctionne sans mot de passe et elle enregistre la clé de l’hôte dans known_hosts. Faites-le maintenant, car Ansible présente une clé d’hôte non enregistrée sous la forme d’une invite interactive au milieu d’une exécution. Cela ressemble exactement à un blocage.
Étape 3 : l’inventaire, INI d’abord, YAML quand il s’étoffe
L’inventaire est un fichier texte qui répertorie les machines qu’Ansible peut gérer. Créez inventory.ini dans un nouveau répertoire de projet :
[vps]
web1 ansible_host=10.0.0.10
web2 ansible_host=10.0.0.20
[vps:vars]
ansible_user=rootweb1 est un alias que vous choisissez. C’est le nom qui apparaît dans la sortie et que vous ciblez avec --limit web1. ansible_host est l’adresse réelle. [vps] est un groupe, et [vps:vars] définit des variables pour chaque hôte de ce groupe ; ansible_user indique le compte utilisé par Ansible pour se connecter. Ajoutez à côté un ansible.cfg pour ne plus avoir à saisir -i :
[defaults]
inventory = inventory.iniAnsible lit ansible.cfg depuis le répertoire courant. Pour utiliser le même inventaire au format YAML, enregistrez-le sous inventory.yml et indiquez plutôt ce nom à ansible.cfg. C’est le format que vous préférerez lorsque chaque hôte possède plusieurs variables :
vps:
hosts:
web1:
ansible_host: 10.0.0.10
web2:
ansible_host: 10.0.0.20
vars:
ansible_user: rootLes deux formats sont équivalents. INI est plus facile à lire rapidement avec deux serveurs ; YAML s’adapte mieux à vingt serveurs. Choisissez-en un et n’y pensez plus.
Étape 4 : commandes ad hoc, le pong vert qui confirme que tout fonctionne
ansible all -m pingIl ne s’agit pas d’ICMP. Le module ping effectue une répétition générale complète : connexion SSH, copie du module, exécution de Python sur la cible, puis nettoyage. Le résultat attendu est vert, avec un bloc par hôte :
web1 | SUCCESS => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3"
},
"changed": false,
"ping": "pong"
}Le résultat vert de SUCCESS signifie que l’authentification, l’interpréteur Python et le transport fonctionnent. Le playbook fonctionnera donc lui aussi. Le résultat rouge de UNREACHABLE! signifie que le transport a échoué avant l’exécution du moindre module. La chaîne exacte et la solution figurent dans la section consacrée aux modes d’échec ci-dessous. Voici deux autres commandes ad hoc utiles :
ansible all -a "uptime"
ansible all -m apt -a "update_cache=true upgrade=dist" --becomeLes commandes ad hoc servent aux opérations ponctuelles et aux vérifications. Toute commande que vous exécuteriez deux fois doit être intégrée à un playbook.
Étape 5 : premier playbook, la checklist d’un nouveau VPS sous forme de code
Voici tout ce que vous feriez manuellement pendant les dix premières minutes sur un nouveau serveur. Enregistrez-le sous site.yml :
---
- name: Baseline a fresh Ubuntu VPS
hosts: vps
become: true
vars:
deploy_user: deploy
deploy_pubkey: "{{ lookup('file', '~/.ssh/id_ed25519.pub') }}"
baseline_packages:
- fail2ban
- unattended-upgrades
- ufw
baseline_services:
- fail2ban
- unattended-upgrades
tasks:
- name: Create the deploy user
ansible.builtin.user:
name: "{{ deploy_user }}"
groups: sudo
append: true
shell: /bin/bash
- name: Install the deploy user's SSH key
ansible.posix.authorized_key:
user: "{{ deploy_user }}"
key: "{{ deploy_pubkey }}"
- name: Passwordless sudo for the deploy user
ansible.builtin.copy:
dest: /etc/sudoers.d/deploy
content: "{{ deploy_user }} ALL=(ALL) NOPASSWD:ALL\n"
mode: "0440"
validate: /usr/sbin/visudo -cf %s
- name: Install baseline packages
ansible.builtin.apt:
name: "{{ baseline_packages }}"
state: present
update_cache: true
- name: Enable and start baseline services
ansible.builtin.service:
name: "{{ item }}"
state: started
enabled: true
loop: "{{ baseline_services }}"
- name: Harden sshd with a drop-in
ansible.builtin.copy:
dest: /etc/ssh/sshd_config.d/00-hardening.conf
content: |
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin prohibit-password
X11Forwarding no
mode: "0644"
validate: /usr/sbin/sshd -t -f %s
notify: Restart ssh
- name: Allow OpenSSH through ufw
community.general.ufw:
rule: allow
name: OpenSSH
- name: Enable ufw with default deny
community.general.ufw:
state: enabled
policy: deny
handlers:
- name: Restart ssh
ansible.builtin.service:
name: ssh
state: restartedVoici les lignes qu’il faut comprendre plutôt que copier :
Les variables se trouvent sous vars: et sont référencées avec "{{ deploy_user }}". Entourez toute l’expression de guillemets lorsqu’une valeur commence par une accolade, sinon l’analyseur YAML l’interprète incorrectement. lookup('file', ...) lit votre clé publique sur la machine de contrôle au moment de l’exécution. Le playbook ne contient donc aucun élément de clé.
La boucle. loop: "{{ baseline_services }}" exécute la tâche sur le service une fois par élément, et la sortie affiche chaque élément sur sa propre ligne. Notez que la tâche apt reçoit toute la liste des paquets en une seule fois. Une seule transaction apt est plus rapide et constitue le modèle recommandé pour les paquets. Les boucles servent aux modules qui traitent réellement un seul élément à la fois.
Le handler est le concept à retenir. notify: Restart ssh ne signifie pas « redémarrer ssh maintenant ». Il met le handler en attente. Celui-ci s’exécute une fois à la fin du play, et uniquement si la tâche qui l’a déclenché a effectivement signalé changed. Si vous relancez le playbook demain, le drop-in est déjà correct, la tâche de copie signale ok et sshd n’est jamais redémarré. La ligne validate: assure la sécurité du déclenchement : sshd vérifie le fichier avant de remplacer l’ancien. Une faute de frappe fait donc échouer la tâche au lieu de casser le daemon.
PermitRootLogin prohibit-password, et non no, volontairement. Ce playbook se connecte en tant que root avec une clé. prohibit-password désactive les connexions root par mot de passe tout en gardant la vôtre active. Une fois l’utilisateur de déploiement validé (ssh deploy@10.0.0.10 sudo true, l’adresse réelle, puisque web1 n’est qu’un alias connu d’Ansible), remplacez ansible_user=deploy dans l’inventaire et durcissez-le avec no lors d’une exécution ultérieure. Appliquez le durcissement dans un ordre qui ne puisse pas vous bloquer l’accès.
Le préfixe 00- est important. Pour la plupart des mots-clés, sshd respecte la première occurrence qu’il analyse. Le sshd_config d’Ubuntu inclut sshd_config.d/*.conf dans l’ordre lexical, avant son propre contenu. Les images cloud Ubuntu 24.04 fournissent déjà un 60-cloudimg-settings.conf dans ce répertoire. Les fournisseurs qui activent les connexions par mot de passe avec cloud-init ajoutent un 50-cloud-init.conf contenant PasswordAuthentication yes. En nommant le nôtre 00-hardening.conf, il est classé en premier et prend le dessus sur les deux autres.
L’ordre des tâches assure la sécurité du pare-feu. Allow OpenSSH s’exécute avant Enable ufw avec une stratégie de refus. Ansible exécute les tâches strictement dans l’ordre indiqué. L’ouverture existe donc avant l’activation du pare-feu. fail2ban n’a besoin d’aucune configuration pour être utile ici : ses valeurs par défaut sur Ubuntu surveillent sshd immédiatement. Le fonctionnement réel des jails et les paramètres à ajuster sont expliqués dans le guide fail2ban sur Ubuntu 24.04.
Étape 6 : effectuer un dry run avec --check, puis lancer réellement le playbook
ansible-playbook site.yml --checkLe mode check se connecte, calcule les actions qu’il effectuerait et ne modifie rien. Lisez le nombre de changed= dans le PLAY RECAP en bas de la sortie : il indique le nombre de tâches qui modifieraient chaque hôte. Une limite structurelle importante concerne les cas où une tâche ultérieure dépend des modifications d’une tâche précédente. L’image serveur standard d’Ubuntu fournit déjà ufw. Ce playbook s’exécute donc proprement en dry run. En revanche, sur une image minimale qui n’inclut pas ufw, les tâches ufw échouent en mode check, car ce mode n’installe jamais réellement le paquet et le module n’a alors rien à appeler. Il s’agit d’une limite des dry runs, pas d’un bug dans votre playbook. Lorsque le plan vous convient :
ansible-playbook site.ymlChaque tâche affiche une ligne par hôte : jaune changed, vert ok. Le récapitulatif doit être le suivant :
PLAY RECAP *********************************************************************
web1 : ok=10 changed=9 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web2 : ok=10 changed=9 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0Ces dix ok correspondent à la collecte des facts, aux huit tâches et au handler. Votre changed peut différer du mien d’une ou deux unités : l’image standard d’Ubuntu fournit déjà ufw et unattended-upgrades, et fail2ban démarre dès que apt l’installe. Une tâche peut donc légitimement afficher ok dès sa première exécution, car l’état qu’elle déclare est déjà présent. Les nombres qui doivent être égaux à zéro sont unreachable et failed. Une remarque concernant become: true : c’est une formalité tant que vous vous connectez en tant que root. Dès que vous remplacez ansible_user par deploy, sudo devient réellement nécessaire, et le fichier sudoers NOPASSWD installé par ce playbook évite que -K vous soit demandé sur la ligne de commande. Sans ce fichier, vous obtenez Missing sudo password, comme expliqué ci-dessous.
Étape 7 : l’exécuter deux fois pour observer l’idempotence
Exécutez immédiatement la même commande :
web1 : ok=9 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0changed=0 et ok ont diminué de un, car le handler non notifié n’a jamais été exécuté. Rien n’a été réinstallé, sshd n’a pas été redémarré et ufw n’a pas été modifié. C’est ce qui fait du playbook un outil d’audit autant qu’un outil de provisionnement : ajoutez web3 à l’inventaire le mois prochain, puis relancez-le. Le nouveau serveur sera configuré et les anciens seront vérifiés. Une valeur changed non nulle sur un serveur que vous n’avez pas modifié indique une dérive de configuration. Elle signifie que quelqu’un a modifié manuellement un élément qui aurait dû l’être dans le playbook.
À partir de là, le schéma se poursuit. Le prochain playbook utile met en place un VPN WireGuard sur le même VPS et renforce la règle ufw afin que SSH ne réponde que sur le tunnel. Ensuite, écrivez-en un autre pour installer Docker et Compose sur chaque serveur applicatif. Lorsque site.yml dépasse trois écrans, répartissez-le dans des rôles, mais pas avant.
Modes d’échec et messages affichés
UNREACHABLE avec Permission denied.
web1 | UNREACHABLE! => {
"changed": false,
"msg": "Failed to connect to the host via ssh: root@10.0.0.10: Permission denied (publickey).",
"unreachable": true
}Le transport SSH a échoué avant l’exécution de tout module : ansible_user est incorrect, la clé n’a jamais été copiée sur cet hôte ou ce n’est pas la bonne clé qui est proposée. Reproduisez le problème avec ssh root@10.0.0.10, puis utilisez ssh -v pour voir quelles clés ont été proposées. Si la connexion SSH par mot de passe fonctionne, mais pas Ansible, vous avez ignoré ssh-copy-id.
Mot de passe sudo manquant.
web1 | FAILED! => {
"msg": "Missing sudo password"
}Vous avez défini become: true, vous vous êtes connecté avec un utilisateur qui n’est pas root et cet utilisateur doit fournir un mot de passe pour sudo. Ajoutez -K (--ask-become-pass) à la ligne de commande ou donnez à l’utilisateur une entrée sudoers NOPASSWD. C’est précisément la raison pour laquelle le playbook en installe une pour deploy avant que vous ne l’utilisiez.
error: externally-managed-environment. Vous avez exécuté pip sur le Python système d’Ubuntu 24.04. C’est traité à l’étape 1 : utilisez pipx, pas pip, et pas --break-system-packages.
mapping values are not allowed in this context.
ERROR! Syntax Error while loading YAML.
mapping values are not allowed in this contextIl s’agit presque toujours d’un problème d’indentation : une clé se trouve au mauvais niveau ou il manque une espace après deux-points. Le numéro de ligne indiqué se trouve près de l’erreur, mais pas nécessairement sur celle-ci. Vérifiez aussi la ligne précédente. Son message associé, found character '\t' that cannot start any token, signifie qu’une tabulation s’est glissée dans le fichier ; YAML les interdit. Faites de ansible-playbook site.yml --syntax-check un réflexe avant chaque exécution et configurez votre éditeur pour utiliser une indentation de deux espaces dans les fichiers YAML.
/usr/bin/python3: not found. Ce problème est rare avec les images Ubuntu 24.04 standard, mais fréquent avec les images minimales ou netboot : l’exécution du module échoue parce que la cible ne dispose pas de Python. Installez-le avec le module raw, le seul module qui ne nécessite rien sur l’hôte distant : ansible all -m raw -a "apt-get update && apt-get install -y python3" --become, puis relancez le playbook.
FAQ
Dois-je installer Ansible sur les serveurs qu’il gère ?
Non. Ansible fonctionne sans agent : la machine de contrôle envoie de petits modules Python via SSH, les exécute, puis les supprime. Une cible a seulement besoin de python3 et d’un accès SSH, déjà présents dans les images Ubuntu standard. La seule installation de ce guide s’effectue sur votre machine de contrôle.
Pourquoi Ansible affiche-t-il « Permission denied (publickey) » ?
Le bloc UNREACHABLE! avec Permission denied (publickey) indique que l’authentification SSH a échoué avant qu’Ansible n’exécute quoi que ce soit. Vérifiez que ansible_user dans l’inventaire correspond bien au compte que vous avez configuré, que vous avez exécuté ssh-copy-id vers cet hôte et qu’une commande ssh user@host simple ouvre une session sans mot de passe. La correction de la commande ssh simple corrige aussi Ansible, car les deux utilisent le même transport.
Que signifie idempotent dans Ansible ?
Une tâche déclare un état souhaité : « ce paquet est présent » ou « cette ligne figure dans ce fichier », plutôt qu’une action à effectuer. Si l’état est déjà conforme, Ansible ne fait rien et indique ok au lieu de changed. C’est pourquoi l’exécution d’un playbook deux fois affiche changed=0 la deuxième fois et pourquoi une nouvelle exécution constitue un audit sûr plutôt qu’une réinstallation risquée.
Dois-je utiliser pip ou pipx pour installer Ansible sur Ubuntu 24.04 ?
pipx. Ubuntu 24.04 marque le Python système comme géré en externe ; pip install ansible échoue donc volontairement avec error: externally-managed-environment. pipx install --include-deps ansible installe Ansible dans un virtualenv isolé et ajoute proprement ansible, ansible-playbook et les autres commandes à votre PATH.
Quelle est la différence entre les paquets ansible et ansible-core ?
ansible-core est le moteur, avec uniquement les modules ansible.builtin. Le paquet ansible regroupe le cœur et les collections communautaires sélectionnées, notamment ansible.posix (le module authorized_key) et community.general (le module ufw), tous deux utilisés dans ce guide. Commencez par le paquet complet ; passez à core avec des collections choisies manuellement uniquement si vous avez une raison de le faire.