Skip to content

Write a heap dump when the JVM dies of an OOM - #30

Merged
pambrose merged 3 commits into
masterfrom
heap-dumps-on-oom
Sep 27, 2026
Merged

pambrose merged 3 commits into
masterfrom
heap-dumps-on-oom

Conversation

@pambrose

Copy link
Copy Markdown
Contributor

Summary

A heap dump taken at the moment of an OOM is the only record of what was holding memory, and the flags cost nothing until then. This arms them for every deployment path.

  • Dockerfile sets JAVA_TOOL_OPTIONS="-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/app/dumps" and creates /app/dumps owned by the readingbat user.
  • docker-compose.yml mounts a named volume per service at /app/dumps; machines/content/run.sh mounts one too, since its --rm would otherwise discard the dump with the container. The retrieval command is in a comment there and in the README.
  • .gitignore ignores dumps/, in case anyone bind-mounts locally.

Two details that are not cosmetic:

  • Named volumes, not bind mounts. Docker creates a missing bind-mount directory as root, while this image runs as uid 1000, so the JVM could not have written the dump — silently, at exactly the moment it is needed. A named volume is initialized from the image's own /app/dumps and inherits its ownership.
  • One volume per composed service. Each container is pid 1 in its own namespace, so the dump is always java_pid1.hprof; a shared volume would have all three overwriting each other.

A dump is a copy of live memory, so it carries session secrets, database credentials and user data — the README says to treat the file as a secret. It is also roughly the size of the live heap, which MaxRAMPercentage=75 allows to be most of the machine's RAM.

Takes effect on the next image build (make release). Until then, adding the same JAVA_TOOL_OPTIONS line to docker_env_vars arms it immediately, since env_file overrides ENV. Once deployed, readingbat-core's startup log reports whether dumps are enabled (readingbat/readingbat-core#129).

Test plan

  • Built an image with the same user setup and forced a real OOM with the JAVA_TOOL_OPTIONS above: Dumping heap to /app/dumps/java_pid1.hprof, and the 23 MB dump landed on the named volume owned by uid 1000.
  • Confirmed the container runs as uid=1000(readingbat) and that a named volume mounted at /app/dumps is writable by it.
  • docker compose config resolves a distinct named volume per service.

🤖 Generated with Claude Code

pambrose and others added 3 commits September 23, 2026 15:02
Challenge evaluation in readingbat-core retains roughly 1.3 MB of heap per eval
and never releases it (readingbat/readingbat-core#128). An OOM here is a
question about what was holding memory, and only a dump taken at the moment it
happens answers it. The flags cost nothing until then.

The image sets JAVA_TOOL_OPTIONS to dump into /app/dumps, and both
docker-compose.yml and machines/content/run.sh mount a volume there so the dump
outlives the container that produced it -- run.sh uses --rm, which would
otherwise discard it immediately.

Named volumes rather than bind mounts, which is not cosmetic: Docker creates a
missing bind-mount directory as root and this image runs as uid 1000, so the
JVM could not have written the dump, silently, at exactly the moment it is
needed. A named volume is initialized from the image's own /app/dumps and
inherits its readingbat ownership. Verified by forcing a real OOM in a
container built the same way: the dump lands on the volume owned by 1000.

One volume per composed service, because each container is pid 1 in its own
namespace -- the dump is named java_pid1.hprof, so a shared volume would have
all three overwriting each other.

A dump is a byte-for-byte copy of live memory, so it carries session secrets,
database credentials and user data. README says to treat the file as a secret,
and dumps/ is gitignored in case anyone bind-mounts one locally. It is also
roughly the size of the live heap, which MaxRAMPercentage=75 allows to be most
of the machine's RAM, so it is worth watching disk.

Takes effect on the next image build; setting JAVA_TOOL_OPTIONS in
docker_env_vars overrides the image default in the meantime, since env_file
wins over ENV.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Dockerfile comment and changelog entry justified heap dumps with
readingbat-core#128's figure of about 1.3 MB retained per evaluation. That was
measured on a many-jar Gradle classpath. The root cause, found since, is a
classloader per evaluation that holds every jar on kotlin.script.classpath
open -- and this image sets -Dkotlin.script.classpath to the single server.jar.

Measured against that uberjar, the cost here is about 20 KB per eval. So #128
is not the likely cause of an OOM in this deployment, and the comment now says
so. The case for heap dumps stands on its own: they are the only record of what
was holding memory when one happens, whatever the cause.

Comments and changelog only.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The Dockerfile comment and changelog entry justified heap dumps by pointing at
readingbat-core#128, a per-evaluation classloader leak. That leak turned out
not to exist: a GC-root trace showed the retained classloaders were held by
Kover's coverage agent, which only runs inside instrumented test tasks. With
the agent off, nothing is retained, and it never runs in this image.

Heap dumps stand on their own -- a dump is the only record of what was holding
memory when an OOM happens, whatever the cause, and the flags cost nothing
until then -- so the comment now says that and nothing more.

Comments and changelog only.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@pambrose
pambrose merged commit 3836130 into master Sep 27, 2026
3 checks passed
@pambrose
pambrose deleted the heap-dumps-on-oom branch September 27, 2026 00:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant