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-cglsO 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.statO 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=20Os 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úcleosCombinar 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 50MIOWeight 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/backupsVale 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 1000Limitar 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=0MemoryHigh é 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.slicesudo systemctl daemon-reload
sudo systemctl restart backup relatorios indexacao
systemd-cgls /fundo.sliceAgora 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=10Isso 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,MemoryMaxMedir 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.eventsEm 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.pressureUm 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 40MA 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-dadosIsso 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.