KB-024: zeroscaler, NFS-availability scale-to-zero via native HPA¶
Status: Reference. Replaced the KEDA-based nfs-scaler (removed 2026-07). Pattern ported from
onedr0p/joryirving home-ops.
Overview¶
NFS-dependent apps (the arr stack, Plex, Jellyfin, qBittorrent, SABnzbd, …) are scaled to *0
replicas when the NFS server is unreachable** and back to 1 when it returns, so they don't
CrashLoopBackOff against a dead mount. This is done with a stock autoscaling/v2
HorizontalPodAutoscaler fed by prometheus-adapter (no KEDA).
How it works¶
The chain is:
blackbox-exporterlan-tcp-nfsProbe (jobName: nfs_probe) TCP-connects the NFS server on:2049every 1m, emittingprobe_success{job="nfs_probe"}.prometheus-adapter(observabilitynamespace) serves that metric on theexternal.metrics.k8s.ioAPI, wrapped asmax_over_time(probe_success[3m]): the debounce (see Gotchas).components/zeroscaler, a Kustomize component adding one HPA per app:minReplicas: 0,maxReplicas: 1, external metricprobe_success{job=nfs_probe}target value1. Metric1→ 1 replica; metric0→ 0 replicas.- An app opts in by adding
../../../../components/zeroscalerto itsks.yamlspec.components(${APP}is already substituted). Override the probe withZEROSCALER_JOB_NAMEor the controller kind withZEROSCALER_CONTROLLER: StatefulSet.
Gotchas¶
HPAScaleToZerofeature gate is required on BOTH the apiserver and the controller-manager. Setfeature-gates: HPAScaleToZero=trueunder bothcluster.apiServer.extraArgsandcluster.controllerManager.extraArgsintalos/patches/controller/cluster.yaml. The apiserver validatesminReplicas. Without the gate there it rejects the HPA at apply (spec.minReplicas: Invalid value: 0: must be greater than or equal to 1), leaving the app's Flux KustomizationReady=False; the controller-manager does the actual scale-to-0. flate/CI pass either way. It only surfaces when Flux applies the HPA. Changing either arg is a control-plane change (rolling restart); regenerate withjust talos gen-configandjust talos apply-nodeon each control-plane node.external.metrics.k8s.iois a cluster singleton. Only one APIService can back it. KEDA's metrics server andprometheus-adapterboth claim it, so they cannot coexist. This is why the migration removed KEDA before adding the adapter.- The
[3m]window is the flap fix. A blackbox-exporter reschedule (e.g. during a Talos drain) can make a single scrape returnprobe_success=0from a DNS blip;max_over_time(...[3m])holds the metric at 1 through transient failures so the apps don't drop. See KB-004. KubeHpaMaxedOutis silenced cluster-wide. Every HPA here ismaxReplicas: 1, permanently "maxed" when NFS is up. Thezeroscaler-hpa-maxedSilence matchesalertname=KubeHpaMaxedOut(Alertmanager can't match the HPA'sapp.kubernetes.io/part-oflabel).- Drift detection stays off. helm-controller
driftDetectionis intentionally disabled: a native HPA writesspec.replicasexactly as the KEDA HPA did, and enabling drift would fight it.
Operations¶
Pin every NFS-gated app up for the duration of a node drain (native HPAs have no paused
annotation, the recipe patches minReplicas: 1):
just kube zeroscaler suspend # pin all zeroscaler HPAs up (minReplicas=1)
just kube zeroscaler resume # back to metric-driven (minReplicas=0)
Flux reverts minReplicas to 0 on the next reconcile, so pause then act promptly, or
flux suspend kustomization <app> -n <ns> for a longer hold.
Inspect state: