graceful restart with docker traefik and node and tini

21/01/2021

les signaux dans docker (le pid 1)

quand on fait docker stop ça envoie un SIGTERM au pid 1

pour savoir qui est pid 1 ou si il y a des zombies dans le container on peut lancer :

docker exec dronehook ps fx

si dans le dockerfile on fait

CMD node server.js

le pid 1 sera node, dans ce cas il est possible d'intercepter les signaux depuis node avec ce code

process.on('SIGTERM', function() {
    console.log('got SIGTERM');
})

si dans le dockerfile on fait

CMD npm start

le pid 1 sera npm, Il ne transmet pas du tout les signaux. ou alors ils sont interceptés par sh qui est lancé par npm start. mais rien n'est reçupar node.

si c'est nodemon, c'est nul aussi car il ne gere pas de façon tres propre les signaux qu'il reçoit. par exemple quand il reçoit SIGTERM il envoie sigterm puis sigint puis sigint. le brutal :)
il est posible de forcer le signal que nodemon envoie lorqu'il detecte des fichiers modifiés, par ex : --signal SIGHUP
mais ça résoud rien.

seule solution propre, appeller node directement depuis le dockerfile, sans npm, ni nodemon mais aucune protection contre les zombies

les zombies

quand un process meurt il devient un zombie
si le parent fait un wait, il obtient le code de retour du process, et le zombie disparait

nodejs fait surement le wait tout seul on dirait car j'arrive pas à créer de zombies.

quand le process meurt il envoie un signal au parent (SIGCHLD)

du coup la solution est de mettre tini, qui appelle node directement.
il ne pose pas les pb de npm/nodemon car il transmet bien les signaux et il gere les zombies

ENTRYPOINT ["tini", "--"]
CMD ["node", "server.js"]

pour créer un zombie :

(sleep 1 & exec /bin/sleep 10)

au bout de 10 secondes ce zombie doit etre recupéré par tini, ou trainer pour toujours si on a pas mis tini

Remarque : créer des zombies c'est mal en soit (il faudrait juste fixer le code pour éviter ça), mais pour des outils comme jenkins ou des runners, c'est inevitable car Jenkins execute du code qui ne lui appartient pas (i.e. les scripts de builds), alors il ne leur est pas possible de les fixer.
C'est pourquoi Jenkins utilise Tini: pour nettoyer après les scripts qui créent des zombies.

remarque
Pourquoi ne pas utiliser Bash en tant que PID 1 vu qu'il fait en réalité la même chose (récupérer les zombies), ?

Un problème est que si vous exécutez Bash en tant que PID 1, alors tous les signaux que vous envoyez à votre conteneur Docker (par exemple en utilisant docker stop ou docker kill) finissent par être envoyés à Bash, qui ne les transmet nulle part (à moins que vous ne le codiez vous-même).

Tini résout cela en "transmettant les signaux" : si vous envoyez un signal à Tini, il envoie le même signal à votre processus enfant (Jenkins dans votre cas).

Un deuxième problème est que une fois que votre processus s'est terminé, Bash va également se terminer. Si vous n'êtes pas prudent, Bash pourrait se terminer avec un code de sortie 0. Cela pourrait empêcher les stratégies de redémarrage de Docker de fonctionner comme vous vous y attendez). Ce que vous voulez réellement, c'est que Bash renvoie le même code de sortie que votre processus.

Notez que vous pouvez résoudre ce problème en créant des gestionnaires de signaux dans Bash pour effectuer réellement la transmission et renvoyer un code de sortie approprié. En revanche, cela demande plus de travail, alors qu'ajouter Tini ne nécessite que quelques lignes dans votre Dockerfile.

graceful restart

une fois que le signal est bien reçu par node (tini + node), il suffit de l'intercepter et de stopper express + les autres trucs qui occupent l'event loop (bdd, etc) le process s'eteint alors tranquillement sans qu'on ait besoin de forcer avec process.exit(0);

process.on('SIGTERM', function() {
    console.log('got SIGTERM');
    server.close(() => {
        console.log('closed nice')
    });
})

traefik

Traefik route le trafic vers chacun de nos conteneurs
Lorsqu'on defini un healthcheck sur un conteneur, traefik ne redirigera que si health = ok

Si on a plusieurs conteneurs qui representent le même service traefik redirige vers l'un d'eux a la fois en round robin. (l'un apres l'autre)

On peut donc faire en sorte que le healthcheck renvoie ko.
En captant le signal sigterm par exemple, ainsi une fois que docker a executé le healthcheck il constate que le conteneur n'est plus healthy.
traefik s'en rend alors compte lui aussi, et ne redirige donc plus le trafic sur ce conteneur.
il est donc maintenant possible de faire server.close()
et enfin une fois que tous les clients sont deconnectés on peut eteindre le conteneur.
Il faut donc que docker soit tres patient lors du docker stop (par defaut 10 sec)

docker scaling

on demarre 4 conteneurs du même service

docker-compose up -d --scale dronehook=4 dronehook

on veut par exemple en éteindre 3 :
le probleme c'est qu'on ne peut pas faire ce call directement car on ne sait pas quels conteneurs seraient éteints, et en plus cela causerait des perte des connexions aux utilisateurs connectés

docker-compose up -d --scale dronehook=1 dronehook

il faut plutot dire à 3 d'entre eux de prevenir qu'ils sont en mauvaise santé afin que traefik arrete de rediriger le trafic vers eux.
(il faut créer la route /sethealth/ko dans l'application)

docker exec -ti docker_dronehook_2 curl http://localhost:3000/sethealth/ko
docker exec -ti docker_dronehook_3 curl http://localhost:3000/sethealth/ko
docker exec -ti docker_dronehook_4 curl http://localhost:3000/sethealth/ko

à présent chacun d'entre eux renvoie ko sur leur /health
docker exec -ti docker_dronehook_4 curl http://localhost:3000/health

traefik ne redirige donc plus vers eux
on peut donc eteindre ces 3 conteneurs

docker rm -f docker_dronehook_2 docker_dronehook_3 docker_dronehook_4;

il est possible de lancer ce script en paralele afin d'etre certain qu'aucun client n'a perdu sa connection

'use strict';

var requestPromise = require('./lib/requestPromise');

var globali = 0;

async function work() {
    var i = globali;
    globali++;
    var { response, body } = await requestPromise({url: 'https://dronehook.raphaelpiccolo.com/'});

    console.log(`${new Date().toJSON().substr(11)} ${i} body=${body}`);
}

setInterval(work, 500);

swarm

Si on veut mettre à jour un projet on doit forcement à un moment faire "docker-compose pull project"
puis on lance "docker-compose up -d" et on se rend compte qu'il y a à nouveau une interuption du service car docker eteint le service puis le relance, (même si on lui demande de upscaler).

La seule solution automatique consiste a créer un swarm qui sera responsable de puller la nouvelle image et de gérer le renouvellement des conteneurs. Si via la techinque du docker scaling ci dessus on parvient à ne plus avoir d'interuptions, swarm permet de garder ce comportement même en cas de changement d'image.

il faut donc créer un swarm : https://blog.raphaelpiccolo.com/post/851

puis utiliser le compose suivant afin d'acomplir un deploiement zero downtime
en gros :

  • on retire les options "restart: always" et "container_name"
  • on crée l'entrée deploy avec tous les reglages suivants : update_config pour gérer les deploiements et rollback_config pour gérer les anulations de deploiements
  • dans update_config il faut mettre "order: start-first" ainsi en cas de mise a jour (changement d'image / scaling / changement de config) swarm créera le nouveau conteneur puis eteindra l'ancien.
  • dans rollback_config il faut mettre "order: stop-first" (pas sur)
  • on met les labels dans deploy, et non plus directement sur le service.
  • on renseigne stop_grace_period afin de spécifier le temps qu'on laisse au conteneur pour s'eteindre.

compose file :

version: '3.7'
services:
  errorpage:
    image: registry.raphaelpiccolo.com/errorpage:1.0.13
    working_dir: /usr/app
    environment:
      - "NODE_ENV=production"
    healthcheck:
      test: ['CMD', 'curl', '-f', 'http://localhost:3000/health']

    # the time the container can take to close itself before swarm forces it.
    stop_grace_period: 130s
    deploy:
      mode: replicated
      replicas: 2
      # en cas de mise a jour (changement d'image / scaling / changement de config) on crée le nouveau conteneur puis on eteint l'ancien
      update_config:
        failure_action: rollback
        parallelism: 1
        delay: 10s
        order: start-first
      # en cas d'erreur de deploiement, swarm remettra l'ancienne image
      rollback_config:
        parallelism: 1
        delay: 10s
        order: stop-first
      labels:
        - "traefik.enable=true"
        - "traefik.http.services.errorpage.loadbalancer.server.port=3000"
        - "traefik.http.routers.errorpage.rule=Host(`error.clone.gextra.net`)"
        - "traefik.http.routers.errorpage.tls.certresolver=le"
        - "traefik.http.routers.errorpage.entrypoints=https"

    networks:
      # Use the public network created to be shared between Traefik and
      # any other service that needs to be publicly available with HTTPS
      - public

networks:
  # Use the previously created public network "public", shared with other
  # services that need to be publicly available via this Traefik
  public:
    external: true