No artigo anterior desta série, rodamos containers a partir de imagens prontas. Mas o valor real do Docker aparece quando você empacota a sua própria aplicação. Um Dockerfile mal escrito gera imagens gigantes e builds lentos; um bem escrito aproveita cache de camadas e produz imagens enxutas. A diferença está em entender como o Docker constrói uma imagem por dentro.
1. Imagens São Feitas de Camadas
Cada instrução de um Dockerfile (RUN, COPY, ADD) cria uma nova camada, empilhada sobre a anterior. Camadas são cacheadas: se uma instrução e tudo que veio antes dela não mudaram, o Docker reaproveita o resultado em vez de executar de novo.
Isso tem uma implicação prática direta: a ordem das instruções importa. Coloque o que muda com menos frequência no topo do arquivo, e o que muda a cada build (como o código-fonte) por último.
2. Um Dockerfile Ingênuo (E Por Que Ele é Lento)
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]O problema aqui: COPY . . copia todo o código-fonte antes do npm install. Qualquer mudança em qualquer arquivo — inclusive um README.md — invalida o cache dessa camada e força o npm install a rodar de novo, baixando todas as dependências do zero.
3. Reordenando para Aproveitar o Cache
FROM node:20-alpine
WORKDIR /app
# Copia só o necessário para instalar dependências
COPY package.json package-lock.json ./
RUN npm install --production
# Só agora copia o restante do código
COPY . .
CMD ["node", "server.js"]Agora, npm install só roda de novo quando package.json ou package-lock.json mudam — mudanças no código da aplicação não invalidam essa camada. Em projetos com muitas dependências, essa reordenação sozinha pode derrubar o tempo de build de minutos para segundos.
4. O .dockerignore
Assim como o .gitignore, um .dockerignore evita que arquivos desnecessários entrem no contexto de build — o que acelera o envio do contexto para o daemon e evita camadas infladas por acidente:
node_modules
.git
*.log
.env
distEsquecer o .dockerignore é uma causa comum de imagens gigantes e, pior, de segredos (.env) parando dentro da imagem sem querer.
5. Multi-Stage Builds
Um problema recorrente: a imagem final carrega ferramentas de build que não são necessárias em produção (compiladores, dependências de desenvolvimento, código-fonte não compilado). Multi-stage builds resolvem isso usando múltiplos FROM no mesmo Dockerfile, onde só o necessário é copiado para o estágio final:
# Estágio 1: build
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
COPY . .
RUN npm run build
# Estágio 2: imagem final, só com o resultado do build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/htmlO estágio final não contém o Node.js, o node_modules nem o código-fonte — só os arquivos estáticos gerados pelo build, servidos por um Nginx enxuto. O resultado costuma ser uma redução drástica no tamanho final da imagem, além de reduzir a superfície de ataque (menos ferramentas instaladas, menos CVEs para se preocupar).
6. Boas Práticas Que Fazem Diferença
- Prefira imagens
-alpineou-slimquando a aplicação permitir — imagens base menores significam menos superfície de ataque e downloads mais rápidos. - Combine
RUNrelacionados em uma única instrução quando fizer sentido, usando&&, para evitar camadas intermediárias desnecessárias:RUN apt-get update && \ apt-get install --yes --no-install-recommends curl && \ rm -rf /var/lib/apt/lists/* - Nunca copie segredos com
COPY— use--secretdo BuildKit ou variáveis de ambiente injetadas em tempo de execução, nunca embutidas na imagem. - Fixe versões das imagens base (
node:20.11-alpineem vez denode:latest) para builds reprodutíveis. - Rode como usuário não-root sempre que possível:
RUN adduser --disabled-password appuser USER appuser
7. Conclusão
Entender camadas e cache transforma o Dockerfile de um script de instalação em uma ferramenta de build eficiente, e multi-stage builds resolvem o problema clássico de imagens de produção carregando peso desnecessário. No próximo artigo desta série, vamos sair da imagem isolada e entrar em redes e volumes — como containers conversam entre si e como persistir dados além do ciclo de vida de um container.