0%
Implantação em produção

Produção

O que separa um contêiner que funciona de um serviço operável.

10-15 min

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.

BaseTamanho indicativoConteúdoDiagnóstico
debian:12~120 MBDistribuição completaConfortável
python:3.12-slim~45 MBDebian reduzida, glibcShell presente
alpine:3.20~8 MBmusl em vez de glibcShell sh
distroless~20 MBSem shell nem gerenciador de pacotesNenhum exec
scratch0NadaNenhum

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 -slim ou distroless conforme a necessidade de diagnóstico, e referência por digest em vez de tag móvel.
  • Um healthcheck com um --start-period realista 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êiner unhealthy: é um sinal para o Compose e para o seu monitoramento.
  • Defina sempre --memory e --cpus. Sem um teto, um vazamento de memória em um contêiner derruba os vizinhos. O código de saída 137 significa “encerrado por exceder a memória”.
  • Configure a rotação de logs em /etc/docker/daemon.json, uma vez por todas. O padrão json-file sem rotação é a principal causa de disco cheio em um servidor Docker.
  • Verifique se o docker stop leva menos de um segundo. Caso contrário, sua aplicação não trata o SIGTERM — forma shell do CMD ou 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-privileges e 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

Link copiado!