Von Garage zu SeaweedFS
Der Ausgangspunkt: S3-kompatibler Objektspeicher im Heimcluster
Wer Kubernetes betreibt und Dateien speichern will, Bilder, Dokumente, Backups, kommt früher oder später um einen S3-kompatiblen Objektspeicher nicht herum. MinIO war lange die erste Wahl: einfach, bekannt, überall unterstützt. Doch seit der Lizenzänderung von Apache 2.0 auf AGPL bzw. das kommerzielle Modell für viele Deployments ist MinIO für selbstgehostete Setups unattraktiv geworden.
Die logische Alternative: Garage. Ein Open-Source-S3-Dienst, in Rust geschrieben, für verteilten Betrieb ausgelegt. Ich hatte Garage eine Weile im Einsatz und er funktionierte. Aber er fraß Ressourcen, die in einem Single-Node-Cluster schlicht nicht im Überfluss vorhanden sind.
Das Problem mit Garage
Garage ist auf verteilte Systeme ausgelegt: mehrere Nodes, Replikation, Konsistenzprotokolle. Das ist gut, wenn man wirklich ein verteiltes Setup betreibt. Für einen einzelnen K3s-Node ist es Overhead, der sich spürbar bemerkbar macht. RAM-Verbrauch, Prozesse, Komplexität in der Konfiguration, alles etwas mehr als nötig.
Dazu kommt: die Dokumentation für Kubernetes-Deployments ist dünn, die Helm-Chart-Unterstützung überschaubar, und FluxCD-Integration erfordert einigen manuellen Aufwand.
Die Alternative: SeaweedFS
SeaweedFS ist ein verteiltes Dateisystem, das in Go geschrieben ist und eine vollständige S3-kompatible API mitbringt. Auf dem Papier klingt das ähnlich wie Garage, in der Praxis ist es ein anderes Tier.
Die Architektur besteht aus drei Komponenten:
- Master: Koordiniert Metadaten und Cluster-Zustand
- Volume: Speichert die eigentlichen Daten in internen Volume-Dateien
- Filer: Bietet eine Verzeichnisstruktur und den S3-Endpoint
Das klingt nach mehr Pods, ist es auch. Aber der Ressourcenverbrauch ist trotzdem gering: unter 300 MB RAM für alle drei Komponenten zusammen, bei normalem Betrieb deutlich darunter.
Die Migration
Die Migration lief über ein FluxCD-Setup mit HelmRelease, HelmRepository und Kustomize. Der offizielle Helm-Chart hat einige Eigenheiten, die man kennen muss:
resources als String: Das Chart erwartet die Ressourcen-Limits nicht als YAML-Map, sondern als String-Literal mit |. Ein klassischer Helm-Chart-Stolperstein, der einen unerwarteten Template-Fehler wirft.
Node Affinity für Multi-Node: Standardmäßig setzt das Chart eine Node Affinity, die einen Multi-Node-Cluster voraussetzt. Für Single-Node muss affinity: "" und nodeSelector: {} explizit gesetzt werden.
volumeSizeLimitMB existiert nicht: Der Flag aus der Dokumentation älterer Versionen ist in der aktuellen Version nicht vorhanden. Die Lösung: -max auf einen festen Wert setzen statt 0 (auto), da 0 die Anzahl der Volumes anhand des freien Speichers berechnet – und dabei von 30 GB pro Volume ausgeht, was bei kleinen PVCs schnell zu "no free volumes left" führt.
postRenderers statt Kustomize-Patch: Da Helm-generierte Ressourcen nicht zur Kustomize-Apply-Zeit existieren, musste der StatefulSet-Command-Patch über den postRenderers-Block im HelmRelease erfolgen – nicht über eine separate kustomization.yaml.
Secret-Dateiname: Das Chart mountet das Auth-Secret unter /etc/sw/seaweedfs_s3_config. Der Key im Kubernetes Secret muss exakt so heißen – nicht config.json oder ähnliches.
Access-Management
SeaweedFS bringt ein eigenes Identity-System mit, das direkt in der gemounteten JSON-Config definiert wird. Mehrere Identities mit unterschiedlichen Berechtigungen, bucket-spezifischer Zugriff, anonymer Read-Zugriff für öffentliche Buckets, alles abbildbar.
Ein Hinweis aus der Praxis: Das buckets-Feld in der anonymous-Identity wird in manchen Versionen nicht korrekt ausgewertet. Zuverlässiger ist die Schreibweise mit bucket-spezifischen Actions:
{
"name": "anonymous",
"credentials": [],
"actions": ["Read:public-bucket", "List:public-bucket"]
}
Fazit
SeaweedFS ist kein MinIO-Klon und kein Garage-Ersatz im engeren Sinne, es ist ein eigenständiges System mit eigenen Stärken. Für einen Kubernetes-Cluster mit moderatem Speicherbedarf, S3-Kompatibilität und dem Wunsch nach öffentlichem Dateizugriff (z.B. Bilder in Webseiten) ist es ein solider, ressourcenschonender Kandidat.
Die initiale Konfiguration hat einige Tücken, aber sobald das Setup steht, ist der Betrieb unkompliziert. Und der Helm-Chart lässt sich mit FluxCD und ein paar postRenderers-Patches gut in einen GitOps-Workflow integrieren.
Der vollständige FluxCD-Setup, HelmRelease, HelmRepository, Ingress, Secret-Template und Kustomization:
Helm-Release mit FluxCD installieren:
File: helmrelease.yaml
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: seaweedfs
namespace: seaweedfs
spec:
interval: 1h
chart:
spec:
chart: seaweedfs
version: ">=3.0.0 <4.0.0"
sourceRef:
kind: HelmRepository
name: seaweedfs
namespace: seaweedfs
interval: 24h
install:
remediation:
retries: 3
upgrade:
cleanupOnFail: true
remediation:
strategy: rollback
retries: 3
postRenderers:
- kustomize:
patches:
- target:
kind: StatefulSet
name: seaweedfs-volume
patch: |
- op: replace
path: /spec/template/spec/containers/0/command
value:
- /bin/sh
- -ec
- |
exec /usr/bin/weed \
-logtostderr=true \
-v=1 \
volume \
-port=8080 \
-metricsPort=9327 \
-dir /data1 \
-dir.idx=/idx \
-max 50 \
-ip.bind=0.0.0.0 \
-readMode=proxy \
-minFreeSpacePercent=7 \
"-ip=${POD_NAME}.${SEAWEEDFS_FULLNAME}-volume.seaweedfs" \
-compactionMBps=50 \
"-mserver=${SEAWEEDFS_FULLNAME}-master-0.${SEAWEEDFS_FULLNAME}-master.seaweedfs:9333"
values:
global:
# Replication-Faktor – auf 1 setzen für Single-Node
replicationPlacment: "000"
# StorageClass – anpassen an dein Cluster (z.B. "local-path" für K3s)
storageClass: "local-path"
# Logs
loggingLevel: 1
# ServiceMonitor für kube-prometheus-stack
enableReplication: false
monitoring:
enabled: false # auf true setzen wenn kube-prometheus-stack läuft
master:
enabled: true
replicas: 1 # für HA: 3 (braucht ungerade Anzahl)
port: 9333
grpcPort: 19333
affinity: ""
nodeSelector: {}
# Persistenz für Metadaten
data:
type: "persistentVolumeClaim"
size: "1Gi"
resources: |
requests:
cpu: 50m
memory: 64Mi
limits:
memory: 256Mi
# Garbage Collection: täglich um 4 Uhr
config: |-
[master.maintenance]
scripts = "ec.encode -fullPercent=95 -quietFor=1h"
sleep_minutes = 17
volume:
enabled: true
replicas: 1
port: 8080
grpcPort: 18080
affinity: ""
nodeSelector: {}
maxVolumes: 100
extraArgs: "-volumeSizeLimitMB=1024"
# Tatsächlicher Datenspeicher – hier großzügig sein
data:
type: "persistentVolumeClaim"
size: "50Gi" # anpassen!
idx:
type: "persistentVolumeClaim"
size: "2Gi"
resources: |
requests:
cpu: 50m
memory: 128Mi
limits:
memory: 512Mi
filer:
enabled: true
replicas: 1
port: 8888
grpcPort: 18888
affinity: ""
nodeSelector: {}
# Filer-Metadaten (Verzeichnisstruktur etc.)
data:
type: "persistentVolumeClaim"
size: "2Gi"
resources: |
requests:
cpu: 50m
memory: 64Mi
limits:
memory: 256Mi
# S3 über den Filer aktivieren
s3:
enabled: true
port: 8333
httpsPort: 0
# Auth-Config wird per Secret/ConfigMap gemountet (siehe s3-config.yaml)
enableAuth: true
existingConfigSecret: seaweedfs-s3-config
# Filer-Config: SQLite reicht für kleine Setups, Postgres für Prod
config: |-
[leveldb2]
enabled = true
dir = "/data/filerldb2"
s3:
enabled: false # S3 läuft über den Filer (filer.s3.enabled), nicht als eigener Pod
# Ingress für S3-Endpoint und Filer-UI
# Anpassen an dein Traefik-Setup
ingress:
enabled: false # manuell unten via extra-Manifest
# Monitoring via ServiceMonitor (wenn kube-prometheus-stack aktiv)
serviceMonitor:
enabled: false
FluxCD Kustomization:
File: namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
name: seaweedfs
labels:
environment: production
app: seaweedfs
File: ingress.yaml
---
# S3-Endpoint (für aws-cli, SDKs, etc.)
# endpoint-url: https://s3.example.com
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: seaweedfs-s3
# namespace: seaweedfs
annotations:
# Großes Body-Limit für Uploads
traefik.ingress.kubernetes.io/router.entrypoints: websecure
nginx.ingress.kubernetes.io/proxy-body-size: "0"
cert-manager.io/cluster-issuer: "cf-letsencrypt-prod"
traefik.ingress.kubernetes.io/router.middlewares: "kube-system-redirect-scheme@kubernetescrd"
# Traefik: body-size über Middleware setzen, falls nötig
spec:
ingressClassName: traefik
rules:
- host: s3fs.example.com # <-- anpassen
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: seaweedfs-filer
port:
number: 8333
tls:
- hosts:
- s3fs.example.com # <-- anpassen
secretName: seaweedfs-s3-tls
---
# Filer-UI (optional, eher zum Debuggen)
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: seaweedfs-filer-ui
# namespace: seaweedfs
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: websecure
cert-manager.io/cluster-issuer: "cf-letsencrypt-prod"
traefik.ingress.kubernetes.io/router.middlewares: "kube-system-redirect-scheme@kubernetescrd,seaweedfs-seaweedfs-filer-auth@kubernetescrd"
# UI absichern – z.B. mit Traefik BasicAuth-Middleware
# traefik.ingress.kubernetes.io/router.middlewares: seaweedfs-auth@kubernetescrd
spec:
ingressClassName: traefik
rules:
- host: seaweedfs.example.com # <-- anpassen
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: seaweedfs-filer
port:
number: 8888
tls:
- hosts:
- seaweedfs.example.com # <-- anpassen
secretName: seaweedfs-filer-tls
File: secret.yaml
---
# Dieses Secret enthält die S3-Authentifizierungskonfiguration.
# In Produktion: per SOPS verschlüsseln oder per ExternalSecret aus Vault/ESO holen.
#
# Format: AWS-S3-kompatible identity.json
# Doku: https://github.com/seaweedfs/seaweedfs/wiki/Amazon-S3-API
apiVersion: v1
kind: Secret
metadata:
name: seaweedfs-s3-config
type: Opaque
stringData:
# Dateiname muss exakt "config.json" sein
seaweedfs_s3_config: |
{
"identities": [
{
"name": "admin",
"credentials": [
{
"accessKey": "XXXXXXXXXXXXXXXXXXXX",
"secretKey": "YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY"
}
],
"actions": [
"Admin",
"Read",
"Write",
"List",
"Tagging"
]
},
{
"name": "app-images",
"credentials": [
{
"accessKey": "AAAAAAAAAAAAAAAAAAAA",
"secretKey": "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
}
],
"actions": [
"Read:app-images",
"Write:app-images",
"List:app-images",
"Tagging:app-images"
]
},
{
"name": "anonymous",
"credentials": [],
"actions": [
"Read:app-images",
]
}
]
}
File: middleware.yaml
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: seaweedfs-filer-auth
spec:
basicAuth:
secret: seaweedfs-filer-auth
removeHeader: true
File: http-auth-secret.yaml
apiVersion: v1
kind: Secret
metadata:
name: seaweedfs-filer-auth
type: Opaque
stringData:
# Format: "user:htpasswd-hash" – hier Beispiel mit admin/changeme
# Unbedingt ersetzen! Hash generieren wie oben beschrieben.
# podman run --rm httpd:alpine htpasswd -nbB admin 'DEIN_PASSWORT'
users: "admin:$2y$05$uM7NqmPwxn4egdGuthLu8.n2/Q4GLdbEvAApzebATfMB/Fl3Ms97S"
File: kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- namespace.yaml
- ingress.yaml
- secret.yaml
- middleware.yaml
- http-auth-secret.yaml
Prod Cluster kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../base
patches: []
File: clusters/prod/flux-system/kustomization/seaweedfs.yaml
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: seaweedfs
namespace: flux-system
spec:
interval: 10m
prune: true
path: ./apps/seaweedfs/prod
sourceRef:
kind: GitRepository
name: flux-system
targetNamespace: seaweedfs
decryption:
provider: sops
secretRef:
name: sops-age