1. Retomando: da aplicação funcionando ao container pronto para produção
Esta série cobriu, até aqui, o suficiente para desenvolver com Docker no dia a dia: conceitos fundamentais, comandos essenciais, Dockerfiles eficientes, rede, volumes e Compose para orquestrar múltiplos serviços. Este último artigo fecha a lacuna entre "funciona no meu Compose local" e "pronto para rodar em produção": imagens menores via multi-stage builds, segurança básica e não negociável, e como tudo isso se integra a um pipeline de CI/CD.
2. O problema que multi-stage builds resolve
Compilar ou empacotar uma aplicação frequentemente exige ferramentas que a aplicação não precisa em tempo de execução: compiladores, headers de desenvolvimento, o próprio código-fonte antes de ser transpilado/buildado. Um Dockerfile ingênuo carrega tudo isso para a imagem final:
# Ruim: ferramentas de build viajam junto para produção
FROM node:20
WORKDIR /app
COPY . .
RUN npm install && npm run build
CMD ["node", "dist/server.js"]
Essa imagem inclui o npm, todo o node_modules (incluindo dependências de desenvolvimento), o código-fonte original e as ferramentas de build — frequentemente centenas de MBs de peso morto que nunca são usados depois que npm run build termina, e que ainda aumentam a superfície de ataque da imagem (mais binários, mais coisa que pode ter vulnerabilidade).
Multi-stage builds resolvem isso permitindo múltiplos blocos FROM no mesmo Dockerfile, onde estágios posteriores copiam seletivamente apenas o que precisam dos anteriores — o restante do estágio de build simplesmente não existe na imagem final:
# Estágio 1: build, com todas as ferramentas necessárias
FROM node:20 AS build
WORKDIR /app
COPY package*.json .
RUN npm ci
COPY . .
RUN npm run build
# Estágio 2: produção, só com o resultado do build
FROM node:20-slim
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY package*.json .
CMD ["node", "dist/server.js"]
A imagem final não contém o código-fonte original, nem as ferramentas de build do primeiro estágio — só o dist/ compilado e as dependências necessárias em runtime. A redução de tamanho costuma ser dramática: não é incomum cair de mais de 1 GB para menos de 150 MB na mesma aplicação.
3. Multi-stage com linguagens compiladas
O ganho é ainda mais evidente com linguagens compiladas, onde o binário final não precisa nem do compilador nem do código-fonte:
# Estágio 1: compila o binário
FROM golang:1.22 AS build
WORKDIR /app
COPY go.mod go.sum .
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o servidor .
# Estágio 2: só o binário, em uma imagem praticamente vazia
FROM scratch
COPY --from=build /app/servidor /servidor
ENTRYPOINT ["/servidor"]
scratch é uma imagem literalmente vazia — sem shell, sem libc, sem utilitário algum. Um binário Go estaticamente linkado (CGO_ENABLED=0) não precisa de nada disso, resultando em uma imagem final de poucos MBs. Isso também é uma vantagem de segurança: uma imagem sem shell nem utilitários tem uma superfície de ataque drasticamente menor, já que não há ferramentas para um invasor usar mesmo que consiga executar algo dentro do container.
4. Segurança: nunca rodar como root
Por padrão, um container roda seus processos como root, a menos que a imagem base ou o Dockerfile digam o contrário. Isso é um risco real: se uma vulnerabilidade na aplicação permitir execução de código arbitrário dentro do container, esse código roda como root — e embora o isolamento de namespaces limite o dano ao host na maioria dos casos, root dentro do container ainda tem acesso irrestrito ao sistema de arquivos e processos daquele container, e certas vulnerabilidades de escape de container (historicamente raras, mas existentes) dependem exatamente de o processo estar rodando como root.
A correção é criar e usar um usuário sem privilégios no Dockerfile:
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN useradd --create-home appuser
COPY --chown=appuser:appuser . .
USER appuser
CMD ["python3", "app.py"]
A instrução USER appuser garante que, a partir dali, todo comando do Dockerfile e todo processo do container em execução roda com esse usuário — não root. Muitas imagens oficiais já vêm com um usuário não-root pronto para uso (por exemplo, node tem o usuário node), bastando declarar USER node em vez de criar um novo.
5. Scan de vulnerabilidades
Mesmo com boas práticas de Dockerfile, a imagem base e as dependências instaladas podem conter vulnerabilidades conhecidas (CVEs) em bibliotecas do sistema. Ferramentas de scan verificam a imagem construída contra bancos de dados de vulnerabilidades conhecidas:
# Docker Scout, integrado ao Docker CLI
docker scout cves minha-app:1.0
# Trivy, alternativa open-source amplamente usada
trivy image minha-app:1.0
A saída lista pacotes com vulnerabilidades conhecidas, sua severidade (baixa, média, alta, crítica) e, quando disponível, a versão que corrige o problema — frequentemente resolvido apenas atualizando a imagem base ou uma dependência específica. Rodar esse scan como parte do pipeline de CI (a seguir) evita que uma imagem com vulnerabilidade crítica conhecida chegue a produção sem que ninguém tenha visto o alerta.
6. Docker em pipelines de CI/CD
O fluxo mais comum em CI é: build da imagem, rodar testes dentro dela (ou contra ela), scan de segurança, e — só se tudo passar — push para o registry e deploy. Um exemplo de pipeline (sintaxe do GitHub Actions, mas o fluxo é o mesmo em qualquer ferramenta de CI):
# .github/workflows/build.yml
name: build-and-push
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build da imagem
run: docker build -t minha-app:${{ github.sha }} .
- name: Scan de vulnerabilidades
run: docker scout cves minha-app:${{ github.sha }} --exit-code --only-severity critical,high
- name: Login no registry
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login registry.exemplo.com -u usuario --password-stdin
- name: Push da imagem
run: |
docker tag minha-app:${{ github.sha }} registry.exemplo.com/minha-app:${{ github.sha }}
docker push registry.exemplo.com/minha-app:${{ github.sha }}
Duas práticas valem destaque nesse pipeline:
-
Tag pela SHA do commit (
${{ github.sha }}), nãolatest— cada build gera uma imagem rastreável a um commit exato, essencial para saber o que está rodando em produção e para reverter (rollback) para uma versão anterior com confiança. -
--exit-codeno scan — faz o comando retornar código de saída diferente de zero se encontrar vulnerabilidade crítica/alta, o que interrompe o pipeline automaticamente em vez de depender de alguém ler o relatório manualmente.
O cache de build (Artigo 3 desta série) também importa em CI: runners efêmeros normalmente começam sem cache local, então ferramentas como docker buildx com cache remoto (--cache-from/--cache-to apontando para o registry) evitam que cada execução do pipeline reconstrua tudo do zero.
7. Conclusão da série
Ao longo desses seis artigos, esta série foi do "o que é um container" até um pipeline de CI/CD publicando imagens seguras e enxutas: o problema que o Docker resolve e seus conceitos fundamentais, os comandos do dia a dia, como escrever Dockerfiles eficientes aproveitando cache de build, como containers se comunicam via rede e persistem dados via volumes, como Compose orquestra tudo isso localmente, e finalmente como levar isso a produção com multi-stage builds, containers não-root e scan de vulnerabilidades integrado ao pipeline. A partir daqui, a base está posta para explorar orquestração em escala (Kubernetes) e tópicos mais específicos de infraestrutura — mas o Docker isolado, bem usado, já resolve a enorme maioria dos casos do dia a dia de desenvolvimento e deploy de aplicações.
Imagem de capa: Logo oficial do Docker — Wikimedia Commons, fonte: docker.com/company/newsroom/media-resources
Referências:
Top comments (0)