DevOps

Escrevendo um Dockerfile do Zero Já leu

9 min de leitura

Escrevendo um Dockerfile do Zero
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 é

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

Comentários

Mais em DevOps

Kubernetes em Produção: Segurança, GitOps e Deploys Avançados
Kubernetes em Produção: Segurança, GitOps e Deploys Avançados

Criar um cluster EKS e fazer uma aplicação rodar nele é relativamente simples...

Artigo 26 — Seus Primeiros Recursos na AWS com Terraform
Artigo 26 — Seus Primeiros Recursos na AWS com Terraform

Artigo 26 — Seus Primeiros Recursos na AWS com Terraform Colocando Infraestru...

Shell Script do Zero
Shell Script do Zero

Até aqui foram aprendidos comandos que se executam um de cada vez. She...