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 (de mvn 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 build mú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 de RUN mvn dependency:go-offline: essa layer muda apenas quando você adiciona ou remove dependências no pom.xml. Na maioria das vezes, essa layer é reutilizada do cache — pulando a etapa de download de bibliotecas (que geralmente é a mais demorada).
  • COPY src ./src vem 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 build seguido de FROM ... 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 COPY ou RUN, 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();
        }
    }
}
  • GenericContainer cria um container temporário a partir da image order-service:1.0, limpando-o automaticamente quando o bloco try termina.
  • 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 rm remove o container, mas a image permanece e consome espaço. Para remover a image, use docker 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 ubuntu completo) para runtime de JRE: a image de produção fica grande, aumentando o tempo de pull/deploy. Prefira imagens JRE minimalistas como eclipse-temurin:21-jre ou versões -alpine se compatíveis.
  • Compilar uma nova image sem alterar a tag: reutilizar a tag latest dificulta 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ágio build no Dockerfile — apenas faça COPY do 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.

dockerdevopscontainerimagespring-boot