Trim the image: 906 MB, measured, and where it goes #157

Closed
opened 2026-09-09 11:43:20 +00:00 by tiagoagueda · 1 comment
Owner

Observation

also assess ways to trim out the docker image

Measured on the test instance against postulo:latest as built from 0.3.0 at d0cc4c8,
arm64. Total: 906 MB.

Where it actually is

By layer:

Size Layer
598 MB uv sync --locked --no-dev --extra server
97.2 MB the Debian bookworm rootfs
64.3 MB apt-get install — pango, harfbuzz, fontconfig, dejavu, noto, curl
46.3 MB COPY /uv /usr/local/bin/uv
41.3 MB the base image's own build tooling (dpkg-dev, g++)
23.1 MB COPY src ./src
19.8 MB collectstatic
9.27 MB base: ca-certificates, netbase, tzdata
6.71 MB messages.py compile

By directory in the finished image:

Size Path Of which
345 MB /app/.venv playwright 136 MB
296 MB /root/.cache/uv uv's unpacked download cache
45 MB /usr/local/bin/uv
32 MB /app/src locale/ 25 MB — 17 MB of .po, 6.6 MB of .mo
28 MB /app/staticfiles flags 9.7 MB (1476 files), js 6.8 MB, admin 6.6 MB, django_htmx 2.9 MB

What can go, in order of size against difficulty

Saving What How Risk
296 MB uv's download cache UV_NO_CACHE=1, or clear it in the same layer none — #154
~136 MB the e2e group --no-default-groups none — #154
45 MB the uv binary do not copy it into the runtime stage plugin installs fall back to pip
17 MB the .po catalogues ship only the compiled .mo none — nothing reads .po at runtime
~6.6 MB Django admin's static collect it only where an admin is mounted breaks an instance that sets POSTULO_ADMIN_URL

#154 alone takes the image from 906 MB to roughly 430 MB. The rest of this is measured
against what would be left.

The structural change that makes most of it automatic

The Python side of the build is single-stage. Only the CSS uses a build stage
(FROM node:22-bookworm-slim AS styles); everything else — uv sync, messages.py compile,
collectstatic — happens in the image that ships. So every build artefact is baked into a
layer, and a later RUN rm frees nothing because the bytes are already below.

A second stage that copies in only what runs — the .venv, /app/src minus the .po files,
staticfiles, and the fonts — drops the cache and the source catalogues by construction, and
makes shipping uv a decision rather than a side effect. That is the change worth making;
the individual flags above are what to do if it is not.

Worth being careful about

uv is load-bearing and its 45 MB is not free to reclaim. plugins/installing.py does
shutil.which("uv") and prefers it "where the image put it", falling back to pip. Dropping
it does not break plugin installation; it makes it slower and changes which resolver an
operator gets. That is a real trade to state rather than a saving to claim.

The fonts are the one large thing that has to stay. fonts-noto-core is why an Amharic,
Georgian or Tigrinya CV renders glyphs instead of empty boxes, and #70 added it with a test
holding the image's fonts to the languages offered. Trimming here would be trimming the
languages.

The flags are 1476 files for 245 flags, because the manifest storage keeps a hashed copy
of each and WhiteNoise adds .gz and .br beside both. That is the storage doing its job —
it is 9.7 MB and it is served pre-compressed — but it is worth knowing before somebody
concludes the flag set is enormous. Every one of the 245 is reachable: the telephone field
offers a flag per country.

The admin's 6.6 MB is awkward rather than easy. django.contrib.admin is installed —
through PostuloAdminConfig, which is the same app with a rate-limited login — so
collectstatic collects its assets whether or not POSTULO_ADMIN_URL is set. But
collectstatic runs at build time and the admin's URL is a runtime environment variable, so
the image cannot know. Either it is a build argument, or it stays.

A smaller base is a separate lever with a separate benefit. python:3.14-slim-trixie
would cut some of the 97 MB rootfs and, more usefully, reset most of the unfixable CVE
backlog in #155. Distroless is the aggressive version and would fight WeasyPrint's system
libraries, which are exactly what a distroless image does not have.

Smaller is not the goal; fewer moving parts is. 906 MB on a Raspberry Pi is a slow pull
and a disk that fills sooner, and every megabyte that is not there is a thing that cannot be
vulnerable — but the reason to do this is that the image should contain what it runs, which
is also what makes a scan (#156) mean something.

## Observation > also assess ways to trim out the docker image Measured on the test instance against `postulo:latest` as built from `0.3.0` at `d0cc4c8`, arm64. Total: **906 MB**. ## Where it actually is By layer: | Size | Layer | | --- | --- | | **598 MB** | `uv sync --locked --no-dev --extra server` | | 97.2 MB | the Debian bookworm rootfs | | **64.3 MB** | `apt-get install` — pango, harfbuzz, fontconfig, dejavu, noto, curl | | **46.3 MB** | `COPY /uv /usr/local/bin/uv` | | 41.3 MB | the base image's own build tooling (`dpkg-dev`, `g++`) | | **23.1 MB** | `COPY src ./src` | | 19.8 MB | `collectstatic` | | 9.27 MB | base: ca-certificates, netbase, tzdata | | 6.71 MB | `messages.py compile` | By directory in the finished image: | Size | Path | Of which | | --- | --- | --- | | 345 MB | `/app/.venv` | playwright 136 MB | | 296 MB | `/root/.cache/uv` | uv's unpacked download cache | | 45 MB | `/usr/local/bin/uv` | | | 32 MB | `/app/src` | `locale/` **25 MB** — 17 MB of `.po`, 6.6 MB of `.mo` | | 28 MB | `/app/staticfiles` | flags 9.7 MB (1476 files), js 6.8 MB, **admin 6.6 MB**, django_htmx 2.9 MB | ## What can go, in order of size against difficulty | Saving | What | How | Risk | | --- | --- | --- | --- | | **296 MB** | uv's download cache | `UV_NO_CACHE=1`, or clear it in the same layer | none — #154 | | **~136 MB** | the `e2e` group | `--no-default-groups` | none — #154 | | **45 MB** | the `uv` binary | do not copy it into the runtime stage | plugin installs fall back to pip | | **17 MB** | the `.po` catalogues | ship only the compiled `.mo` | none — nothing reads `.po` at runtime | | ~6.6 MB | Django admin's static | collect it only where an admin is mounted | breaks an instance that sets `POSTULO_ADMIN_URL` | #154 alone takes the image from 906 MB to roughly **430 MB**. The rest of this is measured against what would be left. ## The structural change that makes most of it automatic **The Python side of the build is single-stage.** Only the CSS uses a build stage (`FROM node:22-bookworm-slim AS styles`); everything else — `uv sync`, `messages.py compile`, `collectstatic` — happens in the image that ships. So every build artefact is baked into a layer, and a later `RUN rm` frees nothing because the bytes are already below. A second stage that copies in only what runs — the `.venv`, `/app/src` minus the `.po` files, `staticfiles`, and the fonts — drops the cache and the source catalogues by construction, and makes shipping `uv` a decision rather than a side effect. That is the change worth making; the individual flags above are what to do if it is not. ## Worth being careful about **`uv` is load-bearing and its 45 MB is not free to reclaim.** `plugins/installing.py` does `shutil.which("uv")` and prefers it *"where the image put it"*, falling back to pip. Dropping it does not break plugin installation; it makes it slower and changes which resolver an operator gets. That is a real trade to state rather than a saving to claim. **The fonts are the one large thing that has to stay.** `fonts-noto-core` is why an Amharic, Georgian or Tigrinya CV renders glyphs instead of empty boxes, and #70 added it with a test holding the image's fonts to the languages offered. Trimming here would be trimming the languages. **The flags are 1476 files for 245 flags**, because the manifest storage keeps a hashed copy of each and WhiteNoise adds `.gz` and `.br` beside both. That is the storage doing its job — it is 9.7 MB and it is served pre-compressed — but it is worth knowing before somebody concludes the flag set is enormous. Every one of the 245 is reachable: the telephone field offers a flag per country. **The admin's 6.6 MB is awkward rather than easy.** `django.contrib.admin` is installed — through `PostuloAdminConfig`, which is the same app with a rate-limited login — so `collectstatic` collects its assets whether or not `POSTULO_ADMIN_URL` is set. But `collectstatic` runs at build time and the admin's URL is a runtime environment variable, so the image cannot know. Either it is a build argument, or it stays. **A smaller base is a separate lever with a separate benefit.** `python:3.14-slim-trixie` would cut some of the 97 MB rootfs and, more usefully, reset most of the unfixable CVE backlog in #155. Distroless is the aggressive version and would fight WeasyPrint's system libraries, which are exactly what a distroless image does not have. **Smaller is not the goal; fewer moving parts is.** 906 MB on a Raspberry Pi is a slow pull and a disk that fills sooner, and every megabyte that is not there is a thing that cannot be vulnerable — but the reason to do this is that the image should contain what it runs, which is also what makes a scan (#156) mean something.
tiagoagueda added this to the 0.3.0 milestone 2026-09-09 11:43:20 +00:00
Author
Owner

The issue named the change worth making and this makes that one:

A second stage that copies in only what runs … drops the cache and the source catalogues
by construction, and makes shipping uv a decision rather than a side effect. That is the
change worth making; the individual flags above are what to do if it is not.

Three stages now. styles builds the CSS, build builds the environment and compiles the
catalogues, runtime copies in the .venv, the source and manage.py — and nothing that
made them.

The distinction the whole thing turns on: the same RUN rm frees 20 MB in a stage that
is discarded and nothing at all in the stage that ships, because there the bytes are
already in a layer below. That is why the .po files could not be deleted before and can
be now.

What it takes

Saving What Status
296 MB uv's download cache #154 stopped it being written; the stage boundary now means it could not ship anyway
~136 MB the e2e group #154
20 MB the .po catalogues deleted in the same layer that compiles them — 476 files, up from the issue's 17 MB because #127 and #129 added six plugin catalogue sets

Plus what stops shipping by construction: the lock, the manifest, README.md,
scripts/messages.py, and whatever git the extra-packages step needed. Two side effects
worth having — that step no longer purges git, because git is left behind with the stage;
and collectstatic runs as plain python, so the lock and manifest are no longer present
in the shipping image purely so uv run would agree to start.

status.json stays. It sits among the .po files and the language picker reads it to say
how far along each translation is, so the deletion is by extension rather than by directory
— and a test says so, because that is exactly the sort of thing a later tidy-up gets wrong.

Two savings deliberately not taken

uv, 45 MB. The issue put it correctly — "a real trade to state rather than a saving to
claim"
. plugins/installing.py prefers it where the image put it and falls back to pip.
Dropping it does not break installing a plugin through the interface; it makes it slower and
hands an operator a resolver this project does not test with. A working feature beats 45 MB
of a ~400 MB image. tests/test_image_build.py fails if the binary goes without the reason
beside it going too.

Django admin's 6.6 MB. The issue's own framing: "Either it is a build argument, or it
stays."
It stays. POSTULO_ADMIN_URL is read at run time and collectstatic runs at build
time, so a build argument hands somebody an image whose admin page loses its stylesheet the
day they decide to enable it. That is a worse thing to own.

The fonts and the flags are untouched, for the reasons the issue gives: trimming
fonts-noto-core would be trimming the languages (#70), and the 1476 flag files are 245
flags each stored hashed and pre-compressed by the manifest storage doing its job.

Not measured, and saying so

Nothing here builds an image (#81), so this is read rather than weighed: the figures are the
issue's own measurements plus a recount of the .po files, and the tests parse the
Dockerfile into stages so they can ask which stage a step is in. A real before-and-after
belongs to #156, which needs the same runner.

python:3.14-slim-trixie is left alone deliberately — the issue calls it a separate lever
with a separate benefit, and it belongs with #155's CVE backlog rather than here.

Shipped in 4346dfa on 0.3.0, with main kept level.

The issue named the change worth making and this makes that one: > A second stage that copies in only what runs … drops the cache and the source catalogues > by construction, and makes shipping `uv` a decision rather than a side effect. That is the > change worth making; the individual flags above are what to do if it is not. Three stages now. `styles` builds the CSS, `build` builds the environment and compiles the catalogues, `runtime` copies in the `.venv`, the source and `manage.py` — and nothing that made them. **The distinction the whole thing turns on**: the same `RUN rm` frees 20 MB in a stage that is discarded and *nothing at all* in the stage that ships, because there the bytes are already in a layer below. That is why the `.po` files could not be deleted before and can be now. ## What it takes | Saving | What | Status | | --- | --- | --- | | 296 MB | uv's download cache | #154 stopped it being written; the stage boundary now means it could not ship anyway | | ~136 MB | the `e2e` group | #154 | | **20 MB** | the `.po` catalogues | deleted in the same layer that compiles them — 476 files, up from the issue's 17 MB because #127 and #129 added six plugin catalogue sets | Plus what stops shipping by construction: the lock, the manifest, `README.md`, `scripts/messages.py`, and whatever git the extra-packages step needed. Two side effects worth having — that step no longer purges git, because git is left behind with the stage; and `collectstatic` runs as plain `python`, so the lock and manifest are no longer present in the shipping image purely so `uv run` would agree to start. `status.json` stays. It sits among the `.po` files and the language picker reads it to say how far along each translation is, so the deletion is by extension rather than by directory — and a test says so, because that is exactly the sort of thing a later tidy-up gets wrong. ## Two savings deliberately not taken **uv, 45 MB.** The issue put it correctly — *"a real trade to state rather than a saving to claim"*. `plugins/installing.py` prefers it where the image put it and falls back to pip. Dropping it does not break installing a plugin through the interface; it makes it slower and hands an operator a resolver this project does not test with. A working feature beats 45 MB of a ~400 MB image. `tests/test_image_build.py` fails if the binary goes without the reason beside it going too. **Django admin's 6.6 MB.** The issue's own framing: *"Either it is a build argument, or it stays."* It stays. `POSTULO_ADMIN_URL` is read at run time and `collectstatic` runs at build time, so a build argument hands somebody an image whose admin page loses its stylesheet the day they decide to enable it. That is a worse thing to own. **The fonts and the flags are untouched**, for the reasons the issue gives: trimming `fonts-noto-core` would be trimming the languages (#70), and the 1476 flag files are 245 flags each stored hashed and pre-compressed by the manifest storage doing its job. ## Not measured, and saying so Nothing here builds an image (#81), so this is read rather than weighed: the figures are the issue's own measurements plus a recount of the `.po` files, and the tests parse the Dockerfile into stages so they can ask *which stage* a step is in. A real before-and-after belongs to #156, which needs the same runner. `python:3.14-slim-trixie` is left alone deliberately — the issue calls it a separate lever with a separate benefit, and it belongs with #155's CVE backlog rather than here. Shipped in `4346dfa` on `0.3.0`, with `main` kept level.
tiagoagueda referenced this issue from a commit 2026-09-12 13:15:08 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Reference
Postulo/postulo#157
No description provided.