Skip to content
Snippets Groups Projects

Introduction

Ce mini-guide vous donne des pistes pour créer de nouveaux services, tout en restant harmonieux par rapport à l'infrastructure existante.

Pour une plongée plus complète dans les différents concepts abordés ici, on pourra se référer aux documentations officielles. Il n'est pas nécessaire de les lire en entier pour travailler sur ce dépôt : ils seront surtout utiles pour comprendre l'existant.

Se baser sur un autre Dockerfile ?

Tous les services tournant sur l'infrastructure de Picasoft sont voués à être pris en charge par ce dépôt. Cela permet notamment d'assurer leur présence sur notre registre Docker privé (registry.picasoft.net), ce qui permet notamment de réduire le temps de téléchargement des images, d'assurer leur intégrité et de décentraliser le stockage des images du seul Hub Docker officiel. Enfin, cela nous permet de tester la sécurité des images en amont.

Il y a plusieurs cas.

Je veux faire tourner un service existant, tel quel

Si un service existant a une image Docker officielle, référencée sur le Docker Hub, alors on créera un Dockerfile dummy, ne contenant qu'une instruction FROM. Exemple :

FROM grafana:4.8

Le reste est identique : on crée notre docker-compose.yml, notre README.md, notre clair-whitelist.yml, et les fichiers de secrets d'exemple.

Je veux customiser un service existant

Supposons que l'image Docker officielle ne nous convienne pas, que l'on soit obligés de la modifier pour régler des problèmes de sécurité, ou tout simplement qu'on veuille l'étendre, il faut créer un Dockerfile plus complet. Il y a deux solutions :

  • Soit on part de l'image officielle, avec un FROM, et on travaille dessus en rajoutant des fichiers, en enlevant des paquets... Cette solution a l'inconvénient de multiplier les layers inutiles, et d'augmenter la taille de l'image.
  • Soit on copie le Dockerfile de l'image officielle (c'est le cas pour Mattermost), et on fait nos modifications. Cette solution a pour inconvénient de devoir se synchroniser avec les modifications du Dockerfile officiel à chaque mise à jour, s'il contient des améliorations ou corrections importantes.

Je veux écrire un Dockerfile de zéro.

Allez-y ! :D

Dockerfile

Une série de recommendations est disponible ici pour l'écriture des Dockerfile.

Builds reproductibles

L'idée derrière un build "reproductible", c'est que si je me rends sur un ancien commit de ce dépôt et que je lance un docker build dans un dossier, comme pica-mattermost, l'image finale doit être la même quel que soit le temps écoulé depuis ce commit. Pourquoi ? Pour pouvoir relancer le build d'une ancienne version et la remettre en production en cas de problème.

Souvent, un Dockerfile va récupérer du code sur un dépôt Git, ou encore un binaire sur un site de téléchargement de releases. Il est fortement déconseillé de faire un git clone ou un wget de la dernière version (latest, master...), ce qui rend le build non reproductible et dépendant de cette dernière version.

Il est donc important de gérer la version du service en question, par exemple avec une variable ENV SERVICE_VERSION=1.0.1 qui sera ré-utilisée dans l'URL de téléchargement.

En outre il existe deux solutions pour récupérer du code existant, versionné sur un dépôt Git distant :

  • Installer Git dans le Dockerfile, utiliser un git clone puis un git checkout <tag> sur la version souhaitée et copier le code dans l'image.
  • Utiliser un submodule dans le dossier du service, en particulier si le dépôt où se trouve le code est de petite taille et qu'il n'utilise pas les tags. En effet, comme un submodule est lié à un numéro de commit, chaque commit de ce dépôt sera associé à un commit précis du dépôt distant. On peut donc retrouver l'état du code distant avec le numéro de commit du submodule associé au commit local.

Volumes

Une fois que l'on a identifié les dossiers du conteneur où l'on a besoin de persistence, il y a plusieurs manières de procéder :

  • Soit utiliser un dossier de l'hôte comme stockage, via un bind mount
  • Soit utiliser un volume Docker, géré en interne par Docker.

Chacun a ses avantages et inconvénients, voilà ce que l'on fait chez Picasoft :

  • Pour monter un ou des fichiers existants dans un conteneur, on utilise un bind mount,
  • Pour assurer la persistence d'un dossier du conteneur, on utilise un volume Docker.

Bind mounts

Si le fichier à monter est versionné sur ce dépôt, on utilisera un chemin relatif. Sinon, on utilisera des chemins absolus, ce qui enlève du côté "indépendant des machines", mais parfois on ne peut pas faire autrement. Par exemple, quand des certificats sont stockés sur une machine de production dans un dossier spécifique, on est obligés d'y faire référence.

Exemple :

services:
  volumes:
    # Dossier contenant les certificats sur les machines de production : utilisation du chemin absolu
    - /DATA/docker/certs/example.picasoft.net:/certs
    # Fichier de configuration versionné dans le même dossier : utilisation du chemin relatif
    - ./config.json:/etc/config.json

Volumes Docker

Si on veut indiquer qu'un des dossiers du conteneur doit persister au fil des recréations, alors on utilise des volumes Docker. C'est typiquement le cas pour le dossier /var/lib/postgresql/data d'une base Postgres, qui ne doit pas supprimer les données à chaque recréation du conteneur.

Exemple à reprendre :

volumes:
  # Nom du volume Docker
  db:
    # On force le nom utilisé par Docker
    name: db

  services:
    exemple:
      volumes:
        # On fait référence au "db" défini ci-dessus, et on le monte sur /mount_point
        - db:/mount_point

Il est suggéré d'éviter les volumes déclarés external :

  • Un volume créé en dehors de Docker Compose peut être utilisé sans être déclaré external ;
  • En revanche un volume non-créé et déclaré external fera échouer Docker Compose.

Reverse-proxy

Si le service est un service HTTP(S) (i.e. Web), on utilisera systématiquement le reverse-proxy Traefik et on bindera pas son port interne sur un port de l'hôte.

En effet, Traefik permet de gérer tout pour nous : la redirection vers le bon conteneur et le bon port en fonction du nom de domaine, la création et le renouvellement des certificats, etc.

Il suffit pour ce faire d'ajouter les bons labels, et d'ajouter le conteneur au réseau par défaut de Traefik, qui s'appelle docker_default, et existe indépendamment de Docker Compose.

Exemple à reprendre :

networks:
  docker_default:
    external: true

services:
  exemple:
    networks:
      - docker_default

Système init

Tous les systèmes Linux ont un système dit init, correspondant au processus avec le premier PID (1). Ce processus est le parent de tous les autres, et doit transmettre les signaux qu'il reçoit à ses enfants (par exemple, un signal de terminaison).

Quand vous lancez un conteneur avec un script Shell ou Bash comme entrypoint, ce script a le PID 1. S'il démarre ensuite l'application, il ne transmettra pas le signal de terminaison à ses enfants.

Le souci, c'est qu'un docker stop enverra un signal SIGTERM au script d'entrypoint, mais il ne sera pas transmis au service en lui-même, qui se terminera brutalement par un SIGKILL après expiration du timeout.

Docker Compose, depuis la version 3.7, adresse ce problème avec une directive très simple :

services:
  exemple:
    init: true

Plus d'informations sur ce lien et sur la documentation de Compose

Réseaux

L'idée est de mettre dans des réseaux séparés les services n'ayant pas besoin de communiquer entre eux, pour améliorer la sécurité de l'infrastructure.

Imaginons un service web et sa base de données. Le service web a besoin d'être exposé sur Internet, via Traefik, mais sa base de données n'a pas besoin ! D'autant que si on a la rajoutait au réseau docker_default, elle serait également accessible des autres conteneurs du réseau.

Ce qui nous donnerait quelque chose comme :

networks:
  docker_default:
    external: true
  db:

services:
  exemple:
    # On voit que le service est dans le réseau docker_default, pour être
    # accessible depuis Traefik, mais aussi dans le réseau db, pour
    # pouvoir parler à la base de données.
    networks:
      - docker_default
      - db
  exemple_db:
    networks:
      - db

Mise en place de TLS

Si le service est un service TLS, mais n'est pas un service web, alors il a besoin de certificats. Ces certificats peuvent être générés par Traefik en lui "faisant croire" que c'est un service web, mais on ne peut pas passer par lui pour servir les requêtes. C'est le cas pour le LDAP, le serveur mail... on utilisera dans ce cas l'outil TLS Certs Monitor. En pratique, cela revient à ajouter des labels dans le Docker Compose du dépôt.

Si plusieurs conteneurs doivent partager un même volume, on accordera les noms dans les fichiers Docker Compose du dépôt.

Divers

On préférera utiliser la politique restart: unless-stopped pour les services. Ceci évite qu'un service arrêté explicitement ne se relance tout seul au démarrage de la machine.