Replace MinIO with SeaweedFS on K8S S3 storage

MinIO to SeaweedFS
MinIO to SeaweedFS

Introduction

SeaweedFS is a distributed object store and file system that can expose an S3-compatible API. It is useful when you want to replace MinIO with lighter self-hosted object storage for applications such as photo backups, static hosting, or internal services that expect S3.

The reason to move away from MinIO is simple: the public minio/minio repository is archived and read-only, so I treat it as no longer maintained for new self-hosted deployments. SeaweedFS gives me an S3-compatible replacement that still fits well into Kubernetes and GitOps.

In this guide, we will deploy SeaweedFS on Kubernetes with the SeaweedFS operator, Longhorn-backed persistent volumes, cert-manager TLS, an external S3 endpoint, buckets, scoped S3 credentials, and a weekly maintenance job.

The SeaweedFS Kubernetes setup below is designed for a small/medium production cluster where applications need a private S3-compatible storage service.

The setup uses the SeaweedFS custom resources directly instead of a large values file. That makes the moving pieces explicit: Seaweed, Bucket, S3Identity, S3Credentials, S3Policy, S3PolicyBinding, and AdminScript.

Prerequisites

  1. A running Kubernetes cluster.
  2. kubectl access to the cluster.
  3. Kustomize support through kubectl apply -k or Flux.
  4. cert-manager with a ClusterIssuer named letsencrypt.
  5. An Ingress controller. The example uses cilium.
  6. A storage class for SeaweedFS data volumes. The example uses longhorn-single.
  7. A ReadWriteMany storage class for the master metadata PVC. The example uses longhorn-rwx.
  8. DNS pointing s3.example.com to your Ingress endpoint.
  9. Sealed Secrets and kubeseal if you want to seal database credentials for an external filer metadata store.

Step-by-step

  1. Create a working directory:

    bash
    mkdir -p seaweed
    cd seaweed
    
  2. Install the SeaweedFS operator. The Seaweed and S3 custom resources used later are reconciled by this operator:

    yaml
    ---
    apiVersion: v1
    kind: Namespace
    metadata:
      name: seaweedfs-operator
    ---
    apiVersion: source.toolkit.fluxcd.io/v1
    kind: HelmRepository
    metadata:
      name: seaweedfs-operator
      namespace: seaweedfs-operator
    spec:
      interval: 1h
      url: https://seaweedfs.github.io/seaweedfs-operator/
    ---
    apiVersion: helm.toolkit.fluxcd.io/v2
    kind: HelmRelease
    metadata:
      name: seaweedfs-operator
      namespace: seaweedfs-operator
    spec:
      interval: 1h
      chart:
        spec:
          chart: seaweedfs-operator
          version: '0.1.36'
          sourceRef:
            kind: HelmRepository
            name: seaweedfs-operator
            namespace: seaweedfs-operator
    

    If you are not using Flux, install the operator with Helm instead:

    bash
    helm repo add seaweedfs-operator https://seaweedfs.github.io/seaweedfs-operator/
    helm repo update
    helm upgrade --install seaweedfs-operator seaweedfs-operator/seaweedfs-operator \
      --namespace seaweedfs-operator \
      --create-namespace \
      --version 0.1.36
    
  3. Create namespace.yaml:

    yaml
    apiVersion: v1
    kind: Namespace
    metadata:
      name: seaweed
    
  4. Create cert.yaml. This certificate is used by the S3 Ingress:

    yaml
    ---
    apiVersion: cert-manager.io/v1
    kind: Certificate
    metadata:
      name: seaweed
      namespace: seaweed
    spec:
      secretName: seaweed-tls
      issuerRef:
        name: letsencrypt
        kind: ClusterIssuer
      dnsNames:
        - s3.example.com
    
  5. Create seaweed.yaml. This manifest defines the SeaweedFS cluster itself:

    yaml
    ---
    apiVersion: seaweed.seaweedfs.com/v1
    kind: Seaweed
    metadata:
      name: seaweedfs
      namespace: seaweed
    spec:
      image: chrislusf/seaweedfs:4.40
      volumeServerDiskCount: 1
    
      master:
        replicas: 3
        affinity:
          podAntiAffinity:
            requiredDuringSchedulingIgnoredDuringExecution:
              - labelSelector:
                  matchExpressions:
                    - key: app.kubernetes.io/component
                      operator: In
                      values:
                        - master
                    - key: app.kubernetes.io/instance
                      operator: In
                      values:
                        - seaweedfs
                topologyKey: kubernetes.io/hostname
        volumes:
          - name: master-data
            persistentVolumeClaim:
              claimName: seaweedfs-master-data
        volumeMounts:
          - name: master-data
            mountPath: /data
        extraArgs:
          - -mdir=/data/$POD_NAME
        volumeSizeLimitMB: 1024
    
      volume:
        storageClassName: longhorn-single
        replicas: 5
        affinity:
          podAntiAffinity:
            requiredDuringSchedulingIgnoredDuringExecution:
              - labelSelector:
                  matchExpressions:
                    - key: app.kubernetes.io/component
                      operator: In
                      values:
                        - volume
                    - key: app.kubernetes.io/instance
                      operator: In
                      values:
                        - seaweedfs
                topologyKey: kubernetes.io/hostname
        requests:
          storage: 750Gi
    
      filer:
        replicas: 1
        persistence:
          enabled: true
          storageClassName: longhorn
          resources:
            requests:
              storage: 5Gi
        config: |
          [filer.options]
          defaultReplicaPlacement = "002"
    
          [leveldb2]
          enabled = true
          dir = "/data/filerldb2"
    
      s3:
        replicas: 1
        ingress:
          enabled: true
          className: cilium
          host: s3.example.com
          tls:
            - hosts:
                - s3.example.com
              secretName: seaweed-tls
    
      admin: {}
    
      worker:
        replicas: 2
        jobType: "all"
        maxExecute: 4
        persistence:
          enabled: true
          resources:
            requests:
              storage: 1Gi
    ---
    apiVersion: v1
    kind: PersistentVolumeClaim
    metadata:
      name: seaweedfs-master-data
      namespace: seaweed
    spec:
      accessModes:
        - ReadWriteMany
      storageClassName: longhorn-rwx
      resources:
        requests:
          storage: 30Gi
    

    The important values are:

    1. master.replicas: 3 gives the cluster a quorum-based control plane.
    2. volume.replicas: 5 creates five volume-server pods.
    3. volume.requests.storage: 750Gi is the size of each volume server PVC.
    4. defaultReplicaPlacement = "002" makes new writes use three copies.
    5. s3.ingress.host exposes the S3 API outside the cluster.

    This example uses leveldb2 for filer metadata. That is simple, but it is local to the filer pod. For a highly available filer metadata store, use PostgreSQL, MySQL, Redis, or another external metadata backend supported by SeaweedFS.

  6. If you want to use MySQL or Galera for filer metadata, create a sealed database secret first:

    bash
    export MYSQL_PASSWORD=FROM_DATABASE_SECRET
    kubectl -n seaweed create secret generic seaweedfs-db-secret \
      --from-literal=user=seaweed \
      --from-literal=password=$MYSQL_PASSWORD \
      --dry-run=client -o yaml | kubeseal --format yaml > seaweedfs-db-secret.yaml
    

    Then update the filer config in seaweed.yaml to use your external metadata store instead of leveldb2.

  7. Create buckets.yaml. This creates a few buckets:

    yaml
    ---
    apiVersion: seaweed.seaweedfs.com/v1
    kind: Bucket
    metadata:
      name: ente
      namespace: seaweed
    spec:
      clusterRef:
        name: seaweedfs
        namespace: seaweed
    ---
    apiVersion: seaweed.seaweedfs.com/v1
    kind: Bucket
    metadata:
      name: media
      namespace: seaweed
    spec:
      clusterRef:
        name: seaweedfs
        namespace: seaweed    
    
  8. Create an admin S3 identity in seaweed.creds.yaml. This identity can access every bucket:

    yaml
    ---
    apiVersion: seaweed.seaweedfs.com/v1
    kind: S3Identity
    metadata:
      name: seaweed
      namespace: seaweed
    spec:
      seaweedRef:
        name: seaweedfs
    ---
    apiVersion: seaweed.seaweedfs.com/v1
    kind: S3Credentials
    metadata:
      name: seaweed-creds
      namespace: seaweed
    spec:
      seaweedRef:
        name: seaweedfs
      identityRef:
        name: seaweed
      secretRef:
        name: seaweed-s3-secret
    ---
    apiVersion: seaweed.seaweedfs.com/v1
    kind: S3Policy
    metadata:
      name: rw-all
      namespace: seaweed
    spec:
      seaweedRef:
        name: seaweedfs
      statements:
        - effect: Allow
          actions:
            - "*"
          resources:
            - "*"
    ---
    apiVersion: seaweed.seaweedfs.com/v1
    kind: S3PolicyBinding
    metadata:
      name: seaweed-binding
      namespace: seaweed
    spec:
      seaweedRef:
        name: seaweedfs
      policyRef:
        name: rw-all
      subjects:
        - kind: S3Identity
          name: seaweed
    
  9. Create an application-scoped identity in app.creds.yaml. This identity can only access the ente bucket:

    yaml
    ---
    apiVersion: seaweed.seaweedfs.com/v1
    kind: S3Identity
    metadata:
      name: ente
      namespace: seaweed
    spec:
      seaweedRef:
        name: seaweedfs
    ---
    apiVersion: seaweed.seaweedfs.com/v1
    kind: S3Credentials
    metadata:
      name: ente-creds
      namespace: seaweed
    spec:
      seaweedRef:
        name: seaweedfs
      identityRef:
        name: ente
      secretRef:
        name: ente-s3-secret
    ---
    apiVersion: seaweed.seaweedfs.com/v1
    kind: S3Policy
    metadata:
      name: rw-ente
      namespace: seaweed
    spec:
      seaweedRef:
        name: seaweedfs
      statements:
        - effect: Allow
          actions:
            - "*"
          resources:
            - ente
            - ente/*
    ---
    apiVersion: seaweed.seaweedfs.com/v1
    kind: S3PolicyBinding
    metadata:
      name: ente-binding
      namespace: seaweed
    spec:
      seaweedRef:
        name: seaweedfs
      policyRef:
        name: rw-ente
      subjects:
        - kind: S3Identity
          name: ente
    

    The operator writes the generated access key and secret key into ente-s3-secret. Use that Secret when configuring applications such as Ente Photos.

  10. Create admin.yaml. This runs a weekly balance and replication repair through weed shell:

    yaml
    apiVersion: seaweed.seaweedfs.com/v1
    kind: AdminScript
    metadata:
      name: weekly-balance
      namespace: seaweed
    spec:
      clusterRef:
        name: seaweedfs
      schedule: "0 2 * * 1"
      script: |
        lock
        volume.balance -force
        volume.fix.replication
        unlock
    
  11. Create kustomization.yaml:

    yaml
    apiVersion: kustomize.config.k8s.io/v1beta1
    kind: Kustomization
    resources:
      - admin.yaml
      - buckets.yaml
      - cert.yaml
      - namespace.yaml
      - seaweed.yaml
      - seaweed.creds.yaml
      - app.creds.yaml
    
  12. Deploy SeaweedFS:

    bash
    kubectl apply -k .
    
  13. Verify the operator reconciled the cluster:

    bash
    kubectl -n seaweed get seaweed
    kubectl -n seaweed get pods
    kubectl -n seaweed get pvc
    kubectl -n seaweed get ingress
    kubectl -n seaweed get buckets
    kubectl -n seaweed get s3identities,s3credentials,s3policies,s3policybindings
    

    Check generated S3 credentials:

    bash
    kubectl -n seaweed get secret seaweed-s3-secret -o yaml
    kubectl -n seaweed get secret ente-s3-secret -o yaml
    
  14. Test the S3 endpoint with an S3 client. Export the generated keys from the Secret, then point the client at your S3 host:

    bash
    export AWS_ACCESS_KEY_ID=REPLACE_WITH_ACCESS_KEY
    export AWS_SECRET_ACCESS_KEY=REPLACE_WITH_SECRET_KEY
    export AWS_EC2_METADATA_DISABLED=true
    
    aws --endpoint-url https://s3.example.com s3 ls
    aws --endpoint-url https://s3.example.com s3 cp ./test.txt s3://ente/test.txt
    aws --endpoint-url https://s3.example.com s3 rm s3://ente/test.txt
    

Conclusion

You now have SeaweedFS running on Kubernetes with persistent Longhorn storage, a public S3 endpoint, cert-manager TLS, managed buckets, scoped S3 credentials, lifecycle rules, and a scheduled maintenance script.

Before storing important data, test uploads and downloads, check replication with volume.fix.replication, and make sure your backup plan covers both the object data and the filer metadata backend.

If you found this useful, you can buy me a coffee! Thanks for the support!