Add fat Ubuntu 26.04 base image with runit, Go, Node, Python toolchains

Single-RUN image built by build.sh on ubuntu:26.04: build-essential,
distro plus latest Go, nvm with Node LTS, corepack with yarn/pnpm cached,
wrangler, hugo extended, golangci-lint, pyenv with latest Python 3, pip
and virtualenv. runit services supervised under runsvinit as PID 1.
Docs, man pages, caches and non-English locales stripped.

Includes build logs for the 2026-11-05 release (amd64 and arm64).
This commit is contained in:
2026-11-05 04:37:00 -08:00
parent ff18e169e7
commit df31d11028
7 changed files with 7675 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
# syntax=docker/dockerfile:1
#
# Fat base image: current Ubuntu LTS with Go, Node, Python, Hugo, and the
# usual build tooling preinstalled, supervised by runit under runsvinit.
# Builds for linux/amd64 and linux/arm64 from the same definition.
# All the work happens in build.sh.
FROM ubuntu:26.04
# Pinned sources. Everything else (apt, Go, Node LTS, Python, Hugo,
# golangci-lint, yarn, wrangler) resolves to its current release at build.
ARG RUNSVINIT_COMMIT=b4b2c785308b1ce785b6155c7fe5f16879080193
ARG NVM_GIT_TAG=v0.41.0
ARG PYENV_GIT_TAG=v2.8.14
ENV LANG=C.UTF-8 \
NVM_DIR=/usr/local/nvm \
NVM_SYMLINK_CURRENT=true \
COREPACK_HOME=/usr/local/corepack \
COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
COREPACK_ENABLE_AUTO_PIN=0 \
PYENV_ROOT=/usr/local/pyenv \
GOPATH=/root/go \
PATH=/usr/local/go/bin:/root/go/bin:/usr/local/nvm/current/bin:/usr/local/pyenv/shims:/usr/local/pyenv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
COPY rootfs/ /
ADD build.sh /tmp/build.sh
RUN bash /tmp/build.sh
CMD ["/usr/local/sbin/runsvinit"]
+190
View File
@@ -1,2 +1,192 @@
# baseimage
A fat Docker base image that trades size for build speed. Everything a
typical Go, Node, Python, or Hugo project needs is preinstalled, so child
images only have to copy their source and build, instead of spending the
first few minutes of every build on `apt-get install` and toolchain
downloads.
Image: `git.eeqj.de/sneak/baseimage`, built for `linux/amd64` and
`linux/arm64`.
## What is in it
| Component | Source | Location |
| --------------- | ------------------------------ | ------------------------------ |
| Ubuntu 26.04 | `ubuntu:26.04`, fully upgraded | `/` |
| init | `runsvinit` (built from Go) | `/usr/local/sbin/runsvinit` |
| services | `runit` (apt) | `/etc/sv`, `/etc/service` |
| build-essential | apt | `gcc`, `make`, `pkg-config` |
| Go (distro) | apt `golang-go` | `/usr/lib/go-*` |
| Go (latest) | go.dev tarball | `/usr/local/go` |
| golangci-lint | GitHub release | `/usr/local/bin/golangci-lint` |
| Hugo (extended) | GitHub release `.deb` | `/usr/local/bin/hugo` |
| Node (distro) | apt `nodejs`, `npm` | `/usr/bin/node` |
| nvm | git tag | `/usr/local/nvm` |
| Node (LTS) | `nvm install --lts` | `/usr/local/nvm/current` |
| corepack | `npm install -g corepack` | nvm LTS global bin |
| yarn, pnpm | corepack, cache pre-populated | `/usr/local/corepack` |
| wrangler | `npm install -g wrangler` | nvm LTS global bin |
| Python (distro) | apt `python3`, `pip`, `venv` | `/usr/bin/python3` |
| pyenv | git tag | `/usr/local/pyenv` |
| Python (latest) | `pyenv install` | `/usr/local/pyenv/versions/*` |
| virtualenv | apt, and pip in pyenv Python | `virtualenv` on `PATH` |
Plus the usual odds and ends: `git`, `curl`, `wget`, `jq`, `rsync`,
`openssh-client`, `zip`/`unzip`, `xz-utils`, `bzip2`, and the `-dev`
headers needed to compile CPython and common native extensions.
The "latest" toolchains win on `PATH`. `go`, `node`, `npm`, `yarn`,
`python3`, and `pip` all resolve to the manually installed current
versions; the distro packages are there as a fallback and to satisfy apt
dependencies in child images.
`PATH` order:
```
/usr/local/go/bin
/root/go/bin
/usr/local/nvm/current/bin
/usr/local/pyenv/shims
/usr/local/pyenv/bin
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
```
Environment variables set in the image: `NVM_DIR`, `NVM_SYMLINK_CURRENT`,
`COREPACK_HOME`, `COREPACK_ENABLE_DOWNLOAD_PROMPT=0`,
`COREPACK_ENABLE_AUTO_PIN=0`, `PYENV_ROOT`, `GOPATH`, `LANG=C.UTF-8`.
### Yarn and corepack
`yarn` is provided by corepack, which is how the Yarn project ships Yarn
4. Corepack reads the `packageManager` field in a project's `package.json`
and runs exactly that version. The corepack cache in `/usr/local/corepack`
is pre-populated with current Yarn stable (the default when a project does
not pin one), Yarn 1 classic, and current pnpm, so projects pinning any of
those do not download anything at run time. A project pinning some other
version will be fetched on first use.
### Pinned versions
Only the sources that have no "latest" lookup are pinned, as build args in
the `Dockerfile`:
| Arg | What |
| ------------------ | -------------------------- |
| `RUNSVINIT_COMMIT` | `peterbourgon/runsvinit` |
| `NVM_GIT_TAG` | `nvm-sh/nvm` release tag |
| `PYENV_GIT_TAG` | `pyenv/pyenv` release tag |
Everything else resolves to its current release at build time: Ubuntu
packages, Go, Node LTS, Python, Hugo, golangci-lint, npm, yarn, pnpm,
wrangler.
## Services and init
PID 1 is [`runsvinit`](https://github.com/peterbourgon/runsvinit), a small
Go program written for exactly this job. It starts `runsvdir` on
`/etc/service`, reaps orphaned zombies, and on `SIGTERM` or `SIGINT` runs
`sv stop` on every service before letting `runsvdir` exit. `docker stop`
therefore stops services gracefully. The project is archived upstream, so
it is built from a pinned commit at image build time.
To add a service in a child image, create a runit service directory and
link it into `/etc/service`:
```dockerfile
FROM git.eeqj.de/sneak/baseimage:latest
COPY sv/myapp/run /etc/sv/myapp/run
RUN chmod +x /etc/sv/myapp/run && ln -s /etc/sv/myapp /etc/service/myapp
```
A minimal `run` script:
```sh
#!/bin/sh
exec 2>&1
exec /usr/local/bin/myapp
```
Services should log to stdout/stderr; the container's log is the combined
output of all services.
The image sets `CMD ["/usr/local/sbin/runsvinit"]` and no `ENTRYPOINT`, so
a child image that only needs one unsupervised process can override `CMD`
and ignore runit entirely, and `docker run -it image bash` works as
expected.
## Cleanup
Installation leaves nothing behind that production does not need:
- apt lists and cache are removed, and `dpkg` is configured (see
`rootfs/etc/dpkg/dpkg.cfg.d/01_nodoc`) to exclude `/usr/share/doc`
(except copyright files), `/usr/share/man`, `/usr/share/info`, and
locales other than English. Package installs in child images inherit
that config.
- npm, nvm, pip, and Go build/module caches are emptied.
- Python test suites and the Go toolchain's test tree are stripped.
- `/root/go` is removed; it is recreated on first use.
Development headers (`-dev` packages) are deliberately kept: they are
what child images need to build things.
## Building
Native architecture only:
```sh
docker build -t git.eeqj.de/sneak/baseimage:latest .
```
### Release build
Each architecture is built natively on a host of that architecture (arm64
locally, amd64 on `fsn1app1` via `DOCKER_HOST=ssh://root@fsn1app1`), pushed
under a per-arch tag, and then joined into a multi-arch manifest:
```sh
# on each host, for its own arch
docker build --progress=plain -t baseimage:dev . 2>&1 | tee logs/YYYY-MM-DD/build-ARCH.log
docker tag baseimage:dev git.eeqj.de/sneak/baseimage:ARCH
docker push git.eeqj.de/sneak/baseimage:ARCH
# once both per-arch tags are in the registry
docker buildx imagetools create \
-t git.eeqj.de/sneak/baseimage:latest \
-t git.eeqj.de/sneak/baseimage:YYYY-MM-DD \
git.eeqj.de/sneak/baseimage:amd64 git.eeqj.de/sneak/baseimage:arm64
```
Tags:
| Tag | Meaning |
| -------------------- | ----------------------------------------- |
| `latest` | multi-arch manifest of the newest release |
| `YYYY-MM-DD` | multi-arch manifest of that day's release |
| `amd64`, `arm64` | per-arch images behind the newest release |
The full `--progress=plain` build log for each arch of each release is
committed under `logs/YYYY-MM-DD/`.
Every unpinned version is resolved at build time, so a rebuild picks up
current packages and tool releases. Rebuild and retag periodically; child
images get everything new on their next build.
The registry sits behind Traefik. The large `RUN` layer (about 650MB
compressed, over 2GB uncompressed) needs the entrypoint
`respondingTimeouts` raised from Traefik's 60 second default, or the final
blob commit fails with a 499 or 500 and docker re-uploads the layer.
Interactive shells (login shells, or anything that sources
`/etc/profile.d`) also get the `nvm` and `pyenv` shell functions from
`rootfs/etc/profile.d/baseimage.sh`.
## Layout
- `Dockerfile` — base, environment, one `COPY`, one `ADD`, one `RUN`.
- `build.sh` — everything the `RUN` does, in order: apt, Go, runsvinit,
golangci-lint, Hugo, nvm/Node/corepack/wrangler, pyenv/Python, cleanup,
sanity checks. Removes itself when done.
- `rootfs/` — files copied verbatim onto `/` before `build.sh` runs.
- `logs/YYYY-MM-DD/` — build logs for each released image, per arch.
Executable
+113
View File
@@ -0,0 +1,113 @@
#!/bin/bash
# Image build script. Run once by the Dockerfile; removes itself when done.
# Everything that is not pinned by an environment variable resolves to its
# current release at build time.
set -euxo pipefail
export DEBIAN_FRONTEND=noninteractive
arch="$(dpkg --print-architecture)"
latest_tag() {
curl -fsSLI -o /dev/null -w '%{url_effective}' \
"https://github.com/$1/releases/latest" | sed 's,.*/tag/,,'
}
# --- distro packages -------------------------------------------------------
apt-get update
apt-get -y full-upgrade
apt-get -y install --no-install-recommends \
bzip2 ca-certificates curl git gnupg jq less openssh-client rsync \
unzip wget xz-utils zip \
build-essential make pkg-config \
runit \
golang-go \
nodejs npm \
python3 python3-dev python3-pip python3-venv virtualenv \
libbz2-dev libffi-dev libgdbm-dev liblzma-dev libncurses-dev \
libreadline-dev libsqlite3-dev libssl-dev libxml2-dev libxmlsec1-dev \
tk-dev uuid-dev zlib1g-dev
# --- Go (latest) -------------------------------------------------------------
gover="$(curl -fsSL 'https://go.dev/VERSION?m=text' | head -n1)"
curl -fsSL "https://go.dev/dl/${gover}.linux-${arch}.tar.gz" | tar -C /usr/local -xz
rm -rf /usr/local/go/test
# --- runsvinit (PID 1: reaps zombies, stops services on SIGTERM) -------------
git clone -q https://github.com/peterbourgon/runsvinit /tmp/runsvinit
git -C /tmp/runsvinit checkout -q "$RUNSVINIT_COMMIT"
(
cd /tmp/runsvinit
go mod init runsvinit >/dev/null 2>&1
CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /usr/local/sbin/runsvinit .
)
# --- golangci-lint -----------------------------------------------------------
gl="$(latest_tag golangci/golangci-lint)"
curl -fsSL "https://github.com/golangci/golangci-lint/releases/download/${gl}/golangci-lint-${gl#v}-linux-${arch}.tar.gz" |
tar -C /usr/local/bin -xz --strip-components=1 "golangci-lint-${gl#v}-linux-${arch}/golangci-lint"
# --- Hugo (extended) ---------------------------------------------------------
hv="$(latest_tag gohugoio/hugo)"
curl -fsSL -o /tmp/hugo.deb \
"https://github.com/gohugoio/hugo/releases/download/${hv}/hugo_extended_${hv#v}_linux-${arch}.deb"
apt-get -y install /tmp/hugo.deb
# --- nvm, Node LTS, yarn, wrangler ------------------------------------------
git clone -q --depth 1 --branch "$NVM_GIT_TAG" https://github.com/nvm-sh/nvm.git "$NVM_DIR"
rm -rf "$NVM_DIR/.git"
set +ux # nvm.sh is not clean under set -u / too noisy under set -x
. "$NVM_DIR/nvm.sh"
nvm install --lts
nvm alias default "lts/*"
nvm use default
set -ux
npm install -g npm@latest corepack wrangler
corepack enable
# Pre-populate the corepack cache so projects that pin a package manager
# do not download at run time. The last yarn installed becomes the default.
corepack install -g pnpm@latest
corepack install -g yarn@1
corepack install -g yarn@stable
# pnpm 10+ fetches a standalone binary on first run; trigger that now so
# it lands in COREPACK_HOME.
pnpm --version
yarn --version
nvm cache clear
# --- pyenv, Python (latest), pip, virtualenv ---------------------------------
git clone -q --depth 1 --branch "$PYENV_GIT_TAG" https://github.com/pyenv/pyenv.git "$PYENV_ROOT"
rm -rf "$PYENV_ROOT/.git"
pyver="$(pyenv latest --known 3)"
pyenv install "$pyver"
pyenv global "$pyver"
rm -rf "$PYENV_ROOT"/versions/*/lib/python3*/test
python3 -m pip install --no-cache-dir --upgrade pip setuptools wheel virtualenv
pyenv rehash
# --- cleanup -----------------------------------------------------------------
go clean -cache -modcache -testcache
apt-get -y autoremove --purge
apt-get clean
rm -rf /var/lib/apt/lists/* /var/cache/apt/* /var/cache/debconf/*-old /var/log/*
rm -rf /usr/share/doc /usr/share/man /usr/share/info /usr/share/lintian
find /usr/share/locale -mindepth 1 -maxdepth 1 ! -name 'en*' ! -name 'locale.alias' -exec rm -rf {} +
rm -rf /root/.cache /root/.npm /root/.config /root/go /tmp/* /var/tmp/*
# --- sanity ------------------------------------------------------------------
go version
golangci-lint version
hugo version
node --version
npm --version
COREPACK_ENABLE_NETWORK=0 yarn --version
COREPACK_ENABLE_NETWORK=0 pnpm --version
wrangler --version
python3 --version
pip --version
virtualenv --version
test -x /usr/local/sbin/runsvinit
test -d /etc/service
# The checks above leave caches behind (node compile cache etc).
rm -rf /tmp/* /var/tmp/* /root/.cache /root/.npm /root/.config
rm -f "$0"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+10
View File
@@ -0,0 +1,10 @@
# Keep documentation and non-English locales out of the image. Applies to
# every package installed here and in child images.
path-exclude /usr/share/doc/*
path-include /usr/share/doc/*/copyright
path-exclude /usr/share/man/*
path-exclude /usr/share/info/*
path-exclude /usr/share/lintian/*
path-exclude /usr/share/locale/*
path-include /usr/share/locale/en*
path-include /usr/share/locale/locale.alias
+8
View File
@@ -0,0 +1,8 @@
# Interactive shells get the nvm and pyenv shell functions. Non-interactive
# use does not need this: the image's PATH already points at the installed
# toolchains.
export NVM_DIR="${NVM_DIR:-/usr/local/nvm}"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" --no-use
export PYENV_ROOT="${PYENV_ROOT:-/usr/local/pyenv}"
command -v pyenv >/dev/null 2>&1 && eval "$(pyenv init - --no-rehash)"