sMug@replicatorbe
~/Publications/Infrastructure/docker-sur-un-petit-serveur-ce-que-j-ai-arrete-de-faire

Docker sur un petit serveur : ce que j'ai arrêté de faire

Infrastructure / /8 min de lecture

Un compose par site, tout conteneuriser, des tags flottants : les réflexes des débuts coûtent surtout de la mémoire, et la mémoire est la ressource rare. Ce que j'ai mutualisé depuis, et ce que ça m'a coûté en échange.

Un docker ps dont la sortie ne tient plus dans l'écran, sur une machine qui héberge une poignée de sites à quelques centaines de visites par jour. C'est le moment où j'ai commencé à compter.

J'ai écrit ici en 2016 tout le bien que je pensais des conteneurs jetables, et je le pense toujours. Ce billet-ci est le contrepoint, huit ans après : ce que j'ai arrêté de faire, sur une machine qui n'a ni vingt cœurs ni cent giga de mémoire.

Un compose par site

C'était le réflexe, et il paraît propre : chaque site avec son nginx, son PHP, sa base. Isolation parfaite, chacun chez soi, on déplace un site en déplaçant un dossier.

Sur sept ou huit sites, ça fait une vingtaine de conteneurs. Et c'est là que le compte devient désagréable, parce que sur un petit serveur la ressource rare n'est pas le processeur, c'est la mémoire. Un serveur de base de données réserve son cache au démarrage, qu'il serve une requête par minute ou mille. Huit bases, ce sont huit caches froids qui se partagent la RAM sans jamais se remplir. Une seule base avec le même total de mémoire, c'est un cache chaud, et il travaille pour tout le monde.

Aujourd'hui c'est donc : un nginx pour tous les sites, un PHP-FPM pour tous les sites, une base de données pour toutes les bases. Les sites sont des dossiers montés dedans, plus des fichiers de configuration. Ajouter un site, c'est un dossier et un fichier de conf, pas une pile de plus à surveiller.

Réduit à l'os, ça donne ceci :

services:
  # PHP-FPM partagé, pour tous les sites
  php:
    build: { context: ., dockerfile: Dockerfile-php }
    container_name: shared-php
    restart: always
    expose: ["9000"]                     # jamais publié sur l'hôte
    volumes:
      - ./sites:/var/www:rw
    networks: [frontend, backend]

  # Base partagée, pour toutes les bases
  db:
    image: mysql:8.0                     # version épinglée
    container_name: shared-mysql
    restart: always
    command: ["mysqld", "--innodb_buffer_pool_size=4G"]
    environment:
      - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD:?definir MYSQL_ROOT_PASSWORD dans .env}
    volumes:
      - mysql_data:/var/lib/mysql
    networks: [backend]                  # invisible depuis le frontal

  # UN nginx pour tous les sites
  web:
    image: nginx:alpine
    container_name: all-sites-nginx
    restart: always
    expose: ["80"]
    volumes:
      - ./sites:/var/www:ro              # il sert les fichiers, il ne les écrit pas
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
    depends_on: [php]
    networks: [frontend, backend]
    logging:
      driver: json-file
      options: { max-size: "10m", max-file: "3" }

volumes:
  mysql_data:

networks:
  frontend:
  backend:

Quatre détails valent plus que le reste du fichier. Le cache de la base est déclaré une seule fois, et il profite à tous les sites au lieu d'être découpé en huit. Aucun service n'utilise ports, donc rien n'est joignable depuis l'extérieur de la machine. La base n'est que sur le réseau interne, ce qui la rend inatteignable depuis le frontal même en cas d'erreur de configuration. Et nginx monte les sites en lecture seule : il les sert, il n'a aucune raison de pouvoir les modifier.

Le reste tient dans un dossier par site et un fichier de configuration par domaine.

Tout conteneuriser par réflexe

L'autre habitude à perdre : se dire que chaque tâche mérite son conteneur.

La tâche planifiée qui régénère ce blog ne tourne pas dans un conteneur à elle. C'est un docker exec dans le PHP qui tourne déjà, avec l'utilisateur qui va bien pour que les fichiers produits restent éditables depuis l'hôte. Ça fait exactement le même travail, et ça ne laisse rien allumé entre deux exécutions.

Quant à ce site-ci, il est en HTML statique : nginx sert des fichiers depuis un disque. Il n'y a ni runtime à isoler, ni processus à redémarrer, donc rien à conteneuriser de plus que le serveur web qui existe déjà.

Chaque conteneur a un coût fixe, qu'il travaille ou non : de la mémoire, une image à suivre, une mise à jour à appliquer, une ligne de plus dans ce qu'il faut surveiller. Ce coût-là se paie même quand le conteneur ne fait rien. C'est ce que le premier docker run ne dit pas.

Laisser les journaux grossir

Celle-là, je l'ai apprise de la pire façon : un disque plein, et plus rien qui démarre. Pas un site en panne, tous les sites en panne, un dimanche.

Le coupable était un conteneur bavard dont les journaux s'écrivaient en continu depuis des mois. Par défaut, Docker n'impose aucune limite : le fichier grossit jusqu'à ce que le disque dise stop. Trois lignes par service suffisent à fermer le sujet définitivement, en fixant une taille maximale et un nombre de fichiers conservés. Ça ne s'invente pas au moment de l'incident, ça s'écrit le jour de l'installation.

Les secrets dans le fichier de composition

J'ai longtemps mis les mots de passe directement dans le YAML, avec l'idée que le fichier n'était de toute façon lisible que par moi. Puis on versionne, on copie le fichier ailleurs pour dépanner, on le colle dans une discussion pour demander un avis, et le mot de passe voyage.

Et puis on s'intéresse sérieusement à la sécurité, on lit ce que recommandent ceux dont c'est le métier, on regarde comment les choses tournent mal chez les autres. On relit alors ses propres fichiers avec d'autres yeux, et ce qui passait pour un détail d'organisation devient une habitude qu'on n'a plus envie de garder.

Les variables vivent donc dans un fichier d'environnement séparé, hors du dépôt, en droits restreints. Avec une précaution que je recommande à tout le monde :

environment:
  - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD:?definir MYSQL_ROOT_PASSWORD dans .env}

Le :? change tout. Sans lui, une variable absente vaut chaîne vide, et la base démarre avec un mot de passe vide sans rien dire à personne. Avec lui, la pile refuse de démarrer et affiche le message. Un échec bruyant vaut mieux qu'un démarrage silencieux dans un état dangereux.

Publier des ports

Le réflexe du début, c'est ports: partout, parce que c'est comme ça qu'on teste. Sauf que publier un port, c'est ouvrir l'accès depuis l'extérieur de la machine, et que Docker écrit ses règles de filtrage lui-même — ce que le pare-feu de l'hôte ne raconte pas toujours comme on l'imagine.

Aujourd'hui, rien n'est publié sauf le proxy en frontal, sur les deux ports qu'il faut. Tout le reste est en expose, joignable uniquement par les autres conteneurs. Et les réseaux sont séparés : la base de données n'est que sur le réseau interne, jamais sur celui qui voit le proxy. Un service qui n'a rien à faire dehors ne doit pas pouvoir y aller, et pas seulement « ne pas être utilisé » de dehors.

Mon erreur, toujours en place

Pour être honnête jusqu'au bout : toutes mes images sont épinglées sur une version, sauf une. Une interface d'administration de base de données, en tag flottant, qui se met donc à jour toute seule au premier redémarrage.

C'est-à-dire que la seule image que je ne contrôle pas est exactement celle qui expose une interface d'administration. Je le sais, je le note ici, et je ne l'ai toujours pas corrigé — mettre un article en ligne m'obligera peut-être à le faire.

Ce que la mutualisation coûte

Il serait malhonnête de présenter tout ça comme gratuit, parce que ça ne l'est pas.

Un PHP partagé, c'est une seule version de PHP pour tous les sites. Le jour où un site a besoin d'une version plus récente et qu'un autre casse dessus, il faut arbitrer, ou sortir ce site de la pile commune. Une base partagée, c'est un seul redémarrage qui les couche tous ensemble. Et un nginx unique, c'est une erreur de syntaxe dans une configuration qui empêche le rechargement de toutes les autres — d'où le nginx -t avant chaque rechargement, sans exception — la même règle que pour le TLS : on vérifie avant de recharger, jamais l'inverse.

C'est donc un arbitrage, pas une vérité. Il tient parce que ces sites me ressemblent : même pile, mêmes besoins, même personne derrière. Sur une machine qui héberge des clients aux exigences différentes, je ne ferais pas ce choix.

Ce que je retiens

  1. Docker sert à isoler et à rendre reproductible, pas à multiplier. Sur une petite machine, le bon réflexe est de se demander ce qu'on peut mutualiser, pas ce qu'on peut séparer de plus.
  2. La mémoire est la ressource rare. Elle se réserve au démarrage, indépendamment du trafic, et c'est elle qui décide du nombre de piles qu'on peut tenir.
  3. Les valeurs par défaut qui font mal sont celles qui ne se voient pas : des journaux sans limite, une variable absente qui vaut chaîne vide, un port publié qu'on croyait interne.

Rien là-dedans n'est un revirement. En 2016 je montais un conteneur jetable pour faire une sauvegarde et je le jetais ensuite — c'était déjà l'idée juste. Ce que j'ai arrêté, c'est de laisser tourner ce qui n'a aucune raison de rester allumé.