Ausfallsicheres MongoDB-Replica-Set in Ubuntu 20 erstellen

Heyho! Du willst also freiwillig das Abenteuer wagen, MongoDB ausfallsicher auf mehreren Servern zu installieren? Hut ab! Hier erkläre ich dir, wie das gehen könnte. Wie immer gilt: Niemand ist perfekt und du solltest deine eigenen Recherchen machen!

Diese Anleitung bezieht sich auf Version 4.4 (https://docs.mongodb.com/manual/tutorial/install-mongodb-on-ubuntu/). Es empfielt sich, immer eine spezifische Version zu installieren – sonst beschwert sich dein Nachbar und schellt mitten in der Nacht.

“Hey! du hast auf deiner lokalen Maschine eine andere Version als auf deinen Produktions-Servern! Deine Mutter schämt sich!”

Achte auch darauf, bei der Installation das File-System XFS zu nutzen. Dies ist für die Nutzung von Journaling (wichtig bei Replication) empfohlen.

Also legen wir los! Zunächst installierst du die nötige Library, inklusive allem, was du so brauchst.

wget -qO - https://www.mongodb.org/static/pgp/server-4.4.asc | sudo apt-key add -
echo "deb [ arch=amd64,arm64 ] https://repo.mongodb.org/apt/ubuntu focal/mongodb-org/4.4 multiverse" | sudo tee /etc/apt/sources.list.d/mongodb-org-4.4.list
sudo apt update
sudo apt-get install -y mongodb-org=4.4.0 mongodb-org-server=4.4.0 mongodb-org-shell=4.4.0 mongodb-org-mongos=4.4.0 mongodb-org-tools=4.4.0

SSL Zertifikat erstellen

Als nächstes kannst du (solltest du) SSL-Zertifikate erstellen. Das erlaubt dir eine verschlüsselte Kommunikation zwischen dir und den Servern. Das ist natürlich nicht nötig, wenn du ein eigenes Netzwerk hast, auf das keiner zugreift (oder VPN getunnelt). Kann aber nicht schaden.

Zunächst auf der ersten Maschine ein Arbeitsverzeichnis erstellen:

cd
mkdir mongo-ssl
cd mongo-ssl

Dann erstellst du das CA-Zertifikat, das für alle Server gilt; deshalb musst du es auch nur einmal machen. Die resultierende Datei mongo-ca.pem sollte dann auch in allen Replikationen, bzw. Servern, benutzt werden. Die Eingabe des “Common-Name” ist egal!

openssl genrsa -out mongo-ca.key 2048
openssl req -x509 -new -nodes -key mongo-ca.key -sha256 -days 3650 -out mongo-ca.pem

Als nächstes erstellst du das Client-File für einen Server. Diese musst du für jeden Server erstellen. Die Datei mongo-server.pem , die erzeugt wird, wird später in den Mongo-Einstellungen benutzt. Achtung: Der Common-Name muss die IP des Servers sein!

openssl genrsa -out mongo-server.key 2048
openssl req -new -key mongo-server.key -out mongo-server.csr
openssl x509 -req -in mongo-server.csr -CA mongo-ca.pem -CAkey mongo-ca.key -CAcreateserial -out mongo-server.crt -days 3650 -sha256
cat mongo-server.key mongo-server.crt > mongo-server.pem

Als letztes kommt das Client-File für Anwendungen, z.B. um sich lokal mit dem Server zu verbinden oder über eine externe Applikation. Dazu benutzt du die Datei mongo-client.pem. Der Common-Name ist egal. Um diese Datei zu erstellen, benötigst du die Dateien mongo-ca.pem und mongo-ca.key.

openssl genrsa -out mongo-client.key 2048
openssl req -new -key mongo-client.key -out mongo-client.csr
openssl x509 -req -in mongo-client.csr -CA mongo-ca.pem -CAkey mongo-ca.key -CAcreateserial -out mongo-client.crt -days 3650 -sha256
cat mongo-client.key mongo-client.crt > mongo-client.pem

Anschließend kopierst du die Keys in ein eigenes Verzeichnis. Aus Gründen der Einfachheit kopieren wir hier alle (um die Verbindung später zu testen), aber auf einem Produktions-Server benötigst du nur die Dateien mongo-server.pem und mongo-ca.pem.

sudo mkdir /var/ssl
sudo mkdir /var/ssl/mongo
sudo cp * /var/ssl/mongo
sudo chmod 777 /var/ssl/mongo/*.pem

Server absichern

MongoDB-Replikations-Server kommunzieren ohne dein Zutun unverschlüsselt miteinander, und das ist natürlich superdoof. Damit das nicht so ist, generierst du ein zusätzliches Keyfile, das du bei jeder Replikation verwendest (praktisch wie ein langes Passwort) (Quelle). Das wird so generiert:

cd

# Keyfile erstellen
openssl rand -base64 756 > mongo-keyfile

# Verzeichnis erstellen
sudo mkdir /opt/mongo

# Keyfile verschieben
sudo mv ~/mongo-keyfile /opt/mongo
sudo chmod 400 /opt/mongo/mongo-keyfile

# Ownership ändern
sudo chown mongodb:mongodb /opt/mongo/mongo-keyfile

Achtung: Da wir später noch “authorization: enabled” einstellen (d.h. Anmeldung auf die MongoDB soll nur mit Passwort möglich sein), müssen Keyfiles eingestellt sein, sonst können die Server nicht miteinander kommunizieren. Dieser Schritt ist also nicht optional!

Ports freigeben

Darf nicht vergessen werden: Schalte Port 27017 frei. Dieser dient sowohl zur Kommunikation mit der Datenbank als auch zur Replikation der Server untereinander.

Config bearbeiten

Es geht ans Eingemachte! Bearbeite die Mongo-Config: sudo mcedit /etc/mongod.conf

net:
  port: 27017
  bindIp: 0.0.0.0
  tls:
    mode: preferTLS
    certificateKeyFile: /var/ssl/mongo/mongo-server.pem
    CAFile: /var/ssl/mongo/mongo-ca.pem
    allowInvalidCertificates: true

security:
  # Die untenstehende Zeile noch auskommentiert lassen
  #authorization: enabled
  keyFile: /opt/mongo/mongo-keyfile

replication:
  replSetName: "rs0"

Achtung: Unter bindIp solltest du statt 0.0.0.0 alle Server, die Zugriff haben sollen, direkt angeben. In diesem Fall sorgt die MongoDB-Firewall später dafür, dass kein anderer Server Zugriff hat.

Die Zeile authorization lässt du noch kommentiert, die aktivieren wir später, wenn alles läuft.

Jetzt den Service beim Startup enablen, dann starten und testen:

sudo systemctl enable mongod
sudo systemctl start mongod
sudo systemctl status mongod

Damit hast du MongoDB auch schon gestartet – als 1-Server-Replikation. Nun sollte der Angstschweiß ausbrechen, denn du verbindest dich jetzt lokal. Das geht noch einfach und ohne Passwort, da wir oben authentication noch nicht enabled haben.

mongo

Wenn du keinen Fehler hast und in der Mongo-Console bist, beende diese mit exit und versuche eine Verbindung per TLS aufzubauen:

mongo <IP SERVER> --tls --tlsCAFile /var/ssl/mongo/mongo-ca.pem --tlsCertificateKeyFile /home/ubuntu/mongo-ssl/mongo-client.pem

Hat alles geklappt? Wenn ja, super! Wenn nein, oh oh. Der einzige Tipp, den ich habe: wiederhole die Installation auf einer frischen Maschine und bete heftiger.

Da aber sicherlich alles geklappt hat, startest du die Mongo-Console udn gibst folgenden Befehl zum Starten der Replikation ein:

rs.initiate()

exit

Jetzt ist deine 1-Server-Replikation wirklich gestartet.

User-Zugang einrichten

MongoDBs sind von Geburt an offen zugänglich und von jedermann zu benutzen. Das führt dazu, dass etliche Instanzen im Internet frei zugänglich sind. Suboptimal – und das will keiner haben. Neben einer Sicherung über die Firewall solltest du jetzt also auch einen Admin-User einrichten.

# Mongo-Shell starten
mongo

# Befehle zum Eintragen von adminUser
use admin;
db.createUser({
	user: "adminUser",
	pwd:"<PASSWORD>",
	roles: [
		{
			role: "userAdminAnyDatabase",
			db: "admin"
		}, {
			role: "dbAdminAnyDatabase", db: "admin"
		}, {
			role: "readWriteAnyDatabase",
			db:"admin"
		}, { 
			role: "clusterAdmin", 
			db: "admin"
		}
	]
});

exit

# Verbinden mit
mongo -u adminUser -p --authenticationDatabase admin

Quelle: https://medium.com/mongoaudit/how-to-enable-authentication-on-mongodb-b9e8a924efac

Anschließend den obigen authorization Kommentar (/etc/mongod.conf) entfernen. Damit kann sich niemand mehr auf den Cluster ohne Passwort einloggen.

Normalen User hinzufügen

Du möchtest bestimmt nicht den AdminUser nutzen, wenn der Webserver sich einloggen soll. Um einen normalen User anzulegen, benutze die folgenden Befehle; die Autorisierungsdatenbank ist admin.

use admin;
db.createUser({
	user: "<BENUTZER>",
	pwd:"<PASSWORD>",
	roles: [
		{
			role: "readWrite",
			db:"<DATENBANK>"
		}
	]
});

Um alle Benutzer anzuzeigen, nutzt du diesen Befehl:

use admin;
db.getUsers();

Server klonen und hinzufügen

Um eine richtige Replikation aufzubauen, bedarf es mehr als einen Server. Mindestens benötigst du drei. Je mehr Server du hast, desto mehr dürfen ausfallen – niemals mehr oder genausoviel als die Hälfte.

3 Server: 1 darf ausfallen
4 Server: 1 darf ausfallen (macht also keinen Sinn)
5 Server: 2 dürfen ausfallen

Der einfachste Weg ist es, diesen Server zu klonen und den Mongo-Daemon zu starten.

sudo systemctl start mongod

Anschließend fügst du den neuen Server auf dem “Primary” (dein erster Server) als “Secondary” hinzu: Dazu auf Primary einloggen und folgende Befehle eintragen.

rs.add( { host: "<IP Server 2>:27017", priority: 2, votes: 1 } )

Die kompletten Options findest du hier.

An dieser Stelle ist es wichtig zu verstehen, wie das Voting bei MongoDB funktioniert: Fällt ein Primary aus (von dem es immer nur einen gibt), wird unter den verbliebenen Servern entschieden, wer der Neue wird. Das passiert aber nur, wenn die Mehrheit der Server im Cluster noch online ist! D.h. bei einem Cluster mit drei Servern darf nur einer ausfallen. Genauso verhält es sich bei vier Servern; deshalb macht ein Cluster am meisten in ungeraden Zahlen Sinn.

Fällt also bei drei Servern einer aus, entscheiden die beiden anderen (sofern voting auf 1 gestellt ist), wer als nächster die Krone auf hat. Das geht dann nach priority. Ist der alte Primary wieder online, ändert sich aber nichts mehr.

Der Vollständigkeit halber: Hast du nur zwei Server zur Verfügung, kannst du einen Arbiter erstellen. Das ist im Prinzip nichts anderes als eine MongoDB-Instanz, die nur abstimmen darf. Bei zwei Servern hat also einer noch einen Arbiter – fällt aber dieser aus, ist der ganze Cluster down.

Replikation prüfen

Hast du mindestens zwei Server am Start und diese dem Cluster hinzugefügt? Dann prüfen den Status der Replication:

rs.status()

Das Ergebnis sollte so aussehen:

{
    "set": "rs0",
    "date": ISODate("2020-08-14T08:30:17.505Z"),
    "myState": NumberInt("1"),
    "term": NumberLong("1"),
    "syncSourceHost": "",
    "syncSourceId": NumberInt("-1"),
    "heartbeatIntervalMillis": NumberLong("2000"),
    "majorityVoteCount": NumberInt("1"),
    "writeMajorityCount": NumberInt("1"),
    "votingMembersCount": NumberInt("1"),
    "writableVotingMembersCount": NumberInt("1"),
    "optimes": {
        "lastCommittedOpTime": {
            "ts": Timestamp(1597393813, 1),
            "t": NumberLong("1")
        },
        "lastCommittedWallTime": ISODate("2020-08-14T08:30:13.499Z"),
        "readConcernMajorityOpTime": {
            "ts": Timestamp(1597393813, 1),
            "t": NumberLong("1")
        },
        "readConcernMajorityWallTime": ISODate("2020-08-14T08:30:13.499Z"),
        "appliedOpTime": {
            "ts": Timestamp(1597393813, 1),
            "t": NumberLong("1")
        },
        "durableOpTime": {
            "ts": Timestamp(1597393813, 1),
            "t": NumberLong("1")
        },
        "lastAppliedWallTime": ISODate("2020-08-14T08:30:13.499Z"),
        "lastDurableWallTime": ISODate("2020-08-14T08:30:13.499Z")
    },
    "lastStableRecoveryTimestamp": Timestamp(1597393753, 1),
    "electionCandidateMetrics": {
        "lastElectionReason": "electionTimeout",
        "lastElectionDate": ISODate("2020-08-14T07:01:13.309Z"),
        "electionTerm": NumberLong("1"),
        "lastCommittedOpTimeAtElection": {
            "ts": Timestamp(0, 0),
            "t": NumberLong("-1")
        },
        "lastSeenOpTimeAtElection": {
            "ts": Timestamp(1597388473, 1),
            "t": NumberLong("-1")
        },
        "numVotesNeeded": NumberInt("1"),
        "priorityAtElection": 1,
        "electionTimeoutMillis": NumberLong("10000"),
        "newTermStartDate": ISODate("2020-08-14T07:01:13.335Z"),
        "wMajorityWriteAvailabilityDate": ISODate("2020-08-14T07:01:13.357Z")
    },
    "members": [
        {
            "_id": NumberInt("0"),
            "name": "<IP1>:27017",
            "health": 1,
            "state": NumberInt("1"),
            "stateStr": "PRIMARY",
            "uptime": NumberInt("5488"),
            "optime": {
                "ts": Timestamp(1597393813, 1),
                "t": NumberLong("1")
            },
            "optimeDate": ISODate("2020-08-14T08:30:13.000Z"),
            "syncSourceHost": "",
            "syncSourceId": NumberInt("-1"),
            "infoMessage": "",
            "electionTime": Timestamp(1597388473, 2),
            "electionDate": ISODate("2020-08-14T07:01:13.000Z"),
            "configVersion": NumberInt("2"),
            "configTerm": NumberInt("1"),
            "self": true,
            "lastHeartbeatMessage": ""
        },
        {
            "_id": NumberInt("1"),
            "name": "<IP2>:27017",
            "health": 1,
            "state": NumberInt("2"),
            "stateStr": "SECONDARY",
            "uptime": NumberInt("131"),
            "optime": {
                "ts": Timestamp(1597393813, 1),
                "t": NumberLong("1")
            },
            "optimeDurable": {
                "ts": Timestamp(1597393813, 1),
                "t": NumberLong("1")
            },
            "optimeDate": ISODate("2020-08-14T08:30:13.000Z"),
            "optimeDurableDate": ISODate("2020-08-14T08:30:13.000Z"),
            "lastHeartbeat": ISODate("2020-08-14T08:30:15.545Z"),
            "lastHeartbeatRecv": ISODate("2020-08-14T08:30:17.067Z"),
            "pingMs": NumberLong("0"),
            "lastHeartbeatMessage": "",
            "syncSourceHost": "<IP1>:27017",
            "syncSourceId": NumberInt("0"),
            "infoMessage": "",
            "configVersion": NumberInt("2"),
            "configTerm": NumberInt("1")
        }
    ],
    "ok": 1,
    "$clusterTime": {
        "clusterTime": Timestamp(1597393813, 1),
        "signature": {
            "hash": BinData(0, "AAAAAAAAAAAAAAAAAAAAAAAAAAA="),
            "keyId": NumberLong("0")
        }
    },
    "operationTime": Timestamp(1597393813, 1)
}

Wenn du soweit bist, ist es Zeit, den Cluster zu testen. Was passiert, wenn ein paar Server ausfallen? Ausprobieren!

Was passiert, wenn man Server 1 (Primary) abschaltet?

S2 und S3 bleiben zunächst Secondaries, nach ein paar Sekunden wird dann der Server mit der höchsten Priority gewählt. Wenn man Server 1 wieder anmacht, bleibt er Secondary.

Was passiert, wenn man Server 3 (Secondary) abschaltet?

Dann wird rs.status() bei Member 3 einen Fehler melden. Die anderen Server laufen aber ohne Probleme weiter. Wird Server 3 wieder aktiviert, synchronisiert er sich automatisch.

Was passiert, wenn man mehr als die Hälfte der Server ausschaltet?

Dann gibt es keine Mehrheit im Voting mehr, d.h. der Primary wird zum Secondary und es ist kein Schreiben in die Datenbank mehr möglich. Grundsätzlich gilt: Wenn es keinen Primary gibt, ist das System nicht mehr als Replication Set ansprechbar. Um mit dem letzten Server zu kommunizieren, muss man auf diesen direkt per SSH connecten. Was hilft: einen zweiten Server wieder aktivieren oder den Server als Einzelinstanz neu starten.

Was passiert, wenn man ein falsches SSL-Zertifikat nutzt?

Ändern wir doch einfach mal das Zertifikat von einem Server (sudo mcedit /var/ssl/mongo/mongo-server.pem). Schreibe hier ein paar Zeichen rein, die das Zertifikat ungültig machen. Anschließend die MongoDB neu starten (sudo systemctl restart mongod).

Ergebnis: rs.status() gibt einen “unhealthy” Server aus. Das sieht man auch, wenn man die Log-Dateien auf dem betroffenen Server anschaut:

Andere Wege, neue Nodes hinzuzufügen

Der Replikationsstatus und auch die Administrations-User sind alle im Data-Verzeichnis (/var/lib/mongodb) gespeichert. Löscht man dieses komplett (Service vorher beenden), ist der Server im “Null-Modus” und komplett resetted. Um einen neuen Server hinzuzufügen, kann man ganz einfach das Data-Dir kopieren; dann synchronisiert sich der Server schneller.

Ein paar Tipps

Hier ein paar Links für dich, um dich tiefer mit der Materie zu beschäftigen:

Björn Falszewski
31. Oktober 2020
Disclaimer
Alle meine Artikel entstehen mit bestem Wissen und Gewissen, sind aber nicht perfekt und sollten immer nur als Ausgangspunkt für deine eigenen Recherchen bilden.

Sollte dir etwas Fehlerhaftes auffallen, freue ich mich über deine Nachricht!