21/01/2021
graceful restart with docker traefik and node and tini

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

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 :

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