Aller au contenu

Pourquoi j'ai construit ma propre image MongoDB pour faire tourner un replica set en production

13 décembre 2025 · 11 min read · Read in English

Sommaire

Il y a un moment, dans la vie d'une application, où une seule base de données ne suffit plus. Pas parce qu'elle est lente. Parce qu'elle est seule.

Une seule instance MongoDB, c'est un point de défaillance unique. Le jour où ce conteneur tombe — disque plein, kernel qui panique, redémarrage de VM qui se passe mal — votre application ne ralentit pas : elle s'arrête. Pas de bascule, pas de réplique prête à prendre le relais. Juste un écran d'erreur et un téléphone qui sonne.

C'est le problème de départ. Et la réponse classique de MongoDB, c'est le replica set : plusieurs nœuds qui détiennent la même donnée, élisent un primaire, et basculent automatiquement quand ce primaire disparaît. Sur le papier, c'est simple. Dans un environnement Docker reproductible, multi-machines et multi-clients, ça l'est beaucoup moins. Cet article raconte où ça coince réellement, et comment j'ai fini par résoudre le problème une bonne fois pour toutes.

Le lab complet (Dockerfile, init.sh, init.js, mongod.conf) est disponible sur github.com/denisakp/dev-life-labs, dans le dossier mongo-replica.

Le contexte : une plateforme multi-tenant, une base par client

Avant d'entrer dans le vif : chaque client de la plateforme tourne sur sa propre VM isolée, avec sa propre stack Docker Compose. Cela veut dire que la base de données n'est pas déployée une fois, mais autant de fois qu'il y a de clients. Tout ce qui est fragile ou manuel dans le déploiement de MongoDB se retrouve donc multiplié par le nombre de VMs.

Cette contrainte change tout. Une procédure « qui marche si on fait attention » n'est pas acceptable quand on doit la rejouer sur dix machines différentes, sur macOS en dev et sur AlmaLinux en prod. Il fallait quelque chose qui marche toujours, sans intervention.

Pourquoi un replica set, concrètement

Un replica set MongoDB, c'est trois nœuds dans mon cas — mongo1, mongo2, mongo3. Un seul est primaire à un instant donné : il reçoit toutes les écritures. Les deux autres sont secondaires et répliquent la donnée en continu.

L'intérêt n'est pas le volume ni la performance. C'est la disponibilité. Si le primaire tombe, les nœuds restants organisent une élection et en désignent un nouveau en quelques secondes. L'application se reconnecte automatiquement, à condition d'utiliser la bonne chaîne de connexion (j'y reviens). Trois nœuds, c'est le minimum pour qu'une élection ait une majorité claire — deux survivants sur trois, ça suffit pour décider ; un seul survivant sur deux, non.

Dans ma config, le nœud mongo1 reçoit une priority: 2, les autres restent à 1 :

members: [
  { _id: 0, host: 'mongo1:27017', priority: 2 },
  { _id: 1, host: 'mongo2:27017' },
  { _id: 2, host: 'mongo3:27017' }
]

Cette priorité plus élevée dit simplement à MongoDB : « si mongo1 est en bonne santé, c'est lui le primaire ». Ça rend le comportement prévisible, ce qui simplifie le debug et la supervision.

Le vrai piège : l'authentification entre nœuds

Voilà où le tutoriel « 3 conteneurs et c'est parti » s'effondre.

Dès qu'un replica set est protégé par authentification — et en production, il doit l'être — les nœuds ne se contentent pas d'authentifier les clients. Ils doivent aussi s'authentifier entre eux. MongoDB utilise pour ça un keyfile : un secret partagé que tous les nœuds possèdent.

On le génère une fois :

openssl rand -base64 756 > rs_keyfile

Et MongoDB est intransigeant sur ce fichier. Il exige :

  • des permissions exactement à 400 (lecture seule pour le propriétaire, rien pour les autres),
  • une appartenance à l'utilisateur mongodb.

Un seul bit de trop, et le nœud refuse de démarrer. Pas un warning : un refus net.

Le réflexe naturel, c'est de monter ce keyfile en volume depuis l'hôte :

volumes:
  - ./mongodb/rs_keyfile:/etc/mongodb/keyfile:ro   # ❌ piège

Sauf que les permissions d'un fichier monté depuis l'hôte dépendent de l'hôte. Sur ma machine macOS de dev, elles ne ressemblent pas à celles d'une VM AlmaLinux. Le fichier qui démarre parfaitement en local fait planter le conteneur en production. On bricole un chmod, ça marche aujourd'hui, ça casse au prochain git clone sur une autre machine. Le comportement n'est jamais reproductible.

Multipliez maintenant ce problème : trois nœuds, quatre fichiers de config chacun (keyfile, mongod.conf, scripts d'init), c'est douze montages de volume par déploiement. Douze occasions de se tromper de chemin ou de permission. Sur chaque VM. C'est exactement le genre de friction qui transforme un déploiement en loterie.

La solution : tout cuire dans l'image

Le déclic a été d'arrêter de monter la configuration au runtime, et de la figer au build. Au lieu de demander à l'hôte de fournir les bons fichiers avec les bonnes permissions, je construis une image qui les contient déjà, correctement.

Dockerfile
FROM mongo:8.2-noble

# Le keyfile reçoit ses permissions 400 ici, une fois pour toutes
COPY --chown=mongodb:mongodb --chmod=400 rs_keyfile /etc/mongodb/keyfile

COPY --chown=mongodb:mongodb --chmod=644 mongod.conf /etc/mongod.conf
COPY --chown=mongodb:mongodb --chmod=755 init.sh /init.sh
COPY --chown=mongodb:mongodb --chmod=644 init.js /init.js

# Le dossier qui contient le keyfile est lui aussi verrouillé
RUN chown -R mongodb:mongodb /etc/mongodb && \
    chmod 700 /etc/mongodb

CMD ["--config", "/etc/mongod.conf"]

Le --chmod=400 du COPY n'est pas une suggestion : c'est gravé dans la couche d'image. Peu importe l'hôte, peu importe l'OS, le keyfile a toujours les bonnes permissions et le bon propriétaire. Le problème de reproductibilité disparaît, parce que l'hôte n'a plus son mot à dire.

Le mongod.conf embarqué est minimal — il pointe vers le keyfile désormais garanti présent :

mongod.conf
replication:
  replSetName: okydookReplSet
security:
  keyFile: /etc/mongodb/keyfile
net:
  bindIp: 0.0.0.0
  port: 27017

Ce changement de perspective a quatre conséquences directes :

  • Cohérence — même comportement sur macOS en dev et sur les VMs en prod. L'hôte n'intervient plus dans la config.
  • Simplicité — fini les douze montages. Le compose ne monte plus que les volumes de données (/data/db), ce qui est leur vrai rôle.
  • Versionnement — un changement de config devient un nouveau tag d'image. On sait exactement quelle configuration tourne sur quel nœud, et on peut revenir en arrière.
  • Sécurité — la configuration est immuable. On ne peut pas la modifier par accident au runtime, et on réduit la surface d'attaque en supprimant les liaisons hôte↔conteneur.

Construire l'image

Pour faire tourner le lab sur une seule machine, pas besoin de registre — build l'image localement depuis le Dockerfile :

docker build -t mongo-replica:latest .

C'est exactement ce que fait le docker-compose.yml du lab tout seul : chaque nœud déclare build: . avec image: mongo-replica:latest, donc un simple docker compose up build l'image une fois et la réutilise pour les trois nœuds. Pas de registre, pas de docker login.

Passer en multi-VM : pousser sur un registre

En prod je ne fais pas tourner les trois nœuds sur une seule machine — ils vivent sur des VMs séparées (linux/amd64, AlmaLinux sur Proxmox), alors que mon poste de dev est en arm64. Builder localement sur chaque VM ne suffit plus : je build une fois pour la plateforme cible et je pousse sur un registre que je contrôle, puis chaque VM tire la même image :

docker buildx build \
  --platform linux/amd64 \
  -t <your-registry>/mongo-replica:latest \
  --push \
  .

Ensuite, dans le compose, remplace build: . par image: <your-registry>/mongo-replica:latest. Remplace <your-registry> par le registre où tu pousses ton image. Comme cette image embarque ton keyfile et ta config, elle n'a rien à faire sur un registre public : pousse-la sur un registre privé — GitHub Container Registry (ghcr.io/<ton-user>), Docker Hub privé, GitLab, ou un registre auto-hébergé. Un registre public ne convient que si l'image ne contient aucun secret (par exemple si tu génères le keyfile au runtime plutôt que de le cuire dans l'image).

Chaque VM se contente ensuite de tirer l'image (après un docker login sur le registre privé). Déployer une base devient : pull, run. Plus aucun fichier à copier à la main.

Le compose, avant et après

Le bénéfice se voit le mieux dans le docker-compose.yml. Avant, chaque nœud croulait sous les montages :

docker-compose.yml
mongo1:
  image: mongo:8.2-noble
  volumes:
    - mongo1_data:/data/db
    - mongo1_config:/data/configdb
    - $PWD/scripts/mongo/rs_keyfile:/etc/mongodb/keyfile:ro   # ❌ permissions hôte
    - $PWD/scripts/mongo/mongod.conf:/etc/mongod.conf:ro      # ❌ × 3 nœuds = 12 montages

Après, l'image custom porte la config, le compose ne garde que les données :

docker-compose.yml
services:
  mongo1:
    container_name: mongo1
    build: .                       # build local → en prod : image: <your-registry>/mongo-replica:latest
    image: mongo-replica:latest
    hostname: mongo1
    networks: [ mongo-cluster ]
    volumes:
      - mongo1_data:/data/db          # ✅ uniquement les données
      - mongo1_config:/data/configdb
    environment:
      MONGO_INITDB_ROOT_USERNAME: '${MONGO_ADMIN_USER:-admin}'
      MONGO_INITDB_ROOT_PASSWORD: '${MONGO_ADMIN_PASSWD:-veryStringPassword}'

  # mongo2 et mongo3 : strictement identiques, seul le suffixe change

  mongo-init:
    container_name: mongo-init
    image: mongo-replica:latest
    restart: "no"
    entrypoint: ["/bin/bash", "/init.sh"]
    networks: [ mongo-cluster ]
    depends_on: [ mongo1, mongo2, mongo3 ]
    environment:
      MONGO_ROOT_USERNAME: '${MONGO_ADMIN_USER:-admin}'
      MONGO_ROOT_PASSWORD: '${MONGO_ADMIN_PASSWD:-veryStringPassword}'
      MONGO_DB_USER: '${DB_USERNAME:-okydook}'
      MONGO_DB_PASSWORD: '${DB_PASSWORD:-mypassword}'

Plus de keyfile, plus de mongod.conf, plus de scripts montés : ils vivent dans l'image. C'est tout l'intérêt. Le docker-compose.yml complet — les trois nœuds, les réseaux, les volumes — est dans le repo du lab.

Orchestrer l'amorçage : le vrai casse-tête caché

Construire l'image ne suffit pas. Un replica set ne s'initialise pas tout seul : il faut, une fois, lancer la commande rs.initiate() qui dit aux trois nœuds qu'ils forment un cluster. Et il faut le faire au bon moment — ni avant que les nœuds soient prêts, ni deux fois.

J'ai confié ce travail à un conteneur éphémère dédié, mongo-init, qui ne fait que ça puis s'éteint. Son script attend patiemment que MongoDB réponde avant d'agir :

init.sh
until mongosh --host mongo1:27017 -u "$MONGO_ROOT_USERNAME" -p "$MONGO_ROOT_PASSWORD" \
  --authenticationDatabase admin --eval "db.adminCommand('ping')" > /dev/null 2>&1; do
  echo "Waiting for MongoDB connection..."
  sleep 5
done

Deux détails comptent énormément ici, parce qu'ils font la différence entre un déploiement automatisé et un déploiement à surveiller :

L'initialisation est idempotente. Le script vérifie d'abord si le replica set existe déjà avant de tenter de le créer :

init.js
try {
  var status = rs.status();
  if (status.ok === 1) {
    isInitialized = true;
    print("ReplicaSet already initiated");
  }
} catch(e) {
  print("ReplicaSet not yet initiated, proceeding...");
}

Sans cette garde, relancer la stack tenterait de réinitialiser un cluster déjà vivant — et le ferait planter. Avec elle, on peut relancer docker compose up autant de fois qu'on veut : si le cluster existe, le script le constate et passe son chemin. C'est ce qui rend le déploiement rejouable sans crainte.

L'utilisateur applicatif est créé dans la foulée, avec les droits stricts dont il a besoin et rien de plus — readWrite sur la seule base okydook, pas un accès admin :

init.js
db.createUser({
  user: process.env.MONGO_DB_USER,
  pwd: process.env.MONGO_DB_PASSWORD,
  roles: [{ role: 'readWrite', db: 'okydook' }]
});

Enfin, Compose enchaîne les dépendances proprement : l'UI d'admin n'attend pas seulement que mongo-init ait démarré, mais qu'il se soit terminé avec succès :

docker-compose.yml
depends_on:
  mongo-init:
    condition: service_completed_successfully

Cette condition est subtile mais essentielle : elle garantit qu'on n'expose jamais une interface sur un cluster à moitié initialisé.

La pièce finale : se connecter au cluster, pas à un nœud

Tout ce travail serait gâché si l'application pointait vers un seul nœud. Car si elle ne connaît que mongo1 et que mongo1 tombe, elle est aveugle au nouveau primaire fraîchement élu — la haute disponibilité ne sert plus à rien.

La chaîne de connexion liste donc les trois nœuds et nomme explicitement le replica set :

mongodb://USER:PASS@mongo1:27017,mongo2:27017,mongo3:27017/okydook?replicaSet=okydookReplSet&authSource=okydook

Les trois nœuds répondent sur le port 27017 à l'intérieur du réseau Docker — ce sont les hostnames du réseau mongo-cluster, pas des ports mappés sur l'hôte. Lister mongo2:27018 ou mongo3:27019 ici est une erreur fréquente : ces ports n'existent qu'en dehors du réseau.

Le paramètre replicaSet=okydookReplSet est ce qui change tout. Avec lui, le driver MongoDB ne traite pas ces trois adresses comme trois bases indépendantes : il comprend que c'est un cluster, découvre sa topologie, repère qui est primaire à chaque instant, et redirige automatiquement les écritures vers le bon nœud — y compris après une bascule. L'application ne sait même pas qu'une élection a eu lieu. C'est précisément l'effet recherché.

Vérifier que tout tourne

Une fois la stack démarrée (docker compose up -d), deux vérifications suffisent. D'abord le keyfile, le point qui faisait planter le setup monté en volume — il doit être à 400 dans le conteneur :

docker exec -it mongo1 ls -la /etc/mongodb/keyfile
# -r-------- 1 mongodb mongodb ...

Ensuite l'état du cluster : rs.status() doit montrer mongo1 en PRIMARY, mongo2/mongo3 en SECONDARY, tous avec health: 1.

docker exec -it mongo1 mongosh -u admin -p PASSWORD \
  --authenticationDatabase admin --eval "rs.status()"

Les autres commandes de diagnostic (auth de l'utilisateur applicatif, ping) sont listées dans le README du repo du lab.

Ce que ce parcours m'a appris

Le problème de départ semblait être « MongoDB doit être hautement disponible ». Le vrai problème, celui qui m'a coûté du temps, était ailleurs : rendre un setup distribué reproductible à travers des environnements qui ne se ressemblent pas.

La leçon que je retiens : quand une configuration est fragile à cause de l'environnement — permissions, chemins, état de l'hôte — la bonne réponse n'est pas de la documenter mieux ni d'ajouter des chmod défensifs. C'est de retirer l'environnement de l'équation. Cuire la config dans l'image, rendre l'init idempotent, déclarer les dépendances explicitement : chacune de ces décisions transforme une étape « il faut faire attention » en une étape « ça marche, point ».

Et c'est peut-être ça le vrai sens de l'infrastructure as code : pas seulement écrire l'infra dans des fichiers, mais éliminer méthodiquement chaque endroit où un humain doit faire attention.

Dans un prochain article, je m'attaque à la suite logique : comment sauvegarder et restaurer ce cluster MongoDB dans un environnement conteneurisé. Parce qu'un cluster hautement disponible sans stratégie de backup, c'est juste une façon plus élaborée de perdre ses données.

Références

S'abonner aux prochains articles

Recevez les nouveaux articles par e-mail. Pas de spam, désinscription à tout moment.

Propulsé par Buttondown.

Related posts

© 2026 < Denis AKPAGNONITE /> | N1BBzerLZXT