DevOps, CI/CD e contêineres

Fundamentos de Docker: imagens, containers e Dockerfiles

Iniciante7 min de leituraAtualizado

Esta página foi traduzida do inglês com a ajuda de IA e conferida com o original. Ler a versão em inglês

O Docker empacota uma aplicação com tudo de que ela precisa em uma imagem e executa essa imagem como um processo isolado chamado container. Este guia explica as peças — imagens, camadas, registries, containers, volumes — e depois mostra como escrever um Dockerfile que faz o build rápido e gera uma imagem pequena e segura, com exemplos práticos para um serviço Spring Boot.

O que você vai aprender

  • Explicar a diferença entre imagem, camada, container e registry
  • Executar, inspecionar, parar e remover containers com os comandos essenciais da CLI
  • Escrever um Dockerfile multi-stage que gere uma imagem pequena e reproduzível
  • Usar .dockerignore, a ordem das camadas e cache mounts para manter os builds rápidos
  • Evitar os erros mais comuns: rodar como root, usar a tag latest, colocar segredos na imagem

“Na minha máquina funciona” já foi piada; o Docker transformou isso em estratégia de deploy ao tornar a máquina parte do artefato. Uma imagem carrega a aplicação, o runtime, as bibliotecas e a configuração. Rode a mesma imagem em um notebook, em um runner de CI e em um cluster de produção e você terá o mesmo comportamento. Este guia explica o modelo por trás dessa promessa e depois parte para a prática: os comandos que você usa todo dia e como escrever um Dockerfile do qual não vai se envergonhar depois.

Imagens, camadas, containers e registries

Quatro palavras concentram a maior parte do vocabulário do Docker.

Uma imagem é um template somente leitura: um snapshot de sistema de arquivos mais metadados, como o comando padrão, as variáveis de ambiente e as portas expostas. Imagens são construídas a partir de um Dockerfile e identificadas por um nome e uma tag, por exemplo eclipse-temurin:21-jre.

Uma imagem é formada por camadas (layers). Cada instrução de um Dockerfile que altera o sistema de arquivos — COPY, RUN, ADD — gera uma nova camada empilhada sobre as anteriores. As camadas são endereçadas pelo conteúdo e compartilhadas: se dez imagens começam todas com FROM eclipse-temurin:21-jre, as camadas base são armazenadas e baixadas uma única vez. É também isso que faz o cache de build funcionar, como veremos.

Um container é uma instância de uma imagem, em execução ou parada. O Docker adiciona uma fina camada gravável sobre as camadas somente leitura da imagem, cria uma visão isolada da tabela de processos, da rede e do sistema de arquivos usando namespaces do Linux e aplica limites de recursos com cgroups. O container é só um processo no kernel do host; nenhum sistema operacional convidado é inicializado.

Um registry armazena e distribui imagens. O Docker Hub é o registry público padrão; as organizações mantêm registries privados (Harbor, GitLab, Amazon ECR, GitHub Container Registry). docker pull e docker push movem imagens entre um host e um registry.

$ docker pull eclipse-temurin:21-jre
$ docker image ls
REPOSITORY         TAG      IMAGE ID       CREATED       SIZE
eclipse-temurin    21-jre   3a1f0b2c9d7e   2 weeks ago   268MB

Os comandos do dia a dia

Dá para ir longe com uma dúzia de comandos.

# Rodar um container a partir de uma imagem, em modo interativo, e removê-lo ao sair
docker run --rm -it ubuntu:24.04 bash

# Rodar um servidor web em segundo plano, mapeando a porta 8080 do host para a porta 80 do container
docker run -d --name web -p 8080:80 nginx:1.27

# Ver o que está rodando (adicione -a para incluir também os containers parados)
docker ps

# Logs, um shell dentro do container, uso de recursos
docker logs -f web
docker exec -it web sh
docker stats web

# Parar, iniciar, remover
docker stop web
docker start web
docker rm -f web

# Inspecionar qualquer coisa — imagens, containers, redes — em JSON
docker inspect web | jq '.[0].NetworkSettings.Ports'

# Liberar disco: containers parados, imagens órfãs, redes sem uso
docker system prune

Vale internalizar duas flags logo no começo. --rm apaga o container quando ele termina, o que evita que sua máquina fique cheia de containers mortos dos seus experimentos. -v (ou --mount) anexa um volume ou um diretório do host para que os dados sobrevivam ao container:

docker run -d --name db -v pgdata:/var/lib/postgresql/data -e POSTGRES_PASSWORD=dev postgres:16

Sem o volume, os arquivos do banco ficam na camada gravável do container e somem com docker rm.

Escrevendo um Dockerfile

Um Dockerfile é um script de instruções que produzem uma imagem. Veja um exemplo mínimo para uma aplicação Spring Boot cujo build já gerou target/app.jar:

FROM eclipse-temurin:21-jre
WORKDIR /app
COPY target/app.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

Faça o build e rode:

docker build -t myorg/app:1.0.0 .
docker run --rm -p 8080:8080 myorg/app:1.0.0

O . no final é o contexto de build — o diretório enviado ao daemon do Docker. Tudo o que o COPY referencia precisa estar dentro dele, e tudo o que estiver dentro dele é transferido; por isso o .dockerignore importa.

As instruções que você vai usar:

InstruçãoFinalidade
FROMImagem base. Todo Dockerfile começa aqui (ou com FROM scratch para partir de uma base vazia).
WORKDIRDefine o diretório de trabalho das instruções seguintes; cria o diretório se ele não existir.
COPYCopia arquivos do contexto de build para a imagem. Prefira-a ao ADD, que também baixa URLs e descompacta arquivos compactados.
RUNExecuta um comando em tempo de build e grava o resultado como uma camada.
ENVDefine uma variável de ambiente para o build e para a execução.
ARGVariável de build, passada com --build-arg; não existe em tempo de execução.
EXPOSEDocumenta em qual porta o processo escuta. Não publica nada.
USERTroca o usuário das instruções seguintes e do container em execução.
ENTRYPOINT / CMDO comando a executar. Use a forma de array JSON para que nenhum shell fique no meio e os sinais cheguem ao processo.
HEALTHCHECKUm comando que o Docker roda periodicamente para informar a saúde do container.

Multi-stage builds

O Dockerfile acima pressupõe que o jar foi gerado fora do Docker, o que deixa o build dependente do Maven e do JDK do host. Um multi-stage build faz a compilação dentro de um primeiro estágio e copia só o artefato para um estágio de runtime enxuto:

# ---- estágio de build ----
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /src
# Copiar primeiro os arquivos que menos mudam, para que o download das dependências fique em cache
COPY pom.xml .
RUN --mount=type=cache,target=/root/.m2 mvn -q dependency:go-offline
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 mvn -q package -DskipTests

# ---- estágio de runtime ----
FROM eclipse-temurin:21-jre
RUN useradd --system --uid 10001 app
WORKDIR /app
COPY --from=build /src/target/app.jar app.jar
USER app
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://localhost:8080/actuator/health || exit 1
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-jar", "app.jar"]

O estágio de build, com o Maven e o JDK completo, é descartado; a imagem final contém só o JRE e o jar. O tamanho cai de cerca de 700 MB para menos de 300 MB, e nem o código-fonte nem as ferramentas de build vão para produção. O mesmo padrão vale para Node (build com node:22, execução com node:22-slim ou arquivos estáticos servidos pelo nginx), Go (build e depois FROM scratch) e todo o resto.

Builds rápidos: cache e ordem das instruções

O Docker guarda cada camada em cache e a reutiliza enquanto a instrução e suas entradas não mudam. O cache é invalidado na primeira instrução cujas entradas mudaram, e todas as instruções seguintes são refeitas. Duas consequências:

  • Ordene as instruções da menos para a mais volátil. Copie o pom.xml ou o package.json e instale as dependências antes de copiar o código-fonte. Assim, editar um arquivo-fonte refaz só a etapa de compilação, e não o download das dependências.
  • Use .dockerignore. Um COPY . . é invalidado sempre que qualquer arquivo do contexto muda — inclusive .git, a saída do build e os arquivos temporários do editor. Exclua-os:
.git
target
node_modules
*.log
.idea
.vscode
Dockerfile
docker-compose*.yml

O --mount=type=cache do BuildKit (mostrado acima) mantém os caches do gerenciador de pacotes entre builds sem gravá-los em uma camada. Junte com && os comandos RUN que andam juntos, para que os arquivos intermediários nunca virem uma camada, e faça a limpeza na mesma instrução:

RUN apt-get update \
 && apt-get install -y --no-install-recommends curl ca-certificates \
 && rm -rf /var/lib/apt/lists/*

O essencial de segurança e higiene

Não rode como root. Por padrão, o processo dentro de um container roda com UID 0. Um escape do container ou um bug da aplicação entrega então a um atacante acesso root ao sistema de arquivos do host que o container enxerga. Crie um usuário e troque para ele com USER, como no exemplo multi-stage.

Fixe as imagens base. FROM node ou FROM node:latest significa uma imagem diferente a cada semana. Use uma tag específica (node:22.9-slim) e, para builds reproduzíveis, um digest: FROM node:22.9-slim@sha256:…. Refaça o build de forma deliberada para pegar os patches de segurança.

Nunca coloque segredos em uma imagem. Um COPY .env . ou um ENV API_KEY=... fica gravado para sempre em uma camada, visível com docker history, e vai para todo registry que a imagem alcançar. Passe os segredos em tempo de execução por variáveis de ambiente ou arquivos montados, e use o --mount=type=secret do BuildKit se uma etapa do build realmente precisar de um.

Mantenha a imagem pequena. Menos pacotes significam menos vulnerabilidades para corrigir e pulls mais rápidos. Prefira bases -slim, -alpine ou distroless, e escaneie as imagens com docker scout ou Trivy na CI.

Trate os sinais. Use a forma de array JSON do ENTRYPOINT para que seu processo seja o PID 1 e receba o SIGTERM quando o container parar. Com a forma shell, ENTRYPOINT java -jar app.jar, o sh fica na frente do seu processo e engole o sinal: sua aplicação é morta sem um shutdown gracioso quando terminam os dez segundos de tolerância.

Erros comuns

  • Tratar containers como bichinhos de estimação. Entrar em um container e editar arquivos lá dentro funciona até o container ser recriado. Altere o Dockerfile e refaça o build.
  • Publicar portas só com EXPOSE. EXPOSE é documentação. Quem publica de fato é o -p host:container (ou uma entrada ports no Compose).
  • Achar que localhost é o host. Dentro de um container, localhost é o próprio container. Acesse outros containers por uma rede definida pelo usuário e pelos nomes de serviço, e o host por host.docker.internal no Docker Desktop.
  • Gravar dados no sistema de arquivos do container. Eles somem no docker rm. Use volumes.
  • Ignorar a regra de um processo por container. Enfiar uma aplicação, um banco de dados e um daemon de cron em uma única imagem atrapalha a orquestração, os logs e a escalabilidade. Compose e Kubernetes existem para aplicações com vários containers.
  • Ignorar o código de saída. docker ps -a mostra os containers encerrados com seus códigos; docker logs mostra o motivo. 137 significa que o processo foi morto pelo OOM killer ou por um SIGKILL: aumente o limite de memória ou corrija o vazamento.

Principais conclusões

  • Uma imagem é um template em camadas, somente leitura; um container é um processo rodando a partir dela com uma camada gravável por cima; um registry armazena imagens.
  • Aprenda run, ps, logs, exec, stop, rm, build, push e system prune. Use --rm nos experimentos e -v em tudo que guarda dados.
  • Escreva Dockerfiles multi-stage: faça o build em uma imagem completa, rode em uma enxuta e copie só o artefato de uma para a outra.
  • Ordene as instruções da mais estável para a mais volátil, mantenha o .dockerignore e use cache mounts para que uma mudança no código-fonte refaça só a etapa de compilação.
  • Rode com um usuário que não seja root, fixe as imagens base, mantenha os segredos fora das camadas, use a forma JSON do ENTRYPOINT e mantenha a imagem pequena.

Fontes e leituras complementares

Perguntas frequentes

Um container é uma máquina virtual leve?
Não. Uma máquina virtual roda um sistema operacional convidado completo sobre um hipervisor. Um container é um processo comum no kernel do host, isolado com namespaces do Linux e limitado com cgroups, que enxerga o próprio sistema de arquivos vindo da imagem. É por isso que containers sobem em milissegundos e compartilham o kernel do host — e por isso um container não consegue rodar um kernel diferente do host.
Qual é a diferença entre CMD e ENTRYPOINT?
ENTRYPOINT define o executável que sempre roda; CMD fornece argumentos padrão que o usuário pode substituir na linha de comando do docker run. Use ENTRYPOINT para a parte fixa (o comando java e suas flags) e CMD para os argumentos que você espera que as pessoas mudem. Se só o CMD estiver definido, o comando inteiro pode ser substituído.
Por que minha imagem está tão grande?
Normalmente porque as ferramentas de build, o código-fonte e os caches do gerenciador de pacotes foram parar na imagem final. Um multi-stage build compila em um estágio e copia só o artefato resultante para uma imagem de runtime enxuta. Confira também se o .dockerignore exclui .git, node_modules, target e as pastas da IDE, e se você limpa os caches do gerenciador de pacotes na mesma instrução RUN que instala os pacotes.
Devo usar a tag latest?
Não para nada que você faça deploy. latest é só uma tag mutável que aponta para o que foi enviado por último, então dois builds em dias diferentes podem gerar imagens diferentes. Fixe as imagens base em uma versão específica (de preferência em um digest) e marque suas próprias imagens com a versão do build ou o hash do commit.