ADR 0169 — The local overlay's S3 destination stops being MinIO and becomes SeaweedFS
Status
Accepted. Decided on 2026-09-27 (AT-200, issue #633), by measurement, under
the maintainer's delegation ("you measure and choose"). It references
ADR 0027 (the S3 backup and the swap of
mc for aws-cli) and ADR 0159
(third-party image by index digest). It edits neither.
Context
The Kubernetes local overlay brings up an S3 server inside the brabo namespace
so that the backup exercised locally is the SAME as production's: the script
always talks S3 and only the endpoint changes. That server was MinIO, pinned
since ADR 0159 at
quay.io/minio/minio@sha256:a1ea29fa… # RELEASE.2025-04-22T22-12-26Z.
MinIO stopped publishing the community image. Measured on 2026-09-27, without authentication:
| reference | response |
|---|---|
quay.io/v2/minio/minio/manifests/sha256:a1ea29fa… | 401 |
quay.io/v2/minio/minio/tags/list | 401 |
registry-1.docker.io/v2/minio/minio/manifests/RELEASE.2025-04-22T22-12-26Z | 401 UNAUTHORIZED (repository nonexistent for anonymous) |
hub.docker.com/v2/repositories/minio/minio/tags | 404 object not found |
The consequence was total, not partial: deploy/k8s/bootstrap.sh waits for
rollout status deployment/minio BEFORE seeding and creating the bucket, so
propriedades.yml died in the bootstrap and none of the property proofs ran
— not even those that do not touch backup (rollout-test, hpa-test,
test-reprojecao-k8s).
The digest fulfilled what ADR 0159 promised — nobody swapped the bytes underneath — and that was not enough: a digest guarantees immutability, not availability. A pin survives the tag's owner moving the tag; it does not survive the registry's owner deleting the repository.
What depends on that S3 (mapped)
- Server:
deploy/k8s/overlays/local/minio/{minio,netpol}.yaml, listed in the local overlay'skustomization.yaml. Only the local overlay; staging and prod point to an external bucket. - Client: it is NOT a MinIO image.
mcleft in Phase 5 (ADR 0027, decision 1b); backup, restore and bucket creation use the apkaws-cliinsidebrabo-backup(docker/backup/Dockerfile.prod), through the adapterdocker/backup/lib.sh. The S3 operations it does are seven:s3 ls,s3 cp -(stdin, multipart),s3 cp(download),s3api head-object,s3 cpbucket→bucket (server-side copy),s3api list-objects-v2ands3 rm— plus the bootstrap'ss3 mb. - Credentials:
BACKUP_S3_ACCESS_KEY/BACKUP_S3_SECRET_KEYof thebraboSecret, materialized by ESO intobrabo-secrets. The server reads the SAME pair. - Bootstrap: the
BACKUP_S3_ENDPOINT, therollout status, the wait for theEndpointsand the ephemeral pod that creates the bucket. - Network: two NetworkPolicies in the overlay (ingress into the server, egress of backup/restore), matching the POD's port.
make test-restore(deploy/k8s/test-restore.sh) and the backup CronJob only see the endpoint. The compose (docker/backup/test-restore-compose.sh) uses disk, not S3, and is not affected.
Decision
The server becomes SeaweedFS, docker.io/chrislusf/seaweedfs 4.47, pinned by
the INDEX digest
(sha256:ce9e796f1fe6f06968f4c04bdaf8f678dad9c8acdfef3d244133d71bfa6bf882),
running server -s3 -s3.port=9000. The resources are now called s3-local
(Deployment, Service, PVC s3-local-data, label app.kubernetes.io/name) and the
folder, overlays/local/s3/.
The candidates, by the criteria in the order the maintainer gave
| candidate | 1. official, anonymous, amd64+arm64 index | 2. real backup flow | 3. smallest change | 4. license | outcome |
|---|---|---|---|---|---|
SeaweedFS 4.47 (chrislusf/seaweedfs) | yes — the author's Docker Hub (the README of seaweedfs/seaweedfs points to it; OCI labels with source/revision), anonymous 200, OCI index with amd64, arm64, arm/v7, 386; the tags have a published cosign signature (.sig) | yes, measured (below) | the server changes; client, script, Secret and port stay | Apache-2.0 | chosen |
Garage v2.1.0 (dxflrs/garage) | yes — anonymous 200, index with amd64, arm64, arm, 386 | not measured | requires a configuration file (ConfigMap with rpc_secret) and, AFTER boot, garage layout assign + layout apply and garage key import <key-id> <secret> — a new administration step in the bootstrap. Garage documents its own key-id format (GK…), which would force changing BACKUP_S3_ACCESS_KEY=brabo-backup (not measured here) | AGPL-3.0 (no problem for unmodified use in a test overlay, but heavier than the first) | discarded at criterion 3 |
MinIO from another source: bitnami/minio | no — anonymous 404 (Bitnami also ended its free catalog) | — | — | — | discarded at criterion 1 |
MinIO from another source: bitnamilegacy/minio | no as a live publication — FROZEN repository, last update 2025-08-19; it is a mirror without patches and without an active publisher | — | — | — | discarded at criterion 1 |
MinIO from another source: cgr.dev/chainguard/minio | partial — amd64+arm64 index, anonymous, but only :latest is free: there is no stable tag for the pin's comment, and the publisher is whoever recompiles the source, not MinIO | — | — | AGPL-3.0 | discarded at criterion 1 (no fixed tag; unofficial publisher) |
original MinIO (quay.io/docker.io) | no — 401/404 (Context) | — | — | — | it is the problem |
"Keep the first one that meets everything": SeaweedFS met all four, so Garage was not taken to the real flow — the measurement stopped where the rule says to stop, and the column says so instead of filling it.
The measurement of criterion 2
The brabo-backup built from this checkout (aws-cli/2.15.57, Alpine 3.20) ran
the real adapter (docker/backup/lib.sh, destino_preparar and the
destino_* functions) against weed server -s3 in a container as uid 1000, with
the credential pair in the environment and /data limited to 4 GiB (the PVC's
size):
- wrong secret →
aws s3 lsrefused (authentication holds); - the bootstrap's
s3 mb→ created the bucket; destino_enviarof 30 MiB through stdin (multipart) →head-objectreturned 31457280, the download matched the sha256;destino_tamanhoof a nonexistent object →0;destino_copiar(server-side copy),destino_listar(3 keys, empty prefix without output) anddestino_remover→ correct;/healthzon the S3 port → 200 in ~1 s, without authentication (it is what the probes use); memory ~470 MiB after the upload, within the 1 GiB limit.
And in the cluster, twice:
- CI,
propriedades.ymlbyworkflow_dispatchon the branch, run36362479629: bootstrap,smoke-k8s,hpa-test,rollout-test,test-restore,test-restore-mutacaoandtest-reprojecao-k8s— allsuccess, with no proof altered. - Local, k3d on this machine:
deploy/k8s/bootstrap.shexited with 0 (therollout status deployment/s3-localand the bucket creation passed), andmake test-restoreuploaded the 183 KB dump tos3-local…/brabo-backups/daily/…, downloaded it and restored 55 identical tables (exit 0);make test-restore-mutacaofailed the break namingzz_mutacao_restore(exit 0). A detail UNRELATED to S3, recorded so it is not confused with it: in the first local run the seed lost the session's activation in a race with the engine (ECONNREFUSED …:4000), the dump came out without anysession_eventand the proof failed because of it, as it should; with three events written through the internal route the proof's own message indicates, it passed. In CI the seed activated the session on the first try.
Why server -s3 and not mini
The image is born with mini, which adds WebDAV, an Admin UI and an Iceberg
catalog. server -s3 brings up master, volume, filer and S3 gateway — the
minimum that answers S3. The rest would be surface and memory without a consumer.
Why port 9000
The NetworkPolicy matches the POD's port, not the Service's. With
-s3.port=9000 the gateway listens where MinIO listened, and the two policies
and the BACKUP_S3_ENDPOINT only change NAME. Master (9333), volume (8080) and
filer (8888) listen inside the pod and remain denied by the baseline.
Why the name is neutral
Calling a Service that runs SeaweedFS minio would save three bootstrap lines
and would teach wrongly whoever opened the cluster. What the backup consumes is
the PROTOCOL; naming it after the product behind it is what would make the next
swap repeat this one.
Consequences
- The bootstrap gets past S3 again, and the property proofs run again (run
36362479629, all six green). - The client did not change. No line of
docker/backup/was touched, and what the proofs measure (test-restore,test-restore-mutacao) is the same. - Declared price:
readOnlyRootFilesystemremains false, as it was with MinIO —weedwrites the filer's socket to/tmp. A local test component, with no equivalent in production. - Declared price: SeaweedFS is not MinIO. S3 compatibility is a per-operation
contract, not a seal. The eight the product uses were measured; a NEW operation
in
lib.sh(lifecycle, versioning, object lock) needs to be measured against it before it enters, because it is here that it would be exercised first. Production keeps talking to the real bucket, which does not change. - The pin by digest does not protect against the publisher disappearing — not
even this one. The signal, if it happens again, is the same: the bootstrap dies
at the S3
rollout statuswithImagePullBackOff. The lesson went tocadeia-de-suprimentos-do-ci.md. - The count of third-party images pinned by digest does not change (one leaves, one enters): 39.
- No RN is born: it is local validation infrastructure, with no business rule.