Autoscaling with KEDA
This section explains how to set up KEDA to auto-scale ExApp pods (using the llm2 app as an example) based on the Nextcloud TaskProcessing queue depth.
Prerequisites
A working Nextcloud + HaRP + k8s setup (see Setting up Kubernetes)
An ExApp deployed and running (e.g.
llm2with deployment namenc-app-llm2)kubectlconfigured and pointing to the clusterhelminstalled (install guide)For GPU ExApps: the daemon must be registered with
--compute_device=cuda
Architecture overview
graph TB
Users[Users submit tasks] --> Nextcloud["Nextcloud TaskProcessing Queue
(scheduled + running tasks)"]
Nextcloud -->|"GET /ocs/v2.php/taskprocessing/queue_stats
Auth: Basic (admin app_password)"| KEDA["KEDA (metrics-api-server in k8s)"]
KEDA -->|"polls every pollingInterval (e.g. 15s)
scaling deployment based on queue depth"| deployment["nc-app-llm2 deployment (1..N pods)
Each pod independently calls next_task()"]
KEDA uses a metrics trigger (HTTP polling) to query Nextcloud
queue_stats endpoint.
When the queue grows, KEDA scales up the ExApp deployment.
When the queue reduces in size, KEDA scales back down.
0. GPU Setup (kind cluster)
If your ExApp requires a GPU (e.g. llm2), you must set up GPU passthrough in the kind cluster.
0.1 Configure Docker on the host
sudo nvidia-ctk runtime configure --runtime=docker --set-as-default --cdi.enabled
sudo nvidia-ctk config --set accept-nvidia-visible-devices-as-volume-mounts=true --in-place
sudo systemctl restart docker
0.2 Create kind cluster with GPU support
# kind-gpu-config.yaml
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
extraMounts:
- hostPath: /dev/null
containerPath: /var/run/nvidia-container-devices/all
kind create cluster --name nc-exapps --config kind-gpu-config.yaml
0.3 Install nvidia-container-toolkit inside the kind node
docker exec nc-exapps-control-plane bash -c '
apt-get update -y && apt-get install -y gnupg2 curl &&
curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey | \
gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg &&
curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list | \
sed "s#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g" \
> /etc/apt/sources.list.d/nvidia-container-toolkit.list &&
apt-get update && apt-get install -y nvidia-container-toolkit
'
0.4 Configure containerd and restart
docker exec nc-exapps-control-plane bash -c '
nvidia-ctk runtime configure --runtime=containerd --set-as-default &&
systemctl restart containerd
'
0.5 Install NVIDIA device plugin
By default, if a Deploy daemon is configured with multiple GPUs, AppAPI will attach one ExApp container/pod per GPU. In order to create multiple pods that share a single GPU, time-slicing must be configured for the GPU device plugin.
Note
All ExApps registered on the same Deploy daemon will share the same GPU resources. For ExApps that require heavy use of GPUs, it is recommended to have a separate Deploy daemon (host) for each of them.
First create a ConfigMap with the number of replicas (virtual GPUs):
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: ConfigMap
metadata:
name: nvidia-device-plugin-config
namespace: kube-system
data:
config.yaml: |
version: v1
sharing:
timeSlicing:
renameByDefault: false
resources:
- name: nvidia.com/gpu
replicas: 4
EOF
Then deploy the device plugin with the config:
cat <<'EOF' | kubectl apply -f -
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: nvidia-device-plugin-daemonset
namespace: kube-system
spec:
selector:
matchLabels:
name: nvidia-device-plugin-ds
template:
metadata:
labels:
name: nvidia-device-plugin-ds
spec:
tolerations:
- key: nvidia.com/gpu
operator: Exists
effect: NoSchedule
priorityClassName: system-node-critical
containers:
- image: nvcr.io/nvidia/k8s-device-plugin:v0.17.0
name: nvidia-device-plugin-ctr
args: ["--config-file=/config/config.yaml"]
env:
- name: FAIL_ON_INIT_ERROR
value: "false"
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
volumeMounts:
- name: device-plugin
mountPath: /var/lib/kubelet/device-plugins
- name: plugin-config
mountPath: /config
volumes:
- name: device-plugin
hostPath:
path: /var/lib/kubelet/device-plugins
- name: plugin-config
configMap:
name: nvidia-device-plugin-config
items:
- key: config.yaml
path: config.yaml
EOF
0.6 Verify GPU is visible
kubectl get nodes -o json | python3 -c "
import json,sys
for n in json.load(sys.stdin)['items']:
gpu = n['status']['capacity'].get('nvidia.com/gpu','N/A')
print(f'{n[\"metadata\"][\"name\"]}: nvidia.com/gpu = {gpu}')
"
Expected: nvidia.com/gpu = 4 (or your configured replicas count
multiplied by the number of available physical GPUs).
0.7 Test GPU from a pod
kubectl run gpu-test --image=nvidia/cuda:12.6.3-base-ubuntu24.04 --restart=Never \
--overrides='{"spec":{"containers":[{"name":"gpu-test","image":"nvidia/cuda:12.6.3-base-ubuntu24.04","command":["nvidia-smi"],"resources":{"limits":{"nvidia.com/gpu":"1"}}}]}}' \
-n nextcloud-exapps
sleep 30 && kubectl logs gpu-test -n nextcloud-exapps
kubectl delete pod gpu-test -n nextcloud-exapps
1. Install KEDA
helm repo add kedacore https://kedacore.github.io/charts
helm repo update
helm install keda kedacore/keda --namespace keda --create-namespace
Verify:
kubectl get pods -n keda
# All pods should be Running
2. DNS setup (kind only)
KEDA pods need to resolve nextcloud.local. HaRP does this
automatically now — when HP_K8S_HOST_ALIASES is set, HaRP patches
the CoreDNS ConfigMap on startup and restarts CoreDNS so that every
pod in the cluster (including KEDA) can resolve the configured
hostnames.
If you need to do it manually (or verify), the commands are:
# Get the nginx proxy IP
PROXY_IP=$(docker inspect master-proxy-1 \
--format '{{(index .NetworkSettings.Networks "master_default").IPAddress}}')
echo "Proxy IP: $PROXY_IP"
# Write the Corefile with the correct IP
cat > /tmp/Corefile << EOF
.:53 {
errors
health {
lameduck 5s
}
ready
kubernetes cluster.local in-addr.arpa ip6.arpa {
pods insecure
fallthrough in-addr.arpa ip6.arpa
ttl 30
}
prometheus :9153
hosts {
${PROXY_IP} nextcloud.local
fallthrough
}
forward . /etc/resolv.conf {
max_concurrent 1000
}
cache 30
loop
reload
loadbalance
}
EOF
kubectl create configmap coredns -n kube-system \
--from-file=Corefile=/tmp/Corefile \
--dry-run=client -o yaml | kubectl apply -f -
kubectl rollout restart deployment coredns -n kube-system
Verify:
kubectl run dns-test --rm -i --restart=Never --image=busybox -- nslookup nextcloud.local
3. Create a Nextcloud App Password
KEDA needs credentials to poll the queue_stats endpoint. The
endpoint is admin-only.
Log in to Nextcloud as admin
Go to Settings > Security > Devices & sessions
Enter a name (e.g.
keda-scaler) and click Create new app passwordCopy the password into a .env file
# .env
NC_USER="admin"
NC_APP_PASSWORD="<the-app-password-you-created>"
NC_URL="https://nextcloud.local"
Verify:
source .env
curl -s -k -u "${NC_USER}:${NC_APP_PASSWORD}" \
"${NC_URL}/ocs/v2.php/taskprocessing/queue_stats?format=json"
Expected:
{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":{"scheduled_count":0,"running_count":0}}}
4. Create k8s secret
kubectl create secret generic nextcloud-keda-auth \
--namespace=nextcloud-exapps \
--from-literal=username="${NC_USER}" \
--from-literal=password="${NC_APP_PASSWORD}"
5. Create KEDA TriggerAuthentication
cat <<'EOF' | kubectl apply -f -
apiVersion: keda.sh/v1alpha1
kind: TriggerAuthentication
metadata:
name: nextcloud-auth
namespace: nextcloud-exapps
spec:
secretTargetRef:
- parameter: username
name: nextcloud-keda-auth
key: username
- parameter: password
name: nextcloud-keda-auth
key: password
EOF
6. Create KEDA ScaledObject
Note
Nextcloud OCS returns XML by default. Always include format=json in the URL.
Task type filter
llm2 registers many task types. Use a comma-separated list to scale on all of them:
?taskTypeId=core:text2text,core:text2text:chat,core:text2text:summary,core:text2text:headline,core:text2text:topics,core:text2text:simplification,core:text2text:reformulation,core:contextwrite,core:text2text:changetone,core:text2text:chatwithtools,core:text2text:proofread
Apply
# keda-llm2-scaler.yaml
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: llm2-scaler
namespace: nextcloud-exapps
spec:
scaleTargetRef:
name: nc-app-llm2
pollingInterval: 15
cooldownPeriod: 120
initialCooldownPeriod: 60
minReplicaCount: 1
maxReplicaCount: 4
triggers:
- type: metrics-api
metadata:
url: "https://nextcloud.local/ocs/v2.php/taskprocessing/queue_stats?format=json&taskTypeId=core:text2text,core:text2text:chat,core:text2text:summary"
valueLocation: "ocs.data.scheduled_count"
targetValue: "5"
authMode: "basic"
unsafeSsl: "true"
authenticationRef:
name: nextcloud-auth
kubectl apply -f keda-llm2-scaler.yaml
Scaling formula
desiredReplicas = ceil( metricValue / targetValue )
Scheduled tasks |
targetValue=5 |
Result |
|---|---|---|
0 |
- |
1 (minReplicaCount) |
3 |
ceil(3/5)=1 |
1 pod |
12 |
ceil(12/5)=3 |
3 pods |
50 |
ceil(50/5)=10 |
4 (capped at max) |
7. Verify and Monitor
Quick status
kubectl get scaledobject -n nextcloud-exapps && echo && \
kubectl get deploy nc-app-llm2 -n nextcloud-exapps && echo && \
kubectl get pods -n nextcloud-exapps -l app=nc-app-llm2 -o wide
READY=True- KEDA can reach the metrics endpointACTIVE=False- no tasks queuedAVAILABLE=1- one pod running (minReplicaCount)
Watch scaling live
# Terminal 1: pods
kubectl get pods -n nextcloud-exapps -l app=nc-app-llm2 -w
# Terminal 2: deployment
kubectl get deploy nc-app-llm2 -n nextcloud-exapps -w
# Terminal 3: KEDA logs
kubectl logs -n keda -l app=keda-operator -f --tail=5
Check HPA (KEDA creates this)
kubectl get hpa -n nextcloud-exapps
kubectl describe hpa -n nextcloud-exapps
Full dashboard
echo "=== ScaledObject ===" && \
kubectl get scaledobject -n nextcloud-exapps && echo && \
echo "=== HPA ===" && \
kubectl get hpa -n nextcloud-exapps && echo && \
echo "=== Deployment ===" && \
kubectl get deploy nc-app-llm2 -n nextcloud-exapps && echo && \
echo "=== Pods ===" && \
kubectl get pods -n nextcloud-exapps -l app=nc-app-llm2 -o wide && echo && \
echo "=== Queue ===" && \
curl -s -k -u "${NC_USER}:${NC_APP_PASSWORD}" \
"${NC_URL}/ocs/v2.php/taskprocessing/queue_stats?format=json"
Tuning Guide
Parameter |
Example |
Default |
What it does |
|---|---|---|---|
|
15 |
30 |
Seconds between polls. Lower = faster reaction |
|
120 |
300 |
Seconds to wait before scaling down |
|
60 |
0 |
Wait after new pod starts. Set to 60 for LLM model loading time |
|
1 |
0 |
Min pods. Must be 1+ (AppAPI needs at least one pod for heartbeat) |
|
4 |
100 |
Max pods. Match your GPU count or time-slicing replicas |
|
5 |
- |
Tasks per pod. Lower = more pods sooner |
GPU time-slicing notes
One physical GPU can be shared by multiple pods using NVIDIA time-slicing
Each llm2 pod uses about 8GB VRAM (model dependent)
RTX 5090 (32GB): can run 3-4 pods with time-slicing replicas=4
RTX 4090 (24GB): can run 2-3 pods with time-slicing replicas=3
Set
maxReplicaCountto match your time-slicing replicasCUDA gives each pod equal GPU time
LLM notes
Model loading takes 30-60s. New pods are not ready right away
Use
initialCooldownPeriodto avoid over-scaling during warmupPVC access mode is
ReadWriteOnce. Works on single-node onlyMulti-node clusters are not supported yet
Cleanup
# Remove KEDA ScaledObject
kubectl delete scaledobject llm2-scaler -n nextcloud-exapps
# Remove auth resources
kubectl delete triggerauthentication nextcloud-auth -n nextcloud-exapps
kubectl delete secret nextcloud-keda-auth -n nextcloud-exapps