6d3a0eb
— und wartet auf eine Voraussetzung, die sich am Ende mit der Headscale-HA-Arbeit
trifft.Objektspeicher ist im Lab unspektakulär, aber überall: Restic-/VolSync-Backup-Targets, Artefakte, kleine statische Sites. Lange lieferte das Ceph RGW — pro Cluster. Genau das ist das Problem: Fällt der Standort aus, ist der Bucket weg. Seit die ClusterMesh-Episode eine Föderation über das Tailnet etabliert hat, wird ein echter Multi-Site-Store möglich. Die Antwort heißt Garage .
Warum nicht einfach Ceph?#
Ceph RGW ist mächtig, aber schwer und im Lab-Setup single-site: Die Pools liegen in einem Ceph-Cluster. Garage ist das Gegenteil — ein einzelnes statisches Binary (FROM scratch-Image, kein Operator), gebaut für Multi-Site-Self-Hosting mit zonen-bewusster Replikation. Das Lab hat das RGW bereits stillgelegt und durch Garage ersetzt; ceph-block und ceph-filesystem bleiben (Garage legt seine eigenen PVCs darauf ab — dazu gleich).
Phase 1: drei Knoten, ein Worker-Verlust#
Was heute läuft, ist ein schlichtes StatefulSet (dxflrs/garage), drei Replicas, per topologySpreadConstraints eine pro hydra-Worker verteilt. Die Konfiguration ist klein genug, um sie ganz zu zeigen:
1# garage-config (garage.toml)
2metadata_dir = "/var/lib/garage/meta"
3data_dir = "/var/lib/garage/data"
4db_engine = "lmdb"
5metadata_auto_snapshot_interval = "6h"
6
7# 3 Knoten, einer pro Worker (zone = hostname). Quorum 2/3 → übersteht einen Worker.
8replication_factor = 3
9consistency_mode = "consistent"
10
11rpc_bind_addr = "[::]:3901"
12rpc_public_addr = "__RPC_PUBLIC_ADDR__"
13bootstrap_peers = [
14 "garage-0.garage-peer.garage.svc.cluster.local:3901",
15 "garage-1.garage-peer.garage.svc.cluster.local:3901",
16 "garage-2.garage-peer.garage.svc.cluster.local:3901",
17]
Ein paar Details, die mir gefallen:
- Konsistenz, nicht „eventual". Der Lab-Mode ist
consistentmit Quorum 2/3 — Read-after-write ist garantiert, solange zwei der drei Knoten stehen. - Stabile Reconnect-Adressen. Pod-IPs wechseln beim Restart; ein initContainer ersetzt
__RPC_PUBLIC_ADDR__durch den stabilen StatefulSet-DNS-Namen des jeweiligen Pods (das Image hat keine Shell). Intra-Cluster-RPC läuft über den headless Servicegarage-peer. - Zwei PVCs auf
ceph-block:meta(lmdb-Metadaten, schnell) unddata(Blocks).
Die Ports verteilen sich klar:
| Port | Rolle |
|---|---|
3900 | S3 API |
3901 | RPC (Knoten-zu-Knoten — der Föderationspfad in Phase 2) |
3902 | S3-Web |
3903 | Admin API + /metrics |
Ein Knoten erzeugt seine Identität erst beim ersten Start — das Layout lässt sich deshalb ohne Operator nicht deklarativ setzen. Bootstrap ist daher bewusst imperativ, ein Task:
1task garage:bootstrap # weist zone = pod-name zu, CAPACITY=100G default
2task garage:status # 3 Knoten "connected", Layout-Version N, factor 3
Benutzen#
Buckets und Keys ebenfalls per Task; S3 hängt im Tailnet an s3.${TAILNET_DOMAIN} (path-style, über shared-gateway-tailnet):
1task garage:bucket-create NAME=backups
2task garage:key-create NAME=backups-key # Access Key ID + Secret, einmalig
3task garage:grant KEY=backups-key NAME=backups
4
5aws --endpoint-url https://s3.${TAILNET_DOMAIN} --region garage \
6 s3 cp ./file.txt s3://backups/file.txt
Damit ist Garage ein vollwertiges S3-Target — z. B. für VolSync/Restic-Backups — und übersteht schon heute den Verlust eines Workers.
Phase 2: ein Knoten pro Cluster wird eine Zone#
Der eigentliche Reiz kommt mit dem zweiten Cluster. Aus „zone = Worker" wird zone = Cluster: Replication-Factor 3 über drei Zonen, sodass die Objekte auch beim Verlust eines ganzen Clusters verfügbar bleiben (Quorum 2/3). Der Knoten-zu-Knoten-RPC (:3901) muss dafür über die Föderation laufen.
Der Transport ist bewusst gateway-routed — konsistent mit der Erkenntnis aus der ClusterMesh-Episode, dass die Föderation nord-süd über die Gateways laufen soll, nicht über ein Knoten-Mesh. Garage-Knoten bekommen tailnet-LB-IPs aus dem /28-Pool, den das tailnet-gateway annonciert; cross-cluster-RPC routet dann: Pod → lokales Gateway → Tailnet → Peer-Gateway → Peer-LB-IP.
Die eine Voraussetzung#
Damit das funktioniert, muss das tailnet-gateway erst zu einem cross-cluster L4-Router werden — also in-cluster-Pod-Traffic an die tailnet-LB-CIDR des Peers weiterleiten. Das ist exakt dasselbe „future L4"-Item, auf das auch das Headscale-Endpoint-Failover
wartet: ein gemeinsamer Gateway-zu-Gateway-Pfad, den mehrere Roadmap-Punkte teilen. Dazu wandert GARAGE_RPC_SECRET von den hydra-Secrets in common-secrets — es muss auf jedem Cluster identisch sein.
rpc_secret. Cross-cluster bedeutet also: dieses Secret ist der Schlüssel zum gesamten Speicher-Cluster über beide Standorte. Es gehört in SOPS/common-secrets und nirgends sonst hin.Die offene Entscheidung#
Zwei Cluster sind nur zwei Zonen — und das hat einen Haken: Für einen sauberen 3-way-Faktor mit Zonen-Redundanz fehlt die dritte Zone als Quorum-Tiebreaker. Also entweder 2-way-Replikation (ohne Tiebreak) als Übergang, oder auf einen dritten Cluster warten. Und: Ist das (heute single-node) cosmos eine echte Replica-Zone — oder zunächst nur ein Witness? Das ist die eine Frage, die das Design noch offenlässt; alles andere ist verdrahtet.
Der Bogen gefällt mir, weil er sich selbst erklärt: Phase 1 übersteht heute den Verlust eines Workers, Phase 2 den Verlust eines ganzen Standorts — und beides setzt auf derselben Föderation auf. Der Blocker ist kein Garage-Problem, sondern derselbe L4-Router, den auch Headscale-HA und die Föderation insgesamt brauchen. Wer den baut, schaltet mehrere Roadmap-Punkte auf einmal frei.
Nachtrag (18. Juni 2026): TLS Ende-zu-Ende, und der Zonen-Fallstrick#
Seit dem Schreiben hat sich an Phase 1 noch einiges getan — zwei Dinge hätte ich beim ersten Entwurf gern schon gewusst.
TLS bis in den Pod. Garage terminiert selbst kein TLS — S3-, Web- und Admin-Endpoint sind by design Klartext, upstream erwartet einen Reverse-Proxy davor. Bisher endete meine Verschlüsselung am Gateway: vom Client bis s3.${TAILNET_DOMAIN} HTTPS, der Hop Gateway→Garage war in-cluster-Klartext. Den habe ich geschlossen. Ein Caddy-Sidecar im StatefulSet (und im garage-webui-Deployment) legt vor jeden Klartext-Port HTTPS mit einem Wildcard-Zertifikat der local-ClusterIssuer-CA (*.garage.svc.cluster.local, Secret garage-cluster-local-tls):
| Endpoint | Klartext | HTTPS (local CA) |
|---|---|---|
| S3 API | :3900 | :3443 |
| S3-Web | :3902 | :3453 |
| Admin | :3903 | :3463 |
Dazu macht eine BackendTLSPolicy den Gateway→Backend-Hop ebenfalls zu TLS, validiert gegen local-ca-bundle. Damit ist der Pfad Ende-zu-Ende verschlüsselt — der einzige verbliebene Klartext-Hop ist localhost innerhalb des Pods, zwischen Caddy und Garage. Die Klartext-Ports bleiben als Fallback offen, für Clients, die sich keine eigene CA unterschieben lassen.
Zwei Details, die das rund machen:
- Der Trust-Anchor liegt überall. trust-manager spiegelt das
local-ca-bundle-ConfigMap (Keyca.crt) in jeden Namespace. Ein Tenant muss sein CA-Bundle also nur dorthin zeigen — kein Secret-Kopieren, kein Zertifikat-Verteilen. - Host-Header unangetastet. Caddys
reverse_proxyreicht den Host-Header unverändert durch — sonst zerbrechen die SigV4-Signaturen der S3-Requests.auto_https offundadmin off: der Sidecar verwaltet keine Zertifikate und macht keine eigene API auf; er läuft non-root mit read-only rootfs und gedroppten Caps, knapp 32–64 Mi.
Der Zonen-Fallstrick. Oben steht „zone = Worker, eine Replica pro Knoten" — genau das hat zuerst nicht gestimmt. Mein Bootstrap-Script las die Zone aus der Address-Spalte von garage status. Unter kubernetes_discovery ist das aber die Pod-IP (10.244.x.y:3901), und ${addr%%.*} machte daraus die Zone 10 — für alle drei Knoten. Ergebnis: eine einzige Bogus-Zone, Zonen-Redundanz dahin, Quorum bedeutungslos. Der Fix ist eine Spalte weiter links: Zone aus der Hostname-Spalte (garage-0/1/2). Seitdem deckt sich die Layout-Topologie wirklich mit dem, was das StatefulSet-Spreading verspricht — eine Lehre fürs Ganze: Ein deklaratives Spreading nützt nichts, wenn der imperative Bootstrap-Schritt es danach wieder einebnet.
Mehr Tasks. Die Bedienung ist runder geworden: garage:bucket-provision macht Bucket + Key + Grant in einem Schritt (Credentials einmalig im Klartext), garage:bucket-info zeigt IDs/Keys/Aliases, und garage:bucket-delete fragt zur Sicherheit nach dem Bucket-Namen, bevor es löscht (FORCE=true überspringt die Rückfrage).
Nachtrag (27. Juni 2026): Öffentliche Endpoints — und warum root_domain alles diktiert#
Phase 1 lief bisher rein im Tailnet. Inzwischen hängt Garage auch am öffentlichen Internet — und der tailnet-interne S3-Name hat sich nebenbei geändert. Zwei Dinge, die zusammengehören, weil beide an derselben Garage-Eigenheit hängen.
Der Tailnet-Name ist jetzt per-Cluster. Mit der aufgeräumten Föderations-Domain
sind die Singleton-Namen (<app>.${TAILNET_DOMAIN}) verschwunden — es gibt keinen bare-zone-Resolver mehr. S3 im Tailnet erreicht man deshalb über den per-Cluster-Namen, an einem per-Cluster-Wildcard-Listener:
1aws --endpoint-url https://s3.${CLUSTER_NAME}.${TAILNET_DOMAIN} --region garage \
2 s3 cp ./file.txt s3://backups/file.txt # path-style, wie gehabt
Zwei öffentliche Zonen. Auf dem shared-gateway-external liegen jetzt zwei Wildcard-Listener mit je einem Let’s-Encrypt-DNS-01-Wildcard-Zertifikat (die einzige ACME-Challenge, die Wildcards ausstellt — Cloudflare-Solver) und einem DNS-only (grey-cloud) Record:
| Zone | Endpoint | Auth | Zweck |
|---|---|---|---|
| Web | <bucket|token>.data.${EXTERNAL_DOMAIN} | anonym (public read) | statische Assets / Sites |
| S3 API | <bucket|token>.s3.${EXTERNAL_DOMAIN} (vhost) bzw. s3.${EXTERNAL_DOMAIN}/<bucket> (path) | SigV4 Key + Grant | programmatischer S3-Zugriff aus dem Internet |
Garage mappt das Subdomain-Label über Name oder globalen Alias auf einen Bucket — dieses Label ist der token. Öffentlich geschaltet wird ein Bucket per Task, unter einem opaken Token statt unter seinem echten Namen (kollisionssicher, und der Bucket-Name leakt nicht):
1task garage:bucket-expose NAME=marketing-assets TOKEN=ax7f209c
2# -> https://ax7f209c.data.${EXTERNAL_DOMAIN}/logo.png (anonym, read-only)
3task garage:bucket-unexpose NAME=marketing-assets TOKEN=ax7f209c # revoke
Der Fallstrick, der das alles erklärt: Garages root_domain ist ein einziger Wert pro Endpoint und steuert das Virtual-Host-Bucket-Mapping. Ich zeige ihn auf die öffentliche Zone ([s3_api] root_domain = .s3.${EXTERNAL_DOMAIN}, [s3_web] root_domain = .data.${EXTERNAL_DOMAIN}). Damit ist eine Sache erzwungen: Alle anderen Endpoints — in-cluster (garage.garage:3900) und tailnet (s3.${CLUSTER_NAME}.${TAILNET_DOMAIN}) — müssen path-style sprechen (s3ForcePathStyle), weil ihr Host nie auf root_domain passt und Garage sie deshalb ohnehin als path-style behandelt. Und: root_domain ändern heißt Garage neu starten — es gibt keinen Config-Reloader (kubectl rollout restart statefulset/garage -n garage).
Die Auth-Modelle trennen sauber, was ein Tenant kontrolliert. Auf der S3 API signiert jeder Request mit einem Access-Key; Garage erzwingt dessen per-Bucket-Grants — der Tenant kontrolliert den Inhalt seines Buckets, der Operator Existenz, Grant und Token-Alias. Auf der Web-Zone gibt es keine per-Request-Auth: Der bucket website --allow-Schalter (Operator) ist das einzige Tor, der Tenant beeinflusst nur, welche Objekte unter <token>.data.… liegen. Genau die „externe Exposition ⇒ Plattform-Freigabe"-Grenze aus EXPOSURE.md
— diesmal über einen globalen Alias, den der Operator vergibt.
