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