Linux Embedded

Le blog des technologies libres et embarquées

Limiter les ressources d'une ligne de commande avec systemd

( ou comment compiler Yocto sans mettre son laptop à genoux )

 

Introduction et objectif.

C'est un problème classique lorsqu'on est développeur. Il est temps de démarrer une grosse compilation, de réindexer une base de donnée ou de faire un gros backup. 

On réspire à fond, on lance notre commande et on va boire un café. Un deuxième café... Un troisième café.

Sous linux, les outils pour lancer des processus gourmands de façon controlée sont peu connus. La commane "nice" a une sémantique... intéressante et très variable selon les systèmes et la réponse la plus fréquente est d'emballer la commande dans un DockerFile et de limiter le conteneur.

Le fait que la plupart des commandes gourmandes lancent elle mêmes des processus gourmands (sous-processus de compilation) rend le problème encore plus compexe.

Le noyau Linux fournit pourtant un outil très puissant pour limiter les ressources d'un processus et de ses enfants : les control-groups (ou cgroups).

Les cgroups sont délicats à utiliser proprement et réussir à limiter les ressources de notre ligne de commande demanderait le développement de scripts complexes que personne n'a envie d'écrire.

Pour les daemons système, systemd gère les cgroups pour nous. Il est aisé de contrôler les ressources de n'importe quel service via son fichier de configuration[1]

Dans cet article nous allons voir comment lancer un processus en ligne de commande, le confier à systemd et laisser systemd appliquer toute sortes de limites pour nous. 

Quelques idées reçues sur systemd.

Pour la plupart d'entre nous, systemd sert avant tout à lancer des services

  • Un service (et sa ligne de commande) est défini dans un fichier de configuration dans /etc/systemd/system 
  • Les services sont démarrés au boot par systemd (ou manuellement avec systemctl)
  • La configuration des services est faite par l'administrateur
  • Une fois qu'un service est démarré, il n'y que peu de façons d'interagir avec.

Toutes ces suppositions sont fausses et nous allons les réfuter une à une.

Comment créer un service depuis la ligne de commande

Si les fichiers de configuration sont la façon la plus commune de définir un service, ce n'est pas la seule façon de faire. systemd présente une API de pilotage complète via DBus. En utilisant intelligemment cette API il est possible de monitorer et de piloter systemd sans modifier de fichier de configuration ni invoquer systemctl

En utilisant l'API DBus, il est possible de définir un service transitoire. Vous envoyez à systemd la commande principale de votre programme accompagnée de toutes les propriétés systemd que vous souhaitez et systemd créera le service pour vous et le démarrera comme n'importe quel service. Il n'est même pas nécessaire d'invoquer systemctl daemon-reload puisque les  fichiers de configuration eux-mêmes n'ont pas été modifiés.

 

Bien sûr, cet article n'est pas là pour parler de DBus. Nous allons utiliser la commande systemd-run qui sert précisément à créer des services depuis la ligne de commande

jerros@logrus:~$ systemd-run  echo 'It''s alive hahahaha!!!'
==== AUTHENTICATING FOR org.freedesktop.systemd1.manage-units ===
Authentification requise pour gérer les services système ou les unités.
Authenticating as: Jérémy Rosen,,, (jerros)
Password: 
==== AUTHENTICATION COMPLETE ===
Running as unit: run-u186.service


jerros@logrus:~$ journalctl -u run-u186.service 
-- Journal begins at Mon 2020-08-10 10:21:23 CEST, ends at Tue 2021-11-30 10:10:21 CET. --
nov. 30 10:10:12 logrus systemd[1]: Started /usr/bin/echo Its alive hahahaha!!!.
nov. 30 10:10:12 logrus echo[705446]: Its alive hahahaha!!!
nov. 30 10:10:12 logrus systemd[1]: run-u186.service: Deactivated successfully.

Dans sa forme la plus simple, systemd-run va:

  • prendre une commande à exécuter sur sa ligne de commande
  • créer un service qui exécute cette commande (avec toutes les propriétés laissées à leur valeur par défaut) 
  • démarrer le service.
  • revenir immédiatement en laissant le service s'exécuter en tâche de fond.

Comme nous laissons toutes les propriétés à leur valeur par défaut, les sorties standard de notre processus sont redirigées vers journald. systemd nous indique le nom de l'unité transitoire et nous pouvons donc utiliser journalctl pour vérifier que tout s'est bien passé.

 

Une invocation un peu plus complexe:

jerros@logrus:~$ systemd-run --unit echo-unit --property=Description="ceci est un ping" ping localhost
==== AUTHENTICATING FOR org.freedesktop.systemd1.manage-units ===
Authentification requise pour gérer les services système ou les unités.
Authenticating as: Jérémy Rosen,,, (jerros)
Password: 
==== AUTHENTICATION COMPLETE ===
Running as unit: echo-unit.service

jerros@logrus:~$ systemctl status echo-unit.service 
● echo-unit.service - ceci est un ping
     Loaded: loaded (/run/systemd/transient/echo-unit.service; transient)
  Transient: yes
     Active: active (running) since Tue 2021-11-30 10:16:00 CET; 5s ago
   Main PID: 707179 (ping)
      Tasks: 1 (limit: 18977)
     Memory: 352.0K
        CPU: 4ms
     CGroup: /system.slice/echo-unit.service
             └─707179 /usr/bin/ping localhost

nov. 30 10:16:00 logrus systemd[1]: Started ceci est un ping.
nov. 30 10:16:00 logrus ping[707179]: PING localhost(localhost (::1)) 56 data bytes
nov. 30 10:16:00 logrus ping[707179]: 64 bytes from localhost (::1): icmp_seq=1 ttl=64 time=0.028 ms
nov. 30 10:16:01 logrus ping[707179]: 64 bytes from localhost (::1): icmp_seq=2 ttl=64 time=0.076 ms
nov. 30 10:16:02 logrus ping[707179]: 64 bytes from localhost (::1): icmp_seq=3 ttl=64 time=0.077 ms
nov. 30 10:16:03 logrus ping[707179]: 64 bytes from localhost (::1): icmp_seq=4 ttl=64 time=0.079 ms
nov. 30 10:16:04 logrus ping[707179]: 64 bytes from localhost (::1): icmp_seq=5 ttl=64 time=0.075 ms
nov. 30 10:16:06 logrus ping[707179]: 64 bytes from localhost (::1): icmp_seq=6 ttl=64 time=0.092 ms

jerros@logrus:~$ systemctl stop echo-unit.service 
==== AUTHENTICATING FOR org.freedesktop.systemd1.manage-units ===
Authentification requise pour arrêter « echo-unit.service ».
Authenticating as: Jérémy Rosen,,, (jerros)
Password: 
==== AUTHENTICATION COMPLETE ===

Cette fois:

  • Notre commande (ping) ne se termine pas immédiatement. Nous pouvons donc regarder son status, mais nous devons l'arrêter explicitement
  • Nous avons donné un nom explicite au service transitoire plutôt que de laisser systemd décider pour nous
  • Nous avons changé une propriété de notre service (la description) pour illustrer comment faire.

Avec cette méthode vous pouvez créer des services aussi complexes que vous le souhaitez via des scripts shell (si vous utilisez autre chose que du shell, je recommande d'utiliser directement l'API DBus. C'est plus simple à utiliser)

Comment lancer un service sans avoir à fournir le mot de passe root

A chaque fois que nous avons voulu créer un service avec systemd-run, systemd nous a demandé de nous authentifier. C'est normal. Les services sont exécutés (par défaut) par l'utilisateur root et créer un service est donc une action privilégiée.

Il est vraiment dommage de devoir être root pour pouvoir utiliser toute les fonctionnalités que fournit systemd pour la gestion de processus en tâche de fond. Les fonctionnalités de monitoring, de démarrage à la demande et de redémarrage automatiques n'ont aucune raison d'être limitées à root lorsque le processus ne requiert aucun privilège particulier.

Pour permettre aux utilisateurs de pouvoir gérer leurs propres daemons, logind va démarrer une instance de systemd par utilisateur. Cette instance n'est pas démarrée en tant que root, mais en tant que l'utilisateur en question. Elle est donc naturellement limitée par les permissions de notre utilisateur.

Pour parler à notre systemd de session au lieu de parler à l'instance système, il suffit d'ajouter --user aux options de systemd-run ou de systemctl

jerros@logrus:~$ systemd-run --user --unit echo-unit  ping localhost
Running as unit: echo-unit.service

jerros@logrus:~$ systemctl --user list-units --type=service
  UNIT                                LOAD   ACTIVE SUB     DESCRIPTION                                     
  at-spi-dbus-bus.service             loaded active running Accessibility services bus
  dbus.service                        loaded active running D-Bus User Message Bus
  dconf.service                       loaded active running User preferences database
  echo-unit.service                   loaded active running /usr/bin/ping localhost
  gvfs-daemon.service                 loaded active running Virtual filesystem service
  gvfs-metadata.service               loaded active running Virtual filesystem metadata service
  gvfs-udisks2-volume-monitor.service loaded active running Virtual filesystem service - disk device monitor
  pipewire-media-session.service      loaded active running PipeWire Media Session Manager
  pipewire.service                    loaded active running PipeWire Multimedia Service
  pulseaudio.service                  loaded active running Sound Service
  xdg-desktop-portal-gtk.service      loaded active running Portal service (GTK+/GNOME implementation)
  xdg-desktop-portal.service          loaded active running Portal service
  xdg-document-portal.service         loaded active running flatpak document portal service
  xdg-permission-store.service        loaded active running sandboxed app permission store

LOAD   = Reflects whether the unit definition was properly loaded.
ACTIVE = The high-level unit activation state, i.e. generalization of SUB.
SUB    = The low-level unit activation state, values depend on unit type.
14 loaded units listed. Pass --all to see loaded but inactive units, too.
To show all installed unit files use 'systemctl list-unit-files'.

Nous voyons bien, ici, que notre commande est en cours d'exécution. Nous voyons également que les unités listées ne sont pas celles que nous avons l'habitude de voir. Il s'agit des daemons utilisateurs liés à notre session. Un autre utilisateur loggé sur la même machine aurait sa propre instance de pulseaudio pour son utilisateur.

Comment changer dynamiquement les propriétés d'une unité

Dans notre dernier exemple nous n'avons pas spécifié explicitement la description de notre service. systemd-run a donc utilisé la ligne de commande comme description. La description est une simple chaîne de caractères. Il n'y a aucune raison que l'on ne puisse pas la changer après le démarrage du service...

jerros@logrus:~$ systemctl --user show -p Description echo-unit.service 
Description=/usr/bin/ping localhost


jerros@logrus:~$ systemctl --user set-property echo-unit.service Description="une nouvelle description"


jerros@logrus:~$ systemctl --user show -p Description echo-unit.service 
Description=une nouvelle description

 

Toutes les propriétés ne peuvent pas être modifiées de cette façon. Certaines propriétés ne peuvent être positionnées qu'au démarrage du processus. systemd ne nous autorise donc pas à les modifier après coup.

jerros@logrus:~$ systemctl --user show -p Nice echo-unit.service 
Nice=0
jerros@logrus:~$ systemctl --user set-property echo-unit.service Nice=10
Failed to set unit properties on echo-unit.service: Cannot set property Nice, or unknown property.

Lancer une ligne de commande avec systemd

Lancer une commande avec systemd-run n'est pas tout à fait la même chose que de lancer une commande depuis notre shell

  • Le working directory reste $HOME quel que soit l'endroit d'où vous démarrez la commande
  • Le processus ne bloque pas notre shell
  • Notre standardinput est connecté à /dev/null  et notre standardoutput à journald
  • Nos variables d'environnement ne sont pas celles de notre shell mais celles définies par systemd[2]
Pour définir le répertoire de travail, Nous avons deux options
  • --working-directory= pour spécifier le répertoire
  • --same-dir pour réutiliser le répertoire d'exécution de systemd-run
jerros@logrus:/proc$ systemd-run --user --unit echo-unit   ls -l /proc/self/cwd
Running as unit: echo-unit.service

jerros@logrus:/proc$ journalctl -u echo-unit.service --user -e
...
nov. 30 11:02:57 logrus systemd[1364]: Started /usr/bin/ls -l /proc/self/cwd.
nov. 30 11:02:57 logrus ls[721585]: lrwxrwxrwx 1 jerros jerros 0 30 nov.  11:02 /proc/self/cwd -> /home/jerros


jerros@logrus:/proc$ systemd-run --user --unit echo-unit  --same-dir ls -l /proc/self/cwd
Running as unit: echo-unit.service

jerros@logrus:/proc$ journalctl -u echo-unit.service --user -e
...
nov. 30 11:12:46 logrus systemd[1364]: Started /usr/bin/ls -l /proc/self/cwd.
nov. 30 11:12:46 logrus ls[724535]: lrwxrwxrwx 1 jerros jerros 0 30 nov.  11:12 /proc/self/cwd -> /proc

 

Pour bloquer le shell, il suffit de demander
jerros@logrus:/proc$ systemd-run --user --unit echo-unit --wait sleep 10
Running as unit: echo-unit.service
Finished with result: success
Main processes terminated with: code=exited/status=0
Service runtime: 10.005s
CPU time consumed: 2ms
jerros@logrus:/proc$ 

systemd-run démarrera la commande puis attendra la fin du service. Attention néanmoins. Si vous utilisez Ctrl-C pour arrêter le service, vous terminerez systemd-run et non le service lui même. Vous devez passer par systemctl stop pour arrêter le service

Pour rediriger nos entrées et sorties

Manipuler les entrées sorties est toujours un peu délicat

  • l'option --pipe héritera stdin, stdout et stderr de son parent (systemd-run). Attention, vous ne devenez pas le contrôleur du TTY. Ce n'est donc pas la bonne méthode pour interagir avec votre commande
  • l'option --pty connectera votre service via un pseudo-tty. Vous pouvez interagir normalement avec le service via votre terminal.
  • Si vous utilisez les deux options à la fois, systemd-run détectera si il est invoqué depuis un TTY et choisira la bonne option automatiquement
Enfin, nos variables d'environnement.

Gérer les variables d'environnement semble un peu plus compliqué. Nous pouvons utiliser les propriétés Environment= ou EnvironmentFile= mais ces solutions sont fragiles. Le plus simple serait que ce ne soit pas systemd qui démarre le processus, mais que le processus soit démarré directement par systemd-run puis récupéré par systemd pour gérer les cgroups, le monitoring ou tout autre fonctionnalité dont nous aurions besoin.

Il y a évidement une option pour ça: --scope

jerros@logrus:/proc$ systemd-run --user --unit echo-unit --scope sh -c 'echo hello ; sleep 10 ; echo $(pwd)'
Running scope as unit: echo-unit.scope
hello
/proc
jerros@logrus:/proc$ 

Cette solution nous simplifie beaucoup les choses. Le processus est lancé directement par systemd-run

  • systemd-run va bloquer jusqu'à la fin de la commande
  • comme la commande est démarrée par systemd-run elle hérite de son contexte. 
    • Elle hérite de son répertoire de travail
    • Elle hérite de ses variables d'environnement
    • Elle hérite de ses stdin/stdout/stderror
  • Comme ce n'est pas systemd qui démarre la commande, il ne s'agit pas d'un service mais d'un scope.

cette option nous simplifie beaucoup les choses. En utilisant simplement "--user --scope" nous démarrons une commande avec tout l'environnement de notre shell mais nous pouvons utiliser systemd pour l'administrer.

Un introduction aux control-groups

Notre commande est donc adoptée de façon transparente par systemd. Son comportement est le même que si nous l'avions lancé depuis la ligne de commande mais, pour l'instant, ce n'est pas très utile. Notre but est de limiter les ressources consommées par notre processus. Pour tester cela, nous allons utiliser un processus un peu plus... aggressif.

jerros@logrus:~$ systemd-run --user --unit=test --scope stress --cpu 8
Running scope as unit: test.scope
stress: info: [7821] dispatching hogs: 8 cpu, 0 io, 0 vm, 0 hdd

stress est un programme linux qui démarre des tâches, chaque tâche va ensuite consommer le plus de CPU possible. Ma machine de test ayant huit coeurs, je fais lancer 8 tâches à stress.

Pour comprendre ce que nous allons faire avec systemd, il faut quelques notions sur les cgroup de linux. Les cgroup sont une organisation hiérarchique des processus de notre système. Les cgroup eux mêmes sont organisés en arbre (chaque noeud de l'arbre est un cgroup) et les processus de notre système sont rattachés aux cgroup feuilles. L'organisation des processus sur l'arbre des cgroups n'a rien à voir avec l'arbre de parenté des processus. 

systemd utilise les cgroups pour organiser les processus de notre système en créant un cgroup par service et par scope et en attachant ensuite les processus correspondant à ce cgroup.

Pour voir l'arbre des cgroup sur notre système, nous utilisons la commande systemd-cgls

jerros@logrus:~$ systemd-cgls 

Control group /:
-.slice
├─user.slice 
│ └─user-1000.slice 
│   ├─user@1000.service 
│   │ ├─session.slice 
<...>
│   │ ├─app.slice 
│   │ │ ├─test.scope 
│   │ │ │ ├─9881 /usr/bin/stress --cpu 8
│   │ │ │ ├─9882 /usr/bin/stress --cpu 8
│   │ │ │ ├─9883 /usr/bin/stress --cpu 8
│   │ │ │ ├─9884 /usr/bin/stress --cpu 8
│   │ │ │ ├─9885 /usr/bin/stress --cpu 8
│   │ │ │ ├─9886 /usr/bin/stress --cpu 8
│   │ │ │ ├─9887 /usr/bin/stress --cpu 8
│   │ │ │ ├─9888 /usr/bin/stress --cpu 8
│   │ │ │ └─9889 /usr/bin/stress --cpu 8
│   └─session-1.scope 
│     ├─ <...>
├─init.scope 
│ └─1 /sbin/init
└─system.slice 
  ├─ <...>

Nous voyons l'arbre des cgroups. Tous les processus utilisateurs sont regroupés au sein de user.slice puis au sein de user-XXX.slice où XXX est l'UID de chaque utilisateur. Les services système se trouvent tous sous system.slice

Nous retrouvons notre scope (test.scope) qui porte bien les neuf processus du programme stress (le processus de contrôle et ses huit threads)

Pour ce qui est de la consommation CPU elle-même, nous pouvons la constater en utilisant un outil dédié aux cgroups: systemd-cgtop

jerros@logrus:~$ systemd-cgtop --depth=12


Control Group                                                     Tasks   %CPU   Memory  Input/s Output/s
/                                                                   991  800,1     6.2G        -        -
user.slice                                                          790  798,6     5.5G        -        -
user.slice/user-1000.slice                                          790  798,6     5.5G        -        -
user.slice/user-1000.slice/user@1000.service                         55  772,9   112.5M        -        -
user.slice/user-1000.slice/user@1000.service/app.slice               44  769,3    33.9M        -        -
user.slice/user-1000…ice/user@1000.service/app.slice/test.scope       9  769,3     1.0M        -        -
user.slice/user-1000.slice/session-1.scope                          735   25,7     5.4G        -        -
user.slice/user-1000.slice/user@1000.service/session.slice            9    3,6    67.0M        -        -
user.slice/user-1000…0.service/session.slice/pulseaudio.service       5    3,6    29.5M        -        -
system.slice                                                         67    0,1   316.0M        -        -

Nous constatons plusieurs choses:

  • Le CPU de notre machine est saturé (consommation total de 800.1% pour une machine à 8 coeurs)
  • Quasiment tout est consommé par user.slice (system.slice ne consomme que 0.1% de CPU)
  • Au sein de user.slice, test.scope consomme la grosse majorité du CPU (769.3%)

cgroup-v1 et cgroup-v2

Il existe deux API pour communiquer avec les cgroups. L'API v1 et l'API v2. 

L'API v1, s'est avérée trop complexe a utiliser et à maintenir. Les développeurs du noyaux linux ont décidé de la rendre obsolète et (bien qu'elle soit encore maintenue) il est recommandé d'utiliser l'API v2. Systemd sait utiliser les deux API et utilisera l'API v2 lorsque c'est possible et l'API v1 dans les autres cas.

Ce détail nous concerne car l'API v1 ne permet pas de déléguer la gestion de ressources à des processus non-root de façon sécurisée. Quasiment toutes les distributions actuelles utilisent l'API v2. Pour vérifier sur votre système, utilisez la commande systemctl --version

jerros@logrus:~$ systemctl --version
systemd 249 (249.7-1)
+PAM +AUDIT +SELINUX +APPARMOR +IMA +SMACK +SECCOMP +GCRYPT +GNUTLS -OPENSSL +ACL +BLKID
+CURL +ELFUTILS -FIDO2 +IDN2 -IDN +IPTC +KMOD +LIBCRYPTSETUP -LIBFDISK +PCRE2 -PWQUALITY -P11KIT 
-QRENCODE +BZIP2 +LZ4 +XZ +ZLIB +ZSTD -XKBCOMMON +UTMP +SYSVINIT default-hierarchy=unified

La dernière ligne (default-hierarchy=unified) indique que systemd utilise l'api v2.

Si ce n'est pas le cas chez vous, vous pouvez forcer l'utilisation de l'api v2 avec la ligne de commande kernel "systemd.unified_cgroup_hierarchy=1"

Comment geler des processus.

L'intérêt de regrouper les processus en control-groups est que nous pouvons ensuite appliquer des limites directement sur les cgroup et que ces limites seront ensuite appliquées à tous les processus au sein du cgroup. Dans le cas d'un processus de compilation constitué d'un grand nombre de processus qui apparaissent et disparaissent rapidement, ce regroupement est très intéressant.

La première chose que nous pouvons faire avec notre scope, c'est de le geler. En donnant un ordre au noyau linux, nous pouvons lui interdire de scheduler l'ensemble des tâches d'un cgroup. En d'autres termes, les processus de notre scope ne pourront simplement plus s'exécuter.

jerros@logrus:~$ systemctl --user freeze test.scope

jerros@logrus:~$ systemctl --user status test.scope
● test.scope - /usr/bin/stress --cpu 8
     Loaded: loaded (/run/user/1000/systemd/transient/test.scope; transient)
  Transient: yes
     Active: active (running) (frozen) since Tue 2021-11-30 17:38:53 CET; 5min ago
      Tasks: 9 (limit: 18977)
     Memory: 1.0M
        CPU: 42min 11.701s
     CGroup: /user.slice/user-1000.slice/user@1000.service/app.slice/test.scope
             ├─12364 /usr/bin/stress --cpu 8
             ├─12365 /usr/bin/stress --cpu 8
             ├─12366 /usr/bin/stress --cpu 8
             ├─12367 /usr/bin/stress --cpu 8
             ├─12368 /usr/bin/stress --cpu 8
             ├─12369 /usr/bin/stress --cpu 8
             ├─12370 /usr/bin/stress --cpu 8
             ├─12371 /usr/bin/stress --cpu 8
             └─12372 /usr/bin/stress --cpu 8

nov. 30 17:38:53 logrus systemd[1383]: Started /usr/bin/stress --cpu 8.

jerros@logrus:~$ systemd-cgtop --depth=12
Control Group                                                     Tasks   %CPU   Memory  Input/s Output/s
/                                                                   975  114,2     6.2G        -        -
user.slice                                                          775   95,8     5.6G        -        -
user.slice/user-1000.slice                                          775   95,7     5.6G        -        -
user.slice/user-1000.slice/session-1.scope                          720   81,3     5.5G        -        -
user.slice/user-1000.slice/user@1000.service                         55   14,2   112.5M        -        -
user.slice/user-1000.slice/user@1000.service/session.slice            9   14,2    67.0M        -        -
user.slice/user-1000…0.service/session.slice/pulseaudio.service       5   14,2    29.5M        -        -
system.slice                                                         67    0,1   316.2M        -   


jerros@logrus:~$ systemctl --user thaw test.scope

 

La commande systemctl freeze nous a permis de geler notre scope. Celui-ci n'a plus accès au CPU et il a donc naturellement disparu de systemd-cgtop. Le scope est encore actif, les différents processus de stress existent toujours, ils n'ont simplement plus la possibilité de s'exécuter, et ce jusqu'à ce qu'ils soient dégelés par systemctl thaw.

 

Limiter la consommation CPU d'un service.

Pouvoir geler notre compilation est bien pratique. Si nous avons besoin de rapidement récupérer un PC utilisable au milieu d'une compilation, nous avons maintenant un moyens de le faire. Néanmoins, il serait utile de pouvoir limiter cette consommation, plutôt que d'avoir un switch on/off.

Pour pouvoir limiter la consommation de notre service, nous pouvons utiliser les cgroups pour définir un maximum accessible à l'ensemble des processus de notre service. Le scheduler ne laissera pas les processus s'exécuter au delà de cette limite.

Préparation de notre environnement.

La configuration systemd n'autorise pas les utilisateurs à poser des limites de CPU sur leurs processus. Nous allons commencer par nous redonner cette capacité.

jerros@logrus:~$ sudo systemctl edit user@$(id -u).service

Et, dans l'éditeur de texte que systemctl nous ouvre, nous saisissons le texte suivant:

[Service]
Delegate=yes

Il nous reste à nous délogger et nous relogger de toutes nos sessions pour que la modification prenne effet.

Limitation de la ressource.

Pour limiter la consommation de notre service/scope, il suffit d'utiliser l'attribut CPUQuota. Cet attribut prend une consommation CPU exprimée en pourcentage. 100% représente la capacité de calcul d'un coeur. Nous allons limiter notre scope à la capacité de calcul équivalente à deux coeurs.

jerros@logrus:~$ systemctl --user set-property test.scope CPUQuota=200%


jerros@logrus:~$ systemd-cgtop --depth 12
Control Group                                                     Tasks   %CPU   Memory  Input/s Output/s
/                                                                  1064  231,8     7.8G       0B   825.9K
user.slice                                                          866  228,2     7.3G       0B   550.6K
user.slice/user-1000.slice                                          866  228,2     7.3G       0B   550.6K
user.slice/user-1000.slice/user@1000.service                         67  195,0    78.0M        -        -
user.slice/user-1000.slice/user@1000.service/app.slice               57  193,8    39.1M        -        -
user.slice/user-1000…ice/user@1000.service/app.slice/test.scope       9  193,8     1.0M        -        -
user.slice/user-1000.slice/session-1.scope                          799   33,2     7.2G       0B   550.6K
user.slice/user-1000.slice/user@1000.service/session.slice            8    1,2    36.2M        -        -
user.slice/user-1000…0.service/session.slice/pulseaudio.service       4    1,2    23.8M        -        -
system.slice                                                         64    0,5   143.9M        -        -


jerros@logrus:~$ systemctl --user set-property test.scope CPUQuota=

Notre machine n'a pas changé. Elle a toujours 8 coeurs et est donc capable de produire 800% de charge CPU. Nos processus stress n'ont pas changés (nous ne les avons même pas redémarrés) et ils veulent toujours consommer le plus de CPU possible. Pourtant, notre test.scope n'utilise que 200% de CPU. La limite que nous avons posé a pris effet et le kernel limite nos processus gourmands à l'équivalent de deux coeurs.

Pondération de la ressource

Nous avons donc imposé un maximum d'accès CPU à notre service. Cela nous permet de garder un accès à notre machine, mais c'est un peu dommage. Lorsque nous ne nous servons pas de notre machine, ce serait bien que le service gourmand puisse utiliser toute la puissance CPU disponible. Pour cela nous allons modifier le poids relatif de notre unité par rapport aux autres unités ayant besoin d'accéder au CPU.

Commençons par lancer une deuxième tâche gourmande et observons ce qui se passe.

jerros@logrus:~$ systemd-run --user --unit=prio --scope stress --cpu 8
Running scope as unit: prio.scope
stress: info: [3160430] dispatching hogs: 8 cpu, 0 io, 0 vm, 0 hdd

jerros@logrus:~$ systemd-cgtop --depth=12


Control Group                                                     Tasks   %CPU   Memory  Input/s Output/s
/                                                                  1087  798,9    13.2G     1.1M   524.1K
user.slice                                                          889  796,8    12.7G     1.1M   157.6K
user.slice/user-1000.slice                                          889  796,8    12.7G     1.1M   157.6K
user.slice/user-1000.slice/user@1000.service                         76  768,0    71.5M        -        -
user.slice/user-1000.slice/user@1000.service/app.slice               66  767,2    34.8M        -        -
user.slice/user-1000…ice/user@1000.service/app.slice/test.scope       9  386,4     1.0M        -        -
user.slice/user-1000…ice/user@1000.service/app.slice/prio.scope       9  380,8     1.0M        -        -
user.slice/user-1000.slice/session-1.scope                          813   31,5    12.7G     1.1M   157.6K
user.slice/user-1000.slice/user@1000.service/session.slice            8    0,9    32.4M        -        -
user.slice/user-1000…0.service/session.slice/pulseaudio.service       4    0,9    20.1M        -        -
system.slice                                                         64    0,1   128.2M        -        -

Nous lançons deux scopes, chacun voulant utiliser tout le CPU disponible (800%). Rien ne distinguant nos deux scopes, le noyau linux répartit la ressource CPU de façon (à peu près) équitable. 

Pour changer le poids relatif nous allons modifier la propriété CPUWeight de notre scope. Cette valeur influence la façon dont le scheduler répartit le CPU entre les services qui en font la demande

  • Le poids peut prendre une valeur entre 0 et 10 000
  • Les unités qui n'ont pas explicitement de poids sont traitées comme si elles avaient un poids de 100
  • Cette mesure n'est prise en compte que lorsqu'il n'y a pas assez de CPU pour tous le monde.

Expérimentons un peu:

jerros@logrus:~$ systemctl --user set-property prio.scope CPUWeight=200

jerros@logrus:~$ systemd-cgtop --depth=12
Control Group                                                     Tasks   %CPU   Memory  Input/s Output/s
user.slice                                                          874  800,0     9.2G        -        -
user.slice/user-1000.slice                                          874  800,0     9.2G        -        -
/                                                                  1069  799,0     9.6G        -        -
user.slice/user-1000.slice/user@1000.service                         76  764,1    73.3M        -        -
user.slice/user-1000.slice/user@1000.service/app.slice               66  762,5    34.8M        -        -
user.slice/user-1000…ice/user@1000.service/app.slice/prio.scope       9  515,7     1.0M        -        -
user.slice/user-1000…ice/user@1000.service/app.slice/test.scope       9  246,8     1.0M        -        -
user.slice/user-1000.slice/session-1.scope                          798   35,9     9.1G        -        -
user.slice/user-1000.slice/user@1000.service/session.slice            8    1,5    34.2M        -        -
user.slice/user-1000…0.service/session.slice/pulseaudio.service       4    1,5    21.9M        -        -
system.slice                                                         64    0,2   128.7M        -        -

En positionnant le poids de prio.scope à 200, nous demandons au noyau de fournir à ce scope deux fois plus de CPU qu'à une autre unité ayant un besoin équivalent. Et nous constatons effectivement que prio.scope reçoit le double de CPU que test.scope.

En positionnant un poids faible (par exemple 25) sur notre scope de compilation, nous pouvons le rendre moins prioritaire pour le CPU et ainsi l'empêcher de ralentir nos autres travaux.

Autres réglages disponibles

Nous avons vu le principe pour limiter et pondérer un scope et nous avons vu comment lancer une compilation chaperonnée par systemd. Sans aller dans les détails, voici d'autres propriétés que vous pouvez positionner sur votre unité afin d'en limiter la consommation

  • StartupCPUWeight Ce paramètre permet de pondérer une unité tout comme CPUWeight mais ce paramètre n'est pris en compte que pendant la phase de boot de notre machine. Il permet donc de ralentir (ou accélérer) une unité donnée pour la rendre disponible plus rapidement
  • AllowedCPUs : Nous avons limité la quantité de CPU disponibles. Ce paramètre permet de réserver certains coeurs à certaines applications. Seuls les unités autorisées pourront s'exécuter sur ces coeurs
  • MemoryMin, MemoryLow, MemoryHigh, MemoryMax : Ces paramètres permettent de réserver de la RAM pour notre unité ou, au contraire, de lui refuser les allocations au delà d'une certaine quantité de RAM allouée
  • TasksMax : Permet de limiter le nombre total de threads au sein de notre unité. Cela peut servir à se protéger des fork-bombs
  • IOWeight, StartupIOWeight, IODeviceWeight : Permet de pondérer les unités pour leur accès aux ressources d'entrée/sortie. La première et la deuxième variantes permettent de poser une limite global, la troisième permet de positionner une limite différente pour chaque périphérique.
  • IOReadBandwidthMax, IOWriteBandwidthMax, IOReadIOPSMax, IOWriteIOPSMax : Permettent de limiter l'accès à la ressource d'IO. Les limites peuvent être posées par périphériques.

D'autres réglages permettent de limiter l'accès à certains périphériques ou de limiter les connexions IP entrantes et sortantes à certaines adresses IP ou ports. Tout cela est documenté dans la page systemd.resource-control(5)

Conclusion

Si systemd est avant tout responsable des daemons et du bon fonctionnement de notre système Linux en général, il offre un grand nombre de possibilités méconnues. Parmi celles-ci, sa capacité à être piloté dynamiquement via DBus offre de nombreuses possibilités pour résoudre des besoins architecturaux particuliers.

Dans cet article, nous avons exploité cette possibilité pour contrôler les ressources utilisées par un groupe de processus. Le gros avantage de systemd est la facilité de mise en oeuvre de la solution. Nous démarrons la commande en l'encapsulant avec systemd-run, la commande s'exécute ensuite normalement et nous pouvons changer dynamiquement ses contraintes d'exécution (CPU, bande passante disque, mémoire) avec des commandes simples et à effet immédiat.

Tout ce que nous avons fait est également faisable manuellement en modifiant le paramétrage noyau des cgroups, mais c'est un travail délicat, demandant une compréhension fine du fonctionnement des cgroups. Avec systemd c'est devenu trivial.

 

 

 

Notes

[1] man systemd.resource-control

[2] Ce serait la racine pour l'instance système de systemd.

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée.