Nos artigos anteriores (Escrevendo um Dockerfile do Zero, Docker Volumes e Redes: Persistência e Comunicação, Docker Compose: Orquestrando Múltiplos Serviços Localmente) 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.
Exercícios
Exercício 1
Entre node:20 (~1.1GB), node:20-alpine (~180MB) e distroless (~160MB), por que a distroless é considerada a mais segura? E qual o custo prático dessa escolha?
Ver resposta
✓ Resposta: Porque a distroless contém apenas o runtime da aplicação — sem shell, sem gerenciador de pacotes, sem utilitários de sistema. A segurança vem exatamente dessa ausência: ainda que um atacante consiga executar algo dentro do container, não há sh, curl, wget nem apt para baixar ferramentas, explorar o ambiente ou tentar escalar privilégio. O custo aparece no dia em que algo dá errado: não existe docker exec -it ... sh, porque não existe shell. A investigação passa a depender de logs, de um container de depuração anexado ao mesmo namespace, ou de uma variante :debug da imagem. É uma troca explícita entre facilidade de diagnóstico e superfície de ataque.
Exercício 2
Alpine produz imagens pequenas, mas o artigo faz uma ressalva sobre musl libc. Qual é o risco, e por que ele é difícil de detectar cedo?
Ver resposta
✓ Resposta: O Alpine usa musl libc no lugar da glibc adotada pela maioria das distribuições. Bibliotecas nativas compiladas contra glibc podem não funcionar sobre musl, ou funcionar com comportamento sutilmente diferente em áreas como resolução de DNS, locales e threads. A dificuldade de detecção está no momento em que a falha aparece: o build conclui normalmente, a imagem sobe, e o problema só se manifesta em runtime — às vezes apenas sob carga ou em um caminho de código pouco exercitado. Daí a recomendação do artigo ser contextual: Alpine para a maioria dos casos, desde que verificado que as dependências nativas do projeto convivem bem com ele.
Exercício 3
Por que rm -rf /var/lib/apt/lists/* precisa estar no mesmo RUN do apt-get install? O que acontece se estiver em uma instrução separada?
Ver resposta
✓ Resposta: Porque cada RUN fecha uma camada, e camadas são imutáveis e cumulativas. Com a remoção em instrução separada, a camada do apt-get install já teria gravado o cache em disco; o rm seguinte apenas registraria a ausência daqueles arquivos na camada final, sem devolver o espaço — a imagem continuaria carregando os megabytes do cache dentro de si, invisíveis no sistema de arquivos mas presentes no download. Encadeando tudo com &&, o cache nasce e morre antes que a camada seja fechada, e o espaço nunca chega a ser cobrado. É exatamente o mesmo princípio do .npmrc com token.
Exercício 4
O que docker history revela, e por que remover um segredo em uma instrução posterior não resolve? Como o build secret do BuildKit muda isso?
Ver resposta
✓ Resposta: O docker history expõe as instruções e camadas que compõem a imagem, de modo que argumentos de build e arquivos gravados ficam visíveis para qualquer um que tenha a imagem em mãos. Remover o segredo depois não adianta: a camada em que ele existiu continua fazendo parte da imagem e continua sendo distribuída junto com ela. O --mount=type=secret do BuildKit resolve o problema pela raiz — o arquivo é montado apenas durante a execução daquele RUN, em um sistema de arquivos temporário que não vira camada. O segredo fica disponível para o npm ci e desaparece sem deixar rastro. Já em runtime a regra é outra: o segredo é injetado pelo orquestrador via variável de ambiente, nunca embutido na imagem.
Exercício 5
Por que FROM node:latest compromete o build? Descreva o cenário concreto que o artigo aponta.
Ver resposta
✓ Resposta: Porque latest é um ponteiro móvel: o que ele aponta hoje pode ser outra versão amanhã. O mesmo Dockerfile deixa de produzir a mesma imagem, e o build perde reprodutibilidade. O cenário concreto é o do pipeline que passou na sexta-feira e quebra na segunda sem que uma única linha de código tenha mudado, porque a imagem base foi atualizada no fim de semana. O agravante é o tempo perdido: o time procura a causa no próprio commit, que é o lugar onde ela não está. Fixar node:20.11.1-alpine3.19 transforma a atualização de base em decisão deliberada, registrada em um commit e revisável como qualquer outra mudança.
Exercício 6
Por que containers rodam como root por padrão? Explique o papel de cada etapa da sequência adduser -S → chown -R → USER — e qual delas costuma ser esquecida.
Ver resposta
✓ Resposta: Root é o padrão porque simplifica tudo durante a construção: instalar pacotes, escrever em qualquer caminho, ligar em portas baixas. O custo aparece depois — uma vulnerabilidade explorada na aplicação já nasce com privilégio máximo dentro do container, o que facilita tentativas de escape para o host, especialmente quando há volumes montados. Na sequência, o adduser -S cria um usuário de sistema sem privilégios; o chown -R appuser:appgroup /app transfere a posse dos arquivos da aplicação para ele; e o USER appuser troca o contexto de execução. A etapa esquecida costuma ser o chown: sem ele, o processo muda de usuário e perde acesso de escrita aos próprios arquivos, produzindo erros de permissão que parecem não ter relação com a mudança. Vale lembrar que a imagem oficial do Node já traz um usuário node pronto, reduzindo tudo isso a uma linha.