Pular para o conteúdo

Dockerfile em produção

Intermediário22 min de leituracontainers

O Dockerfile que aparece no primeiro tutorial funciona. Ele também produz uma imagem de 1,1 GB, roda como root, ignora SIGTERM e refaz o build inteiro quando você muda uma linha de código.

Cada uma dessas quatro coisas tem uma correção simples e um motivo que vale entender.

Cada instrução do Dockerfile gera uma camada. O build reaproveita uma camada se a instrução e todas as anteriores não mudaram. Uma instrução invalidada invalida tudo abaixo dela.

Isso torna a ordem a decisão de desempenho mais importante do arquivo.

# RUIM: qualquer mudança em qualquer arquivo refaz a instalação de dependências.
COPY . .
RUN npm ci
# BOM: a instalação só refaz quando os manifests mudam.
COPY package.json package-lock.json ./
RUN npm ci
COPY . .

A regra geral: o que muda pouco vem primeiro; o que muda a cada commit vem por último. Na prática isso significa quase sempre: manifests de dependência → instalação → código-fonte.

Um .dockerignore também não é opcional. Sem ele, node_modules e .git locais entram no contexto de build, deixando tudo mais lento e invalidando cache por mudanças irrelevantes:

.dockerignore
.git
node_modules
dist
*.log
.env*
Dockerfile
.dockerignore

A ferramenta de build não precisa existir na imagem final. Compilador, cabeçalhos de desenvolvimento, dependências de teste — nada disso serve em produção, e tudo isso aumenta a superfície de ataque.

FROM node:22-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci # inclui devDependencies, necessárias para o build
COPY . .
RUN npm run build
FROM node:22-bookworm-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev # só o que roda em produção
FROM gcr.io/distroless/nodejs22-debian12:nonroot
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
USER nonroot
CMD ["dist/server.js"]

Os estágios build e deps são descartados: só o que foi copiado explicitamente entra na imagem final. Repare que eles rodam em paralelo no BuildKit, porque não dependem um do outro.

O template completo, comentado, está em templates.

Base Tamanho típico Tem shell? Quando usar
debian:bookworm ~120 MB Sim Precisa de muitas bibliotecas de sistema
*-bookworm-slim ~30 MB Sim Padrão sensato para a maioria dos casos
alpine ~7 MB Sim Menor, mas usa musl em vez de glibc
distroless ~20 MB Não Produção, quando você quer superfície mínima
scratch 0 Não Binário estático (Go, Rust)

Sobre Alpine. Ela usa musl libc, não glibc. Isso quebra binários pré-compilados e, em Python, invalida as wheels prontas — o build passa a compilar tudo do zero, ficando muito mais lento. Há também casos documentados de diferenças de desempenho em alocação de memória. Alpine é ótima para Go e para ferramentas; pense duas vezes para Python e Node.

Sobre distroless. Sem shell, sem gerenciador de pacotes, sem ls. Isso é a vantagem: um atacante que consiga execução remota não tem ferramenta nenhuma. E é também a desvantagem: kubectl exec deixa de funcionar. A resposta é container efêmero, não voltar para uma base com shell.

O padrão é root. Isso significa que um escape de container te encontra como root no namespace do host.

RUN groupadd --system --gid 10001 app \
&& useradd --system --uid 10001 --gid app --no-create-home --shell /usr/sbin/nologin app
COPY --from=build --chown=app:app /app /app
USER app

Três detalhes que costumam passar:

  • UID numérico alto e fixo. Kubernetes com runAsNonRoot: true precisa saber que o UID não é 0; um nome de usuário só funciona se houver /etc/passwd, o que não existe em scratch.
  • --chown no COPY é mais eficiente que um RUN chown depois — este último duplica todos os arquivos numa nova camada.
  • Sistema de arquivos somente leitura. Com readOnlyRootFilesystem: true no Kubernetes, o container precisa de um emptyDir montado em /tmp, senão qualquer escrita temporária falha.

Este é o problema mais sutil e o que mais causa erro 502 durante rollout.

O processo definido no CMD roda como PID 1, e o PID 1 no Linux tem tratamento especial: sinais sem handler explícito são ignorados. Se a sua aplicação não trata SIGTERM, ela não morre — o Kubernetes espera o terminationGracePeriodSeconds inteiro e depois manda SIGKILL, matando as requisições em andamento.

A forma exec é obrigatória:

# RUIM: o shell vira PID 1 e não repassa os sinais ao filho.
CMD npm start
# BOM: o processo recebe os sinais diretamente.
CMD ["node", "dist/server.js"]

Note que CMD npm start também é ruim por outro motivo: o npm fica no meio e não repassa sinais para o Node.

E a aplicação precisa tratar o sinal — parar de aceitar conexões novas, terminar as em andamento, fechar o pool do banco e só então sair:

process.on('SIGTERM', async () => {
server.close(async () => { // para de aceitar, drena as conexões abertas
await pool.end();
process.exit(0);
});
});

Em ordem de impacto, medido:

  1. Multi-stage — tirar ferramentas de build. Costuma cortar 60% a 80%.

  2. Base menor — de node:22 para node:22-bookworm-slim já corta ~800 MB.

  3. Dependências de produção apenas — --omit=dev, --no-dev, --only=main.

  4. Limpar cache de pacote na mesma instrução — em camada separada não adianta, porque os arquivos continuam na camada anterior:

    RUN apt-get update \
    && apt-get install -y --no-install-recommends ca-certificates \
    && rm -rf /var/lib/apt/lists/*

O que não ajuda tanto quanto dizem: espremer dezenas de RUN em um só. Isso prejudica o cache e economiza pouco. Otimize o que pesa, não o que parece bonito.

Para ver onde o peso está:

Janela do terminal
docker history --no-trunc <imagem> | head -20

O HEALTHCHECK do Dockerfile é útil em Docker e em Compose. Sob Kubernetes ele é ignorado — quem manda são as probes do Pod. Não confie nele se o destino é um cluster.

FROM node:22 muda de conteúdo ao longo do tempo. Para builds que precisam ser auditáveis, fixe o digest:

FROM node:22-bookworm-slim@sha256:0000000000000000000000000000000000000000000000000000000000000000

Isso trava também as atualizações de segurança da base — então combine com uma ferramenta que abra pull request quando houver digest novo, como o Renovate. O ganho é que a atualização passa a ser uma mudança revisada e versionada, e não algo que acontece sozinho num build de sexta à noite.

  • .dockerignore existe e exclui .git, dependências locais e artefatos
  • Dependências instaladas antes de copiar o código
  • Multi-stage: nenhuma ferramenta de build na imagem final
  • USER com UID numérico não-zero
  • CMD na forma exec, e a aplicação trata SIGTERM
  • Nenhum segredo em ARG, ENV ou em camada intermediária
  • Imagem escaneada, e o resultado tratado — não só gerado