O que Todo Dev Deve Saber sobre Dockerfile em Profundidade: Cada Instrução e seu Impacto no Build Já leu

Introdução: Por que entender Dockerfile é fundamental Um Dockerfile é um script declarativo que define como construir uma imagem Docker — o template imutável que permite criar contêineres consistentes em qualquer ambiente. A maioria dos desenvolvedores trata Dockerfiles como um "set and forget", copiam exemplos da internet e seguem adiante. Isso funciona até o momento em que você precisa otimizar tempo de build, reduzir tamanho de imagem ou debugar um comportamento inesperado em produção. Neste artigo, vamos explorar cada instrução principal de um Dockerfile, entender por que cada uma existe, como elas impactam o processo de build e, mais importante, como suas decisões afetam performance, segurança e manutenibilidade. Você aprenderá a escrever Dockerfiles que não apenas funcionam, mas que são eficientes e profissionais. Fundamentos: A Estrutura e o Fluxo de Construção O que acontece durante um Docker Build Quando você executa , o Docker passa por um processo bem definido: lê o Dockerfile linha por linha, executa cada instrução em

Introdução: Por que entender Dockerfile é fundamental

Um Dockerfile é um script declarativo que define como construir uma imagem Docker — o template imutável que permite criar contêineres consistentes em qualquer ambiente. A maioria dos desenvolvedores trata Dockerfiles como um "set and forget", copiam exemplos da internet e seguem adiante. Isso funciona até o momento em que você precisa otimizar tempo de build, reduzir tamanho de imagem ou debugar um comportamento inesperado em produção.

Neste artigo, vamos explorar cada instrução principal de um Dockerfile, entender por que cada uma existe, como elas impactam o processo de build e, mais importante, como suas decisões afetam performance, segurança e manutenibilidade. Você aprenderá a escrever Dockerfiles que não apenas funcionam, mas que são eficientes e profissionais.

Fundamentos: A Estrutura e o Fluxo de Construção

O que acontece durante um Docker Build

Quando você executa docker build -t minha-app:1.0 ., o Docker passa por um processo bem definido: lê o Dockerfile linha por linha, executa cada instrução em um contêiner temporário, salva o resultado como uma camada (layer), descarta o contêiner temporário e usa a camada anterior como base para a próxima instrução. Esse sistema de camadas é a chave para entender Dockerfiles.

Cada instrução cria uma nova camada. Se a instrução não mudou desde o último build, o Docker reutiliza a camada em cache. Isso significa que a ordem das instruções importa profundamente — instruções que mudam frequentemente devem vir por último, não por primeiro. A estrutura de um Dockerfile segue um fluxo lógico: especificar a imagem base, instalar dependências, copiar código, executar testes ou build, e finalmente definir como iniciar o contêiner.

FROM, WORKDIR e as Instruções Essenciais

FROM: Escolhendo a base certa

A instrução FROM especifica a imagem base. Essa escolha é crítica — ela determina o sistema operacional, as ferramentas pré-instaladas, o tamanho inicial da imagem e, consequentemente, a superfície de ataque de segurança.

FROM ubuntu:22.04

Este Dockerfile constrói sobre Ubuntu completo (cerca de 77MB já da base). Se você apenas precisa rodar uma aplicação Python, isso é desperdício. Imagens especializadas como python:3.11-slim (150MB) ou até melhor, python:3.11-alpine (50MB) reduzem significativamente o tamanho. Alpine usa musl libc ao invés de glibc, o que causa incompatibilidades ocasionais com bibliotecas compiladas, mas geralmente é a escolha ideal para contêineres.

FROM python:3.11-alpine

Um padrão profissional é usar tags específicas, nunca latest. O latest é um alvo móvel — sua imagem pode quebrar semanas depois quando uma atualização é lançada. Use versões:

FROM python:3.11.7-alpine3.18

Se você está compilando C/C++ durante o build mas não precisa disso na execução, use builds multi-estágio (discutiremos depois).

WORKDIR: Organizando o espaço de trabalho

WORKDIR define o diretório de trabalho dentro do contêiner. Todas as instruções subsequentes (RUN, COPY, ADD, CMD) operam neste contexto.

FROM python:3.11-alpine
WORKDIR /app

Se WORKDIR não existir, o Docker o cria automaticamente. Use caminhos absolutos, nunca relativos. WORKDIR /app é claro; WORKDIR ./src cria confusão e bugs. Um padrão comum em ambientes corporativos é criar um usuário não-root e definir o WORKDIR com permissões apropriadas, que veremos mais adiante.

RUN: Executando comandos

RUN executa um comando shell durante o build. Cada RUN cria uma camada. Iniciantes cometem o erro de fazer uma instrução RUN por comando:

# RUIM - cria 3 camadas desnecessárias
RUN apk add --no-cache python3
RUN apk add --no-cache pip
RUN pip install flask

Combine em uma única RUN:

# BOM - cria 1 camada
RUN apk add --no-cache python3 pip && \
    pip install --no-cache-dir flask==2.3.0

Note o uso de --no-cache-dir no pip e --no-cache no apk — esses flags removem caches de gerenciadores de pacotes, reduzindo o tamanho da camada. Em contêineres, você não reutiliza o gerenciador, então esses caches são lixo.

FROM python:3.11-alpine

WORKDIR /app

# Instalação de dependências do sistema e Python
RUN apk add --no-cache \
    gcc \
    musl-dev \
    && pip install --no-cache-dir --upgrade pip

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

Este padrão é padrão industrial: instale dependências de sistema, depois dependências Python. As dependências de sistema raramente mudam, então sua camada é reutilizável. requirements.txt pode mudar frequentemente, então sua camada é reconstruída quando necessário.

COPY, ADD e Gerenciamento de Arquivos

COPY: O comando correto para copiar arquivos

COPY copia arquivos do seu host para o contêiner. É simples e previsível:

COPY requirements.txt /app/
COPY src/ /app/src/

Você pode usar wildcards:

COPY *.txt /app/

E copiar com alteração de proprietário:

COPY --chown=nobody:nobody app.py /app/

Use COPY na maioria dos casos. É mais claro que ADD e melhor para cache — o Docker invalida a camada apenas se os arquivos realmente mudarem (comparação de hash).

ADD: Quando usar, e quando evitar

ADD é similar ao COPY, mas com recursos extras: pode extrair arquivos .tar automaticamente e suporta URLs. Essas extras introduzem complexidade:

# ADD baixa de URL e extrai automaticamente
ADD https://example.com/archive.tar.gz /tmp/

# Equivalente mais explícito e profissional
RUN wget -O /tmp/archive.tar.gz https://example.com/archive.tar.gz && \
    tar -xzf /tmp/archive.tar.gz -C /tmp && \
    rm /tmp/archive.tar.gz

A versão explícita com RUN é preferível em código corporativo — é clara, suas intenções são óbvias, e você controla exatamente o que acontece. Use ADD apenas para arquivos .tar quando for realmente necessário.

Exemplo prático: Estrutura de projeto

FROM python:3.11-alpine

WORKDIR /app

# Copiar estrutura do projeto
COPY src/ ./src/
COPY tests/ ./tests/
COPY requirements.txt requirements-dev.txt ./

# Instalar dependências
RUN pip install --no-cache-dir -r requirements.txt

# Testes durante o build (falhar rápido)
RUN python -m pytest tests/ --tb=short

EXPOSE 8000

CMD ["python", "-m", "uvicorn", "src.main:app", "--host", "0.0.0.0"]

Este Dockerfile segue um padrão profissional: copiamos estrutura, instalamos, testamos no build, e apenas se tudo passar, a imagem é criada. Se um teste falhar, o build falha, impedindo que código quebrado vire imagem.

ENV, EXPOSE, ENTRYPOINT e CMD

ENV: Variáveis de ambiente

ENV define variáveis que existem tanto no build quanto no contêiner em execução:

ENV PYTHONUNBUFFERED=1
ENV APP_ENV=production
ENV DATABASE_URL=postgresql://localhost/mydb

PYTHONUNBUFFERED=1 é crítico para aplicações Python em contêineres — força Python a não bufferizar stdout, garantindo que logs apareçam imediatamente. Sem isso, seus logs podem desaparecer ou atrasar em um crash.

Variáveis sensíveis (senhas, chaves) devem ser passadas em tempo de execução, nunca hardcodadas:

# RUIM - a senha fica na imagem permanentemente
ENV DB_PASSWORD=super_secret_123

# BOM - usar build args ou runtime secrets

Se precisar de uma variável apenas no build:

ARG BUILD_DATE
ARG VERSION=1.0.0

RUN echo "Build date: $BUILD_DATE" && echo "Version: $VERSION"

ARG existe apenas durante o build. ENV persiste no contêiner.

EXPOSE: Documentação de portas

EXPOSE documenta qual porta a aplicação usa, mas não abre a porta:

EXPOSE 8000

Isso é puramente informacional. Ao rodar docker run -P, o Docker usa EXPOSE para mapear portas automaticamente. Sempre use EXPOSE para deixar claro qual porta sua aplicação usa, mesmo que você mapeie diferente em runtime.

ENTRYPOINT vs CMD: A diferença sutil mas crítica

Iniciantes confundem ENTRYPOINT e CMD. Entenda assim:

  • ENTRYPOINT é o executável principal do contêiner. Nunca muda (geralmente).
  • CMD são argumentos padrão para ENTRYPOINT.
# Exemplo 1: Apenas CMD
FROM python:3.11-alpine
CMD ["python", "app.py"]

# Rodar: docker run myimage
# Executa: python app.py

# Rodar: docker run myimage --debug
# Executa: python app.py --debug (CMD é substituído completamente)
# Exemplo 2: ENTRYPOINT + CMD (melhor prática)
FROM python:3.11-alpine
ENTRYPOINT ["python", "app.py"]
CMD ["--port", "8000"]

# Rodar: docker run myimage
# Executa: python app.py --port 8000

# Rodar: docker run myimage --port 9000
# Executa: python app.py --port 9000 (apenas CMD é substituído)

A forma "exec" (array JSON) é preferível à forma shell:

# Ruim - exec em shell, sinais não funcionam corretamente
ENTRYPOINT "python app.py"

# Bom - exec direto
ENTRYPOINT ["python", "app.py"]

Na forma shell, seu aplicativo é filho de um shell, impedindo que sinais como SIGTERM sejam recebidos corretamente. Ao parar um contêiner, ele fica esperando timeout ao invés de desligar gracefully.

Otimizações e Padrões Avançados

Multi-stage builds: Separando build de runtime

Um multi-stage build cria uma imagem "builder" temporária e depois copia apenas artefatos necessários para a imagem final, descartando ferramentas de build.

# Stage 1: Build
FROM golang:1.21-alpine AS builder

WORKDIR /build

COPY . .

RUN go mod download && \
    go build -o /build/app .

# Stage 2: Runtime
FROM alpine:3.18

WORKDIR /app

# Copiar apenas o binário compilado
COPY --from=builder /build/app .

EXPOSE 8080

CMD ["./app"]

A primeira imagem (builder) contém compilador Go, headers, dependências de desenvolvimento — tudo pesado. A imagem final contém apenas o binário de 10MB. Seu contêiner fica drasticamente menor.

Exemplo com Python:

FROM python:3.11-slim AS builder

WORKDIR /build

COPY requirements.txt .

# Compilar wheels em /build/wheels
RUN pip wheel --no-cache-dir --no-deps --wheel-dir /build/wheels -r requirements.txt

# Runtime
FROM python:3.11-alpine

WORKDIR /app

# Copiar wheels pré-compilados
COPY --from=builder /build/wheels /wheels

# Instalar de wheels (rápido, sem compilação)
COPY requirements.txt .
RUN pip install --no-cache /wheels/* && rm -rf /wheels

COPY app.py .

CMD ["python", "app.py"]

Multi-stage é a diferença entre uma imagem de 800MB e 200MB.

.dockerignore: Reduzindo contexto de build

Quando você executa docker build ., o Docker envia todo o contexto (seu diretório) para o daemon. Arquivos desnecessários aumentam tempo de transferência.

# .dockerignore
.git
.gitignore
node_modules
.env
__pycache__
*.pyc
.pytest_cache
.venv
dist
build
*.log
.DS_Store

Isso reduz o contexto drasticamente, acelerando o build.

Segurança: Princípios básicos

Nunca rode como root:

FROM python:3.11-alpine

RUN addgroup -S appgroup && adduser -S appuser -G appgroup

WORKDIR /app

COPY --chown=appuser:appgroup . .

RUN pip install --no-cache-dir -r requirements.txt

USER appuser

CMD ["python", "app.py"]

Escanear vulnerabilidades:

docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
  aquasec/trivy image myapp:latest

Imagens menores reduzem superfície de ataque. Alpine é mais seguro que Ubuntu por ter menos pacotes.

Example completo: Aplicação Node.js profissional

# Stage 1: Builder
FROM node:20-alpine AS builder

WORKDIR /build

COPY package*.json ./

RUN npm ci && npm run build

# Stage 2: Runtime
FROM node:20-alpine

ENV NODE_ENV=production

RUN addgroup -S nodejs && adduser -S nodejs -G nodejs

WORKDIR /app

COPY --from=builder --chown=nodejs:nodejs /build/dist ./dist
COPY --from=builder --chown=nodejs:nodejs /build/node_modules ./node_modules
COPY --chown=nodejs:nodejs package.json ./

USER nodejs

EXPOSE 3000

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD node -e "require('http').get('http://localhost:3000/health', (r) => {if (r.statusCode !== 200) throw new Error(r.statusCode)})"

CMD ["node", "dist/server.js"]

Este Dockerfile segue padrões profissionais:
- Multi-stage para otimização
- Não-root user por segurança
- HEALTHCHECK para orquestração
- NODE_ENV definido
- npm ci (não npm install) para builds reproduzíveis

Debugging e Boas Práticas

Inspecionando camadas e histórico

# Ver histórico de camadas
docker history myapp:latest

# Inspecionar imagem
docker image inspect myapp:latest | jq '.[]'

# Entrar em uma imagem para debugar
docker run -it myapp:latest /bin/sh

Rebuild sem cache quando necessário

# Forçar rebuild sem usar cache
docker build --no-cache -t myapp:latest .

# Fazer cache apenas até uma instrução específica
docker build --target builder -t myapp:builder .

Linting e validação

Use hadolint para validar Dockerfiles:

docker run --rm -i hadolint/hadolint < Dockerfile

Detecta más práticas automaticamente.

Medindo impacto de mudanças

# Tamanho total
docker images myapp:latest --format "{{.Size}}"

# Tamanho por camada
docker history myapp:latest --human

Conclusão

Três aprendizados-chave ao dominar Dockerfiles: Primeiro, ordem e agrupamento de instruções impactam drasticamente tempo de build e reuso de cache — coloque instruções que mudam frequentemente por último e combine RUNs com &&. Segundo, multi-stage builds são a ferramenta mais poderosa para reduzir tamanho de imagem sem sacrificar funcionalidade — é a diferença entre "funciona" e "profissional". Terceiro, segurança e otimização não são extras — são fundamentais — use usuários não-root, Alpine quando possível, e sempre escanear vulnerabilidades.

Um Dockerfile bem escrito não é mais longo que um mal escrito. A diferença está em cada decisão deliberada: por que essa instrução está nessa ordem? Por que não multi-stage aqui? Essa mentalidade transforma você de alguém que copia exemplos para um profissional que escreve containers eficientes e seguros.

Referências


Artigos relacionados