Site Overlay

MongoDB Replica-Set und Authentifizierung | 2021

Als Entwickler habe ich im Normalfall nichts mit dem eigentlichen Betrieb der Software zu tun, an der ich arbeite. Das machen Administratoren, die Ahnung von Firewalls, Backups, Ausfallsicherheit und Monitoring haben.

Wenn ich aber an einem Projekt privat arbeite – so wie jetzt – muss ich mich zwangsweise in diese Themen einarbeiten.

Für das aktuelle Projekt habe ich mir in den Kopf gesetzt Transaktionen in MongoDB zu nutzen. Das was für relationale Datenbanken selbstverständlich ist, war bis Version 4 der NoSQL Datenbank nicht möglich.

Allerdings funktioniert dies nicht out of the box. Es muss konfiguriert werden. Und dieser Post wird meine Gedächtnisstütze, falls ich es mal wieder brauche 🙂

Das Setup

Ich wollte mein Setup wie auf dem Bild haben. Nichts Besonderes. Ein SpringBoot-Backend, das auf MongoDB zugreift und hinter einem Nginx Proxy läuft. Als Server habe ich ein Ubuntu-Droplet bei DigitalOcean gestartet.

Was mich letztendlich einige Nachmittage gekostet hat, ist das MongoDB Setup. Denn es reicht nicht den Daemon unter Ubuntu zu installieren und einfach zu starten. Mongo muss als s.g. replica set laufen. Hier ein Auszug aus der Doku:

Distributed Transactions and Multi-Document Transactions

Starting in MongoDB 4.2, the two terms are synonymous. Distributed transactions refer to multi-document transactions on sharded clusters and replica sets. Multi-document transactions (whether on sharded clusters or replica sets) are also known as distributed transactions starting in MongoDB 4.2.

Quelle: https://docs.mongodb.com/manual/core/transactions/

Und dazu gibt es im Internet einige StackOverflow Fragen, Codeschnipsel und Blog-Beiträge. Unter anderem hier und hier. Und das hat seinen Grund, denn es gibt Stolperfallen.

MongoDB Replica Sets

Was sind denn diese replica sets überhaupt? Nun für eine One-Man-Show wie mich nichts was ich vermissen würde. Für einen Online-Dienst wie Airbnb ist es ein Killer-Feature. Denn damit lässt sich eine gigantische globale Datenbank betreiben. Und zwar ohne Downtime und ohne Datenverlust.

Wer User auf der ganzen Welt hat, kann überall MongoDB-Knoten starten und sie alle zu einem einzigen Cluster verbinden. Einzelne Knoten lassen sich beliebig austauschen und weitere Knoten hinzufügen.

In meinem Diagram oben habe ich einen Cluster von drei Knoten gezeichnet, die alle in einem replica set sind. Sie funktioniert wie ein RAID. Jedes von ihnen speichert so viele Daten, dass beim Ausfall eines Knotens, die anderen den Verlust kompensieren können.

Überigens kann ein replica set auch nur mit einem Knoten aufgesetzt werden. Das nur nebenbei.

Warum Docker?

Ein replica set lässt sich – wie gesagt – auch mit einem einzigen MongoDB-Knoten realisieren. Aber mein Ehrgeiz hat mich gepackt und ich wollte lernen.

Ein Server kostet Geld. Um es im Sinne der MongoDB-Macher aufzusetzen, würde ich drei Server benötigen. Für meine Zwecke wäre das oversized. Aus diesem Grund habe ich drei Docker-Container gestartet und sie zu einem replica set verbunden.

Außerdem bleibt mir, bis auf Docker, die Installation von Software auf meinem Server erspart. Das macht einen späteren Umzug einfacher.

Was ist das Problem?

Ich wollte mit einer einzigen Docker-Compose-Datei den ganzen Abwasch erledigen. Sprich Benutzer anlegen, das replica set konfigurieren und dann auch noch die Authentifizierung aktivieren.

Aber Pustekuchen. Denn hier gibt es ein klassisches Henne-Ei Problem. Denn der Mongo-Daemon kann entweder mit aktivierter Authentifizierung (--auth) gestartet werden, oder ohne (--noauth).

Um einen Benutzer anzulegen, muss ich den Daemon ohne authentifizierung starten.

Und um das replica set mit aktivierter Authentifizierung zu betreiben, benötige ich einen Benutzer, denn sonst kann ich mich bei Mongo nicht anmelden.

Ich habe also keinen Weg gefunden, um dies mit einem einfachen docker-compose up -Aufruf zu erledigen.

Aber mal abgesehen davon, wäre es auch nicht schön. Denn der admin Benutzer muss nur einmal erstellt und das replica-set nur einmal initialisiert werden – und nicht bei jedem Container-Neustart.

D.h. Die Lösungen, die im Netz zu finden sind, die einen vierten Container starten, der dann die Initialisierung übernimmt, ist aus meiner Sicht nicht gut.

Meine Lösung

  1. Den 1 von 3 Containern ohne Authentifizierung starten.
  2. Admin Benutzer anlegen
  3. Container stoppen
  4. Alle 3 Container mit der finalen Konfiguration starten
  5. replica set initialisieren

Schritt 1 – MongoDB ohne Authentifizierung starten

Ich habe mich im Wesentlichen an Harvey Connor’s Code orientiert, weil es für mich am Verständlichsten war. Als erstes starte ich einen Mongo-Container ohne Authentifizierung:

docker-compose -f docker-compose-noauth.yml up -d

version: '3.3'

services:

  mongo1:
    hostname: mongo1
    container_name: localmongo1
    image: mongo:4
    expose:
      - 27017
    ports:
      - 27017:27017
    restart: always
    entrypoint: [ "/usr/bin/mongod", "--bind_ip", "localhost", "--noauth" ]
    volumes:
      - /var/lib/mongodb1/db:/data/db
      - /var/lib/mongodb1/conf:/data/configdb
      - ./mongoscripts:/mongoscripts

In Zeile 14 wird dem Daemon die --noauth Option übergeben. Und natürlich muss ich unter Volumes (Zeile 16) das Mapping für das Dateisystem setzen, in das Mongo seine Daten dauerhaft speichert. Andernfalls wären die Daten beim nächsten Container-Neustart verloren.

Schritt 2 – Admin Benutzer anlegen

Ein Mongo-Benutzer lege ich über die Mongo-Console an. Dafür habe ich mir ein kleines Shell-Script geschrieben und in den Container gemounted. Um es auszuführen, führe ich im Terminal das exec Kommando von Docker aus:

docker exec localmongo1 /mongoscripts/run/inituser.sh

#!/usr/bin/env bash

mongo admin --host localhost --eval "db.createUser({user: 'root', pwd: 'root', roles: [{role: 'userAdminAnyDatabase', db: 'admin'}, {role: 'clusterAdmin', db: 'admin'}]});"

Zwei Punkte sind an der inituser.sh wesentlich:

  1. Das Passwort meines root Users lautet natürlich nicht root 😉
  2. Der User, der später das replica set initialisiert, muss die Rolle clusterAdmin haben.

Schritt 3 – Container stoppen

Sobald ich den Benutzer angelegt habe, muss ich den Container stoppen, denn ich starte Mongo im nächsten Schritt mit anderen Optionen:

docker-compose -f docker-compose-noauth.yml down

Schritt 4 – replica set starten

Jetzt starte ich eine zweite Docker-Compose-Konfiguration. Dieses Mal mit allen Containern, die ich im replica set betreiben will:

docker-compose -f docker-compose.yml up -d

version: '3.3'

services:

  mongo1:
    hostname: mongo1
    container_name: localmongo1
    image: mongo:4
    expose:
      - 27017
    ports:
      - 27017:27017
    restart: always
    entrypoint: [ "/usr/bin/mongod", "--bind_ip", "localhost,mongo1", "--replSet", "rs01", "--dbpath", "/data/db", "--keyFile", "/mongoscripts/etc/mongo.key", "--journal", "--auth" ]
    volumes:
      - /var/lib/mongodb1/db:/data/db
      - /var/lib/mongodb1/conf:/data/configdb
      - ./mongoscripts:/mongoscripts
  
  mongo2:
    hostname: mongo2
    container_name: localmongo2
    image: mongo:4
    expose:
      - 27017
    ports:
      - 27018:27017
    restart: always
    entrypoint: [ "/usr/bin/mongod", "--bind_ip", "localhost,mongo2", "--replSet", "rs01", "--dbpath", "/data/db", "--keyFile", "/mongoscripts/etc/mongo.key", "--journal", "--auth" ]
    volumes:
      - /var/lib/mongodb2/db:/data/db
      - /var/lib/mongodb2/conf:/data/configdb
      - ./mongoscripts:/mongoscripts
  
  mongo3:
    hostname: mongo3
    container_name: localmongo3
    image: mongo:4
    expose:
      - 27017
    ports:
      - 27019:27017
    restart: always
    entrypoint: [ "/usr/bin/mongod", "--bind_ip", "localhost,mongo3", "--replSet", "rs01", "--dbpath", "/data/db", "--keyFile", "/mongoscripts/etc/mongo.key", "--journal", "--auth" ]
    volumes:
      - /var/lib/mongodb3/db:/data/db
      - /var/lib/mongodb3/conf:/data/configdb
      - ./mongoscripts:/mongoscripts

Alle drei Container sind gleichwertig und starten unabhängig von einander. In den Zeilen 14, 29 und 44 sind die jeweiligen Start-Optionen für Mongo übergeben.

  1. Mit bind_ip gebe ich an, auf welche IP-Adressen der Daemon lauschen soll. Neben dem localhost, ist es auch der Hostname des Containers. Ohne diese Option können die Container untereinander nicht kommunizieren.
  2. Die Option --replSet gibt den Namen des replica sets an, zu dem dieser Knoten gehört. Für alle drei Container ist es rs01. Dieser Name ist frei wählbar, muss aber für alle Knoten gleich sein.
  3. Die Option --auth aktiviert die Authentifizierung. Jetzt kann ich nur noch über eine gültige Anmeldung mit dem Server kommunizieren.
  4. In einem replica set mit der Option --auth, muss zwangsweise auch ein --keyFile angegeben werden. In diesem Key-File befindet sich ein generierter Schlüssel. Alle Knoten eines replica sets, müssen über den selben Schlüssel verfügen. Sonst können sie sich nicht gegenseitig authentifizieren.
    1. So ein Schlüssel kann wie folgt generiert werden: openssl rand -base64 756 > ./mongoscripts/etc/mongo.key.
    2. Und ganz wichtig ist die strickte Einschränkung der Berechnung: chmod 400 ./mongoscripts/etc/mongo.key.
  5. Beachte auch das Port-Mapping und die Host-Namen.

Schritt 5 – replica set initialisieren

Im letzten Schritt melde ich mich mit dem Benutzer aus Schritt 1 an, um das replica set zu initialisieren:

docker exec localmongo1 /mongoscripts/run/replicationset.sh

#!/usr/bin/env bash

MONGODB1=mongo1
MONGODB2=mongo2
MONGODB3=mongo3

until curl http://${MONGODB1}:27017/serverStatus\?text\=1 2>&1 | grep uptime | head -1; do
  printf '.'
  sleep 1
done

mongo --host ${MONGODB1}:27017 -u root -p root --authenticationDatabase admin <<EOF
var cfg = {
    "_id": "rs01",
    "protocolVersion": 1,
    "version": 1,
    "members": [
        {
            "_id": 0,
            "host": "${MONGODB1}:27017",
            "priority": 2
        },
        {
            "_id": 1,
            "host": "${MONGODB2}:27017",
            "priority": 0
        },
        {
            "_id": 2,
            "host": "${MONGODB3}:27017",
            "priority": 0
        }
    ],settings: {chainingAllowed: true}
};
rs.initiate(cfg, { force: true });
rs.reconfig(cfg, { force: true });
rs.secondaryOk();
db.getMongo().setReadPref('nearest');
db.getMongo().setSecondaryOk(); 
EOF

Dieses Script stellt die Verbindung zwischen allen Knoten her und bestimmte auch welcher Knoten der Primary ist und welche die Secondaries sind. Siehe die "priority" Option.

Hinweis: Ich muss nach dem Start der Container ein wenig warten, bis ich das Script ausführe. Das liegt daran, dass Mongo selbst Zeit braucht, bis es vollständig initialisiert ist.

Schritt 6 – Firewall

Die MongoDB Ports 27017 – 27019 stehen offen. Zwar ist die Authentifizierung aktiviert, aber die Ports sollten von außen blockiert werden. Ich habe es über die Ubuntu-Firewall (ufw) lösen wollen, habe aber schnell festgestellt, dass die Ports gar nicht gesperrt werden und weiterhin offen bleiben.

Das hat mit Docker zu tun, der seine eigenen ip_tables Regeln setzt und ufw umgeht. In diesem Post ist das Problem und die Lösung beschrieben.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.