Docker em produção: os limites que ninguém configura
O container sobe igual em qualquer lugar — e é isso que dá a falsa sensação de que produção não exige configuração. Log sem limite, memória sem teto e reinício mal definido derrubam servidor.
Equipe EasyOps Cloud · · 7 min de leitura
Container tem uma qualidade perigosa: ele sobe igual na sua máquina e no servidor. Isso resolve o problema de ambiente e cria a impressão de que não há mais nada para configurar.
Há. E o que falta costuma aparecer entre trinta e noventa dias depois, quando o disco enche de log, um container consome toda a memória da máquina ou um serviço que morreu de madrugada nunca voltou porque a política de reinício estava no padrão.
São cinco ajustes. Todos cabem no arquivo que você já tem.
Log: o campeão de disco cheio
Por padrão, o Docker guarda a saída de cada container em um arquivo JSON sem
limite de tamanho. Uma aplicação verborrágica escreve gigabytes em semanas, e o docker
system prune não toca nisso.
# Quanto cada container já acumulou
sudo du -sh /var/lib/docker/containers/*/*-json.log | sort -rh | headA correção global, que vale para todo container novo:
{
"log-driver": "json-file",
"log-opts": {
"max-size": "50m",
"max-file": "3"
}
}sudo tee /etc/docker/daemon.json < /dev/null # edite o arquivo acima
sudo systemctl restart dockerAtenção a um detalhe que gera frustração: essa configuração não afeta containers já existentes. Eles precisam ser recriados, não apenas reiniciados.
No compose, por serviço:
services:
api:
image: minha-api:1.4
logging:
driver: json-file
options:
max-size: "50m"
max-file: "3"Memória e CPU: sem teto, um derruba todos
Sem limite, um container pode consumir toda a memória da máquina. Quando o kernel intervém, ele escolhe a vítima pelo consumo — e a vítima costuma ser o banco de dados, não o container que causou o problema.
services:
api:
deploy:
resources:
limits:
memory: 1g
cpus: "1.5"
reservations:
memory: 256mNa linha de comando:
docker run -m 1g --cpus 1.5 --memory-reservation 256m minha-apiO limite de memória é rígido: ao encostar, o processo dentro do container é encerrado. Isso é o comportamento desejado — falha contida em vez de máquina inteira comprometida, exatamente a lógica descrita no artigo sobre o OOM killer.
Vale reservar memória para o sistema. Somar os limites de todos os containers até esgotar a RAM da máquina deixa o hospedeiro sem margem para o próprio kernel, e o resultado é uma máquina que trava em vez de degradar.
Para saber o valor certo, meça antes de fixar:
docker stats --no-streamUm bom ponto de partida é o pico observado mais 30%.
Reinício: o padrão não reinicia nada
A política padrão é no. Container que morre fica morto, e não volta nem depois
de reiniciar a máquina.
restart: unless-stoppedAs opções, e quando cada uma faz sentido:
no— padrão. Útil só para tarefa pontual.on-failure— reinicia quando sai com erro. Bom para *job*.always— reinicia sempre, inclusive se você parou de propósito. Volta a subir no boot.unless-stopped— reinicia sempre, exceto se você parou explicitamente. Respeita a intenção do operador e é a escolha certa para serviço.
Repare que restart não substitui supervisão de verdade. Um container que sobe
mas não funciona — porta errada, dependência fora — continua "rodando" para o
Docker. Daí a necessidade da próxima seção.
Healthcheck: rodando não é o mesmo que funcionando
services:
api:
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 40sO start_period é o parâmetro mais esquecido e o que mais causa falso alarme: ele
dá à aplicação um tempo de inicialização durante o qual falhas não contam. Sem
ele, uma aplicação que leva 30 segundos para subir é marcada como doente logo de
saída.
Com healthcheck definido, as dependências passam a fazer sentido:
api:
depends_on:
db:
condition: service_healthySem a condição, depends_on apenas ordena a partida — o Postgres é iniciado
antes, mas a API tenta conectar enquanto ele ainda está subindo, e falha.
Rede: o container que fura o seu firewall
Já tratamos disso no artigo sobre firewall na VM e na nuvem, e vale repetir
porque é o erro de segurança mais comum com Docker: publicar uma porta com -p
5432:5432 escreve regras de iptables avaliadas antes do ufw. O banco fica
exposto à internet com o firewall jurando que está fechado.
A correção é publicar apenas no loopback sempre que o acesso externo não for necessário:
services:
db:
ports:
- "127.0.0.1:5432:5432"Melhor ainda: não publicar. Containers na mesma rede do compose se enxergam pelo nome do serviço, sem expor nada ao host:
# sem a seção ports — a api acessa via "db:5432"Imagem: o que roda como root
Muita imagem oficial roda como root por padrão. Combinado com volume montado do host, isso significa que uma falha na aplicação escreve no sistema de arquivos real.
services:
api:
user: "1000:1000"
read_only: true
tmpfs:
- /tmp
cap_drop:
- ALL
security_opt:
- no-new-privileges:trueO read_only costuma funcionar sem ajuste em aplicação bem comportada; quando
falhar, o erro aponta exatamente o diretório que precisa de escrita, e aí basta
declará-lo como volume.
Volume: onde o dado realmente mora
A confusão mais cara com Docker é sobre persistência. Container é descartável por desenho — tudo que ele escreve no próprio sistema de arquivos desaparece quando ele é recriado, e recriar acontece a cada atualização de imagem.
Existem dois jeitos de guardar dado fora dele, com propriedades diferentes:
services:
db:
volumes:
- dados-db:/var/lib/postgresql/data # volume nomeado
- ./config:/etc/postgresql:ro # bind mount
volumes:
dados-db:O volume nomeado é gerenciado pelo Docker, vive em /var/lib/docker/volumes e tem
desempenho melhor. É a escolha certa para dado de banco.
O bind mount aponta para um caminho do host. É o certo para configuração que
você quer versionar junto com o projeto, e o :ro no final impede que o container
altere o que deveria só ler.
O erro clássico é subir um banco em container sem volume nenhum. Funciona perfeitamente — até a primeira atualização de imagem, quando o dado some sem qualquer aviso, porque tecnicamente nada falhou.
Vale confirmar o que está persistido antes de precisar:
docker volume ls
docker inspect meu-container -f '{{json .Mounts}}' | python3 -m json.toolE lembre que volume não entra no backup do servidor automaticamente se a sua rotina copia apenas diretórios de aplicação. O dump lógico do banco continua sendo necessário, com a rotina de teste de restauração que vale para qualquer arranjo.
Atualizar sem derrubar
Trocar a versão de um container é pull e up, e nesse intervalo o serviço fica
fora. Para a maioria das aplicações internas, os poucos segundos são aceitáveis.
Quando não são, o caminho é subir a versão nova antes de tirar a antiga.
docker compose pull api
docker compose up -d --no-deps apiO --no-deps evita reiniciar o banco junto, que é o efeito colateral indesejado
mais comum de um up sem argumentos.
Antes de qualquer atualização que envolva migração de esquema, vale um snapshot — imagem nova com migração automática é justamente o cenário em que voltar atrás é difícil, porque o banco já mudou de forma.
Limpeza que não pode ser manual
Imagem antiga, container parado e cache de build crescem sem parar. Um timer semanal resolve:
# /etc/systemd/system/docker-prune.service
[Service]
Type=oneshot
ExecStart=/usr/bin/docker system prune -af --filter "until=168h"
ExecStart=/usr/bin/docker builder prune -af --filter "until=168h"# /etc/systemd/system/docker-prune.timer
[Timer]
OnCalendar=Sun 04:00
Persistent=true
[Install]
WantedBy=timers.targetRepare que não há --volumes em nenhuma das linhas. Volume é onde vive o dado do
banco, e prune de volume em produção é uma das formas mais rápidas de perder
dados. Se algum dia precisar, faça manualmente e depois de um snapshot.
Um compose de referência
services:
api:
image: minha-api:1.4
restart: unless-stopped
user: "1000:1000"
env_file: /etc/meuapp/env
ports:
- "127.0.0.1:3000:3000"
deploy:
resources:
limits: { memory: 1g, cpus: "1.5" }
logging:
driver: json-file
options: { max-size: "50m", max-file: "3" }
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
start_period: 40s
depends_on:
db:
condition: service_healthyFixar a versão da imagem — 1.4, não latest — é o detalhe final e um dos mais
importantes. Com latest, um docker compose pull de rotina pode trazer uma versão
maior com mudança incompatível, e o rollback fica difícil justamente porque
ninguém sabe qual versão estava rodando antes.
O mesmo raciocínio vale para a imagem base do seu Dockerfile. Uma linha FROM
node:22 continua trazendo correções dentro daquela linha, o que é desejável, mas
FROM node sem nada pula versões maiores sem aviso. E vale reconstruir
periodicamente mesmo sem mudar código: a imagem base recebe correção de
segurança do sistema operacional, e uma imagem construída há oito meses carrega
oito meses de falhas conhecidas — assunto que o calendário de fim de suporte
trata em detalhe, e que em container é especialmente fácil de esquecer, porque
nada avisa.
Nenhum desses ajustes é difícil. O que os torna raros é que todos protegem contra problemas que ainda não aconteceram — e a hora de configurá-los é justamente enquanto tudo está funcionando.