Dockerfile na Prática: Imagens Enxutas, Rápidas e Seguras
Multi-stage build, ordem de camadas para cache, imagem base mínima, usuário não-root e .dockerignore: o que separa uma imagem amadora de uma de produção.
Neste artigo
Uma imagem de container mal construída não avisa que está errada. Ela funciona. Sobe, responde requisições, passa nos testes. O custo aparece depois, disperso: builds que demoram cinco minutos porque o cache nunca acerta, imagens de oitocentos megabytes que lotam o registry e demoram para puxar em cada deploy, superfícies de ataque enormes carregando compiladores e ferramentas que produção nunca usa, e o container rodando como root porque ninguém pensou nisso. Nenhum desses problemas quebra a demo. Todos cobram caro em escala.
O Dockerfile é engano de fácil: parece um script de shell e é tratado como tal. Mas ele descreve um artefato imutável que será construído, versionado e distribuído centenas de vezes. As decisões que você toma nele se multiplicam por cada build e cada pull. Este guia percorre as práticas que separam uma imagem de produção de uma que só passou no primeiro teste.
Cada camada conta: como o cache realmente funciona#
Toda instrução no Dockerfile que altera o filesystem gera uma camada. O Docker mantém um cache dessas camadas e, no build seguinte, reaproveita tudo até a primeira instrução cuja entrada mudou. A partir dali, invalida todo o resto e reconstrói. Esse comportamento é a chave de builds rápidos, e ignorá-lo é a causa número um de builds lentos.
O erro clássico é este:
``dockerfile FROM node:20 WORKDIR /app COPY . . RUN npm install RUN npm run build ``
O problema está em COPY . . antes do npm install. Qualquer alteração em qualquer arquivo do projeto, inclusive um comentário no código, muda a camada do COPY e invalida o npm install que vem depois. Resultado: você reinstala todas as dependências a cada mudança de uma linha, mesmo que o package.json não tenha mudado.
A correção é copiar primeiro apenas o que define as dependências, instalar, e só então copiar o resto do código:
``dockerfile FROM node:20 WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci COPY . . RUN npm run build ``
Agora a camada pesada, a instalação de dependências, só é refeita quando os arquivos de manifesto realmente mudam. Alterar o código-fonte reaproveita as dependências em cache e o build cai de minutos para segundos. O princípio é geral e vale para qualquer linguagem: ordene as instruções da que muda menos para a que muda mais. Dependências mudam raramente, código muda a toda hora. Coloque o estável embaixo e o volátil em cima.
Repare também no npm ci no lugar de npm install. O ci instala exatamente o que está no lockfile, de forma reprodutível e mais rápida, sem atualizar o lockfile no processo. Builds de imagem devem ser determinísticos, e comandos que respeitam o lockfile são parte disso.
Multi-stage build: o divisor de águas do tamanho#
A técnica que mais reduz o tamanho de uma imagem é o multi-stage build. A ideia é separar o ambiente de construção do ambiente de execução em estágios distintos, dentro do mesmo Dockerfile, e copiar para a imagem final apenas o artefato pronto.
Um binário Go ilustra o extremo do ganho:
```dockerfile # Estágio de build: tem o compilador e as dependências FROM golang:1.22 AS build WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 go build -o /app/server ./cmd/server
# Estágio final: só o binário, nada mais FROM gcr.io/distroless/static-debian12 COPY --from=build /app/server /server USER nonroot:nonroot ENTRYPOINT ["/server"] ```
O estágio de build carrega o toolchain completo do Go, que pesa centenas de megabytes. Mas nada disso vai para a imagem final. O COPY --from=build puxa apenas o binário compilado para uma imagem base mínima. O resultado é uma imagem de poucos megabytes contendo o binário e praticamente nada além dele. Sem compilador, sem shell, sem gerenciador de pacotes: nada que um atacante possa usar se comprometer o processo.
Para linguagens interpretadas o ganho é menor mas ainda relevante: você constrói e transpila num estágio com todas as ferramentas de desenvolvimento e copia apenas o resultado e as dependências de produção para o estágio final, deixando para trás devDependencies, caches e artefatos intermediários.
A imagem base define seu piso#
A escolha da imagem base determina o tamanho mínimo e a superfície de ataque inicial da sua imagem. Aqui há um espectro claro.
No topo do peso estão as bases completas como ubuntu ou debian, com um sistema quase inteiro dentro. São confortáveis para desenvolver e desnecessariamente grandes para rodar. No meio estão as variantes slim, que removem documentação e pacotes supérfluos. Mais enxutas estão as bases sobre Alpine Linux, que usam a musl libc e ocupam poucos megabytes, com a ressalva de que a musl às vezes causa incompatibilidades sutis com software que espera a glibc. No extremo mínimo estão as imagens distroless, que contêm apenas a runtime da linguagem e as bibliotecas estritamente necessárias, sem shell e sem gerenciador de pacotes.
Não existe escolha universalmente certa; existe a escolha certa para o caso. Uma imagem distroless é excelente para um binário estático em produção e péssima quando você precisa entrar no container para depurar, justamente porque não tem shell. Uma boa regra é usar bases confortáveis nos estágios de build e bases mínimas no estágio final. Você paga o conforto onde ele não custa e colhe a economia onde ela importa.
Um detalhe que se paga sozinho: fixe a versão da base com uma tag específica ou, melhor ainda, com o digest. FROM node:20.11.1 é reprodutível; FROM node:latest é uma bomba-relógio que muda embaixo de você sem aviso e transforma builds idênticos em resultados diferentes.
Rodar como root é dívida de segurança#
Por padrão, o processo dentro do container roda como root. E, embora seja o root de um user namespace e não o root do host, tratá-lo como inofensivo é ingenuidade. Se uma vulnerabilidade permitir escapar do container, ou se houver má configuração no runtime, o root interno vira uma ponte perigosa. Além disso, muitos frameworks de segurança e políticas de cluster simplesmente proíbem containers rodando como root.
A correção é declarar um usuário sem privilégio e trocar para ele antes de o processo iniciar:
``dockerfile RUN addgroup --system app && adduser --system --ingroup app app USER app ``
A partir do USER app, o processo principal roda sem privilégios de root. Isso limita o estrago de um comprometimento e alinha a imagem às políticas de segurança de qualquer cluster sério. É uma linha de configuração que fecha uma classe inteira de riscos, e não há bom motivo para omiti-la em produção.
O .dockerignore que ninguém escreve#
Assim como o .gitignore diz ao Git o que ignorar, o .dockerignore diz ao build o que não enviar para o contexto de construção. Sem ele, o COPY . . arrasta para dentro da imagem coisas que não deveriam estar lá: a pasta .git com todo o histórico, o node_modules local que será reinstalado de qualquer jeito, arquivos .env com segredos, logs, caches e artefatos de build antigos.
O impacto é duplo. Primeiro, o contexto de build fica gigante e lento, porque tudo é empacotado e enviado ao daemon. Segundo, e mais grave, você corre o risco real de vazar segredos para dentro da imagem. Um .env copiado para uma camada fica lá para sempre naquela camada, e quem tiver a imagem tem o segredo, mesmo que uma instrução posterior o apague, porque a camada anterior o preserva.
Um .dockerignore mínimo e honesto resolve:
`` .git node_modules .log .env .env. dist coverage ``
Segredos e o pecado que fica gravado na camada#
Vale insistir num erro que parece inofensivo e é grave, porque ele explora justamente o desenho de camadas que discutimos. Imagens são construídas em camadas imutáveis e empilhadas, e cada instrução gera uma camada que registra o estado do filesystem naquele ponto. A consequência é que apagar um arquivo numa camada posterior não o remove das camadas anteriores.
O padrão perigoso é este: copiar um arquivo de segredo, uma chave privada ou um token para usar durante o build, e depois apagá-lo achando que sumiu. Ele não sumiu. A camada onde ele foi copiado continua existindo dentro da imagem, e qualquer pessoa que tenha a imagem pode extrair essa camada e ler o segredo, mesmo que uma instrução posterior o tenha deletado. A camada é histórico permanente, não estado atual.
A solução correta para segredos em tempo de build é não os colocar em nenhuma camada. Ferramentas modernas de build oferecem montagens de segredo que expõem o valor apenas durante a execução de uma instrução específica, sem persistir em camada alguma. E segredos de tempo de execução, como senhas de banco e chaves de API, nunca entram na imagem: são injetados pelo orquestrador no momento em que o container sobe, via variáveis de ambiente ou volumes de segredo. A regra é absoluta e alinhada aos pisos de segurança da casa: segredo nunca é assado dentro do artefato, porque o artefato é distribuído e as camadas guardam tudo.
Escaneie a imagem antes de confiar nela#
Uma imagem pronta não é uma caixa fechada em que se deposita fé cega. A base que você escolheu carrega dezenas ou centenas de pacotes do sistema, e pacotes têm vulnerabilidades descobertas ao longo do tempo. Uma imagem que era segura no dia do build pode ter uma falha crítica revelada uma semana depois, sem que uma linha do seu Dockerfile mude.
Por isso, escanear imagens em busca de vulnerabilidades conhecidas é parte da higiene, não um luxo. Um scanner compara os pacotes presentes na imagem contra bases públicas de vulnerabilidades e reporta o que encontra, por severidade. O lugar natural de rodar isso é o pipeline, barrando a promoção de imagens com falhas críticas, mas também vale reescanear periodicamente imagens já publicadas, porque vulnerabilidades novas surgem para imagens antigas. Aqui, de novo, a base mínima paga dividendos: uma imagem distroless com pouquíssimos pacotes tem uma superfície de escaneamento minúscula e quase nada para dar errado, enquanto uma base completa carrega centenas de pacotes que você nunca usa mas precisa manter livres de falhas.
Uma imagem que se comporta bem em produção#
Faltam alguns acabamentos que separam uma imagem funcional de uma imagem operável. Declare a porta com EXPOSE para documentar a intenção. Prefira a forma exec do ENTRYPOINT, com colchetes e argumentos como lista, em vez da forma shell: a forma exec faz seu processo receber os sinais do sistema diretamente, o que permite um encerramento gracioso quando o orquestrador manda um SIGTERM. Na forma shell, o processo vira filho de um /bin/sh que engole os sinais, e seu container morre no tapa em vez de drenar conexões.
Adicione um HEALTHCHECK quando fizer sentido, para que o runtime saiba distinguir um container que subiu de um container que está saudável. E mantenha as variáveis de ambiente com valores padrão sensatos, deixando os segredos de fora da imagem, injetados em tempo de execução pelo orquestrador.
Amarrando tudo: uma boa imagem tem camadas ordenadas para cache, usa multi-stage para carregar só o artefato final, parte de uma base mínima com versão fixada, roda como usuário sem privilégio, ignora o que não deve entrar e trata sinais corretamente. Cada uma dessas decisões é pequena isoladamente e enorme no agregado, porque a imagem é construída e distribuída incontáveis vezes ao longo da vida do serviço.
Com a imagem bem construída, o próximo passo é orquestrá-la em escala, distribuindo réplicas por vários nós, tratando falhas e balanceando carga. Esse é justamente o trabalho que a arquitetura do Kubernetes resolve, e uma imagem enxuta e bem-comportada é o que faz esse orquestrador brilhar em vez de sofrer.