image: run on jemalloc so a server rests near what it holds
glibc's malloc keeps what a request freed, an arena per Puma thread, so
after a burst of requests the server rested at its peak: 248 MB on the
600-card book, against 18 MB of live Ruby objects. jemalloc with two
arenas and a background thread returning pages after a second rests at
116 MB and peaks at 162 MB. Measurements in
docs/plans/2026-10-15-resting-memory.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GNVLyiMrZJ7sZctEYq6WCM
change
commit 5c164b4230d37a4422cb9b6236d3f45494c99c36
author Claude <noreply@anthropic.com>
date
parent e66a0b87
diff --git a/Dockerfile b/Dockerfile
index 4376cd3..9f2ff98 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -45,7 +45,17 @@ ENV RUBYOPT=${RUBYOPT}
 # git, for the commit `rake db:dump` makes of what it wrote
 # (tasks/db.rake): a snapshot is then something to revert to rather
 # than the last one overwritten.
-RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
+RUN apt-get update && apt-get install -y git libjemalloc2 && rm -rf /var/lib/apt/lists/*
+
+# jemalloc in place of glibc's malloc, which keeps what a request
+# freed and spreads it over an arena per request thread, so a server
+# that has drawn a few big pages rests near its peak. Two arenas, and a
+# background thread handing back what has gone unused for a second, so
+# it rests near what it holds instead
+# (docs/plans/2026-10-15-resting-memory.md). Named by soname, which the
+# loader finds on either architecture's library path.
+ENV LD_PRELOAD=libjemalloc.so.2
+ENV MALLOC_CONF=narenas:2,background_thread:true,dirty_decay_ms:1000,muzzy_decay_ms:0
 
 WORKDIR /app
 
diff --git a/docs/plans/2026-10-15-resting-memory.md b/docs/plans/2026-10-15-resting-memory.md
new file mode 100644
index 0000000..5f0029e
--- /dev/null
+++ b/docs/plans/2026-10-15-resting-memory.md
@@ -0,0 +1,54 @@
+# Resting memory
+
+2026-10-15. The image runs the app on jemalloc, tuned to hand freed
+memory back, instead of glibc's malloc. What a server holds while
+nobody is using it is mostly what its allocator kept from the last
+burst of requests, not what the app keeps.
+
+## What was measured
+
+The 600-card book from `2026-10-14-lists-read-the-index.md` (120 cards
+with an 80 KB photo), served by `rackup` under Puma with its default
+five threads. A hundred requests, five at a time, ran across PROPFIND,
+`/contacts`, `/search`, the dashboard, and one card's GET. Then the
+server sat idle for five seconds.
+
+| | Boot | Peak | Idle after |
+|---|---|---|---|
+| glibc malloc | 67 MB | 249 MB | 248 MB |
+| glibc, `MALLOC_ARENA_MAX=2` | 67 MB | | 173 MB |
+| jemalloc, untuned | 70 MB | | 219 MB |
+| jemalloc, as configured | 70 MB | 162 MB | 116 MB |
+
+All figures are RSS. The Ruby heap at idle held 18 MB of live objects
+over about 25 MB of pages (`ObjectSpace.memsize_of_all`,
+`GC.stat`), and a full GC moved RSS by under 2 MB. Calling
+`malloc_trim(0)` dropped the glibc process from 245 MB to 115 MB.
+That means the rest was freed memory that glibc keeps, spread over an
+arena per request thread.
+
+## Why jemalloc and not a trim
+
+A Puma `out_of_band` hook that calls `malloc_trim` gets glibc down to
+about the same idle figure. It still peaks at glibc's height, though,
+and it needs Fiddle, which is a bundled gem from Ruby 3.5 on and would
+join the Gemfile. jemalloc lowers the peak as well. It comes from the
+distribution's package and is loaded with `LD_PRELOAD`, so nothing in
+the app knows about it. Running the test suite on a host is unchanged.
+
+The settings:
+
+- `narenas:2` caps the arenas whatever the thread count.
+- `background_thread:true` returns memory without waiting for a later
+  allocation to trigger it.
+- `dirty_decay_ms:1000,muzzy_decay_ms:0` hand pages back a second
+  after they go unused.
+
+## What else was weighed
+
+- **Fewer Puma threads.** Under jemalloc, two threads rested at 94 MB
+  against five threads' 119 MB. That changes how many requests run at
+  once, so it is a separate decision from this change.
+- **Fewer gems at boot.** Every require together comes to about 38 MB
+  over bare Ruby. Sentry (8 MB) and Bundler's setup (8 MB) are the
+  largest; nothing stands out enough to drop.