Networking avec Docker (2/3)
Lundi 22 Août 2016 à 03:42

Dans cette seconde partie, on va commencer à aller légèrement plus loin. Comme lancer des conteneurs à la main c’est fatigant, on va s’intéresser à docker-compose qui permet d’orchestrer le déploiement de plusieurs conteneurs. Et comme la pratique c’est plus fun, on fera une petite étude de cas à la fin :smile:

Préambule

Docker compose, ça permet de déployer une application de plusieurs services sans lancer tous les conteneurs manuellement, et en faisant tous les exports/publish/liens à la main. C’est du YAML très simple, et ça permet aussi de gérer les dépendances des services.

Avant de lancer web, il faut lancer maria:

version: '2'
services:
  web:
    build: .
    ports:
        - "5000:5000"
    depends_on:
        - maria
    links:
        - maria
  maria:
    image: mariadb
    expose:
        - "3306"
    environment:
        - MYSQL_ROOT_PASSWORD=secret

Bon, c’était l’exemple bateau, la syntaxe de base est très simple et on peut voir facilement ce que ça fait: Lance maria avec l’image mariadb, puis lance web qui contient un lien vers maria.

La règle “ports” est l’équivalent de notre “-p” d’avant. La règle expose est équivalente à avant. Sauf qu’ici, elle ne sert à rien puisqu’il y a déjà un EXPOSE dans l’image mariadb. Mais c’est pour l’exemple.

On lance les conteneurs avec docker-compose up -d et on a accès à notre belle page web sur le port 5000.

Création de réseau

Bon, avec tout ça, qu’est ce que notre compose fait exactement ?

Il créé un nouveau réseau et fait tourner les conteneurs dedans. Ici, il a créé le réseau “experiments_default” parce que je l’ai lancé depuis mon dossier “experiments” et que le réseau créé par défaut s’appelle… default.

Là où c’est encore un peu tordu (de mon point de vue) c’est que la règle “links” ne sert pas à grand chose. Il faut fouiller un peu la documentation pour voir qu’en fait, tous les conteneurs du réseau compose par défaut peuvent se parler, après tout c’est un réseau défini par l’utilisateur.

Donc, les liens permettent surtout d’aliaser des services puisque lesdits services peuvent déjà se parler par défaut.

Un petit docker network inspect experiments_default nous permet de vérifier ce que l’on savait déjà, et on est parés pour la suite.

=> Magie, un nouveau réseau bridge avec deux conteneurs dedans.

Un peu plus loin

Le réseau par défaut c’est bien, mais tout le monde connait tout le monde, et les réseaux flat c’est pas super joli. Heureusement, on peut établir des réseaux depuis compose. Pour cela, on va modifier un peu les services actuels: On garde maria, et on supprime web pour le séparer en webapp et api. Ce à quoi on rajoute le service job qui va taper sur maria (en toute amitié évidemment).

----------       -------      ---------
| webapp | ----- | api | -----| maria |
----------       -------      ---------
                                  |
                              ---------
                              |  job  |
                              ---------

Donc, notre application ressemble à peu près à ça. Et comme aujourd’hui j’ai beaucoup d’imagination, ‘webapp’ va être sur le réseau ‘front’ et les trois autres sur le réseau ‘back’.

version: '2'
services:
  webapp:
    build: webapp/
    ports:
        - "5000:5000"
    depends_on:
        - api
    links:
        - api
    networks:
        - front
  api:
    build: api/
    expose:
        - "80"
    depends_on:
        - maria
    networks:
        - back
  maria:
    image: mariadb
    environment:
        - MYSQL_ROOT_PASSWORD=secret
    networks:
        - back
  job:
    build: job/
    depends_on:
        - maria
    networks:
        - back

networks:
    front:
        driver: bridge
    back:
        driver: bridge

Et ça ne marche PAS. Parce que assez logiquement, deux réseaux ne peuvent pas se parler entre eux.

En inspectant les réseaux ‘experiments_front’ et ‘experiments_back’, on se rend compte que webapp est tout seul, et que les trois autres s’amusent sans lui. Mais du coup, impossible de parler entre webapp et api.

Pour remédier à ça, il suffit de dire qu’api fait partie de ‘front’ ET de ‘back’ (ou que webapp fait partie des deux, selon ce que vous trouvez le plus logique). Je ne remettrai pas le docker-compose.yml pour une seule ligne modifiée, mais du coup ça marche. On a au final 4 conteneurs, répartis entre ‘experiments_front’ et ‘experiments_back’.

En inspectant les réseaux, on retrouve ce à quoi on s’attendait:

docker network inspect experiments_front:
{ blabla,
"Containers": {
            "1e476411a9002c4fd4edf63febcf7d10c723b92236c2a5715ead8550f615e83d": {
                "Name": "experiments_webapp_1",
                "EndpointID": "4999027bef1543ecd79bb06e1a8570917e6d35f72275c0e0052142b0294e5644",
                "MacAddress": "02:42:ac:13:00:02",
                "IPv4Address": "172.19.0.2/16",
                "IPv6Address": ""
            },
            "43f82406fd9f723e10e1b0f4c1c82c2a5dc1d0d7b45e1e250965493cd40b4122": {
                "Name": "experiments_api_1",
                "EndpointID": "218425c5daf10ef985eeec71f2b89e7f1dcbde9d097be760173a4d7818d230f9",
                "MacAddress": "02:42:ac:13:00:03",
                "IPv4Address": "172.19.0.3/16",
                "IPv6Address": ""
            }
        }
    }
}
docker network inspect experiments_back:
{ blabla,
"Containers": {
            "02185afa1ed85e065d8d64b7b69d08417bee4fda35bd4a5f445bf5537889fd58": {
                "Name": "experiments_maria_1",
                "EndpointID": "15e49e652bf28969137e5a7f98ea2a68651984550e8eb586c988cdf0bf0810ee",
                "MacAddress": "02:42:ac:14:00:02",
                "IPv4Address": "172.20.0.2/16",
                "IPv6Address": ""
            },
            "0ffc4e4d90af59277962422be2bc09091e65980fa8bfe04e137753a9da15d944": {
                "Name": "experiments_job_1",
                "EndpointID": "a01c0d713db3f0dc7dc2035cc1313ebc518e3143af0de03d343fe039f3ea7c1e",
                "MacAddress": "02:42:ac:14:00:04",
                "IPv4Address": "172.20.0.4/16",
                "IPv6Address": ""
            },
            "43f82406fd9f723e10e1b0f4c1c82c2a5dc1d0d7b45e1e250965493cd40b4122": {
                "Name": "experiments_api_1",
                "EndpointID": "af3323d97fe09dbf74a9fd278ddb4a3981f6e0c158428cb6aeb6fe3519512083",
                "MacAddress": "02:42:ac:14:00:03",
                "IPv4Address": "172.20.0.3/16",
                "IPv6Address": ""
            }
        }
    }
}

Et voilà, on peut facilement créer des réseaux avec docker-compose, pour créer des infrastructures proches de la réalité. La syntaxe est simple, et c’est assez facile de comprendre ce qu’il se passe.

Etude de cas

Et maintenant, allons un peu avant dans les entrailles de la baleine.

Scénario: Un modèle master-slaves distribué -> chaque noeud doit pouvoir parler à chaque autre. Le master est composé de 3 services, un slave est composé de 5 services.

Problématique: Comment est-ce que je peux configurer mon docker-compose pour poper 1 master et n slaves, n étant raisonnable (2-50, puisque c’est une infra de test en local). Et comme on aime jouer, chacun des services nécessite un fichier de configuration différent, notamment pour lui indiquer ou trouver les autres services (parce que les URIs hardcodées, hmmm hmm….)

Vous avez 2h.

Bon, l’application est quand même bien faite, il se trouve que le master possède un service d’entrée/sortie et deux services internes. Un slave possède qwant à lui un service d’entrée, un service de sortie et trois services internes.

Maintenant, il faut configurer tout ça pour que les services se lancent et arrivent à se contacter sans problème.

Première solution

IaaS (Intern as a Service):

Concrètement, il s’agit de contacter un stagiaire pour qu’il écrive un gros docker-compose.yml, la conf, et lancer le résultat. Pas super.

Deuxième solution

Flat network

Même idée que précédemment, un gros docker-compose avec tous les conteneurs sur le même réseau. En étant un petit peu malin, on peut automatiser via un script bash qui va générer un set de fichiers corrects et en utilisant des volumes pour la conf. Ça marche, mais ça reste TRÈS moche.

Troisième solution

Chacun chez soi

Avec l’exemple webapp/api/maria/job, on a vu que l’on pouvait faire communiquer des conteneurs tout en séparant les points d’entrée/sortie des services internes.

Du coup, il est possible de faire deux fichiers compose de référence: un pour slave et un pour master. Chaque noeud a son réseau interne, sauf les points d’entrée/sortie qui sont sur le réseau interne au conteneur ET sur un réseau prédéfini où ils peuvent communiquer allègrement.

Ça mérite un petit exemple, donc c’est parti. Les exemples sont évidemment allégés pour ne montrer que la partie réseau, les links et dépendences dépendent de l’organisation de l’application et n’ont pas grand intérêt, surtout pour une application inexistante.

cat docker-compose-master.yml

version: '2'
services:
    service_entry:
        image: image_1
        expose:
            - "443"
        networks:
            - internal
            - inout
    service_2:
        image: image_2
        networks:
            - internal
    service_3:
        image: image_2
        networks:
            - internal

networks:
    internal:
        driver: bridge
    inout:
        external:
            name: inout

Le réseau ‘internal’ sera donc le réseau interne à l’appli. Comme docker-compse préfixe le nom d’un réseau par le nom d’un projet, le réseau ‘inout’ est déclaré comme externe, avec un nom fixe. C’est à dire que docker-compose n’essaiera pas de le créer au lancement et le cherchera parmi les réseau déjà existants. Seul pré-requis: il faut le créer à la main avant de lancer les services.

Même chose pour le slave, rien de transcendant:

cat docker-compose-slave.yml

version: '2'
services:
    service_entry:
        image: service_1
        expose:
            - "443"
        networks:
            - internal
            - inout
    service_out:
        image: service_2
        networks:
            - internal
            - inout
    service_3:
        image: service_3
        networks:
            - internal
    service_4:
        image: service_4
        networks:
            - internal
    service_5:
        image: service_5
        networks:
            - internal

networks:
    internal:
        driver: bridge
    inout:
        external:
            name: inout

Et un petit script bash pour orchestrer tout ça:

cat run.sh

#!/bin/bash
docker-compose -p master -f docker-compose-master.yml up -d

for i in `seq 1 $1`; do
    docker-compose -p "slave$i" -f docker-compose-slave.yml up -d
done

Le flag -p spécifie le nom du projet, et -f le fichier à prendre. Le script prend en argument simplement le nombre de slaves à lancer, et créé tout seul les réseau master_internal, slave1_internal, slave2_internal

Vous pouvez essayer de faire un ./run.sh 10 avec des fakes conteneurs et vous verrez 11 réseaux poper, pour un total de 53 conteneurs. C’est le moment d’utiliser la fameuse commande docker rm -f $(docker ps -a -q) dont j’avais parlé en partie 1.


Tip: Si vous avez déjà des conteneurs qui tournent que vous ne voulez pas arrêter, vous pouvez supprimer tous les conteneurs d’un réseau via:

docker rm -f `docker network inspect <network> | grep -o "[a-zA-Z0-9_]\+_[a-zA-Z0-9_]\+_[0-9]\+"`

Sinon, en utilisant les fichiers compose:

docker-compose -p <projet> -f docker-compose-slave.yml stop

Malheureusement, si on se contente du setup précédent, on va avoir quelques surprises pour communiquer à la bonne personne.

Après avoir lancé un master et deux slaves, voici l’état de mes réseaux:

inout:            172.24.0.0/16
master_internal:  172.21.0.0/16
slave1_internal:  172.22.0.0/16
slave2_internal:  172.23.0.0/16

On va se connecter à slave2 pour étudier un peu les connexions auxquelles on a accès:

docker-compose -p slave2 -f docker-compose-slave.yml exec service_out bash

ping service_3              # On ping service_3 sur slave2_internal
ping service_4              # On ping service_4 sur slave2_internal
ping service_entry          # On ping le premier service_entry sur inout (!!)
ping service_out            # On ping le premier service_out sur inout (!!)
ping master_service_entry   # Hôte non accessible
ping master_service_entry_1 # On ping service_entry de master sur inout

En résumé, pour atteindre le bon hôte, la manière la plus sure est de passer par le nom complet du conteneur: <nom projet>_<nom conteneur>_<numéro conteneur>.

Cela dit, on peut aussi se rendre la vie plus facile en utilisant les alias, qui permettent de nommer des conteneurs sur un réseau. Seule limite: sur un réseau donné, les alias doivent être uniques.

Je ne vais pas remettre tous les fichiers ici, seulement le docker-compose-slave.yml et run.sh pour donner l’idée:

cat docker-compose-slave.yml

version: '2'
services:
    service_entry:
        image: service_1
        expose:
            - "443"
        networks:
            internal:
                aliases:
                    - entry
            inout:
                aliases:
                    - SLAVE_ALIAS
    service_out:
        image: service_2
        networks:
            internal:
                aliases:
                    - out
            inout:
    service_3:
        image: service_3
        networks:
            - internal
    service_4:
        image: service_4
        networks:
            - internal
    service_5:
        image: service_5
        networks:
            - internal

networks:
    internal:
        driver: bridge
    inout:
        external:
            name: inout
cat run.sh

#!/bin/bash
docker-compose -p master -f docker-compose-master.yml up -d

for i in `seq 1 $1`; do
    sed -i -e "s/SLAVE_ALIAS/slave$i/g" docker-compose-slave.yml
    docker-compose -p "slave$i" -f docker-compose-slave.yml up -d
    sed -i -e "s/slave$i/SLAVE_ALIAS/g" docker-compose-slave.yml
done

Je l’admets, c’est pas très beau tous ces sed. Voici donc un script alternatif: on va faire des connections à la main. Imaginez juste que toute référence au réseau inout a disparu du fichier compose:

cat run.sh

#!/bin/bash

docker-compose -p master -f docker-compose-master.yml up -d
docker network connect --alias=master inout master_service_entry_1

for i in `seq 1 $1`; do
    docker-compose -p "slave$i" -f docker-compose-slave.yml up -d
    docker network connect --alias="slave$i" inout "slave$i"_service_entry_1 # On connecte l'entrée à inout
    docker network connect inout "slave$i"_service_out_1 # On connecte la sortie à inout
done

La topologie du réseau n’a pas changé par rapport à avant, les mêmes conteneurs restent sur les mêmes réseaux. Seulement, pour communiquer sur un réseau interne, les conteneurs entry, out, service_3, service_4 et service_5 sont accessibles.

Sur le réseau inout, les conteneurs master (pourvu qu’il soit aliasé). slave1 et slave2 sont disponibles, slave1 correspondant au point d’entrée du réseau slave1 et de même pour slave2.

Les points de sortie ont seulement besoin de pouvoir communiquer avec les points d’entrée, c’est pour cela qu’ils n’ont pas d’alias.

Résumé

Qu’est ce qu’on a vu ? Pas mal de choses au final. Docker compose est super pour lancer des applications simplement. Et quand on veut générer des infras un peu plus compliquées, il permet de gérer très simplement des réseaux divers.

Lors de la partie d’avant on a vu comment connecter des conteneurs entre eux. Là on a vu comment connecter des réseaux de conteneurs entre eux, en juste quelques lignes de configuration.

Est-ce qu’on peut aller plus loin ? Et bien, il y a une troisième partie :smile: .


Back to posts