Limites de memória no Docker Compose sem OOM
Evite que um container derrube seu VPS: configure deploy.resources ou mem_limit, entenda o exit 137, o swap e dimensione a RAM com segurança.
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 do Linux aplica ao cgroup (grupo de controle, o recurso do kernel que mede os recursos de um conjunto de processos) de um container. Defina deploy.resources.limits.memory em um serviço, e esse container nunca poderá usar mais do que o número informado. Quando tenta ultrapassar esse valor, o kernel encerra um processo dentro do container, e o container normalmente sai com o código 137.
Isso é mais importante em um VPS, onde a RAM é fixa e não há memória livre do host para usar. Um container com vazamento de memória ou uma consulta inadequada consumirá todas as páginas livres de um servidor com 8GB. O kernel então encerra o processo que considera mais problemático, geralmente um banco de dados ou sua sessão SSH, em vez do container que causou o problema. Os limites transformam uma indisponibilidade de todo o servidor em um único serviço que é 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 mostrar algo como 142MiB / 1GiB. Se a coluna de limite mostrar toda a RAM do host, a configuração não foi aplicada, e o restante deste guia não ajudará até que isso seja corrigido. Se o arquivo do Compose ainda for novidade para você, os conceitos básicos do Docker Compose para um VPS explicam a estrutura de arquivo usada aqui.
deploy.resources.limits ou mem_limit: qual se aplica
Existem duas formas de escrever a mesma ideia, por isso há 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 de arquivo Compose. deploy.resources veio do esquema do Swarm e agora faz parte da Compose Specification, o formato que docker compose lê atualmente.
Ambas funcionam em um único host. O Compose V2, o plugin docker compose, aplica deploy.resources.limits e deploy.resources.reservations quando você executa docker compose up, sem nenhum cluster do Swarm. As partes exclusivas do Swarm no bloco deploy são as outras chaves: mode, placement, update_config e endpoint_mode têm significado para docker stack deploy e são ignoradas por docker compose up. Portanto, a orientação comum de que "deploy precisa do Swarm" está errada para a subseção de recursos. Segui-la deixa seus serviços sem limite algum.
Escolha uma forma de escrita por projeto. Escrever mem_limit: 512m e deploy.resources.limits.memory: 1g no mesmo serviço gera um arquivo que ninguém consegue entender rapidamente. Em vez de tentar adivinhar qual valor foi aplicado, consulte o daemon:
docker inspect --format '{{.HostConfig.Memory}} {{.HostConfig.MemoryReservation}} {{.HostConfig.NanoCpus}}' app-1Os valores de memória são expressos em bytes, portanto 1g é exibido como 1073741824. A CPU é expressa em nano CPUs, portanto 1.5 é exibido como 1500000000. Um 0 em qualquer campo significa que nenhum limite foi definido. O menor limite de memória aceito pelo Docker é 6m, e abaixo desse valor o container não inicia.
O que acontece quando um container atinge o limite
O container não fica mais lento. Ele é encerrado.
Quando um processo solicita uma página e o cgroup já está em memory.max, o kernel primeiro recupera o que puder dentro desse cgroup: o cache de páginas limpo e, depois, as páginas que puder mover para swap. Se essa recuperação não liberar espaço suficiente, o OOM killer (out of memory) do cgroup escolhe um processo dentro do container e envia SIGKILL. Encerrar o PID 1 do container encerra o container. O código de saída 137 é simplesmente 128 mais o sinal 9. Portanto, 137 é a identificaçã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 encerramento por OOM. false 137 significa que algo diferente enviou SIGKILL. A causa usual é docker compose stop atingir seu período de tolerância de dez segundos porque o aplicativo ignorou SIGTERM. Essa distinção economiza horas, porque os dois problemas não têm relação.
Outros dois locais registram o evento. Monitore o daemon em tempo real:
docker events --filter event=oomDepois, leia o log do kernel, que é o registro que permanece após uma reinicialização:
sudo dmesg -T | grep -i -E 'memory cgroup out of memory|killed process'Um encerramento pelo cgroup imprime uma linha que começa com Memory cgroup out of memory: Killed process 24713 (node). Uma linha sem o prefixo Memory cgroup indica um OOM do host. Isso significa que a própria máquina ficou sem RAM. Esse é o problema que os limites devem evitar. Portanto, sua ocorrência indica que a soma dos limites está alta demais ou que alguns serviços não têm limite algum.
Com restart: unless-stopped, um loop de OOM pode passar despercebido, porque o serviço aparece como ativo em docker compose ps um segundo depois de ser encerrado. Verifique a coluna de tempo de atividade e a contagem de reinicializações. Combine o limite com um healthcheck que informe o aplicativo como não íntegro para que um container que continue sendo encerrado fique visível sem que você precise monitorá-lo continuamente.
A reserva é uma indicação; o limite é a regra
reservations.memory (o mem_reservation mais antigo) é um limite mínimo flexível. O Docker o descreve como um limite flexível que é ativado quando o daemon detecta contenção ou pouca memória no host. Ele nunca impede que um container ultrapasse esse valor e nunca garante que a memória estará livre quando o container precisar dela. Ele apenas orienta o kernel a recuperar primeiro a memória dos containers que estão acima da reserva.
Portanto, uma reserva não protege nada por si só. Use-a para marcar um serviço que deve receber tratamento preferencial sob pressão e confie no limite para garantir a segurança. Mantenha a reserva abaixo do limite; caso contrário, o container não será iniciado: o Docker rejeita a configuração com Minimum memory limit can not be less than memory reservation limit.
Contabilização de swap, sem ilusões
A maioria das imagens de VPS é distribuída sem nenhum arquivo de swap. Execute swapon --show e free -h. Se o total de swap for zero, todas as configurações relacionadas a swap abaixo não terão efeito, e o limite de memória será 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 container recebe 1GB de RAM e 1GB de swap. Definir os dois valores como iguais não fornece swap ao container. Definir mem_limit e deixar memswap_limit sem configuração permite que o container use swap novamente até o tamanho do seu limite de memória.
Ubuntu 24.04 e Debian 13 usam cgroup v2 por padrão. Nesse modo, o swap é um contador separado (memory.swap.max), e isso funciona sem configuração adicional. A mensagem antiga Your kernel does not support swap limit capabilities vem de hosts com cgroup v1 inicializados sem swapaccount=1. Nesses hosts, o limite de memória ainda se aplica, mas a parte referente a swap é ignorada.
Seja preciso sobre o que o swap oferece. Ele torna um encerramento por OOM mais lento, não menos provável, porque um processo com vazamento preenche o swap com a mesma facilidade com que preenche a RAM. Enquanto isso, um container usando swap intensivamente em armazenamento compartilhado de uma VPS reduz o desempenho de todos os outros serviços no host. Para qualquer serviço sensível à latência, um limite correto sem swap falha mais rapidamente e de forma mais previsível.
Por que o uso de memória parece pior do que realmente é
O valor MEM USAGE em docker stats inclui o cache de páginas. Por isso, um contêiner que lê arquivos grandes se aproxima do limite e permanece nesse nível. Isso é normal e não indica um vazamento, porque o cache limpo é recuperado antes que o OOM killer seja acionado. Um serviço como um servidor de mídia Jellyfin auto-hospedado parecerá permanentemente próximo do limite exatamente por esse motivo.
Separe o valor entre o cache e o conjunto de trabalho real de dentro do contêiner:
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 descartado. file é o cache de páginas, que pode ser descartado. Dimensione o limite com base em anon mais uma margem, não no total. O arquivo memory.events elimina a dúvida: um contador oom_kill acima de zero significa que o kernel encerrou algum processo neste contêiner desde que ele foi iniciado, e um contador max em crescimento significa que o contêiner está sendo mantido no limite neste momento. Ambos os comandos precisam de um shell e de coreutils dentro da imagem. Por isso, falham em uma imagem distroless ou scratch.
Limites de dimensionamento em um VPS de 8GB
Comece pelo host, não pelos aplicativos. Em um VPS de 8GB, reserve cerca de 1GB para o kernel, o daemon do Docker, o sshd, o journald e seu próprio shell de login. Isso deixa aproximadamente 7GB para distribuir, e a soma dos limites de todos os containers deve permanecer abaixo desse valor. O overcommit funciona até o dia em que dois serviços atingem o pico ao mesmo tempo.
Uma divisão viável em um host de 8GB:
- Proxy reverso: limite de 128m. É um processo pequeno, e um limite tão restrito detecta imediatamente um recarregamento descontrolado da configuração.
- PostgreSQL: limite de 2g, com
shared_buffersdefinido como aproximadamente 512MB na configuração do banco de dados. - Container da aplicação: limite de 1g.
- Worker em segundo plano: limite de 512m.
- Serviço de mídia ou arquivos: limite de 2g, usado principalmente para page cache.
Não copie esses números para sua própria stack. Execute os serviços sob carga real durante um dia, monitore docker stats, obtenha o valor de pico de anon por container e acrescente aproximadamente metade desse valor como margem. Um limite muito restrito é pior do que nenhum limite, porque encerra um serviço saudável durante um pico normal de tráfego.
Uma armadilha merece uma observação própria. O limite fica invisível para a maioria dos runtimes, a menos que você os informe sobre ele. O PostgreSQL dimensionará shared_buffers e work_mem acima do limite do container e será encerrado. Uma JVM (máquina virtual Java) precisa de -XX:MaxRAMPercentage=75 para dimensionar o heap com base no limite do cgroup, e não na RAM do host. O Node.js precisa de --max-old-space-size em megabytes, definido abaixo do limite do container, ou o coletor de lixo permitirá que o heap cresça até a intervenção do kernel. O cgroup não negocia. Ele encerra o processo.
Os limites de CPU funcionam de forma completamente diferente
cpus: "1.5" significa 150% de um núcleo, aplicado como uma cota do CFS (completely fair scheduler). O contêiner recebe 150ms de tempo de CPU em cada período de 100ms, compartilhado entre todas as suas threads. Quando esgota esse tempo, o kernel o faz aguardar o próximo período.
Esse é o contraste importante. Um contêiner que ultrapassa o limite de memória é encerrado. Um contêiner que ultrapassa o limite de CPU sofre throttling e continua funcionando, mais lentamente. Por isso, é seguro definir um limite de CPU de forma agressiva, enquanto um limite de memória precisa de margem.
cpu_shares é uma ferramenta diferente: um peso relativo que só importa quando as CPUs estão realmente saturadas. Dois contêineres com shares de 1024 e 512 dividem um núcleo ocupado aproximadamente na proporção de dois para um; em um host ocioso, nenhum dos dois é restringido. Use shares para classificar os serviços por importância e use cpus quando precisar de um teto real, por exemplo, para impedir que um job noturno de transcodificação consuma os recursos do seu servidor web.
FAQ
deploy.resources.limits funciona sem Docker Swarm?
Sim. O Compose V2 aplica deploy.resources.limits e deploy.resources.reservations quando você executa docker compose up em um único host. Confirme com docker inspect --format '{{.HostConfig.Memory}}' <container>, que exibe o limite em bytes e exibe 0 quando nenhum limite foi aplicado. As chaves dentro de deploy que realmente exigem o Swarm são mode, placement, update_config e endpoint_mode.
O que significa o código de saída 137 no Docker Compose?
Isso 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 desligamento produz o mesmo código quando um aplicativo ignora SIGTERM. Execute docker inspect --format '{{.State.OOMKilled}} {{.State.ExitCode}}' <container> para diferenciá-los. true 137 indica uma finalização por falta de memória, e false 137 não indica.
Devo definir mem_limit ou deploy.resources.limits.memory?
Qualquer um funciona com docker compose. deploy.resources.limits.memory é o formato atual da Compose Specification e é a melhor opção padrão para um arquivo novo. Mantenha mem_limit se o restante do arquivo já usar as chaves de nível superior mais antigas. Definir ambos no mesmo serviço apenas dificulta a leitura do arquivo. Escolha um e verifique o resultado com docker inspect.
Por que meu contêiner está usando todo o limite de memória sem ser finalizado?
O valor de uso em docker stats inclui o cache de páginas, que o kernel descarta sob pressão em vez de disparar uma finalizaçã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 representa o conjunto de trabalho que não pode ser recuperado. Um valor alto de file ao lado de um valor baixo de anon indica que o contêiner está realizando entrada e saída de disco, não que está prestes a ser finalizado.
Quanta RAM devo deixar sem alocar em uma VPS de 8GB?
Deixe cerca de 1GB para o kernel, o daemon do Docker, o sshd, o journald e seu próprio shell. Depois, mantenha a soma dos limites de todos os contêineres abaixo dos 7GB restantes. Monitore o valor máximo de anon de cada contêiner sob carga real durante um dia antes de definir os valores. Trate o total como um orçamento, não como uma meta a ser preenchida.