The runtime image carries 430 MB it never runs, including a Node.js runtime #154
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
security
tier
1
tier
2
tier
3
tier/4
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Blocks
#157 Trim the image: 906 MB, measured, and where it goes
Postulo/postulo
Reference
Postulo/postulo#154
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What the scan found
Trivy 0.71.2 and Grype 0.115.0 were run against
postulo:latestas built on the testinstance from
0.3.0atd0cc4c8. The image is 906 MB, and about 430 MB of it isthings the application never runs.
Cause one: the browser test tooling is installed
The Dockerfile looks right, which is why this went unnoticed:
--no-devomits thedevgroup and nothing else.pyproject.tomlhas two groups anddeclares both as defaults:
So
e2eis installed. Checked in the image rather than reasoned about: zero packagesfrom the
devgroup are present — no ruff, no factory-boy, no pre-commit — and all fourfrom
e2eare.--no-devis doing exactly what it says; it is not doing what the lineintends.
The comment above
default-groupsexplains why both are defaults — "so thatuv addanduv syncnever quietly remove the browser test's dependencies" — which is a good reasonthat collides with the image build.
A Node.js runtime in a Python image is the part that matters, more than the megabytes.
Trivy reports a
Node.jsscan target in a container that has no business running JavaScript,and Playwright's driver exists to launch browsers. It is dormant, and it is a whole second
language runtime present because of a test tool.
Cause two: uv's download cache is left behind
Every wheel
uv syncunpacked is still there — including wheels for packages that were notinstalled. Trivy scans them as real packages, which is why its report lists
root/.cache/uv/archive-v0/…/pytest_base_url-2.1.0.dist-info/METADATAbeside theapplication's own dependencies.
uvwrites this cache by default and the Dockerfile neverclears it.
What this asks for
An image that contains what it runs.
Worth being careful about
--no-default-groupsis the fix for the first, and it should be spelled deliberately.uv sync --locked --no-default-groups --extra serverinstalls the project and the namedextra and nothing else;
--no-dev --no-group e2esays the same thing and needs editing everytime a group is added. The first is the one that stays right.
UV_NO_CACHE=1, or removing the cache in the same layer, for the second. Deleting it ina later
RUNsaves nothing — the bytes are already in the layer below.A test can catch both, and should, because nothing else will.
tests/test_image_build.pyalready reads the Dockerfile for a different reason (#121); asserting that the sync line
excludes every group, and that the cache does not survive, is the same shape. The real check
is a built image, and that waits on #81.
Rebuilding is not free on the target hardware — around ten minutes on the Pi — so this is
worth doing together with anything else that changes the image.
Nothing here is exploitable today, and the issue should not pretend otherwise. Playwright
launches no browser, pytest is never imported, and the cache is inert. This is attack surface
and image weight, not a vulnerability: fewer things in the image is fewer things to be wrong
about later, and a 906 MB image on a Raspberry Pi is a slow pull and a full disk sooner.
Measured the rest of the image while confirming this one. The two causes here are 432 MB of 906 MB — the uv cache is 296 MB and playwright is 136 MB — so fixing this issue alone takes the image to roughly 430 MB.
#157 carries the full breakdown and what is left afterwards: the
uvbinary (45 MB, load-bearing for plugin installs), the.pocatalogues (17 MB, and nothing reads them at runtime), and Django admin's static files (6.6 MB, collected whether or not an admin is mounted). It also makes the structural point that the Python side of the build is single-stage, which is why build artefacts end up in the shipped layers at all.uv sync --no-devomitted only thedevgroup whilepyproject.tomldeclaresdefault-groups = ["dev", "e2e"], so the runtime image shipped Playwright, its browser driver and the rest of the browser suite.--no-default-groupsomits every one of them.--no-cachegoes with it: uv's cache was a second copy of every wheel, in a layer nothing reads at runtime.A third test asserts
default-groups ⊆ dependency-groupsand that there is more than one group, because the flag is only wrong when the two disagree — which is exactly the condition nobody notices. Verified in the built image: nodev-group package present, all foure2eones gone.Shipped in
42954ccon0.3.0, withmainkept level.