Install

Install csi-s3-archiver

One DaemonSet, no RBAC, no operator. From an empty cluster to a file in a bucket in four steps.

Before you start#

You need a Linux Kubernetes cluster you can install a DaemonSet into, and kubectl. That is the whole list. There is no operator to install first, no CRDs, no cert-manager, and no API-server access for the driver to negotiate.

RequirementDetail
NodesLinux, amd64 or arm64. Windows is a non-goal.
PermissionsEnough to create a namespace, a CSIDriver object and a DaemonSet. The driver itself needs no RBAC.
Kubelet rootThe default /var/lib/kubelet. On a cluster that moved it, patch the host paths in the DaemonSet.
An S3 bucketAWS, Ceph RGW, SeaweedFS: Anything speaking the S3 API. Credentials can also come from IRSA or an instance profile.

1. Install the driver#

install
$ kubectl apply -k deploy/base
$ kubectl -n csi-s3-archiver rollout status daemonset/csi-s3-archiver
$ kubectl get csinode -o jsonpath='{.items[*].spec.drivers[*].name}'
s3archiver.csi.trion.de
Terminal recording: kubectl apply -k deploy/base creates the CSIDriver, namespace, service account and DaemonSet; the rollout finishes across three nodes; kubectl get csinode shows s3archiver.csi.trion.de registered on every node.
Install: One kustomize apply, one DaemonSet, driver registered on every node. 13.7s loop, still image

That applies one CSIDriver object, a namespace with an RBAC-less ServiceAccount, and a DaemonSet running the driver alongside the upstream node-driver-registrar and livenessprobe sidecars (diagram). There are no Roles or bindings anywhere: The driver never talks to the API server, because kubelet pushes pod metadata and per-volume secrets into the CSI calls.

The driver container runs as root but not privileged, with no added capabilities and a read-only root filesystem. Root is needed only to create and remove directories under /var/lib/kubelet/pods, which are root-owned. Because the volume is a plain directory rather than a mount, there is no mount(2) in this driver, and therefore no privileged: true and no bidirectional mount propagation.

1, with Helm instead#

The chart under deploy/helm/csi-s3-archiver installs exactly what the kustomize base installs: The CSIDriver, the ServiceAccount with no RBAC, the DaemonSet and its two sidecars. Nothing on the rest of this page changes if you use it. Pick one of the two, not both, or the second install fights the first over the cluster-scoped CSIDriver object.

install with Helm
$ git clone https://github.com/trion-development/csi-s3-archiver.git
$ helm install csi-s3-archiver csi-s3-archiver/deploy/helm/csi-s3-archiver \
    --namespace csi-s3-archiver --create-namespace \
    --set config.bucket=prod-dumps
NAME: csi-s3-archiver
NAMESPACE: csi-s3-archiver
STATUS: deployed
REVISION: 1
$ kubectl -n csi-s3-archiver rollout status daemonset/csi-s3-archiver
daemon set "csi-s3-archiver" successfully rolled out
Terminal recording: git clone fetches the repository because the chart is not published yet; kubectl creates a namespace and a credentials Secret from environment variables; helm install deploys the chart from deploy/helm/csi-s3-archiver with the bucket and the existing Secret set; the DaemonSet rolls out; helm list shows the release deployed.
Helm: Clone, one Secret, one helm install, driver rolled out. 17.6s loop, still image

The clone is there because the chart is not on a chart repository yet, for the same reason there is no published image: Nothing has been tagged. Once it is, those two commands become a helm repo add and a helm install, and every value below keeps its name.

The values worth knowing on a first install. helm show values deploy/helm/csi-s3-archiver prints all of them with the comments that explain the rest.

ValueDefaultWhat it does
config.bucketemptyThe driver-global bucket. A volume can override it, so this is a default rather than a requirement, but an install with neither archives nothing.
config.endpoint, config.region, config.pathStyleAWS, us-east-1, falsePoint it at Ceph, SeaweedFS or any other S3 API. Most non-AWS stores want pathStyle: true.
config.prefixemptyKey prefix, with the placeholders the reference lists.
credentials.existingSecretemptyA Secret you manage, with S3A_ACCESS_KEY_ID and friends as keys. Leave it empty on EKS and the AWS default chain takes over, which is how IRSA works with no configuration.
credentials.createfalseHave the chart create that Secret from values instead. Convenient, and it puts the key into the release. The callout below is about that trade.
image.repository, image.tagGHCR, chart appVersionPoint at your own registry while there is no published image.
kubeletDir/var/lib/kubeletOnly MicroK8s and k0s move it. k3s and RKE2 do not, despite the folklore.
metrics.enabled, metrics.serviceMonitor.enabledfalseA /metrics endpoint on port 9809, and a ServiceMonitor for the Prometheus Operator.
webui.enabledfalseThe read-only statistics page. It has no authentication, so reach it with a port-forward or put an authenticating ingress in front.
controller.enabledfalseThe optional Controller, for volumes declared with a volumeClaimTemplate rather than an inline csi: block.
resources, tolerations, priorityClassNamemeasured; tolerate everything; system-node-criticalThe defaults come from measurement. The blanket toleration is deliberate: A node the driver cannot schedule onto silently stops archiving for every pod there.
credentials without --set
# From the environment, so no key lands in shell history or in the release secret.
$ kubectl create namespace csi-s3-archiver
$ kubectl -n csi-s3-archiver create secret generic s3-credentials \
    --from-literal=S3A_ACCESS_KEY_ID="$AWS_ACCESS_KEY_ID" \
    --from-literal=S3A_SECRET_ACCESS_KEY="$AWS_SECRET_ACCESS_KEY"
$ helm upgrade --install csi-s3-archiver deploy/helm/csi-s3-archiver \
    --namespace csi-s3-archiver \
    --set config.bucket=prod-dumps \
    --set credentials.existingSecret=s3-credentials

Past two or three flags, a values file is easier to review and easier to put in Git:

values.yaml
# values.yaml, applied with: helm upgrade --install ... -f values.yaml
image:
  repository: ghcr.io/trion-development/csi-s3-archiver
  tag: ""              # defaults to the chart's appVersion

config:
  bucket: prod-dumps
  endpoint: ""         # set for Ceph, SeaweedFS or any non-AWS endpoint
  region: eu-central-1
  pathStyle: false     # most non-AWS stores need true
  prefix: "{namespace}/{podName}/"
  logFormat: text      # or json, for a collector

credentials:
  existingSecret: s3-credentials   # or leave empty for IRSA / instance profiles

metrics:
  enabled: true
  port: 9809           # NOT 9808: the livenessprobe sidecar already listens there
  serviceMonitor:
    enabled: true      # needs the Prometheus Operator

webui:
  enabled: false       # read-only statistics page, no authentication

kubeletDir: /var/lib/kubelet   # MicroK8s and k0s move this

Upgrades are helm upgrade --install ... -f values.yaml, and the DaemonSet rolls one node at a time. One upgrade needs a manual step: CSIDriver.spec.volumeLifecycleModes is immutable and controller.enabled=true adds Persistent to it, so turning the Controller on over an existing install fails with field is immutable. Delete the object and let the upgrade recreate it. No running volume is disturbed, because kubelet reads it at publish time only.

Removing it is helm uninstall, which takes the CSIDriver object with it because the chart created it. A namespace made by --create-namespace is not part of the release and stays. The warning in uninstalling applies here too: Delete the pods that use the driver first.

uninstall
helm uninstall csi-s3-archiver -n csi-s3-archiver
kubectl delete namespace csi-s3-archiver   # not part of the release

2. Give it credentials#

Four sources, resolved per volume, first match wins. Pick whichever fits your cluster; the reference has the full rules.

  1. Nothing at all, if the nodes already have them. With no per-volume secret and no S3A_* keys, resolution falls through to the AWS default provider chain, which is what makes IRSA, EKS Pod Identity and instance profiles work untouched.
  2. Cluster-global, for one bucket shared by everything. Create the secret and reference it from the DaemonSet's environment in a kustomize overlay:
    $ kubectl -n csi-s3-archiver create secret generic s3-credentials \
        --from-literal=accessKeyId=AKIAEXAMPLE \
        --from-literal=secretAccessKey=...
  3. Per volume, when different namespaces use different buckets: A nodePublishSecretRef in the pod spec, read by kubelet from the pod's own namespace.
    per-volume credentials
    apiVersion: v1
    kind: Secret
    metadata:
      name: s3-creds
      namespace: team-a
    stringData:
      accessKeyId: AKIAEXAMPLE
      secretAccessKey: "…"
    ---
    # in the pod spec
    csi:
      driver: s3archiver.csi.trion.de
      volumeAttributes:
        bucket: prod-dumps
      nodePublishSecretRef:
        name: s3-creds
  4. No credentials on the node at all. Set presignEndpoint and a signer service hands out a fresh URL per upload.

3. Archive your first file#

A pod that writes four megabytes into a CSI volume and exits. Everything under /dumps ends up in the bucket.

heapdump-example.yaml
apiVersion: v1
kind: Pod
metadata:
  name: heapdump-example
spec:
  restartPolicy: Never
  containers:
    - name: app
      image: busybox:1.36
      command: ["sh", "-c", "dd if=/dev/urandom of=/dumps/java_pid1.hprof bs=1M count=4; sleep 60"]
      volumeMounts:
        - name: dumps
          mountPath: /dumps
  volumes:
    - name: dumps
      csi:
        driver: s3archiver.csi.trion.de
        volumeAttributes:
          bucket: prod-dumps
          prefix: "heapdumps/{namespace}/{podName}/"
verify
$ kubectl apply -f heapdump-example.yaml
$ aws s3 ls s3://prod-dumps/heapdumps/default/heapdump-example/
2026-07-27 09:16:04    4194304 java_pid1.hprof
Terminal recording: a pod writes a 4 MiB heapdump into its CSI volume; the driver log shows the file going quiescent and being uploaded; aws s3 ls lists the finished object under heapdumps/default/heapdump-example/ in the bucket.
Archive: Write a file, watch it appear in the bucket. 14.4s loop, still image

The driver's own log tells the same story, one structured line per event, on stderr, which is what kubectl logs shows:

kubectl logs -n csi-s3-archiver ds/csi-s3-archiver
2026-07-27T09:15:30.402Z INFO  [csi-rpc-3] NodeService - rpc=NodePublishVolume volumeId=csi-9f3a bucket=prod-dumps status=OK duration_ms=6
2026-07-27T09:16:01.884Z INFO  [archiver-1] Archiver - event=quiescent file=java_pid1.hprof bytes=4194304
2026-07-27T09:16:04.117Z INFO  [upload-2] Uploader - event=upload key=heapdumps/default/heapdump-example/java_pid1.hprof bytes=4194304 attempt=1 status=OK duration_ms=2231
2026-07-27T09:17:12.006Z INFO  [csi-rpc-5] NodeService - rpc=NodeUnpublishVolume volumeId=csi-9f3a event=sweep uploaded=0 status=OK duration_ms=41

4. Check it before you rely on it#

Three things worth confirming on a new install, in the order they tend to go wrong:

CheckCommandExpected
Driver registeredkubectl get csinode -o jsonpath='{.items[*].spec.drivers[*].name}'s3archiver.csi.trion.de, once per node
Pods healthykubectl -n csi-s3-archiver get pods3/3 Running on every node
Uploads workingcheck-upload in a pod using the driver image and the same environmentIt exercises the real upload path and reports exactly what S3 answered

If a pod using the driver hangs in ContainerCreating, the cause is almost always a rejected attribute, and the driver says so on the pod's own events:

Terminal recording: a pod with a misspelled prefix placeholder stays in ContainerCreating, and kubectl describe pod shows the driver returning INVALID_ARGUMENT with a message naming the bad placeholder and listing the accepted ones.
Misconfiguration: The driver's own message, on the pod's own events. 10.6s loop, still image

More symptoms and what to do about them are in the troubleshooting table.

Distributions that need more than one apply#

The four steps above assume a cluster whose kubelet keeps its state in /var/lib/kubelet and whose admission controller lets a DaemonSet mount it. Most clusters are that cluster. The ones that are not fail in one of three ways. They moved the directory, they enforce a policy that forbids the mount, or they are not a cluster with nodes you can reach at all.

DistributionWhat is differentWhat it costs you
k3s, RKE2, kind, minikube, Docker Desktop, Rancher DesktopNothing. Kubelet's root really is /var/lib/kubeletInstall exactly as above
vclusterThe driver belongs to the host cluster, and the pod identity it sees is the host'sNothing to install, but the archive layout changes
Talos LinuxPod Security Admission enforces baseline everywhere but kube-system, and baseline forbids hostPathOne namespace label
OpenShiftA SecurityContextConstraint has to allow hostPath volumes and uid 0One oc adm policy
MicroK8sKubelet's root directory is under the snap treeOne kubeletDir
k0sKubelet's root directory is /var/lib/k0s/kubeletOne kubeletDir, or one worker flag
GKE Autopilot and EKS FargateNo writable hostPath, and on Fargate no DaemonSets at allIt cannot be installed

Each has a page of its own, because the answer is a paragraph in some cases and a change of plan in others:

  • vcluster. Install once on the host cluster. Volumes work from inside untouched, but every prefix placeholder resolves to the synced pod's host identity.
  • Talos Linux. The kustomize base already labels its namespace; Helm does not. No kubelet extra mounts are needed.
  • OpenShift. hostmount-anyuid rather than privileged. The driver mounts host directories and runs as root, but it is not a privileged container.
  • MicroK8s. Everything works once the three host paths point at /var/snap/microk8s/common/var/lib/kubelet.
  • k0s. Point the driver at the k0s path, or point k0s back at the standard one and never think about it again.
  • GKE Autopilot and EKS Fargate. Both rule the driver out by design. What to do instead, and why a mixed cluster is still fine.

Building from source#

Requires JDK 25 or newer and Maven 3.9 or newer. The native build needs only Docker on the host and produces the host architecture, because GraalVM cannot cross-compile; multi-arch images are stitched from per-architecture runners.

build
$ mvn package                 # unit tests + target/csi-s3-archiver.jar
$ mvn verify -Pit             # + SeaweedFS integration tests (needs Docker)
$ ./build-native.sh           # -> target/csi-s3-archiver, statically linked
$ ./build-image.sh            # -> ghcr.io/trion-development/csi-s3-archiver:dev

The native build tries a fully static musl link first, which allows a FROM scratch image, and falls back to a mostly-static glibc link automatically. It also re-records the GraalVM reachability metadata by running the csi-sanity suite under the tracing agent; commit the result when it changes.

Uninstalling#

$ kubectl delete -k deploy/base

Or helm uninstall csi-s3-archiver -n csi-s3-archiver for a chart install.

Delete the pods that use the driver first, or their final sweep has nowhere to run and they will sit in Terminating until the CSI calls time out. Objects already in the bucket are untouched: The driver never deletes what it uploaded.

Where next#

  • Configuration reference: Every attribute, the prefix placeholders, and the semantics worth knowing before you rely on them.
  • Append-only and spool modes: Logs that never go quiet, and bounded node disk.
  • FAQ: What happens when S3 is down, and why pod deletion blocks.
  • Comparison: The cases where a different tool is the right answer.