Configuration

La configuration d'un service doit être optionnelle et tous ses paramètres doivent avoir une valeur par défaut :

1const ELASTICSEARCH_HOST = process.env.ELASTICSEARCH_HOST || 'localhost';

Ou encore :

1ELASTICSEARCH_HOST=${$ELASTICSEARCH_HOST:-localhost}

Chaque élément de configuration doit être modifiable par une variable d'environnement correctement nommée. L'intérêt est de permettre de changer le comportement de l'application au runtime plutôt qu'au buildtime. De plus, l'usage d'orchestrateurs de conteneurs comme Docker Swarm ou Kubernetes rendent plus pratique le passage de variables d'environnement que de fichiers de configuration.

1apiVersion: v1
2kind: Pod
3metadata:
4 name: foo
5spec:
6 containers:
7 - name: foo
8 image: foo:1.0
9 env:
10 - name: ELASTICSEARCH_HOST
11 value: "elasticsearch.example.com"

Signaux et fermeture

Une application doit être en mesure de réagir aux signaux envoyés par l'OS et en tirer parti. Par exemple, Prometheus recharge sa configuration à la réception d'un SIGHUP :

1$ kill -HUP 1234
2INFO[1234] Loading configuration file prometheus.yml source=main.go:201
3INFO[1234] Stopping target manager... source=targetmanager.go:281
4INFO[1234] Target manager stopped. source=targetmanager.go:216
5INFO[1234] Starting target manager... source=targetmanager.go:122

L'application doit s'éteindre proprement lorsqu'elle reçoit un SIGTERM. Elle doit pouvoir nettoyer tous les éléments externes dont elle a eu besoin : connections ouvertes, caches utilisés, fichiers ouverts, fichiers temporaires créés, etc...

Logs

La gestion des logs est souvent bien plus complexe que ce que l'on pense. Il est donc généralement intéressant de faire usage d'une librairie dédiée. Cette librairie est en charge de différents paramètres :

  • format : JSON, clé-valeur
  • contexte : date et heure, module émétteur
  • export : réseau (tcp/udp, http, kafka, ...), syslog, fichiers
  • rotation

Dans le cas d'un microservice, l'application ne doit pas s'occuper du routage et du stockage de ses logs. Elle doit simplement écrire dans stdout et stderr en fonction des besoins, et laisser un routeur de logs (comme fluentd ou filebeat) gérer et acheminer les logs.

Chaque message de log doit être associé au bon niveau (debug, info, warn, error, ...) pour qu'il puisse être affiché et/ou traité de façon optimale. Il peut être intéressant d'afficher un message pour certains cas :

  • Message de démarrage de l'application;
  • Ports sur lesquels elle écoute;
  • Services auxquels elle est connectée;
  • Évènements prévus et imprévus;
  • Signaux reçus;
  • Fermeture;

[foo] [INFO] Listening on port 80

[foo] [INFO] Connected to mysql://foo:bar@host:port/database. Alive and kicking !

...

[foo] [INFO] SIGHUP received. Reloading configuration

...

[foo] [INFO] Shutting down... Closing connexions, removing temporary files

Choix du langage

Éviter les langages à machines virtuelles

Les langages basés sur des machines virtuelles (Java, Clojure, Erlang, .NET) sont plus lents à démarrer et ont nécessairement besoin de plus de ressources. De plus, ces machines virtuelles sont généralement conçues pour gérer de large applications monolithiques qui ont besoin de fonctionnalités avancées de gestion de mémoire, de CPU, de threads, etc... Ces fonctionnalités sont redondantes avec les orchestrateurs et les runtimes et peuvent créer des conflits, comme par exemple la JVM qui ne supporte pas (ou mal) les limites de CPU et de mémoire définies dans un conteneur. Seules les versions les plus récentes du JDK 10 permettent une prise en charge correcte de ces paramètres.

Créer des binaires statiques

Utiliser un langage qui crée des binaires statiques présente plusieurs avantages :

  • le binaire est portable : les librairies liées sont distribuées avec le binaire;
  • il n'est pas nécessaire d'avoir une arborescence complète d'un OS (/bin, /usr/bin, /tmp, etc...) pour exécuter le binaire;
  • la construction du binaire est prévisible;
  • le binaire est moins sensible aux contaminations de ses librairies par des tierces-parties;

C'est avec toutes ces contraintes que des langages comme Go et Rust ont vu leur popularité croître énormément ces dernière années.

Pour un même programme C++ :

1#include <iostream>
2
3int main() {
4 std::cout << "Foo";
5 return 0;
6}

Le binaire compilé dynamiquement pèse 7.8Ko contre 1.6Mo statiquement. La différence vient de la présence (ou non) des librairies nécessaires au sein du binaire.

La commande ldd permet de connaître les librairies liées au binaire :

1$ g++ -o foo foo.cpp
2$ ldd foo
3 linux-vdso.so.1 (0x00007fffc9ff8000)
4 libstdc++.so.6 => /usr/lib/x86_64-linux-gnu/libstdc++.so.6 (0x00007f6c9c4b6000)
5 libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f6c9c1b5000)
6 libgcc_s.so.1 => /lib/x86_64-linux-gnu/libgcc_s.so.1 (0x00007f6c9bf9f000)
7 libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f6c9bbf4000)
8 /lib64/ld-linux-x86-64.so.2 (0x00007f6c9c7c1000)

Mon binaire foo requiert notamment libstdc++.so.6 (/usr/lib/x86_64-linux-gnu/libstdc++.so.6) et libc.so.6 (/lib/x86_64-linux-gnu/libc.so.6). Si je distribue ce binaire sur une autre machine, il faudra non seulement qu'elle tourne sur le même OS, mais aussi que les librairies soient les mêmes (chemin et version) :

1$ mv /lib/x86_64-linux-gnu/libc.so.6 /lib/x86_64-linux-gnu/libc.so.6.old
2$ ./foo
3./foo: error while loading shared libraries: libc.so.6: cannot open shared object file: No such file or directory

Stateless vs Stateful

Un bon conteneur est un conteneur que l'on peut déplacer et redémarrer à la demande, sans pré-requis. Il faut donc qu'il soit le plus possible stateless : toutes les données persistantes dont a besoin l'application doivent être stockées dans des systèmes externes comme une base de données. Il ne doit pas y avoir de différence entre plusieurs instances d'une application.

Robustesse, healthchecks et timeouts

L'application doit être en mesure de gérer les erreurs via une dégradation de service ou via du back-off plutôt que de crasher. Elle doit pouvoir non-seulement répondre à des health checks (via une route http par exemple) mais aussi en émettre afin de surveiller la disponibilité des services liés.

À un stade plus avancé, l'application doit supporter des mécanismes plus complexes comme les timeouts, le throttling et les circuit-breakers.