O problema: "na minha máquina funciona"
Uma aplicação depende de um ambiente: uma versão específica do Node, certas variáveis, bibliotecas do sistema... Se esse ambiente difere entre seu notebook e o servidor, surgem erros difíceis de reproduzir. Os contêineres resolvem isso empacotando a aplicação junto com seu ambiente para que ela seja executada igual em qualquer lugar.
Imagem vs contêiner
É a distinção mais importante do tema:
- Uma imagem é um modelo imutável e somente leitura: o sistema de arquivos, as dependências e o comando de inicialização. É como a classe.
- Um contêiner é uma instância em execução de uma imagem, com sua própria camada de escrita e processos vivos. É como o objeto.
De uma mesma imagem você pode lançar muitos contêineres. A imagem é construída uma vez; os contêineres são criados, parados e destruídos continuamente.
O Dockerfile
Uma imagem é definida com um Dockerfile: uma receita de instruções que são executadas de cima para baixo.
# Imagem base da qual partimos
FROM node:20-alpine
# Pasta de trabalho dentro do contêiner
WORKDIR /app
# Copiamos primeiro os manifestos (para aproveitar o cache, veja abaixo)
COPY package.json package-lock.json ./
RUN npm ci
# Copiamos o resto do código-fonte
COPY . .
# Comando que é executado ao iniciar o contêiner
CMD ["node", "server.js"]
As instruções principais:
FROM: imagem base de partida.WORKDIR: diretório de trabalho dentro da imagem.COPY: copia arquivos do host para a imagem.RUN: executa um comando ao construir a imagem (instalar, compilar).CMD: define o comando que roda ao iniciar o contêiner.
Camadas e cache de build
Cada instrução gera uma camada. O Docker faz cache de cada camada: se uma
instrução e tudo que vem antes não mudaram, ele reutiliza a camada em vez de
reexecutá-la. Por isso copiamos package.json e fazemos npm ci antes de
copiar o resto do código: enquanto as dependências não mudam, essa camada
(a lenta) é reutilizada mesmo que você edite seu código-fonte.
Regra prática: ponha o que muda pouco em cima e o que muda muito embaixo.
Builds multi-stage
Para construir você precisa de ferramentas (compiladores, dev-dependencies) que não quer na imagem final. Um build multi-stage usa uma etapa para construir e outra, limpa e pequena, para executar:
# Etapa 1: construir
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Etapa 2: imagem final, somente com o necessário para executar
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
CMD ["node", "dist/server.js"]
A imagem final não contém o código-fonte nem as dev-dependencies: apenas o
resultado do build (dist) e as dependências de produção. Resultado:
imagens mais pequenas, rápidas de implantar e com menor superfície de
ataque.
Vários serviços: docker-compose
Uma app real combina vários contêineres (API, banco de dados, cache...). docker-compose os descreve e orquestra em um único arquivo:
services:
api:
build: .
ports:
- "3000:3000"
environment:
DATABASE_URL: postgres://user:pass@db:5432/app
depends_on:
- db
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: pass
volumes:
- db-data:/var/lib/postgresql/data
volumes:
db-data:
Com docker compose up você sobe toda a pilha de uma vez. Os serviços se veem
entre si pelo seu nome (db), e os volumes persistem dados mesmo que
o contêiner seja destruído.