O essencial do Docker: image, container e layer
1. Visão geral
Muitos iniciantes em Docker confundem image com container, ou não entendem por que docker build é tão rápido na segunda execução. Este artigo explica três conceitos fundamentais: image, container e layer — acompanhados de um exemplo prático de criação de image para uma aplicação Spring Boot real. Após a leitura, você será capaz de escrever um Dockerfile adequado, aproveitar cache de layer para builds mais rápidos, e evitar alguns dos erros mais comuns ao containerizar aplicações Java.
2. O que é Image
Image é um snapshot read-only que contém tudo o que é necessário para executar sua aplicação: um sistema operacional base, runtime (como JVM), bibliotecas, variáveis de ambiente e seu código. Uma image não é executada por si mesma — ela funciona como um “template” a partir do qual você cria containers.
Você define uma image através de um Dockerfile:
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY target/order-service.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
Explicação de cada linha:
FROM: seleciona a image base (JRE 21 no Temurin). Tudo que for adicionado durante o build reside sobre essa base.WORKDIR: define o diretório de trabalho dentro do container; os comandos subsequentes são executados relativos a esse diretório.COPY: copia o arquivo jar já compilado (demvn package) para a image.ENTRYPOINT: comando a ser executado quando o container é iniciado.
Para compilar a image, use:
docker build -t order-service:1.0 .
Resultado:
[+] Building 3.2s (8/8) FINISHED
=> [1/3] FROM eclipse-temurin:21-jre
=> [2/3] WORKDIR /app
=> [3/3] COPY target/order-service.jar app.jar
=> exporting to image
=> => naming to docker.io/library/order-service:1.0
A image é armazenada localmente; você pode visualizar a lista de images com docker images.
Nota: Uma image é apenas um arquivo estático. Executar
docker buildmúltiplas vezes com o mesmo Dockerfile e código inalterado não cria uma aplicação em execução — para isso, você precisa criar um container a partir da image.
3. O que é Container
Container é uma instância em execução (ou parada) criada a partir de uma image. Se a image é uma “classe”, o container é um “objeto” — você pode criar múltiplos containers a partir da mesma image, sendo cada um independente em termos de processo, rede e camada de escrita do filesystem.
Para criar e executar um container:
docker run -d --name order-svc -p 8080:8080 order-service:1.0
-d: executa em modo detached (background).--name: define um nome para o container, facilitando o gerenciamento em vez de usar um ID aleatório.-p 8080:8080: mapeia a porta 8080 do host para a porta 8080 dentro do container.
Para verificar containers em execução:
docker ps
CONTAINER ID IMAGE STATUS PORTS NAMES
3f2a9c1b8d4e order-service:1.0 Up 5 seconds 0.0.0.0:8080->8080/tcp order-svc
Você pode executar um segundo container da mesma image, alterando apenas o nome e a porta:
docker run -d --name order-svc-2 -p 8081:8080 order-service:1.0
Esses dois containers são completamente independentes: se um container falhar ou se você escrever arquivos no filesystem dele, o outro container não é afetado. A image original (order-service:1.0) permanece inalterada — essa é a natureza read-only da image.
Para parar e remover um container quando não for mais necessário:
docker stop order-svc && docker rm order-svc
3.1. Como Containers diferem de Máquinas Virtuais
Containers compartilham o kernel com o host, não virtualizam todo o sistema operacional como VMs. Por isso, containers iniciam em poucos segundos (como mostrado no log do build acima) e não em dezenas de segundos como VMs, além de consumir muito menos recursos.
4. O que é Layer e por que é importante
Cada comando no Dockerfile (FROM, COPY, RUN, ADD…) cria uma layer separada. Essas layers se sobrepõem (union filesystem), e Docker faz cache de cada layer — se uma layer não mudou em relação à compilação anterior, Docker reutiliza o cache em vez de recompilar.
Considere um Dockerfile escrito sem otimização:
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY . .
RUN mvn package -DskipTests
ENTRYPOINT ["java", "-jar", "target/order-service.jar"]
O problema: COPY . . copia todo o código-fonte, mesmo que você tenha mudado apenas um comentário. Como a layer COPY mudou, Docker é forçado a recompilar a layer RUN mvn package que vem depois — perdendo alguns minutos a cada build, mesmo que a lógica do código não tenha mudado.
A forma otimizada é separar as dependências (que mudam raramente) das mudanças no código (que mudam com frequência):
FROM eclipse-temurin:21-jdk AS build
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn package -DskipTests
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /app/target/order-service.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
Explicação:
COPY pom.xml .seguido deRUN mvn dependency:go-offline: essa layer muda apenas quando você adiciona ou remove dependências nopom.xml. Na maioria das vezes, essa layer é reutilizada do cache — pulando a etapa de download de bibliotecas (que geralmente é a mais demorada).COPY src ./srcvem depois: apenas essa layer e as subsequentes são recompiladas quando você modifica o código; a layer de dependências mantém o cache.- Build de múltiplos estágios (
AS buildseguido deFROM ... jre): o estágio final toma apenas o arquivo jar compilado, não levando o JDK e cache do Maven — a image de produção fica consideravelmente menor.
Para visualizar as layers de uma image, use:
docker history order-service:1.0
IMAGE CREATED CREATED BY SIZE
a1b2c3d4e5f6 3 minutes ago ENTRYPOINT ["java" "-jar"...] 0B
7f8e9d0c1b2a 3 minutes ago COPY /app/target/order-ser… 45.2MB
...
Cada linha corresponde a uma layer, com o tamanho que ela adiciona. A layer do COPY do jar é a maior — isso faz sentido, pois contém o código real da aplicação.
4.1. Como o cache de layer funciona durante recompilação
Suponha que você altere apenas uma classe Java e recompile:
docker build -t order-service:1.1 .
=> [build 1/5] FROM eclipse-temurin:21-jdk
=> CACHED [build 2/5] COPY pom.xml .
=> CACHED [build 3/5] RUN mvn dependency:go-offline
=> [build 4/5] COPY src ./src
=> [build 5/5] RUN mvn package -DskipTests
=> [stage-1 2/2] COPY --from=build /app/target/order-service.jar app.jar
As duas linhas com CACHED mostram que Docker pulou o download de dependências — o build é notavelmente mais rápido em comparação com a abordagem COPY . . inicial.
Nota: Docker compara o cache baseado no conteúdo dos arquivos e na ordem dos comandos, não em timestamps. Se você alterar a ordem de um
COPYouRUN, todas as layers a partir desse ponto em diante serão invalidadas, mesmo que o conteúdo real seja idêntico ao build anterior.
5. Verificação através de testes automatizados
Ao containerizar um serviço, geralmente você quer garantir que a image compilada funciona corretamente em um healthcheck. Use a biblioteca Testcontainers para escrever testes que confirmem que o container inicia e retorna respostas corretas.
Adicione a dependência:
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>1.20.1</version>
<scope>test</scope>
</dependency>
Escreva um teste:
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.wait.strategy.Wait;
import static org.assertj.core.api.Assertions.assertThat;
class OrderServiceContainerTest {
@Test
void containerShouldRespondOnHealthEndpoint() {
try (GenericContainer<?> container =
new GenericContainer<>("order-service:1.0")
.withExposedPorts(8080)
.waitingFor(Wait.forHttp("/actuator/health"))) {
container.start();
assertThat(container.isRunning()).isTrue();
assertThat(container.getMappedPort(8080)).isPositive();
}
}
}
GenericContainercria um container temporário a partir da imageorder-service:1.0, limpando-o automaticamente quando o blocotrytermina.waitingFor(Wait.forHttp(...)): o teste aguarda até que o endpoint de saúde retorne 2xx antes de considerar o container pronto, evitando race conditions.getMappedPort(8080): Docker mapeia uma porta aleatória no host; o teste obtém a porta real para fazer chamadas subsequentes se necessário.
Executar esse teste confirma que a image que você compilou na Seção 2 não apenas foi compilada com sucesso, mas também funciona de verdade — uma etapa de verificação que muitos desenvolvedores negligenciam.
6. Erros comuns
- Confundir image com container ao limpar:
docker rmremove o container, mas a image permanece e consome espaço. Para remover a image, usedocker rmi. - COPY de todo o código-fonte antes de instalar dependências: como mencionado na Seção 4, isso elimina o benefício do cache de layer, tornando o build desnecessariamente lento.
- Usar uma image base muito pesada (como
ubuntucompleto) para runtime de JRE: a image de produção fica grande, aumentando o tempo de pull/deploy. Prefira imagens JRE minimalistas comoeclipse-temurin:21-jreou versões-alpinese compatíveis. - Compilar uma nova image sem alterar a tag: reutilizar a tag
latestdificulta o rollback quando há erros em produção, pois fica incerto qual image exata está em execução.
Quando não precisa de build de múltiplos estágios: se você compilar o jar antecipadamente em CI (por exemplo, GitHub Actions) antes de fazer
docker build, você não precisa do estágiobuildno Dockerfile — apenas façaCOPYdo arquivo jar existente, como no exemplo da Seção 2. Build de múltiplos estágios só é necessário quando você quer que o Docker compile o código a partir do source.
7. Conclusão
Image é um template estático, container é uma instância em execução criada a partir dessa image, e layer são as camadas sobrepostas que permitem ao Docker fazer cache de forma inteligente para builds mais rápidos. Compreender bem a ordem das layers no Dockerfile — colocando partes que mudam raramente (dependências) primeiro e partes que mudam frequentemente (código-fonte) depois — é o jeito mais simples de otimizar o tempo de build. A partir desse fundamento, você pode avançar para tópicos como Docker Compose para orquestrar múltiplos serviços, ou otimizar o tamanho da image usando base images distroless.