ADR-0035: The thin images¶
Status: Accepted Date: 2026-10-13 Deciders: Alex Nodeland
Builds the thin images of RFC-0004's phase 3, for the settings ADR-0034 gives them: the six that carry a service's configuration, then the two one-off tasks', stackr-db-init and stackr-auth-keys, in the pull request after. It amends ADR-0034's table of those settings, for the telemetry stores' S3, and settles the release that publishes them.
Context¶
Fargate can't mount a checkout, so each service on Fargate whose configuration stackr owns runs a thin image: the upstream image compose.yaml pins, with stackr's files in it. RFC-0004 asks for one file per service, which reads from the environment whatever differs on AWS. ADR-0034 named those settings: each service's peers by their names in stackr.internal, and the telemetry stores' storage in S3.
Building the images settled the rest: where they're built from, how each tool takes its settings, and how CI shows that each one works on two architectures, since Fargate runs arm64 and tier 2's Floci runs the runners' amd64.
Decision¶
- Six images carry a service's configuration:
stackr-otel-collector,stackr-tempo,stackr-loki,stackr-pyroscope,stackr-grafanaandstackr-litellm. Each is the imagecompose.yamlpins, with stackr's configuration copied in, and its command set to read it. stackr-db-initis the PostgreSQL clientcompose.yamlpins, withdeploy/postgres/init.sql, run as the image'spostgresuser. The file gains what the AWS recipe needs, each part only when its settings are given, so one machine's db-init is unchanged:- Grafana's role and database, with
GRAFANA_DB_PASSWORD, which only Grafana may connect to; - the application's database and its owner, with
APP_DB_NAMEandAPP_DB_PASSWORD, on PostgreSQL 16 or later, which it refuses to go without. With grantr,APP_DB_CREATES_ROLESlets the owner create roles, fordb-roles; on 16 and later, it administers only those it creates. Without grantr, the owner may not. The stack refuses an application named as one of its own roles or databases, or RDS's (rdsadmin,rds_*).
- Grafana's role and database, with
- db-init keeps the statements that carry passwords out of the server's log where it may. It sets
log_min_error_statementandlog_min_duration_statementfor its own session, which a superuser, or RDS'srds_superuser, may. Where the server refuses, it goes on with the server's settings, and the AWS recipe's parameter group logs statements slower than a second: a password statement that failed, or took that long, would be in RDS's log, which only the account's operators read. stackr-auth-keysrunsscripts/auth-keys, a command around_auth_keys.py, as RFC-0004 first named it, through boto3. It writes the key set toAUTH_JWT_KEYS_ARNonly when that secret has no value. It writes the service role's key toAUTH_SERVICE_ROLE_KEY_ARNwhen that has none, or no longer verifies with the key set. Each write's request token is derived from what it's for, so of two runs at once, Secrets Manager takes the first's write, and the second reads it back. ADR-0034's one JSON secret becomes these two, each read whole: Floci's ECS refused the key set as a JSON key of it ("secret value is not a string"), and a whole secret asks nothing of ECS's JSON keys. Its dependencies are stackr'sauth-keysgroup, as lattice'suv.lockpins them, which uv, from its own pinned image, installs into a virtual environment on the image's Python. The image runs asnobody, with no pip.- Each Dockerfile is beside its configuration, as
deploy/<service>/Dockerfile, labelled with lattice's repository, built from lattice's root, since Grafana's carries artifactr's and reflexr's dashboards. Each has its ownDockerfile.dockerignore, which lets in its files alone, as lattice's other images do. - Each base is its service's image, as
compose.yamlpins it.scripts/check-config awsfails whendeploy/<service>/Dockerfile'sFROMisn't<service>'s image incompose.yaml, so Renovate's moves of the two stay together. LiteLLM's is now its non-root image, in both. auth-keys, whichcompose.yamldoesn't run, has its own pins, Python's and uv's. - Their files are read-only: each
COPYgives thema=rX, and--linkwhere the destination's directories are the image's own to make. auth-keys' virtual environment is the exception: its build stage setsa=rXon it, directories included, since aCOPY's--chmodof a tree leaves its directories to the builder, and wherenobodycan't readpyvenv.cfg, Python ignores the environment. - One file per service, as RFC-0004 asks. It reads the AWS recipe's settings from the environment, with one machine's values by default, so Compose mounts the same file and changes nothing else:
- the Collector reads
${env:TEMPO_ADDRESS:-tempo}and its other peers so; - Tempo, Loki and Pyroscope read theirs with
-config.expand-env=true, whichcompose.yamlnow passes them too. Each file holds both its local disk and S3, and the environment chooses:TEMPO_BACKEND,LOKI_OBJECT_STOREandPYROSCOPE_BACKEND; - Grafana expands
$VARin its provisioning, with no defaults, so the data sources' addresses are settings oncompose.yaml's Grafana, and defaults in its image; - LiteLLM's file already reads everything that differs from the environment.
- the Collector reads
- No tool needed a file for AWS alone.
deploy/build-imagesbuilds and checks them, for the machine's architecture:- the Collector's, Tempo's, Loki's and Pyroscope's configuration, by the tool's own check, on one machine's settings and on the AWS recipe's;
- that each image starts and answers its health check, on a Docker network of its own, which publishes no port and has no route out; LiteLLM's beside a Valkey, which it needs to start;
- that Grafana's four data sources are at their addresses, Compose's and then the AWS recipe's, and that it has the three folders of dashboards;
- that db-init, as a
postgresthat may create roles and databases and is no superuser, as RDS's master, creates one machine's roles and databases and none of the AWS recipe's; then the AWS recipe's, twice; and that the application's owner may create and drop a role with grantr, and may not without; - that auth-keys refuses to start without its secrets, and says which, and that the script does what it should against a stubbed Secrets Manager: with nothing written yet, with both written, with a service role's key that no longer verifies, losing a race to write the key set, and when it is refused.
- The checks' helpers, curl, Valkey and PostgreSQL, are
compose.yaml's pins too. - A stackr release publishes them, and nothing else does: no pull request, no push to main without a release, and no one by hand. Two scripts do it, which CI runs too. After release-please creates stackr's release,
release.yml:- on a runner of each architecture, checks out the release's commit (release-please's
packages/stackr--sha), and builds every image as an OCI archive withdeploy/image-archive, in jobs that can't write to the registry. It builds with a pinned BuildKit, in a builder of its own, and without BuildKit's provenance or SBOM, since the release attests the image itself. The archives are kept for seven days; - in one job per image, the only one with
packages: write, runsdeploy/publish-image. It pushes each architecture's image as<version>-amd64and<version>-arm64, and joins them asghcr.io/alexnodeland/stackr-<image>:<version>, an index carrying the image's description, source and licence. The job then attests that index's build provenance, stored in the registry beside it, as the wheels' is attached to their release; - in a job that may write the release and nothing in the registry, adds each image's reference,
ghcr.io/alexnodeland/stackr-<image>:<version>@sha256:…, to the release's notes, forenvs/prod.
- on a runner of each architecture, checks out the release's commit (release-please's
- Each image's tags are its version, bare, and that version for each architecture. There is no
latest, and no major or minor tag: a deployment names a version and its digest. - A failed publish is run again by re-running the release workflow's failed jobs only, within the archives' seven days, never all its jobs. Re-running all of them re-runs release-please too, which finds nothing new to release and skips the rest, green and silent. The same archives push the same images, under the same tags. After seven days, a new stackr release publishes them. While a publish job has failed for good, the release's notes get no images: their job runs only after every image is published.
- The builds are reproducible. Each layer's and file's time is the release commit's:
SOURCE_DATE_EPOCH, given to each build as an argument too, with BuildKit'srewrite-timestamp. With it, auth-keys'.pycfiles are checked against their sources' hashes, not their times. The labels carry the commit asrevisionand its time ascreated, and the files' modes are fixed in the Dockerfiles, by theirCOPYor, for auth-keys' virtual environment, by its build stage. Nightly builds auth-keys, whose.pycfiles are the likeliest to differ, twice without a cache on amd64, and checks that the digests match. All eight, built twice without a cache on arm64, matched when this was written; nothing checks the other seven since. - A package GHCR makes is private until its owner makes it public, once, in its settings: RFC-0004's images are public. Each image's label links its package to lattice's repository.
- CI builds them natively on both architectures, on
ubuntu-24.04andubuntu-24.04-arm, in a "Thin images" job, whenever stackr, or a project it depends on, changes (scripts/affected.py), and nightly. It publishes nothing. - CI runs the release's path too, short of GHCR, whenever the images' files, the two scripts or
release.ymlchange: "Release images" builds each architecture's archives on its own runner, and publishes them withdeploy/publish-imageto a registry on its own loopback, then checks that each of the eight images' indexes has its two platforms and its annotations. It needs no write permission.
Options considered¶
| Option | For | Against |
|---|---|---|
| Native runners per architecture (chosen) | Each image built and run where it will run; no emulation | Two runners per change |
| One runner, the other architecture under QEMU | One job | Slow, and a check under emulation shows less |
| Dockerfiles beside each configuration (chosen) | A service's files in one place | A Dockerfile in each image's directory |
A deploy/images/ directory |
The images together | Each image's files far from its Dockerfile |
| One file per service, the backend from the environment (chosen) | One file for Compose and AWS, as the RFC asks | Each file carries both backends |
| A file for AWS alone, in the image | Each file simpler | Two files to keep in step |
Consequences¶
- Easier: an image's configuration is the file Compose mounts, checked on both architectures, and on AWS's settings.
- Harder: the telemetry stores' files carry S3's settings, unused on one machine.
- Next: pinning the published digests in
envs/prod, with a Renovate manager forenvs/; and tier 2, which runs the images against Floci, auth-keys' write included.