Escrevendo Dockerfiles bons: multi-stage build para Spring Boot
1. Visão geral
Um Dockerfile escrito rapidamente geralmente produz uma imagem de centenas de MB. A imagem contém JDK, Maven, código-fonte e o diretório .m2. A cada vez que você modifica uma linha de código, Docker re-baixa todas as dependências do zero.
Este artigo o guia no uso de multi-stage build para separar a etapa de build da etapa de execução. Após este artigo, você será capaz de escrever um Dockerfile para uma aplicação Spring Boot 3.x (Java 21) com uma imagem pequena, rebuild rápido graças ao cache, executar testes em um stage separado e rodar o processo com um usuário que não seja root.
2. Problemas do Dockerfile em um único stage
Vamos usar uma pequena aplicação com um endpoint que retorna o status do pedido:
package com.nonla.orders;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@SpringBootApplication
public class OrderApplication {
public static void main(String[] args) {
SpringApplication.run(OrderApplication.class, args);
}
}
@RestController
class OrderController {
@GetMapping("/orders/{id}/status")
String status(@PathVariable long id) {
return id > 0 ? "CONFIRMED" : "UNKNOWN";
}
}
As dependências em pom.xml precisam apenas de web e test:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
A forma comum de escrever um Dockerfile “que funciona” geralmente se parece com isto:
FROM maven:3.9-eclipse-temurin-21
WORKDIR /app
COPY . .
RUN mvn package -DskipTests
CMD ["java", "-jar", "target/orders-0.0.1-SNAPSHOT.jar"]
Este Dockerfile tem três problemas:
- Imagem pesada: a imagem final contém Maven, JDK completo, código-fonte e todo o cache
.m2. - Cache deficiente:
COPY . .vem antes demvn package. Basta modificar um arquivo.javae este layer é invalidado, forçando o re-download de todas as dependências. - Superfície de ataque maior: ferramentas de build e código-fonte estão presentes no container de produção.
3. Multi-stage build básico
Multi-stage build permite que um Dockerfile tenha múltiplos comandos FROM. Cada FROM inicia um novo stage. Um stage posterior pode copiar arquivos de um stage anterior usando COPY --from=<nome do stage>. Apenas o último stage se torna a imagem resultante. Os stages intermediários são descartados.
3.1. Stage de build e stage de runtime
# Etapa 1: build
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /workspace
COPY pom.xml .
COPY src ./src
RUN mvn -B package -DskipTests
# Etapa 2: runtime
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /workspace/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
Explicação de cada parte:
AS buildnomeia o primeiro stage. Você usa este nome emCOPY --from=build.- O stage de build usa uma imagem com Maven e JDK para compilar e empacotar o jar.
- O stage de runtime usa
eclipse-temurin:21-jre. Esta imagem contém apenas JRE, semjavacou Maven. COPY --from=buildcopia apenas o arquivo jar para a imagem final.ENTRYPOINTem forma exec (array JSON). Assim,javaroda como PID 1 e recebe o sinalSIGTERMquando o container para.
3.2. Comparação de resultados
Construa e veja o tamanho:
docker build -t orders:single -f Dockerfile.single .
docker build -t orders:multi .
docker images orders
Resultado ilustrativo (números reais dependem das versões de imagem e dependências):
REPOSITORY TAG SIZE
orders single ~560MB
orders multi ~290MB
A maior parte do espaço restante é JRE e o sistema operacional base. Você pode reduzir ainda mais usando uma imagem base mais enxuta como eclipse-temurin:21-jre-alpine. Porém, verifique bem se a aplicação usa bibliotecas nativas.
4. Aproveitando o cache de layers
Cada comando RUN, COPY cria um layer. Docker reutiliza o layer se o comando e entrada não mudarem. Quando um layer muda, todos os layers posteriores devem ser reconstruídos. Portanto, o princípio geral é colocar o que muda menos na frente, o que muda mais atrás.
4.1. Separando o download de dependências
pom.xml muda muito menos que o código-fonte. Vamos copiá-lo primeiro e baixar as dependências em um layer separado:
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
Agora, quando você modificar apenas OrderController.java, Docker reutiliza o layer dependency:go-offline. O log de build parecerá com:
=> CACHED [build 3/5] COPY pom.xml .
=> CACHED [build 4/5] RUN mvn -B dependency:go-offline
=> [build 5/5] COPY src ./src
Nota:
dependency:go-offlinenem sempre baixa 100% dos plugins que Maven precisa na etapapackage. Alguns plugins ainda podem ser baixados depois. Mas o resultado é muito melhor do que não separar.
4.2. Cache mount com BuildKit
BuildKit (padrão no Docker atual) suporta --mount=type=cache. Um diretório montado é mantido entre builds, mas não incluído na imagem:
# syntax=docker/dockerfile:1
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /workspace
COPY pom.xml .
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 mvn -B package -DskipTests
- A linha
# syntax=docker/dockerfile:1habilita a sintaxe Dockerfile completa mais recente. /root/.m2é cacheado no lado do builder. Mesmo quepom.xmlmude, Maven só baixa as novas dependências em vez de tudo novamente.
Você pode combinar ambas as técnicas. Em CI, cache mount só funciona se o builder for mantido entre execuções. Se não, separar layers como em 4.1 combinado com cache de registry ainda é uma opção mais confiável.
5. Separando layers para jar do Spring Boot
Um fat jar do Spring Boot contém seu código e dezenas de MB de bibliotecas. Se você copiar o jar inteiro para a imagem, cada mudança de código cria um novo layer de vários MB. O registry precisa armazenar este layer extra e o servidor precisa puxá-lo tudo novamente.
Spring Boot suporta descompactar jar em layers de acordo com frequência de mudança. A partir do Spring Boot 3.3, você usa jarmode=tools:
# syntax=docker/dockerfile:1
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /workspace
COPY pom.xml .
RUN --mount=type=cache,target=/root/.m2 mvn -B dependency:go-offline
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 mvn -B package -DskipTests
FROM eclipse-temurin:21-jre AS extract
WORKDIR /extract
COPY --from=build /workspace/target/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --launcher --destination layers
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=extract /extract/layers/dependencies/ ./
COPY --from=extract /extract/layers/spring-boot-loader/ ./
COPY --from=extract /extract/layers/snapshot-dependencies/ ./
COPY --from=extract /extract/layers/application/ ./
EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
Pontos principais:
- O stage
extractdescompacta o jar em quatro diretórios:dependencies,spring-boot-loader,snapshot-dependencies,application. - Quatro comandos
COPYcriam quatro layers, ordenados do que muda menos para o que muda mais. - Quando você modifica o código, apenas o layer
applicationmuda. Esse layer é geralmente apenas alguns centos de KB. - A opção
--launchermantém o launcher. Portanto,ENTRYPOINTchamaJarLauncherem vez de-jar.
Nota: Com Spring Boot 3.0–3.2, você usa a sintaxe antiga
java -Djarmode=layertools -jar app.jar extract. O pacote do launcher também difere entre versões: a partir da 3.2 éorg.springframework.boot.loader.launch.JarLauncher, antes eraorg.springframework.boot.loader.JarLauncher. Usar o nome de classe errado causaráClassNotFoundExceptionna inicialização do container.
6. Executando testes em um stage separado
Usar -DskipTests no stage de build faz a construção ser mais rápida, mas você não quer fazer push de uma imagem não testada para o registry. Multi-stage permite separar testes em um stage próprio e escolher qual stage construir com --target.
Os testes do controller usam JUnit 5 e AssertJ (já inclusos em spring-boot-starter-test):
package com.nonla.orders;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.test.web.servlet.MockMvc;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired
MockMvc mockMvc;
@Test
void givenPositiveId_whenGetStatus_thenConfirmed() throws Exception {
String body = mockMvc.perform(get("/orders/42/status"))
.andReturn().getResponse().getContentAsString();
assertThat(body).isEqualTo("CONFIRMED");
}
@Test
void givenZeroId_whenGetStatus_thenUnknown() throws Exception {
String body = mockMvc.perform(get("/orders/0/status"))
.andReturn().getResponse().getContentAsString();
assertThat(body).isEqualTo("UNKNOWN");
}
}
Adicione um stage test baseado no stage de build:
FROM build AS test
RUN --mount=type=cache,target=/root/.m2 mvn -B test
Em CI, você executa testes primeiro e depois constrói a imagem de runtime:
docker build --target test -t orders:test .
docker build -t orders:1.0.0 .
O primeiro comando constrói apenas até o stage test. Se um teste falhar, o comando RUN retorna um código de erro diferente de 0 e a construção para:
[ERROR] Tests run: 2, Failures: 1, Errors: 0, Skipped: 0
ERROR: failed to solve: process "/bin/sh -c mvn -B test" did not complete successfully: exit code: 1
O segundo comando constrói o stage final. Com BuildKit, o stage test não é referenciado pelo stage final, portanto é descartado. A imagem de produção não executa testes novamente. Graças ao cache, os stages comuns também não são reconstruídos.
Nota: Você não deve executar testes de integração que precisam de Docker (como Testcontainers) dentro de
docker build. O ambiente de build não tem um daemon Docker. Execute esse tipo de teste em um passo de CI fora.
7. Segurança: usuário não-root e .dockerignore
7.1. Não executar como root
Por padrão, processos no container executam como root. Se a aplicação for comprometida, o atacante terá privilégios de root no container. Crie um usuário dedicado no stage de runtime:
FROM eclipse-temurin:21-jre
RUN groupadd --system spring && useradd --system --gid spring spring
WORKDIR /app
COPY --from=extract --chown=spring:spring /extract/layers/dependencies/ ./
COPY --from=extract --chown=spring:spring /extract/layers/spring-boot-loader/ ./
COPY --from=extract --chown=spring:spring /extract/layers/snapshot-dependencies/ ./
COPY --from=extract --chown=spring:spring /extract/layers/application/ ./
USER spring:spring
EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
Verificação rápida:
docker run --rm --entrypoint id orders:1.0.0
uid=999(spring) gid=999(spring) groups=999(spring)
Os UIDs/GIDs específicos podem variar, desde que não sejam 0. Com imagens Alpine, use addgroup -S spring && adduser -S spring -G spring em vez de groupadd e useradd.
7.2. Adicione .dockerignore
O build context é todo o diretório que você passa para docker build. Se não filtrado, Docker enviará target/, .git/ e arquivos de configuração de IDE para o builder. Isso deixa a construção lenta, quebra cache e pode expor informações sensíveis.
target/
.git/
.idea/
*.iml
.vscode/
.env
Dockerfile*
Coloque este arquivo no diretório raiz do projeto, ao lado de Dockerfile.
8. Erros comuns
COPY . .muito cedo: quebra o cache de dependências. Copiepom.xmlprimeiro,srcdepois.- Usar JDK no stage de runtime: funciona mas desperdiça dezenas a centenas de MB. Produção precisa apenas de JRE, a menos que você precise de ferramentas como
jcmdpara diagnóstico. - Wildcard correspondendo múltiplos jars:
COPY --from=build /workspace/target/*.jar app.jarfalhará setarget/tem*-plain.jarou*-sources.jar. Defina<finalName>fixo empom.xmlou desative artifacts secundários. - Forma shell para
ENTRYPOINT:ENTRYPOINT java -jar app.jarroda via/bin/sh -c. Neste caso,javanão recebeSIGTERM, então Spring não faz shutdown gracioso e o container é morto após o timeout. - Tag
latestpara imagem base: a construção hoje e no próximo mês pode diferir. Fixe a versão (por exemplo,eclipse-temurin:21-jre) ou fixe o digest para reprodutibilidade completa. - Colocar segredos em
ARG/ENV: valores podem ser expostos viadocker history. Quando precisar de token para baixar dependências privadas, useRUN --mount=type=secret.
Quando não é necessário ser sofisticado? Para scripts pequenos ou imagens apenas para ambiente de dev pessoal, um stage é suficiente. Além disso, se seu time já usa mvn spring-boot:build-image (Cloud Native Buildpacks) ou Jib, essas ferramentas já cuidam da separação de layers e non-root. Você não precisa escrever Dockerfile manualmente.
9. Conclusão
Multi-stage build separa claramente “ambiente para build” e “ambiente para executar”. A imagem final contém apenas o que a aplicação realmente precisa. Combinado com ordem de layers lógica, cache mount, separação de layers do Spring Boot, stage de teste dedicado e usuário não-root, você tem um Dockerfile que constrói rápido, imagem compacta e significativamente mais segura.
Você pode começar com o Dockerfile da seção 3. Depois, aplique cada melhoria gradualmente e meça novamente o tempo de build e o tamanho da imagem após cada passo para ver o efeito claramente.