Tusd
tusdhandles resumable/chunked file uploads (the tus protocol) and streams them directly to SeaweedFS — no local disk writes. On completion, it notifiescoreapivia a webhook. Assumes SeaweedFS and theplaytellybucket already exist.
Overview
- 2 replicas, autoscaling 2–6 via HPA on CPU (70%) / memory (80%)
- Streams uploads straight to the internal SeaweedFS S3 endpoint, bucket
playtelly - Calls
coreapi's webhook on upload completion (post-finishhook) - Requires sticky sessions — tusd's default locker tracks upload locks in-memory per pod
Architecture
Client (browser)
↓ resumable upload (tus protocol)
tusd Service (sessionAffinity: ClientIP, 3hr timeout)
↓
tusd pods (2–6, HPA)
↓ streams directly to S3, no local writes
SeaweedFS S3 gateway (seaweedfs-s3.seaweedfs.svc.cluster.local:8333) → playtelly bucket
↓ on completion (post-finish hook)
coreapi-service.production.svc.cluster.local:3000/hooks/tusd
Prerequisites
- SeaweedFS running with the
playtellybucket already created (see SeaweedFS) coreapireachable atcoreapi-service.production.svc.cluster.local:3000, with/hooks/tusdimplementedmetrics-serverrunning in-cluster — required for the HPA to read CPU/memory metrics; without it the HPA silently can't scale
Files to apply
| File | Purpose |
|---|---|
01-configmap.yaml |
tusd-config — non-secret env vars |
02-secret.yaml |
tusd-s3-credentials — S3 access key/secret |
03-deployment.yaml |
Main Deployment — 2 replicas, anti-affinity, rolling update |
04-service.yaml |
ClusterIP Service with sticky sessions |
05-pdb.yaml |
PodDisruptionBudget — minAvailable: 1 |
06-hpa.yaml |
HorizontalPodAutoscaler — 2–6 replicas |
02-secret.yaml (example — replace the values):
apiVersion: v1
kind: Secret
metadata:
name: tusd-s3-credentials
namespace: production
type: Opaque
stringData:
AWS_ACCESS_KEY_ID: "admin" # override with your actual key
AWS_SECRET_ACCESS_KEY: "password" # override with your actual secret
⚠️ Must configure before applying
| What | Where | Why it matters |
|---|---|---|
| Image tag | 03-deployment.yaml → image: |
Pin an exact version (e.g. v2.4.0) — a floating tag like v2 means the running version can silently change on the next pod restart |
-s3-bucket |
03-deployment.yaml → args |
Must match the real SeaweedFS bucket name (playtelly) |
-s3-endpoint |
03-deployment.yaml → args |
Must point at the internal SeaweedFS S3 service, e.g. http://seaweedfs-s3.seaweedfs.svc.cluster.local:8333 |
-hooks-http |
03-deployment.yaml → args |
Must point at the real coreapi service/endpoint that handles /hooks/tusd |
AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY |
02-secret.yaml |
Real SeaweedFS identity credentials — not the placeholder admin/password shown above |
-cors-allow-origin=.* |
03-deployment.yaml → args |
Wildcard is fine to get running, but lock this down to the real frontend domain before this is truly production-ready |
tusd-config / tusd-s3-credentials names |
01-configmap.yaml / 02-secret.yaml |
The Deployment's envFrom references these by exact name with no -production suffix — confirm they exist under those exact names in the production namespace before applying |
⚠️ Do not change these — required exactly as documented
| Setting | Why it can't be adjusted casually |
|---|---|
runAsUser: 1000 / runAsGroup: 1000 in securityContext |
The tusproject/tusd image creates its non-root user by name (tusd), not a numeric UID Kubernetes can resolve at admission time. Without an explicit numeric UID, kubelet can't verify non-root and refuses to start the container (CreateContainerConfigError) |
sessionAffinity: ClientIP on the Service |
tusd's default locker tracks upload locks in-memory, per pod. Without sticky sessions, a client's PATCH sequence for one upload could hit different pods and corrupt it |
rollingUpdate.maxUnavailable: 0 / maxSurge: 1 |
Zero-downtime deploys — dropping this can briefly take capacity below what's needed mid-rollout |
PodDisruptionBudget minAvailable: 1 |
Protects against a node drain or rolling update taking down all replicas simultaneously |
| HPA's slow scale-down (5 min stabilization window) | Reduces (but doesn't eliminate — see caveat below) the chance of killing a pod mid-upload during scale-down |
readOnlyRootFilesystem: true |
Only safe because tusd streams uploads directly to S3 with no local writes. If tusd's behavior ever changes (temp files, hook side effects writing locally), this will need revisiting — it hasn't been stress-tested against that |
⚠️ Known tension — HPA scale-down vs. sticky sessions
Sticky sessions and autoscaling pull in opposite directions: if a pod handling an in-progress upload gets removed during scale-down, that upload fails regardless of how PDB/stabilization windows are tuned — those reduce the chance, not eliminate it. If large uploads become common, a distributed locker (e.g. etcd-backed) instead of tusd's in-memory default would remove this tension entirely, at the cost of extra infrastructure.
Deploy
kubectl apply -f tusd/prod/01-configmap.yaml
kubectl apply -f tusd/prod/02-secret.yaml
kubectl apply -f tusd/prod/03-deployment.yaml
kubectl apply -f tusd/prod/04-service.yaml
kubectl apply -f tusd/prod/05-pdb.yaml
kubectl apply -f tusd/prod/06-hpa.yaml
kubectl rollout status deployment/tusd -n production
kubectl get pods -n production -l app=tusd
Verify
# Confirm which bucket/endpoint the running Deployment is actually using
kubectl get deployment tusd -n production -o jsonpath='{.spec.template.spec.containers[0].args}'
kubectl get hpa -n production
kubectl get pdb -n production
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
CreateContainerConfigError: "container has runAsNonRoot and image has non-numeric user" |
runAsNonRoot: true set without a numeric runAsUser |
Add runAsUser: 1000 / runAsGroup: 1000 (the image's built-in tusd user) |
| Upload corrupts partway through, especially under load | Sticky sessions missing, or an Ingress/LB in front of the Service isn't honoring the Service's sessionAffinity |
Confirm sessionAffinity: ClientIP on 04-service.yaml; if there's an Ingress/LB in front, it needs its own separate sticky-session config — the Service-level setting alone doesn't cover that layer |
HPA shows <unknown> for CPU/memory targets, never scales |
metrics-server not running in-cluster |
Install/verify metrics-server |
One-off ops note — renaming the S3 bucket later
If the bucket ever needs to be renamed (as happened once already, cms →
playtelly), the general pattern is: check the old bucket for real data,
delete any leftover test objects, delete the now-empty bucket, re-run the
SeaweedFS init Job with the new bucket name, then update tusd's -s3-bucket
arg and re-apply. Not part of a fresh install — noted here only because it's
happened once and will likely happen again.