Produção
O que separa um contêiner que funciona de um serviço operável.
Implantação em produção
Uma imagem que sobe corretamente na sua máquina não é uma imagem pronta para produção, e a distância entre as duas não se fecha com mais um comando. Ela se fecha com uma série de decisões que nada o obriga a tomar — e é exatamente por isso que tantas vezes são esquecidas.
Os incidentes decorrentes têm uma assinatura reconhecível, porque se repetem em toda parte. Um servidor cujo disco chega a 100 % em três semanas: ninguém configurou a rotação de logs, e o Docker os mantém sem limite por padrão. Um contêiner com vazamento de memória que acaba derrubando o banco de dados vizinho, por falta de um teto. Uma implantação que interrompe o serviço por quarenta segundos porque o processo nunca recebe o sinal de encerramento. Um tráfego redirecionado para um contêiner “iniciado” que ainda não aceita conexões, porque nada distinguia “lançado” de “pronto”. E, de forma mais discreta, uma imagem que, sem que ninguém saiba, carrega uma biblioteca vulnerável há oito meses.
Nenhum desses problemas é difícil. Todos são invisíveis em desenvolvimento, onde o serviço roda dez minutos, a memória é abundante e uma interrupção não incomoda ninguém. Eles só aparecem com o tempo e a carga, ou seja, no pior momento.
Esta última lição percorre essas decisões uma a uma. O objetivo não é transformar um servidor em cluster: o Compose em um único host é uma arquitetura perfeitamente legítima para muitos serviços. O objetivo é que esse servidor aguente seis meses sem intervenção de emergência.
Uma imagem de produção: mínima e fixada
Uma imagem menor é transferida mais rápido, inicia mais rápido e oferece menos código explorável; uma imagem fixada é reconstruída de forma idêntica daqui a seis meses. O multi-stage da lição 3 faz a maior parte do trabalho; resta escolher a base final.
| Base | Tamanho indicativo | Conteúdo | Diagnóstico |
|---|---|---|---|
debian:12 | ~120 MB | Distribuição completa | Confortável |
python:3.12-slim | ~45 MB | Debian reduzida, glibc | Shell presente |
alpine:3.20 | ~8 MB | musl em vez de glibc | Shell sh |
distroless | ~20 MB | Sem shell nem gerenciador de pacotes | Nenhum exec |
scratch | 0 | Nada | Nenhum |
A escolha não é “o menor vence”. O Alpine impõe o musl, que quebra certos binários pré-compilados e altera o comportamento da resolução de DNS; as variantes -slim costumam oferecer a melhor relação entre tamanho e tranquilidade. Distroless e scratch dão a menor superfície de ataque ao custo do diagnóstico: sem shell, o docker exec deixa de servir para qualquer coisa, e é preciso contar inteiramente com os logs e as métricas.
Quanto à fixação de versão, a tag não basta em produção: uma tag é móvel, um digest não é.
# Uma tag pode apontar para um conteudo diferente amanha
FROM node:22-slim
# Um digest designa um conteudo exato, para sempre
FROM node:22-slim@sha256:d3e2b17e0f5a9b1d9c8f6e5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c
O digest a ser fixado é obtido com docker inspect node:22-slim --format '{{index .RepoDigests 0}}'.
Por fim, se seus servidores e suas máquinas de trabalho não têm a mesma arquitetura — caso comum desde os Macs Apple Silicon — construa imagens multiarquitetura com docker buildx build --platform linux/amd64,linux/arm64 --push. O --push não é opcional: uma imagem assim é um índice que referencia vários manifestos, estrutura que o cache local clássico não sabe armazenar.
Healthchecks: distinguir “iniciado” de “pronto”
Sem healthcheck, o Docker conhece um único estado útil: o processo está rodando ou não está. Mas um processo que está rodando pode estar travado em um lock, ter perdido a conexão com o banco ou não ter terminado de carregar seu cache.
# --interval : frequencia das verificacoes em regime normal
# --timeout : alem disso, a verificacao conta como falha
# --start-period : periodo de carencia na inicializacao, falhas nao contadas
# --retries : falhas consecutivas antes do status "unhealthy"
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
A escolha do comando de teste importa. curl -f é o reflexo habitual, mas pressupõe que o curl esteja instalado — o que não é o caso em uma imagem mínima, e instalá-lo apenas para o healthcheck aumenta a superfície de ataque. Usar o ambiente de execução já presente, como acima com o Node, evita esse compromisso.
O --start-period é o parâmetro mais frequentemente negligenciado e o que mais provoca falsos positivos. Uma aplicação Java, ou uma aplicação que aplica migrações na inicialização, pode levar trinta segundos para responder: sem período de carência, ela é declarada unhealthy, reiniciada e nunca consegue iniciar. Observa-se um loop de reinicialização sem causa aparente.
No lado da aplicação, o endpoint deve verificar algo real. Uma rota que responde 200 sem testar nada não detecta nenhuma falha.
app.get('/health', async (req, res) => {
try {
await db.query('SELECT 1'); // o banco responde de verdade?
await cache.ping(); // e o cache?
res.status(200).json({ status: 'ok' });
} catch (err) {
res.status(503).json({ status: 'degraded', error: err.message });
}
});
docker ps # o status de saude aparece na coluna STATUS
# Historico das verificacoes, com a saida de cada tentativa:
# indispensavel para entender POR QUE um servico esta unhealthy
docker inspect --format '{{json .State.Health}}' mon-api
Uma observação honesta: o Docker sozinho não faz nada com um contêiner unhealthy, não o reinicia. O status serve ao Compose (condition: service_healthy), aos orquestradores e ao seu monitoramento — que é quem deve emitir o alerta.
Limites de recursos: impedir que um contêiner derrube os outros
Sem limite, um contêiner pode consumir toda a memória e toda a CPU do host. O kernel acaba acionando o OOM killer, e nada garante que ele escolherá o culpado: frequentemente é o processo mais volumoso que cai, ou seja, o seu banco de dados.
docker run -d \
--memory 512m \ # teto de memoria estrito
--memory-swap 512m \ # igual a --memory: proibe qualquer swap
--memory-reservation 256m \ # limite flexivel, aplicado sob pressao
--cpus 1.5 \ # 1,5 nucleo no maximo
--pids-limit 200 \ # numero maximo de processos (anti fork bomb)
--restart unless-stopped \
mon-api:1.2.3
O ajuste de --memory-swap merece uma explicação. Se não for especificado, o Docker autoriza um volume de swap igual ao limite de memória: um processo com vazamento então deixa a máquina enormemente lenta em vez de ser encerrado. Definir os dois com o mesmo valor proíbe o swap e faz o contêiner falhar de forma franca — quase sempre preferível, pois uma falha clara é detectada, enquanto uma lentidão difusa não é.
No Compose, esses limites passam por deploy.resources, respeitado pelo Compose v2 mesmo fora de um cluster:
services:
api:
image: registre.exemple.com/equipe/mon-api:1.2.3
deploy:
resources:
limits:
cpus: "1.5"
memory: 512M
reservations:
memory: 256M
pids_limit: 200
restart: unless-stopped
Como escolher os valores? Observe o consumo sob carga normal com docker stats e depois some 50 a 100 % de margem. E tenha em mente a assinatura de um teto baixo demais: o código de saída 137, ou seja, 128 + 9, um SIGKILL enviado pelo kernel.
docker stats --no-stream --format 'table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}'
docker inspect mon-api --format '{{.State.ExitCode}} {{.State.OOMKilled}}'
Logs: a primeira causa de disco cheio
O driver padrão, json-file, não aplica nenhuma rotação. Uma aplicação verbosa escreve indefinidamente em /var/lib/docker/containers/<id>/<id>-json.log. Em um servidor que roda há seis meses, esse único arquivo pode chegar a várias dezenas de gigabytes e encher a partição — o que derruba todos os outros serviços da máquina.
A correção pode ser feita por contêiner (--log-opt max-size=10m --log-opt max-file=3, ou seja, no máximo três arquivos de 10 MB), mas a boa prática é defini-la uma vez por todas no nível do daemon, para que o padrão seja saudável para tudo o que for lançado na máquina.
// /etc/docker/daemon.json
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
},
"live-restore": true
}
Um sudo systemctl reload docker aplica o arquivo, e docker info --format '{{.LoggingDriver}}' confirma que foi levado em conta.
O live-restore merece ser conhecido: ele permite que os contêineres continuem rodando durante uma reinicialização do daemon, o que torna as atualizações do Docker muito menos angustiantes. Atenção, porém: a configuração do daemon só se aplica aos contêineres criados depois do recarregamento; os já existentes mantêm a sua.
Os demais drivers atendem a necessidades de centralização — local (mais compacto), journald, syslog, fluentd, gelf, awslogs. Um ponto a conhecer antes de migrar: com a maioria deles, o docker logs deixa de funcionar, pois o engine não guarda mais nada localmente. Somente json-file, local e journald o permitem.
Duas regras no lado da aplicação, que condicionam a utilidade de todo o resto. Escreva na saída padrão, nunca em um arquivo interno ao contêiner. E escreva em JSON estruturado em vez de texto livre: com cinquenta contêineres, a diferença entre logs utilizáveis e uma parede de texto se decide aí.
Encerramento limpo: o detalhe que causa as interrupções de serviço
O docker stop envia SIGTERM ao processo PID 1, espera dez segundos e então envia SIGKILL. Se sua aplicação não trata o SIGTERM, cada implantação corta bruscamente as requisições em andamento. Três causas possíveis, três correções.
Primeira causa: a forma shell do CMD. O PID 1 passa a ser o /bin/sh, que não repassa os sinais.
# PID 1 = sh. Sua aplicacao NUNCA recebe SIGTERM.
CMD node server.js
# PID 1 = node. O sinal chega.
CMD ["node", "server.js"]
Segunda causa: a aplicação não trata o sinal.
process.on('SIGTERM', () => {
server.close(async () => { // nao aceita mais novas conexoes
await db.end(); // fechamento limpo do pool
process.exit(0);
});
setTimeout(() => process.exit(1), 25_000); // rede de seguranca
});
Terceira causa: os processos zumbis. Um verdadeiro PID 1 deve coletar os processos órfãos, o que a maioria das aplicações não faz. A opção --init insere um gerenciador mínimo (tini) que cuida disso e repassa os sinais.
services:
api:
image: registre.exemple.com/equipe/mon-api:1.2.3
init: true
# Prazo de carencia: deve exceder a duracao do seu encerramento limpo
stop_grace_period: 30s
# Cronometrar a parada. Proximo de 10 s (ou de stop_grace_period) =>
# o sinal nao e tratado. Menos de um segundo => esta ok.
time docker stop mon-api
Registry privado: enviar e obter suas imagens
Em produção, não se faz build no servidor: obtém-se uma imagem já construída e testada. Isso pressupõe um registry.
# --password-stdin evita que o segredo apareca no
# historico do shell ou na saida de "ps".
echo "$REGISTRY_TOKEN" | docker login registre.exemple.com \
-u ci-bot --password-stdin
docker tag mon-api:1.2.3 registre.exemple.com/equipe/mon-api:1.2.3
docker push registre.exemple.com/equipe/mon-api:1.2.3
docker logout registre.exemple.com
Uma convenção de etiquetagem a adotar desde o início: duas tags por build, uma legível e outra rastreável.
DEPOT="registre.exemple.com/equipe/mon-api"
docker build -t "$DEPOT:1.2.3" -t "$DEPOT:$(git rev-parse --short HEAD)" .
docker push --all-tags "$DEPOT"
A tag de commit responde à única pergunta que importa às 3 da manhã: qual código exato está rodando em produção? No lado do servidor, a implantação se resume então a três comandos.
docker compose pull # obter a nova imagem
docker compose up -d # recriar os servicos cuja imagem mudou
docker compose ps
# Mexer em apenas um servico, sem reiniciar suas dependencias
docker compose up -d --no-deps api
Sejamos claros quanto aos limites: essa operação recria o contêiner e, portanto, interrompe brevemente o serviço. Uma implantação sem interrupção exige duas instâncias atrás de um balanceador de carga, o que foge do escopo do Compose sozinho. Para a maioria dos serviços, uma interrupção de um a dois segundos é aceitável; quando não é, esse é o sinal de que um orquestrador se tornou necessário.
Analisar vulnerabilidades e reforçar a execução
Uma imagem carrega centenas de pacotes dos quais você escolheu apenas um punhado. Vulnerabilidades são publicadas continuamente, inclusive em uma imagem que você não modificou há seis meses — daí a necessidade de reconstruir regularmente, e não apenas quando o código muda.
# Resumo: numero de vulnerabilidades por nivel de gravidade
docker scout quickview mon-api:1.2.3
# Detalhe, filtrado nos niveis que exigem acao
docker scout cves --only-severity critical,high mon-api:1.2.3
# Sugestoes de atualizacao da imagem base
docker scout recommendations mon-api:1.2.3
O Trivy, ferramenta open source muito difundida, é usado em contêiner e se adapta bem à integração contínua. Um aviso sobre este comando: montar /var/run/docker.sock dá ao contêiner o controle total do daemon, e portanto da máquina. É aceitável para uma ferramenta escolhida, em um agente de build dedicado; nunca é banal.
# --exit-code 1 faz o comando falhar se restarem vulnerabilidades
# do nivel solicitado: e assim que se bloqueia um pipeline de entrega.
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy:latest image \
--severity HIGH,CRITICAL --exit-code 1 \
mon-api:1.2.3
O reforço da execução complementa utilmente a análise: essas opções reduzem o que um atacante pode fazer mesmo em caso de falha na aplicação.
docker run -d \
--read-only \ # arquivos somente leitura
--tmpfs /tmp:rw,noexec,nosuid,size=64m \ # exceto /tmp, em memoria
--cap-drop ALL \ # remover todas as capabilities
--cap-add NET_BIND_SERVICE \ # depois devolver a unica util
--security-opt no-new-privileges \ # sem elevacao via setuid
--user 1001:1001 \ # nunca root
mon-api:1.2.3
O --read-only é particularmente eficaz: ele impede que um atacante grave um binário dentro do contêiner. Basta identificar os poucos caminhos nos quais a aplicação precisa escrever e montá-los separadamente.
Uma stack de produção completa
Vamos reunir tudo em um arquivo que complementa o compose.yaml de desenvolvimento.
# compose.prod.yaml
# Uso : docker compose -f compose.yaml -f compose.prod.yaml up -d --wait
services:
api:
# Imagem do registry, fixada: nenhum build no servidor
image: registre.exemple.com/equipe/mon-api:${VERSION:?VERSION est obligatoire}
build: !reset null # neutraliza o build herdado do arquivo base
restart: unless-stopped
init: true
stop_grace_period: 30s
read_only: true
tmpfs:
- /tmp:size=64m
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
user: "1001:1001"
environment:
NODE_ENV: production
DATABASE_URL: postgres://app:${DB_PASSWORD:?}@db:5432/appdb
ports:
- "127.0.0.1:3000:3000" # um proxy na frente fará a exposição pública
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 30s
timeout: 3s
retries: 3
start_period: 20s
deploy:
resources:
limits:
cpus: "1.5"
memory: 512M
reservations:
memory: 256M
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
- frontend
- backend
db:
image: postgres:16-alpine
restart: unless-stopped
stop_grace_period: 1m # deixar o Postgres concluir seu checkpoint
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: ${DB_PASSWORD:?}
POSTGRES_DB: appdb
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
deploy:
resources:
limits:
memory: 1G
networks:
- backend # nunca acessível a partir do exterior
networks:
frontend:
backend:
internal: true
volumes:
db-data:
# Sempre verificar a mesclagem antes de fazer o deploy
docker compose -f compose.yaml -f compose.prod.yaml config
VERSION=1.2.3 docker compose -f compose.yaml -f compose.prod.yaml up -d --wait
Observe o ${VERSION:?...}: um deploy sem versão explícita falha imediatamente, em vez de seguir silenciosamente com latest. E restart: unless-stopped em vez de always: a diferença é que um contêiner parado deliberadamente não reinicia após um reboot do servidor, o que evita reativar um serviço que foi tirado do ar de propósito.
O que monitorar depois
O deploy não é o fim. Primeiro, o espaço em disco, pois é a falha mais frequente. Depois, o estado de saúde e o contador de reinicializações, porque um valor que cresce sinaliza um problema que talvez nenhum alerta venha a reportar. Por fim, o consumo em relação aos limites definidos, para detectar uma deriva antes do incidente.
docker system df ; df -h /var/lib/docker
docker ps --format 'table {{.Names}}\t{{.Status}}'
docker inspect mon-api --format '{{.RestartCount}}'
Resta a atualização das imagens: uma imagem que não é reconstruída há meses acumula as vulnerabilidades de suas dependências, mesmo que seu código não tenha mudado. Reconstruir e refazer o deploy periodicamente, sem alteração funcional, é uma prática de manutenção, não perda de tempo.
A limpeza automatizada, por fim, exige cautela. docker system prune -af em um servidor remove as imagens não utilizadas, incluindo a versão anterior para a qual você gostaria de voltar em caso de problema. Prefira um comando direcionado como docker image prune -a --filter "until=720h" --force, que afeta apenas as imagens sem uso há mais de trinta dias.
Pontos principais
- Uma imagem de produção é mínima e fixada: multi-stage, base
-slimou distroless conforme a necessidade de diagnóstico, e referência por digest em vez de tag móvel. - Um healthcheck com um
--start-periodrealista distingue “iniciado” de “pronto”. O endpoint deve testar o banco de dados e o cache, caso contrário não detecta nada. O Docker não reinicia um contêinerunhealthy: é um sinal para o Compose e para o seu monitoramento. - Defina sempre
--memorye--cpus. Sem um teto, um vazamento de memória em um contêiner derruba os vizinhos. O código de saída137significa “encerrado por exceder a memória”. - Configure a rotação de logs em
/etc/docker/daemon.json, uma vez por todas. O padrãojson-filesem rotação é a principal causa de disco cheio em um servidor Docker. - Verifique se o
docker stopleva menos de um segundo. Caso contrário, sua aplicação não trata oSIGTERM— forma shell doCMDou ausência de handler de sinal — e cada deploy interrompe as requisições em andamento. - Não faça build no servidor de produção: envie uma imagem etiquetada tanto pela versão quanto pelo commit e depois execute
docker compose pull && docker compose up -d. - Analise suas imagens (
docker scout cves, Trivy) e reconstrua-as periodicamente, mesmo sem mudança de código. Reforce a segurança da execução com--read-only,--cap-drop ALL,no-new-privilegese um usuário não root.
Para ir além: quando um único host não basta mais — alta disponibilidade, deploy progressivo sem interrupção, escalonamento automático — entramos no domínio dos orquestradores. Tudo o que você aprendeu aqui continua válido: o Kubernetes consome as mesmas imagens OCI e espera exatamente os mesmos healthchecks, limites de recursos e logs na saída padrão. A referência completa das opções continua sendo docs.docker.com.
Comentários
Os comentários são alimentados pelo GitHub Discussions
Conecte-se com o GitHub para participar da discussão