Nos artigos anteriores foram construídas imagens funcionais. Uma imagem funcional resolve o problema imediato — a aplicação sobe e responde. Uma imagem de produção vai além: ela é pequena o suficiente para ser transferida rapidamente, segura o suficiente para não expor superfícies de ataque desnecessárias e organizada o suficiente para ser construída em segundos no pipeline de CI/CD.
A diferença entre as duas não está no que a imagem faz, mas em como foi construída. Este artigo sistematiza as práticas que separam uma da outra.
Princípio 1: Escolher a Imagem Base Correta
A imagem base determina o tamanho inicial e a superfície de ataque da imagem final. Três categorias principais:
Imagens completas — como ubuntu:22.04 ou debian:bookworm. Incluem um sistema operacional completo com centenas de utilitários. São convenientes para desenvolvimento e depuração, mas desnecessariamente pesadas para produção.
Imagens slim — como node:20-slim ou python:3.12-slim. São versões reduzidas das imagens oficiais, sem pacotes desnecessários. Um bom equilíbrio entre conveniência e tamanho.
Imagens Alpine — baseadas no Alpine Linux, que ocupa apenas 5MB. Como node:20-alpine ou nginx:alpine. Resultam nas menores imagens possíveis, mas podem exigir ajustes — o Alpine usa musl libc em vez de glibc, o que ocasionalmente causa incompatibilidades com bibliotecas nativas.
Imagens distroless — desenvolvidas pelo Google, contêm apenas o runtime necessário, sem shell, sem gerenciador de pacotes, sem nenhum utilitário de sistema. São as mais seguras para produção porque não oferecem superfície para execução de comandos arbitrários se o container for comprometido.
Comparação prática de tamanhos para uma aplicação Node.js simples:
node:20 → ~1.1GB
node:20-slim → ~240MB
node:20-alpine → ~180MB
gcr.io/distroless/nodejs20-debian12 → ~160MB
A escolha ideal depende do contexto: Alpine para a maioria dos casos em produção, distroless para sistemas de alta segurança, imagens completas apenas para desenvolvimento e depuração.
Princípio 2: Minimizar o Número de Camadas
Cada instrução RUN, COPY e ADD cria uma camada. Camadas desnecessárias aumentam o tamanho da imagem e o tempo de build. A prática recomendada é encadear comandos relacionados em uma única instrução RUN:
Ineficiente — quatro camadas separadas:
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get install -y git
RUN apt-get clean
Eficiente — uma única camada:
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
curl \
git \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
O --no-install-recommends evita a instalação de pacotes sugeridos que não são obrigatórios. O rm -rf /var/lib/apt/lists/* remove o cache do apt na mesma camada — se fosse em uma instrução separada, o cache já teria sido persistido na camada anterior e o espaço não seria recuperado.
Princípio 3: Não Rodar como Root
Por padrão, processos dentro de containers rodam como root. Se um atacante explorar uma vulnerabilidade na aplicação e conseguir executar comandos arbitrários dentro do container, terá privilégios de root dentro dele — o que facilita tentativas de escape para o host.
A solução é criar um usuário não-privilegiado e usá-lo para executar a aplicação:
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
# Cria usuário e grupo dedicados
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
# Define dono dos arquivos
RUN chown -R appuser:appgroup /app
# Muda para o usuário não-privilegiado
USER appuser
EXPOSE 3000
CMD ["node", "dist/index.js"]
A imagem node já inclui um usuário chamado node que pode ser usado diretamente:
# Forma simplificada usando o usuário já existente na imagem base
USER node
Princípio 4: Não Incluir Segredos na Imagem
Um erro comum é passar segredos como variáveis de ambiente durante o build ou copiá-los para dentro da imagem. Mesmo que removidos em camadas subsequentes, os segredos ficam acessíveis no histórico da imagem:
# Isso expõe o token no histórico da imagem
docker history minha-imagem
A forma correta de lidar com segredos durante o build é usar o mecanismo de build secrets do Docker BuildKit:
# syntax=docker/dockerfile:1
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
# O secret é montado temporariamente e não persiste na imagem
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN=$(cat /run/secrets/npm_token) \
npm ci
# Passa o secret durante o build
docker build \
--secret id=npm_token,src=$HOME/.npmrc \
-t minha-app:1.0.0 .
Em runtime, segredos devem ser injetados via variáveis de ambiente pelo orquestrador — nunca embutidos na imagem.
Princípio 5: Usar .dockerignore Rigorosamente
O .dockerignore evita que arquivos desnecessários entrem no contexto de build. Um arquivo bem configurado reduz o tempo de transferência do contexto e evita que informações sensíveis sejam incluídas acidentalmente:
# Controle de versão
.git
.gitignore
# Dependências — serão instaladas durante o build
node_modules
vendor
# Arquivos de ambiente — nunca devem entrar na imagem
.env
.env.*
*.pem
*.key
# Artefatos de build locais
dist
build
coverage
.cache
# Documentação e arquivos de desenvolvimento
*.md
docs
.vscode
.idea
# Logs
*.log
logs
# O próprio Dockerfile e dockerignore
Dockerfile*
.dockerignore
Princípio 6: Fixar Versões de Dependências
Usar tags latest ou omitir versões em imagens base é uma prática que compromete a reprodutibilidade do build:
# Problemático — o que é 'latest' hoje pode não ser amanhã
FROM node:latest
RUN apt-get install -y curl
# Correto — versão específica e reproduzível
FROM node:20.11.1-alpine3.19
RUN apt-get install -y curl=8.2.1-*
Fixar versões garante que o mesmo Dockerfile produz a mesma imagem independentemente de quando é executado. Em pipelines de CI/CD isso é especialmente importante — um build que passou na sexta-feira não deve quebrar na segunda porque uma dependência foi atualizada no fim de semana.
Princípio 7: Verificar Vulnerabilidades
Imagens são compostas por dezenas de pacotes, cada um com seu próprio histórico de vulnerabilidades. A verificação de segurança deve fazer parte do pipeline de build.
O Docker Scout é a ferramenta integrada ao Docker CLI para essa finalidade:
# Analisa a imagem local
docker scout cves minha-app:1.0.0
# Exibe um resumo rápido
docker scout quickview minha-app:1.0.0
# Compara com a versão anterior
docker scout compare minha-app:1.0.0 --to minha-app:0.9.0
O Trivy é uma alternativa open source amplamente usada em pipelines de CI:
# Instala o Trivy
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh
# Escaneia a imagem
trivy image minha-app:1.0.0
# Falha o build se encontrar vulnerabilidades críticas
trivy image --exit-code 1 --severity CRITICAL minha-app:1.0.0
Integrado ao GitHub Actions:
- name: Escaneia vulnerabilidades
uses: aquasecurity/trivy-action@master
with:
image-ref: minha-app:1.0.0
format: table
exit-code: 1
severity: CRITICAL,HIGH
Um Dockerfile Seguindo Todos os Princípios
# syntax=docker/dockerfile:1
# Versões fixas para reprodutibilidade
FROM node:20.11.1-alpine3.19 AS base
# Instala apenas o necessário em uma única camada
RUN apk add --no-cache tini dumb-init
# ── Dependências ──────────────────────────────────
FROM base AS deps
WORKDIR /app
COPY package*.json ./
# Build secret para registros privados
RUN --mount=type=secret,id=npm_token \
--mount=type=cache,target=/root/.npm \
npm ci --only=production
# ── Build ─────────────────────────────────────────
FROM base AS builder
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY . .
RUN npm run build
# ── Produção ──────────────────────────────────────
FROM base AS production
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
# Copia dependências de produção do estágio deps
COPY --from=deps /app/node_modules ./node_modules
# Copia apenas o código compilado
COPY --from=builder /app/dist ./dist
# Define permissões antes de mudar o usuário
RUN chown -R node:node /app
# Usuário não-privilegiado
USER node
EXPOSE 3000
# Healthcheck
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:3000/health || exit 1
# tini como PID 1 — gerencia sinais corretamente
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "dist/index.js"]
O Que Vem a Seguir
No próximo e último artigo do Módulo 3 será abordada a publicação de imagens em registros — Docker Hub, GitHub Container Registry e registros privados na AWS. É o elo entre a construção local e a distribuição para ambientes de produção.
Referências para Aprofundamento
Documentação oficial - Best Practices for Writing Dockerfiles — docs.docker.com — Guia oficial de boas práticas do Docker, cobrindo cache de camadas, tamanho e segurança. - Docker Scout Documentation — docs.docker.com — Documentação completa da ferramenta de análise de vulnerabilidades integrada ao Docker.
Segurança - Trivy — aquasecurity.github.io — Documentação completa do Trivy, o scanner de vulnerabilidades open source mais usado em pipelines de CI/CD. - Distroless Images — GitHub — Repositório oficial das imagens distroless do Google com exemplos de uso para Node.js, Python, Java e Go.
Leitura complementar - Hadolint — GitHub — Linter para Dockerfiles que verifica automaticamente violações de boas práticas. Pode ser usado localmente ou integrado ao pipeline de CI como step obrigatório.