Docker Completo: do zero ao production-ready
Docker resolveu um problema velho e chato: “na minha máquina funciona”. Antes dele, subir uma aplicação em outro servidor era um ritual — instalar a versão certa da linguagem, as libs do sistema, configurar permissões, rezar. Container empacota o código junto com todo o ambiente que ele precisa, gera um artefato imutável (a imagem) e executa esse artefato em qualquer lugar que tenha um runtime Docker. Este guia vai do porquê dos containers existirem até Dockerfiles profissionais, multi-stage, redes, volumes, segurança e otimização. É denso de propósito — leia sem pressa, cole os comandos no terminal, teste.
De onde veio tudo isso: uma linha do tempo de 25 anos
Docker não inventou containers. Inventou a experiência de uso. A ideia de isolar processos é velha:
A revolução do Docker não foi técnica — foi de interface. Antes dele, usar LXC exigia conhecimento profundo de namespaces e cgroups. Docker entregou três comandos (build, run, push), um formato de imagem versionável e um registry público (Docker Hub). Desenvolvedores adotaram em massa.
Container vs VM — a diferença que todo dev precisa entender
| Critério | Container | VM |
|---|---|---|
| Kernel | Compartilhado com o host | Próprio (duplica kernel) |
| Boot | 50-500 ms | 30 s - vários minutos |
| Tamanho | ~10 MB - 500 MB | ~500 MB - 20 GB |
| Overhead de CPU/RAM | Quase zero | Significativo (5-15%) |
| Densidade | Centenas por host | Dezenas por host |
| Isolamento | Namespaces + cgroups (software) | Hardware (ring -1) |
| Multi-OS | Só mesmo kernel (Linux/Win separados) | Qualquer OS em cima do host |
| Uso típico | Microserviços, CI, dev envs | Tenants hostis, compliance, legacy |
Resumo: container é isolamento feito em software pelo kernel do Linux (namespaces isolam o que o processo vê; cgroups limitam o que ele consome). VM é isolamento feito em hardware pelo hypervisor. Por isso container é leve e VM é forte — use VM quando precisar rodar código de terceiros que você não confia; use container pra tudo mais.
A arquitetura real do Docker
Quando você digita docker run, não é um binário mágico. Tem uma cadeia de processos colaborando:
Por que isso importa: quando Kubernetes “removeu o Docker” em 2022, o que ele removeu foi o dockerd. Containers continuam rodando via containerd + runc. Imagens feitas com continuam funcionando — elas são OCI, não “Docker”.
Os 4 objetos que você precisa dominar
| Objeto | O que é | Ciclo de vida |
|---|---|---|
| Image | Template imutável de um filesystem + metadata (cmd, env, ports) | Build → push → pull → run |
| Container | Instância em execução (ou parada) de uma imagem + camada R/W | create → start → stop → rm |
| Volume | Armazenamento persistente gerenciado pelo Docker (fora da imagem) | create → mount em N containers → rm |
| Network | Rede virtual que conecta containers (com DNS embutido) | create → attach → disconnect → rm |
Uma forma útil de pensar: imagem é uma classe, container é um objeto instanciado. Volume é estado persistente injetado; network é o “barramento” que permite containers conversarem por nome.
Imagens: o truque das camadas (UnionFS)
Uma imagem Docker não é um blob único. É uma pilha de camadas read-only, cada uma com um diff do filesystem, montadas por cima via UnionFS (overlay2 é o driver padrão hoje). Quando o container inicia, Docker coloca uma camada read-write no topo (“container layer”). Mudanças no runtime ficam lá.
CMD ["nginx","-g","daemon off;"]COPY nginx.conf /etc/nginx/RUN apt-get install nginxFROM debian:12-slimConsequência prática: se 10 containers usam a mesma imagem base, o Docker só armazena a base uma vez no disco. A mesma lógica vale para o registry — um só baixa camadas que você ainda não tem localmente. Isso é o que faz “rebuild + push” ser rápido quando você mudou só a última camada.
Dockerfile — o DNA da imagem
Dockerfile é um script declarativo que descreve como construir a imagem. Cada instrução de topo de linha gera uma camada (exceto FROM, LABEL, ARG, etc., que afetam metadata). As instruções mais usadas:
# ❌ RUIM — 3 camadas, 3 passos de cache invalidados por coisas que não deveriam
FROM ubuntu:24.04
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get install -y git
# ✅ BOM — 1 camada, limpeza de cache do apt no final, menor
FROM ubuntu:24.04
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl git \
&& rm -rf /var/lib/apt/lists/*Layer caching — a diferença entre build de 3s e 3min
Docker tenta reusar cache de cada instrução. Regra de ouro: coisas que mudam pouco primeiro; coisas que mudam sempre por último. Um Dockerfile comum de Node mal escrito reinstala node_modules a cada commit porque o cache do RUN npm ci é invalidado antes.
# ❌ Reinstala dependências a cada mudança de código
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm ci
CMD ["node", "server.js"]
# ✅ Cache-friendly — deps só reinstalam quando package.json muda
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
CMD ["node", "server.js"]Como ver o que invalidou o cache: rode — o BuildKit (default hoje) mostra CACHED vs executado para cada step.
Multi-stage builds — imagens pequenas de verdade
A ideia: um stage para compilar, outro só para rodar. O stage final recebe apenas os artefatos, sem toolchain, cache do gerenciador de pacotes ou fontes. Exemplos canônicos:
# ─── Node.js / TypeScript ──────────────────────────────────────
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]# ─── Go (imagem final "scratch" = ~6 MB) ────────────────────────
FROM golang:1.23-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /bin/app ./cmd/api
FROM scratch
COPY --from=builder /bin/app /app
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
EXPOSE 8080
ENTRYPOINT ["/app"]# ─── Python (com distroless — sem shell, sem apt, ~40 MB) ───────
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
FROM gcr.io/distroless/python3-debian12
WORKDIR /app
COPY --from=builder /root/.local /root/.local
COPY . .
ENV PATH=/root/.local/bin:$PATH
CMD ["server.py"]Ganhos reais: uma API Node típica sai de ~1.2 GB (node + devDeps + source) para 150-250 MB (node:alpine) ou ~100 MB (distroless). Uma API Go sai de 900 MB para 6-12 MB (scratch). Menor imagem = pull mais rápido no deploy, superfície de ataque menor, menos CVEs pra triar.
Escolhendo a imagem base
| Base | Tamanho | Tem shell? | Quando usar |
|---|---|---|---|
| ubuntu / debian | ~80 MB | Sim (bash, apt) | Dev/debug, apps que exigem libs glibc completas |
| *-slim | ~30-50 MB | Sim, enxuto | Default seguro pra prod quando precisa de glibc |
| alpine | ~5-10 MB | Sim (ash, apk) | Prod, mas cuidado: usa musl libc (quebra libs com binários glibc) |
| distroless | ~20-50 MB | Não | Prod — maior segurança, sem shell pra atacante explorar |
| scratch | 0 MB | Não | Binários estáticos (Go, Rust) — o menor possível |
Alpine + Python/Node nativo = dor. Alpine usa musl libc, não glibc. Libs Python com bindings C (, , ) e algumas libs Node (, ) podem precisar recompilar ou até quebrar. Em produção Node/Python, costuma ser mais previsível que .
CMD vs ENTRYPOINT — o ponto que 90% erra
| Instrução | Propósito | Sobrescrito por docker run? |
|---|---|---|
| CMD | Comando default | Sim — docker run imagem <novo-cmd> substitui |
| ENTRYPOINT | Binário fixo da imagem | Não (exceto com --entrypoint) |
| ENTRYPOINT + CMD | Binário + args default | Args sobrescritos, binário mantido |
# Padrão profissional: ENTRYPOINT é o binário, CMD são flags default
ENTRYPOINT ["./myapp"]
CMD ["--port=8080", "--log-level=info"]
# docker run img → roda: ./myapp --port=8080 --log-level=info
# docker run img --log=debug → roda: ./myapp --log=debug
# docker run --entrypoint=sh img → abre shell (útil pra debug)Gotcha do shell form: (sem colchetes) roda . O sh vira PID 1 e não repassa SIGTERM ao node — seu container demora 10s pra morrer no . Sempre use a forma exec (JSON array): .
Docker Compose — multi-container sem sofrimento
Quando a aplicação tem mais de um container (app + db + cache), escrever docker run à mão vira tortura. Compose descreve tudo num YAML declarativo e sobe com docker compose up.
# compose.yaml — app Node + Postgres + Redis com healthchecks e volumes
services:
web:
build: .
ports: ["3000:3000"]
environment:
DATABASE_URL: postgres://app:secret@db:5432/app
REDIS_URL: redis://cache:6379
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
POSTGRES_DB: app
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 5s
retries: 5
cache:
image: redis:7-alpine
volumes:
- redisdata:/data
volumes:
pgdata:
redisdata:DNS interno: no Compose, cada serviço é um hostname. A app Node conecta em — não precisa de IP. O Docker mantém um DNS embutido na rede bridge que resolve nomes de serviço.
Redes — como containers se falam (e se isolam)
| Driver | Uso | Quando escolher |
|---|---|---|
| bridge | Default. Rede virtual isolada no host. | Dev local, Compose, single-host |
| host | Container usa a stack de rede do host. | Quando precisa de perf máxima ou monitorar rede do host |
| none | Sem rede. | Batch jobs isolados, compliance |
| overlay | Rede distribuída entre múltiplos hosts. | Swarm ou multi-host (hoje quase sempre K8s resolve isso) |
| macvlan | Container com IP/MAC próprios na rede física. | Integrar container como se fosse um host físico |
# Criar rede bridge customizada (com DNS entre containers por nome)
docker network create backend
# Subir containers nela
docker run -d --name db --network backend postgres:16-alpine
docker run -d --name api --network backend -p 3000:3000 my-api:1.0
# Dentro do container api, conectar em "db:5432" funciona por DNS.
# O bridge default (sem --network) NÃO tem DNS por nome — sempre crie uma rede.-p host:container não “abre porta na rede” — ele cria um NAT no kernel do host. O container continua só na rede bridge interna. Isso importa quando você tem firewall: em algumas configs, bypassa o ufw/iptables do host porque Docker mexe direto no netfilter.
Volumes — onde o estado persiste
| Tipo | Origem | Quando usar |
|---|---|---|
| Named volume | Gerenciado pelo Docker em /var/lib/docker/volumes/ | Prod — dados do db, cache, uploads |
| Bind mount | Caminho explícito do host | Dev — montar código-fonte pra hot reload |
| tmpfs | Memória (não persiste) | Segredos temporários, caches de sessão |
# Named volume (recomendado em prod)
docker run -d --name db -v pgdata:/var/lib/postgresql/data postgres:16-alpine
# Bind mount (dev — código refletindo no container)
docker run -d --name dev -v "$(pwd):/app" -w /app node:20 npm run dev
# tmpfs (dados sensíveis que não devem tocar o disco)
docker run -d --tmpfs /run/secrets:size=10M my-app:1.0
# Backup de um named volume
docker run --rm -v pgdata:/data -v "$(pwd):/backup" alpine \
tar czf /backup/pgdata-$(date +%F).tgz -C /data .Regra de ouro: qualquer coisa que você não pode perder fica em volume. A camada R/W do container é descartada no . Sobrescrever volume com bind mount por acidente em produção é um dos desastres mais comuns — cuidado com caminhos relativos em .
Registries — onde as imagens vivem
| Registry | Hospedeiro | Quando usar |
|---|---|---|
| Docker Hub | docker.io | Images públicas, OSS, prototipagem |
| GHCR | ghcr.io (GitHub) | Projetos hospedados no GitHub, integra com Actions |
| ECR | AWS | Prod na AWS — IAM, scanning, regional |
| GCR / Artifact Registry | Google Cloud | Prod no GCP |
| ACR | Azure | Prod no Azure |
| Harbor / self-hosted | Seu cluster | Air-gapped, compliance, custo |
# Tag + push pro GHCR
docker build -t ghcr.io/me/app:1.2.0 -t ghcr.io/me/app:latest .
echo "$GH_TOKEN" | docker login ghcr.io -u me --password-stdin
docker push ghcr.io/me/app:1.2.0
docker push ghcr.io/me/app:latestTag imutável: evite fazer deploy baseado em — é uma referência móvel. Prod deve apontar para tags versionadas () ou digests (), que são criptograficamente imutáveis.
Segurança — checklist mínimo de produção
# Padrão production-hardened (Node)
FROM node:20-alpine AS runner
WORKDIR /app
# Usuário não-root
RUN addgroup -S app && adduser -S app -G app
COPY --chown=app:app . .
RUN npm ci --omit=dev
USER app
EXPOSE 3000
# Healthcheck pra orquestrador reiniciar container travado
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
ENTRYPOINT ["node"]
CMD ["server.js"]CLI essencial — os comandos que você usa toda semana
# Imagens
docker build -t app:1.0 . # build do diretório atual
docker images # lista imagens locais
docker tag app:1.0 ghcr.io/me/app:1.0
docker push ghcr.io/me/app:1.0
docker pull nginx:1.27-alpine
docker rmi app:0.9 # remove imagem
# Containers
docker run -d --name web -p 80:80 nginx:1.27-alpine
docker ps # containers em execução
docker ps -a # inclui parados
docker logs -f --tail 100 web # logs ao vivo
docker exec -it web sh # shell dentro
docker stop web && docker rm web # para e remove
docker restart web
# Inspeção / debug
docker inspect web # JSON com tudo (rede, mounts, env)
docker stats # top de CPU/mem por container
docker top web # processos dentro do container
docker diff web # o que mudou na camada R/W
docker port web # mapa de portas
# Limpeza (cuidado em prod — apaga de verdade)
docker system df # quanto disco está sendo usado
docker system prune -a --volumes # apaga imagens, containers, volumes não usados
docker builder prune # só o cache do BuildKitBuildKit — o build moderno
Desde o Docker 23, BuildKit é o builder default. Habilita recursos que o build antigo não tinha:
# Cache mount: npm ci fica MUITO mais rápido em rebuilds
# syntax=docker/dockerfile:1.7
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev
COPY . .
CMD ["node", "server.js"]Armadilhas comuns (que você vai encontrar mais cedo ou mais tarde)
1. PID 1 e sinais. Se você roda (shell form), o bash vira PID 1 e não propaga SIGTERM. Container demora 10s pra morrer. Use forma exec ou adicione um init como (flag do ).
2. Zombies em Node/Python. Mesma causa do PID 1. Node não faz em filhos órfãos. Rode com ou use como ENTRYPOINT.
3. node_modules do host vazando pro container. Num bind mount de dev, sobrescreve com o do Mac (que pode ter binários macOS). Solução: volume anônimo em cima — .
4. .dockerignore esquecido. Sem ele, o Docker manda , , e dumps de db pro contexto de build. Lentidão + potencial vazamento de segredo.
5. localhost dentro do container não é localhost do host. App dentro do container que faz fetch em está chamando ele mesmo. Pra acessar o host, use (Docker Desktop) ou .
6. Imagens multi-arch e Mac M-series. Uma imagem só para roda em Mac M via emulação Rosetta — lenta. Use com e publique multi-arch.
Cenários de decisão
📋 API Node 20 para produção: qual base usar?
Slim tem glibc (compatível com toda lib nativa do npm), é ~60 MB, e num multi-stage o stage final só recebe dist + node_modules --omit=dev. Prod roda em ~180 MB, previsível.
Alt: node:20-alpine —
Alt: distroless —
📋 Preciso de hot reload no dev, mas o código está no Mac e o container em Linux
-v $(pwd):/app monta o código (hot reload funciona). -v /app/node_modules cria volume anônimo em cima, preservando os módulos instalados no build (evita conflito de binários Mac vs Linux).
Alt: Sem bind, rebuild a cada mudança —
📋 Meu CI demora 8 min no build de uma API Python
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt reusa cache entre builds. Com COPY requirements.txt antes do COPY . ., deps só reinstalam quando requirements muda.
Alt: Só multi-stage —
Perguntas típicas
❓ Docker vai morrer porque Kubernetes “removeu” o Docker?
❓ Qual a diferença entre COPY e ADD?
❓ Por que minha imagem está 1.5 GB se meu binário tem 20 MB?
❓ É seguro rodar docker em prod?
❓ Preciso aprender Docker antes de Kubernetes?
Take-aways. (1) Container é processo isolado por namespaces+cgroups, não VM. (2) Imagem é pilha de camadas — cacheie agressivamente (muda-raro primeiro, muda-sempre depois). (3) Multi-stage é obrigatório em prod — separa builder do runner. (4) Use imagem base certa (slim/alpine/distroless/scratch) pro seu caso; alpine quebra libs nativas com frequência. (5) ENTRYPOINT pro binário, CMD pros args default; sempre forma exec. (6) Volume pra tudo que não pode perder; bind mount só pra dev. (7) Rede bridge customizada dá DNS entre containers; porta com -p é NAT, não exposição real. (8) Segurança = não-root, read-only FS, drop caps, scan de CVE, secrets via BuildKit. O próximo módulo (Kubernetes) orquestra tudo isso em escala.
Próximos passos sugeridos
Discussão
Carregando…