Limiter la mémoire et le CPU avec systemd
Un processus limité peut bloquer votre VPS. Configurez MemoryHigh, MemoryMax, CPUQuota et TasksMax, puis vérifiez le message d’OOM kill.
Limiter la mémoire et le temps CPU d’un processus avec un drop-in systemd
Vous limitez la mémoire et le temps CPU d’un processus sur un VPS Linux en ajoutant quelques lignes à l’unité qui l’exécute. MemoryMax= définit la limite maximale de mémoire. CPUQuota= définit la limite maximale de temps processeur. Ces deux limites sont appliquées par cgroup v2 (groupes de contrôle, version 2), la fonctionnalité du noyau que systemd utilise déjà pour comptabiliser chaque service du serveur.
sudo systemctl edit myapp.serviceCette commande ouvre un fichier drop-in contenant des instructions sous forme de commentaires. Ajoutez ceci au-dessus de ces commentaires :
[Service]
MemoryHigh=512M
MemoryMax=768M
MemorySwapMax=0
CPUQuota=80%
TasksMax=128sudo systemctl daemon-reload
sudo systemctl restart myapp.service
systemctl show myapp.service -p MemoryHigh -p MemoryMax -p CPUQuotaPerSecUSec -p TasksMaxsystemctl show doit afficher à nouveau vos valeurs dans les unités utilisées par le noyau : MemoryMax=805306368 et CPUQuotaPerSecUSec=800ms. S’il affiche MemoryMax=infinity, le drop-in n’a pas été chargé. Vérifiez que le fichier se trouve bien dans /etc/systemd/system/myapp.service.d/override.conf et qu’il commence par l’en-tête [Service], car une directive de configuration sans section préalable amène systemd à journaliser Assignment outside of section. Ignoring. et à démarrer le service sans aucune limite.
La suite de ce guide explique comment choisir ces valeurs et quels problèmes peuvent encore se produire une fois qu’elles sont définies.
Pourquoi un processus incontrôlé bloque un VPS sans le remplir
Un processus qui atteint une limite stricte de mémoire s’arrête en environ une seconde, puis le service redémarre. C’est le bon scénario. Le mauvais scénario est celui où rien ne s’arrête : la machine répond au ping, SSH accepte la connexion, mais l’invite du shell n’arrive jamais. La machine est active et occupée, mais aucune de ces opérations n’est utile.
Voici le mécanisme, car il n’est pas évident. Lorsque la mémoire libre diminue, le kernel récupère des pages au lieu d’en allouer de nouvelles. Les pages les moins coûteuses à récupérer sont celles adossées à des fichiers, et le page cache contient le code exécutable de tout ce qui tourne. Le kernel évince donc les pages de code de sshd, puis la prochaine instruction exécutée par sshd déclenche un page fault qui doit relire ces octets depuis le stockage. Chaque processus finit par attendre le disque au lieu de s’exécuter. Les mêmes pages sont évincées puis rechargées en boucle : c’est ce qu’on appelle le thrashing.
Deux facteurs aggravent ce problème sur un VPS par rapport à un laptop. Le stockage est souvent connecté au réseau ou partagé, donc chaque faute de page coûte davantage de millisecondes qu’avec un périphérique NVMe local. Ensuite, le kernel ne mesure pas le temps, mais les échecs : tant que la récupération restitue une page, même très lentement, le kernel considère qu’il progresse et ne déclenche pas l’out of memory (OOM) killer. Une machine peut rester dans cet état pendant plusieurs minutes avant qu’un processus soit tué.
Vous pouvez observer ce phénomène. Le kernel expose les pressure stall information (PSI) sous Linux 4.20 et les versions ultérieures :
cat /proc/pressure/memory
cat /proc/pressure/iosome avg10=63.72 avg60=41.02 avg300=12.33 total=13729481
full avg10=48.15 avg60=30.44 avg300=8.90 total=9114233La ligne full est celle qui compte. full avg10=48.15 signifie qu’au cours des dix dernières secondes, 48 % du temps, toutes les tâches exécutables de la machine étaient bloquées en attente d’opérations liées à la mémoire ; rien ne s’exécutait donc. Sur un serveur sain, full reste proche de zéro. Au-dessus de 10, la machine semble lente pour un humain. À partir de 40, elle est généralement décrite comme bloquée.
C’est aussi pourquoi une limite seule ne constitue pas une garantie. Une unité maintenue sous MemoryHigh= est ralentie au lieu d’être tuée. Elle reste donc active et lente, et rien ne la redémarre puisque, du point de vue de systemd, elle n’a jamais échoué. Une unité plafonnée qui peut toujours utiliser le swap génère des lectures et des écritures facturées à cette unité, mais exécutées par un périphérique partagé. Elle peut donc faire augmenter /proc/pressure/io pour tous les autres services de la machine. Les limites déterminent qui supporte le coût d’un manque de ressources, mais elles ne créent pas de capacité.
Vérifiez que votre VPS utilise cgroup v2
stat -fc %T /sys/fs/cgroupcgroup2fs correspond à la hiérarchie unifiée, dont tous les paramètres ci-dessous ont besoin. tmpfs signifie que le serveur a démarré avec l’ancienne organisation v1, dans laquelle MemoryHigh= et MemorySwapMax= n’existent pas et où le comportement OOM par unité diffère. Ubuntu 22.04 et les versions ultérieures, ainsi que Debian 11 et les versions ultérieures, utilisent v2 par défaut. Une ancienne image ou un kernel démarré avec systemd.unified_cgroup_hierarchy=0 ne l’utilise pas.
Avec cgroup v2, systemd active par défaut le suivi de la mémoire pour chaque unité. Les valeurs sont donc déjà disponibles :
systemd-cgtop -mCette commande liste les cgroups triés par utilisation mémoire. C’est le moyen le plus rapide de répondre à la question « qu’est-ce qui consomme les ressources de ce serveur ? » tant qu’il peut encore répondre. Si le serveur est récent, le compte et la configuration du firewall dans les dix premières minutes sur un nouveau VPS passent avant cette étape.
MemoryHigh limite. MemoryMax tue.
La différence entre ces deux paramètres mémoire détermine la forme que prend une défaillance.
MemoryHigh=est une limite souple. Au-delà, le noyau récupère agressivement la mémoire de ce cgroup et ralentit volontairement ses allocations. La consommation peut dépasser cette valeur et aucun processus n’est tué.MemoryMax=est une limite stricte. Lorsqu’une allocation ne peut pas être satisfaite dans cette limite, le tueur OOM s’exécute à l’intérieur de ce cgroup et tue l’un des processus de cette unité.
C’est la vraie raison de définir MemoryMax= pour tout service auquel vous ne faites pas entièrement confiance. Sans limite, un manque de mémoire concerne l’ensemble du serveur, et le tueur OOM global choisit sa victime selon oom_score, ce qui désigne généralement le processus le plus volumineux. Il s’agit généralement de votre base de données, et non du script qui a provoqué la fuite. Avec une limite, le processus tué appartient à l’unité qui a causé le problème.
Définissez les deux paramètres, avec MemoryHigh= environ 20 à 30 pour cent en dessous de MemoryMax=. L’écart constitue une zone d’avertissement : une fuite lente dépasse High et se manifeste par un service qui ralentit, tandis qu’un pic soudain dépasse directement Max et provoque l’arrêt du processus.
Les valeurs en pourcentage sont calculées à partir de la mémoire physique installée. Ainsi, MemoryMax=25% sur une offre de 4 GB correspond à 1 GB et reste égal au quart du serveur après le redimensionnement de l’offre. MemorySwapMax=0 empêche complètement cette unité d’utiliser le swap, ce qui transforme un ralentissement prolongé en arrêt rapide et évident.
Certains services vous permettent de définir leur consommation à l’avance au lieu de la mesurer. Une unité Ollama dimensionne son cache KV à partir de la fenêtre de contexte que vous lui attribuez. Consultez donc le coût en RAM d’une augmentation de num_ctx avant de choisir une limite.
Une limite doit être accompagnée d’une politique de redémarrage. Sinon, l’arrêt du processus vous laisse simplement avec un service arrêté.
[Unit]
StartLimitIntervalSec=300
StartLimitBurst=5
[Service]
Restart=on-failure
RestartSec=5sStartLimit* doit se trouver dans [Unit] et Restart= dans [Service]. Si vous placez l’un des deux dans la mauvaise section, systemd l’ignore. Cinq redémarrages en cinq minutes indiquent une fuite plutôt qu’un incident ponctuel. Après cela, systemd abandonne et laisse l’unité en échec. C’est l’état que vous voulez pouvoir identifier plus tard, plutôt qu’une boucle de crash qui masque le problème.
Limiter le CPU avec CPUQuota ou le partager avec CPUWeight
CPUQuota= correspond à un pourcentage du temps disponible sur un CPU. CPUQuota=50% représente la moitié d’un cœur. CPUQuota=200% équivaut à deux cœurs, que l’unité peut répartir sur autant de threads qu’elle le souhaite. Avec une offre de 2 vCPU, CPUQuota=200% correspond à l’ensemble de la machine.
CPUWeight= constitue le meilleur choix par défaut pour la plupart des services. Il définit une part relative comprise entre 1 et 10000, tandis que la valeur par défaut du kernel est 100. Cette pondération ne s’applique que lorsqu’il y a concurrence : sous charge, une tâche de sauvegarde réglée sur CPUWeight=20 cède du temps CPU à un serveur web réglé sur 100, tout en pouvant utiliser l’ensemble de la machine lorsque celle-ci est inactive. Un quota strict gaspille cette capacité inutilisée.
Soyez précis sur ce qu’une limite CPU vous apporte. Un processus limité par le CPU bloque rarement Linux, car le scheduler continue d’attribuer du temps CPU aux autres processus. C’est la mémoire qui met une machine à genoux. Utilisez CPUQuota= lorsque vous voulez un plafond prévisible, par exemple pour un build ou un agent qui fonctionnerait sinon à pleine charge pendant une heure. Le dimensionnement de ce type de workload est une question distincte, traitée dans la quantité de RAM et de CPU nécessaire à un VPS exécutant un agent de code.
Si le CPU est indiqué comme occupé alors qu’aucun de vos processus ne fait grand-chose, la cause peut se trouver de l’autre côté de l’hyperviseur. Il s’agit du steal time CPU dû à un voisin bruyant, et aucun quota que vous définissez ne changera cela.
TasksMax arrête une boucle de fork
TasksMax= correspond au nombre maximal de processus et de threads qu’une unité peut contenir. Les threads sont comptabilisés. Un service Java ou Go a donc besoin de plus de marge que ne le laisse penser la liste des processus. C’est la protection la moins coûteuse contre un script qui lance des fork en boucle, car le fork échoue dans l’unité au lieu d’épuiser les identifiants de processus du serveur.
TasksMax=128Lorsqu’une unité atteint la limite, le kernel journalise une ligne qui indique le cgroup :
cgroup: fork rejected by pids controller in /system.slice/myapp.serviceLe programme lui-même signale généralement fork: retry: Resource temporarily unavailable. Vérifiez la valeur appliquée par défaut par le manager avec systemctl show -p DefaultTasksMax.
Limiter une tâche ponctuelle avec systemd-run
Vous n’avez pas besoin d’un unit file pour utiliser cette fonctionnalité. systemd-run en crée un transient autour d’une seule commande.
sudo systemd-run --scope -p MemoryMax=1G -p MemorySwapMax=0 -p CPUQuota=50% -p TasksMax=64 ./import-data.sh--scope exécute la commande dans votre terminal après avoir affiché Running scope as unit: run-r7c1a....scope. La sortie reste affichée à l’écran et les limites disparaissent lorsque la commande se termine. Toutes les propriétés de systemd.resource-control fonctionnent après -p.
Pour une tâche longue, supprimez --scope et donnez-lui un nom. Elle s’exécute alors en arrière-plan comme un service transient et écrit ses journaux dans le journal systemd :
sudo systemd-run --unit=nightly-import -p MemoryMax=1G -p CPUWeight=20 ./import-data.sh
journalctl -u nightly-import -fLes mêmes options fonctionnent avec --user lorsque vous n’êtes pas root, mais votre user manager ne dispose que des contrôleurs qui lui ont été délégués ; une propriété peut donc y être refusée. Exécutez la commande avec sudo dans ce cas. Lorsqu’une tâche obtient une configuration permanente, transférez les paramètres tels quels dans un véritable unit : voir exécuter un script comme service et timer systemd.
La question du swap, avec une réponse honnête
Le swap modifie la forme de la panne au lieu de l’empêcher.
Sans swap, une fuite atteint la limite et un processus s’arrête en quelques secondes. La panne est brutale, courte et facile à analyser ensuite dans le journal. Avec le swap, le kernel écrit sur le disque les pages anonymes froides et vous fait gagner du temps. Si le processus devait finir par se stabiliser, le swap vous protège. S’il continue sans limite, le swap transforme une panne de cinq secondes en un blocage de vingt minutes. Ce blocage est pire, car un processus arrêté vous laisse tout de même un shell fonctionnel, contrairement à une machine en thrashing.
swapon --show
free -hSur un petit VPS, un compromis pratique consiste à conserver un fichier swap de taille modeste pour les pages allouées une seule fois et jamais réutilisées, puis à définir MemorySwapMax=0 sur les unités que vous acceptez de perdre. Les services importants conservent leur swap. Les services imprévisibles atteignent rapidement la limite et redémarrent.
Réduire vm.swappiness est un levier limité, et il est utile de comprendre pourquoi. Cela modifie seulement l’équilibre entre l’éviction du page cache et le swap des pages anonymes. Dans les deux cas, une lecture sur le disque sera nécessaire plus tard. Cela détermine quelles pages entrent en thrashing, mais pas si la machine entre en thrashing.
Un démon OOM précoce tue les processus avant le blocage
Le noyau attend l’échec complet de la récupération de mémoire. Sur un petit VPS, cette attente correspond exactement à la période pendant laquelle vous perdez la machine. Deux démons en espace utilisateur réduisent cette fenêtre en surveillant eux-mêmes la mémoire et en tuant les processus plus tôt.
earlyoom surveille la mémoire disponible et le swap libre. Il tue le processus ayant le score le plus élevé lorsque l’une de ces valeurs passe sous un seuil.
sudo apt install earlyoom
systemctl status earlyoomLe paquet Debian et Ubuntu démarre le service lors de l’installation. Ses options se trouvent dans /etc/default/earlyoom :
EARLYOOM_ARGS="-m 5,2 -s 5,2 --avoid '^(sshd|systemd)$' --prefer '^(node|python3)$'"-m PERCENT définit le minimum de mémoire disponible et -s PERCENT le minimum de swap libre. Les deux valeurs sont fixées à 10 pour cent par défaut. Le second nombre de chaque paire correspond au seuil de SIGKILL : earlyoom envoie SIGTERM lorsque la valeur passe sous le premier seuil, puis SIGKILL sous le second. Par défaut, ce second seuil correspond à la moitié du premier. Appliquez une modification avec sudo systemctl restart earlyoom, puis consultez journalctl -u earlyoom pour voir quel processus a été tué et quelle quantité de mémoire il utilisait.
systemd-oomd est l’autre option. Sa page de manuel le décrit comme « un service système qui utilise cgroups-v2 et les informations de pression d’attente (PSI) pour surveiller le système et prendre des mesures correctives avant qu’un OOM ne se produise dans l’espace noyau ». Il agit sur des cgroups entiers plutôt que sur des processus individuels. Il tue donc une unité, pas un processus enfant isolé. Les unités doivent l’activer avec ManagedOOMMemoryPressure=kill ou ManagedOOMSwap=kill. Les seuils se trouvent dans /etc/systemd/oomd.conf.
systemctl status systemd-oomd
oomctloomctl affiche ce que le service surveille actuellement. Sur une image serveur, il ne s’agit souvent de rien, car le paramètre doit être activé explicitement pour chaque unité. Choisissez un seul de ces démons. Les exécuter tous les deux crée une concurrence entre deux processus qui tentent de choisir une victime, ce qui complique la détermination de la cause d’un kill.
Quelle unité était responsable ?
Commencez par le kernel, car il journalise chaque processus qu’il tue.
journalctl -k --grep "Killed process" --since "2 hours ago"Un kill du OOM killer global ressemble à ceci :
Out of memory: Killed process 4127 (node) total-vm:2731084kB, anon-rss:1874232kB, file-rss:0kB, shmem-rss:0kB, UID:1000 pgtables:4212kB oom_score_adj:0anon-rss correspond à la mémoire que le processus occupait en RAM au moment de sa mort, environ 1.8 GB ici. Interprétez avec prudence le nom entre crochets. Il s’agit de la victime choisie par le kernel. Or le kernel choisit le plus gros processus, qui n’est pas toujours celui à l’origine du manque de mémoire.
Un kill dû à une limite de cgroup possède un préfixe différent. Le rapport affiché au-dessus indique le cgroup qui a atteint sa propre limite :
Memory cgroup out of memory: Killed process 8811 (python3) total-vm:1044320kB, anon-rss:769112kB, file-rss:0kB, shmem-rss:0kB, UID:998 pgtables:1720kB oom_score_adj:0Ce préfixe fournit l’essentiel du diagnostic. Memory cgroup out of memory signifie qu’une unité a atteint le MemoryMax= que vous lui avez attribué et que le reste du serveur fonctionnait normalement. Un simple Out of memory signifie que la machine entière n’avait plus de mémoire disponible. Vos limites étaient donc absentes ou trop généreuses pour être compatibles entre elles.
Demandez ensuite à systemd ce qu’il a observé :
systemctl status myapp.service
journalctl -u myapp.service -n 50myapp.service: A process of this unit has been killed by the OOM killer.
myapp.service: Main process exited, code=killed, status=9/KILL
myapp.service: Failed with result 'oom-kill'.systemctl status indique la même chose sur une seule ligne, sous la forme de Active: failed (Result: oom-kill).
Les compteurs du cgroup constituent la troisième source. C’est la seule qui enregistre le throttling, lequel ne produit jamais de ligne dans les journaux :
cat /sys/fs/cgroup/system.slice/myapp.service/memory.events
cat /sys/fs/cgroup/system.slice/myapp.service/memory.peaklow 0
high 4213
max 118
oom 12
oom_kill 12high compte le nombre de fois où l’unité a dépassé MemoryHigh= et a été soumise au throttling. max compte le nombre de fois où elle a atteint la limite stricte, et oom_kill compte les processus effectivement tués. Une valeur élevée de high avec oom_kill 0 correspond au cas silencieux décrit plus haut : le service fonctionne, fortement ralenti, et n’a signalé aucune erreur. memory.peak (Linux 5.19 et versions ultérieures) contient l’utilisation maximale atteinte par le cgroup. C’est cette valeur qui permet de dimensionner MemoryMax=. Les deux fichiers sont réinitialisés au redémarrage de l’unité, car systemd recrée le cgroup.
Un prérequis conditionne tout cela. Si /var/log/journal n’existe pas, le journal reste en RAM et chaque ligne disparaît après le redémarrage nécessaire pour récupérer le serveur.
sudo mkdir -p /var/log/journal
sudo systemd-tmpfiles --create --prefix /var/log/journal
sudo systemctl restart systemd-journald
journalctl --list-bootsLe fait que journalctl --list-boots affiche plus que le boot actuel signifie que l’historique est désormais conservé. journalctl -k -b -1 peut donc afficher les messages du kernel issus du boot qui a échoué.
Point de départ pour un petit VPS
Sur un plan de 2 GB, réservez 300 à 400 MB au kernel et au page cache. Ne répartissez pas les limites jusqu’à atteindre les 2 GB, car chaque unité peut atteindre son pic de consommation au même moment. Donnez la part la plus importante au service prioritaire, puis limitez les services plus incertains qui l’entourent.
[Service]
MemoryHigh=256M
MemoryMax=384M
MemorySwapMax=0
CPUWeight=20
TasksMax=64
Restart=on-failure
RestartSec=5sConserver un accès de secours justifie un réglage supplémentaire. OOMScoreAdjust=-500 dans un drop-in pour ssh.service réduit fortement le risque que l’OOM killer global choisisse votre daemon SSH comme victime. Cela peut faire la différence entre corriger le serveur et le redémarrer depuis le control panel. Ce réglage modifie uniquement le choix de la victime par le kernel. Il ne réduit pas la durée du blocage.
Les conteneurs s’exécutent dans leurs propres cgroups, créés par le container runtime et non par vos fichiers d’unité. Une limite définie sur docker.service ne devient donc pas une limite appliquée à un conteneur. Les équivalents par conteneur de MemoryMax= et CPUQuota= sont présentés dans définir des limites de mémoire et de CPU dans Docker Compose.
FAQ
Pourquoi mon VPS s’est-il figé au lieu de tuer le processus qui s’emballait ?
Parce que le kernel évalue la progression selon que la récupération libère des pages, et non selon le temps nécessaire. Lorsque la mémoire manque, il évacue le page cache, y compris les pages exécutables des programmes en cours, puis les relit à l’instruction suivante. Tout attend le stockage et aucune allocation n’a techniquement échoué. L’OOM killer n’est donc jamais appelé. Consultez /proc/pressure/memory pendant l’incident : une valeur full avg10 supérieure à 40 signifie que presque aucune tâche n’a pu s’exécuter au cours des dix dernières secondes. Un daemon userspace tel que earlyoom tue le processus avant que la machine atteigne cet état.
Quelle est la différence entre MemoryHigh et MemoryMax ?
MemoryHigh= est une limite souple qui bride le service. Le kernel récupère de force la mémoire de l’unité et ralentit ses allocations, mais la consommation peut dépasser cette valeur et aucun processus n’est tué. MemoryMax= est une limite stricte : une allocation qui ne peut pas être satisfaite dans cette limite déclenche l’OOM killer dans le cgroup de cette unité. Le processus à l’origine du problème est donc celui qui est tué, et non le plus gros processus de la machine. Définissez MemoryHigh= en dessous de MemoryMax= et considérez l’écart entre les deux comme une zone d’alerte.
Comment trouver le service ciblé par l’OOM killer ?
Exécutez journalctl -k --grep "Killed process" --since "2 hours ago". Une ligne commençant par Memory cgroup out of memory signifie qu’une unité a atteint son propre MemoryMax=, tandis qu’un simple Out of memory signifie que toute la machine a manqué de mémoire. Exécutez ensuite journalctl -u <unit> -n 50 et recherchez Failed with result 'oom-kill'. Si /var/log/journal n’existe pas sur votre serveur, le journal était conservé en RAM et les éléments de preuve ont disparu lors du redémarrage. Créez donc ce répertoire avant le prochain incident.
Dois-je ajouter du swap à un petit VPS ?
Un petit fichier swap aide pour les pages froides qui sont allouées une fois puis ne sont plus jamais utilisées. Il n’aide pas pour un processus qui s’emballe : il retarde son arrêt et transforme une brève interruption en un long blocage auquel vous ne pouvez pas vous connecter pour remédier au problème. Gardez une quantité de swap modeste et définissez MemorySwapMax=0 sur les unités que vous pouvez vous permettre de perdre. Elles atteindront ainsi leur plafond et redémarreront rapidement, tandis que les services importants conserveront leur swap.
Puis-je limiter une commande sans écrire de fichier d’unité ?
Oui. sudo systemd-run --scope -p MemoryMax=1G -p CPUQuota=50% ./script.sh exécute la commande dans votre terminal, au sein d’un scope transitoire avec ces limites. Les limites disparaissent lorsque la commande se termine. Toutes les propriétés de systemd.resource-control sont disponibles après -p. MemorySwapMax=, TasksMax= et CPUWeight= fonctionnent donc aussi. Supprimez --scope et ajoutez --unit=name pour exécuter la tâche en arrière-plan en envoyant sa sortie dans le journal.