Escrevendo um Dockerfile do Zero

[94] Escrevendo um Dockerfile do Zero

Escrevendo um Dockerfile do zero: o papel de cada instrução, por que a ordem das camadas determina o tempo de build, o contexto de build e o .dockerignore, multi-stage builds para imagens enxutas e seguras, e o uso de ENV, ARG, USER e HEALTHCHECK.
DevOps

12 min de leitura

Usar imagens prontas do Docker Hub é o ponto de partida. O passo seguinte — e o mais importante para qualquer profissional de DevOps — é saber construir imagens customizadas que empacotam aplicações reais.

O Dockerfile é o arquivo de texto que descreve, passo a passo, como uma imagem deve ser construída. Cada instrução do Dockerfile cria uma camada na imagem final. Entender como essas camadas funcionam é a diferença entre uma imagem bem construída e uma imagem pesada, lenta de construir e difícil de manter.

Estrutura Básica de um Dockerfile

Um Dockerfile mínimo para uma aplicação Node.js:

FROM node:20-alpine

WORKDIR /app

COPY package*.json ./

RUN npm ci

COPY . .

EXPOSE 3000

CMD ["node", "src/index.js"]

Cada instrução tem um papel específico.

FROM — define a imagem base. Todo Dockerfile começa aqui. A imagem base fornece o sistema operacional e, neste caso, o runtime do Node.js. O sufixo alpine indica uma variante baseada no Alpine Linux — uma distribuição minimalista de apenas 5MB que resulta em imagens significativamente menores.

WORKDIR — define o diretório de trabalho dentro do container. Todos os comandos subsequentes são executados a partir desse diretório. Se o diretório não existir, o Docker o cria automaticamente.

COPY — copia arquivos do contexto de build (a máquina local) para dentro da imagem. O primeiro argumento é a origem, o segundo é o destino dentro da imagem.

RUN — executa um comando durante a construção da imagem. O resultado é persistido na camada gerada. Usado para instalar dependências, compilar código, criar diretórios.

EXPOSE — documenta que o container escuta na porta indicada. É apenas documentação — não publica a porta automaticamente. A publicação acontece no docker run -p.

CMD — define o comando padrão executado quando o container inicia. Deve ser especificado no formato de array JSON (forma exec), não como string.

Construindo e Testando a Imagem

Com o Dockerfile criado na raiz do projeto:

# Constrói a imagem com uma tag
docker build -t minha-app:1.0.0 .

# O ponto final indica que o contexto de build é o diretório atual

Acompanhar a saída do build é valioso — cada linha corresponde a uma instrução do Dockerfile e mostra o hash da camada gerada:

[+] Building 12.4s (10/10) FINISHED
 => [internal] load build definition from Dockerfile
 => [1/6] FROM node:20-alpine
 => [2/6] WORKDIR /app
 => [3/6] COPY package*.json ./
 => [4/6] RUN npm ci
 => [5/6] COPY . .
 => exporting to image

Rodando e testando:

docker run -d -p 3000:3000 --name minha-app minha-app:1.0.0
curl http://localhost:3000
docker logs minha-app

A Ordem das Instruções Importa — e Muito

O Docker utiliza um mecanismo de cache por camada: se uma instrução e todos os seus predecessores não mudaram desde o último build, o Docker reutiliza a camada cacheada em vez de reconstruí-la. Isso acelera enormemente o ciclo de desenvolvimento.

O problema é que qualquer mudança invalida o cache de todas as camadas subsequentes. Por isso a ordem das instruções no Dockerfile não é arbitrária — ela deve colocar o que muda com menos frequência primeiro e o que muda com mais frequência por último.

Considere a diferença entre essas duas abordagens:

Ordem ineficiente — invalida o cache de dependências a cada mudança de código:

FROM node:20-alpine
WORKDIR /app
COPY . .               # copia tudo, incluindo o código
RUN npm ci             # instala dependências APÓS o código
CMD ["node", "src/index.js"]

Ordem eficiente — dependências são cacheadas separadamente do código:

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./  # copia apenas o manifesto de dependências
RUN npm ci             # instala dependências — esta camada só é reconstruída
                       # quando package.json muda
COPY . .               # copia o código — muda frequentemente, mas não invalida
                       # a camada de dependências acima
CMD ["node", "src/index.js"]

Na prática, a segunda abordagem reduz o tempo de build de dezenas de segundos para poucos segundos na maioria dos ciclos de desenvolvimento.

O Arquivo .dockerignore

Assim como o .gitignore evita que arquivos desnecessários entrem no repositório, o .dockerignore evita que arquivos desnecessários entrem no contexto de build — o conjunto de arquivos enviados ao Docker daemon durante o docker build.

# .dockerignore
node_modules
.git
.gitignore
*.log
.env
.env.*
dist
coverage
README.md
Dockerfile
.dockerignore

Sem o .dockerignore, a pasta node_modules inteira seria enviada ao daemon e depois sobrescrita pelo npm ci — um desperdício considerável de tempo e banda.

Multi-stage Builds: Imagens Enxutas para Produção

Um padrão avançado mas essencial: o multi-stage build permite usar múltiplas imagens base em um único Dockerfile, copiando apenas os artefatos necessários de cada estágio para o próximo. O resultado é uma imagem final que contém apenas o necessário para executar a aplicação — sem compiladores, sem ferramentas de build, sem dependências de desenvolvimento.

Exemplo para uma aplicação Node.js com TypeScript:

# ── Estágio 1: Build ──────────────────────────────
FROM node:20-alpine AS builder

WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY . .
RUN npm run build        # compila TypeScript para JavaScript em /app/dist


# ── Estágio 2: Produção ───────────────────────────
FROM node:20-alpine AS production

WORKDIR /app

# Copia apenas o manifesto e instala somente dependências de produção
COPY package*.json ./
RUN npm ci --only=production

# Copia apenas o código compilado do estágio anterior
COPY --from=builder /app/dist ./dist

# Usuário não-root por segurança
USER node

EXPOSE 3000

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

O estágio builder tem todas as ferramentas de desenvolvimento. O estágio production começa do zero e importa apenas o resultado compilado. Uma aplicação que geraria uma imagem de 800MB pode resultar em menos de 100MB com essa técnica.

Instruções Complementares

ENV — define variáveis de ambiente que estarão disponíveis durante o build e em tempo de execução:

ENV NODE_ENV=production
ENV PORT=3000

ARG — define variáveis disponíveis apenas durante o build, não em tempo de execução. Útil para passar tokens de autenticação durante a construção sem que fiquem na imagem final:

ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc \
    && npm ci \
    && rm .npmrc

HEALTHCHECK — define como o Docker deve verificar se o container está saudável:

HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
  CMD curl -f http://localhost:3000/health || exit 1

ENTRYPOINT vs CMD — a distinção é sutil mas importante. O CMD define argumentos padrão que podem ser substituídos ao rodar o container. O ENTRYPOINT define o executável principal, que não é substituído facilmente:

# Com ENTRYPOINT, o container sempre usa 'node' como executável
# O CMD fornece o argumento padrão, substituível no docker run
ENTRYPOINT ["node"]
CMD ["dist/index.js"]
# Roda com o argumento padrão
docker run minha-app

# Substitui o CMD para rodar outro arquivo
docker run minha-app dist/worker.js

Um Dockerfile Completo e Comentado

# Imagem base com Node.js 20 em Alpine Linux
FROM node:20-alpine AS base

# Instala dependências do sistema necessárias
RUN apk add --no-cache tini

# ── Estágio de dependências ───────────────────────
FROM base AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci

# ── Estágio de build ──────────────────────────────
FROM deps AS builder
COPY . .
RUN npm run build

# ── Estágio de produção ───────────────────────────
FROM base AS production

WORKDIR /app

# Variáveis de ambiente de produção
ENV NODE_ENV=production
ENV PORT=3000

# Dependências apenas de produção
COPY package*.json ./
RUN npm ci --only=production

# Código compilado
COPY --from=builder /app/dist ./dist

# Usuário não-root — boa prática de segurança
USER node

# Porta exposta
EXPOSE 3000

# Healthcheck
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
  CMD wget -qO- http://localhost:3000/health || exit 1

# tini como init process — gerencia sinais corretamente
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "dist/index.js"]

O Que Vem a Seguir

No próximo artigo serão abordados volumes e redes no Docker — como persistir dados além do ciclo de vida de um container e como fazer containers diferentes se comunicarem entre si. São os dois temas que completam o entendimento do Docker como plataforma de execução de serviços.

Referências para Aprofundamento

Documentação oficial

Boas práticas

Prática

Exercícios

Exercício 1

O que a instrução EXPOSE 3000 faz — e, principalmente, o que ela não faz?

Ver resposta

✓ Resposta: Ela apenas documenta que o container escuta naquela porta, gravando um metadado na imagem que ferramentas e quem faz docker inspect conseguem ler. O que ela não faz é publicar a porta: nada se torna acessível a partir do host por causa dela. A publicação acontece exclusivamente no docker run -p 3000:3000, ou no mapeamento equivalente do Compose. O engano é frequente e desorienta bastante — a pessoa escreve EXPOSE 3000, sobe o container sem -p, recebe conexão recusada no navegador e conclui que a aplicação quebrou, quando ela está funcionando perfeitamente e apenas inalcançável.

Exercício 2

Por que COPY package*.json ./ seguido de RUN npm ci vem antes de COPY . .? O que muda concretamente no dia a dia se a ordem for invertida?

Ver resposta

✓ Resposta: Por causa do cache por camada: qualquer instrução que muda invalida o cache dela e de todas as seguintes. Copiando apenas o manifesto primeiro, a camada do npm ci só é reconstruída quando o package.json muda — o que é raro. O código, que muda a cada commit, entra depois, e sua alteração não alcança a camada de dependências. Com a ordem invertida, o COPY . . vem antes e qualquer linha alterada em qualquer arquivo invalida tudo o que vem depois: o npm ci reinstala o projeto inteiro do zero. A diferença no dia a dia é sair de poucos segundos por build para dezenas de segundos, repetidos dezenas de vezes ao dia.

Exercício 3

O que o .dockerignore evita? Por que node_modules e .env merecem estar nele — e por motivos bem diferentes?

Ver resposta

✓ Resposta: Ele reduz o contexto de build, que é o conjunto de arquivos enviado ao daemon antes de o build sequer começar. O node_modules está ali por desperdício: seria transferido inteiro e logo sobrescrito pelo npm ci — e, pior, se a máquina local usar outro sistema operacional, binários compilados para a plataforma errada podem acabar dentro da imagem e quebrá-la de forma difícil de diagnosticar. O .env está ali por segurança: sem a exclusão, o COPY . . leva suas credenciais para dentro da imagem. E imagem vai para registro — o segredo fica gravado em uma camada, recuperável mesmo que você apague o arquivo em uma instrução posterior.

Exercício 4

No multi-stage build, o que fica em cada estágio? Por que a imagem final encolhe tanto, e qual o ganho além do tamanho?

Ver resposta

✓ Resposta: O estágio builder carrega tudo que é necessário para construir: dependências de desenvolvimento, compilador TypeScript, ferramentas de build. O estágio production parte de uma base limpa, instala apenas dependências de produção e importa somente o resultado, via COPY --from=builder /app/dist ./dist. A imagem final encolhe porque as camadas do primeiro estágio nunca fazem parte dela — não são removidas depois, simplesmente não são incluídas. O ganho além do tamanho é de segurança: sem compilador, gerenciador de pacotes de desenvolvimento e ferramentas auxiliares em produção, sobra bem menos superfície para quem conseguir executar algo dentro do container.

Exercício 5

Qual a diferença entre ENV e ARG? E por que, no exemplo do NPM_TOKEN, a criação e a remoção do .npmrc acontecem dentro de um único RUN?

Ver resposta

✓ Resposta: ENV define uma variável disponível durante o build e em tempo de execução, e ela fica gravada na imagem. ARG existe apenas durante o build. Por isso o token usa ARG: ele não deve sobreviver na imagem final. Só que isso, sozinho, não basta — o arquivo .npmrc gerado com o token cria uma camada. Se a criação e a remoção estivessem em instruções RUN separadas, a camada intermediária contendo o arquivo continuaria fazendo parte da imagem, e o token seria recuperável por quem examinasse as camadas, ainda que ele não apareça no sistema de arquivos final. Encadear tudo com && em um único RUN faz o arquivo nascer e morrer dentro da mesma camada.

Exercício 6

O que USER node e HEALTHCHECK resolvem, cada um? Que informação o healthcheck dá que o docker ps sozinho não daria?

Ver resposta

✓ Resposta: USER node faz o processo rodar como usuário não-root dentro do container. Por padrão containers rodam como root, de modo que uma falha explorada na aplicação já nasce com privilégio máximo lá dentro — situação especialmente grave quando há volumes do host montados. O HEALTHCHECK resolve outra coisa: ele distingue rodando de saudável. Um processo pode estar de pé, aparecendo como Up no docker ps, e ter parado de responder — travado em um deadlock, sem conexão com o banco, com a fila de requisições estourada. Com o healthcheck, o container passa ao estado unhealthy, e orquestradores usam esse sinal para reiniciá-lo ou removê-lo do balanceamento. Sem ele, o tráfego continua sendo entregue a um container que não atende ninguém.

Comentários

Mais em DevOps

Deploy Automático para Servidores com GitHub Actions
Deploy Automático para Servidores com GitHub Actions

Os três modelos de deploy — push, pull e por agente — e quando cada um se…

Bitbucket e o Ecossistema Atlassian
Bitbucket e o Ecossistema Atlassian

A plataforma que não compete pelo melhor repositório Git, e sim por conversar…

Pipeline com GitHub Actions: Build, Test e Deploy Automático
Pipeline com GitHub Actions: Build, Test e Deploy Automático

Um pipeline completo com GitHub Actions: jobs de lint, testes unitários e de…