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 de mvn package. Basta modificar um arquivo .java e 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 build nomeia o primeiro stage. Você usa este nome em COPY --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, sem javac ou Maven.
  • COPY --from=build copia apenas o arquivo jar para a imagem final.
  • ENTRYPOINT em forma exec (array JSON). Assim, java roda como PID 1 e recebe o sinal SIGTERM quando 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-offline nem sempre baixa 100% dos plugins que Maven precisa na etapa package. 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:1 habilita a sintaxe Dockerfile completa mais recente.
  • /root/.m2 é cacheado no lado do builder. Mesmo que pom.xml mude, 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 extract descompacta o jar em quatro diretórios: dependencies, spring-boot-loader, snapshot-dependencies, application.
  • Quatro comandos COPY criam quatro layers, ordenados do que muda menos para o que muda mais.
  • Quando você modifica o código, apenas o layer application muda. Esse layer é geralmente apenas alguns centos de KB.
  • A opção --launcher mantém o launcher. Portanto, ENTRYPOINT chama JarLauncher em 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 era org.springframework.boot.loader.JarLauncher. Usar o nome de classe errado causará ClassNotFoundException na 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. Copie pom.xml primeiro, src depois.
  • 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 jcmd para diagnóstico.
  • Wildcard correspondendo múltiplos jars: COPY --from=build /workspace/target/*.jar app.jar falhará se target/ tem *-plain.jar ou *-sources.jar. Defina <finalName> fixo em pom.xml ou desative artifacts secundários.
  • Forma shell para ENTRYPOINT: ENTRYPOINT java -jar app.jar roda via /bin/sh -c. Neste caso, java não recebe SIGTERM, então Spring não faz shutdown gracioso e o container é morto após o timeout.
  • Tag latest para 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 via docker history. Quando precisar de token para baixar dependências privadas, use RUN --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.

dockerdockerfilemulti-stage buildspring bootjava