Faster Java Pod Startups Without GraalVM: Why AppCDS Needs Kubernetes Image Volumes

# kubernetes# devops# java# performance
Faster Java Pod Startups Without GraalVM: Why AppCDS Needs Kubernetes Image VolumesBaoKhang

If you run Java workloads on Kubernetes, you have probably stared at slow pod readiness metrics...

If you run Java workloads on Kubernetes, you have probably stared at slow pod readiness metrics during a deployment or autoscaling event. While the JVM is unmatched for long-running throughput, its cold-start tax—spent loading, verifying, and linking thousands of classes—can make scaling painful.

The usual advice is to compile your application into a GraalVM Native Image. That delivers sub-second startups, but it comes at a steep operational price: long build times, closed-world reflection constraints, and breaking changes to existing Java agents and libraries.

OpenJDK offers a middle ground: Application Class-Data Sharing (AppCDS) on JDK 21+ and Project Leyden AOT caches on JDK 25+. These features let the JVM dump its internal class metadata into a pre-linked archive file on disk, allowing subsequent restarts to memory-map the classes instead of parsing them from scratch.

When I started building mnemo-warmup—an open-source tool that automates training and sharing AppCDS/Leyden caches inside Kubernetes without changing application source code—I wanted to answer a simple engineering question:

Does a Kubernetes pod actually become Ready sooner once you pay the infrastructure tax of fetching, verifying, and mounting the cache?

After running hundreds of automated cold-restart benchmarks across different JVM architectures, the answer turned out to be: It depends entirely on how you deliver the tooling, and what your application does during its boot cycle.


The Hidden Infrastructure Tax: Init Containers

In a standard Kubernetes setup, injecting a shared cache archive into a stateless pod requires a familiar pattern:

  1. An install init container copies the launcher tools onto a shared emptyDir volume.
  2. A fetch init container pulls the trained archive from an OCI registry and verifies its SHA-256 digest.
  3. The main application container boots the JVM pointing to the shared archive.

On paper, this looks clean. In practice, measuring the end-to-end timeline from pod.status.startTime to Ready revealed a major bottleneck.

In our initial benchmarks running a heavy Spring Boot application (69 JARs, constrained to 1 CPU / 1 GiB), the AppCDS archive successfully cut the JVM's internal startup time from 3.18s down to 1.88s. Yet, total pod readiness remained completely unchanged at 5 seconds.

Even after rewriting our helper utilities from Java into static Go binaries (dropping tool execution time from ~1,050ms down to ~140ms), the pod was still losing nearly 1.75 seconds before the main application even began. The culprit wasn't our code—it was Kubernetes itself. Because the kubelet schedules, initializes, and tears down init containers sequentially, the orchestration overhead was eating 100% of our JVM startup gains.

The Breakthrough: Kubernetes Image Volumes

To eliminate the init-container tax, we pivoted to Kubernetes image volumes (introduced as beta in Kubernetes 1.33+ and supported by containerd 2.1+).

Instead of spinning up sequential containers to copy binaries around, an image volume allows the container runtime to mount the read-only OCI tools layer directly into the application container's filesystem at launch. Our static launcher runs as the entrypoint, fetches the archive directly, and replaces itself with java via exec so the JVM remains PID 1.

Removing the init containers changed the economics overnight:

Workload Profile (1 CPU / 1 GiB) Plain Deployment (none) Init Containers (cached) Image Volume (cached) Net Pod-to-Ready Delta
🟢 Spring Boot (69 JARs) 5 s 5 s 3 s -2 s (40% faster)
🟡 Jenkins LTS (2.492.3) 4 s 5 s 4 s 0 s (Parity)
🔴 SonarQube (26.11) 15 s 17 s Not Run +2 s (Slower)

(Note: Figures are medians over 12 cold restarts on a local kind cluster; Kubernetes pod condition timestamps have a 1-second resolution.)


Three Lessons on Workload Suitability

Testing across three distinct real-world applications mapped out the exact boundaries of where shared class caches thrive and where they fail.

1. Framework-Heavy Apps See Massive Gains (Spring Boot)

Modern microservice frameworks spend the vast majority of their boot cycle scanning classpaths, evaluating conditional annotations, and weaving dependency graphs. On our heavy Spring Boot workload, AppCDS cut JVM initialization by over 40%. Once paired with image volume delivery, total pod readiness dropped from 5 seconds to 3 seconds—with zero changes to the application's source code.

2. I/O and Plugin-Bound Apps Hit Parity (Jenkins)

Jenkins is a massive Java monolith, making it seem like an ideal candidate for AppCDS. However, while the archive successfully loaded 85% of Jenkins' classes and shaved ~300ms off the raw JVM boot, total pod readiness stayed flat at 4 seconds.

Jenkins spends most of its startup timeline performing tasks that class-data sharing cannot accelerate: unpacking bundled archives, parsing XML configurations on disk, and executing sequential plugin initialization hooks. When class loading isn't your primary bottleneck, caching won't move the needle.

3. Multi-Process Wrappers Bypass the Cache (SonarQube)

SonarQube proved to be an important architectural edge case. In our test harness (run at 2 CPU / 3 GiB), training succeeded cleanly and all 12 pods validated and loaded the archive. Yet pod readiness got 2 seconds slower.

The reason lies in how SonarQube boots. Its container entrypoint launches a small Java coordinator process, which then dynamically spawns three separate child JVMs inside the container (one for ElasticSearch, one for the Web Server, and one for the Compute Engine). Because a container wrapper only passes -XX:SharedArchiveFile to the parent entrypoint command, the heavy child JVMs booted completely cold.


The Rule of Thumb

If you are evaluating AppCDS or Project Leyden for your Kubernetes clusters, use these three rules to predict your ROI:

  • Check your delivery mechanism: If you are running on an older cluster that forces you to rely on sequential init containers, the kubelet scheduling overhead will likely cancel out your savings on fast-starting microservices. Target clusters supporting Kubernetes image volumes.
  • Profile your startup bottleneck: If your application spends its first seconds loading classes and wiring frameworks (Spring Boot, Micronaut), expect a 30–40% reduction in pod readiness. If it spends its time running database migrations, unpacking files, or waiting on network I/O, expect parity.
  • Watch out for wrapper scripts: Single-process containers work out of the box. If your container launches a supervisor process that forks downstream child JVMs, those child processes will bypass the cache unless explicitly configured.

What's Next for mnemo-warmup

mnemo-warmup is currently in active alpha. Before tagging our v0.3.0 beta, we are running the next wave of studies: comparing JDK 21 AppCDS against JDK 25 Project Leyden AOT archive sizes, mapping out the application size break-even curve (from 40 MB microservices to 350 MB monoliths), and simulating HPA scale-out bursts against remote cloud registries.

If you want to inspect the raw benchmark CSVs, reproduce the cluster tests on your own hardware, or weigh in on making image volumes our default delivery mode, check out the repository at mnemo-warmup.

Frequently Asked Questions

What is mnemo-warmup and when is it a good fit?

mnemo-warmup is a Kubernetes-native tool that automatically trains and shares OpenJDK AppCDS (.jsa) and Project Leyden AOT (.aot) caches across application pods without requiring source code changes or GraalVM compilation.

  • Best fit: Standard JVM applications (Java 21+) running on Kubernetes—especially framework-heavy microservices like Spring Boot—where teams want faster pod startup and readiness times without migrating to GraalVM Native Image.
  • Not a fit: Workloads running on Java 17 or older, Windows containers, build-time optimized frameworks like Quarkus, or multi-process Java wrappers like SonarQube.

How does mnemo-warmup compare to GraalVM Native Image and build-time AOT?

  • vs. GraalVM Native Image: GraalVM achieves sub-second startups but requires complex build pipelines, closed-world compilation, and breaks dynamic reflection or Java agents. mnemo-warmup keeps the standard HotSpot JVM, requires zero code changes, and preserves full dynamic Java compatibility while still cutting Spring Boot pod readiness by ~40%.
  • vs. Build-Time AppCDS / Leyden AOT (e.g., Spring Boot onRefresh in CI): Baking a cache into a Docker image during CI causes fingerprint drift if production Kubernetes manifests modify -Xmx, GC flags, or environment variables. mnemo-warmup trains asynchronously inside the cluster using the exact live pod fingerprint.

Why does mnemo-warmup recommend Kubernetes image volumes over init containers?

Standard Kubernetes init containers (init-container mode) introduce ~1.75 seconds of sequential kubelet scheduling and container startup overhead, which cancels out the JVM startup savings on fast-booting microservices. Setting warmup.mnemo.io/tools-delivery: "image-volume" (supported on Kubernetes 1.33+ and containerd 2.1+) mounts the tools directly as a read-only OCI layer, eliminating init containers and dropping Spring Boot Pod-to-Ready times from 5 seconds to 3 seconds.

Which workloads see the best and worst performance gains?

  • High Gain (Spring Boot, Micronaut): Single-process applications bottlenecked by runtime classpath scanning and bean wiring see a 30% to 40% reduction in total pod readiness time.
  • Neutral / Parity (Jenkins): Applications bottlenecked by disk I/O, archive extraction, and sequential plugin initialization see minor JVM startup gains (~300ms) but zero net change in pod readiness.
  • Zero Gain (SonarQube): Containers whose entrypoint acts as a coordinator that dynamically spawns child JVM subprocesses (e.g., ElasticSearch, Web, Compute Engine) do not benefit, because the cache flag only applies to the parent coordinator process.

Can a missing, corrupted, or unreachable cache crash a Kubernetes pod?

No. mnemo-warmup is strictly fail-safe. It never passes -Xshare:on (which causes fatal JVM exits on cache mismatch). If the OCI registry is unreachable, the archive digest is corrupted, or a JVM flag changes, the launcher logs the mismatch reason and falls back to an ordinary cold JVM start with exit code 0.

Does mnemo-warmup require a Kubernetes Operator, CRD, or mutating webhook?

No. It operates entirely via a stateless CLI (mnemo-warmup inject) used as a Helm 3 post-renderer or GitOps manifest transformer. Adding three pod template annotations (warmup.mnemo.io/enabled, warmup.mnemo.io/store, warmup.mnemo.io/exit-mode) injects the required volume mounts and generates a standard Kubernetes CronJob and Lease for asynchronous training.

Why must Spring Boot fat JARs be extracted before using mnemo-warmup?

OpenJDK invalidates AppCDS and Leyden AOT archives if the underlying JAR file modification times (mtime) or paths differ between training and runtime. Extracting a Spring Boot 3.3+ fat JAR at container build time (java -Djarmode=tools -jar app.jar extract) ensures the trainer pod and application replicas read identical, immutable file layouts.