Limites de memória no Docker Compose sem OOM
Defina limites de memória e CPU no Docker Compose para evitar que um container derrube a VPS. Veja deploy.resources, mem_limit, exit 137, swap e dimensionamento.
O que um limite de memória do Docker Compose faz
Um limite de memória do Docker Compose é um teto rígido que o kernel Linux aplica ao cgroup (grupo de controlo, a funcionalidade do kernel que mede os recursos de um conjunto de processos) de um contentor. Defina deploy.resources.limits.memory num serviço e esse contentor nunca poderá usar mais do que o valor indicado. Quando tenta ultrapassá-lo, o kernel termina um processo dentro do contentor, que normalmente termina com o código 137.
Isto é especialmente importante numa VPS, onde a RAM é fixa e não há memória livre no host para utilizar. Um contentor com uma fuga de memória ou uma consulta mal concebida pode consumir todas as páginas livres de um sistema com 8GB. O kernel termina então o processo que considera mais problemático. Muitas vezes, esse processo é uma base de dados ou a sua sessão SSH, e não o contentor que causou o problema. Os limites transformam uma indisponibilidade de todo o servidor num problema de um único serviço, que pode ser reiniciado.
services:
app:
image: ghcr.io/example/app:1.4
deploy:
resources:
limits:
cpus: "1.5"
memory: 1g
reservations:
memory: 256mAplique a configuração e confirme que o limite está ativo:
docker compose up -d
docker stats --no-streamA coluna MEM USAGE / LIMIT deve apresentar algo semelhante a 142MiB / 1GiB. Se a coluna do limite mostrar toda a RAM do host, a configuração não foi aplicada. O restante deste guia não será útil até corrigir isso. Se o ficheiro Compose ainda for novo para si, os conceitos básicos do Docker Compose para uma VPS explicam a estrutura de ficheiros usada aqui.
deploy.resources.limits ou mem_limit: qual se aplica
Existem duas formas de escrever a mesma ideia, por isso esta situação causa confusão.
mem_limit, mem_reservation, memswap_limit, cpus e cpu_shares são chaves de serviço de nível superior herdadas dos formatos antigos do ficheiro Compose. deploy.resources veio do esquema do Swarm e agora faz parte da Compose Specification, que é o formato que docker compose lê atualmente.
Ambas funcionam num único host. O Compose V2, o plugin docker compose, aplica deploy.resources.limits e deploy.resources.reservations quando executa docker compose up, sem qualquer cluster Swarm. As partes do bloco deploy que são exclusivas do Swarm são as restantes chaves: mode, placement, update_config e endpoint_mode têm significado para docker stack deploy e são ignoradas por docker compose up. Por isso, o conselho comum de que "deploy precisa do Swarm" está errado para a subseção de recursos. Segui-lo deixa os seus serviços sem qualquer limite.
Escolha uma forma de escrita por projeto. Escrever mem_limit: 512m e deploy.resources.limits.memory: 1g no mesmo serviço produz um ficheiro que ninguém consegue interpretar rapidamente. Em vez de tentar adivinhar qual dos valores prevaleceu, consulte o daemon:
docker inspect --format '{{.HostConfig.Memory}} {{.HostConfig.MemoryReservation}} {{.HostConfig.NanoCpus}}' app-1Os valores de memória são expressos em bytes, por isso 1g apresenta-se como 1073741824. A CPU é expressa em nano CPUs, por isso 1.5 apresenta-se como 1500000000. Um 0 em qualquer campo significa que não foi definido nenhum limite. O menor limite de memória aceite pelo Docker é 6m. Abaixo desse valor, o contentor recusa iniciar.
O que acontece quando um contentor atinge o limite
O contentor não fica mais lento. Termina.
Quando um processo solicita uma página e o cgroup já atingiu o seu memory.max, o kernel começa por recuperar o que conseguir dentro desse cgroup: a cache de páginas limpas e, depois, as páginas que puder enviar para swap. Se a recuperação não libertar memória suficiente, o terminador OOM (out of memory) do cgroup escolhe um processo dentro do contentor e envia-lhe SIGKILL. Terminar o PID 1 do contentor termina o contentor. O código de saída 137 é simplesmente 128 mais o sinal 9. Por isso, 137 é a indicação de qualquer SIGKILL, não uma prova de OOM por si só.
docker compose ps -a
docker inspect --format '{{.State.OOMKilled}} {{.State.ExitCode}}' app-1true 137 é um terminador OOM. false 137 significa que outra coisa enviou SIGKILL. A causa habitual é docker compose stop atingir o seu período de tolerância de dez segundos porque a aplicação ignorou SIGTERM. Esta distinção poupa horas, porque os dois problemas não têm qualquer relação.
Há mais dois locais que registam o evento. Monitorize o daemon em tempo real:
docker events --filter event=oomDepois leia o log do kernel, que é o registo que permanece depois de um reinício:
sudo dmesg -T | grep -i -E 'memory cgroup out of memory|killed process'Um kill de cgroup imprime uma linha que começa por Memory cgroup out of memory: Killed process 24713 (node). Uma linha sem o prefixo Memory cgroup indica um OOM do host, o que significa que a própria máquina ficou sem RAM. É precisamente esta falha que os limites devem impedir. Se a vir, isso indica que a soma dos seus limites é demasiado elevada ou que alguns serviços não têm qualquer limite.
Com restart: unless-stopped, um ciclo de OOM pode passar despercebido, porque o serviço aparece como ativo em docker compose ps um segundo depois de terminar. Verifique a coluna de uptime e o contador de reinícios. Associe também o limite a um healthcheck que indique que a aplicação está não saudável, para que um contentor que termina repetidamente fique visível sem ter de o monitorizar.
A reserva é uma indicação; o limite é a regra
reservations.memory (o mem_reservation mais antigo) é um limite mínimo flexível. O Docker descreve-o como um limite flexível ativado quando o daemon deteta contenção ou pouca memória no host. Nunca impede que um contentor ultrapasse esse valor e nunca garante que a memória esteja livre quando o contentor a solicitar. Apenas orienta o kernel a recuperar primeiro memória dos contentores que estejam acima da respetiva reserva.
Por si só, uma reserva não protege nada. Use-a para indicar um serviço que deve ser tratado de forma favorável sob pressão e confie no limite para garantir a segurança. Mantenha a reserva abaixo do limite. Caso contrário, o contentor não inicia: o Docker rejeita a configuração com Minimum memory limit can not be less than memory reservation limit.
Contabilidade de swap, sem rodeios
A maioria das imagens de VPS é fornecida sem qualquer ficheiro de swap. Execute swapon --show e free -h. Se o total de swap for zero, todas as definições relacionadas com swap abaixo não têm efeito, e o limite de memória é apenas um limite de RAM.
memswap_limit não é a quantidade de swap. É o total de memória mais swap. Com mem_limit: 1g e memswap_limit: 2g, o contentor recebe 1GB de RAM e 1GB de swap. Definir os dois valores como iguais não deixa qualquer swap disponível para o contentor. Definir mem_limit e deixar memswap_limit sem valor permite novamente que o contentor use swap até ao tamanho do seu limite de memória.
Ubuntu 24.04 e Debian 13 usam cgroup v2 por predefinição. Nesse modo, o swap é um contador separado (memory.swap.max), e isto funciona sem configuração adicional. A mensagem antiga Your kernel does not support swap limit capabilities ocorre em hosts com cgroup v1 arrancados sem swapaccount=1. Nesses hosts, o limite de memória continua a aplicar-se, mas a parte relativa ao swap é ignorada.
Seja claro sobre o benefício do swap. O swap torna mais lento o encerramento por OOM, mas não o torna menos provável, porque um processo com fuga de memória preenche o swap tão facilmente como preenche a RAM. Entretanto, um contentor que use swap intensivamente no armazenamento partilhado da VPS abranda todos os outros serviços do servidor. Para qualquer serviço sensível à latência, um limite correto sem swap falha mais depressa e de forma mais previsível.
A utilização de memória parece pior do que é
A métrica MEM USAGE em docker stats inclui a cache de páginas. Por isso, um contentor que lê ficheiros grandes aproxima-se do seu limite e permanece nesse nível. Isto é normal e não é uma fuga de memória, porque a cache limpa é recuperada antes de o OOM killer ser chamado. Um serviço como um servidor multimédia Jellyfin autoalojado parecerá permanentemente próximo do limite exatamente por este motivo.
Separe a cache do conjunto de trabalho real a partir do interior do contentor:
docker compose exec app grep -E '^(anon|file) ' /sys/fs/cgroup/memory.stat
docker compose exec app cat /sys/fs/cgroup/memory.eventsanon é a memória anónima, o conjunto de trabalho que não pode ser libertado. file é a cache de páginas, que pode ser libertada. Dimensione o limite com base em anon mais uma margem, e não no total. O ficheiro memory.events elimina qualquer dúvida: um contador oom_kill acima de zero significa que o kernel matou algo neste contentor desde que foi iniciado, e um contador max a aumentar significa que o contentor está a atingir o limite neste momento. Ambos os comandos precisam de uma shell e do coreutils dentro da imagem, pelo que falham numa imagem distroless ou scratch.
Limites de dimensionamento num VPS com 8GB
Comece pelo host, não pelas aplicações. Num VPS com 8GB, reserve cerca de 1GB para o kernel, o daemon do Docker, o sshd, o journald e a sua própria shell de login. Sobram aproximadamente 7GB para distribuir, e a soma dos limites de todos os contentores deve ficar abaixo desse valor. A sobrealocação funciona até ao dia em que dois serviços atingem o pico em simultâneo.
Uma divisão funcional num servidor com 8GB:
- Reverse proxy: limite de 128m. É um processo pequeno, e um limite tão apertado deteta imediatamente uma recarga descontrolada da configuração.
- PostgreSQL: limite de 2g, com
shared_buffersdefinido para cerca de 512MB na configuração da base de dados. - Contentor da aplicação: limite de 1g.
- Worker em segundo plano: limite de 512m.
- Serviço de multimédia ou ficheiros: limite de 2g, sendo a maior parte desse valor usada pela cache de páginas.
Não copie estes valores para a sua própria stack. Execute os serviços sob carga real durante um dia, monitorize docker stats, registe o valor máximo de anon por contentor e acrescente aproximadamente metade desse valor como margem. Um limite demasiado apertado é pior do que não ter limite, porque termina um serviço saudável durante um pico normal de tráfego.
Há uma armadilha que merece uma nota própria. A maioria dos runtimes não conhece o limite, a menos que o configure. O PostgreSQL dimensiona shared_buffers e work_mem acima do limite do contentor sem problemas e acaba terminado. Uma JVM (Java virtual machine) precisa de -XX:MaxRAMPercentage=75 para dimensionar a heap a partir do limite do cgroup, em vez da RAM do host. O Node.js precisa de --max-old-space-size em megabytes, definido abaixo do limite do contentor, ou o garbage collector deixa a heap crescer até o kernel intervir. O Ollama segue o mesmo princípio, mas com outra opção, porque aumentar num_ctx faz crescer a cache KV em centenas de megabytes e o contentor termina a meio de um prompt longo. O cgroup não negoceia. Termina o processo.
Os limites de CPU funcionam de forma completamente diferente
cpus: "1.5" significa 150% de um core, aplicado como uma quota do CFS (completely fair scheduler). O contentor recebe 150ms de tempo de CPU em cada período de 100ms, partilhado por todas as suas threads. Quando esgota esse tempo, o kernel faz com que aguarde pelo período seguinte.
Esta é a diferença importante. Um contentor que ultrapassa o limite de memória é terminado. Um contentor que ultrapassa o limite de CPU é limitado e continua a funcionar, mais lentamente. Por isso, é seguro definir um limite de CPU agressivo, enquanto um limite de memória precisa de margem.
cpu_shares é uma ferramenta diferente: um peso relativo que só é relevante quando as CPUs estão efetivamente saturadas. Dois contentores com shares de 1024 e 512 dividem um core ocupado aproximadamente na proporção de dois para um. Num sistema sem carga, nenhum dos dois fica limitado. Use shares para estabelecer a prioridade dos serviços e use cpus quando precisar de um limite efetivo, por exemplo, para impedir que um trabalho noturno de transcodificação prive o seu servidor Web de recursos.
FAQ
O limite deploy.resources.limits funciona sem Docker Swarm?
Sim. O Compose V2 aplica deploy.resources.limits e deploy.resources.reservations quando executa docker compose up num único host. Confirme com docker inspect --format '{{.HostConfig.Memory}}' <container>, que mostra o limite em bytes e mostra 0 quando não foi aplicado nenhum limite. As chaves dentro de deploy que requerem efetivamente o Swarm são mode, placement, update_config e endpoint_mode.
O que significa o código de saída 137 no Docker Compose?
Significa que o processo principal recebeu SIGKILL, porque 137 é 128 mais o sinal 9. O OOM killer do kernel é a causa mais comum, mas um tempo limite de encerramento produz o mesmo código quando uma aplicação ignora SIGTERM. Execute docker inspect --format '{{.State.OOMKilled}} {{.State.ExitCode}}' <container> para distingui-los. true 137 indica uma eliminação por falta de memória, e false 137 não indica.
Devo definir mem_limit ou deploy.resources.limits.memory?
Ambos funcionam com docker compose. deploy.resources.limits.memory é a forma atual da Compose Specification e é a opção predefinida mais adequada para um ficheiro novo. Mantenha mem_limit se o restante ficheiro já usar as chaves de nível superior mais antigas. Definir ambos no mesmo serviço apenas torna o ficheiro mais difícil de ler. Escolha um e verifique o resultado com docker inspect.
Porque é que o meu contentor está a usar todo o limite de memória sem ser eliminado?
O valor de utilização em docker stats inclui a cache de páginas, que o kernel liberta sob pressão em vez de desencadear uma eliminação por falta de memória. Execute docker compose exec <service> grep -E '^(anon|file) ' /sys/fs/cgroup/memory.stat e leia o valor anon, que corresponde ao conjunto de trabalho que não pode ser recuperado. Um valor file elevado junto de um valor anon baixo indica que o contentor está a realizar entrada e saída de disco, não que está prestes a terminar.
Quanta RAM devo deixar não alocada numa VPS de 8GB?
Deixe cerca de 1GB para o kernel, o daemon do Docker, o sshd, o journald e a sua própria shell. Depois, mantenha a soma dos limites de todos os contentores abaixo dos 7GB restantes. Monitorize o valor máximo de anon de cada contentor sob carga real durante um dia antes de fixar os valores. Trate o total como um orçamento, não como um objetivo a preencher.