GraalVM native-image — build e diagnóstico¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-21
Como o binário native do integrador se builda, onde mora a metadata e como se diagnostica um gap. Por que o integrador roda native e jar, a medição e a ordem da troca estão na decisão 0028; a compilação nasceu na 0020. A regra da casa é a norma de build native.
Quem builda¶
- Produção: o job
build_nativedo.github/workflows/pipeline.ymlbuilda oDockerfile.nativenum runner do GitHub e publicafonte.xadm.biz/xadm/integrador-server:native-amd64; o Coolify só puxa. O job roda quandonativeestá nobuild.targetsdodocs/app.json(ou no trailerDeploy:da tag). - O container não lê o
~/.m2: versão de lib da casa ainda não publicada no registro quebra o build doDockerfile.native. Local, onativeCompileresolve pelomavenLocal.
Comandos locais¶
export JAVA_HOME="C:/Dev/graalvm-jdk-25.0.3" # GraalVM 25, com native-image
./gradlew nativeCompile -PnativeQuick # -Ob: loop de diagnóstico (~5 min, pico de ~6 GB)
./gradlew nativeCompile # -Os: o perfil da release
# → build/native/nativeCompile/application(.exe)
docker build -f Dockerfile.native -t integrador-server:native . # o que o CI builda (Linux)
O bloco configure<GraalVMExtension> do build.gradle.kts fixa o nome application, -Ob/-Os,
--gc=serial, -H:IncludeLocales=pt-BR e -march=compatibility; -PnativeCI limita o builder no
runner. O nativeCompile roda com o configuration cache.
Onde mora a metadata¶
- Views JTE: geradas no build pela extensão
NativeResourcesExtensiondo plugin JTE. Nada à mão, e opipeline.ymlconfere a cobertura. - Sentry e
version.properties: vêm daxadm-comum-web. src/main/resources/META-INF/native-image/br.com.xadm/integrador/:reflect-config.json— os appenders do logback que o boot instancia por reflexão (ConsoleAppender,RollingFileAppender,TimeBasedRollingPolicy,PatternLayoutEncoder) e oio.sentry.logback.SentryAppender, cujos<minimumEventLevel>/<minimumBreadcrumbLevel>o Joran resolve por reflexão. Sem o appender do Sentry registrado, o native logaCould not find an appropriate class for property [...]no boot e ignora a config do appender — oSENTRY_ENVIRONMENTdologback.xmldeixa de valer (medido no central-backend).resource-config.json— duas entradas, e a segunda é do próprio SDK, não nossa:firebase-admin-sdk.json, lido porgetResourceAsStreamem runtime. Desde a decisão 0030 a credencial chega porPUSH_CREDENTIALS_PATH(secret file), e esta entrada fica por cobrir o caminho de teste.admin_sdk.properties, recurso na raiz dofirebase-admin-9.2.0.jar, lido no<clinit>decom.google.firebase.internal.SdkUtils(só para carimbar a versão do SDK no header HTTP). Sem ele o primeiroFirebaseMessaging.send()morre comExceptionInInitializerError(NullPointerException: Failed to load: admin_sdk.properties) e todos os seguintes comNoClassDefFoundError: Could not initialize class FirebaseMessagingClientImpl— a JVM marca a classe como erroneous e nunca retenta. Medido em produção na v1.7.2 native (GlitchTip 1299/1300, Sul Plata): push 100% morto, do boot ao restart. OFirebaseNativeResourceConfigTestguarda as duas entradas, porque na JVM elas vêm do jar sem configuração nenhuma — nenhum teste normal falha quando somem da imagem.
reflect-config.json— família@Keydo firebase-admin, toda comallDeclaredFields. O google-http-client serializa e parseia por reflexão (ClassInfolê os declared fields, inclusiveprivate final); sem entrada, o native poda o campo e o dado some sem erro algum. Na JVM nada falha, então nenhum teste normal pega — oFirebaseNativeReflectConfigTestguarda a lista. Medido em produção na v1.7.3 native (GlitchTip 1310, Sul Plata). São duas pernas:- request —
Message,Notification,AndroidConfig,AndroidNotification,AndroidFcmOptions,ApnsConfig,ApsAlert,ApnsFcmOptions,Webpush*,FcmOptions,LightSettings*: sem elas o corpo sai{"message":{}}semtopice o FCM responde 400 INVALID_ARGUMENT"Recipient of the message is not set". Assinatura do gap: obuild()valida o tópico no cliente, então um 400 de destinatário ausente depois de umbuild()ok é campo podado, não tópico vazio. (Aps/CriticalSound/WebpushNotificationestão na lista só por simetria — não têm@Key, serializam viagetFields()numMap.) - response —
internal/MessagingService{,Error}ResponseeAbstractPlatformErrorHandler$PlatformError{,Response}. Sem a 1ª,send()devolvemessageIdnulo até em sucesso; sem a 2ª,getMessagingErrorCode()volta nulo, oFcmPushServicenunca classifica o erro como transitório e o retry não acontece; sem as duas últimas, a mensagem do Google se perde e sobra o dump genéricoUnexpected HTTP response with status: NNN— foi essa assinatura no 1310 que denunciou a poda, porque na JVM a mensagem seria a curta (Recipient of the message is not set.).
- request —
- Locale pt-BR — são duas coisas, e as duas precisam existir:
- os dados do locale vêm do binário, por
-H:IncludeLocales=pt-BRnobuild.gradle.kts(a GraalVM embarca sóen). Quem formata comLocaleexplícito já funcionava sem mais nada; - o default locale da JVM vem do ambiente. A imagem do jar (
eclipse-temurin) trazLANG/LANGUAGE/LC_ALL; a base do native (debian:12-slim) não traz nada, então quem formata sem locale explícito caía no default do sistema. Por isso oDockerfile.nativegerapt_BR.UTF-8e exporta as três envs — paridade com o jar. Sem elas o desvio é silencioso: número, data e moeda mudam de forma sem erro nenhum.
- os dados do locale vêm do binário, por
- Corpo JSON lido para record: pelo
JsonMapperdo Micronaut Serde (@Serdeable), nunca peloObjectMapperdo Jackson, que lê por reflexão e o AOT poda.
Diagnosticar um gap de runtime¶
- O compile passou e o boot quebra: quase sempre é
MissingReflectionRegistrationErrorou recurso ausente, e a stack trace nomeia a classe ou o recurso. - Reflexão: acrescente a classe ao
reflect-config.json. Recurso lido por caminho montado em runtime: acrescente opatternaoresource-config.json. Valide o JSON depois de editar:\Q...\Eé regex-quote e precisa da barra DOBRADA no arquivo ("\\Qnome\\E"); com uma barra só,\Qnão é escape de JSON válido e o native-image descarta o arquivo inteiro — você perde também as entradas que já funcionavam, e o binário sai pior do que sairia sem a edição. Guarde com um teste que faça parse do JSON, nãocontains()no texto cru: ocontains()passa verde exatamente no arquivo quebrado (é o que oFirebaseNativeResourceConfigTestfaz). - Rota com 404 só no native: o AOT podou. Compare com o jar pela mesma checagem antes de concluir.
- Gap dentro de um
<clinit>de lib tem assinatura própria: umExceptionInInitializerErrorcom a causa real (a classe ou o recurso que faltou) e depois NNoClassDefFoundError: Could not initialize class Xsem causa nenhuma. O primeiro evento é o único que diz o que faltou — se o GlitchTip mostra sóNoClassDefFoundError, procure o evento anterior do mesmotrace_id. catch (Exception)não pega nada disso — os dois sãoLinkageError. CaptureLinkageError(nãoThrowable:OutOfMemoryErroreStackOverflowErrordevem subir, engoli-los esconde a JVM morrendo) em dois lugares, e os dois importam:- na borda fire-and-forget (thread virtual solta, listener de evento) — senão o gap vira evento
FATAL pelo
UncaughtExceptionHandler, um por tentativa, em vez de log tratado; - em toda compensação
try/catchno caminho, e é aqui que o estrago é silencioso. NoDeduplicatingPushServicea compensação inteira — liberar a reivindicação de dedup e gravar a linha de erro empush_enviada— estava atrás decatch (RuntimeException). OLinkageErrorpulou tudo: os pushes perdidos não deixaram traço nenhum na auditoria do próprio app, nem como sucesso nem como erro, e a reivindicação ficou pendurada os 10 min da janela, fazendo um retry saudável da mesma mensagem pela perna jar ser descartado como duplicado. Alargar só a captura de fora e não a compensação de dentro mantém a assimetria que cria o estado sujo.
- na borda fire-and-forget (thread virtual solta, listener de evento) — senão o gap vira evento
FATAL pelo
- Não chame
Sentry.captureExceptionjunto deLOG.error. OSentryAppenderdologback.xmlpublica a partir deERROR; os dois juntos criam duas issues por ocorrência. - Rebuild com
-PnativeQuicke repita o smoke.
Smoke local do binário¶
Sobe contra o Postgres de dev, com a auth em bypass:
docker compose -f docker-compose.dev.yml up -d --wait postgres
MICRONAUT_ENVIRONMENTS=dev ./build/native/nativeCompile/application
curl -s localhost:8080/health # {"status":"UP",…,"flavor":"native"}
Rotas-chave, conferidas contra o jar: as views de listagem, PUT /api/v1/xadm com um payload de
docs/anexos/exemplos-payload/, POST /api/v1/powersync, POST /api/v1/heartbeat (sem central,
responde 503 heartbeat_desligado), GET /export/xlsx/{table} (ex. municipio) e GET /api/health.
O envio FCM real não roda em dev (push.enabled=false); prova-se na instância piloto da troca.
Foi exatamente esse buraco que deixou a v1.7.2 native ir a produção com o push morto (GlitchTip
1299/1300): compile, boot, health e rotas passaram — o <clinit> do FirebaseMessagingClientImpl só
roda no primeiro send(). Um smoke de push contra o canal isolado do e2e
(PUSH_EMPRESA=test → tópicos bl_liberado_test/nota_venda_emitida_test, que produção não assina —
ver a constante EMPRESA_TEST do FcmPushService) é o único jeito de exercitar esse caminho antes do
deploy. O roteiro de push com app real está em push-fcm-testar.