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:21conté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.JarLauncherem 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.xmle depois executadependency:go-offlineantes de copiarsrc. 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á emapplicatione o diretóriolib/está emdependencies, após mesclar,/apptem a estruturaapp.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.0faz o heap usar no máximo 75% do limite de memória do container.ExitOnOutOfMemoryErrorforç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á trazwgetdo BusyBox, então você não precisa instalarcurl.ENTRYPOINTem forma exec (array JSON):javaé PID 1 e recebe o sinalSIGTERMquando 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:
ImageFromDockerfileconstrói a imagem a partir doDockerfileno diretório raiz do projeto. O build context é o diretório contendo o Dockerfile. O parâmetrofalsemanté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 -udentro do container. Se alguém acidentalmente remover a linhaUSER spring:spring, o resultado será0e 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.jarem forma de shell. Nesse caso/bin/shé o PID 1 e não repassaSIGTERMpara a JVM. O container é forçadamente encerrado após o timeout, perdendo graceful shutdown. Sempre use a forma de array JSON. - Definir
-Xmxcomo constante. Se você definir-Xmx2gmas o limite do container for 1 GB, ele será OOM-killed sem stack trace. UseMaxRAMPercentagepara que o heap se redimensione de acordo com o limite. - Alpine e bibliotecas nativas. Alpine usa
muslem vez deglibc. Algumas bibliotecas com código nativo (Netty native transport, alguns drivers, bibliotecas de processamento de imagem) podem falhar ou rodar lentamente. Nesse caso mude paraeclipse-temurin:21-jre-jammyou-noble. A imagem é alguns MB maior mas mais estável. COPYem ordem errada. Se copiarapplicationantes dedependencies, 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
latestpara base image. Build hoje e na próxima semana podem gerar duas imagens diferentes. Fixe um tag específico como21.0.5_11-jre-alpine, ou fixe o digest se precisar de reprodutibilidade absoluta.
Nota: Se precisar de imagens ainda menores, pode usar
jdepsejlinkpara 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.