Pular para o conteúdo

Containers - Docker e Docker Compose

  • Imagem — artefato imutável, versionado por tags, composto de camadas somente-leitura empilhadas. É o “template”.
  • Container — instância em execução de uma imagem, com uma fina camada de escrita no topo (copy-on-write). É efêmero: o que grava dentro dele some quando é removido, salvo se persistido em volume.

Uma imagem é a classe; o container é o objeto. Da mesma imagem você instancia N containers idênticos.

Cada instrução do Dockerfile (FROM, COPY, RUN…) gera uma camada. O Docker cacheia camadas: se nada mudou naquele passo e nos anteriores, ele reaproveita a camada em vez de reconstruí-la. A ordem das instruções, portanto, é decisão de performance:

  • Copie primeiro o que muda pouco (arquivos de dependências: pom.xml, build.gradle) e resolva as dependências. Só depois copie o código-fonte, que muda a cada commit.
  • Assim, alterar uma classe não invalida o cache do download de dependências — o build fica muito mais rápido.

O padrão essencial para Java. Um estágio pesado com o JDK + Maven/Gradle compila o jar; um estágio final leve, só com a JRE slim, recebe apenas o artefato. O toolchain de build não vaza para a imagem de produção — imagem menor, superfície de ataque menor.

# ---- Estágio 1: build ----
FROM eclipse-temurin:21-jdk AS build
WORKDIR /app
# Copia só o necessário para resolver dependências (bom para o cache de camadas)
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
RUN ./mvnw dependency:go-offline -B
# Agora o código-fonte (muda a cada commit)
COPY src ./src
RUN ./mvnw clean package -DskipTests -B
# ---- Estágio 2: runtime ----
FROM eclipse-temurin:21-jre-alpine AS runtime
WORKDIR /app
# Usuário não-root: nunca rode a aplicação como root
RUN addgroup -S spring && adduser -S spring -G spring
USER spring
# Copia apenas o jar do estágio de build
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

Sem ele, COPY . . arrasta target/, .git/, IDE, etc. para o contexto de build — lento e potencialmente vazando artefatos locais.

target/
.git/
.gitignore
.idea/
*.iml
Dockerfile
docker-compose.yml
README.md

Compose descreve, em um YAML, um conjunto de serviços que sobem juntos com uma rede compartilhada. Ideal para levantar app + banco em um comando (docker compose up).

services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: appdb
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
# App só deve subir quando o banco aceitar conexões
test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
interval: 5s
timeout: 3s
retries: 5
app:
build: .
ports:
- "8080:8080"
environment:
# 'db' é o hostname na rede do compose (nome do serviço)
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/appdb
SPRING_DATASOURCE_USERNAME: app
SPRING_DATASOURCE_PASSWORD: secret
depends_on:
db:
condition: service_healthy
volumes:
pgdata:

Pontos-chave do exemplo: o app alcança o banco pelo nome do serviço (db) via DNS interno da rede do Compose; depends_on com condition: service_healthy garante ordem de inicialização; o volume nomeado pgdata persiste os dados entre up/down.

Aqui a área de containers encontra a de testes. Testcontainers é uma biblioteca que sobe dependências reais em containers Docker efêmeros durante a execução da suíte — um Postgres de verdade, um Kafka de verdade — e os derruba ao final.

  • Isso eleva a fidelidade dos testes de integração (Tipos de Teste - Unit, Integration e E2E): você valida contra o banco real (dialetos SQL, migrations, constraints), não contra um H2 que se comporta diferente.
  • Diferentemente de um mock (que substitui a colaboração), Testcontainers fornece a dependência real e descartável. Por isso depende do runtime Docker estar disponível — inclusive no runner da CI - GitHub Actions.
  • Vantagem sobre o Compose para testes: o ciclo de vida é gerenciado pelo próprio teste (portas aleatórias, isolamento entre execuções), evitando estado compartilhado e “testes que sujam o ambiente”.
graph TD
    A[Dockerfile] --> B[docker build]
    B --> C[Imagem: camadas read-only]
    C --> D[docker run]
    D --> E[Container em execução]
    C --> F[docker push]
    F --> G[Registry]
    subgraph "Dev local / CI"
        H[docker compose up] --> I[app]
        H --> J[Postgres]
        K[Testcontainers] --> L[Postgres efêmero p/ testes]
    end
  • Tamanho — base slim/alpine, multi-stage, sem toolchain de build no runtime; menos MB = pull/deploy mais rápido e menos superfície de ataque.
  • Usuário não-root — crie e use um usuário dedicado; container comprometido rodando como root é risco desnecessário.
  • Layer caching — ordene instruções da menos volátil para a mais volátil (dependências antes do código).
  • Imutabilidade e tags — evite latest em produção; fixe versões para builds reproduzíveis.
  • Um processo por container — o container encapsula um serviço; orquestre múltiplos serviços via Compose/orquestrador, não empilhando processos dentro de um container.
  • Configuração via ambiente — nunca embuta segredos na imagem; injete por variáveis de ambiente/secrets.
  • FROM openjdk:latest (JDK completo em produção). Imagem gigante com toolchain desnecessário; use JRE slim em multi-stage.
  • COPY . . sem .dockerignore. Contexto de build inchado, cache invalidado à toa e risco de vazar arquivos locais.
  • Rodar como root. Padrão inseguro; sempre defina USER.
  • Instruções voláteis no topo do Dockerfile. Copiar o código antes de resolver dependências destrói o cache a cada commit.
  • Persistir dados na camada de escrita do container. Ao remover o container, os dados somem; use volumes.
  • Segredos como ENV/ARG fixos na imagem. Ficam gravados nas camadas e visíveis no histórico da imagem.
  • latest em produção. Deploys não reproduzíveis; a mesma tag pode apontar para builds diferentes.

1. Qual a diferença entre imagem e container, e o que acontece com dados gravados dentro de um container?

Resposta

A imagem é um artefato imutável em camadas somente-leitura (o template); o container é uma instância em execução com uma fina camada de escrita copy-on-write no topo. Dados gravados nessa camada são efêmeros: somem quando o container é removido, a menos que sejam persistidos em um volume.

2. Por que um build multi-stage é o padrão para empacotar apps Spring Boot?

Resposta

Porque separa o estágio de compilação (JDK + Maven/Gradle, pesado) do estágio de runtime (JRE slim). Só o jar final é copiado para a imagem de produção, que fica pequena e sem o toolchain de build — menos MB, deploy mais rápido e menor superfície de ataque.

3. Como a ordem das instruções no Dockerfile afeta o cache de camadas?

Resposta

Cada instrução vira uma camada cacheável; uma camada é reaproveitada se ela e todas as anteriores não mudaram. Copiando primeiro os arquivos de dependências (pom.xml) e resolvendo-as, e só depois o código-fonte, uma alteração de classe não invalida o cache do download de dependências, acelerando muito o build.

4. Por que Testcontainers depende de Docker e que vantagem traz sobre um banco em memória?

Resposta

Testcontainers sobe dependências reais em containers Docker efêmeros durante os testes, exigindo o runtime Docker disponível (inclusive no runner da CI). A vantagem sobre um H2 em memória é a fidelidade: você testa contra o Postgres real — mesmos dialetos SQL, migrations e constraints — reduzindo a chance de o teste passar e a produção falhar.