0%
Criação de Dockerfiles

Dockerfiles

Transformar uma receita em uma imagem reproduzível.

10-15 min

Criação de Dockerfiles

Até aqui, você consumiu imagens escritas por outras pessoas. Chegou o momento de produzir as suas, e é aí que o Docker deixa de ser um utilitário para se tornar um método de trabalho: o Dockerfile é o documento em que o ambiente de execução da sua aplicação deixa de ser conhecimento tribal e passa a ser código-fonte, revisado e versionado junto com o resto.

A tentação, no começo, é tratá-lo como um script de instalação que apenas foi movido para um arquivo. Empilham-se os comandos que funcionavam na máquina local, faz-se o build, funciona, e segue-se em frente. O resultado roda. Mas também pesa 1,4 GB para uma aplicação Node de três arquivos, é reconstruído em quatro minutos a cada vírgula alterada, roda como root e carrega, em uma de suas camadas, o token de acesso privado que você achava ter apagado na linha seguinte.

Nenhum desses quatro defeitos é inevitável, e nenhum exige conhecimentos avançados. Todos vêm de um mesmo mal-entendido: o Dockerfile não é um script executado de cima para baixo em um ambiente único. É uma sequência de transformações, cada uma produzindo uma camada imutável, mantida em cache e empilhada sobre a anterior. Entender essa mecânica é obter, quase de graça, imagens dez vezes mais leves e builds dez vezes mais rápidos.

Esta lição parte de um Dockerfile ingênuo, mas funcional, e o corrige defeito por defeito, explicando em cada etapa o que o mecanismo de build realmente faz.

Um primeiro Dockerfile, linha por linha

Vamos pegar uma pequena aplicação Node. Na raiz do projeto, um arquivo chamado exatamente Dockerfile, sem extensão:

# Imagem de partida: todo Dockerfile começa com FROM.
# A versão é fixada: nem "latest", nem simplesmente "node".
FROM node:22-slim

# Diretório de trabalho para as instruções seguintes.
# WORKDIR cria a pasta se ela não existir.
WORKDIR /app

# Copiar SOMENTE os manifestos de dependências, antes do código.
# O motivo é o cache: ele é detalhado mais adiante.
COPY package.json package-lock.json ./

# "npm ci" respeita estritamente o lock file, ao contrário de "npm install".
RUN npm ci --omit=dev

# O restante do código da aplicação
COPY . .

# EXPOSE não publica NADA: é um metadado documental.
# A publicação é feita com -p ao iniciar o contêiner.
EXPOSE 3000

# Comando executado na inicialização do contêiner
CMD ["node", "server.js"]
# -t etiqueta a imagem (nome:tag). O "." final é o CONTEXTO
# de build: a pasta enviada ao daemon, não o local do Dockerfile.
docker build -t mon-api:1.0 .

docker images mon-api
docker run --rm -p 3000:3000 mon-api:1.0

Esse Dockerfile funciona e já está correto em um ponto importante: a ordem dos COPY. Antes de avançar, é preciso esclarecer esse ponto final.

O contexto de build é o conjunto de arquivos que o cliente envia ao daemon antes de a construção começar. O daemon não tem nenhum acesso ao seu disco: ele só enxerga esse pacote. Daí duas consequências que todo mundo encontra pelo menos uma vez. Um COPY ../secrets/config.json falha com path outside of build context, porque o caminho sai do pacote. E, se o seu node_modules local pesa 800 MB, esses 800 MB são transferidos a cada build, mesmo que nunca sejam copiados — daí o .dockerignore, mais adiante.

Aliás, o contexto e o local do Dockerfile são independentes: docker build -f docker/Dockerfile.prod -t mon-api:prod . lê a receita em uma subpasta, mantendo a pasta atual como contexto.

As instruções essenciais

FROM define a imagem base e pode aparecer várias vezes (veja multi-stage). FROM scratch designa uma imagem totalmente vazia.

RUN executa um comando durante a construção e congela seu resultado em uma camada. Cada RUN cria uma camada, portanto eles devem ser encadeados.

# Ruim: três camadas, e o cache do apt permanece na imagem
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get install -y git

# Bom: uma camada, limpeza na MESMA instrução
RUN apt-get update && apt-get install -y --no-install-recommends \
      curl \
      git \
 && rm -rf /var/lib/apt/lists/*

O rm -rf deve obrigatoriamente estar no mesmo RUN. Colocado em um RUN posterior, ele apenas esconderia os arquivos sem reduzir o tamanho da imagem, já que as camadas são aditivas. O --no-install-recommends evita dezenas de pacotes sugeridos de que você não precisa.

COPY copia do contexto para a imagem. ADD faz o mesmo, acrescentando dois comportamentos implícitos — descompactar um arquivo local, baixar uma URL — que muitas vezes surpreendem. A regra aceita é COPY por padrão, ADD apenas para extrair um arquivo compactado.

COPY ./src /app/src
COPY --chown=node:node ./src /app/src   # copia definindo o proprietário
ADD app-v2.tar.gz /opt/                 # extrai automaticamente

ENV define uma variável persistente na imagem e nos contêineres; ARG define uma variável disponível apenas durante o build, que pode ser sobrescrita pela linha de comando.

ARG NODE_VERSION=22
FROM node:${NODE_VERSION}-slim
ARG APP_ENV=production
ENV NODE_ENV=${APP_ENV}
docker build --build-arg NODE_VERSION=20 --build-arg APP_ENV=staging -t mon-api:test .

# Um ARG NÃO é um segredo: seu valor continua legível aqui
docker history --no-trunc mon-api:test

WORKDIR define o diretório atual: prefira-o sempre a RUN cd /app, que não tem efeito duradouro, pois cada RUN inicia um novo processo. EXPOSE documenta uma porta e habilita docker run -P, que publica automaticamente as portas declaradas. Por fim, LABEL org.opencontainers.image.source="..." adiciona metadados de rastreabilidade que as ferramentas sabem aproveitar.

O cache de camadas: a ordem decide o tempo de build

Este é o ponto que produz os ganhos mais espetaculares, e ele se resume a uma regra.

Para cada instrução, o mecanismo calcula uma chave de cache. Se a instrução e suas entradas não mudaram, a camada é reutilizada como está. Mas, assim que uma camada é invalidada, todas as seguintes também são. O cache é uma corrente: uma ruptura no elo 3 quebra os elos 4 a 12. Para RUN, a chave é o texto do comando; para COPY, é o conteúdo dos arquivos copiados.

Daí o contraexemplo clássico:

FROM node:22-slim
WORKDIR /app

COPY . .          # invalida assim que UM único arquivo-fonte muda

RUN npm ci --omit=dev   # ...logo, reinstala tudo a cada vírgula alterada

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

E a versão correta, que explora a estabilidade relativa das dependências:

FROM node:22-slim
WORKDIR /app

# Apenas os manifestos: eles mudam raramente
COPY package.json package-lock.json ./

# Camada cara, mas preservada enquanto os manifestos forem idênticos
RUN npm ci --omit=dev

# O código por último: o que muda com mais frequência e custa menos
COPY . .

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

A regra geral, válida para todas as linguagens: do mais estável ao mais volátil. Instalação do sistema, depois dependências, depois código. Só os nomes dos arquivos mudam: requirements.txt para Python (pip install --no-cache-dir -r), go.mod e go.sum para Go, pom.xml para Maven, Cargo.toml para Rust.

Durante um build, as linhas CACHED indicam as camadas reutilizadas. Duas opções servem para contornar o cache: --no-cache força uma reconstrução completa, o que verifica se o Dockerfile ainda funciona a frio, e --pull obtém uma imagem base atualizada em vez da que está no cache local.

O BuildKit, mecanismo de build padrão desde o Docker 23, oferece ainda caches montados que sobrevivem às invalidações. Eles exigem a diretiva de sintaxe na primeira linha:

# syntax=docker/dockerfile:1
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./

# O cache do npm é montado durante o RUN e NÃO é
# incluído na camada: rápido, sem engordar a imagem.
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev

COPY . .
CMD ["node", "server.js"]

.dockerignore: indispensável, nunca opcional

Ao lado do Dockerfile, esse arquivo exclui caminhos do contexto de build. Sua sintaxe segue a do .gitignore.

# Dependências: reinstaladas na imagem, e as do host
# talvez tenham sido compiladas para outro SO
node_modules
__pycache__
vendor

.git            # muitas vezes centenas de MB, nunca útil

# Segredos e configuração local: o risco mais grave
.env
.env.*
*.pem
*.key

# Artefatos, cobertura, arquivos de editor
dist
build
coverage
.vscode
.idea

Dockerfile*
docker-compose*.yml

Ele atua em três frentes ao mesmo tempo. Acelera: o contexto transferido pode passar de várias centenas de megabytes para algumas centenas de kilobytes. Protege: sem ele, um COPY . . leva seu .env e suas chaves privadas para uma camada da imagem, de onde continuarão extraíveis. E estabiliza o cache: um arquivo de log que muda a cada execução deixa de invalidar suas camadas.

O caso do node_modules merece destaque: copiá-lo do host não é apenas inútil, é arriscado, pois os módulos nativos são compilados para a arquitetura e o SO da máquina em que foram instalados.

CMD versus ENTRYPOINT

CMD fornece um comando padrão, totalmente substituível pelos argumentos de docker run. ENTRYPOINT fixa o executável, e os argumentos de docker run são acrescentados a ele.

Com apenas CMD ["echo", "bonjour"], docker run mon-image exibe “bonjour” e docker run mon-image ls / lista a raiz: o comando padrão foi simplesmente substituído. O par ENTRYPOINT + CMD se comporta de modo bem diferente, e é o padrão recomendado para uma aplicação.

FROM alpine:3.20
ENTRYPOINT ["ping"]
CMD ["-c", "3", "localhost"]
docker run --rm mon-image               # ping -c 3 localhost
docker run --rm mon-image -c 5 8.8.8.8  # ping -c 5 8.8.8.8

# ENTRYPOINT só é substituído explicitamente
docker run --rm --entrypoint sh -it mon-image

Resta uma distinção mais sutil, com consequências bem reais: a forma exec versus a forma shell.

# Forma exec (array JSON): o processo é diretamente o PID 1
CMD ["node", "server.js"]

# Forma shell (string): o Docker executa /bin/sh -c "node server.js"
CMD node server.js

Na forma shell, o PID 1 é o sh, e o sh não repassa o SIGTERM ao processo filho. Sua aplicação nunca recebe o sinal de parada: docker stop espera dez segundos e depois a encerra de forma abrupta. Use sempre a forma exec para um processo de longa duração. As variáveis de ambiente não são interpretadas nela; se você precisar delas, diga isso explicitamente:

CMD ["sh", "-c", "node server.js --port=$PORT"]

Multi-stage: construir em uma imagem, entregar em outra

Esta é a maior alavanca disponível para reduzir o tamanho das imagens. A ideia: as ferramentas necessárias para construir uma aplicação — compilador, headers, dependências de desenvolvimento — não têm nada a fazer na imagem que a executa.

Um Dockerfile pode conter vários FROM. Cada um abre um estágio, e o estágio final copia arquivos de um estágio anterior com COPY --from. Tudo o que não é copiado é descartado.

# syntax=docker/dockerfile:1

# ---------- Estágio 1: construção ----------
FROM node:22 AS builder
WORKDIR /app
COPY package.json package-lock.json ./
# Aqui instalamos TUDO: TypeScript, bundlers, ferramentas de teste
RUN npm ci
COPY . .
RUN npm run build          # produz /app/dist

# ---------- Estágio 2: execução ----------
FROM node:22-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production

COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force

# SOMENTE o artefato compilado. O compilador, as fontes
# e as devDependencies ficam para trás.
COPY --from=builder /app/dist ./dist

USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]

O efeito é ainda mais nítido com uma linguagem compilada, em que a imagem final pode dispensar qualquer ambiente de execução:

FROM golang:1.23 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# CGO desativado: binário estático, sem dependência da libc
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /bin/api ./cmd/api

# scratch é literalmente vazio: sem shell, sem libc, sem gerenciador de pacotes
FROM scratch
# Os certificados raiz são necessários para qualquer chamada HTTPS de saída
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /bin/api /api
EXPOSE 8080
ENTRYPOINT ["/api"]

Passa-se de cerca de 1 GB para alguns megabytes, e a superfície de ataque diminui na mesma proporção: sem shell nem utilitários, um atacante que conseguisse executar código teria quase nada à disposição. O reverso é que não se pode mais diagnosticar com docker exec ... sh. As imagens distroless do Google são um meio-termo frequente: sem shell, mas com um ambiente de execução completo.

Os estágios também podem ser usados separadamente, o que torna o mesmo arquivo aproveitável em integração contínua:

docker build --target builder -t mon-api:build .
docker run --rm mon-api:build npm test

Nunca rodar como root

Por padrão, o processo de um contêiner é executado como root. Não é o root do host, mas a combinação desse privilégio com uma falha do kernel ou com um volume montado a partir do host basta para transformar uma vulnerabilidade da aplicação em comprometimento da máquina. A correção se resume a duas instruções.

FROM python:3.12-slim
WORKDIR /app

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

# Usuário de sistema, sem senha nem shell de login
RUN groupadd --system --gid 1001 appgroup \
 && useradd --system --uid 1001 --gid appgroup --no-create-home appuser \
 && chown -R appuser:appgroup /app

# Tudo o que vem a seguir é executado sob esse usuário
USER appuser

EXPOSE 8000
CMD ["python", "-m", "app"]

Em uma base Alpine, as ferramentas são diferentes: addgroup -S -g 1001 appgroup && adduser -S -u 1001 -G appgroup -H appuser. Além disso, algumas imagens oficiais já fornecem uma conta sem privilégios: as imagens node têm um usuário node, bastando escrever USER node.

Dois pontos de atenção. A ordem importa: o USER vem depois das instalações que exigem privilégios, e o chown precede a troca de usuário, caso contrário a aplicação não conseguirá escrever no próprio diretório. E um usuário sem privilégios não pode se associar a uma porta inferior a 1024: faça sua aplicação escutar na 8080 e depois publique-a onde quiser com -p 80:8080.

A verificação se resume a um comando: docker run --rm mon-api:1.0 id deve responder uid=1001(appuser), e não uid=0(root).

As camadas são aditivas: segredos não são apagados

Um erro que parece inofensivo e não é nada inofensivo:

# NE FAITES PAS CECI
COPY .npmrc /root/.npmrc
RUN npm ci
RUN rm /root/.npmrc      # illusoire : le fichier reste dans la couche du COPY

O rm cria uma camada que oculta o arquivo; ele não o remove da imagem. Qualquer pessoa que tenha acesso à imagem pode extrair a camada anterior e recuperar o token. O mesmo raciocínio vale para um ARG TOKEN, visível em docker history.

O BuildKit oferece a solução adequada: um segredo montado apenas durante a execução de um RUN, que não pertence a nenhuma camada.

# syntax=docker/dockerfile:1
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./

# Le fichier existe le temps du RUN, puis disparait sans trace
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci --omit=dev

COPY . .
CMD ["node", "server.js"]
docker build --secret id=npmrc,src=$HOME/.npmrc -t mon-api:1.0 .

Construir, etiquetar, verificar

O nome dado a uma imagem permitirá saber, seis meses depois, qual commit está rodando em produção. Portanto, etiquete no momento do build e verifique três coisas antes de considerar a imagem finalizada.

# Plusieurs tags pour une meme construction, dont un derive du commit Git
docker build -t mon-api:1.2.3 -t mon-api:"$(git rev-parse --short HEAD)" .

# Etiqueter pour un registre prive, prealable au push
docker tag mon-api:1.2.3 registre.exemple.com/equipe/mon-api:1.2.3

docker images mon-api             # la taille est-elle raisonnable ?
docker run --rm mon-api:1.2.3 id  # tourne-t-elle sans privileges ?
docker history mon-api:1.2.3      # ou est passe le poids ?

Pontos-chave

  • Um Dockerfile produz uma sequência de camadas imutáveis, não um script linear. Ordene as instruções da mais estável para a mais volátil: dependências antes do código; caso contrário, você paga uma reinstalação completa a cada caractere modificado.
  • Uma camada invalidada invalida todas as seguintes. O cache é uma cadeia, e um COPY . . colocado muito cedo a quebra sistematicamente.
  • O .dockerignore não é opcional: ele acelera o build, estabiliza o cache e impede que seus .env e suas chaves privadas acabem em uma camada publicável.
  • ENTRYPOINT fixa o executável, CMD fornece argumentos padrão que podem ser sobrescritos. Use a forma exec para que seu processo seja o PID 1 e receba o SIGTERM.
  • O multi-stage é a maior alavanca disponível: as ferramentas de build permanecem no estágio builder, e apenas o artefato é copiado. Ganhos típicos de um fator 10, tanto no tamanho quanto na superfície de ataque.
  • Sempre adicione um USER sem privilégios, depois das instalações e depois do chown, e faça a aplicação escutar em uma porta acima de 1024.
  • Um arquivo removido em uma camada posterior continua podendo ser extraído. Para tokens de build, use RUN --mount=type=secret, nunca COPY seguido de rm, nunca ARG.

Dica: após cada modificação, execute docker history na sua imagem. Uma camada anormalmente grande quase sempre aponta para um cache de pacotes esquecido ou para um COPY amplo demais.

Comentários

Os comentários são alimentados pelo GitHub Discussions

Conecte-se com o GitHub para participar da discussão

Link copiado!