Simulate a cold start without rebooting or drop_caches
Contents
Drop one file you own from the Linux page cache with posix_fadvise(POSIX_FADV_DONTNEED), prove it with fincore, and start the program cold, as on the first start after a reboot, with no root and no reboot.
- Tested on
- an 8-core Ryzen 7 8845HS mini PC, Fedora 44, kernel 7.2.10-200.fc44, util-linux
fincore2.41.5, Python 3.14.8, Docker 29.9.2 (rootful, cgroup v2, systemd cgroup driver), imagedebian:bookworm-slim(2026-10-26). The file was a 67 MiB native executable built on the host and bind-mounted read-only into the container. - Not verified
- the cgroupfs Docker driver (its cgroup path differs from the one in step 8); rootless Docker and Podman; filesystems other than the btrfs
/homeused here. - Needs
- Python 3,
fincorefrom util-linux, read access to the file, and ownership of it (or write access) iffincoreis to show the result. Docker only for the container part. No sudo. - Time
- 5 min.
- Risk
- none to data. The eviction drops clean cached pages of one file; the next read of that file, by any process on the host, comes from disk. Nothing to undo.
Before you start#
- Every transcript ran in one scratch directory.
app/demo-micronautis the file to evict, a native executable;app/is the directory the container mounts. Replace both with your own file and directory. Port 18080 is a free host port on the test box. fincoreand Python 3 are installed:$ fincore --version fincore from util-linux 2.41.5 $ python3 --version Python 3.14.8- You own the file.
fincorereports honest numbers only for a file you own or could write (Failure modes, row 2):$ stat -c '%U %n' app/demo-micronaut user app/demo-micronaut - For the container part: Docker uses the systemd cgroup driver on cgroup v2, which gives the cgroup path used in step 8:
$ docker info --format '{{.CgroupDriver}} cgroup v{{.CgroupVersion}}' systemd cgroup v2 - The container reads the file through a bind mount of a host directory (
-v "$PWD/app:/app:ro"). A file inside an image layer under Docker's data root cannot be opened by a normal user and cannot be evicted this way (Failure modes, row 3).
Steps#
- Save the eviction script as
evict.py.Offset 0 and length 0 cover the whole file. The script prints nothing and exits 0 even when the kernel keeps pages; only step 6 tells you whether it worked.import os, sys for path in sys.argv[1:]: fd = os.open(path, os.O_RDONLY) try: os.posix_fadvise(fd, 0, 0, os.POSIX_FADV_DONTNEED) finally: os.close(fd) - Make sure no container is running the file.Expected: no output (
$ docker ps --filter volume=/app --format '{{.Names}} {{.Status}}'volume=matches the mount destination inside the container). If a name is printed, remove that container first: pages a running process has mapped stay in the cache (Failure modes, row 1). - Check what is cached now.
$ fincore app/demo-micronaut RES PAGES SIZE FILE 52.2M 13373 67.4M app/demo-micronautRESis the part of the file in the page cache. Here 52.2M was left over from an earlier start. - Write out dirty pages of the file.Expected: no output. posix_fadvise(2) does not free dirty pages that have not been written back. On the test box a freshly written 64 MiB file reached 0 B without this step, so it is a cheap precaution, not a fix for an observed failure.
$ sync app/demo-micronaut - Evict the file.Expected: no output, exit status 0.
$ python3 evict.py app/demo-micronaut - Confirm nothing of the file is cached.Expected:
$ fincore app/demo-micronaut RES PAGES SIZE FILE 0B 0 67.4M app/demo-micronaut0Band0pages. Anything else: Failure modes, rows 1 and 2. - Start the program; it is now the first reader of the file. Here a native binary in a container with a 64 MiB limit (
-m 64m):Expected: the container ID, then the first answer from the app. Wait with a loop like this one, not with$ docker run -d --name cold-demo -m 64m --memory-swap 64m -p 127.0.0.1:18080:8080 -v "$PWD/app:/app:ro" debian:bookworm-slim /app/demo-micronaut -XX:MaxHeapSize=32m feb26a504b88824bbac72cd6835307764c0ccbcc33f7ab44759041e4900437d2 $ for i in $(seq 100); do curl -sf http://127.0.0.1:18080/hello && break; sleep 0.2; done; echo {"runtime":"native","pid":1,"framework":"micronaut"}curl --retry(Failure modes, row 4). - Read what the container's cgroup was charged for the page cache.Expected: a large
$ grep -E '^(anon|file|file_mapped) ' /sys/fs/cgroup/system.slice/docker-$(docker inspect -f '{{.Id}}' cold-demo).scope/memory.stat anon 10444800 file 54722560 file_mapped 32153600filevalue, here 54,722,560 bytes (52.2 MiB). The container read the binary first, so the page cache it loaded counts against its own memory limit. A warm start showsfileat or near 0 (Verify). - Remove the container. The next start is warm again.The cold start left 52.2M of the file in the cache.
$ docker rm -f cold-demo cold-demo $ fincore app/demo-micronaut RES PAGES SIZE FILE 52.2M 13360 67.4M app/demo-micronaut - For several rounds, evict before every start. Save as
cold-rounds.sh:#!/usr/bin/env bash # Three cold starts of a bind-mounted binary; the page cache is evicted before each one. set -u BIN=app/demo-micronaut for round in 1 2 3; do python3 evict.py "$BIN" fincore "$BIN" | tail -1 docker run -d --name cold-demo -m 64m --memory-swap 64m -p 127.0.0.1:18080:8080 \ -v "$PWD/app:/app:ro" debian:bookworm-slim /app/demo-micronaut -XX:MaxHeapSize=32m > /dev/null for i in $(seq 100); do curl -sf http://127.0.0.1:18080/hello > /dev/null && break; sleep 0.2; done cg=/sys/fs/cgroup/system.slice/docker-$(docker inspect -f '{{.Id}}' cold-demo).scope echo "round $round: $(curl -s http://127.0.0.1:18080/hello) $(grep '^file ' "$cg/memory.stat")" docker rm -f cold-demo > /dev/null doneExpected:$ bash cold-rounds.sh 0B 0 67.4M app/demo-micronaut round 1: {"runtime":"native","pid":1,"framework":"micronaut"} file 54546432 0B 0 67.4M app/demo-micronaut round 2: {"runtime":"native","pid":1,"framework":"micronaut"} file 54960128 0B 0 67.4M app/demo-micronaut round 3: {"runtime":"native","pid":1,"framework":"micronaut"} file 547348480Bbefore every round and a similarfilevalue in each. A round with an empty answer afterround N:did not start within the 20 s the loop waits.
Verify#
The cold start is real when fincore showed 0B right before the start (step 6) and the container's file counter is large (step 8). For the comparison, start the same container warm: read the whole file first, then start it.
$ cat app/demo-micronaut > /dev/null
$ fincore app/demo-micronaut
RES PAGES SIZE FILE
67.4M 17249 67.4M app/demo-micronaut
$ docker run -d --name warm-demo -m 64m --memory-swap 64m -p 127.0.0.1:18080:8080 -v "$PWD/app:/app:ro" debian:bookworm-slim /app/demo-micronaut -XX:MaxHeapSize=32m
3c379dde8a1ae7a5a434edb5fee97406954855318a1c6815e51cb1198a6d7839
$ for i in $(seq 100); do curl -sf http://127.0.0.1:18080/hello && break; sleep 0.2; done; echo
{"runtime":"native","pid":1,"framework":"micronaut"}
$ grep -E '^(anon|file|file_mapped) ' /sys/fs/cgroup/system.slice/docker-$(docker inspect -f '{{.Id}}' warm-demo).scope/memory.stat
anon 10440704
file 0
file_mapped 4096
Warm, the container's cgroup is charged file 0 here (0, 0 and 110,592 bytes in the three warm starts on the test box): the pages were already cached, and per the kernel's cgroup v2 documentation they stay charged to the cgroup that read them first (here the one running the cat). Cold, the same start was charged 52.2 MiB of page cache against its 64 MiB limit; anon is about 10 MB both ways. warm-demo is still running; the first transcript under Failure modes continues from here.
Undo / rollback#
Nothing to roll back: the eviction only drops clean cached pages, and the next read of the file loads them again. Remove every test container first (docker rm -f <NAME>; the Failure modes transcripts end with that). Then warm the file on purpose and check that no test container is left. This transcript ran last, after the Failure modes checks:
$ cat app/demo-micronaut > /dev/null
$ fincore app/demo-micronaut
RES PAGES SIZE FILE
67.4M 17249 67.4M app/demo-micronaut
$ docker ps -a --filter volume=/app --format '{{.Names}}'
Expected: the full size under RES and no container names.
Failure modes#
| Symptom (exact message) | Cause | Fix |
|---|---|---|
evict.py exits 0, but fincore still shows part of the file (42.3M 10838 67.4M) | A running process (here the container) has the file mapped. POSIX_FADV_DONTNEED is advice; mapped pages are not dropped. | Stop the process (docker rm -f <NAME>), run evict.py again, check fincore. |
fincore: failed to do cachestat: <FILE>: Operation not permitted, then RES equal to SIZE | You neither own nor can write the file. cachestat(2) refuses, and the mincore(2) fallback returns fake data, as if every page were resident (fincore(1)). The eviction may still have worked. | Use a copy you own, for example in your build directory, and bind-mount that. |
PermissionError: [Errno 13] Permission denied: '/var/lib/docker/image'; fincore: failed to open: /var/lib/docker/image: Permission denied | The file lives in an image layer under Docker's data root, which a normal user cannot open. /var/lib/docker/image stands in for any path there; layer files themselves sit under /var/lib/docker/overlay2/. | Keep the file on the host and bind-mount it (-v <DIR>:/app:ro). |
curl: (56) Recv failure: Connection reset by peer right after docker run, although --retry-connrefused is set | The host side of the published port accepts the connection before the app listens in the container, then resets it; --retry-connrefused covers only a refused connection, not this reset. With -s the error is silent and the next command runs too early. | Wait with a loop: for i in $(seq 100); do curl -sf <URL> && break; sleep 0.2; done. (curl's --retry-all-errors should also retry the reset; not tested.) |
Rows 1 to 3 as they ran, continuing from Verify with warm-demo still running:
$ python3 evict.py app/demo-micronaut
$ fincore app/demo-micronaut
RES PAGES SIZE FILE
42.3M 10838 67.4M app/demo-micronaut
$ docker rm -f warm-demo
warm-demo
$ python3 evict.py app/demo-micronaut
$ fincore app/demo-micronaut
RES PAGES SIZE FILE
0B 0 67.4M app/demo-micronaut
$ python3 evict.py /usr/share/dict/linux.words
$ fincore /usr/share/dict/linux.words
fincore: failed to do cachestat: /usr/share/dict/linux.words: Operation not permitted
RES PAGES SIZE FILE
4.7M 1210 4.7M /usr/share/dict/linux.words
$ python3 evict.py /var/lib/docker/image
Traceback (most recent call last):
File "/home/user/…/evict.py", line 4, in <module>
fd = os.open(path, os.O_RDONLY)
PermissionError: [Errno 13] Permission denied: '/var/lib/docker/image'
$ fincore /var/lib/docker/image
fincore: failed to open: /var/lib/docker/image: Permission denied
Row 4:
$ docker run -d --name cold-demo -m 64m --memory-swap 64m -p 127.0.0.1:18080:8080 -v "$PWD/app:/app:ro" debian:bookworm-slim /app/demo-micronaut -XX:MaxHeapSize=32m
02c367fdbd74ed8de4bd86028cb044d8e72f016e5e9b6cb56915984487c795b3
$ curl -sS --retry 20 --retry-connrefused --retry-delay 1 http://127.0.0.1:18080/hello
curl: (56) Recv failure: Connection reset by peer
$ docker rm -f cold-demo
cold-demo
Field notes#
- 2026-10-26 20:01, 8-core Ryzen 7 8845HS mini PC, Fedora 44, kernel 7.2.10-200.fc44, fincore 2.41.5, Python 3.14.8, Docker 29.9.2 (cgroup v2, systemd driver): I ran every command in this manual on a copy of a Micronaut native binary (67.4 MiB) in a scratch directory: the steps and Verify in the order above, then the Failure modes checks, then the Undo transcript. The transcripts are from that run; the other observations below are probes from 19:58 to 20:04. The first readiness check used
curl -s --retry 20 --retry-connrefused: it printed nothing, andmemory.statread right after showedanon 389120andfile 48193536, an app that had not finished starting. With-sSthe reason showed:curl: (56) Recv failure: Connection reset by peer. With a polling loop, five cold starts were chargedfile52.0 to 52.4 MiB; the two cold starts where I also readanonshowed about 10 MB, as did the warm starts. Three warm starts were chargedfile0, 0 and 110,592 bytes. A container running onlysleepbehind the same-p 127.0.0.1:18080:8080gave the samecurl: (56)reset, so the host side of the published port answers before anything listens in the container. Evicting while the warm container ran left 42.3M of the file cached; after removing the container it went to 0 B. A 64 MiB file just written withddreached 0 B with nosync. On a root-owned 4.7 MiB filefincoreprinted the cachestat error and reported the whole file resident; a timing check on a root-owned 73 MB file showed the eviction itself works (ddread 15.8 GB/s warm, 577 MB/s right after eviction, 14.0 GB/s again after that read). Writing/proc/sys/vm/drop_cachesas my user failed withPermission denied. - 2026-10-26 14:47, same box and versions: a talk showcase: three rounds, each evicting the native binaries of Spring Boot (85.6M), Micronaut (67.4M) and Quarkus (48.4M), then starting each in a container at a 64 MiB limit with a 32 MiB heap, then a round without eviction.
fincoreshowed0Bfor all three binaries before every round, and all 12 starts answered (ok), from 14:47:39 to 14:48:12. The script'sdocker statscolumn (memory.current minus inactive_file, not process RSS) read 20.22, 30.26 and 20.59 MiB cold against 16.31 without eviction for Spring Boot, and 36.05, 35 and 37.83 against 11.54 for Micronaut. Quarkus barely changed (5.391, 5.395 and 6.023 against 5.34); I did not look into why. The JVM runs of the same showcase (all three OOM-killed, exit 137, at 64 MiB) were a separate run at 14:41:38 to 14:41:42, without eviction; neither logged run evicted the JVM's files. The longer native-vs-JVM benchmark under memory limits, at 128 MB, is in Native Image vs JVM at 128 MB.
Notes#
echo 1 > /proc/sys/vm/drop_cachesis the usual way and needs root: the file is--w------- rootand the write fails withPermission deniedfor a normal user. It also drops clean caches for the whole machine, not one file.posix_fadvisechecks no ownership: read access is enough to evict (kernel 7.2mm/fadvise.c). Only the proof needs ownership or write access, because offincore's fallback (Failure modes, row 2).- With a JVM, only what sits on the bind mount (the jar) can be evicted this way. The JDK in the image layer stays as cached as it was.
- Which cgroup is charged follows the kernel's rule: "A memory area is charged to the cgroup which instantiated it and stays charged to the cgroup until the area is released." Shown above for the first start; how charges move after the container is removed was not tested.
Filed under: linux, docker, memory, containers
Sources#
- posix_fadvise(2):
POSIX_FADV_DONTNEED, partial pages kept, dirty pages freed only after writeback (fsync(2)first). - fincore(1): uses
cachestat(2), falls back tomincore(2), which returns fake "all resident" data without permission. - cachestat(2): the syscall
fincoreuses first. - Control Group v2, Memory Ownership: page cache is charged to the cgroup that brought it in.
- Documentation for /proc/sys/vm, drop_caches: the root-only alternative, system-wide.
- Linux v7.2 source: mm/fadvise.c (the
DONTNEEDpath, no permission check), mm/mincore.c (can_do_mincore()reports every page resident when it refuses), mm/filemap.c (can_do_cachestat()).