Geo-redundantes S3 über die Föderation — Garage in zwei Phasen

Ceph RGW war pro Cluster — ein Standort-Ausfall, und der Bucket ist weg. Garage löst das: Phase 1 läuft heute single-cluster auf hydra mit Replication-Factor 3 und übersteht den Verlust eines Workers. Phase 2 macht jeden Cluster zu einer Zone und übersteht den Verlust eines ganzen Standorts — über dieselbe Tailnet-Föderation, gated auf denselben L4-Router.
Table of contents
Stand: zweiphasig. Phase 1 (Single-Cluster auf hydra, drei Knoten) läuft und hat das frühere Ceph RGW bereits abgelöst. Phase 2 (cross-cluster, eine Zone pro Cluster) ist Roadmap — Issue 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:

# garage-config (garage.toml)
metadata_dir = "/var/lib/garage/meta"
data_dir     = "/var/lib/garage/data"
db_engine    = "lmdb"
metadata_auto_snapshot_interval = "6h"

# 3 Knoten, einer pro Worker (zone = hostname). Quorum 2/3 → übersteht einen Worker.
replication_factor = 3
consistency_mode   = "consistent"

rpc_bind_addr   = "[::]:3901"
rpc_public_addr = "__RPC_PUBLIC_ADDR__"
bootstrap_peers = [
  "garage-0.garage-peer.garage.svc.cluster.local:3901",
  "garage-1.garage-peer.garage.svc.cluster.local:3901",
  "garage-2.garage-peer.garage.svc.cluster.local:3901",
]

Ein paar Details, die mir gefallen:

  • Konsistenz, nicht „eventual". Der Lab-Mode ist consistent mit 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 Service garage-peer.
  • Zwei PVCs auf ceph-block: meta (lmdb-Metadaten, schnell) und data (Blocks).

Die Ports verteilen sich klar:

PortRolle
3900S3 API
3901RPC (Knoten-zu-Knoten — der Föderationspfad in Phase 2)
3902S3-Web
3903Admin 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:

task garage:bootstrap     # weist zone = pod-name zu, CAPACITY=100G default
task 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):

task garage:bucket-create NAME=backups
task garage:key-create    NAME=backups-key     # Access Key ID + Secret, einmalig
task garage:grant         KEY=backups-key NAME=backups

aws --endpoint-url https://s3.${TAILNET_DOMAIN} --region garage \
  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 der Verlust eines ganzen Clusters die Objekte verfügbar lässt (Quorum 2/3). Der Knoten-zu-Knoten-RPC (:3901) muss dafür über die Föderation laufen.

flowchart LR subgraph A["hydra · zone A"] GA["garage nodes"] --- GWA["tailnet-gateway (L4)"] end subgraph B["cosmos · zone B"] GWB["tailnet-gateway (L4)"] --- GB["garage nodes"] end GWA <-->|"RPC :3901 über Tailnet, peer tailnet-LB IP"| GWB

Der Transport ist bewusst gateway-routed, nicht über einen Node-Mesh — 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.

Der RPC-Port ist nicht authentifiziert wie eine normale API — er hängt am geteilten 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 offen lä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 reitet auf derselben Föderation. 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.