AppCDS (Application Class Data Sharing)¶
Brewlet supports build-time archive delivery
(application/vnd.brewlet.cds.layer.v1+jsa, brewlet push
--appcds-archive, and Maven brewlet:appcds) and node-side regeneration
(spec.jvm.cds.regenerate, brewlet run/bundle --appcds-regenerate). The
launch path uses -Xshare:auto -XX:SharedArchiveFile so mismatches safely fall
back while preserving correctness.
1. TL;DR¶
- Startup is the JVM's weakness vs. Wasm (§13), and CDS is the cheapest win. Class-data sharing memory-maps a pre-parsed archive of loaded classes, cutting class-load/verify time (typically ~10–40% off startup) with no code change.
- Brewlet already gets the base JDK CDS for free. The JDK's default archive
(
classes.jsa) ships inside the node JDK root, which Brewlet mounts read-only as the overlay lower layer (setupOverlayRootfs). It's shared across every sandbox on the node and warm in the page cache — a genuine, already-realized advantage. - Application CDS extends the shared archive to your app and its libraries. Generate it with a training run or let Brewlet regenerate it on the node.
- The sharp constraint: an AppCDS archive is bound to the exact JDK build (and classpath layout). The binding is to the JVM's full build identity — not the feature release, not even the minor/patch version, but the exact version+build+date+compiler string. Even a minor patch update (e.g. 21.0.1 → 21.0.2) invalidates the archive, and this applies equally to static AppCDS and dynamic CDS — there is no patch-tolerant mode (verified, see §2.1). Brewlet's whole point is patching the node JDK centrally and independently — which therefore invalidates a shipped archive on every patch. CDS fails safe (ignores a stale archive and runs normally), so it's a performance optimization that degrades gracefully, never a correctness risk — but the win silently evaporates after any JDK patch. Node-side regeneration handles this (§4.3, §6).
- Brewlet carries an optional archive layer and launch-config hint. The Maven plugin and CLI generate these values for you.
2. Background: the CDS ladder¶
| Level | What it covers | How produced | Brewlet behavior |
|---|---|---|---|
| Base / default CDS | JDK (java.base etc.) classes |
Ships in the JDK (lib/server/classes.jsa) |
Already shared via the node JDK root overlay. |
| AppCDS (static) | App + library classes | Training run: record a class list, then -Xshare:dump (or -XX:ArchiveClassesAtExit for dynamic) → app.jsa |
Shippable as a cds.layer.v1+jsa layer. |
| Dynamic CDS (JDK 13+) | App + library classes | Single run with -XX:ArchiveClassesAtExit=app.jsa |
Easiest to generate; shippable. |
| Auto-create archive (JDK 19+) | App + library classes | -XX:+AutoCreateSharedArchive -XX:SharedArchiveFile=app.jsa — used if valid, (re)created at exit if missing/JDK-stale |
Basis for node-side regen (§4.3); needs JDK 21 floor. |
Launch consumes an app archive with:
java -XX:SharedArchiveFile=/app/app.jsa -jar /app/app.jar
# dynamic CDS falls back to the base archive automatically if app.jsa is stale
Key JVM behavior: if the archive doesn't validate against the running JDK (version/build mismatch) or the classpath differs, the JVM logs a warning and runs without it — no failure. This safe-fallback is what makes shipping an archive acceptable in Brewlet's centrally-patched-JDK world.
2.1 Verified: archives are bound to the exact build, not the minor version¶
A natural assumption is that an app archive survives a minor patch update
(e.g. 21.0.1 → 21.0.2). It does not. The archive header records
VM_Version::internal_vm_info_string() as _jvm_ident and the JVM compares it
verbatim on load. That string encodes version + build number + build date +
compiler, so any patch bump (or even a rebuild of the same version) mismatches.
Measured on two same-vendor patch builds (Temurin 25.0.1+8 → 25.0.3+9), for
both static AppCDS (-Xshare:dump) and dynamic CDS (-XX:ArchiveClassesAtExit):
_jvm_ident expected: OpenJDK 64-Bit Server VM (25.0.3+9-LTS) ... built on 2026-04-21 ... with clang ...
actual: OpenJDK 64-Bit Server VM (25.0.1+8-LTS) ... built on 2025-10-21 ...
warning: The shared archive file was created by a different version or build of HotSpot
-Xshare:auto(Brewlet's default) → archive rejected, JVM falls back to base CDS and runs normally. You lose the app-archive win, nothing breaks.-Xshare:on→ fatal (Unable to use shared archive … Failed to initialize).
There is no "patch-tolerant" application-CDS mode: the same _jvm_ident check
governs base, static-AppCDS, and dynamic archives alike. This is exactly why the
build-time archive must be treated as best-effort seed data and paired with
-Xshare:auto and/or node-side regeneration (§4.3, §6).
2.2 Minimum JDK version¶
Set the AppCDS feature floor at JDK 21:
- It is the LTS version Brewlet already defaults to when no pod annotation is
present (
temurin-21), so it introduces no new fragmentation. - It is the lowest LTS that ships
-XX:+AutoCreateSharedArchive(added in JDK 19) — the primitive the node-side regeneration design (§4.3) relies on to create/refresh the archive automatically when it is missing or JDK-stale. - Everything below it in the ladder (static AppCDS since 10, dynamic CDS since 13) is available too, so build-time generation (§4.1–4.2) works unconditionally at 21.
JDK 17 works for build-time-only archives (static + dynamic CDS) but lacks
AutoCreateSharedArchive, so it cannot self-heal after a patch — reason enough to
prefer 21 as the gate.
3. Alignment with the Brewlet model¶
- Base CDS is a Brewlet freebie worth advertising. Because the JDK root is shared read-only and page-cache-warm, every sandbox on a node reuses the same mapped base archive. Container images that each carry their own JVM don't share this across pods on a node. Call it out in resource-tuning / observability.
- AppCDS fits the artifact model as an extra layer. Brewlet already supports
optional extra layers (the
classpath.layer.v1+tar,artifact.go) that the shim stages/mounts (mountClasspathLayers). An archive layer is the same shape: an extra blob, bind-mounted into/app, referenced by a jvm arg. - The friction is JDK coupling. Brewlet deliberately decouples the artifact from the JDK (the JDK lives on the node, patched centrally). An AppCDS archive re-introduces a coupling to the exact JDK build. This is the core design problem (§6), so Brewlet uses safe fallback and/or node-side regeneration rather than a hard artifact↔JDK pin.
AppCDS is a best-effort accelerator, never a correctness or scheduling constraint.
4. Using AppCDS¶
4.1 Build-time archive layer (recommended primary)¶
Brewlet carries app.jsa as an optional
application/vnd.brewlet.cds.layer.v1+jsa archive layer. The launch config uses
a small, backward-compatible hint:
{
"schemaVersion": 1,
"mainJar": "app.jar",
"entry": { "mode": "jar" },
"cds": { "archive": "app.jsa", "mode": "dynamic" } // optional
}
The shim mounts the archive read-only at /app/<archive> and BuildJVMArgs
prepends -XX:SharedArchiveFile=/app/<archive> (plus -Xshare:auto, the safe
default, so a mismatch falls back rather than fails). Ship a prebuilt archive with
brewlet push --appcds-archive app.jsa (the config cds.archive defaults from the
file's basename); the same layer flows through run, bundle, and the production
containerd shim.
Point --appcds-archive at a prebuilt .jsa; the CLI writes the launch-config
field.
4.2 Turnkey generation in the Maven plugin / CLI¶
The Maven plugin automates the training run with a dedicated goal:
- Maven plugin —
brewlet:appcds: stages a copy of the built JAR with a canonical mtime (§4.4), then runs a training JVM with-XX:ArchiveClassesAtExit=<target>/brewlet/app.jsa(dynamic CDS — one run, no class-list file) using a JDK 21+ runtime (the AppCDS floor, §2.2). App-intrinsic launch knobs (enablePreview,addModules,addOpens,addExports,systemProperties) are replayed so the training flags match production. Extra warmup arguments go through-Dbrewlet.appcds.trainingArgs, and-Dbrewlet.appcds.timeoutSecondsbounds the run. Feed the result intobrewlet:push/brewlet:buildwith-Dbrewlet.cdsArchive=target/brewlet/app.jsa, which attaches it as the CDS layer. Bind the goal topre-integration-testwhen the build already has a short startup/warmup path.
The training layout mirrors how the artifact is pushed, so the recorded archive
matches the shim's layout (verified: HotSpot validates each classpath/module-path
entry by basename+size+mtime, not absolute path, and expands lib/* in sorted
order — see §4.4):
- fat JAR (entry.mode=jar): -jar <mainJar>.
- layered class-path (-Dbrewlet.layered=true, non-modular): the resolved
runtime dependencies are staged into lib/ and trained with
-cp <mainJar>:lib/* <mainClass>, matching /app/lib.
- JPMS module (-Dbrewlet.layered=true, modular): dependencies staged into
mods/, trained with -p <mainJar>:mods -m <module>[/<mainClass>], matching
/app/mods.
Run the goal with the same layered/module settings you push with; every
staged file (app JAR + deps) is mtime-pinned. The training JAR bytes must be
identical to the pushed JAR (same resolved artifact, no rebuild between appcds
and push).
- CLI (
brewlet push --appcds): turnkey for a fat JAR — runs a self-terminating training JVM (-XX:ArchiveClassesAtExit) against a canonical-mtime copy of the JAR, then ships the resulting archive exactly like--appcds-archive. The trainingjavais--appcds-java(a binary or aJAVA_HOMEdir), else$JAVA_HOME/bin/java, elsejavaonPATH; drive startup with repeatable--appcds-argand bound the run with--appcds-timeout(seconds). It is mutually exclusive with--appcds-archive,--classpath-layer, and--module-layer— for layered class-path / JPMS module training use the Mavenbrewlet:appcdsgoal (which stageslib/,mods/), or ship a prebuilt archive with--appcds-archive.
Emit clear guidance that the training run should exercise startup paths (Spring
context refresh, etc.) for the archive to be worthwhile. Because the training JVM
must exit cleanly for dynamic CDS to write the archive, the default exit mode
requires a self-terminating app; for long-running servers, brewlet:appcds
-Dbrewlet.appcds.mode=signal starts the app, waits for a readiness signal
(readyLog regex / readyHttp poll / readyDelaySeconds), then sends SIGTERM
so the shutdown hook runs and the archive flushes (verified: a SIGTERM'd JVM with
-XX:ArchiveClassesAtExit produces an archive that maps cleanly under
-Xshare:on). Signal mode is Unix-oriented and needs the app to shut down
gracefully on SIGTERM.
4.3 Node-side regeneration (the durable answer for a patched fleet)¶
Opt in per-deployment with the spec.jvm.cds.regenerate field on the
JavaApplication CRD. The controller
stamps the
brewlet.sh/cds-regeneratepod annotation, which the shim reads. For local dev thebrewlet run/brewlet bundlecommands take an equivalent--appcds-regenerateflag (with or without a seed archive). Regeneration is a fleet/operational choice (it depends on your JDK patch cadence), so it lives in the deployment descriptor, not baked into the artifact digest. The decision engine lives ininternal/runtime/cds_regen.go(DecideCDSRegen) and is wired into localrun,bundle/e2e harness, and the production shim (applyBrewletLaunch). It is entirely best-effort: any failure degrades to base CDS and never fails a launch.
The verified patch-invalidation (§2.1) makes build-time generation structurally at
odds with Brewlet's core promise — patch the node JDK once, patch everything. A
build-time archive goes stale on the next central patch, and a .jsa is also
arch-specific, so shipping one breaks the "same artifact runs on any arch"
property. Node-side regeneration removes both problems by decoupling the archive
from the shipped artifact entirely.
The node generates/refreshes a per-(artifact-digest, jdk-build) archive lazily
and caches it under <cacheDir>/<key>.jsa, where key = sha256(artifactKey|jdkBuild)
(first 32 hex) and cacheDir defaults to /opt/brewlet/cds (DefaultCDSCacheDir,
overridable via the BREWLET_CDS_CACHE env var). Inside the sandbox the cache is
bind-mounted at /run/brewlet/cds (InSandboxCDSDir) — read-write for the elected
writer, read-only for everyone else. On a JDK patch the <jdkBuild> component of the
key changes, the old entry is ignored, and a fresh archive is produced on the next
launch — always matched to the running JVM and to the node's architecture. The
shipped archive (§4.1) becomes optional seed data (copied into the cache slot when
present and the slot is empty; AutoCreateSharedArchive revalidates and recreates it
if JDK-stale, so seeding is always safe).
Built on -XX:+AutoCreateSharedArchive (JDK 19+, hence the JDK 21 floor).
Launching with -XX:+AutoCreateSharedArchive -XX:SharedArchiveFile=<cache>.jsa
makes the JVM use the archive when it is valid and (re)create it at exit when
it is missing or JDK-stale — so regeneration is automatic and JDK-build-keyed with
no bespoke training tooling. This flag is a fatal unrecognized-option error on
JDK < 19, so the engine reads the JDK's build identity from <jdkRoot>/release
(falling back to java -Xinternalversion) and gates regeneration on feature ≥ 19
(minRegenFeature); an older JDK falls back to base CDS rather than emitting a flag
that would break the app.
The engine resolves one of four roles per launch (RegenDecision.Role):
- consume — a valid cached archive exists →
-Xshare:auto -XX:SharedArchiveFile=<cache>. - write — no archive yet and this launch wins the writer election → seed if a
shipped archive is present, then
-XX:+AutoCreateSharedArchive -XX:SharedArchiveFile=<cache>. - defer — no archive and another writer is in flight → base CDS this time.
- skip — JDK unsupported or regeneration disabled → base CDS.
Two hard problems this design handles:
- Long-running servers don't exit.
AutoCreateSharedArchiveonly writes the archive at JVM exit; a service that runs for weeks never produces one during normal operation. In practice the archive is emitted on the first pod's graceful shutdown / rollout and is then warm for subsequent replicas — i.e. the win lands on the second rollout, not the first boot. (A dedicated short "training" invocation is the alternative, at the cost of extra plumbing and knowing when startup is "done".) - Thundering herd. A Deployment scaling to N cold replicas all miss the cache
at once. The engine elects exactly one writer per key via an
O_EXCLmarker file (<archive>.writer); the rest take the defer role — start on base CDS and pick up the app archive on a later restart. A stale marker (older thanDefaultWriterTTL= 10m) is reclaimable, so a crashed writer never wedges the key permanently.
The engine also evicts cache entries untouched for longer than DefaultEvictTTL
(14d) on a best-effort pass, and emits a best-effort node-local metric
(brewlet_cds_archive_mapped{key,role} as a textfile under BREWLET_METRICS_DIR)
so operators can watch archive hits/rebuilds. Running app code to produce archives on
the node is inherent to this mechanism; it runs inside the same sandbox as the app.
Verified end to end in a real cluster. e2e Tier 8
provisions a kind node for real (shim + full-userland temurin-21 JDK root +
brewlet containerd runtime), deploys a genuine runtimeClassName: brewlet pod
with brewlet.sh/cds-regenerate: true, and asserts the full lifecycle: rollout 1
elects a writer (-XX:+AutoCreateSharedArchive), a graceful delete dumps the
.jsa into the node cache, and rollout 2 consumes it (-Xshare:auto
-XX:SharedArchiveFile, no AutoCreate) with the archive confirmed mmap'd into the
JVM via /proc/1/maps — a real CDS hit, not a silent fallback. Standing this up
also hardened the shim's CRI path (it now registers the runtimeoptions proto,
translates the generic options CRI hands a non-runc handler into runc options
preserving the cgroup driver, and skips the pod sandbox container) and added the
pod_annotations = ["brewlet.sh/*"] passthrough to the node provisioner so the
shim actually receives the artifact digest and the cds-regenerate toggle.
4.4 Deterministic JAR mtime — why a shipped archive maps on the node¶
This is the subtle piece that makes §4.1/§4.2 actually work (guarded by an
automated JDK integration test — TestAppCDSTrainThenMapIntegration in
internal/runtime/cds_train_integration_test.go, runnable via
make appcds-verify; it trains a real archive through the push --appcds code
path, then asserts it maps under -Xshare:on from a different directory with the
canonical mtime and is refused when the mtime drifts):
- CDS validates each classpath entry by basename + file size + mtime — not by
directory or absolute path. An archive trained at
/x/traindir/app.jarmaps cleanly when consumed at/y/appdir/app.jar(or relativeapp.jar) iff the JAR's size and mtime are unchanged. So production's absolute-jar /app/app.jarlaunch needs no change; only the timestamp matters. - Under Brewlet's default
-Xshare:auto, an mtime/size mismatch is silent: the JVM drops the app archive and falls back to base CDS with no warning and no benefit (under-Xshare:onit is fatal). A naivecp(fresh mtime) is enough to make the archive inert. - The shim bind-mounts the app JAR straight from the content-store blob, so the
in-container
/app/app.jarmtime is the node's pull time — never the build machine's. Without intervention, every build-time archive would be silently rejected on the node.
Fix: normalize the JAR mtime to a canonical fixed value on both sides. Both the
training run and the node stamp the JAR (and any staged lib//mods/ entries) with
2000-01-01T00:00:00Z = Unix second 946684800 (ns=0). This is a shared
invariant that must stay in lockstep:
- Go:
runtime.CDSModTime = time.Unix(946684800, 0).UTC()(internal/runtime/cds_mtime.go). - Java:
FileTime.from(Instant.ofEpochSecond(946684800L))(AppCdsMojo.CANONICAL_APP_MTIME).
Normalization happens only when the artifact ships a CDS archive
(cfg.CDS != nil), so the common no-CDS path is byte-for-byte unchanged. On the
node side the JAR can't be re-timestamped on the shared read-only blob, so when CDS
is present the runtime copies the JAR into per-container staging, chtimes it to
the canonical value, and bind-mounts that copy; extracted lib//mods/ JARs are
pinned in place. All three JAR-materialization paths implement this identically:
AssembleSandboxWithCDS (run), GenerateBundleWithCDS (bundle + harness), and
the production shim's applyBrewletLaunch.
5. Resolved launch argv¶
| Config | Emitted (illustrative) |
|---|---|
no cds |
java -jar /app/app.jar (still uses base CDS from the JDK root) |
cds.archive: app.jsa |
java -Xshare:auto -XX:SharedArchiveFile=/app/app.jsa -jar /app/app.jar |
| dynamic + layered classpath | archive generated against the same -cp /app/app.jar:/app/lib/* layout it will run with |
-Xshare:auto (not :on) is deliberate: on would make a stale archive fatal;
auto preserves Brewlet's safe-fallback posture.
6. JDK coupling and safe fallback¶
Brewlet's value: patch the node JDK once, patch everything. An AppCDS archive is validated against the JDK's exact build identity (not the feature or patch version); after any patch it no longer applies (verified, §2.1).
Mitigations, in order of preference:
- Always
-Xshare:auto+ dynamic CDS. A mismatch silently falls back to base CDS. Worst case: you lose the app archive benefit until the archive is regenerated; you never break the app. This alone makes shipping an archive safe. - Node-side regeneration keyed on JDK build (§4.3), built on
-XX:+AutoCreateSharedArchive(JDK 19+). Opt in withspec.jvm.cds.regenerate. This decouples the artifact from the JDK build — the shipped archive is optional seed data, and the node self-heals the archive on the next patch. This is the durable fix; see §4.3 for the server-doesn't-exit and thundering-herd handling. Explicitly reject turning the archive into a hard artifact↔JDK-build pin (e.g. denying scheduling unless an exact JDK build is present) — that would resurrect the per-image-JVM coupling Brewlet exists to remove.
7. References¶
- JEP 310: Application Class-Data Sharing, JEP 350: Dynamic CDS Archives.
- JEP 483: Ahead-of-Time Class Loading & Linking (Leyden, JDK 24).
javaflags:-XX:SharedArchiveFile,-XX:ArchiveClassesAtExit,-Xshare:auto|on|off.- Brewlet: SPECIFICATION §13 (startup),
§4 (artifact),
layered-classpath-deployment,
multi-arch.md;
internal/runtime/launch.go,internal/artifact/artifact.go,shim/cmd/containerd-shim-brewlet-v2/service_linux.go.