Florent Peterschmitt

Un cluster MongoDB avec redondance et répartition de charge

Buts de la réplication avec MongoDB

  • Assurer la résistance à la panne d’au moins une machine.
  • Permettre des accès en lecture plus rapides.
  • Dans un environnement géographiquement étendu, réduit les latences réseau si l’application utilisant la base n’a pas besoin des informations de dernière date.

Choix d’architecture

Le premier (et nécessaire pour le second) est une architecture simple avec un maître (primaire) et des esclaves (secondaires).

Les écritures sont contrôlées par le primaire et retransmises aux secondaires (qui peuvent être plus que deux).

C’est l’architecture de « réplicats » :

               Clients
               Driver
                 ||
                 ||
              Primaire                 }
        /-----^      ^-----\           } base mongodb
Réplica1                    Réplica2   }

Le problème de cette architecture étant la répartition de la charge sur de grosses bases. Si une base subit plus de transactions que la machine ne peut en satisfaire (limitation des disques, CPU…), il devient intéressant d’utiliser une architecture en « shards » :

                            Clients
                            Driver
                              ||
                              ||
                            Routeur_________
                               |            \_____Serveurs de configuration (>1)
                      _________|____________/
                     |      |     |      |           }
                     |      |     |      |           } base mongodb
                    Shard Shard Shard  Shard (>0)    }

Le routeur se chargera de communiquer avec les serveurs de configuration pour pouvoir trouver les données et les donner aux bons shards.

Les shards peuvent être des bases mongo seules mais seront plutôt des réplicats.

En production, MongoDB recommande d’utiliser trois serveurs de configuration pour assurer la continuité de service. Le choix du nombre de shards est une science que je n’ai pas encore apprise.

Pour se connecter à une base MongoDB, nous devons utiliser un « driver » qui est en fait une brique logicielle qui nous utiliserons dans toute application voulant utiliser MongoDB. J’ai choisi le driver en Python pour réaliser les tests.

L’architecture en shards est ce qu’on pourrait appeler une extension bigrement essentielle des réplicats et permettent de distribuer le travail au travers de multiples machines.

Environnement de travail

Trois machines virtuelles hébergeront chacune une instance de mongod, une base MongoDB.

Machines :

  • Debian sid
  • MongoDB 2.4.5
  • Heartbeat 3.0.5 installé et configuré. Fichier haresourses vide.

Réseau :

  • Réseau privé 192.168.56.0/24
  • hôte : .1
  • mongodb0 : .2
  • mongodb1 : .3
  • mongodb2 : .4

Faire

Nous allons voir comment mettre en place un « replica set », la réplication simple avec la répartition de charge que peut offrir les secondaires en lecture.

1. Configurer Heartbeat sur toutes les machines

/etc/ha.d/ha.cf

bcast eth1
deadtime 5
keepalive 2
mcast eth0 239.0.0.1 694 1 0
node mongodb2.localhost mongodb1.localhost mongodb0.localhost

/etc/ha.d/authkeys

auth 1
1 md5 cacc670d1040becee271a4be61cf303d

/etc/ha.d/haresources

-vide-

2. Configurer les DNS

Nous devons avoir des hôtes suivant ce schéma :

  • mongodb0.localhost # primaire
  • mongodb1.localhost # secondaire1
  • mongodb2.localhost # secondaire2

3. Sur chaque machine

Configurer la base MongoDB :

$EDITOR /etc/mongodb.conf
: replSet = rs0

replSet sert à indiquer à notre base à quel « replica set » elle appartient. Le nom est donc arbitraire mais doit être identique pour toutes les bases voulant appartenir au même groupe, évidemment.

Lancer la base MongoDB si ce n’est pas déjà fait (Debian prend soin de tout lancer à notre place, même quand on ne le veut pas…).

$ service mongodb start

4. Sur la machine maître, ou primaire

S’y connecter :

$ mongo

Initier la réplication :

> rs.initiate()
{
    "info2" : "no configuration explicitly specified -- making one",
    "me" : "mongodb0.localhost:27017",
    "info" : "Config now saved locally.  Should come online in about a minute.",
    "ok" : 1
}

MongoDB va générer une configuration pour la réplication, nous pouvons l’afficher :

> rs.config()
{
    "_id" : "rs0",
    "version" : 1,
    "members" : [
        {
            "_id" : 0,
            "host" : "mongodb0.localhost:27017"
        }
    ]
}

Ajouter les secondaires :

> rs.add('mongodb1.localhost')
{ "ok" : 1 }
> rs.add('mongodb2.localhost')
{ "ok" : 1 }

Statut de la réplication (des champs ont été retirés) :

mongodb0 :

rs0:PRIMARY> rs.status()
{
    "set" : "rs0",
    "date" : ISODate("2013-09-14T15:00:44Z"),
    "myState" : 1,
    "members" : [
            {
                    "_id" : 0,
                    "name" : "mongodb0.localhost:27017",
                    "stateStr" : "PRIMARY",
                    "self" : true
            },
            {
                    "_id" : 1,
                    "name" : "mongodb1.localhost:27017",
                    "stateStr" : "SECONDARY",
                    "syncingTo" : "mongodb0.localhost:27017"
            },
            {
                    "_id" : 2,
                    "name" : "mongodb2.localhost:27017",
                    "stateStr" : "SECONDARY",
                    "syncingTo" : "mongodb0.localhost:27017"
            }
    ],
    "ok" : 1
}

Un peut d’attente est nécessaire pour que l’élection se fasse, de l’ordre de quelques secondes. Nous voyons que mongodb1 et mongodb2 se synchronisent sur mongodb0, car c’est le primaire.

Les serveurs secondaires doivent voir chacun de leurs copains :

mongodb1 :

rs0:SECONDARY> rs.status()
{
    "set" : "rs0",
    "date" : ISODate("2013-09-14T15:03:06Z"),
    "myState" : 2,
    "syncingTo" : "mongodb0.localhost:27017",
    "members" : [
            {
                    "_id" : 0,
                    "name" : "mongodb0.localhost:27017",
                    "stateStr" : "PRIMARY",
                    "pingMs" : 3
            },
            {
                    "_id" : 1,
                    "name" : "mongodb1.localhost:27017",
                    "stateStr" : "SECONDARY",
                    "self" : true
            },
            {
                    "_id" : 2,
                    "name" : "mongodb2.localhost:27017",
                    "stateStr" : "SECONDARY",
                    "pingMs" : 1,
                    "syncingTo" : "mongodb0.localhost:27017"
            }
    ],
    "ok" : 1
}

Statut des bases à ce stade :

  • mongodb0 -> primaire
  • mongodb1 -> secondaire
  • mongodb2 -> secondaire

5. Tester l’élection

Coupons le primaire (mongodb0) (brutalement ou pas) :

mongodb1 :

rs0:PRIMARY> rs.status()
{
    "set" : "rs0",
    "date" : ISODate("2013-09-14T15:14:53Z"),
    "myState" : 1,
    "members" : [
            {
                    "_id" : 0,
                    "name" : "mongodb0.localhost:27017",
                    "stateStr" : "(not reachable/healthy)",
            },
            {
                    "_id" : 1,
                    "name" : "mongodb1.localhost:27017",
                    "stateStr" : "PRIMARY",
                    "errmsg" : "db exception in producer: 10278 dbclient error communicating with server: mongodb0.localhost:27017",
                    "self" : true
            },
            {
                    "_id" : 2,
                    "name" : "mongodb2.localhost:27017",
                    "stateStr" : "SECONDARY",
                    "syncingTo" : "mongodb1.localhost:27017"
            }
    ],
    "ok" : 1
}

Le champs "stateStr" nous indique que mongodb0 n’est plus existante. Aussi, un nouveau primaire a été élu, il s’agit de mongodb1. De ce fait, mongodb2 se synchronise sur mongodb1. On pourra voir –presque– la même chose sur la machine restée secondaire.

Redémarrons maintenant l’ancienne machine primaire (mongodb0) :

mongodb1 :

rs0:PRIMARY> rs.status()
{
    "set" : "rs0",
    "date" : ISODate("2013-09-14T15:15:44Z"),
    "myState" : 1,
    "members" : [
            {
                    "_id" : 0,
                    "name" : "mongodb0.localhost:27017",
                    "stateStr" : "SECONDARY",
                    "syncingTo" : "mongodb1.localhost:27017"
            }

Nous voyons maintenant que la base mongodb0 se synchronise avec la nouvelle machine primaire mongodb1.

Désormais, nous avons :

  • mongodb0 -> secondaire
  • mongodb1 -> primaire
  • mongodb2 -> secondaire

6. Tester la réplication

J’ai réalisé un petit script pour faciliter les essais. Le script écrit et lit dans la base « test » et la collection « posts ».

#!/usr/bin/env python

import datetime
import time
import signal
import argparse
import inspect
import sys

import pymongo

class MongoDBTest:
    CONTW = True

    def sigtrap_int(self, signum, frame):
        MongoDBTest.CONTW = False

    def __init__(self, readsec, servers=[
        'mongodb0.localhost:27017',
        'mongodb1.localhost:27017',
        'mongodb2.localhost:27017']):

        self.client = pymongo.MongoReplicaSetClient(
                'mongodb://{}/'.format(','.join(servers)),
                replicaSet='rs0',
                connectTimeoutMS=1000,
                socketTimeoutMS=1000)

        self.db = self.client['test']
        rpref = pymongo.read_preferences.ReadPreference
        if readsec:
            # read from secondaries
            self.db.read_preference = rpref.SECONDARY
        else:
            self.db.read_preference = rpref.PRIMARY

        signal.signal(signal.SIGINT, self.sigtrap_int)

    def insert(self):
        date = datetime.datetime.now()
        insert_post = {'date': date.strftime('%H:%M:%S')}
        self.db.posts.insert(insert_post)

    def remove(self, oid=None):
        if not oid:
            oid = self.db.posts.find_one()['_id']
        self.db.posts.remove(oid)

    def printall(self):
        header = '#### {} : {} | Secondaries: {}Master: {} ####\n'
        poststr = 'id: {} | date: {}\n'

        secs = self.db.connection.secondaries
        secstr = ''
        primary = 'None'
        try:
            primary = self.db.connection.primary[0]
        except TypeError:
            pass
        posts = self.db.posts.find()
        date = datetime.datetime.now().strftime('%H:%M:%S')
        caller = inspect.stack()[1][3]
        for sec in secs: secstr += str(sec[0]) + ' '

        sys.stdout.write(header.format(date, caller, secstr, primary))
        for post in posts:
            sys.stdout.write(poststr.format(post['_id'], post['date']))
        sys.stdout.flush()

    def drop(self):
        for post in self.db.posts.find():
            self.remove(post['_id'])

    def fill(self, count):
        if count < 1:
            count = 1
        for i in range(0, count):
            self.insert()

    ############## TESTS ##############

    def test_fill_roll(self):
        self.remove()
        self.insert()

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument('--init', dest='init', action='store_true')
    parser.add_argument('--add', dest='add', action='store_true')
    parser.add_argument('--show', dest='show', action='store_true')
    parser.add_argument('--test-fill-roll', dest='test_fr', action='store_true')
    parser.add_argument('--read-sec', dest='readsec', action='store_true',
            default=False)
    parser.add_argument('--forever', dest='forever', action='store_true',
            default=False)
    args = parser.parse_args()

    mtest = MongoDBTest(args.readsec)

    while MongoDBTest.CONTW:
        try:
            if args.init:
                mtest.drop()
            if args.add:
                mtest.fill(2)

            if args.test_fr:
                mtest.test_fill_roll()

            if args.show:
                mtest.printall()

            if not args.forever:
                break
            else:
                time.sleep(1)

        except pymongo.errors.AutoReconnect:
            sys.stderr.write('Cannot auto-reconnect\n')
            time.sleep(1) # should not exists, but 20% cooler for tests
        except pymongo.errors.ConnectionFailure:
            sys.stderr.write('Cannot connect to a master\n')
            time.sleep(1) # should not exists, but 20% cooler for tests
        except ConnectionRefusedError:
            pass
            time.sleep(1) # should not exists, but 20% cooler for tests
        except AssertionError:
            pass
            time.sleep(1) # should not exists, but 20% cooler for tests
        finally:
            sys.stderr.flush()

if __name__ == '__main__':
    main()

Vider la base y écrire des données et les afficher (lecture depuis le primaire) :

python mongotest.py --init --add --show

#### 19:15:09 : main | Secondaries: mongodb1.localhost mongodb2.localhost Master: mongodb0.localhost ####
id: 5235eb1dbb45f954fea1a8ec | date: 19:15:09
id: 5235eb1dbb45f954fea1a8ed | date: 19:15:09

Éteindre le nœud primaire, attendre l’élection d’un nouveau primaire et tenter de lire les données. Elles doivent être identiques.

python mongotest.py --show

#### 19:16:46 : main | Secondaries: mongodb1.localhost Master: mongodb2.localhost ####
id: 5235eb1dbb45f954fea1a8ec | date: 19:15:09
id: 5235eb1dbb45f954fea1a8ed | date: 19:15:09

Ajouter des données et vérifier leur présence :

python mongotest.py --add --show

#### 19:17:15 : main | Secondaries: mongodb1.localhost Master: mongodb2.localhost ####
id: 5235eb1dbb45f954fea1a8ec | date: 19:15:09
id: 5235eb1dbb45f954fea1a8ed | date: 19:15:09
id: 5235eb9abb45f956252e2a26 | date: 19:17:14
id: 5235eb9bbb45f956252e2a27 | date: 19:17:15

Rallumez le précédent nœud (mongodb0), attendez sa synchronisation puis éteignez le nouveau nœud primaire (mongodb2), les données du premier et second ajout (--add) doivent être présentes :

python mongotest.py --show

#### 19:21:22 : main | Secondaries: mongodb1.localhost Master: mongodb0.localhost ####
id: 5235eb1dbb45f954fea1a8ec | date: 19:15:09
id: 5235eb1dbb45f954fea1a8ed | date: 19:15:09
id: 5235eb9abb45f956252e2a26 | date: 19:17:14
id: 5235eb9bbb45f956252e2a27 | date: 19:17:15

Voyons ce qu’il se passe quand nous voulons écrire, puis lire depuis les secondaires :

python mongotest.py --test-fill-roll --show --forever --read-sec

#### 17:56:22 : test_fill_roll | Secondaries: mongodb2.localhost Master: mongodb0.localhost ####
id: 5235d8a3bb45f943f54f71dd | date: 17:56:19
id: 5235d8a4bb45f943f54f71de | date: 17:56:20
id: 5235d8a5bb45f943f54f71df | date: 17:56:21
#### 17:56:23 : test_fill_roll | Secondaries: mongodb2.localhost Master: mongodb0.localhost ####
id: 5235d8a4bb45f943f54f71de | date: 17:56:20
id: 5235d8a5bb45f943f54f71df | date: 17:56:21
id: 5235d8a6bb45f943f54f71e0 | date: 17:56:22
id: 5235d8a7bb45f943f54f71e1 | date: 17:56:23

test-fill-roll supprime le premier enregistrement (le plus vieux) et ajoute un enregistrement immédiatement après. Une pause d’une seconde est faite entre chaque.

On voit ici qu’un insertion n’a pas eu le temps de percer jusqu’au secondaire et il se trouve que nous avons lu depuis ce secondaire, d’où un enregistrement manquant. Quand on lit depuis le primaire, nous n’avons pas ce problème, les données reçues sont celles écrites récemment.

Amusons nous à couper le maître, par exemple. Ici une extinction brutale de la VM ne posera pas de problème, mais on peut aussi le faire plus doucement avec l’ACPI.

Nous obtiendrons cela, par exemple :

python mongotest.py --test-fill-roll --show --forever

#### 18:37:59 : main | Secondaries: mongodb2.localhost mongodb0.localhost Master: mongodb1.localhost ####
id: 5235e264bb45f94e2c774f1c | date: 18:37:56
id: 5235e265bb45f94e2c774f1d | date: 18:37:57
id: 5235e266bb45f94e2c774f1e | date: 18:37:58
id: 5235e267bb45f94e2c774f1f | date: 18:37:59
Cannot auto-reconnect
Cannot auto-reconnect
Cannot auto-reconnect
Cannot auto-reconnect
Cannot auto-reconnect
Cannot auto-reconnect
Cannot auto-reconnect
#### 18:38:11 : main | Secondaries: mongodb2.localhost Master: mongodb0.localhost ####
id: 5235e265bb45f94e2c774f1d | date: 18:37:57
id: 5235e266bb45f94e2c774f1e | date: 18:37:58
id: 5235e267bb45f94e2c774f1f | date: 18:37:59
id: 5235e273bb45f94e2c774f20 | date: 18:38:11

Le temps pendant lequel nous ne pouvons pas nous connecter dure quelques secondes, dépendant de la configuration Heartbeat.

7. L’accès en lecture seule

Très simple, lancer les trois bases et le test de lecture seule, puis éteindre le primaire :

python mongotest.py --show --forever --read-sec

#### 20:34:55 : main | Secondaries: mongodb0.localhost mongodb2.localhost Master: mongodb1.localhost ####
id: 5235eb1dbb45f954fea1a8ec | date: 19:15:09
id: 5235eb1dbb45f954fea1a8ed | date: 19:15:09
id: 5235eb9abb45f956252e2a26 | date: 19:17:14
id: 5235eb9bbb45f956252e2a27 | date: 19:17:15
#### 20:34:56 : main | Secondaries: mongodb2.localhost Master: mongodb0.localhost ####
id: 5235eb1dbb45f954fea1a8ec | date: 19:15:09
id: 5235eb1dbb45f954fea1a8ed | date: 19:15:09
id: 5235eb9abb45f956252e2a26 | date: 19:17:14
id: 5235eb9bbb45f956252e2a27 | date: 19:17:15

Aucune action d’écriture n’est réalisée dans cet essai. Pendant le temps où aucun primaire n’a été ré-élu, on a continué à lire les donnés présentes et la reprise s’est faite sans aucun problème. Pas de coupure non plus lors de l’extinction du primaire.

Conclusion

La réplication avec MongoDB est presque magique, après quelques heures de documentation et essais on arrive à une installation pleinement fonctionnelle.

J’ai préféré mettre l’accent sur des démonstrations plutôt que de me lancer dans une architecture à shards, bien que le principale soit fait avec les réplicats. À suivre dans un prochain épisode.

À propos de ce qui est fait :

  • La configuration est très simple, j’ai même cru à un piège concernant Heartbeat.
  • Viandage au début, écrire sur la base « local » n’est pas une bonne idée car contenant la configuration de la base…
  • La reprise de service (élection d’un nouveau primaire) est tout de même longue : 10 secondes environ.

À faire/tester :

  • Éloignement géographique.
  • Sauvegarder une base MongoDB.
  • Les bases en « shards », avec les routeurs et serveurs de configuration.
  • Comparer avec d’autres bases, comme CouchDB par exemple, qui propose une réplication à double sens.

Une petite vidéo (qui vaut le coup d’installer flash) : http://www.youtube.com/watch?v=jyx8iP5tfCI