Build and run are two images, from one recipe

A module's recipe starts from this image and invokes the compiler out of it, so
the compiler had to be here. The same image was also what every module ran in,
so every running container on every machine carried a compiler it would never
invoke: 23 of the 28 MB of libraries. An earlier attempt to prune the build
tools produced a smaller image that nothing could be built on, and the comment
defending their return made a workaround look like a decision.

One recipe, because the two must agree about the operating system, the language
version and the library, and two files drift.
This commit is contained in:
2026-09-14 01:57:05 +02:00
parent 0383c1987a
commit 62e2aa7708
2 changed files with 43 additions and 39 deletions
+31 -38
View File
@@ -1,40 +1,32 @@
# The tool runtime: the base every module written in this toolchain is compiled on top of, and the
# container a node runs to serve them. It is handed the broker credential and the assigned modules'
# entrypoints at deploy time and serves them.
# Two images from one recipe: the one modules are COMPILED in, and the one they RUN in.
#
# **Built from this repository alone.** It used to copy in a compiled output directory that is not
# in source control, and resolve the mesh's own toolkit to a sibling checkout on the same disk — so
# it could only be produced on a workstation with two repositories laid out side by side, and its
# fingerprint was then typed into every module's recipe by hand. That put the one artifact the whole
# toolchain stands on outside the toolchain: nothing could rebuild it, so nothing could check it,
# and the rule that catches a base moving had no version on the far end of its edge and could never
# fire (novox/hq issue 044).
# **They were the same image, and that was a mistake.** A module's recipe starts from this and
# invokes the compiler out of it, so the compiler had to be here — and because the same image was
# also what every module ran in, every running container on every machine carried a TypeScript
# compiler it would never invoke. 23 of the 28 MB of libraries were that compiler. It was defended
# in a comment, which made a workaround look like a decision: the earlier attempt to prune the build
# tools produced a smaller image that nothing could be built on, and the answer to that is two
# images rather than one image that is bad at both jobs.
#
# Debian rather than Alpine, and root rather than an unprivileged user, because that is what the
# image actually in service is — and modules have already been built against it, one of which
# installs a package with Debian's package manager. This recipe said Alpine while serving Debian for
# as long as nobody could rebuild it to notice. Changing the operating system under every module is
# a separate decision from making this buildable, and is not being taken here.
FROM node:22-bookworm-slim AS build
# Kept in one recipe deliberately. They must agree about the operating system, the language version
# and the library, and two files drift. `build.artifacts` in module.json names a stage each.
# ---- toolchain: what a module is compiled in -------------------------------------------------
FROM node:22-bookworm-slim AS toolchain
# git, because a dependency named by a git URL is fetched by git and this image does not carry it.
# Only in the build stage: what it is needed for happens here, and a runtime that can clone is a
# runtime that can be made to clone.
# Only here: what it is needed for happens at build time, and an image that can clone is an image
# that can be made to clone.
RUN apt-get update \
&& apt-get install -y --no-install-recommends git ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package.json package-lock.json ./
# Development dependencies included: the compiler is one of them, and so is the toolkit's own — it
# builds itself on install, which is what lets it be named by a git URL rather than fetched from a
# package registry this mesh does not yet run.
# Development dependencies included: the compiler is one, and so is the toolkit's own.
RUN npm install --no-audit --no-fund
# **And then compile the toolkit, because npm did not.** It declares a `prepare` script, which is
# the hook npm is supposed to run after installing a package from git — and this npm does not run
# it, so the package arrives as sources with every one of its entry points pointing at a compiled
# directory that is not there. The compile is therefore done here, explicitly: install the toolkit's
# own build dependencies inside it, build it, then drop them again so they do not travel into the
# image. Doing it by hand rather than relying on the hook is also the honest arrangement — a build
# that silently depended on a hook firing would break the day it stopped, in the same invisible way.
# **And then compile the toolkit, because npm did not.** It declares a `prepare` script, the hook a
# package manager is supposed to run after installing from git, and this one does not run it — so
# the package arrives as sources with every entry point pointing at a compiled directory that is not
# there. Done explicitly rather than relying on a hook firing, which would break the day it stopped.
RUN npm --prefix node_modules/@novox/mesh-sdk install --no-audit --no-fund \
&& npm --prefix node_modules/@novox/mesh-sdk run build \
&& npm --prefix node_modules/@novox/mesh-sdk prune --omit=dev
@@ -42,16 +34,17 @@ COPY tsconfig.json ./
COPY src ./src
RUN npm run build
# Everything the modules compile against and run on.
#
# **The build dependencies stay, and that is deliberate.** This image is not only what a module runs
# in — it is also what every module is *compiled* in: a module's recipe starts from this and invokes
# the compiler out of these same directories. Dropping them would halve the image and break every
# module that builds on it, which is the sort of tidy-looking change that only fails somewhere else.
# If the two roles are ever separated, they should be separated deliberately and named separately.
FROM node:22-bookworm-slim
# ---- what the running image needs, and nothing else -------------------------------------------
# Its own stage so the toolchain image keeps its build tools while the runtime image does not. The
# prune has to happen somewhere, and doing it in the toolchain stage would take the compiler out of
# the image whose whole purpose is to have one.
FROM toolchain AS lean
RUN npm prune --omit=dev
# ---- runtime: what a module runs in -----------------------------------------------------------
FROM node:22-bookworm-slim AS runtime
WORKDIR /app
COPY package.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY --from=lean /app/node_modules ./node_modules
COPY --from=toolchain /app/dist ./dist
ENTRYPOINT ["node", "dist/main.js"]
+12 -1
View File
@@ -4,7 +4,18 @@
"slug": "tools",
"build": {
"artifacts": [
{ "name": "runtime", "kind": "image", "from": "Dockerfile" }
{
"name": "build",
"kind": "image",
"from": "Dockerfile",
"target": "toolchain"
},
{
"name": "runtime",
"kind": "image",
"from": "Dockerfile",
"target": "runtime"
}
]
},
"resources": []