Spring Boot e Spring Cloud

Empacotar e fazer deploy de Spring Boot 3: jar executável, contêineres e imagens nativas

Intermediário9 min de leituraAtualizado

Esta página foi traduzida do inglês com a ajuda de IA e conferida com o original. Ler a versão em inglês

Você vai conseguir levar uma aplicação Spring Boot 3 do código-fonte à produção: entender a estrutura do jar executável, gerar uma imagem de contêiner pequena e que aproveite bem o cache com um Dockerfile em camadas ou com Cloud Native Buildpacks, dimensionar a JVM dentro de um contêiner, configurar o graceful shutdown para rolling deployments e decidir quando um WAR ou uma imagem nativa GraalVM é a escolha certa.

O que você vai aprender

  • Descrever o jar executável gerado pelos plugins Maven e Gradle do Spring Boot e como ele inicializa
  • Gerar uma imagem de contêiner com um Dockerfile multi-stage em camadas ou com o goal build-image e Cloud Native Buildpacks
  • Configurar memória e CPU da JVM para contêineres e injetar a configuração pelo ambiente
  • Habilitar o graceful shutdown e a sinalização de readiness para que rolling deployments não percam nenhuma requisição
  • Explicar quando fazer deploy de um WAR em um servidor externo e quando compilar uma imagem nativa GraalVM

É no build que as promessas de um serviço Spring Boot são cumpridas ou quebradas: se o artefato que passou nos testes é o mesmo que chega à produção, se uma mudança de uma linha exige reconstruir a imagem inteira, se um rolling deployment perde requisições e se a JVM dentro de um contêiner sabe quanta memória tem de fato. Esta página acompanha uma aplicação Spring Boot 3 desde o package até um contêiner em execução e cobre as decisões pelo caminho, incluindo os dois modelos alternativos de empacotamento, o WAR e a imagem nativa.

O jar executável

./mvnw package (ou ./gradlew bootJar) executa a etapa repackage do plugin do Spring Boot, que transforma o jar comum em um executável autocontido:

app.jar
├── META-INF/MANIFEST.MF          Main-Class: org.springframework.boot.loader.launch.JarLauncher
│                                 Start-Class: com.example.shop.ShopApplication
├── org/springframework/boot/loader/   the launcher classes
├── BOOT-INF/
│   ├── classes/                  your compiled classes and resources
│   ├── lib/                      every runtime dependency as a nested jar
│   ├── classpath.idx             load order of the dependencies
│   └── layers.idx                layer assignment, used for container images

java -jar app.jar inicia o JarLauncher, que cria um class loader capaz de ler os jars aninhados no lugar (por isso eles são armazenados sem compressão) e depois chama o método main da Start-Class. Nada é extraído para o disco, nenhum servidor de aplicação é necessário e o mesmo arquivo roda em um notebook, em uma máquina virtual ou em um contêiner. No Maven, o jar anterior ao repackage é mantido como app.jar.original, e é esse jar simples que você publica se outro módulo depender das suas classes; o executável não pode ser usado como biblioteca.

No Maven, o plugin é herdado de spring-boot-starter-parent; com um parent próprio, é preciso declarar o goal repackage explicitamente. O plugin org.springframework.boot do Gradle fornece bootJar e mantém a task padrão jar, cuja saída recebe o classifier -plain para que os dois não sejam confundidos. build-info no Maven e springBoot { buildInfo() } no Gradle geram META-INF/build-info.properties, que o endpoint info do Actuator expõe e que é a forma mais limpa de saber exatamente qual versão está rodando.

Rodar o jar como está funciona bem em uma máquina virtual atrás de um gerenciador de processos. Uma unit do systemd é o arranjo mais comum:

[Unit]
Description=Shop service
After=network.target

[Service]
User=shop
WorkingDirectory=/opt/shop
ExecStart=/usr/bin/java -XX:MaxRAMPercentage=75 -jar /opt/shop/app.jar
EnvironmentFile=/etc/shop/env
SuccessExitStatus=143
Restart=on-failure

[Install]
WantedBy=multi-user.target

SuccessExitStatus=143 impede que o systemd reporte falha por causa do código de saída que a JVM retorna após um SIGTERM. A configuração vem de /etc/shop/env e de um config/application.yml ao lado do jar, ambos fora do artefato.

Contêineres: o problema do rebuild rápido

Copiar o app.jar para uma imagem funciona, mas erra em um ponto específico: o jar inteiro vira uma única camada, então cada mudança de código invalida a camada que contém 50 MB de dependências inalteradas, e cada deploy faz push e pull de tudo de novo. O Boot resolve isso com layered jars. O layers.idx distribui o conteúdo do jar em camadas ordenadas da menos para a mais propensa a mudar: dependencies, spring-boot-loader, snapshot-dependencies, application. O modo tools do jar faz a extração:

java -Djarmode=tools -jar app.jar extract --layers --destination extracted
# Boot 3.2 and earlier: java -Djarmode=layertools -jar app.jar extract

Um Dockerfile multi-stage então copia cada camada separadamente, de modo que uma mudança de código só reconstrói o último COPY:

# ---- build stage: produce and explode the jar
FROM eclipse-temurin:21-jdk AS build
WORKDIR /workspace
COPY .mvn .mvn
COPY mvnw pom.xml ./
RUN --mount=type=cache,target=/root/.m2 ./mvnw -q dependency:go-offline
COPY src src
RUN --mount=type=cache,target=/root/.m2 ./mvnw -q -DskipTests package \
 && cp target/*.jar app.jar \
 && java -Djarmode=tools -jar app.jar extract --layers --destination extracted

# ---- runtime stage: small JRE, non-root, one COPY per layer
FROM eclipse-temurin:21-jre
RUN useradd --system --uid 1001 app
WORKDIR /app
COPY --from=build --chown=app /workspace/extracted/dependencies/ ./
COPY --from=build --chown=app /workspace/extracted/spring-boot-loader/ ./
COPY --from=build --chown=app /workspace/extracted/snapshot-dependencies/ ./
COPY --from=build --chown=app /workspace/extracted/application/ ./
USER app
EXPOSE 8080
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-jar", "app.jar"]

A estrutura gerada por extract --layers coloca em cada camada um app.jar enxuto e um diretório lib/, e o launcher lê esses arquivos do sistema de arquivos em vez de entradas aninhadas, o que ainda deixa a inicialização um pouco mais rápida que a do fat jar. Como dependencies só muda quando o pom.xml muda, os builds do dia a dia geram apenas uma camada nova e pequena.

O guia de fundamentos de Docker explica de forma geral builds multi-stage, cache mounts e .dockerignore; os pontos específicos do Boot são a extração das camadas e a escolha de uma imagem JRE (-jre, não -jdk) para o estágio de runtime.

Buildpacks em vez de um Dockerfile

./mvnw spring-boot:build-image (ou ./gradlew bootBuildImage) gera uma imagem OCI sem nenhum Dockerfile, usando Cloud Native Buildpacks (por padrão, a implementação Paketo). O buildpack detecta uma aplicação Boot, escolhe uma JVM, aplica a mesma separação em camadas, calcula as flags de memória da JVM na inicialização do contêiner a partir do limite do cgroup com seu memory calculator, roda com um usuário não root e registra uma lista de materiais de software (SBOM). A configuração é feita por propriedades do plugin ou variáveis de ambiente:

<plugin>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-maven-plugin</artifactId>
  <configuration>
    <image>
      <name>registry.example.com/shop/orders:${project.version}</name>
      <env>
        <BP_JVM_VERSION>21</BP_JVM_VERSION>
        <BPE_APPEND_JAVA_TOOL_OPTIONS>-XX:+ExitOnOutOfMemoryError</BPE_APPEND_JAVA_TOOL_OPTIONS>
      </env>
    </image>
  </configuration>
</plugin>

Buildpacks precisam de um daemon Docker (ou de um socket compatível, como o do Podman) durante o build, e as imagens base são as publicadas pela Paketo, o que é uma vantagem ou um impedimento dependendo da política de imagens da sua organização. Quando um time de plataforma exige uma imagem base com hardening, o caminho do Dockerfile é o que se encaixa.

Configurando a JVM em um contêiner

JVMs modernas (incluindo Java 17 e 21) leem os limites de cgroup do contêiner e dimensionam o heap padrão de acordo, mas esse padrão é um quarto do limite, o que desperdiça a maior parte de um contêiner de 1 GiB. Prefira um percentual a um -Xmx fixo, para que a mesma imagem funcione com limites diferentes:

ConfiguraçãoRecomendação
Heap-XX:MaxRAMPercentage=75 (menos se a aplicação usar muita memória off-heap); evite -Xmx em imagens
Heap inicial-XX:InitialRAMPercentage igual ao máximo evita pausas de redimensionamento em serviços em regime estável
CPUA JVM deriva seu paralelismo da cota de CPU; um limite abaixo de 2 CPUs deixa o aquecimento do G1 e do JIT visivelmente mais lento
Garbage collectorG1 é o padrão e atende bem; ZGC ou Shenandoah para serviços sensíveis a latência com heaps grandes; Serial GC para contêineres muito pequenos
Diagnóstico-XX:+ExitOnOutOfMemoryError para que o orquestrador reinicie um processo morto em vez de deixar um zumbi; -XX:+HeapDumpOnOutOfMemoryError para um volume montado

JAVA_TOOL_OPTIONS é lida em toda inicialização da JVM e é a variável de ambiente convencional para injetar essas flags a partir de um manifesto Kubernetes sem reconstruir a imagem. A memória total do contêiner precisa superar o heap com folga suficiente para metaspace, pilhas das threads (cada thread do Tomcat reserva espaço de pilha), code cache e direct buffers; um contêiner encerrado com código de saída 137 é o kernel aplicando o limite, não um OutOfMemoryError do Java.

Boot 3.2 e Java 21 trazem uma configuração que vale conhecer: spring.threads.virtual.enabled=true faz o Tomcat e os task executors do Boot usarem virtual threads, o que remove o teto do pool de threads em serviços limitados por I/O sem alterar o código.

Configuração e secrets em runtime

A imagem não deve conter valores específicos de ambiente. Injete-os pelos mecanismos descritos no guia de configuração, nesta ordem de preferência: variáveis de ambiente para valores simples, arquivos montados para secrets (spring.config.import=optional:configtree:/run/secrets/) e um config server ou gerenciador de secrets para tudo o que é compartilhado entre muitos serviços. Um trecho de deployment Kubernetes:

env:
  - name: SPRING_PROFILES_ACTIVE
    value: prod
  - name: JAVA_TOOL_OPTIONS
    value: "-XX:MaxRAMPercentage=75 -XX:+ExitOnOutOfMemoryError"
  - name: SPRING_DATASOURCE_PASSWORD
    valueFrom:
      secretKeyRef: { name: orders-db, key: password }
resources:
  requests: { cpu: "500m", memory: "768Mi" }
  limits: { memory: "768Mi" }

Definir o request de memória igual ao limite garante ao contêiner uma alocação reservada; o limite de CPU costuma ser omitido para que a JVM possa usar picos de CPU durante a inicialização e a compilação JIT.

Graceful shutdown e rolling deployments

Fazer deploy de uma nova versão significa parar instâncias antigas enquanto há requisições em andamento. Duas configurações tornam isso seguro:

server:
  shutdown: graceful
spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s

Ao receber SIGTERM, o Boot para de aceitar novas conexões, deixa as requisições ativas terminarem até o limite do timeout e então encerra o contexto. Com a readiness probe do Kubernetes apontando para /actuator/health/readiness (veja o guia de observabilidade), a sequência fica assim: o pod é marcado como terminating, a readiness passa a recusar tráfego, o Service para de rotear para ele, as requisições em andamento são concluídas e o processo termina. Defina o terminationGracePeriodSeconds do pod acima do timeout do Boot e considere um sleep curto no preStop para que os load balancers tenham tempo de perceber a mudança de readiness antes que o processo comece a desligar.

A inicialização também importa: um rolling update que promove um pod antes de ele conseguir atender tráfego causa erros, exatamente como uma parada abrupta. Use uma startup probe ou um initialDelaySeconds de readiness que reflita o tempo real de inicialização, que você pode ler no endpoint startup do Actuator ou na linha de log que o Boot escreve quando a aplicação está pronta.

Alternativas: arquivos WAR e imagens nativas

O deploy de WAR em um Tomcat, Jetty ou WildFly externo continua sendo suportado nas organizações que padronizaram um servidor de aplicação. Mude o packaging para war, marque spring-boot-starter-tomcat como provided e estenda SpringBootServletInitializer para que o servidor consiga inicializar a aplicação sem um método main. Você perde o controle sobre as versões da JVM e do servidor, não ganha nada em desempenho e complica a configuração; escolha esse caminho só quando o modelo operacional exigir.

Imagens nativas GraalVM compilam a aplicação antecipadamente em um executável da plataforma. O Spring Boot 3 oferece suporte a isso com seu motor ahead-of-time (AOT), que pré-calcula as definições de beans e gera os hints de reflection, proxy e recursos de que o compilador nativo precisa. Com as GraalVM Native Build Tools configuradas (o profile native em um projeto gerado pelo Spring Initializr), ./mvnw -Pnative native:compile gera um binário que sobe em dezenas de milissegundos e roda com uma fração da memória; ./mvnw -Pnative spring-boot:build-image faz o build dentro de um buildpack, sem precisar de GraalVM local. O preço é um build lento e que consome muita memória, restrições a reflection e proxies dinâmicos em runtime (as bibliotecas precisam de hints, que o Spring fornece para os próprios projetos e para muitas dependências comuns) e a premissa de mundo fechado: profiles e condições são avaliados no build, então uma imagem nativa não consegue trocar de conjunto de beans ativando um profile em runtime. Teste o próprio binário nativo na CI; testes passando na JVM não provam que a imagem funciona.

Entre os dois extremos, o Boot 3.3 trouxe um ganho mais barato: Class Data Sharing (CDS). Extraia o jar com -Djarmode=tools, execute-o uma vez durante o build da imagem com -XX:ArchiveClassesAtExit=app.jsa -Dspring.context.exit=onRefresh (a segunda flag faz o Boot sair assim que o contexto fica pronto, então a execução de treinamento não precisa de banco de dados) e inicie-o na imagem com -XX:SharedArchiveFile=app.jsa; o tempo de inicialização normalmente cai de forma perceptível, sem nenhuma mudança no comportamento da aplicação.

Erros comuns

Gerar a imagem a partir do fat jar como uma única camada. Todo deploy envia todas as dependências de novo. Extraia as camadas ou use buildpacks.

Usar uma imagem JDK em runtime. O estágio de runtime precisa de um JRE; o JDK dobra o tamanho da imagem e adiciona ferramentas que um atacante adoraria.

Rodar como root dentro do contêiner. Uma instrução USER ou o usuário não root padrão do buildpack não custa nada e limita o estrago de um escape do contêiner.

-Xmx fixo embutido na imagem. A mesma imagem não pode mais ser agendada com outro limite de memória sem desperdício ou quedas. Use percentuais.

Deixar de lado o graceful shutdown. O padrão é uma parada imediata; todo rolling update produz então uma rajada de requisições com falha que ninguém atribui ao deploy.

Arquivos de configuração dentro da imagem. Uma imagem com application-prod.yml embutido não pode ser promovida entre ambientes sem alterações e vaza as configurações de produção para qualquer um que consiga fazer pull dela.

Testar imagens nativas só na JVM. Problemas de reflection e de recursos só aparecem no binário compilado.

Principais pontos

  • O plugin do Boot reempacota seu jar em um executável com dependências aninhadas; publique o jar .original se o código também for uma biblioteca.
  • Layered jars extraídos com -Djarmode=tools e copiados camada por camada, ou imagens geradas por build-image com Cloud Native Buildpacks, mantêm pequenos os rebuilds e os pushes de contêiner.
  • Dimensione a JVM com MaxRAMPercentage e injete flags via JAVA_TOOL_OPTIONS; deixe folga no limite do contêiner para a memória fora do heap.
  • Mantenha a configuração específica de cada ambiente e os secrets fora da imagem e injete-os pelo ambiente, por arquivos montados ou por um config server.
  • server.shutdown=graceful, somado a readiness probes e a um período de graça de encerramento adequado, torna os rolling deployments livres de perdas.
  • O empacotamento WAR existe para modelos operacionais legados; imagens nativas GraalVM trocam tempo de build e comportamento dinâmico por inicialização instantânea e pouca memória, e o CDS é um meio-termo mais barato.

Fontes e leituras complementares

Perguntas frequentes

Por que o meu jar do Spring Boot fica muito maior que o meu código?
Porque a etapa repackage do plugin gera um executável autocontido: suas classes em BOOT-INF/classes, todas as dependências de runtime em BOOT-INF/lib e um pequeno launcher que sabe carregar jars aninhados dentro de outro jar. O jar simples gerado pelo compilador também é mantido, com o sufixo .original no Maven, e é ele que você deve publicar se outro projeto quiser depender do seu código como biblioteca.
Devo usar um Dockerfile ou o goal build-image?
Use build-image (Cloud Native Buildpacks) quando quiser uma imagem bem ajustada sem manter um Dockerfile: ele escolhe uma JVM, define as flags de memória, roda como usuário não root e separa a aplicação em camadas automaticamente. Use um Dockerfile quando precisar controlar a imagem base, os certificados corporativos, ferramentas extras ou um padrão de hardening específico. Os dois caminhos geram imagens em camadas que são reconstruídas rapidamente.
Quanta memória devo dar a um contêiner Spring Boot?
Comece pelo heap de que a aplicação realmente precisa sob carga e some espaço para metaspace, pilhas das threads, code cache e a memória nativa usada pela JVM e pelas bibliotecas; um limite de contêiner de aproximadamente o heap mais 25 a 50 % é um ponto de partida comum para serviços pequenos. Deixe a JVM derivar o heap do limite com MaxRAMPercentage em vez de um Xmx fixo, e fique de olho em reinícios do contêiner causados pelo kernel matando um processo que estourou o limite.
Uma imagem nativa GraalVM é sempre mais rápida?
Ela sobe em dezenas de milissegundos e usa muito menos memória em repouso, o que faz diferença em funções serverless e clusters densos. O throughput máximo costuma ser um pouco menor que o de uma JVM com o JIT já aquecido, o build leva minutos e consome muita memória, e bibliotecas que dependem muito de reflection precisam de hints. Para serviços de longa duração com tráfego estável, a JVM continua sendo o padrão; escolha o nativo quando o tempo de inicialização ou o consumo de memória for a restrição.
DevOpsFundamentos de Docker: imagens, containers e DockerfilesO que um container realmente é, como uma imagem é organizada em camadas, como escrever um bom Dockerfile Java ou Node e os comandos e hábitos do dia a dia.Iniciante7 min de leituraDevOpsFundamentos de Kubernetes: arquitetura, Pods, Deployments e ServicesComo um cluster Kubernetes é montado, o que um Pod realmente é e como Deployments, Services, ConfigMaps e labels formam uma aplicação em execução.Intermediário10 min de leituraDevOpsPipelines de entrega contínua: estágios, artefatos e estratégias de deployComo um pipeline transforma um commit em release: build uma vez, promoção de artefatos imutáveis, migrações de banco seguras e deploy sem downtime.Intermediário10 min de leituraSpring Boot Actuator e observabilidade: health, métricas, traces e logsTorne um serviço Spring Boot 3 observável: endpoints do Actuator, probes do Kubernetes, métricas Micrometer para Prometheus, tracing e correlação de logs.Intermediário8 min de leituraConfiguração no Spring Boot: properties, YAML, profiles e @ConfigurationPropertiesConfiguração externalizada no Spring Boot 3: precedência das fontes, YAML e profiles, @ConfigurationProperties tipado, config import e segredos.Iniciante9 min de leituraNuvemCloud-native e Twelve-Factor App: o que cada fator significa para um contêinerOs doze fatores para um serviço Java em contêiner — config, ausência de estado, descartabilidade, logs como fluxos — e o que o modelo deixa de fora hoje.Intermediário11 min de leitura