SSD Nodes Learn 🎉 VPS desde $5.50/mês
Guias Matt ConnorPor Matt Connor

Como limitar memória e CPU de um processo no systemd

Configure MemoryHigh, MemoryMax, CPUQuota e TasksMax no drop-in do systemd. Veja por que um processo limitado ainda pode travar a VPS e como ler o OOM kill.

Limite a memória e a CPU de um processo com um drop-in do systemd

Pode limitar a memória e a CPU de um processo numa VPS Linux adicionando algumas linhas à unidade que executa o processo. MemoryMax= é o limite máximo de memória. CPUQuota= é o limite de tempo de processador. Ambos são aplicados pelo cgroup v2 (control groups, version 2), a funcionalidade do kernel que o systemd já utiliza para contabilizar cada serviço no sistema.

sudo systemctl edit myapp.service

Isto abre um ficheiro drop-in com instruções nos comentários. Adicione o seguinte antes dessas instruções:

[Service]
MemoryHigh=512M
MemoryMax=768M
MemorySwapMax=0
CPUQuota=80%
TasksMax=128
sudo systemctl daemon-reload
sudo systemctl restart myapp.service
systemctl show myapp.service -p MemoryHigh -p MemoryMax -p CPUQuotaPerSecUSec -p TasksMax

systemctl show tem de repetir os seus valores nas unidades utilizadas pelo próprio kernel: MemoryMax=805306368 e CPUQuotaPerSecUSec=800ms. Se apresentar MemoryMax=infinity, o drop-in não foi carregado. Verifique se o ficheiro foi criado em /etc/systemd/system/myapp.service.d/override.conf e se começa pelo cabeçalho [Service], porque uma linha de configuração sem uma secção anterior faz o systemd registar Assignment outside of section. Ignoring. e iniciar o serviço sem quaisquer limites.

O resto deste guia explica como escolher esses valores e o que ainda pode correr mal depois de os definir.

Por que um processo descontrolado bloqueia um VPS sem o encher

Um processo que atinge um limite rígido de memória termina em cerca de um segundo e o serviço é reiniciado. Esse é o caso bom. O caso mau é aquele em que nada termina: o servidor responde a ping, o SSH aceita a ligação, mas a prompt da shell nunca aparece. A máquina está ativa e ocupada, mas nenhum desse trabalho é útil.

Este é o mecanismo, porque não é óbvio. Quando a memória livre fica reduzida, o kernel recupera páginas em vez de atribuir novas. As páginas mais fáceis de recuperar são as suportadas por ficheiros, e a cache de páginas contém o código executável de tudo o que está em execução. Assim, o kernel expulsa as páginas de texto de sshd, e a instrução seguinte que sshd executa é uma falha de página que tem de ler esses bytes novamente do armazenamento. Todos os processos acabam por esperar pelo disco em vez de executar. As mesmas páginas saem e voltam num ciclo, o que se chama thrashing.

Duas coisas tornam esta situação pior num VPS do que num portátil. O armazenamento está frequentemente ligado através da rede ou é partilhado, pelo que cada falha custa mais milissegundos do que custaria num dispositivo NVMe local. Além disso, o kernel não mede o tempo; mede as falhas: enquanto a recuperação continuar a devolver uma página, por mais lentamente que seja, o kernel considera que está a progredir e não chama o out of memory (OOM) killer. Um servidor pode permanecer nesse estado durante muitos minutos antes de algo ser terminado.

É possível observar o problema. O kernel exporta informações de pressão de recursos (PSI) no Linux 4.20 e versões posteriores:

cat /proc/pressure/memory
cat /proc/pressure/io
some avg10=63.72 avg60=41.02 avg300=12.33 total=13729481
full avg10=48.15 avg60=30.44 avg300=8.90 total=9114233

A linha full é a importante. full avg10=48.15 significa que, nos últimos dez segundos, durante 48% do tempo todas as tarefas executáveis no servidor estiveram bloqueadas à espera de operações de memória, pelo que nada foi executado. Um servidor saudável apresenta um valor próximo de zero em full. Acima de 10, o sistema parece lento para uma pessoa; com 40 ou mais, está no estado que as pessoas descrevem como bloqueado.

É também por isso que um limite, por si só, não é uma garantia. Uma unidade mantida sob MemoryHigh= é limitada em vez de terminada, por isso continua ativa e lenta, e nada a reinicia porque, do ponto de vista do systemd, nunca falhou. Uma unidade limitada que continua autorizada a usar swap gera leituras e escritas contabilizadas nessa unidade, mas servidas por um único dispositivo partilhado. Assim, pode aumentar /proc/pressure/io para todos os outros serviços do servidor. Os limites determinam quem suporta o custo de uma escassez, mas não criam capacidade.

Verifique se o seu VPS utiliza cgroup v2

stat -fc %T /sys/fs/cgroup

cgroup2fs é a hierarquia unificada, que todos os parâmetros abaixo exigem. tmpfs significa que o servidor arrancou com o esquema v1 mais antigo, no qual MemoryHigh= e MemorySwapMax= não existem e o comportamento de OOM por unidade é diferente. Ubuntu 22.04 e versões posteriores, e Debian 11 e versões posteriores, utilizam v2 por predefinição. Uma imagem antiga ou um kernel arrancado com systemd.unified_cgroup_hierarchy=0 não utiliza v2.

No cgroup v2, o systemd ativa por predefinição a contabilização de memória para todas as unidades. Por isso, os valores já estão disponíveis:

systemd-cgtop -m

Isto lista os cgroups ordenados pelo uso de memória. É a forma mais rápida de responder à pergunta "o que está a consumir este servidor" enquanto ele ainda responde. Se o servidor for novo, a criação da conta e a configuração da firewall descritas em os primeiros dez minutos num VPS novo devem ser feitas antes deste passo.

MemoryHigh aplica limitação progressiva. MemoryMax termina processos.

A diferença entre as duas definições de memória determina o aspeto de uma falha.

  • MemoryHigh= é um limite flexível. Acima desse valor, o kernel recupera memória agressivamente desse cgroup e abranda intencionalmente as respetivas alocações. O uso pode ultrapassar o valor e nenhum processo é terminado.
  • MemoryMax= é um limite rígido. Quando uma alocação não pode ser satisfeita dentro desse limite, o OOM killer é executado dentro desse cgroup e termina um dos processos pertencentes a essa unidade.

Esta segunda parte é o verdadeiro motivo para definir MemoryMax= em qualquer serviço em que não confie totalmente. Sem limite, uma falta de memória afeta todo o servidor, e o OOM killer global escolhe a vítima com base em oom_score, o que normalmente significa escolher o processo maior. O processo maior costuma ser a base de dados, e não o script que provocou a fuga de memória. Com um limite, o processo terminado pertence à unidade que causou o problema.

Defina ambos, com MemoryHigh= cerca de 20 a 30 por cento abaixo de MemoryMax=. A diferença é uma zona de aviso: uma fuga lenta ultrapassa High e manifesta-se como um serviço que ficou lento, enquanto um pico súbito ultrapassa diretamente Max e termina o processo.

Os valores percentuais são calculados com base na memória física instalada. Assim, MemoryMax=25% num plano de 4 GB corresponde a 1 GB e continua a representar um quarto do servidor depois de aumentar o plano. MemorySwapMax=0 impede completamente essa unidade de usar swap, transformando uma degradação prolongada numa terminação rápida e evidente.

Um limite precisa de uma política de reinício associada. Caso contrário, a terminação deixa apenas um serviço parado.

[Unit]
StartLimitIntervalSec=300
StartLimitBurst=5

[Service]
Restart=on-failure
RestartSec=5s

StartLimit* pertence a [Unit] e Restart= a [Service]. Se colocar qualquer um deles na secção errada, o systemd ignora-o. Cinco reinícios em cinco minutos indicam uma fuga, e não uma falha momentânea. Depois disso, o systemd desiste e deixa a unidade no estado failed. É esse o estado que pretende encontrar mais tarde, em vez de um ciclo de falhas que oculta o problema.

Limite a CPU com CPUQuota ou partilhe-a com CPUWeight

CPUQuota= consome uma percentagem do tempo disponível num CPU. CPUQuota=50% corresponde a metade de um núcleo. CPUQuota=200% equivale a dois núcleos, que a unidade pode distribuir pelo número de threads que entender. Num plano com 2 vCPUs, CPUQuota=200% corresponde à máquina inteira.

CPUWeight= é a melhor predefinição para a maioria dos serviços. É uma quota relativa entre 1 e 10000, e a predefinição do kernel é 100. Só tem efeito quando existe concorrência: uma tarefa de backup com CPUWeight=20 cede tempo a um servidor Web com 100 sob carga, mas continua a usar a máquina inteira quando esta está livre. Uma quota rígida elimina essa capacidade disponível.

Seja claro sobre o que um limite de CPU permite. Um processo limitado pela CPU raramente bloqueia o Linux, porque o escalonador continua a atribuir tempo a todos os processos. É a memória que pode fazer uma máquina deixar de responder. Use CPUQuota= quando quiser um limite previsível, por exemplo numa compilação ou num agente que, de outro modo, usaria toda a CPU durante uma hora. Dimensionar esse tipo de carga é uma questão separada, abordada em quanta RAM e CPU um VPS para um agente de programação precisa.

Se a CPU aparecer ocupada enquanto nenhum dos seus processos faz muito trabalho, a causa pode estar do outro lado do hipervisor. Trata-se de tempo de CPU roubado por um vizinho ruidoso, e nenhum limite que defina alterará essa situação.

O TasksMax impede um loop de fork

TasksMax= é o número de processos e threads que uma unidade pode manter. As threads contam, por isso um serviço Java ou Go precisa de mais margem do que a lista de processos sugere. Esta é a proteção mais simples contra um script que executa fork num loop, porque o fork falha dentro da unidade em vez de o sistema ficar sem IDs de processos.

TasksMax=128

Quando uma unidade atinge o limite, o kernel regista uma linha com o nome do cgroup:

cgroup: fork rejected by pids controller in /system.slice/myapp.service

O próprio programa normalmente comunica fork: retry: Resource temporarily unavailable. Verifique o que o gestor aplica por predefinição com systemctl show -p DefaultTasksMax.

Limitar uma tarefa pontual com systemd-run

Não precisa de um ficheiro de unidade para utilizar nada disto. systemd-run cria uma unidade transitória para um único comando.

sudo systemd-run --scope -p MemoryMax=1G -p MemorySwapMax=0 -p CPUQuota=50% -p TasksMax=64 ./import-data.sh

--scope executa o comando no seu terminal depois de apresentar Running scope as unit: run-r7c1a....scope. O resultado permanece no ecrã e os limites desaparecem quando o comando termina. Qualquer propriedade de systemd.resource-control funciona depois de -p.

Para uma tarefa longa, remova --scope e atribua-lhe um nome. A tarefa é executada em segundo plano como um serviço transitório e os logs são enviados para o journal:

sudo systemd-run --unit=nightly-import -p MemoryMax=1G -p CPUWeight=20 ./import-data.sh
journalctl -u nightly-import -f

As mesmas opções funcionam com --user quando não é root, mas o gestor do utilizador só tem acesso aos controladores que lhe foram delegados, pelo que uma propriedade pode ser rejeitada nesse contexto. Execute-o com sudo se isso acontecer. Quando uma tarefa passa a ter uma configuração permanente, as definições são transferidas sem alterações para uma unidade real: consulte executar um script como serviço e temporizador do systemd.

A questão da swap, respondida com honestidade

A swap altera a forma como a falha ocorre, em vez de a impedir.

Sem swap, uma fuga de memória atinge o limite e algo morre em poucos segundos. A indisponibilidade é evidente, curta e fácil de interpretar depois no journal. Com swap, o kernel grava no disco as páginas anónimas que não estão a ser usadas e ganha tempo. Se o processo ia estabilizar, a swap pode salvá-lo. Se for um processo descontrolado, a swap transforma uma indisponibilidade de cinco segundos numa paragem de vinte minutos. A paragem é pior, porque um processo terminado ainda deixa uma shell funcional, mas um sistema em thrashing não.

swapon --show
free -h

Uma solução equilibrada num VPS pequeno é manter um ficheiro de swap moderado para páginas que são alocadas uma vez e nunca mais são acedidas, e definir MemorySwapMax=0 nas unidades que aceita perder. Os serviços importantes mantêm acesso à swap. Os serviços imprevisíveis atingem rapidamente o limite e reiniciam.

Reduzir vm.swappiness é uma alavanca fraca, e é importante perceber porquê. Apenas altera o equilíbrio entre remover páginas da cache de páginas e trocar páginas anónimas, e ambas as opções obrigam a uma leitura do disco mais tarde. Altera quais páginas entram em thrashing, mas não impede que o sistema entre em thrashing.

Um daemon OOM antecipado mata antes da paragem

O kernel espera que a recuperação de memória falhe completamente. Num VPS pequeno, essa espera é precisamente o intervalo em que perde a máquina. Dois daemons em userspace evitam esse problema ao monitorizarem a memória por conta própria e matarem processos mais cedo.

earlyoom monitoriza a memória disponível e a swap livre. Mata o processo com a pontuação mais elevada quando qualquer um dos valores fica abaixo de um limite.

sudo apt install earlyoom
systemctl status earlyoom

O pacote Debian e Ubuntu inicia o serviço durante a instalação. As opções estão em /etc/default/earlyoom:

EARLYOOM_ARGS="-m 5,2 -s 5,2 --avoid '^(sshd|systemd)$' --prefer '^(node|python3)$'"

-m PERCENT define o mínimo de memória disponível e -s PERCENT o mínimo de swap livre. Ambos usam 10 por cento por predefinição. O segundo número de cada par é o limite para SIGKILL. O earlyoom envia SIGTERM quando o valor fica abaixo do primeiro limite. Depois envia SIGKILL quando fica abaixo do segundo, que por predefinição corresponde a metade do primeiro. Aplique uma alteração com sudo systemctl restart earlyoom. Consulte journalctl -u earlyoom para ver qual processo foi terminado e quanta memória esse processo estava a utilizar.

systemd-oomd é a outra opção. A página de manual descreve-a como "um serviço do sistema que utiliza cgroups-v2 e informações de pressão de stall (PSI) para monitorizar e tomar medidas corretivas antes de ocorrer um OOM no espaço do kernel". Atua sobre cgroups completos, e não sobre processos individuais. Por isso, termina uma unidade, e não um processo filho isolado. As unidades ativam esta opção com ManagedOOMMemoryPressure=kill ou ManagedOOMSwap=kill. Os limites estão em /etc/systemd/oomd.conf.

systemctl status systemd-oomd
oomctl

oomctl mostra o que está a monitorizar no momento. Numa imagem de servidor, normalmente não mostra nada, porque a configuração é ativada individualmente por unidade. Escolha um daemon e use apenas esse. Executar ambos significa que há dois processos a competir para escolher uma vítima, o que dificulta determinar a causa de qualquer terminação.

Que unidade foi responsável?

Comece pelo kernel, porque ele regista todos os processos que termina.

journalctl -k --grep "Killed process" --since "2 hours ago"

Um kill do OOM killer global tem este aspeto:

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:0

anon-rss é a memória que o processo mantinha na RAM quando morreu, cerca de 1.8 GB neste caso. Leia com cautela o nome entre parênteses retos. Esse é o processo que o kernel escolheu como vítima, e o kernel escolhe o processo maior, que nem sempre é o processo que causou a falta de memória.

Um kill causado por um limite de cgroup tem um prefixo diferente, e o relatório apresentado acima identifica o cgroup que atingiu o seu próprio 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:0

Esse prefixo contém a maior parte do diagnóstico. Memory cgroup out of memory significa que uma unidade atingiu o MemoryMax= que lhe atribuiu e que o resto do sistema estava normal. Um Out of memory simples significa que a máquina inteira ficou sem memória, portanto os seus limites estavam ausentes ou eram demasiado elevados no conjunto.

Depois, consulte o que o systemd registou:

systemctl status myapp.service
journalctl -u myapp.service -n 50
myapp.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 indica o mesmo numa linha, como Active: failed (Result: oom-kill).

Os contadores do cgroup são a terceira fonte e a única que regista a limitação de recursos, que nunca produz uma linha de log:

cat /sys/fs/cgroup/system.slice/myapp.service/memory.events
cat /sys/fs/cgroup/system.slice/myapp.service/memory.peak
low 0
high 4213
max 118
oom 12
oom_kill 12

high conta quantas vezes a unidade ultrapassou MemoryHigh= e foi limitada. max conta quantas vezes atingiu o limite rígido, e oom_kill conta os processos efetivamente terminados. Um high elevado com oom_kill 0 é o caso silencioso descrito anteriormente: o serviço está em execução, ficou extremamente lento e não comunicou nenhuma falha. memory.peak (Linux 5.19 e mais recente) guarda o maior consumo atingido pelo cgroup. Esse é o valor usado para dimensionar MemoryMax=. Ambos os ficheiros são repostos quando a unidade reinicia, porque o systemd cria novamente o cgroup.

Existe um pré-requisito subjacente a tudo isto. Se /var/log/journal não existir, o journal fica na RAM e todas as linhas desaparecem depois do reboot necessário para recuperar o sistema.

sudo mkdir -p /var/log/journal
sudo systemd-tmpfiles --create --prefix /var/log/journal
sudo systemctl restart systemd-journald
journalctl --list-boots

O facto de journalctl --list-boots mostrar mais do que o boot atual significa que o histórico agora persiste. Assim, journalctl -k -b -1 pode mostrar as mensagens do kernel do boot que falhou.

Um ponto de partida para um VPS pequeno

Num plano de 2 GB, reserve 300 a 400 MB para o kernel e a page cache. Não deixe que os limites somem os 2 GB completos, porque todas as unidades podem atingir o pico ao mesmo tempo. Atribua a maior parcela ao serviço mais importante e limite tudo o que for secundário à sua volta.

[Service]
MemoryHigh=256M
MemoryMax=384M
MemorySwapMax=0
CPUWeight=20
TasksMax=64
Restart=on-failure
RestartSec=5s

Manter uma forma de acesso vale mais uma configuração. OOMScoreAdjust=-500 num drop-in para ssh.service torna muito menos provável que o OOM killer global escolha o daemon SSH como vítima. Essa pode ser a diferença entre corrigir o servidor e reiniciá-lo no painel de controlo. A opção apenas altera a escolha da vítima pelo kernel. Não reduz a duração da indisponibilidade.

Os contentores são executados nos seus próprios cgroups, criados pelo runtime de contentores e não pelos ficheiros das suas unidades. Por isso, um limite em docker.service não se torna um limite para um contentor. Os equivalentes por contentor de MemoryMax= e CPUQuota= são explicados em configurar limites de memória e CPU no Docker Compose.

FAQ

Porque é que o meu VPS bloqueou em vez de terminar o processo descontrolado?

Porque o kernel avalia o progresso com base no facto de a recuperação devolver páginas, e não no tempo que essa operação demora. Enquanto há pouca memória, remove páginas da cache de páginas, incluindo as páginas executáveis dos programas em execução, e lê-as novamente na instrução seguinte. Tudo fica à espera do armazenamento e nenhuma alocação falhou tecnicamente, por isso o OOM killer nunca é chamado. Consulte /proc/pressure/memory enquanto isto acontece: um full avg10 superior a 40 significa que quase nenhuma tarefa conseguiu executar nos últimos dez segundos. Um daemon em userspace, como earlyoom, termina processos antes de o sistema chegar a esse estado.

Qual é a diferença entre MemoryHigh e MemoryMax?

MemoryHigh= é um limite flexível que reduz a velocidade. O kernel força a recuperação de memória da unidade e abranda as suas alocações, mas o uso pode ultrapassar esse valor e nada é terminado. MemoryMax= é um limite rígido: uma alocação que não possa ser satisfeita dentro desse limite chama o OOM killer dentro do cgroup da própria unidade. Assim, termina o processo que causou o problema, em vez do maior processo do sistema. Defina MemoryHigh= abaixo de MemoryMax= e trate a diferença entre ambos como uma zona de aviso.

Como descubro qual serviço foi atingido pelo OOM killer?

Execute journalctl -k --grep "Killed process" --since "2 hours ago". Uma linha que comece por Memory cgroup out of memory significa que uma unidade atingiu o seu próprio MemoryMax=, enquanto um Out of memory simples significa que toda a máquina ficou sem memória. Em seguida, execute journalctl -u <unit> -n 50 e procure Failed with result 'oom-kill'. Se /var/log/journal não existir no seu servidor, o journal estava mantido em RAM e as evidências perderam-se com o reboot. Crie esse diretório antes do próximo incidente.

Devo adicionar swap a um VPS pequeno?

Um ficheiro de swap pequeno ajuda com páginas frias que são alocadas uma vez e nunca mais são utilizadas. Não ajuda com um processo descontrolado: atrasa o termination e substitui uma interrupção curta por uma espera longa, durante a qual não consegue iniciar sessão para corrigir o problema. Mantenha o swap moderado e defina MemorySwapMax=0 nas unidades que aceita perder. Assim, essas unidades atingem o limite e reiniciam rapidamente, enquanto os serviços importantes continuam a utilizar o respetivo swap.

Posso limitar um comando sem escrever um ficheiro de unidade?

Sim. sudo systemd-run --scope -p MemoryMax=1G -p CPUQuota=50% ./script.sh executa o comando no seu terminal dentro de um scope transitório com esses limites, e os limites desaparecem quando o comando termina. Todas as propriedades de systemd.resource-control ficam disponíveis depois de -p, por isso MemorySwapMax=, TasksMax= e CPUWeight= também funcionam nesse contexto. Remova --scope e adicione --unit=name para executar o trabalho em background com a saída no journal.