From b0ab6ee5c05bdf4e66e614b45b73967318e4c17e Mon Sep 17 00:00:00 2001 From: Kristian Wind Date: Mon, 17 Aug 2026 23:04:09 +0200 Subject: [PATCH] fix(docker): build the frontend and the binary once, and cross-compile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two of the last five releases died at exit 132 — SIGILL, an instruction QEMU does not implement — partway through `npm run build`. It reads as a broken build and is a broken emulator. The cause is that neither builder stage was pinned to the build platform, so buildx ran both once per target: Node and esbuild under emulation for arm64, and the entire Go toolchain too. Nothing in either stage needs to be the target architecture. The frontend produces HTML, CSS and JavaScript, which are the same bytes wherever the image runs, and Go cross-compiles natively with CGO already off. So both are `--platform=$BUILDPLATFORM` now, and the binary is built with `GOARCH=${TARGETARCH}`. The frontend is built once instead of twice, and nothing is emulated at all — which should also take most of the fifteen minutes off a release. Co-Authored-By: Claude Opus 5 --- Dockerfile | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 0349aaa..abab844 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,17 @@ # manager and no libc — the attack surface of the running container is the binary. # --- 1. the web interface ---------------------------------------------------- -FROM node:22-alpine AS web +# +# Pinned to the *build* platform, not the target. What this stage produces is +# HTML, CSS and JavaScript, which are the same bytes whatever the image will run +# on — so building it once and copying it into both architectures is not an +# optimisation, it is the correct thing. +# +# Without the pin, buildx runs this stage once per target, which means running +# Node and esbuild under QEMU for arm64. That crashes: exit 132, SIGILL, an +# instruction the emulator does not implement, roughly one release in three. It +# looks like a broken build and is a broken emulator. +FROM --platform=$BUILDPLATFORM node:22-alpine AS web WORKDIR /src COPY web/ ./ @@ -24,7 +34,12 @@ RUN if [ -f package.json ]; then \ fi # --- 2. the binary ----------------------------------------------------------- -FROM golang:1.26-alpine AS build +# +# Also pinned to the build platform, and cross-compiled instead. Go does that +# natively and CGO is already off, so there is nothing to emulate — where +# building *in* the target architecture means running the whole Go toolchain +# under QEMU for one of the two. +FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS build WORKDIR /src @@ -37,11 +52,13 @@ COPY . . COPY --from=web /src/build ./cmd/verdande/webbuild ARG VERSION=dev +# Supplied by buildx per target. The default keeps a plain `docker build` working. +ARG TARGETARCH=amd64 # CGO off is what makes this binary static, and it is only possible because the # SQLite driver is pure Go. -trimpath and the empty buildid keep the output # reproducible; -s -w drop the symbol and DWARF tables, which is most of the size. -RUN CGO_ENABLED=0 GOOS=linux go build \ +RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \ -tags embedweb \ -trimpath \ -ldflags="-s -w -buildid= -X main.version=${VERSION}" \