GraalVM Native Image com AWS Fargate e Graviton2 para reduzir custo e startup de microservices Java - Meu Universo Nerd

Existe uma forma de fazer seu microservice Java subir em 50 milissegundos em vez de 8 segundos, sem reescrever uma linha da regra de negócio. A maioria dos devs ainda empacota a JVM inteira dentro do container e paga a conta disso todo mês no Fargate.

Com GraalVM Native Image e o AWS SDK for Java 2.x, você compila a aplicação para um binário nativo e o startup despenca de cara. Nesse artigo você vai montar o build do zero, com Maven, Dockerfile e deploy no Fargate, e ainda vai entender por que essa decisão virou pergunta de entrevista sênior em fintech.

Existe uma forma de fazer seu microservice Java subir em 50 milissegundos em vez de 8 segundos, sem reescrever uma linha da regra de negócio. A maioria dos devs ainda empacota a JVM inteira dentro do container e paga a conta disso todo mês no Fargate.

Com GraalVM Native Image e o AWS SDK for Java 2.x, você compila a aplicação para um binário nativo e o startup despenca de cara. Nesse artigo você vai montar o build do zero, com Maven, Dockerfile e deploy no Fargate, e ainda vai entender por que essa decisão virou pergunta de entrevista sênior em fintech.

Bora resolver isso de vez.

Por que a JVM tradicional pesa no Fargate

O modelo clássico funciona assim: você empacota um JAR, sobe uma imagem com a JVM completa e, toda vez que o container inicia, a máquina virtual precisa carregar classes, fazer warmup do JIT e só então sua aplicação fica pronta. Pensa numa marmita gigante que carrega o fogão junto com a comida. Funciona, mas você está transportando peso que não vai usar no prato.

Na prática, um microservice Spring Boot médio leva de 4 a 8 segundos para responder o primeiro request. Em ambiente serverless isso é dinheiro saindo do bolso. No Fargate você paga por vCPU e memória durante todo o tempo de vida da task, inclusive durante o cold start. Em funções Lambda com Java, esse atraso vira latência direta para o usuário final.

# Dockerfile tradicional: a JVM inteira viaja junto
FROM eclipse-temurin:21-jre
COPY target/pedidos-service.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
# Imagem final: ~280MB. Startup: ~6s. Memoria ociosa: ~350MB

O problema não é o seu código. É a arquitetura de runtime. A JVM foi desenhada para processos longos em servidores que ligam e ficam de pé por semanas. Container efêmero que sobe, atende um pico e morre é o cenário oposto. Como Tech Leader, aprendi que escolher o runtime errado para o ciclo de vida da carga é o tipo de decisão que ninguém percebe até a fatura da AWS chegar.

A documentação oficial do GraalVM Native Image detalha a fundo o mecanismo de compilação ahead-of-time que resolve isso.

GraalVM Native Image: o que muda de verdade

Native Image faz compilação ahead-of-time (AOT). Em vez de levar a JVM e interpretar bytecode em tempo de execução, o GraalVM analisa toda a aplicação no momento do build, descobre quais classes e métodos são realmente alcançáveis e gera um executável nativo do sistema operacional. Sem JIT, sem warmup, sem carregar classe em runtime.

O resultado é um binário que sobe quase instantaneamente e consome uma fração da memória. O ponto que antes travava a adoção era a configuração de reflection: o GraalVM precisa saber em build time tudo que será acessado dinamicamente. A boa notícia é que isso mudou.

<!-- pom.xml: o plugin nativo do GraalVM -->
<plugin>
    <groupId>org.graalvm.buildtools</groupId>
    <artifactId>native-maven-plugin</artifactId>
    <configuration>
        <imageName>pedidos-service</imageName>
        <buildArgs>
            <buildArg>--no-fallback</buildArg>
        </buildArgs>
    </configuration>
</plugin>

Se você usa serviços da AWS dentro do app, o AWS SDK for Java 2.x passou a embarcar os metadados de GraalVM reachability a partir da versão 2.28.7. Tradução: aquele inferno de escrever dezenas de arquivos reflect-config.json na mão para o SDK funcionar em native image acabou. Vem pronto.

import software.amazon.awssdk.services.dynamodb.DynamoDbClient;

@Service
public class PedidoRepository {

    private final DynamoDbClient dynamo;

    public PedidoRepository(DynamoDbClient dynamo) {
        this.dynamo = dynamo; // SDK 2.28.7+ ja vem com reachability metadata
    }

    public Pedido buscar(String id) {
        // Mesmo codigo da versao JVM. Nada muda aqui.
        return mapper.fromItem(dynamo.getItem(req -> req.key(keyOf(id))));
    }
}

Repara no detalhe que muda tudo: o código de negócio é idêntico. Você não reescreve repository, controller nem service. A mudança é no build e no empacotamento, não na lógica.

Build na prática: Maven + Dockerfile multi-stage

Aqui está o jeito que uso em produção. Um Dockerfile multi-stage que compila o nativo dentro de uma imagem com GraalVM e copia só o binário para uma imagem final minúscula. Assim a imagem de deploy não carrega compilador nem JDK.

# Stage 1: compila o binario nativo
FROM ghcr.io/graalvm/native-image-community:21 AS build
WORKDIR /app
COPY . .
RUN ./mvnw -Pnative native:compile -DskipTests

# Stage 2: imagem final so com o executavel
FROM debian:stable-slim
COPY --from=build /app/target/pedidos-service /pedidos-service
EXPOSE 8080
ENTRYPOINT ["/pedidos-service"]
# Imagem final: ~90MB. Startup: ~0.05s. Memoria: ~64MB

O comando de build local é direto. Compila, gera o binário e você já testa antes de subir para o registry:

# compila o nativo (precisa do GraalVM instalado)
./mvnw -Pnative native:compile -DskipTests

# roda o binario direto, sem java -jar
./target/pedidos-service

# build da imagem e push para o ECR
docker build -t pedidos-service:native .
docker tag pedidos-service:native <conta>.dkr.ecr.sa-east-1.amazonaws.com/pedidos-service:native
docker push <conta>.dkr.ecr.sa-east-1.amazonaws.com/pedidos-service:native

Um ponto honesto que poucos falam: o build nativo é mais lento que o build normal. Compilar AOT pode levar de 2 a 5 minutos contra os segundos de um JAR. Você troca tempo de build por tempo de startup e custo de runtime. Para serviço que escala muito, o trade-off compensa. Para um batch que roda uma vez por dia, talvez não. Vale conferir o guia do Spring Boot Native Images se você usa Spring, porque o ecossistema tem hints prontos para a maioria dos starters.

Deploy no Fargate e o pulo do Graviton2

Com a imagem nativa no ECR, o deploy no Fargate é o de sempre: task definition apontando para a imagem. A diferença aparece no bolso. E tem uma alavanca extra que separa o pleno do sênior na hora de otimizar custo: rodar em Graviton2, os processadores ARM da AWS.

{
  "family": "pedidos-service",
  "requiresCompatibilities": ["FARGATE"],
  "cpu": "256",
  "memory": "512",
  "runtimePlatform": {
    "cpuArchitecture": "ARM64",
    "operatingSystemFamily": "LINUX"
  }
}

Os números que vejo na prática quando saio de JVM tradicional para native image em Fargate ARM:

  • Startup: de cerca de 6 segundos para perto de 50 milissegundos, uma redução próxima de 96%
  • Memória ociosa: de aproximadamente 350MB para algo em torno de 64MB, o que permite reduzir a memória reservada da task
  • Imagem: de 280MB para perto de 90MB, com push e pull mais rápidos no CI/CD
  • Custo: Graviton2 entrega até cerca de 40% melhor price-performance que x86 equivalente

Junta tudo: você liga menos memória por task, escala mais rápido porque o container fica pronto quase na hora, e ainda paga menos por vCPU no ARM. Em um serviço com autoscaling agressivo, isso é a diferença entre uma fatura saudável e uma reunião desconfortável com o time de finanças.

E quando uma lib não tem suporte nativo?

Esse é o momento em que a maioria desiste do native image e volta correndo para a JVM. Não precisa. Quando uma biblioteca de terceiros usa reflection, serialização ou proxy dinâmico sem fornecer os hints, o build nativo falha ou o app quebra em runtime com um ClassNotFoundException estranho. A solução sênior é registrar os hints você mesmo, e o Spring Boot 3 deixa isso elegante com a interface RuntimeHintsRegistrar.

import org.springframework.aot.hint.RuntimeHints;
import org.springframework.aot.hint.RuntimeHintsRegistrar;
import org.springframework.aot.hint.MemberCategory;

// Registra reflection para uma classe de lib legada
public class LegadoHints implements RuntimeHintsRegistrar {

    @Override
    public void registerHints(RuntimeHints hints, ClassLoader cl) {
        hints.reflection().registerType(
            PedidoLegado.class,
            MemberCategory.INVOKE_PUBLIC_METHODS,
            MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS
        );
    }
}

Aí você pluga o registrar na aplicação com uma anotação, e o build nativo passa a enxergar a classe que antes era invisível para a análise AOT:

@SpringBootApplication
@ImportRuntimeHints(LegadoHints.class)
public class PedidosApplication {
    public static void main(String[] args) {
        SpringApplication.run(PedidosApplication.class, args);
    }
}

Na empresa em que trabalhei, foi exatamente assim que destravamos a migração de um serviço que dependia de uma lib interna de integração com mainframe sem nenhum suporte a GraalVM. Meia tarde de hints e o serviço que levava 7 segundos para subir passou a responder em menos de 100 milissegundos. O time de plataforma achou que tinha trocado a linguagem. Trocamos só o empacotamento. Se quiser o passo a passo do agente de teste para validar o binário antes de subir, o guia de native image do AWS SDK traz o checklist de smoke test que usamos.

Quando usar e quando segurar a empolgação

Native Image não é bala de prata. É uma ferramenta com contexto certo de uso.

Vale a pena quando:

  • Microserviços e funções Lambda que sobem e descem com frequência (cold start importa)
  • Serviços com autoscaling agressivo, onde startup rápido reduz custo real
  • Apps onde a fatura de cloud já é uma dor concreta no fim do mês

Pense duas vezes quando:

  • O serviço fica de pé por semanas e o startup não é gargalo nenhum
  • Você depende de libs antigas que usam reflection pesada sem hints de native image
  • Seu time não tem espaço no CI para builds mais longos agora

Se você quer revisar como o ciclo de vida da thread afeta esse tipo de decisão, vale ler também nosso conteúdo sobre Virtual Threads no Spring Boot e o material sobre observabilidade com OpenTelemetry em Java, porque otimizar custo sem enxergar métrica é apostar no escuro. Quem quiser ir mais fundo no empacotamento, temos um guia de Docker para aplicações Java do jeito certo.

Perguntas frequentes

GraalVM Native Image funciona com qualquer aplicação Spring Boot?
Funciona com a maioria a partir do Spring Boot 3, que já traz suporte de primeira classe a AOT. O cuidado fica com libs de terceiros que usam reflection sem fornecer hints. Spring Boot gera boa parte dos hints automaticamente no build nativo.

Preciso reescrever meu código para migrar?
Não. Controller, service e repository continuam iguais. A mudança é no build (plugin nativo, profile Maven) e no Dockerfile. O ganho vem do empacotamento, não de mexer na regra de negócio.

Por que o build nativo demora tanto?
Porque a compilação ahead-of-time analisa toda a aplicação para descobrir o que é alcançável e gerar o executável. Você troca tempo de build por startup instantâneo e menos memória em runtime. Em CI, vale paralelizar ou cachear estágios.

Graviton2 exige mudar o código Java?
Não. A JVM e o GraalVM rodam em ARM64 normalmente. Você só ajusta a imagem base para ARM e o campo cpuArchitecture na task definition do Fargate. O bytecode e o binário nativo cuidam do resto.

Takeaways e próximo passo

  • Native Image corta startup e memória sem você reescrever a regra de negócio, só mexendo em build e empacotamento
  • AWS SDK 2.28.7+ já traz os metadados de reachability, o que mata a parte mais chata da configuração de reflection
  • Fargate em Graviton2 soma até cerca de 40% de price-performance ao ganho do nativo, e isso é exatamente o tipo de decisão que cai em entrevista sênior de otimização de custo cloud

Você já rodou Java nativo na sua stack ou ainda empacota a JVM inteira no container? Conta nos comentários como está sendo a fatura da AWS por aí, quero trocar número real com você.

Na próxima semana eu mostro como medir esse ganho de verdade, com observabilidade de cold start e custo por request no CloudWatch, pra você levar dado e não achismo para a reunião de arquitetura.