AnsibleLinuxIntermédiaireConfigurationAdministration système

Gérer la configuration des serveurs avec Ansible

Jerry RajaonarivoMis à jour le 28 sept. 20269 min de lecture

Ceci couvre comment structurer les inventaires, rôles et playbooks Ansible pour que la configuration des serveurs reste idempotente et relisable, avec un playbook de durcissement SSH et Nginx comme exemple fil rouge, plus des secrets chiffrés avec Vault.

Inventaire : hôtes statiques plus group_vars/host_vars

Résistez à l'envie de mettre les valeurs de configuration directement dans les playbooks. L'inventaire indique à Ansible quels hôtes existent et comment ils sont regroupés ; group_vars et host_vars indiquent ce qui est vrai pour chaque groupe ou chaque hôte. Cette séparation permet au même playbook de tourner sans risque contre la staging et contre la production.

ini
# inventory/production.ini
[web]
web-01.example.internal
web-02.example.internal
 
[db]
db-01.example.internal
 
[web:vars]
nginx_worker_connections=2048
 
[production:children]
web
db
bash
inventory/
├── production.ini
├── staging.ini
├── group_vars/
│   ├── all.yml
│   ├── web.yml
│   └── db/
│       ├── vars.yml
│       └── vault.yml
└── host_vars/
    └── db-01.example.internal.yml

group_vars/all.yml contient les valeurs valables partout (serveurs NTP, clé SSH d'administration, durée de rétention des logs). group_vars/web.yml contient les valeurs valables pour chaque node web (réglages Nginx, port de l'application). host_vars/<hostname>.yml sert pour les cas réellement particuliers. Et si ce fichier commence à accumuler plus de deux ou trois clés, c'est généralement le signe que l'hôte n'a pas sa place dans son groupe actuel.

Structure des rôles : une responsabilité par rôle

Un rôle est l'unité de réutilisation. Chacun doit faire exactement une chose : « durcir SSH » ou « installer et configurer Nginx », et non « monter un serveur web », formulation qui finit inévitablement par grossir en un tas sans structure.

bash
roles/
└── nginx/
    ├── tasks/
    │   └── main.yml
    ├── handlers/
    │   └── main.yml
    ├── templates/
    │   └── nginx.conf.j2
    ├── defaults/
    │   └── main.yml
    └── files/
        └── snakeoil-fallback.conf

defaults/main.yml fixe des valeurs raisonnables que l'appelant peut surcharger depuis group_vars. tasks/main.yml contient le travail proprement dit. handlers/main.yml contient les actions qui ne s'exécutent que lorsque quelque chose a réellement changé.

Un playbook réel : durcissement SSH et Nginx

Voici un exemple allégé mais fonctionnel qui couvre les deux besoins : verrouiller SSH et déployer Nginx à partir d'un template.

yaml
# playbooks/site.yml
---
- name: Harden SSH and configure web servers
  hosts: web
  become: true
  roles:
    - ssh_hardening
    - nginx
yaml
# roles/ssh_hardening/tasks/main.yml
---
- name: Deploy SSH hardening drop-in
  ansible.builtin.copy:
    dest: /etc/ssh/sshd_config.d/00-hardening.conf
    content: |
      # Géré par Ansible, ne pas modifier à la main
      PasswordAuthentication no
      KbdInteractiveAuthentication no
      PermitRootLogin no
    owner: root
    group: root
    mode: '0644'
    validate: '/usr/sbin/sshd -t -f %s'
  notify: Restart ssh
 
- name: Check the effective sshd configuration
  ansible.builtin.command: /usr/sbin/sshd -T
  register: sshd_effective
  changed_when: false
  when: not ansible_check_mode   # en --check, le drop-in n'est pas encore écrit
  failed_when: >-
    sshd_effective.rc != 0 or
    'passwordauthentication no' not in sshd_effective.stdout or
    'permitrootlogin no' not in sshd_effective.stdout
yaml
# roles/ssh_hardening/handlers/main.yml
---
- name: Restart ssh
  ansible.builtin.service:
    name: ssh   # l'unité s'appelle "ssh" sur Debian/Ubuntu, "sshd" sur la famille RHEL
    state: restarted

Pourquoi un drop-in plutôt qu'une modification de /etc/ssh/sshd_config avec lineinfile ? Sur Debian 12 et Ubuntu 22.04+, la configuration principale commence par Include /etc/ssh/sshd_config.d/*.conf, et sshd garde la première valeur lue pour chaque mot-clé. Sur les images cloud Ubuntu, cloud-init écrit /etc/ssh/sshd_config.d/50-cloud-init.conf, et selon l'image et son réglage ssh_pwauth, ce fichier peut contenir PasswordAuthentication yes. Dans ce cas, un PasswordAuthentication no ajouté dans le fichier principal est lu après coup et ignoré sans avertissement. En nommant le drop-in 00-hardening.conf, on le fait charger en premier, avant le fichier de cloud-init et tout ce qui suit. Vous pouvez aussi supprimer le fichier de cloud-init ou mettre ssh_pwauth: false dans vos user-data cloud-init, mais le drop-in à petit numéro continue de fonctionner quoi qu'on dépose d'autre dans ce répertoire.

validate exécute sshd -t sur le nouveau drop-in seul avant de le mettre en place, ce qui détecte une faute de frappe dans le fichier. Il ne voit pas comment le drop-in se combine avec le reste de la configuration, donc la deuxième tâche vérifie la configuration fusionnée avec sshd -T et fait échouer le play si les réglages ne sont pas appliqués. notify met le handler en file d'attente, et les handlers ne s'exécutent qu'à la fin du play, une seule fois, quel que soit le nombre de tâches qui les ont notifiés. La vérification passe donc toujours avant le redémarrage de sshd, et en cas d'échec, cet hôte ne redémarre pas sshd (corrigez le fichier et relancez avant que quoi que ce soit ne redémarre sshd). C'est important quand le service que vous modifiez est celui par lequel vous êtes connecté en SSH.

Le rôle Nginx fonctionne de la même façon, avec un template Jinja2 au lieu d'un fichier statique :

yaml
# roles/nginx/tasks/main.yml
---
- name: Install nginx
  ansible.builtin.apt:
    name: nginx
    state: present
    update_cache: true
 
- name: Deploy nginx configuration
  ansible.builtin.template:
    src: nginx.conf.j2
    dest: /etc/nginx/nginx.conf
    owner: root
    group: root
    mode: '0644'
    validate: '/usr/sbin/nginx -t -c %s'
  notify: Reload nginx
 
- name: Ensure nginx is running and enabled
  ansible.builtin.service:
    name: nginx
    state: started
    enabled: true
yaml
# roles/nginx/defaults/main.yml
---
nginx_worker_connections: 1024
nginx_keepalive_timeout: 65
nginx_document_root: /var/www/html
yaml
# roles/nginx/handlers/main.yml
---
- name: Reload nginx
  ansible.builtin.service:
    name: nginx
    state: reloaded
jinja
# roles/nginx/templates/nginx.conf.j2
user www-data;
worker_processes auto;
 
events {
    worker_connections {{ nginx_worker_connections }};
}
 
http {
    include /etc/nginx/mime.types;
    default_type application/octet-stream;
 
    server_tokens off;
    keepalive_timeout {{ nginx_keepalive_timeout }};
 
    server {
        listen 80;
        server_name {{ inventory_hostname }};
        root {{ nginx_document_root }};
 
        location / {
            try_files $uri $uri/ =404;
        }
    }
}

Chaque variable utilisée par le template a une valeur par défaut dans defaults/main.yml, et le nginx_worker_connections=2048 de l'inventaire la surcharge pour le groupe web. Ne retirez pas l'include de mime.types. Sans lui, tous les fichiers sont servis avec le même type par défaut (text/plain si default_type n'est pas défini), et les navigateurs refusent d'appliquer des feuilles de style et des scripts servis ainsi. Le chemin est absolu parce que validate exécute nginx -t sur une copie temporaire du fichier située hors de /etc/nginx, et que nginx résout les includes relatifs par rapport au répertoire du fichier de configuration qu'on lui passe. Un include mime.types; relatif ferait échouer la validation.

Le module template rend le Jinja2 avec les variables de l'hôte, compare le résultat à ce qui existe déjà sur le disque, et ne remonte un changement (et ne déclenche notify) que si le fichier généré diffère réellement. Relancez ce playbook une seconde fois sans rien changer en amont : chaque tâche doit remonter ok, jamais changed. Si ce n'est pas le cas, une partie du rôle n'est pas encore vraiment idempotente.

Dégainer shell ou command avant de vérifier s'il existe un module adapté, et vous perdez l'idempotence par défaut. Ansible n'a aucun moyen de savoir si shell: nginx -s reload doit s'exécuter à nouveau, donc il l'exécute à chaque fois et remonte changed quel que soit l'état réel. Si shell est vraiment incontournable, associez-le à creates, removes, ou à une condition changed_when pour que la tâche puisse honnêtement indiquer si elle a fait quelque chose.

Secrets : Ansible Vault dans group_vars

Mots de passe de base de données, clés d'API, clés privées TLS n'ont rien à faire en clair dans du YAML, même dans un dépôt privé. Vault règle ce problème en chiffrant les valeurs sur place tout en les gardant accessibles exactement comme n'importe quelle autre variable.

bash
ansible-vault encrypt_string --stdin-name 'db_password' \
  >> inventory/group_vars/db/vault.yml

--stdin-name lit la valeur sur l'entrée standard au lieu de la ligne de commande, donc le secret ne se retrouve ni dans l'historique du shell ni dans la sortie de ps. Tapez la valeur, puis appuyez deux fois sur Ctrl-D sans appuyer sur Entrée avant. Sinon, le retour à la ligne final fait partie du secret chiffré.

Je garde en général un group_vars/db/vars.yml en clair pour les valeurs non sensibles, et un group_vars/db/vault.yml séparé pour les valeurs chiffrées. Ansible fusionne automatiquement tout ce qui se trouve sous group_vars/db/*, donc un rôle référence simplement {{ db_password }} sans se soucier du fichier d'où elle provient. Exécuter le playbook nécessite alors seulement --ask-vault-pass, ou --vault-password-file pointant vers un fichier qui, lui, ne fait jamais partie du dépôt.

En CI, stockez le mot de passe Vault comme secret de pipeline masqué, et ne l'écrivez jamais sur disque. Si le fichier passé à --vault-password-file est exécutable, Ansible l'exécute et prend sa sortie comme mot de passe. Un script de deux lignes commité dans le dépôt peut ainsi lire le mot de passe depuis l'environnement :

bash
#!/usr/bin/env bash
# scripts/vault-pass.sh (à commiter avec chmod +x ; il ne contient aucun secret)
printf '%s\n' "${ANSIBLE_VAULT_PASSWORD:?ANSIBLE_VAULT_PASSWORD non défini}"
bash
ansible-playbook -i inventory/production.ini playbooks/site.yml \
  --vault-password-file scripts/vault-pass.sh

Si vous avez vraiment besoin d'un fichier de mot de passe, créez-le avec mktemp sous un umask strict et supprimez-le avec un trap dans le même script, pour qu'il disparaisse même si le playbook échoue :

bash
umask 077
vault_pass_file=$(mktemp)
trap 'rm -f "$vault_pass_file"' EXIT
printf '%s\n' "$ANSIBLE_VAULT_PASSWORD" > "$vault_pass_file"
ansible-playbook -i inventory/production.ini playbooks/site.yml \
  --vault-password-file "$vault_pass_file"

N'affichez jamais le mot de passe Vault dans les logs du job, et ne l'écrivez pas à un chemin fixe comme /tmp/vault-pass. Avec l'umask par défaut, ce fichier est lisible par tous sur un runner partagé, et quiconque le lit peut déchiffrer tous les secrets du dépôt.

Tags et --check pour un déploiement sans risque

Sur un parc de taille un tant soit peu réelle, vous voulez rarement exécuter tout le playbook à chaque fois ; vous voulez recharger les configurations Nginx sur tout le parc sans toucher aux réglages SSH, ou l'inverse. Les tags donnent ce contrôle sans avoir à éclater le playbook en une dizaine de fichiers.

yaml
# roles/nginx/tasks/main.yml (la même tâche, désormais taguée)
- name: Deploy nginx configuration
  ansible.builtin.template:
    src: nginx.conf.j2
    dest: /etc/nginx/nginx.conf
    owner: root
    group: root
    mode: '0644'
    validate: '/usr/sbin/nginx -t -c %s'
  notify: Reload nginx
  tags:
    - nginx
    - config
bash
# uniquement les tâches taguées nginx
ansible-playbook -i inventory/production.ini playbooks/site.yml --tags nginx
 
# prévisualiser tous les changements, n'en appliquer aucun
ansible-playbook -i inventory/production.ini playbooks/site.yml --check --diff

--check exécute le playbook sans rien modifier et indique ce qui se produirait ; --diff ajoute le texte avant/après réel pour tout ce que template ou copy réécrirait. Je fais tourner chaque playbook avec --check --diff contre la production en premier, jusqu'à ce que l'exécution réelle devienne une routine pour l'équipe d'un client.

Migrer des scripts bricolés vers des rôles est une décision qui coûte moins cher avant que le parc n'atteigne cinquante hôtes qu'après.

ALLER PLUS LOIN

Envie de faire tourner ça en production ?

Ce tutoriel couvre les concepts et l'architecture. Si vous voulez l'implémenter dans votre propre infrastructure, ou monter en compétence pour posséder ce sujet durablement, je propose du mentorat individuel construit autour de votre environnement réel, pas une formation générique.

Ce tutoriel

  • Concepts clés et architecture principale
  • Extraits de code illustratifs
  • Le raisonnement derrière chaque décision

Mentorat individuel

  • Des sessions de travail sur votre propre environnement
  • Des réponses directes aux cas particuliers que vous rencontrez
  • Un retour sur votre implémentation réelle
  • Un accompagnement continu pendant que vous la construisez
Me contacterRéponse sous 1 jour ouvré.

Tutoriels similaires