Configurer Node.js pour le serveur

Node est génial. Quand on commence, on fait joujou avec et on lance toutes nos commandes en CLI à base de node server.js sans se poser plus de questions. Puis vient le jour où on veut mettre en ligne notre belle application codée avec amour, et là, on ne sait pas comment faire tourner Node comme un vrai serveur, indépendamment du shell. Voyons ça de plus près.

Pour la plupart d’entre nous, que nous ayons une expérience avec Apache, Nginx, Lighthttpd ou n’importe quel serveur HTTPd, nous avons l’habitude de faire un sudo apt-get install monServeurFavoris et que tout fonctionne sans autre forme de procès. S’il y a un « d » dans HTTPd, c’est bien pour signifier que ces serveurs sont des daemons. Ils possèdent dès leur installation un utilisateur qui leur est propre ainsi qu’un processus qui se lance automatiquement au démarrage de la machine.

Ce n’est pas le cas de notre ami Node, mais pas d’inquiétude, nous allons mettre tout cela en place, ce n’est pas très compliqué. D’ailleurs, comme souvent avec Node, ce que nous faisons manuellement est l’occasion d’en apprendre un peu plus sur le fonctionnement sous-jacent des technos que nous utilisons.

Lancer Node en tant que daemon

Sous Linux, historiquement les services sont lancés via des scripts init. Cependant, init manque quelque peut de souplesse et il tend à être peu à peu remplacé. Nous allons donc utiliser le système de Service systemD.

De cette manière, Node se lancera automatiquement au démarrage, et pourra être arrêté, démarré ou redémarré avec les commandes service.

systemD

Les scripts systemD se trouvent à deux endroits :

  • /lib/systemd/system,
  • /etc/systemd/system.

Le premier est plutôt dédié à ce qui est installé par le système. Qui plus est, le dernier l’emporte, et comme nous avons l’habitude d’avoir les configurations des logiciels dans /etc, c’est dans ici que nous placerons notre service : /etc/systemd/system/node.service.

[Unit]
Description=Contacts pro node.js app

[Service]
# précise d'où on lance notre app
# permet d'avoir des paths à partir de la racine du projet
WorkingDirectory=/var/www/app

# lance l'exécutable avec le fichier de démarrage
# on est ici en relatif car on a précisé le WorkingDirectory
ExecStart=/usr/bin/node serverCluster.js

# si un problème survient, on tente de redémarrer (indéfiniement)
Restart=always

# le redémarrage intervient 500ms après le crash
RestartSec=500ms

# on envoie les log dans le syslog 
indépendemment des logs de l'app elle même
StandardOutput=syslog
StandardError=syslog

# le nom du process dans le syslog
SyslogIdentifier=nodejs

# utilisateur et groupe qui lancent l'app
User=www-data
Group=www-data

# définition de l'environnement
Environment=NODE_ENV=production


[Install]
# précise qu'on démarre quand dès que le mode multi user est lancé
# équivalent du runlevel 3 de sysVinit
WantedBy=multi-user.target

Il est également possible de préciser une dépendance, comme votre base de données par exemple (MySQL, MongoDB, Redis…). Vous utiliserez pour cela Requires, je vous laisse avec le man pour de plus amples détails. Dans le cas présent je ne l’utilise pas car node attendra que la base se connecte si elle est sur la même machine, et dans le cas d’une installation distribuée, la base n’est tout simplement pas sur la même machine.

Enfin, il ne reste plus qu’à indiquer à systemD que nous voulons que notre joli petit service se lance comme un grand au démarrage :

systemctl enable node

Terminé, c’est tout !

Le cas upstart

Ubuntu avait développé un système alternatif, upstart, qui était utilisé par défaut jusqu’à la 14.04LTS et sur d’autres distributions. Comme Upstart tourne toujours sur de nombreux système, vous trouverez la méthode dédiée ci-dessous. Une fois en place, systemD et upstart s’utilise de la même manière avec la commande service.

Les scripts upstart se trouvent dans /etc/init/ et prennent l’extension .conf. Nous appellerons le notre node.conf. Je prends ici l’exemple du script de mon application de contacts :

# description et author ne sont pas obligatoires
description "Contacts pro node.js app"
author "Buzut"

# on précise ici les évènements de démarrage
# ainsi que d'arrêt
start on filesystem
stop on shutdown

# tous les scripts doivent avoir au moins une expression exec ou script
script
  # on exporte la home afin d'utiliser les chemins relatifs dans notre app
  export HOME="/var/www/app"

  # On démarre node, il faut bien penser à adapter selon l'emplacement de votre exécutable
  # On utilise ici l'utilisateur www-data, habituellement attribué au serveur web
  # /!\ Ne jamais utiliser root car ce n'est pas sécurisé !!
  # enfin on redirige la sortie vers un fichier de log
  exec sudo -u www-data NODE_ENV=production /var/www/node/bin/node /var/www/app/serverCluster.js >> /var/log/node.log 2>&1
end script

# avant le lancement et l'arrêt on logue dans un fichier
pre-start script
  echo "[`date`] Node Starting" >> /var/log/node.log
end script

pre-stop script
  echo "[`date`] Node Stopping" >> /var/log/node.log
end script

C’est tout ! Vous trouverez plus d’infos sur les scripts Upstart dans les sections getting startet et cookbook du site officiel.

Node sur le port 80

Si vous tentez de démarrer node sur le port 80, vous aurez sans doute une erreur EACCES si vous n’êtes pas root. Sous Linux, seul les ports supérieurs à 1024 sont accessibles aux utilisateurs autres que root. Cependant, comme expliqué précédemment, c’est une très mauvaise idée de démarrer son serveur en tant que root. En effet, dans le cas où un attaquant arriverait à gagner l’accès au serveur web, si ce dernier est root, c’est l’accès à l’ensemble de votre machine que vous lui offrez !

Il y a deux manières de résoudre ce problème :

  1. autoriser l’exécutable à ouvrir les ports inférieurs à 1024,
  2. utiliser le NAT pour rediriger le trafic d’un port > 1024 vers le port 80.

Il y a en réalité une autre manière de faire, qui est d’ailleurs la plus performante. Elle nécessite cependant autre chose que node. Il s’agit en effet de placer un reverse proxy devant, lequel se chargera d’envoyer les requêtes à node quand c’est nécessaire.

Cette manière est bien plus performante car le server, Nginx par exemple, peut se charger de servir les fichiers statiques sans solliciter node, qui n’est pas le meilleur pour ça [en]. Bien que Nginx soit le plus couramment utilisé à cet effet, on pourra choisir n’importe quel serveur faisant office de reverse (Squid, Apache…).

LA configuration du serveur de proxy inverse sort du sujet de cet article, il n’y en a pour l’instant pas sur ce blog, je vous invite donc à faire une petite recherche sur le web !

Dans le cas où vous voudriez avoir seulement node, je considère la seconde solution comme étant la plus sûre car la première donne au serveur la possibilité d’accéder à tous les ports privilégiés. Si un attaquant… Vous me suivez. Je vous donne néanmoins les deux techniques.

Dans le premier cas, il faut autoriser l’exécutable Node a avoir accès aux ports privilégiés. Cela s’effectue via la commande setcap :

setcap 'cap_net_bind_service=+ep' /chemin/vers/nodejs

Dans le second cas, on configure Node pour écouter sur un port non privilégié, par exemple le 8080, et on configure le NAT pour rediriger l’ensemble du trafic du port 80 vers le port 8080, celui sur lequel écoute Node. Cela s’effectue par la commande :

iptables -t nat -I PREROUTING -p tcp --dport 80 -j REDIRECT --to-port 8080

Néanmoins, ce réglage ne restera pas après un redémarrage du serveur. On placera donc cette commande dans rc.local (ou /ect/rc.d/local pour RedHat), cela permettra de la relancer à chaque démarrage du serveur.

Node cluster

Il est presque impératif de mettre en place un cluster. Vous le savez sans doute, Node est monothread, il ne tire donc pas profit de votre processeur multicœur. Un cluster lancera automatiquement un processus par cœur. En outre, même si vous n’avez qu’un seul cœur (un VPS par exemple), le cluster permettra de relancer automatiquement une nouvelle instance lorsque qu’une instance s’arrête (après une erreur non capturée par exemple).

Pour mettre en place le cluster, nous allons créer un nouveau fichier serverCluster.js, lequel se chargera de lancer votre serveur classique (server.js ou app.js en général). On va avant tout faire quelques modifications dans notre server.js :

// vous démarrez vraisemblablement votre serveur d'une manière similaire à ça
var port = 8080;
server = app.listen(port, function() {
  console.log('Express server listening on port %d in %s mode', port, app.settings.env);
});

On va intégrer cela dans une fonction de manière à ce qu’on puisse appeler le démarrage du serveur pour chaque worker, et nous ajouterons un système qui permet de savoir si on lance l’application directement ou via le cluster :

// on créé la fonction de démarrage du serveur
function startServer() {
  var port = 8888;
  server = app.listen(port, function() {
    console.log('Express server listening on port %d in %s mode', port, app.settings.env);
  });
}

// on lance l'application directement (node server.js)
if (require.main === module) {
    startServer();
}

// ou on lance via le cluster (node serverCluster.js)
else {
    module.exports = startServer;
}

// on affiche le numéro du worker qui répond à la requête
app.use(function(request, response, next) {
    if (cluster.isWorker) {
        console.log('Worker %d', cluster.worker.id);
    }
    return next();
});

C’est tout pour notre server.js. Comme vous l’avez certainement compris, cette adaptation permet d’exporter startServer en tant que module afin que le cluster puisse démarrer des workers. On conserve aussi la possibilité d’appeler directement notre server.js sans passer par le cluster. Cela nous permet par exemple d’effectuer des tests en développement. Passons maintenant au serverCluster.js :

var cluster = require('cluster');

function startWorker() {
    var worker = cluster.fork();
    console.log('CLUSTER: Worker %d started', worker.id);
}

if (cluster.isMaster) {
    require('os').cpus().forEach(function() {
        startWorker();
    });
    
    // logue quand un worker se déconnecte
    cluster.on('disconnect', function(worker) {
        console.log('CLUSTER: Worker %d disconnected from the cluster.', worker.id);
    });
    
    // lance un nouveau worker quand un s'arrête
    cluster.on('exit', function(worker, code, signal) {
        console.log('CLUSTER: Worker %d died with exit code %d (%s)', worker.id, code, signal);
        startWorker();
    });
}

// démarre le serveur en tant que module pour les workers
else {
  require('./server.js')();
}

Cela se passe presque de commentaires ! On est soit dans le contexte du master, soit du worker. Losqu’on lance le script, on est d’abord dans le contexte du master. On lance alors un ou plusieurs worker – un par cpu core require('os').cpus().forEach en utilisant cluster.fork. Enfin, dans le else, nous lançons les worker.

Node domain

Pour obtenir un serveur viable en production, il faut également penser à gérer les erreurs avec les domains. Les blocs try/catch ne permettent en effet pas de capturer les erreurs asynchrones qui pourraient crasher le serveur. Par défaut Express encapsule les routes dans des blocs try/catch, les erreurs sont donc capturées :

app.get('/nicefail', function(req, res) {
  throw new Error('Ça passe');
});

Vous pouvez observer un log de l’erreur et ainsi que l’affichage de la pile d’exécution dans le navigateur. Pas très joli, mais le serveur tourne toujours. Essayons maintenant l’asynchrone :

app.get('/uglyfail', function(req, res) {
  process.nextTick(function() {
    throw new Error('Ça casse !');
  }
});

Vous avez cette fois pu constater que le serveur est down et qu’il ne répond plus aux requêtes subséquentes. Avec nextTick, la fonction est différée jusqu’à ce que Node soit idle. À ce moment là, il n’a plus le contexte de la requête initiale. Il ne sait donc pas comment gérer l’erreur autrement qu’en fermant, car la fonction est undefined et il ne peut supposer que les fonctions à venir fonctionneront correctement à leur tour.

Pour contourner ce problème, nous allons utiliser les domains. Lorsqu’on rencontre une uncaught exception, le seul recours est de fermer le serveur. Le mieux que l’on puisse faire est de le fermer gracieusement, en fermant les connexions ouvertes et en terminant les requêtes en cours. Grâce au cluster, un autre worker prendra le relai.

Nous allons mettre en place un middleware dans notre application, avant tous les autres middleware. Généralement, le fichier qui gère l’application est le server.js :

// on inclue la dépendance
var errDomain = require('domain');

app.use(function(request, response, next) {
  // On créer un domain pour cette requête
  var domain = errDomain.create();
    
  // gestion des erreurs
  domain.on('error', function(error) {
    console.error('DOMAINE ERROR CAUGHT:', error.stack);
    try {
      // on ferme gracieusement
      setTimeout(function() {
        console.error('Failsafe shutdown');
                
        // on clos le processus
          process.exit(1);
      }, 5000);
            
      // on se déconnecte du serveur
      var worker = cluster.worker;
            
      if (worker) {
        worker.disconnect();
      }

      // on arrête de répondre aux nouvelles requêtes
      server.close();
                
      try {
        // on tente d'utiliser l'erreur d'Express
        next(error);
      }
      catch(error) {
        // Si l'erreur d'Express échoue
        // on répond en node pur
        console.error('Failed to route Express error');
        response.statusCode = 500;
        response.setHeader('content-type', 'text/plain');
        response.send('Server error');
      }
    }
    catch(error) {
      console.error('Unable to send 500 response\n', error.stack);
    }
  });
  
  // on ajoute notre requête et réponse au domaine
  domain.add(request);
  domain.add(response);

  // on passe aux autres middleware
  domain.run(next);
});

Que fait-on ici ? Nous créons d’abord un domain auquel nous attachons un gestionnaire d’événement. Dès qu’une erreur non capturée sera levée, notre fonction sera appelée. Dans le bloc try, on se laisse 5 secondes (à moduler selon votre application) pour répondre aux requêtes en cours. Après quoi nous fermerons le serveur.

En parallèle, on déconnecte le worker – si s’en est un – du cluster pour que celui-ci arrête d’attribuer de nouvelles requêtes. On arrête de prendre de nouvelles requêtes en invoquant server.close. server est la variable dans laquelle on récupère la sortie de app.listen.

Enfin, on tente de répondre à la requête qui a généré l’erreur via la route d’erreur d’Express next(error), si cela échoue, on répond directement avec l’API de node. Si cela échoue aussi, on logue juste l’erreur et le client finira par avoir un timeout.

Votre application est maintenant prête pour affronter son succès !

N’hésitez pas à faire part de vos expériences. Avec quelles technos couplez-vous Node ? L’utilisez-vous pour de l’API ou même pour directement générer du HTML ? Comme on dit en anglais: what’s your stack like?

Déjà 4 réponses, rejoignez la discussion !

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *