Sauvegarder un annuaire OpenLDAP : deux exports, un rechargement, quatre vérifications
Un annuaire est la porte d'entrée de tout le reste. Quand il ne revient pas, personne ne se connecte à rien — et c'est le genre de sauvegarde qu'on découvre incomplète le jour où on en a besoin, parce qu'un export LDIF d'un annuaire vivant a l'air parfaitement valide jusqu'à ce qu'on essaie de le recharger sur un serveur neuf.
Ce guide monte la chaîne entière. À la fin, vous saurez pourquoi une sauvegarde d'annuaire tient dans deux exports et pas un, ce que slapcat garantit que ldapsearch ne garantit pas, ce que votre LDIF contient réellement — y compris ce qui fera échouer son rechargement —, comment recharger l'export à la main dans un conteneur jetable, et quelles quatre commandes ldapsearch disent qu'un annuaire restauré est utilisable.
Tout cela fonctionne avec les outils OpenLDAP, docker et l'AWS CLI, et rien d'autre. La dernière section montre comment faire tourner ce même rechargement et ces mêmes vérifications sur une planification plutôt qu'à la main — c'est le travail de RestoreProof — mais la procédure tient debout seule, et le jour où ça compte, c'est elle que vous suivrez.
Une sauvegarde d'annuaire, c'est deux exports
Un annuaire OpenLDAP tient dans deux bases, et une sauvegarde qui n'en prend qu'une ne se recharge pas.
La première est l'arbre de données : vos unités d'organisation, vos comptes, vos groupes. C'est celle que tout le monde exporte, avec slapcat ou avec ldapsearch.
La seconde est l'arbre de configuration, cn=config : les schémas, les règles d'accès, les greffons chargés, les index. Elle ne contient aucun de vos comptes, et c'est pourtant elle qui décide si l'export de données peut être rechargé. Un annuaire qui a reçu au fil des années un attribut maison — matricule, numeroDossier, n'importe quoi qu'une application métier a ajouté — décrit cet attribut dans cn=config, et nulle part ailleurs. Sans cet export, vous avez un fichier de données que le serveur de restauration refuse d'écrire, entrée après entrée, sans savoir pourquoi.
L'oubli de cn=config coûte aussi les règles d'accès. Un annuaire rechargé sans elles est un annuaire dont les entrées sont là et où les applications ne lisent plus ce qu'elles lisaient.
slapcat à froid, ldapsearch à chaud
slapcat lit directement les fichiers de la base, sur le serveur, sans passer par slapd. Il est rapide, il ne consomme pas de connexion, il n'est soumis à aucune limite de taille — mais il lit une base qui bouge. Un export pris pendant que des écritures arrivent peut contenir une entrée dans son état d'avant une modification et une autre dans son état d'après. Pour un annuaire, ce n'est presque jamais grave : les entrées sont indépendantes. Ça le devient quand la modification en cours touche un compte et le groupe qui le référence : vous récupérez un groupe qui pointe vers un membre absent. Le seul export réellement figé est celui pris avec slapd arrêté.
ldapsearch passe par le serveur, et n'a donc besoin ni d'un accès au disque ni d'un arrêt de service : il s'exécute depuis n'importe quelle machine qui atteint le port 389. Chaque entrée qu'il renvoie est cohérente, mais l'ensemble ne l'est pas davantage qu'avec slapcat : la lecture se fait entrée par entrée, sans vue figée.
Deux pièges lui sont propres. Le serveur applique une limite de taille aux résultats — sizelimit, 500 entrées par défaut chez slapd : au-delà, il s'arrête et votre export s'arrête avec lui. Faites l'export avec le compte administrateur de l'annuaire, qui n'y est pas soumis, et regardez le code de sortie. Et par défaut, ldapsearch ne renvoie que les attributs applicatifs : il faut demander "*" "+" pour obtenir aussi ce que le serveur a fabriqué.
Ce que le LDIF contient vraiment
Trois choses qu'on découvre en général au rechargement.
Les mots de passe y sont, sous forme de hachés. Un userPassword: {SSHA}xxxxx se recharge tel quel, et le compte se reconnecte avec son mot de passe d'origine. Deux conséquences : le fichier d'export vaut exactement ce que vaut l'annuaire, donc il se protège comme lui ; et la seule vérification qui prouve que les gens pourront encore entrer est une authentification réussie après restauration, pas un décompte d'entrées.
Les attributs opérationnels appartiennent au serveur. entryUUID, entryCSN, structuralObjectClass, creatorsName, createTimestamp, modifiersName, modifyTimestamp, entryDN, contextCSN, memberOf, les attributs de politique de mot de passe en pwd… : le serveur les fabrique lui-même et refuse qu'on les écrive. Un export slapcat, ou un ldapsearch avec "+", les contient tous — il faut les retirer avant de recharger. Le cas de memberOf est le plus trompeur : il est calculé par un greffon à partir des groupes, et le réimporter revient à figer des appartenances que le serveur va recalculer de son côté.
Les schémas personnalisés sont ailleurs. Les entrées qui portent un attribut maison en dépendent, et cet attribut n'est pas dans l'export de données. C'est tout l'intérêt du second export.
Le script de sauvegarde
Sur le serveur d'annuaire, les deux exports et leur envoi :
#!/bin/bash
set -euo pipefail
ts=$(date +%Y%m%d_%H%M%S)
dest=s3://sauvegardes-annuaire/ldap
slapcat -n 0 -o ldif-wrap=no | gzip > "/backups/config_${ts}.ldif.gz"
slapcat -n 1 -o ldif-wrap=no | gzip > "/backups/annuaire_${ts}.ldif.gz"
aws s3 cp "/backups/config_${ts}.ldif.gz" "${dest}/"
aws s3 cp "/backups/annuaire_${ts}.ldif.gz" "${dest}/"
-n 0 désigne la base de configuration, -n 1 la première base de données. -o ldif-wrap=no empêche le repliement des lignes longues à 78 colonnes : sans lui, un attribut long est coupé et continue sur la ligne suivante, précédé d'une espace. Un fichier replié se recharge très bien, mais il ne se filtre plus à la ligne — et c'est exactement ce que vous allez devoir faire.
Si vous n'avez pas d'accès shell au serveur d'annuaire, l'export de données se prend à distance, sur le même modèle :
ldapsearch -x -H ldap://annuaire.interne \
-D cn=admin,dc=exemple,dc=com -w "$LDAP_ADMIN_PASSWORD" \
-b dc=exemple,dc=com -LLL -o ldif-wrap=no "(objectClass=*)" "*" "+" \
| gzip > "/backups/annuaire_${ts}.ldif.gz"
Sans
pipefail, un export raté ressort en succèsDans
slapcat | gzip, le shell ne regarde que le code de sortie du dernier maillon.gzipréussit à compresser une sortie vide, donc le script rend 0 et le cron est content.set -o pipefail— inclus dans leset -euo pipefailci-dessus — fait échouer la ligne entière dès queslapcatéchoue. C'est la première cause de sauvegardes vides qui passent au vert pendant des mois.
Gardez l'horodatage dans les noms de fichiers, et gardez les deux exports du même horodatage ensemble : un schéma qui ne correspond pas aux données rechargées ne sert à rien. Pour la rétention, une règle de cycle de vie sur le bucket supprime les objets de plus de N jours sans script à maintenir.
Recharger une fois, à la main
Un export n'est prouvé que rechargé, sur un serveur qui ne sait rien de l'ancien. Un conteneur jetable suffit.
docker run -d --name ldap-essai \
-e LDAP_ROOT=dc=exemple,dc=com \
-e LDAP_ADMIN_USERNAME=admin \
-e LDAP_ADMIN_PASSWORD=essai \
-e LDAP_SKIP_DEFAULT_TREE=yes \
-e LDAP_CONFIG_ADMIN_ENABLED=yes \
-e LDAP_CONFIG_ADMIN_PASSWORD=essai-config \
bitnamilegacy/openldap:2.6.10-debian-12-r4
until docker exec ldap-essai ldapsearch -x -H ldap://localhost:1389 \
-b "" -s base namingContexts >/dev/null 2>&1; do sleep 1; done
LDAP_SKIP_DEFAULT_TREE est indispensable : sans lui, l'image crée son propre arbre de démonstration sous LDAP_ROOT, et ces entrées entrent en collision avec les vôtres au rechargement (« entry already exists »). Un export parfaitement restaurable échoue alors pour une raison qui n'a rien à voir avec lui.
Reste à retirer les attributs opérationnels et à charger les données. C'est ici que le ldif-wrap=no de l'export paie : le filtre travaille ligne par ligne.
gunzip -c /backups/annuaire_20260918_030000.ldif.gz \
| grep -viE '^(structuralObjectClass|entryUUID|entryCSN|creatorsName|createTimestamp|modifiersName|modifyTimestamp|entryDN|subschemaSubentry|hasSubordinates|numSubordinates|contextCSN|memberOf|pwd[A-Za-z]+):' \
> /tmp/annuaire.ldif
docker exec -i ldap-essai ldapadd -x -H ldap://localhost:1389 \
-D cn=admin,dc=exemple,dc=com -w essai < /tmp/annuaire.ldif
Sur un annuaire qui a un schéma personnalisé, c'est là que ça s'arrête :
adding new entry "cn=camille.durand,ou=people,dc=exemple,dc=com"
ldap_add: Undefined attribute type (17)
additional info: matricule: attribute type undefined
ldapadd s'arrête sur la première entrée qui porte un attribut que le serveur ne connaît pas, et il ne dit rien des suivants. Un export qui en utilise trois demande donc trois rechargements complets pour être diagnostiqué, et vous n'apprenez le manque suivant qu'après avoir corrigé le précédent.
La correction est dans l'export cn=config. On ne le rejoue pas en entier sur un serveur neuf : il décrit les chemins de fichiers, les bases et les greffons de l'ancien serveur. Ce qu'on en tire, ce sont les entrées de schéma, sous cn=schema,cn=config, et seulement celles que vos applications ont ajoutées — core, cosine, inetorgperson et nis sont déjà dans l'image. Une entrée de schéma ressemble à ceci :
dn: cn=monschema,cn=schema,cn=config
objectClass: olcSchemaConfig
cn: monschema
olcAttributeTypes: ( 1.3.6.1.4.1.99999.1.1 NAME 'matricule' DESC 'matricule RH' EQUALITY caseIgnoreMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 SINGLE-VALUE )
olcObjectClasses: ( 1.3.6.1.4.1.99999.1.2 NAME 'salarie' SUP top AUXILIARY MAY ( matricule ) )
Elle se charge dans l'arbre de configuration, avec le compte administrateur de cet arbre — qui n'est pas celui de l'annuaire —, avant les données :
docker exec -i ldap-essai ldapadd -x -H ldap://localhost:1389 \
-D cn=admin,cn=config -w essai-config < /tmp/schema.ldif
docker exec -i ldap-essai ldapadd -x -H ldap://localhost:1389 \
-D cn=admin,dc=exemple,dc=com -w essai < /tmp/annuaire.ldif
C'est aussi l'ordre dans lequel il faudra les rejouer le jour où vous restaurerez pour de vrai. Notez le temps que ça a pris : c'est votre durée de restauration réelle, la seule à comparer au délai que vous avez promis.
Ce qu'on vérifie dans un annuaire rechargé
Le rechargement s'est fait sans erreur ne veut pas dire que l'annuaire est utilisable. Quatre questions, dans cet ordre :
ldapsearch -x -H ldap://localhost:1389 -D cn=admin,dc=exemple,dc=com -w essai \
-b ou=people,dc=exemple,dc=com -LLL "(objectClass=inetOrgPerson)" dn \
| grep -c '^dn:'
ldapsearch -x -H ldap://localhost:1389 -D cn=admin,dc=exemple,dc=com -w essai \
-b cn=camille.durand,ou=people,dc=exemple,dc=com -s base -LLL dn
ldapsearch -x -H ldap://localhost:1389 -D cn=admin,dc=exemple,dc=com -w essai \
-b ou=groups,dc=exemple,dc=com -LLL "(cn=support)" member
ldapsearch -x -H ldap://localhost:1389 \
-D cn=camille.durand,ou=people,dc=exemple,dc=com -w "$MOT_DE_PASSE_CAMILLE" \
-b "" -s base -LLL namingContexts
- Les comptes sont là. Comparez à l'ordre de grandeur de la production, pas à un chiffre exact. Et vérifiez le code de sortie de la commande : si le serveur a atteint sa limite de taille, le décompte est faux vers le bas.
- Un compte connu est là, celui-là précisément. Six entrées, ce n'est pas forcément les six bonnes : la recherche par DN complet est plus forte qu'un décompte.
- Les appartenances sont là. Les groupes décident des droits. Un annuaire rechargé sans ses
memberest un annuaire où tout le monde a perdu ses accès — et c'est le premier symptôme d'unmemberOfréimporté à la place des groupes eux-mêmes. - Les mots de passe fonctionnent. Cette dernière commande n'interroge rien d'utile : elle s'authentifie en tant que
camille.durand. C'est la seule qui prouve que les hachés sont revenus utilisables. Un export qui recharge tout sauf lesuserPassworddonne un annuaire d'apparence parfaite où personne ne se connecte.
Puis détruisez tout : docker rm -f ldap-essai.
Automatiser cette vérification
Ce qui précède coûte une heure ou deux, à chaque fois. C'est pour cette raison que ces tests, faits à la main, finissent par ne plus être faits.
RestoreProof rejoue exactement ces étapes en tâche planifiée, sur votre infrastructure : un runner récupère l'export, le recharge dans un conteneur jetable, pose les mêmes questions que ci-dessus, détruit tout, et signe le résultat. Les données ne sortent pas de chez vous.
Déclarez d'abord la sauvegarde comme source — le bucket, le préfixe, le motif *.ldif, et la stratégie le plus récemment modifié. Les clés d'accès ne se saisissent pas : le plan porte une référence, env://AWS_ACCESS_KEY_ID, que le runner résout dans son propre environnement. Voir les références de secrets.
L'assistant OpenLDAP écrit ensuite le plan, et ce plan est la manipulation que vous venez de faire à la main, ligne pour ligne :
| À la main | Dans le plan |
|---|---|
aws s3 cp de l'export le plus récent | fetch |
gunzip -c | unpack |
le docker run de l'image OpenLDAP | start_sandbox |
le ldapadd du schéma dans cn=config | exec_in_sandbox |
le grep -v des attributs opérationnels | restore_ldap, avec format: "auto" |
le ldapadd des données | restore_ldap |
ldapsearch … | grep -c '^dn:' | RESTOREPROOF_LDAP_BASE_DN et _EXPECTED_ENTRIES |
| la recherche du DN connu | RESTOREPROOF_LDAP_EXPECT_DN |
le ldapsearch qui s'authentifie | RESTOREPROOF_LDAP_TEST_BIND_DN et _TEST_BIND_PASSWORD |
docker rm -f | le nettoyage, toujours exécuté |
Trois choses ne se saisissent pas. L'hôte, le port et le DN administrateur : le runner reconnaît le bac à sable à son nom d'image — openldap, 389ds, dirsrv ou lldap — et compose lui-même cn=admin,<suffixe> à partir du suffixe posé dans LDAP_ROOT. Le retrait des attributs opérationnels : avec format: "auto", l'étape restore_ldap regarde le début du fichier, y reconnaît un export de serveur, et filtre. Et le diagnostic du schéma : avant d'écrire la première entrée, le runner demande au serveur du bac à sable ce qu'il sait accepter, le compare à ce que votre export utilise, et rend la liste complète en une fois — tous les types d'attributs manquants, toutes les classes d'objets manquantes. Ce n'est pas un adoucissement : l'exécution échoue. Un export qui ne se recharge pas sur un serveur neuf n'est pas restaurable, et c'est ce qu'il fallait découvrir aujourd'hui plutôt que pendant une panne.
La sonde ldap fait les quatre vérifications d'un coup : elle compte les entrées sous un sous-arbre avec un filtre et un opérateur (gte, lte, eq), exige un DN précis, et s'authentifie avec un compte restauré. Elle refuse de passer si aucune des trois n'est configurée — une sonde qui ne vérifie rien ne prouve rien.
Le plan complet, avec l'étape de chargement du schéma et les deux sondes, est dans la recette LDAP.
Le seul ajout par rapport à votre procédure à la main est max_age, et c'est celui qu'un rechargement ne peut pas déduire du contenu : il fait échouer l'exécution quand l'export le plus récent trouvé à la source est plus vieux que ce délai. Un annuaire de mars se recharge parfaitement en septembre.
Les seuils ne se recopient pas depuis cette page. Un essai recharge votre export, compte ce qu'il contient réellement, et propose chaque seuil sous la valeur mesurée.

Il reste à choisir une fréquence. Chaque nuit place l'exécution une heure après votre export : il a donc une heure quand il est éprouvé. L'autre déclenchement possible est un appel HTTP à la fin du script de sauvegarde — le test porte alors exactement sur les fichiers qui viennent d'être produits.
Chaque exécution laisse un rapport horodaté, signé, qui nomme l'export éprouvé et ce que chaque sonde a mesuré.
Ce que cette chaîne ne prouve pas
Elle prouve que l'export le plus récent se recharge dans un OpenLDAP neuf, que les entrées et les groupes attendus sont là, et qu'un compte restauré s'authentifie encore.
Elle ne prouve pas que le reste de cn=config est revenu : les règles d'accès, les greffons et les index ne sont pas rejoués dans le bac à sable, seuls les schémas le sont. Elle ne dit rien de la réplication entre vos serveurs. Et l'annuaire rechargé n'est pas identique octet pour octet à l'original : les attributs opérationnels ont été refabriqués par le serveur du bac à sable, ce qui est très bien — ce qui vous intéresse, c'est que vos utilisateurs se reconnectent. Enfin, elle ne dit rien de ce qui a été créé depuis le dernier export : cet écart, c'est votre RPO, et il se règle avec la fréquence des sauvegardes, pas avec les tests.
Éprouver ses sauvegardes sans y penser
RestoreProof rejoue ces étapes sur votre infrastructure, aussi souvent que vous le décidez : il récupère l'export, le recharge dans un conteneur jetable, pose les mêmes questions, détruit tout, et signe le résultat. Vos données ne sortent pas de votre réseau.
FAQ
Un export LDIF qui fait la bonne taille suffit-il ?
Non. Un export de données complet ne se recharge pas sur un serveur neuf dès que l'annuaire utilise un attribut maison : ldapadd s'arrête sur la première entrée qui le porte. La taille du fichier ne dit rien de ça, et rien non plus des attributs opérationnels qu'il faut retirer avant de le rejouer.
Que contient cn=config et pourquoi le sauvegarder ?
Les schémas, les règles d'accès, les greffons chargés et les index. Aucun de vos comptes. C'est pourtant cet export qui rend l'autre rechargeable : les attributs personnalisés dont dépendent vos entrées sont décrits là, et nulle part ailleurs.
Faut-il arrêter slapd pour faire une sauvegarde ?
Pour un export réellement figé, oui. À chaud, les entrées restent individuellement cohérentes, mais un groupe et le compte qu'il référence peuvent être capturés de part et d'autre d'une modification. Sur la plupart des annuaires, le jeu en vaut la chandelle ; sur un annuaire qui bouge en permanence, l'arrêt est le seul export sans surprise.
Pourquoi un compte restauré n'arrive-t-il plus à se connecter ?
Parce que les userPassword n'ont pas été rechargés, ou pas tels quels. Le LDIF les contient sous forme de hachés, qui se rejouent à l'identique — mais un export filtré, ou pris par un compte qui ne les lit pas, donne un annuaire d'apparence parfaite où personne n'entre. Seule une authentification réussie après restauration le prouve.
Pourquoi un export ldapsearch s'arrête-t-il à 500 entrées ?
Parce que le serveur applique une limite de taille aux résultats, sizelimit, fixée à 500 entrées par défaut dans slapd. Au-delà, la recherche s'arrête et l'export s'arrête avec elle, sur un fichier d'apparence normale. Prenez l'export avec le compte administrateur de l'annuaire, qui n'y est pas soumis, et lisez le code de sortie de la commande.