Empacotando Spring Boot em imagens Docker compactas

1. Visão geral

Empacotar Spring Boot em Docker é muito fácil. Mas para ter imagens pequenas, build rápido, execução segura, precisa de alguns passos extras. Com o Dockerfile familiar de “três linhas”, as imagens costumam ter centenas de MB, toda vez que você muda uma linha de código precisa fazer push de todo o arquivo JAR novamente, e o processo roda com privilégios de root.

Neste artigo, vamos evoluir de um Dockerfile ingênuo para um Dockerfile multi-stage, usando layered jar e JRE enxuto. Depois você verá como usar Buildpacks como alternativa, e escrever um teste com Testcontainers para validar a imagem recém-construída.

2. Aplicação de exemplo

Vamos usar um pequeno serviço chamado order-service, com um endpoint REST e Actuator para health check.

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>
</dependencies>
@SpringBootApplication
public class OrderServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(OrderServiceApplication.class, args);
    }
}

@RestController
@RequestMapping("/orders")
class OrderController {

    record Order(long id, String customer, BigDecimal total) {}

    @GetMapping("/{id}")
    Order find(@PathVariable long id) {
        return new Order(id, "Nguyen Van A", new BigDecimal("250000"));
    }
}

O comando ./mvnw package gera o arquivo target/order-service-1.0.0.jar. Este é um “fat jar” que contém seu código e todas as bibliotecas de dependência.

3. Dockerfile ingênuo e seus problemas

A versão que muita gente escreve em primeiro lugar fica assim:

FROM eclipse-temurin:21
COPY target/order-service-1.0.0.jar app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]

Este Dockerfile funciona, mas tem quatro problemas:

  • Base image muito grande. eclipse-temurin:21 contém o JDK completo: compilador, jshell, javadoc… Em tempo de execução você não precisa deles.
  • Uma única layer para tudo. O fat jar tem cerca de 20-30 MB, sendo que seu código ocupa apenas algumas centenas de KB. Quando você muda uma linha de código, aquela layer de 30 MB muda, precisando fazer build e push novamente.
  • Dependência da máquina de build. A imagem precisa de um arquivo JAR já compilado. JDK e versão Maven em cada máquina podem ser diferentes.
  • Executado como root. Se a aplicação for comprometida, o atacante terá privilégios root no container.

Confira o tamanho:

docker build -t order-service:naive .
docker images order-service
REPOSITORY      TAG     IMAGE ID       SIZE
order-service   naive   3f1c9a7e2b10   ~480MB

O número exato depende da versão da base image, mas perto de meio GB para um serviço “hello world” é demais.

4. Multi-stage build com layered jar

4.1. O que é layered jar

Desde Spring Boot 2.3, o fat jar já vem dividido em layers conforme a frequência de mudanças:

Layer Conteúdo Frequência de mudanças
dependencies Bibliotecas com versão release Raro
spring-boot-loader Class loader do Spring Boot Muito raro
snapshot-dependencies Bibliotecas -SNAPSHOT Ocasionalmente
application Seu código e recursos Frequente

Você copia cada parte na imagem com um comando COPY separado, em ordem da mudança menos frequente para a mais frequente. Docker faz cache das layers acima. Quando você muda apenas código, apenas a layer application é reconstruída e reenviada.

A partir do Spring Boot 3.3, você separa as layers usando jarmode=tools:

java -Djarmode=tools -jar target/order-service-1.0.0.jar extract --layers --destination extracted
ls extracted
application  dependencies  snapshot-dependencies  spring-boot-loader

O diretório application contém o arquivo order-service-1.0.0.jar “enxuto”. Este arquivo faz referência ao diretório lib/ que está na layer dependencies. Após mesclar as layers, você apenas executa java -jar como de costume.

Nota: Para Spring Boot 3.2 e anteriores, a sintaxe é java -Djarmode=layertools -jar app.jar extract. O entrypoint nesse caso é org.springframework.boot.loader.launch.JarLauncher em vez de -jar.

4.2. Dockerfile completo

# ---------- Stage 1: build com Maven ----------
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /workspace
COPY pom.xml .
RUN mvn -B dependency:go-offline
COPY src ./src
RUN mvn -B package -DskipTests

# ---------- Stage 2: separar as layers ----------
FROM eclipse-temurin:21-jre-alpine AS extract
WORKDIR /extract
COPY --from=build /workspace/target/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --destination extracted

# ---------- Stage 3: runtime ----------
FROM eclipse-temurin:21-jre-alpine
RUN addgroup -S spring && adduser -S spring -G spring
WORKDIR /app

COPY --from=extract /extract/extracted/dependencies/ ./
COPY --from=extract /extract/extracted/spring-boot-loader/ ./
COPY --from=extract /extract/extracted/snapshot-dependencies/ ./
COPY --from=extract /extract/extracted/application/ ./

USER spring:spring
EXPOSE 8080
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75.0 -XX:+ExitOnOutOfMemoryError"

HEALTHCHECK --interval=30s --timeout=3s --start-period=30s \
  CMD wget -qO- http://localhost:8080/actuator/health || exit 1

ENTRYPOINT ["java", "-jar", "app.jar"]

Explicação de cada parte:

  • Stage build: COPY pom.xml e depois executa dependency:go-offline antes de copiar src. Quando você muda apenas o código, Docker reutiliza a layer com as dependências já baixadas. Você não precisa baixar tudo novamente do Maven Central.
  • Stage extract: separa o JAR em quatro diretórios de layer. Este stage é apenas intermediário e não aparece na imagem final.
  • Stage runtime: usa 21-jre-alpine, apenas JRE em Alpine Linux.
  • Quatro comandos COPY: cada um cria uma layer Docker, em ordem de mudança menos frequente para mais frequente. Como o arquivo JAR “enxuto” (app.jar) está em application e o diretório lib/ está em dependencies, após mesclar, /app tem a estrutura app.jar + lib/.
  • USER spring:spring: o processo Java não roda mais com privilégios root.
  • JAVA_TOOL_OPTIONS: JVM lê automaticamente essa variável. MaxRAMPercentage=75.0 faz o heap usar no máximo 75% do limite de memória do container. ExitOnOutOfMemoryError força a JVM a sair quando ficar sem memória, deixando o orquestrador reiniciar o container em vez de deixá-lo em um estado “entre a vida e a morte”.
  • HEALTHCHECK: Alpine já traz wget do BusyBox, então você não precisa instalar curl.
  • ENTRYPOINT em forma exec (array JSON): java é PID 1 e recebe o sinal SIGTERM quando o container para. Assim Spring Boot pode fazer graceful shutdown.

4.3. Não esqueça do .dockerignore

O comando build envia todo o diretório atual (build context) para o daemon Docker. Você deve excluir coisas desnecessárias:

target/
.git/
.idea/
*.iml
.mvn/wrapper/maven-wrapper.jar
Dockerfile

Sem este arquivo, o diretório target/ ou .git/ podem fazer o context crescer para centenas de MB. Cada build ficará notavelmente mais lento.

4.4. Resultado

docker build -t order-service:1.0.0 .
docker images order-service
docker history order-service:1.0.0 --format "{{.Size}}\t{{.CreatedBy}}" | head -8
REPOSITORY      TAG     IMAGE ID       SIZE
order-service   1.0.0   9b2e44d1c0af   ~200MB
order-service   naive   3f1c9a7e2b10   ~480MB

0B      ENTRYPOINT ["java" "-jar" "app.jar"]
...
45kB    COPY /extract/extracted/application/ ./
0B      COPY /extract/extracted/snapshot-dependencies/ ./
290kB   COPY /extract/extracted/spring-boot-loader/ ./
22MB    COPY /extract/extracted/dependencies/ ./

A imagem ficou menos da metade. Mais importante, a layer application tem apenas algumas dezenas de KB. Quando você muda OrderController e faz rebuild, apenas aquela layer muda. O push para o registry leva apenas alguns segundos.

5. Alternativa: Cloud Native Buildpacks

Se você não quer manter um Dockerfile manualmente, o plugin Maven do Spring Boot pode construir a imagem diretamente com Buildpacks:

./mvnw spring-boot:build-image \
  -Dspring-boot.build-image.imageName=shop/order-service:1.0.0

Você também pode configurar permanentemente no pom.xml:

<plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
    <configuration>
        <image>
            <name>shop/order-service:${project.version}</name>
            <env>
                <BP_JVM_VERSION>21</BP_JVM_VERSION>
            </env>
        </image>
    </configuration>
</plugin>

Buildpacks fazem automaticamente o que fizemos manualmente acima: separar layers, escolher JRE, rodar como usuário non-root, calcular memória para JVM. Você não precisa de um Dockerfile, apenas do daemon Docker em execução.

Por outro lado, você tem menos controle. A base image é decidida pelo buildpack, o primeiro build é lento porque precisa baixar a builder image, e customizações profundas são mais difíceis do que editar um Dockerfile. Equipes que querem padronizar rapidamente devem usar Buildpacks. Equipes que precisam de controle fino, por exemplo usar uma base image interna previamente aprovada de segurança, devem usar Dockerfile.

6. Testando a imagem com Testcontainers

Uma imagem “compacta” que não inicializa é inútil. Você pode escrever um teste que construa a imagem a partir do próprio Dockerfile, execute o container e valide que funciona corretamente.

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <scope>test</scope>
</dependency>

O BOM do Spring Boot já gerencia a versão para ambas as bibliotecas, então você não precisa declarar <version>.

@Testcontainers
class OrderServiceImageIT {

    static final ImageFromDockerfile IMAGE =
            new ImageFromDockerfile("order-service-it", false)
                    .withDockerfile(Path.of("./Dockerfile"));

    @Container
    static final GenericContainer<?> APP = new GenericContainer<>(IMAGE)
            .withExposedPorts(8080)
            .waitingFor(Wait.forHttp("/actuator/health").forStatusCode(200))
            .withStartupTimeout(Duration.ofMinutes(2));

    @Test
    void shouldServeOrdersAndReportHealthy() throws Exception {
        String baseUrl = "http://" + APP.getHost() + ":" + APP.getMappedPort(8080);
        HttpClient client = HttpClient.newHttpClient();

        HttpResponse<String> response = client.send(
                HttpRequest.newBuilder(URI.create(baseUrl + "/orders/42")).build(),
                HttpResponse.BodyHandlers.ofString());

        assertThat(response.statusCode()).isEqualTo(200);
        assertThat(response.body()).contains("\"id\":42", "Nguyen Van A");
    }

    @Test
    void shouldNotRunAsRoot() throws Exception {
        var result = APP.execInContainer("id", "-u");

        assertThat(result.getStdout().trim()).isNotEqualTo("0");
    }
}

Este teste faz o seguinte:

  • ImageFromDockerfile constrói a imagem a partir do Dockerfile no diretório raiz do projeto. O build context é o diretório contendo o Dockerfile. O parâmetro false mantém a imagem após o teste para acelerar builds futuros usando cache.
  • Wait.forHttp("/actuator/health") aguarda até que a aplicação esteja realmente pronta, não apenas que o container tenha iniciado.
  • Primeiro teste chama o endpoint através da porta mapeada aleatoriamente no host e valida o JSON retornado.
  • Segundo teste executa id -u dentro do container. Se alguém acidentalmente remover a linha USER spring:spring, o resultado será 0 e o teste falhará imediatamente.

Nomeie a classe com sufixo IT e execute com maven-failsafe-plugin. Este teste constrói a imagem inteira, então leva dezenas de segundos e não é adequado para ser executado nos testes unitários regulares.

7. Erros comuns e quando não aplicar

  • Usar ENTRYPOINT java -jar app.jar em forma de shell. Nesse caso /bin/sh é o PID 1 e não repassa SIGTERM para a JVM. O container é forçadamente encerrado após o timeout, perdendo graceful shutdown. Sempre use a forma de array JSON.
  • Definir -Xmx como constante. Se você definir -Xmx2g mas o limite do container for 1 GB, ele será OOM-killed sem stack trace. Use MaxRAMPercentage para que o heap se redimensione de acordo com o limite.
  • Alpine e bibliotecas nativas. Alpine usa musl em vez de glibc. Algumas bibliotecas com código nativo (Netty native transport, alguns drivers, bibliotecas de processamento de imagem) podem falhar ou rodar lentamente. Nesse caso mude para eclipse-temurin:21-jre-jammy ou -noble. A imagem é alguns MB maior mas mais estável.
  • COPY em ordem errada. Se copiar application antes de dependencies, qualquer mudança de código invalida o cache da layer de dependências. Você perde todo o benefício do layered jar.
  • Usar tag latest para base image. Build hoje e na próxima semana podem gerar duas imagens diferentes. Fixe um tag específico como 21.0.5_11-jre-alpine, ou fixe o digest se precisar de reprodutibilidade absoluta.

Nota: Se precisar de imagens ainda menores, pode usar jdeps e jlink para criar uma JRE contendo apenas os módulos necessários, ou compilar nativo com GraalVM. Ambas as abordagens reduzem tamanho significativamente, mas complicam muito o build e quebram facilmente com reflection. Só aplique quando tamanho de imagem ou tempo de inicialização forem realmente um problema, por exemplo com serverless ou scale-to-zero.

8. Conclusão

Neste artigo, você transformou um Dockerfile de três linhas em um Dockerfile multi-stage com os seguintes passos:

  • build dentro do container para não depender da máquina de build;
  • separar o layered jar para que apenas a layer com código mude a cada edição;
  • usar JRE Alpine para reduzir tamanho da imagem;
  • rodar como usuário non-root, com health check e configuração JVM de acordo com o limite de memória do container.

Você também viu que Buildpacks é uma alternativa que não requer Dockerfile. Por fim, testes com Testcontainers ajudam a garantir que a imagem continue inicializando corretamente e não rode como root após cada mudança.

spring-bootdockerjavadevopscontainer