Manual

Simulate a cold start without rebooting or drop_caches

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 fincore 2.41.5, Python 3.14.8, Docker 29.9.2 (rootful, cgroup v2, systemd cgroup driver), image debian: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 /home used here.
Needs
Python 3, fincore from util-linux, read access to the file, and ownership of it (or write access) if fincore is 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-micronaut is 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.
  • fincore and Python 3 are installed:
    $ fincore --version
    fincore from util-linux 2.41.5
    $ python3 --version
    Python 3.14.8
    
  • You own the file. fincore reports 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#

  1. Save the eviction script as evict.py.
    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)
    
    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.
  2. Make sure no container is running the file.
    $ docker ps --filter volume=/app --format '{{.Names}} {{.Status}}'
    
    Expected: no output (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).
  3. Check what is cached now.
    $ fincore app/demo-micronaut
      RES PAGES  SIZE FILE
    52.2M 13373 67.4M app/demo-micronaut
    
    RES is the part of the file in the page cache. Here 52.2M was left over from an earlier start.
  4. Write out dirty pages of the file.
    $ sync app/demo-micronaut
    
    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.
  5. Evict the file.
    $ python3 evict.py app/demo-micronaut
    
    Expected: no output, exit status 0.
  6. Confirm nothing of the file is cached.
    $ fincore app/demo-micronaut
    RES PAGES  SIZE FILE
     0B     0 67.4M app/demo-micronaut
    
    Expected: 0B and 0 pages. Anything else: Failure modes, rows 1 and 2.
  7. 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):
    $ 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"}
    
    Expected: the container ID, then the first answer from the app. Wait with a loop like this one, not with curl --retry (Failure modes, row 4).
  8. Read what the container's cgroup was charged for the page cache.
    $ 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 32153600
    
    Expected: a large file value, 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 shows file at or near 0 (Verify).
  9. Remove the container. The next start is warm again.
    $ docker rm -f cold-demo
    cold-demo
    $ fincore app/demo-micronaut
      RES PAGES  SIZE FILE
    52.2M 13360 67.4M app/demo-micronaut
    
    The cold start left 52.2M of the file in the cache.
  10. 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
    done
    
    $ 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 54734848
    
    Expected: 0B before every round and a similar file value in each. A round with an empty answer after round 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)CauseFix
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 SIZEYou 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 deniedThe 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 setThe 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, and memory.stat read right after showed anon 389120 and file 48193536, an app that had not finished starting. With -sS the reason showed: curl: (56) Recv failure: Connection reset by peer. With a polling loop, five cold starts were charged file 52.0 to 52.4 MiB; the two cold starts where I also read anon showed about 10 MB, as did the warm starts. Three warm starts were charged file 0, 0 and 110,592 bytes. A container running only sleep behind the same -p 127.0.0.1:18080:8080 gave the same curl: (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 with dd reached 0 B with no sync. On a root-owned 4.7 MiB file fincore printed the cachestat error and reported the whole file resident; a timing check on a root-owned 73 MB file showed the eviction itself works (dd read 15.8 GB/s warm, 577 MB/s right after eviction, 14.0 GB/s again after that read). Writing /proc/sys/vm/drop_caches as my user failed with Permission 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. fincore showed 0B for all three binaries before every round, and all 12 starts answered (ok), from 14:47:39 to 14:48:12. The script's docker stats column (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_caches is the usual way and needs root: the file is --w------- root and the write fails with Permission denied for a normal user. It also drops clean caches for the whole machine, not one file.
  • posix_fadvise checks no ownership: read access is enough to evict (kernel 7.2 mm/fadvise.c). Only the proof needs ownership or write access, because of fincore'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#