DEV Community

Alex Volnei Galante
Alex Volnei Galante

Posted on

Kernel Linux para Desenvolvedores Backend - Gerenciamento de Memória Parte VIII

[!NOTE]
Última encerramento da "saga" de gerenciamento de memória. Na Parte VII vimos reclaim, PSI e o OOM Killer. Agora fechamos com o mecanismo que amarra tudo em produção — os cgroups v2 — e a conexão profunda com Python, .NET e Go, diagnóstico e estudos de caso.


Sumário


Memory Control Groups (cgroups v2)

Na Parte V falamos de alocação global vs local de frames. Os cgroups (control groups) são como o Linux moderno reintroduz fronteiras locais sobre um sistema global — e são a fundação de todo container (Docker, Podman, Kubernetes). Todo pod que você sobe é, no fundo, um conjunto de processos dentro de um cgroup com limites de memória.

Vamos focar em cgroups v2 (a versão unificada, padrão nas distros modernas). O controlador de memória expõe arquivos num diretório do sistema de arquivos cgroup:

# Um container/serviço vive em algo como:
/sys/fs/cgroup/system.slice/docker-<id>.scope/
# ou, no Kubernetes:
/sys/fs/cgroup/kubepods.slice/.../<pod>/
Enter fullscreen mode Exit fullscreen mode

memory.max, memory.high e memory.low

Os três controles essenciais, e a diferença entre eles é crucial e frequentemente ignorada:

     0 ─────────── memory.low ─────── memory.high ─────── memory.max
     │                  │                   │                  │
  protegido do    abaixo daqui,        throttle:          limite HARD:
  reclaim         reclaim "poupa"      reclaim agressivo   estourou → OOM
                  este cgroup          + throttle da app   do cgroup
Enter fullscreen mode Exit fullscreen mode
  • memory.max — o limite absoluto (hard limit). Se o cgroup tenta passar disso e o reclaim não consegue segurar, o OOM Killer do cgroup dispara (o OOMKilled do Kubernetes). É o teto intransponível.

  • memory.high — o limite suave (throttle). Ao ultrapassá-lo, o cgroup não é morto; em vez disso, o kernel aplica reclaim agressivo e desacelera (throttle) as alocações do grupo, criando "contrapressão". É um freio, não uma guilhotina — dá à aplicação a chance de reagir (rodar GC, liberar cache) antes de bater no max.

  • memory.lowproteção. Memória abaixo desse valor é protegida do reclaim enquanto houver memória recuperável em outros cgroups. Garante um mínimo para serviços críticos.

# Ler os limites de um cgroup
cat /sys/fs/cgroup/<meu-servico>/memory.max     # ex.: 2147483648 (2 GB) ou "max"
cat /sys/fs/cgroup/<meu-servico>/memory.high    # ex.: 1932735283 (~1.8 GB)

# Definir (normalmente feito pelo Docker/K8s, mas você pode ajustar)
echo 2G > /sys/fs/cgroup/<meu-servico>/memory.max
Enter fullscreen mode Exit fullscreen mode

[!IMPORTANT]
A estratégia de ouro em produção: configure memory.high abaixo de memory.max. O high dá à sua aplicação uma zona de "amortecimento" onde ela é desacelerada e pressionada a liberar memória (o runtime faz GC), antes de o max disparar um OOMKill abrupto. Muitos setups só definem max (via limits do K8s) e perdem essa rede de segurança.

Memory accounting

O cgroup contabiliza tudo: não só a memória anônima (heap) do processo, mas também o page cache e o slab atribuíveis a ele. Isso é uma surpresa comum:

cat /sys/fs/cgroup/<meu-servico>/memory.current   # uso total atual
cat /sys/fs/cgroup/<meu-servico>/memory.stat
# anon           524288000    ← heap/stack (páginas anônimas)
# file           838860800    ← PAGE CACHE contabilizado no cgroup!
# kernel_stack     2097152
# slab            10485760    ← estruturas do kernel deste cgroup
# sock             1048576
# ...
Enter fullscreen mode Exit fullscreen mode

Pegadinha clássica: seu container "usa" memória perto do limite, mas boa parte é page cache (arquivos que ele leu). Isso normalmente é reclaimável e não causa OOM sozinho — mas conta no memory.current. Entender memory.stat (separando anon de file) é o que distingue um falso alarme de um vazamento real.

Hierarquia e eventos

Cgroups são hierárquicos: limites do pai restringem os filhos, e o accounting sobe na árvore. E, como vimos na Parte VII, o memory.events e o memory.pressure (PSI por cgroup) dão observabilidade fina:

cat /sys/fs/cgroup/<meu-servico>/memory.events
# high 152   ← bateu no throttle 152 vezes (sinal de subdimensionamento!)
# max  8
# oom  2
# oom_kill 2

cat /sys/fs/cgroup/<meu-servico>/memory.pressure   # PSI local (Parte VII)
Enter fullscreen mode Exit fullscreen mode

Um high alto e crescente é o alarme precoce de que o container está apertado — muito antes do oom_kill. É a métrica que você quer no dashboard.


Conexão com Backend: Python

Python data-intensive (Pandas, NumPy, PyArrow) vive e morre pela relação com o page cache (Parte VI) e pelos limites de cgroup.

Page cache e mmap em análise de dados

Bibliotecas como Pandas e PyArrow podem ler arquivos via mmap, deixando o page cache fazer o trabalho pesado: só as páginas realmente tocadas entram na RAM, e são compartilhadas entre processos.

import pyarrow.parquet as pq

# memory_map=True → usa mmap; os dados vêm do page cache sob demanda,
# sem carregar o arquivo inteiro na heap do Python (zero-copy quando possível).
tabela = pq.read_table("dados_grandes.parquet", memory_map=True)
Enter fullscreen mode Exit fullscreen mode

ETL sem entupir o page cache

Em jobs ETL que varrem arquivos gigantes uma única vez, o page cache vira um problema: ele enche a RAM com dados que não serão relidos, pressionando o working set de outros processos (o cenário de "varredura única" da Parte VII). A solução é a dica fadvise da Parte VI:

import os

def processar_arquivo_grande(caminho):
    fd = os.open(caminho, os.O_RDONLY)
    os.posix_fadvise(fd, 0, 0, os.POSIX_FADV_SEQUENTIAL)  # read-ahead agressivo
    try:
        # ... lê e processa em streaming, sem carregar tudo ...
        pass
    finally:
        # Descarta do page cache o que já foi processado: não polui a RAM
        os.posix_fadvise(fd, 0, 0, os.POSIX_FADV_DONTNEED)
        os.close(fd)
Enter fullscreen mode Exit fullscreen mode

Evitando OOM em scripts de dados e isolando Celery

Um script Pandas que faz df.copy() descuidado dobra o uso de memória e toma OOM (Parte VII). Monitore com tracemalloc (Artigo 2, Parte IV) e processe em chunks. Para workers Celery, isole cada um em seu cgroup com memory.high/memory.max, evitando que um worker guloso derrube os vizinhos:

# systemd unit de um worker Celery
[Service]
MemoryHigh=1500M      # throttle: pressiona antes do limite
MemoryMax=2G          # hard limit: OOM do cgroup aqui
Enter fullscreen mode Exit fullscreen mode

Conexão com Backend: .NET

FileStream, page cache e MemoryMappedFile

Por padrão, System.IO.FileStream usa I/O bufferizado — passa pelo page cache (Parte VI). Para arquivos grandes acessados aleatoriamente, MemoryMappedFile deixa o kernel gerenciar via page faults:

using System.IO.MemoryMappedFiles;

using var mmf = MemoryMappedFile.CreateFromFile("dados_grandes.bin",
                                                FileMode.Open);
using var accessor = mmf.CreateViewAccessor();
// O acesso dispara page faults que trazem só as páginas necessárias
// do page cache — sem carregar o arquivo inteiro na managed heap.
byte valor = accessor.ReadByte(offset);
Enter fullscreen mode Exit fullscreen mode

GC pressure vs kernel memory pressure

Um ponto sutil: o GC do .NET decide coletar com base na sua própria visão de heap, que pode não enxergar a pressão de memória do cgroup. O resultado: o container bate em memory.high/memory.max (Parte VII) antes de o GC achar que precisa coletar → throttle ou OOMKill. A ponte é fazer o CLR respeitar o cgroup:

// runtimeconfig.json
{
  "configProperties": {
    "System.GC.Server": true,
    "System.GC.HeapHardLimitPercent": 75   // GC "vê" o limite do container
  }
}
Enter fullscreen mode Exit fullscreen mode

Assim o GC coleta de forma mais agressiva conforme se aproxima do teto, transformando um OOMKill em coleta controlada. (Retomamos aqui o tema do Server GC da Parte IV.)

Diagnóstico e page cache

dotnet-counters monitor -p <PID> --counters System.Runtime
#   GC Heap Size, Gen 2 GC Count, LOH Size, Working Set...

# Correlacionar working set do processo com o page cache do cgroup
cat /sys/fs/cgroup/<container>/memory.stat | grep -E 'anon|file'
Enter fullscreen mode Exit fullscreen mode

Conexão com Backend: Go

mmap e I/O com page cache

Go acessa mmap diretamente via syscall.Mmap, ideal para processar arquivos grandes (log aggregation, índices) aproveitando o page cache:

import (
    "os"
    "syscall"
)

func mapearArquivo(caminho string) ([]byte, error) {
    f, err := os.Open(caminho)
    if err != nil {
        return nil, err
    }
    defer f.Close()

    info, _ := f.Stat()
    // Mapeia o arquivo: o kernel serve as páginas via page cache,
    // sob demanda, sem cópia para a heap gerenciada pelo GC.
    return syscall.Mmap(int(f.Fd()), 0, int(info.Size()),
        syscall.PROT_READ, syscall.MAP_SHARED)
}
Enter fullscreen mode Exit fullscreen mode

bufio e a interação com o page cache

bufio.Reader/bufio.Writer adicionam um buffer em user space sobre o I/O que já passa pelo page cache em kernel space. Para leituras sequenciais, isso reduz o número de syscalls (cada read traz um bloco maior), enquanto o read-ahead do kernel (Parte VI) trabalha por baixo. Duas camadas de buffer cooperando.

GOMEMLIMIT: o freio contra o OOM do cgroup

O ponto mais importante para Go em containers. Como o GC do Go (por padrão, guiado por GOGC) não conhecia o limite do cgroup, apps Go tomavam OOMKill sob picos. O GOMEMLIMIT (Go 1.19+) resolve: define um teto suave de memória que faz o GC ficar mais agressivo ao se aproximar dele.

# Alinhe o GOMEMLIMIT ABAIXO do memory.max do cgroup (deixe folga p/ não-heap)
GOMEMLIMIT=1800MiB ./minha-app     # se o container tem memory.max=2Gi
Enter fullscreen mode Exit fullscreen mode

Isso é o equivalente Go do HeapHardLimitPercent do .NET: fazer o runtime "enxergar" o cgroup (Parte VII) e agir antes do OOM.

Direct I/O

Para workloads que gerenciam o próprio cache e não querem duplicação no page cache (Parte VI), Go pode abrir arquivos com O_DIRECT — casos específicos (bancos, storage engines), com o custo de alinhamento de buffers e perda de read-ahead automático.


Técnicas de Diagnóstico e Tuning

O arsenal completo do Artigo 3, conectando todas as partes:

# 1. Visão geral (lembre: 'available', não 'free' — Parte VI)
free -h
grep -E 'MemAvailable|Cached|Dirty|Writeback|SReclaimable' /proc/meminfo

# 2. Pressão real de memória (PSI — Parte VII). A métrica que importa.
cat /proc/pressure/memory
cat /sys/fs/cgroup/<servico>/memory.pressure

# 3. Slab: memória do kernel (Parte VI)
sudo slabtop -o
grep -E 'SReclaimable|SUnreclaim' /proc/meminfo

# 4. cgroup: o que realmente está acontecendo no container (Parte VIII)
cat /sys/fs/cgroup/<servico>/memory.current
cat /sys/fs/cgroup/<servico>/memory.stat      # separe anon (heap) de file (cache)
cat /sys/fs/cgroup/<servico>/memory.events    # high/oom_kill

# 5. Swap e reclaim (Partes III e VII)
vmstat 1                                       # si/so + coluna 'wa'
sar -B 1                                       # pgscank/pgscand (kswapd vs direct)

# 6. Watermarks e zonas (Parte VII)
cat /proc/zoneinfo | grep -E 'zone|min|low|high|free'

# 7. Quem foi morto pelo OOM (Parte VII)
journalctl -k | grep -i 'out of memory'
Enter fullscreen mode Exit fullscreen mode

Tuning que vale conhecer

vm.swappiness            # anon-swap vs cache-drop (Parte VII)
vm.min_free_kbytes       # folga da watermark min (Parte VII)
vm.dirty_ratio           # teto de dirty pages antes de bloquear (Parte VI)
vm.dirty_background_ratio# quando as flusher threads acordam (Parte VI)
Enter fullscreen mode Exit fullscreen mode

Boas Práticas

  1. Configure memory.high abaixo de memory.max. Dê à aplicação uma zona de amortecimento com throttle antes do OOMKill. Só definir limits no K8s não basta.

  2. Faça o runtime enxergar o cgroup. GOMEMLIMIT (Go), HeapHardLimitPercent (.NET), max_requests/monitoramento (Python). O runtime deve fazer GC/reciclagem antes do memory.max.

  3. Monitore por PSI, não por free. Alerte em memory.pressure (full avg60) e em memory.events:high, que sobem antes do OOM. Considere systemd-oomd para ação graciosa.

  4. Entenda seu accounting. Separe anon de file no memory.stat. Cache alto raramente é problema; anon crescente sem parar é vazamento.

  5. Use dicas de page cache em jobs de varredura única. POSIX_FADV_DONTNEED/SEQUENTIAL em ETL para não expulsar o working set alheio.

  6. Direct I/O só quando fizer sentido. Se a aplicação já tem cache próprio (bancos), O_DIRECT evita dupla cache; caso contrário, o page cache é seu amigo.


Estudos de Caso

1. Python — Pipeline de ETL com controle de page cache

Um job noturno de ETL em Pandas processava dezenas de arquivos Parquet de vários GB. A cada execução, o page cache enchia com dados de uso único, pressionando os serviços co-residentes (que começavam a sofrer direct reclaim — Parte VII). A solução combinou leitura em chunks, memory_map=True no PyArrow e POSIX_FADV_DONTNEED após cada arquivo. Resultado: o working set dos serviços vizinhos deixou de ser expulso, e o PSI full do host caiu drasticamente.

2. .NET — Microserviço com memory.high para prevenir OOM

Uma API ASP.NET Core em Kubernetes sofria OOMKilled intermitente sob picos. Só havia limits.memory (= memory.max) definido; sem memory.high, o container ia de "confortável" a "morto" sem transição. A correção: adicionar memory.high a ~85% do max, configurar HeapHardLimitPercent=75 e alertar em memory.events:high. O Server GC passou a coletar sob pressão, o throttle absorveu os picos, e os OOMKills cessaram.

3. Go — Stream processor com mmap e GOMEMLIMIT

Um agregador de logs em Go mapeava arquivos grandes via syscall.Mmap e sofria OOMKill quando o volume subia — o GC, guiado só por GOGC, não via o limite do container. Definir GOMEMLIMIT logo abaixo do memory.max fez o GC intensificar perto do teto; combinado ao mmap (que mantém os dados no page cache, fora da heap gerenciada), o RSS anônimo estabilizou e o OOM desapareceu.


Encerramento da Série de Memória

Percorremos um caminho longo. No Artigo Sobre Gerenciamento de Memória, construímos a fundação: a hierarquia de memória, a abstração do espaço de endereçamento, paginação, tabelas multinível, TLB, page faults, Copy-on-Write, o memory layout de um processo, NUMA — e como CPython, CLR e o runtime Go se apoiam em tudo isso.

O próximo artigo da série muda de subsistema: I/O e Armazenamento — block layer, I/O schedulers, filesystems e o revolucionário io_uring. Muitos dos conceitos daqui (page cache, dirty pages, fsync, Direct I/O) reaparecerão lá, agora do ponto de vista do disco. Até lá! tenham paciencia que esse vai demorar um pouco mais pra sair, mas vai ser tão profundo quanto este.

Parabens

Conteúdo parcialmente gerado com auxílio de IA generativa (me ajudou a organizar tudo isso kkkk)

Referências Bibliográficas

Top comments (0)