Skip to content

infra(image): a .dockerignore that is read, 69 MB nobody reads, and bytecode in the layer - #11211

Merged
MarkusNeusinger merged 4 commits into
mainfrom
infra/image-context
Sep 3, 2026
Merged

infra(image): a .dockerignore that is read, 69 MB nobody reads, and bytecode in the layer#11211
MarkusNeusinger merged 4 commits into
mainfrom
infra/image-context

Conversation

@MarkusNeusinger

@MarkusNeusinger MarkusNeusinger commented Sep 3, 2026

Copy link
Copy Markdown
Owner

Three findings from the 2026-09-02/03 sibling-transfer audit, all in the API image build.

1. api/.dockerignore was never read

Docker reads the ignore file from the build context, and the context is the repo root — api/cloudbuild.yaml builds -f api/Dockerfile ., ci-image.yml uses context: .. So api/.dockerignore has been decorative since it was written.

The proof is inside the file: it excludes *.md, and the builder's COPY pyproject.toml uv.lock README.md ./ has succeeded on every build.

Measured cost (clean checkout of origin/main plus the .git a Cloud Build/Actions checkout brings):

part size
working tree 110 MB
.git 120 MB
context uploaded per build ~230 MB

Fix: a root .dockerignore, written as an allowlist (api, core, pyproject.toml, uv.lock, README.md). A denylist that misses a new directory only makes the context quietly fatter; an allowlist that misses one fails at the COPY line — the loud failure. Narrowing the context to api/ instead is not available: the image needs core/ and the lock files, which live above it. app/ is unaffected — the frontend build's context is app, so app/.dockerignore still governs it.

2. COPY plots/ ./plots/ is dead weight

Nothing under api/ or core/ opens a path below plots/ — the implementations this API serves come from Postgres (core/database). ci-image.yml already says so where it explains why plots/** is not a build trigger.

Measured on the latest manifest in Artifact Registry (2026-09-03):

before after
plots/ layer (compressed) 16.58 MB
plots/ unpacked 69.2 MB, 9,166 files
image, compressed layers total 215.30 MB 198.72 MB before item 3

tar cf - plots \| gzip gives 16.57 MB, which is how layer 10 was identified.

3. No bytecode in the image

The venv shipped source only, and the .dockerignore excludes __pycache__, so the COPYs brought none either: every cold start recompiled the whole import graph.

Measured on this exact dependency set (a throwaway venv from this pyproject.toml + uv.lock, import api.main, three runs each):

run 1 run 2 run 3
no bytecode (PYTHONDONTWRITEBYTECODE=1, empty cache prefix) 4.08 s 3.56 s 4.43 s
bytecode present 2.26 s 2.14 s 1.79 s

~1.8 s off every cold start. It is not free, and the number is worth stating plainly:

venv unpacked compressed
uv sync --frozen 493.1 MB 142.25 MB
UV_COMPILE_BYTECODE=1 uv sync --frozen 686.6 MB 210.28 MB

(142.25 MB reproduces layer 7 of the live manifest at 142.71 MB, which is what validates the whole measurement.)

Net on the image: 215.30 → ~266.8 MB compressed — 16.6 MB out for plots/, 68.0 MB in for .pyc. The trade is one bigger pull per node against ~1.8 s on every container's first request; Cloud Run streams layers and caches them per node, so the repeated cost is the import, not the pull. Say the word and item 3 comes back out — items 1 and 2 stand on their own.

Also here: uv is pinned — the unpinned link in the dependency chain uv.lock otherwise fixes, not a claim that the image is reproducible (python:3.13-slim is a mutable tag and the apt packages stay deliberately unversioned) — and UV_PYTHON names the interpreter, so uv can never quietly download a managed CPython that the runtime stage does not have at the same path.

4. Hadolint exceptions move to the line they excuse

ci-image.yml carried ignore: DL3013,DL3008,DL3025 file-wide and said in its own comment that this was the wrong home. With pip install uv==0.10.9, DL3013 is simply gone. The other two are now # hadolint ignore=<code> comments at the apt-get and the HEALTHCHECK instruction, so a new DL3008 or DL3025 elsewhere in the file blocks instead of being swallowed. The workflow keeps failure-threshold: warning and nothing else changed in it.

Verified locally against hadolint 2.15.1 — the version hadolint-action@v3.5.0 pins:

$ hadolint --failure-threshold warning api/Dockerfile
api/Dockerfile:111 DL3066 info: Non-numeric user-id may not be resolvable by host system
$ echo $?
0

Verification

ci-image.yml builds this image and smokes the container on this PR — that is the loop for a Dockerfile change, and it is what proves the allowlist leaves the build everything it copies. No Docker locally, hence the layer figures come from the Artifact Registry manifest and the venv figures from a reproduced uv sync.

Finding for a follow-up (not in this PR)

api/ and core/ import none of pandas, scipy, scikit-learn, statsmodels or anthropic, and matplotlib only lazily inside two core/palette.py functions — yet all of them are unconditional [project] dependencies and so sit in the API image. Of the 11,227 .pyc files UV_COMPILE_BYTECODE=1 writes, import api.main touches 1,149 (25 MB). Moving the plot-runtime libraries into an extra the image does not install would dwarf both numbers in this PR. That is a dependency-graph change with its own blast radius, so it is filed here rather than done.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UEScQMZFvxxNNyNJYryfa3

…69 MB nobody reads

`api/.dockerignore` was never consulted: Docker reads the ignore file from the
build context, and both api/cloudbuild.yaml and .github/workflows/ci-image.yml
build with `.`. The file proved it itself — it excluded `*.md` while the
builder's `COPY … README.md` kept succeeding. Every build uploaded ~230 MB.

The replacement is a root allowlist. A denylist that misses a new directory
only makes the context quietly fatter; an allowlist that misses one fails at
the COPY line. Narrowing the context to `api/` is not available: the image
needs `core/` and the lock files, which live above it.

`COPY plots/` leaves the runtime stage — 16.6 MB of every pulled image for a
directory nothing under api/ or core/ opens; the implementations come from
Postgres, as ci-image.yml already notes.

`UV_COMPILE_BYTECODE=1` plus `compileall` over api and core take ~1.8 s off
every cold start (import api.main: 3.56–4.43 s cold vs 1.79–2.26 s with .pyc),
at 68 MB more compressed venv layer. uv is pinned, and UV_PYTHON names the
interpreter so uv cannot download a managed CPython the runtime stage lacks.

With the uv pin, hadolint's DL3013 exception is obsolete; DL3008 and DL3025
move from the workflow's file-wide `ignore:` onto the instructions they excuse,
so a new occurrence elsewhere is caught rather than swallowed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UEScQMZFvxxNNyNJYryfa3
Copilot AI balanced review requested due to automatic review settings September 3, 2026 20:49
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UEScQMZFvxxNNyNJYryfa3

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

It changes production image composition and makes an operational image-size versus cold-start tradeoff requiring human review.

Pull request overview

Optimizes the API container build context and runtime image.

Changes:

  • Adds a root allowlist-based .dockerignore.
  • Removes unused plot assets and precompiles Python bytecode.
  • Pins uv and scopes Hadolint exceptions to individual instructions.
File summaries
File Description
.dockerignore Defines the API build-context allowlist.
api/.dockerignore Removes the ineffective nested ignore file.
api/Dockerfile Optimizes image contents and startup behavior.
.github/workflows/ci-image.yml Moves Hadolint exceptions into the Dockerfile.
CHANGELOG.md Documents the image-build changes.
Review details
  • Files reviewed: 5/5 changed files
  • Comments generated: 2
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread CHANGELOG.md Outdated
Comment thread api/Dockerfile Outdated
Copilot: python:3.13-slim is a mutable tag and the apt packages are
deliberately unpinned, so uv was never the image's only unpinned input — only
the unpinned link in the chain uv.lock otherwise fixes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UEScQMZFvxxNNyNJYryfa3
Copilot AI review requested due to automatic review settings September 3, 2026 21:10

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The deployment-image changes introduce a substantial size-versus-startup tradeoff and require final human review.

Review details
  • Files reviewed: 5/5 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread api/Dockerfile
… the image

Copilot: this PR removed the COPY, and the comment explaining why plots/** is
not a build trigger still described it as copied.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UEScQMZFvxxNNyNJYryfa3
@MarkusNeusinger
MarkusNeusinger merged commit 4e91f02 into main Sep 3, 2026
10 checks passed
@MarkusNeusinger
MarkusNeusinger deleted the infra/image-context branch September 3, 2026 21:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants