Escalar para zero para reduzir custos. Uma requisição chega. Seus usuários esperam 3 segundos pela JVM inicializar. Cada cold start após um período de inatividade é uma violação de P99. Para qualquer serviço com SLO abaixo de 3 segundos, isso não é um caso extremo. É uma garantia.

A compilação nativa do Quarkus resolve isso. Construí o quarkus-cloud-native, uma API de recebimento de webhooks com PostgreSQL, e fiz benchmark em containers usando o mesmo caminho de startup que o Knative segue em produção. Os números abaixo são reais, reproduzíveis, e importam além do benchmark em si.

Por Que Isso Importa em Produção

Antes dos números, o contexto. O Knative Serving escala pods para zero quando ociosos. Quando chega uma requisição, acontece um cold start:

  1. O Activator do Knative recebe a requisição
  2. O Pod é agendado em um nó
  3. A imagem do container é baixada
  4. O container inicia e a app é inicializada
  5. A primeira requisição é atendida

Com app JVM, só o passo 4 leva 2-3 segundos. Cada cold start é um usuário esperando. Nos picos de tráfego após períodos de inatividade (exatamente quando scale-to-zero mais ajuda), cada pod novo é um cold start.

Impacto no SLA. Um cold start de container de 3.541ms significa que cada evento scale-from-zero no JVM viola um SLO de 3s imediatamente. Os 642ms do Native cabem na maioria dos orçamentos de SLO “rápidos”. A diferença aparece nos dashboards de P99, não em load tests rodados contra instâncias quentes.

Impacto no custo. Com 241 MB RSS por pod JVM, um nó de 16 GB comporta cerca de 66 pods. Com 12 MB RSS por pod Native, o mesmo nó comporta mais de 1.300 pods. Rodando 500 réplicas, JVM precisa de ~8 nós enquanto Native precisa de menos de 1. Isso não é uma otimização marginal. É um orçamento de infraestrutura diferente.

A App

Uma API de recebimento de webhooks, o tipo de workload que encaixa bem em serverless:

  • Quarkus 3.35.1 + JDK 25 + Mandrel 25.0.2
  • Hibernate ORM com Panache + PostgreSQL 18
  • REST + Jackson + Bean Validation
  • SmallRye Health, OpenAPI, Micrometer
  • I/O bloqueante em 4 vCPUs fixados
@Entity
public class Webhook extends PanacheEntity {

    @Column(nullable = false)
    public String source;

    @Column(nullable = false)
    public String eventType;

    @Column(columnDefinition = "TEXT")
    public String payload;

    public Instant receivedAt;
    public String processingMode;
}

DTOs de entrada e saída com Bean Validation e factory methods:

public record WebhookRequest(
    @NotBlank @Size(max = 255) String source,
    @NotBlank @Size(max = 100) String eventType,
    String payload
) {}

public record WebhookResponse(
    Long id, String source, String eventType,
    String payload, Instant receivedAt, String processingMode
) {
    public static WebhookResponse from(Webhook w) {
        return new WebhookResponse(w.id, w.source,
            w.eventType, w.payload, w.receivedAt, w.processingMode);
    }
}

Quatro endpoints (listar, buscar por ID, criar, deletar) acessando PostgreSQL a cada requisição.

O Benchmark

Um comando: make compare. Roda um benchmark baseado em containers seguindo o mesmo caminho que o Knative usa em produção:

  1. Builda imagens Docker para JVM e Native
  2. Limpa page cache do SO (sudo) para garantir cold start real
  3. docker run --cpuset-cpus inicia cada container com CPU pinning
  4. Aguarda health check (/q/health) para medir startup do container
  5. Extrai “started in Xs” dos logs para medir init da app
  6. Repete 5 vezes (1a = cold, 2-5 = warm)
  7. Mede memória RSS via docker stats
  8. Roda k6 com 500 VUs por 60 segundos
  9. Salva JSON com timestamp e info da máquina em metrics/

Duas Métricas de Startup

  • Start do container: docker run até health check 200 OK. Realista para produção: inclui criação do container, init da app e health probe. O que os usuários do Knative realmente sentem.
  • Log do Quarkus: “started in Xs” dos logs do container. Só a app, elimina overhead do container. Útil para comparar runtimes diretamente.

CPU Pinning

  • Container da app: cpuset 2-5 (4 vCPUs), igual ao default do Quarkus Benchmark Lab
  • PostgreSQL: cpuset 0-1 (2 threads)
  • Gerador de carga k6: cpuset 6-11 (6 threads)

Isso segue a metodologia do Quarkus Benchmark Lab: conjuntos de CPU separados eliminam interferências entre componentes.

Teste de Carga k6

export const options = {
  scenarios: {
    readHeavy: {
      executor: 'constant-vus',
      vus: __ENV.K6_VUS || 500,
      duration: __ENV.K6_DURATION || '60s',
    }
  },
};

Mix de workload: 60% leituras (GET /api/webhooks), 25% escritas (POST /api/webhooks), 15% info do sistema. Realista para um receptor de webhooks.

Os Números

Medições reais. AMD Ryzen 5 5600GT, 12 cores, 30 GB RAM. CPU-pinned. Page cache limpo entre runs de cold start.

MétricaJVMNativevs JVM
Cold start: container3.541 ms642 ms5.5x mais rápido
Cold start: log Quarkus2.718 ms174 ms15.6x mais rápido
Média quente: container2.400 ms301 ms8.0x mais rápido
Média quente: log Quarkus1.990 ms48 ms41.5x mais rápido
Memória RSS241 MB12 MB20.1x menor
Heap usado26 MB9 MB2.9x menor
Imagem do container185,2 MB37,9 MB4.9x menor
Requisições/s4082601.6x JVM
Latência P501.039 ms1.581 ms1.5x JVM
Latência P902.310 ms4.077 ms1.8x JVM
Latência P994.265 ms7.118 ms1.7x JVM

Lendo os Números de Cold Start

As duas métricas contam histórias diferentes. Start do container (3.541ms JVM vs 642ms Native) é o que os usuários sentem: desde o Activator do Knative receber a requisição até o primeiro health probe bem-sucedido. Log do Quarkus (2.718ms vs 174ms) é o tempo puro de init da app, eliminando o overhead do container. Ambas importam para decisões diferentes.

Algo que os números warm não mostram: cada run “warm” ainda é um docker run novo com um processo JVM novo. “Warm” significa apenas que o page cache do SO guarda os JARs do JDK em memória. Num pod Knative novo sem page cache, o cold start do JVM seria ainda maior. O Native não tem esse problema. O binário é autocontido e sempre do mesmo tamanho. Cold start e warm start são essencialmente iguais.

Lendo os Números de Throughput

Com 500 VUs, ambos os modos estão sob pressão. JVM processa 408 req/s, Native 260 req/s (vantagem 1.6x para JVM). O banco é o gargalo: toda requisição acessa PostgreSQL, então a diferença vem principalmente de otimização JIT e comportamento de GC, não de compute puro. O Native usa Serial GC com heap menor, o que fica evidente nesse nível de carga.

Em produção, 500 conexões concorrentes a um único pod é extremo. Escalabilidade horizontal cuida disso antes de um pod atingir a saturação. A pergunta relevante para workloads scale-to-zero não é “qual modo aguenta 500 VUs melhor”, mas “qual modo se recupera do idle mais rápido”.

Tamanho do Container

Native: 37,9 MB (micro image) vs JVM: 185,2 MB (UBI9 + OpenJDK 25 runtime).

No Knative, o tamanho da imagem afeta diretamente o cold start. O nó precisa baixar a imagem antes de iniciar o pod. Uma imagem 4.9x menor significa pulls mais rápidos em nós frios. Em clusters com eventos frequentes de scale-from-zero em nós frios, essa diferença se multiplica.

A imagem nativa usa quay.io/quarkus/ubi9-quarkus-micro-image, uma base mínima sem distribuição JDK. O binário é a app.

Isso É Realista Para Produção?

Nosso benchmark segue a metodologia do Quarkus Benchmark Lab:

  • Medições baseadas em container (docker run até health check)
  • CPU pinning com --cpuset-cpus (4 vCPUs para app, igual ao ActiveProcessorCount=4)
  • Conjuntos de CPU separados para app, BD e gerador de carga
  • Limpeza de page cache do SO para cold start real

O que não é simulado:

  • Tempo de pull da imagem (Native faz pull 4.9x mais rápido, o ratio se mantém)
  • Sidecar queue proxy do Knative (adiciona latência igual nos dois modos)
  • Overhead do scheduler K8s e containerd (igual para ambos)
  • Service mesh e network overlay (igual para ambos)

Esses overheads de infraestrutura somam igual nos dois modos. Os ratios relativos (5.5x cold start de container, 15.6x init da app, 20.1x memória, 4.9x imagem) são confiáveis. Números absolutos num cluster real seriam maiores, mas as proporções se mantêm.

Deploy no Knative

O Quarkus gera o manifest Knative automaticamente:

quarkus.kubernetes.deployment-target=knative
quarkus.container-image.group=omatheusmesmo
make native-image   # Build da imagem container nativa
make deploy-knative # Aplica knative.yml no cluster

O knative.yml em target/kubernetes/ está pronto para aplicar. O Quarkus gera os recursos Service, Configuration e Route.

Métricas Versionadas

Cada make compare salva um JSON com timestamp em metrics/:

{
  "date": "2026-05-03T23:34:43",
  "methodology": "container-based",
  "machine": {
    "cpu": "AMD Ryzen 5 5600GT with Radeon Graphics",
    "cores": "12",
    "memoryGb": "30",
    "os": "Arch Linux",
    "docker": "29.4.1"
  },
  "benchmarkConfig": {
    "vus": 500,
    "duration": "60s",
    "appCpus": "2-5",
    "dbCpus": "0-1",
    "k6Cpus": "6-11"
  },
  "jvm": {
    "startup": { "coldStartMs": 3222, "appColdStartMs": 2558 },
    "memory": { "rssMb": 231 },
    "k6": { "rps": 441, "p50Ms": 957.4, "p99Ms": 3818.7 }
  },
  "native": {
    "startup": { "coldStartMs": 640, "appColdStartMs": 212 },
    "memory": { "rssMb": 12 },
    "k6": { "rps": 235, "p50Ms": 1707.9, "p99Ms": 7355.5 }
  }
}

Essas métricas são versionadas no git. Execute make compare após upgrades do Quarkus ou Mandrel para acompanhar a evolução.

Reproduza Você Mesmo

git clone https://github.com/omatheusmesmo/quarkus-cloud-native
cd quarkus-cloud-native
sdk env install
make db-up
make jvm-image
make native-image
make compare

Você precisa do Mandrel 25.0.2 para builds nativos e do k6 para teste de carga.

Conclusão

Para workloads scale-to-zero no Knative, a escolha se resume ao que você quer otimizar.

JVM ganha em throughput (1.6x com 500 VUs) e latência de cauda sob carga sustentada. Se você roda serviços always-on com tráfego alto e consistente, a vantagem do JIT é real.

Native ganha em cold start (5.5x de container, 15.6x de init da app), footprint de memória (20.1x) e tamanho do container (4.9x). No Knative, essas três métricas se traduzem diretamente em conformidade com SLA, custo em cloud e velocidade de scale-out.

Se seu serviço escala para zero, Native é o runtime certo. O benchmark fecha o argumento com números reais.

Recursos