Dans l’un de mes précédents articles, j’expliquais comment utiliser Supervisor pour lancer des workers. L’idée était de présenter comment avoir des programmes qui s’exécutent en tâche de fond, avec un mécanisme pour les relancer automatiquement en cas de plantage.
J’apprécie Supervisor parce que ses fichiers de configuration sont vraiment simples à écrire, et j’aime l’idée d’avoir un outil spécifiquement dédié à la gestion des workers. Mais ça fait quand même une brique supplémentaire à ajouter (parce que Supervisor n’est jamais installé par défaut), et on a de plus en plus tendance à utiliser systemd pour remplir ce besoin.
Systemd est utilisé par les distributions Linux modernes à la place de l’init System V (qui était hérité des systèmes Unix plus anciens) pour démarrer les processus de base du système. J’ai longtemps fait partie de ceux qui n’appréciaient pas le changement, à cause de la complexité supérieure de systemd dans ses fichiers de configuration et ceux de programmes associés (la page Wikipédia donne un exemple de configuration mtab, devenue bien plus complexe avec systemd).
Mais il faut bien reconnaître que systemd gère des choses assez pointues, que ce soit au niveau des logs ou de la sécurité. Et il a l’avantage d’être présent aujourd’hui sur tous les Linux sans avoir à installer quoi que ce soit d’autre.
Je vais reprendre les deux cas d’utilisation que j’avais listés dans mon article dédié à Supervisor : lancement d’une instance unique, et lancement de plusieurs instances simultanées. J’y ajouterai quelques considérations sur la sécurité.
Utilisation de systemd pour gérer une instance unique
Si on veut qu’il y ait une seule instance de notre programme, devant s’exécuter en tâche de fond, on créera un fichier /etc/systemd/system/worker.service (attention, le nom « worker » présent dans le nom du fichier de configuration est important, c’est lui qui détermine l’identifiant « worker » qui sera utilisé en paramètre des commandes de systemd).
Voici un exemple simple de fichier :
[Unit]
Description=Worker daemon
After=network.target
[Service]
ExecStart=/chemin/vers/worker
Type=simple
Restart=on-failure
RestartSec=2
StartLimitBurst=5
StartLimitIntervalSec=60
[Install]
WantedBy=multi-user.target
Langage du code : JavaScript (javascript)
- Ligne 6 : chemin vers le binaire à exécuter.
- Ligne 7 : on indique qu’il s’agit d’un démon simple (pas de fork).
- Ligne 8 : on demande un redémarrage automatique en cas de plantage.
- Ligne 9 : on indique un délai de 2 secondes avant redémarrage.
- Lignes 10 et 11 : si le programme plante plus de 5 fois en 60 secondes, il n’est pas redémarré (pour éviter des boucles plantage-redémarrage sans fin).
- Ligne 14 : indique qu’on peut activer la règle pour un démarrage automatique au lancement du système.
La directive Restart=on-failure permet un redémarrage automatique si le programme plante (disparition du processus, réception d’un signal SIGSEGV/SIGILL/SIGPIPE/…, tué par le kernel pour consommation mémoire excessive, etc.), ou s’il s’arrête en retournant une valeur différente de 0. Avec cette directive, le démon ne sera pas redémarré s’il a été arrêté avec la commande systemctl stop.
Sinon, il est possible d’utiliser la directive Restart=always, qui redémarrera systématiquement le programme (même arrêté avec systemctl stop), ou Restart=on-abnormal, qui redémarre uniquement si le programme a reçu un signal de terminaison (mais pas s’il s’est arrêté avec un retour différent de 0).
On exécutera ensuite les commandes suivantes :
$ sudo systemctl daemon-reload # pour que systemd recharge ses fichiers
$ sudo systemctl enable worker # pour activer le service
$ sudo systemctl start worker # pour démarrer le démonLangage du code : Bash (bash)
Il est possible de gérer le démon avec la commande systemctl :
$ sudo systemctl stop worker # pour arrêter le worker
$ sudo systemctl disable worker # pour désactiver le service
$ sudo systemctl restart worker # pour redémarrer le workerLangage du code : Bash (bash)
Pour afficher des informations sur le service :
$ systemctl status worker # pour connaître le statut
$ systemctl is-active worker # pour savoir s'il est actif
$ systemctl is-enabled worker # pour savoir s'il est activé au boot
$ systemctl is-failed worker # pour savoir s'il est en échecLangage du code : Bash (bash)
Un programme géré par systemd peut écrire ses messages de log simplement sur sa sortie standard ou sa sortie d’erreur. Ils seront pris en charge par journald, et ils sont accessibles avec la commande journalctl.
Pour afficher les logs :
$ sudo journalctl -u worker # pour voir les logs
$ sudo journalctl -u worker --since "10 min ago" # logs des 10 dernières minutes
$ sudo journalctl -u worker -f # pour garder ouvert (comme un tail -f)Langage du code : Bash (bash)
Utilisation de systemd pour gérer des instances multiples
Tout comme avec Supervisor, il est possible de demander l’exécution simultanée de plusieurs instances d’un démon. Par contre, la configuration nécessaire se complexifie beaucoup plus qu’avec Supervisor (qui n’a globalement besoin que d’un paramètre supplémentaire).
On va commencer par créer un fichier de “template”, qui ressemble beaucoup au fichier qu’on avait créé plus tôt. On enregistrera ce fichier dans /etc/systemd/system/worker@.service (le “@” est nécessaire).
[Unit]
Description=Worker instance %i
After=network.target
[Service]
ExecStart=/chemin/vers/worker
Type=simple
Restart=on-failure
RestartSec=2
[Install]
WantedBy=multi-user.target
Langage du code : JavaScript (javascript)
- Ligne 2 : Ici, le
%isera remplacé par l’identifiant de l’instance (1, 2, 3…).
On va ensuite créer un fichier de “target”. On le nommera /etc/systemd/system/worker.target :
[Unit]
Description=Group of workers
Wants=worker@1.service worker@2.service worker@3.service worker@4.service worker@5.service
- Ligne 3 : On liste les instances à créer. Ici on crée cinq instances, nommées
worker@1.service,worker@2.service,worker@3.service,worker@4.serviceetworker@5.service
Ensuite, on peut activer cette target, pour que les workers soient automatiquement lancés au démarrage du système :
$ sudo systemctl enable worker.targetLangage du code : Bash (bash)
Et enfin on peut lancer les cinq workers à la main :
$ sudo systemctl start worker.targetLangage du code : Bash (bash)
Il est possible de voir l’état de chaque instance en utilisant son identifiant. Par exemple :
$ systemctl status worker@3Langage du code : Bash (bash)
Il est possible de démarrer des instances supplémentaires, indépendants des autres :
$ sudo systemctl start worker@6
$ sudo systemctl start worker@7Langage du code : Bash (bash)
Les logs de chaque instance sont accessibles séparément :
$ sudo journalctl -u worker@1 # pour voir les logs du 1er worker
$ sudo journalctl -u worker@2 -f # pour garder ouverts les logs du 2e workerLangage du code : Bash (bash)
Il est aussi possible de voir les logs de toutes les instances :
$ sudo journalctl -u "worker@*" -f # attention à bien mettre les guillemetsLangage du code : Bash (bash)
Limites
Il est possible d’ajouter des directives supplémentaires dans le fichier de configuration du service (worker.service ou worker@.service), pour mettre des limites à l’utilisation des ressources système par les instances. Attention, ces directives sont valables par instance. Donc en augmentant le nombre d’instances, on augmente mécaniquement la consommation de ressources.
Ces directives doivent être ajoutées dans la section [Service] du fichier de configuration.
CPUQuota=20%
Permets de limiter la consommation du processeur.
MemoryMax=200M
Permets de limiter la quantité de mémoire vive utilisable par chaque instance. Si un processus dépasse cette limite, il sera tué par le OOM Killer ; si la configuration le prévoit, il sera alors redémarré automatiquement par systemd.
IOReadBandwidthMax=/dev/sda 2M
IOWriteBandwidthMax=/dev/sda 2M
Langage du code : JavaScript (javascript)
Ici, le processus sera limité à un débit de 2 MO/s sur le device /dev/sda.
Options de sécurité
Il est aussi possible d’ajouter des directives pour isoler les programmes de la majorité du système, sans passer par des conteneurs Docker.
Gestion de l’utilisateur et du groupe d’exécution
Les directives User et Group permettent de définit l’identité d’exécution du programme.
Exemple :
User=monutilisateur
Group=mongroupe
Plus puissante, la directive DynamicUser force un comportement encore plus sécurisé.
Par exemple :
DynamicUser=yes
Dans ce cas, systemd va exécuter le programme en utilisant un utilisateur et un groupe qu’il aura créés à la volée, avec des UID/GID choisis dynamiquement, et qui ne sont pas inscrits dans le fichier /etc/passwd.
L’utilisateur créé n’a pas de home, pas de shell, n’a pas accès aux fichiers des autres utilisateurs. L’exécution est donc complètement isolée.
Et enfin, cet utilisateur est automatiquement détruit dès la fin de l’exécution du programme.
Restriction d’accès au système de fichiers
La directive ReadOnlyPaths permet de définir des chemins qui ne sont accessibles qu’en lecture seule.
Par exemple :
ReadOnlyPaths=/etc/ssl /etc/pkiLangage du code : JavaScript (javascript)
La directive ProtectHome sert à protéger l’accès aux répertoires /home, /root et /run/user.
Avec la valeur read-only, ces répertoires sont accessibles en lecture seule.
Avec la valeur yes, le service n’a plus du tout accès à ces répertoires, qui apparaissent alors vides.
Exemples :
ProtectHome=read-only # /home, /root et /run/user en lecture seule
ProtectHome=yes # pas d'accès à /home, /root et /run/userLangage du code : PHP (php)
Avec la directive PrivateDevices, on peut empêcher l’accès au contenu du répertoire /dev (à part /dev/null, /dev/zero, /dev/random et /dev/urandom, qui restent accessibles).
Utilisation :
PrivateDevices=trueLangage du code : JavaScript (javascript)
La directive InaccessiblePaths interdit complètement l’accès aux chemins spécifiés (que ce soit en lecture, en écriture ou en exécution).
Exemple :
InaccessiblePaths=/mnt /media /srvLangage du code : JavaScript (javascript)
En complément, on utilisera la directive ProtectSystem.
Si on lui donne la valeur full, cela aura pour effet de monter /boot, /usr et /etc en lecture seule, ce qui empêche la modification du système.
Elle prend toute son utilité avec la valeur strict. C’est alors l’ensemble du système de fichiers qui est monté en lecture seule. C’est un mode dit « container-like ».
Exemples :
ProtectSystem=full # montage en lecture seule de /boot, /usr et /etc
ProtectSystem=strict # montage en lecture seul de tout le filesystemLangage du code : PHP (php)
Ensuite, on peut utiliser la directive ReadWritePaths pour indiquer les seuls chemins auxquels le service aura accès en écriture.
Par exemple :
ReadWritePaths=/var/lib/broker /var/log/brokerLangage du code : JavaScript (javascript)
Mais on aura plutôt tendance à utiliser des répertoires de travail, qui sont créés dynamiquement par systemd, et auxquels le service est le seul à avoir accès.
Par exemple :
StateDirectory=worker # crée /var/lib/worker
RunTimeDirectory=worker # crée /run/worker
CacheDirectory=worker # crée /var/cache/worker
LogsDirectory=worker # crée /var/log/worker
ConfigurationDirectory=worker # crée /etc/workerLangage du code : PHP (php)
La directive PrivateTmp permet d’offrir au service l’accès à des répertoires /tmp et /var/tmp privés, qui sont isolés du reste du système.
Utilisation :
PrivateTmp=trueLangage du code : JavaScript (javascript)
Protection du kernel
ProtectKernelTunables=yes
Cette directive empêche de modifier les paramètres du noyau (pas d’écriture possible dans /proc/sys/, /proc/sysrq-trigger, /proc/acpi/, /proc/timer_stats et /sys/).
ProtectKernelModules=yes
Avec cette directive, il est impossible de manipuler les modules kernel via /proc/modules et /sys/module/.
ProtectControlGroups=yes
Cette directive empêche le service de manipuler les “cgroups” (control groups, qui gèrent sous Linux l’utilisation des ressources par les processus).
ProtectClock=yes
Empêche toute manipulation de l’horloge système et des timers.
Accès réseau
La directive RestrictAddressFamilies permet de restreindre les types de sockets que le programme peut ouvrir. Parmi les types supportés, il y a AF_INET (IPv4), AF_INET6 (IPv6), AF_UNIX (socket Unix), Bluetooth, Netlink… On peut mettre plusieurs types, séparés par des virgules.
Deux exemples (on ne met qu’une ligne dans le fichier) :
RestrictAddressFamilies=AF_INET AF_INET6 # peut ouvrir des sockets réseau uniquement
RestrictAddressFamilies=AF_UNIX # pour un worker interne sans accès réseauLangage du code : PHP (php)
Restriction de capacités et de privilèges
NoNewPrivileges=yes
L’instance ne peut alors pas gagner de privilèges supplémentaires, même si le binaire exécuté est setuid.
CapabilityBoundingSet=
En laissant la directive CapabilityBoundingSet sans valeur, on retire toutes les capacités Linux accessibles par le service.
Par exemple, pour pouvoir ouvrir des ports réseau inférieurs à 1024 (ce qui n’est autorisé que pour l’utilisateur root par défaut) :
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
Les capacités comprennent aussi la modification de l’horloge (CAP_SYS_TIME), la possibilité de charger des modules kernel (CAP_SYS_MODULE), le droit de tuer n’importe quel processus (CAP_KILL), les pouvoirs eBPF (CAP_BPF), le droit de monter des filesystems (CAP_SYS_ADMIN), modifier la configuration réseau (CAP_NET_ADMIN), redémarrer la machine (CAP_SYS_BOOT), changer les propriétaires des fichiers (CAP_CHOWN), ignorer les permissions de lecture (CAP_DAC_OVERRIDE), etc.
On peut mettre plusieurs capacités en les séparant avec des espaces, mais pour un démon applicatif standard, on laissera une liste vide. Pour un démon réseau qui doit pouvoir ouvrir n’importe quel port réseau, on mettra juste CAP_NET_BIND_SERVICE.
MemoryDenyWriteExecute=yes
Cette directive empêche la création de pages mémoire qui sont à la fois modifiables et exécutables. Cela bloque les attaques de type shellcode ou injection de code en mémoire. Par contre, il faut faire attention : si le programme qui s’exécute a été compilé nativement, pas de soucis ; par contre, si vous passez par un environnement qui a besoin de compilation à la volée (JIT), comme Java, .NET, NodeJS et d’autres, cela ne fonctionnera pas.
LockPersonality=yes
Cette directive empêche le service de modifier l’ABI du processeur. Ça bloque des attaques très spécifiques et très rares, mais pourquoi pas, ça ne coûte rien.
RestrictNamespaces=yes
Empêche la création de nouveaux namespaces Linux (fonctionnalité kernel permettant d’isoler les ressources comme les utilisateurs, les points de montage, le réseau, etc.). Seuls quelques namespaces minimaux, exigés par le kernel, restent autorisés.
Cette directive est très importante pour éviter certains types d’attaques via des binaires vérolés. Les seuls cas pour lesquels il faut ouvrir les autorisations, ce sont les services containerisés (Docker, Kubernetes), ou les services qui nécessitent des choses spéciales comme des espaces chroot par exemple.
Exemple de configuration sécurisée pour un worker
[Service]
ExecStart=/usr/local/bin/broker
DynamicUser=yes
StateDirectory=broker
LogsDirectory=broker
ConfigurationDirectory=broker
# isolation système
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
ProtectClock=yes
# restrictions de capacités et privilèges
NoNewPrivileges=yes
CapabilityBoundingSet= # aucune capacité
LockPersonality=yes
MemoryDenyWriteExecute=yes
RestrictNamespaces=yes
# restrictions de filesystem
ReadWritePaths=/var/lib/common-brokers
InaccessiblePaths=/mnt /media /srv
# restrictions réseau
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
Restart=on-failure
RestartSec=2Langage du code : PHP (php)