Files
sneak 8f976e38e0 Add nginx (closes #11)
Install nginx from apt and check nginx -t in the smoke test.

Model: deepseek-v4-pro
2026-11-05 15:45:26 +00:00

225 lines
12 KiB
Markdown

# 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` |
| shellcheck | apt | `/usr/bin/shellcheck` |
| govulncheck | `go install` (latest) | `/usr/local/bin/govulncheck` |
| 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 |
| prettier | `npm install -g prettier` | nvm LTS global bin |
| Playwright | `npm install -g playwright` | nvm LTS global bin |
| Playwright browsers | `playwright install` (chromium, firefox) | `/ms-playwright` |
| Firefox | Mozilla apt repo | `/usr/bin/firefox` |
| geckodriver | GitHub release | `/usr/local/bin/geckodriver` |
| headless Chrome | Playwright Chromium | `chromium` on `PATH` |
| 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` |
| tzdata | apt | `/usr/share/zoneinfo` |
| zstd | apt | `/usr/bin/zstd` |
| sqlite3 (CLI) | apt | `/usr/bin/sqlite3` |
| libvips | apt `libvips-dev`, `libvips-tools` | `vips` on `PATH` |
| libheif | apt `libheif-dev`, `libheif-plugin-aomenc` | HEIF/AVIF codec |
| ImageMagick | apt | `magick` |
| Ghostscript | apt | `gs` |
| Ruby | apt `ruby`, `ruby-dev` | `/usr/bin/ruby` |
| Bundler | apt `bundler` | `bundle` on `PATH` |
| Valkey | apt `valkey-server`, `valkey-tools` | `valkey-server`, `valkey-cli` |
| PostgreSQL | apt `postgresql` | `psql`, `postgres`, `initdb`, `pg_ctl` on `PATH` |
| nginx | apt | `/usr/sbin/nginx` |
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`,
`PLAYWRIGHT_BROWSERS_PATH`.
### 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, and Python
- Hugo and golangci-lint
- npm, yarn, pnpm, wrangler, and prettier
- Playwright and its browsers
- Firefox and geckodriver
Playwright installs its browsers for the image's Playwright version only. A
child image that pins a different Playwright version downloads its own
browsers.
## 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 and releasing
```sh
make release # build, test, push both arches, tag, commit logs
make -j2 release # same, with the two arches in parallel
make build # build for this machine's arch only
make test # smoke-test this machine's build
```
Each architecture is built natively on a daemon of that architecture: arm64
on the local daemon, amd64 on `fsn1app1` via `DOCKER_HOST`. The daemon per
arch is set by `DOCKER_HOST_amd64` and `DOCKER_HOST_arm64` in the
`Makefile` and can be overridden on the command line. The release date
defaults to today and can be overridden with `DATE=YYYY-MM-DD`.
`make release` runs, per arch, `bin/build`, `bin/test`, and `bin/push`,
then `bin/manifest` to join the per-arch tags into `latest` and the dated
tag, then `bin/commit-logs` to commit `logs/YYYY-MM-DD/` and push the repo.
`bin/test` checks that the toolchains resolve, yarn and pnpm work offline,
nothing is left in `/tmp`, and a runit service is stopped cleanly by
`docker stop` with exit code 0.
Registry login is done once on the local docker client with
`docker login git.eeqj.de`; the client forwards the credentials to the
remote daemon on push.
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 1.63 GB
compressed, 4.3 GB on disk) 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
- `Makefile` — target wiring and configuration only; no logic.
- `bin/` — one script per step: `build`, `test`, `push`, `manifest`,
`commit-logs`, `clean`, plus `lib.sh` (shared setup) and `native-arch`.
- `Dockerfile` — base, environment, one `COPY`, one `ADD`, one `RUN`.
- `build.sh` — everything the `RUN` does, in order: apt, Go, runsvinit,
golangci-lint, govulncheck, Hugo, nvm/Node/corepack/wrangler, browsers,
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.