← Voltar para o Blog

Limitar CPU e disco por serviço com cgroups

O backup das 2h derruba a latência do site. O worker de relatório consome os oito núcleos. Dá para dar a cada serviço uma fatia definida — e o systemd já faz isso com duas linhas.

Equipe EasyOps Cloud · · 7 min de leitura

São duas da manhã e o backup começou. O site, que respondia em 120 ms, passa a responder em dois segundos. Não é falta de recurso — é falta de arbitragem: dois processos disputando disco, e o kernel atendendo os dois com a mesma prioridade.

O mesmo padrão aparece de outras formas. O worker de relatório ocupa os oito núcleos e a API fica sem CPU. A reindexação satura o disco e o banco engasga.

O Linux resolve isso com cgroups, e o systemd expõe o mecanismo de forma que qualquer serviço ganha limites com duas linhas de configuração. Não é preciso aprender a interface do kernel — só saber quais controles existem.

Ver o que já está acontecendo

O systemd já organiza tudo em cgroups, mesmo sem nenhum limite definido:

systemd-cgtop
systemd-cgls

O systemd-cgtop é o top por serviço em vez de por processo — mostra CPU, memória e IO agregados de cada unidade. É a visão que responde "qual serviço está consumindo", em vez de "qual processo", que é uma pergunta menos útil quando a aplicação tem vinte workers.

Para o consumo acumulado de um serviço específico:

systemctl status meuapp | grep -E 'Memory|CPU|Tasks'
cat /sys/fs/cgroup/system.slice/meuapp.service/memory.peak
cat /sys/fs/cgroup/system.slice/meuapp.service/cpu.stat

O memory.peak é especialmente útil: ele registra o pico histórico, que é o número a usar como base para definir o limite. Definir com base no consumo instantâneo leva a limites apertados demais.

Limitar CPU

# /etc/systemd/system/backup.service.d/limites.conf
[Service]
CPUQuota=50%
CPUWeight=20

Os dois fazem coisas diferentes, e a distinção importa.

CPUQuota é um teto absoluto. 50% significa meio núcleo; 200% significa dois núcleos. O serviço não passa disso nem com a máquina ociosa — o que desperdiça capacidade quando não há concorrência.

CPUWeight é prioridade relativa, e só entra em jogo quando há disputa. O padrão é 100; um serviço com 20 recebe menos CPU que um com 100 quando os dois querem, e continua podendo usar tudo quando está sozinho.

Para tarefa de fundo, CPUWeight baixo costuma ser a melhor escolha: o backup roda rápido de madrugada, quando ninguém disputa, e cede espaço automaticamente se o tráfego aparecer.

[Service]
CPUWeight=20        # cede quando há disputa
CPUQuota=400%       # e nunca passa de 4 núcleos

Combinar os dois dá o comportamento mais previsível: prioridade baixa com teto de segurança.

Limitar disco, que é o que resolve o caso do backup

Disco é onde a disputa mais dói, porque a latência sobe de forma desproporcional.

[Service]
IOWeight=10
IOReadBandwidthMax=/dev/vda 50M
IOWriteBandwidthMax=/dev/vda 50M

IOWeight funciona como o CPUWeight — proporção sob disputa, entre 1 e 10000.

Os limites de banda são absolutos e exigem o dispositivo. Descubra qual é:

lsblk
findmnt -no SOURCE /var/backups

Vale usar o dispositivo pai — /dev/vda, não /dev/vda1 — porque o controle atua no disco físico.

Para carga sensível a latência, existe também o limite por operações:

IOReadIOPSMax=/dev/vda 1000
IOWriteIOPSMax=/dev/vda 1000

Limitar IOPS costuma preservar melhor a latência dos outros serviços do que limitar banda, porque o que degrada o tempo de resposta é a fila de operações, não a quantidade de bytes — raciocínio detalhado no artigo sobre IO lento.

Memória, com dois níveis

[Service]
MemoryHigh=1500M
MemoryMax=2G
MemorySwapMax=0

MemoryHigh é um freio: ao ultrapassar, o kernel pressiona o processo a devolver memória, com perda de desempenho e sem matar. MemoryMax é a parede — ao encostar, o processo é encerrado, mas dentro do próprio cgroup, sem colocar em risco o resto da máquina.

Deixar uma folga entre os dois dá à aplicação a chance de se recuperar antes do corte. O tema completo está no artigo sobre o OOM killer.

O MemorySwapMax=0 impede que aquele serviço use swap. É útil em banco de dados, onde ir para o disco é pior que falhar rápido, e contraproducente em processo de fundo, que pode perfeitamente ficar estacionado em swap.

Agrupar serviços em uma fatia

Limitar unidade por unidade fica repetitivo quando há vários serviços do mesmo tipo. As *slices* permitem tratar um conjunto como um bloco:

# /etc/systemd/system/fundo.slice
[Slice]
CPUWeight=20
IOWeight=20
MemoryMax=4G
# /etc/systemd/system/backup.service.d/slice.conf
[Service]
Slice=fundo.slice
sudo systemctl daemon-reload
sudo systemctl restart backup relatorios indexacao
systemd-cgls /fundo.slice

Agora backup, relatórios e indexação dividem 4 GB e competem entre si, sem afetar a aplicação principal. Adicionar um quarto serviço de fundo é uma linha, e ele já nasce contido.

Esse é o padrão que escala: uma fatia para o que é interativo, outra para o que é de fundo, e limites definidos por fatia em vez de por serviço.

Aplicar sem reiniciar

Para testar antes de gravar, o systemd aceita mudança ao vivo:

sudo systemctl set-property backup.service CPUQuota=30% IOWeight=10

Isso escreve a configuração e aplica de imediato. Para testar sem persistir:

sudo systemctl set-property --runtime backup.service CPUQuota=30%

O --runtime some no reinício, o que é exatamente o que se quer ao experimentar.

Confirme o que ficou valendo:

systemctl show backup.service -p CPUQuota,CPUWeight,IOWeight,MemoryMax

Medir se o limite está sendo atingido

Definir limite sem acompanhar é meio caminho. Os contadores dizem se ele está apertando:

cat /sys/fs/cgroup/system.slice/backup.service/cpu.stat
cat /sys/fs/cgroup/system.slice/backup.service/io.stat
cat /sys/fs/cgroup/system.slice/backup.service/memory.events

Em cpu.stat, o campo throttled_usec mostra quanto tempo o serviço ficou contido pelo teto. Valor alto significa que ele está sendo limitado de fato — o que pode ser o desejado, ou pode indicar limite apertado demais.

Em memory.events, o contador oom_kill maior que zero mostra que o processo já foi encerrado por estourar MemoryMax. É a confirmação de que o limite está protegendo o resto da máquina, e o sinal de que vale revisar se o consumo é legítimo.

E a pressão por cgroup, que é a métrica mais direta:

cat /sys/fs/cgroup/system.slice/backup.service/io.pressure
cat /sys/fs/cgroup/system.slice/backup.service/cpu.pressure

Um arranjo de referência

Para uma máquina rodando aplicação web, banco e tarefas de fundo:

# aplicacao — prioridade alta, teto de segurança
[Service]
CPUWeight=200
IOWeight=200
MemoryHigh=2G
MemoryMax=2500M

# banco — não deve competir, e não deve ir para swap
[Service]
CPUWeight=300
IOWeight=300
MemoryMax=3G
MemorySwapMax=0

# fundo — cede sempre, com limite de banda
[Service]
Slice=fundo.slice
CPUWeight=20
IOWeight=10
IOWriteBandwidthMax=/dev/vda 40M

A soma dos MemoryMax deve deixar folga para o sistema. Alocar toda a RAM entre os serviços tira a margem do kernel e do cache de disco, e o resultado é uma máquina que trava em vez de degradar.

O que isso não resolve

Vale ser claro sobre o limite da técnica. Cgroups arbitram disputa; eles não criam capacidade.

Se a máquina está saturada porque a carga legítima cresceu, limitar serviços apenas escolhe quem sofre. A leitura correta nesse caso vem do load average e da pressão, e a resposta é ajustar o tamanho da VPS.

O valor real dos limites está em outro lugar: garantir que uma tarefa secundária nunca derrube a principal, e que um vazamento fique contido no serviço que vazou. É proteção contra o comportamento anômalo de um componente — não substituto para capacidade.

Aplicar em processo avulso, sem criar serviço

Nem tudo que precisa de contenção é um serviço permanente. Uma migração de dados, um rsync grande ou uma compilação também disputam recurso — e podem ser executados dentro de um cgroup temporário:

sudo systemd-run --scope \
  -p CPUQuota=50% -p IOWeight=10 \
  rsync -a /var/www/ /backup/www/

O --scope executa o comando no seu terminal, dentro de um cgroup com os limites declarados. Ao terminar, o cgroup some.

Para tarefa longa que não deve morrer se a sessão cair, a variação sem --scope cria uma unit transitória gerenciada pelo systemd:

sudo systemd-run --unit=migracao-dados \
  -p CPUQuota=200% -p MemoryMax=4G -p IOWeight=20 \
  /usr/local/bin/migrar.sh

journalctl -u migracao-dados -f
systemctl status migracao-dados

Isso resolve de forma elegante o problema que normalmente se resolve com nohup ou tmux: a tarefa sobrevive à desconexão, tem log no journal, e ainda por cima roda contida.

É o mesmo mecanismo do artigo sobre escrever units, só que descartável — útil justamente para o que não vale um arquivo permanente.

Uma observação sobre onde começar. Aplicar limites a todos os serviços de uma vez é tentador e costuma gerar mais problema que solução — limites definidos no chute provocam contenção onde não havia. O caminho que funciona é inverso: comece medindo com systemd-cgtop por alguns dias, identifique o serviço que efetivamente atrapalha os outros, e limite apenas ele. Um limite bem posto resolve o incidente que motivou a busca; dez limites mal postos criam dez incidentes novos.

Vale registrar também o efeito colateral positivo que raramente é antecipado: depois de definir limites, o comportamento da máquina sob carga passa a ser previsível. Sem eles, um pico de uso produz um resultado diferente a cada vez, dependendo de qual processo chegou primeiro à disputa — o que torna qualquer diagnóstico posterior um exercício de reconstituição. Com limites, o mesmo pico produz o mesmo resultado, e isso é o que permite tratar desempenho como algo mensurável em vez de anedótico.

Suba um servidor em minutos

Preço em real, suporte em português e dados no Brasil.

Criar conta