← Voltar para o Blog

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 | head

A 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 docker

Atençã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: 256m

Na linha de comando:

docker run -m 1g --cpus 1.5 --memory-reservation 256m minha-api

O 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-stream

Um 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-stopped

As 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: 40s

O 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_healthy

Sem 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:true

O 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.tool

E 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 api

O --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.target

Repare que não--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_healthy

Fixar 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.

Suba um servidor em minutos

Preço em real, suporte em português e dados no Brasil.

Criar conta