Das TYPO3-Backend ist aus, bis jemand kommt
Für alle, die TYPO3 auf Kubernetes betreiben und deren Backend nachts und am Wochenende leer läuft. Der Horizontal Pod Autoscaler, kurz HPA, ist der Teil von Kubernetes, der je nach Last Pods startet und stoppt. Seit Kubernetes 1.37 darf er bis auf null gehen, ohne Zusatzsoftware wie KEDA. Das klingt nach einer Zeile im Manifest. In der Praxis stellt es eine Frage, die der HPA allein nicht beantworten kann: Wer misst, ob jemand kommt, wenn gar kein Pod läuft? Diese Feldnotiz zeigt die Antwort, die auf unserer Plattform läuft. Sie baut auf der Feldnotiz zum Backend im eigenen Pod auf.
01 — Ein Backend, das nachts niemand braucht
Seit der Trennung hat das TYPO3-Backend ein eigenes Deployment. Es bedient den Host der Redaktion: das Backend selbst und den MCP-Endpunkt, über den ein KI-Assistent Inhalte pflegt. Tagsüber hat dieser Pod gut zu tun, nachts und am Wochenende dagegen bekommt er kaum mehr als die Prüfungen des Monitorings.
Trotzdem lief er rund um die Uhr und hielt dabei etwa 310 MiB Speicher. Ein laufender Pod ist außerdem eine Angriffsfläche, die in diesen Stunden niemand braucht. Über alle Mandanten gerechnet wartet so mehr als ein Gigabyte, ohne etwas zu tun.
Auf null zu gehen lohnt sich allerdings nur, wenn der Start schnell genug geht. Bis Ende September brauchte ein TYPO3-Pod bei uns zwischen 46 und 126 Sekunden, bis er bereit war. Seit der Code als Image-Volume kommt, sind es zwölf, und erst damit wird ein Backend auf null überhaupt erträglich.
02 — Kubernetes 1.37 kann auf null, aber nur mit fremder Metrik
Das Feature heißt HPAScaleToZero. In Kubernetes 1.37 ist es Beta und standardmäßig aktiv, auf unserem k3s-Cluster ohne ein einziges Flag. Ein HPA mit minReplicas: 0 wird einfach angenommen.
Der Haken zeigt sich erst danach. Ein HPA, der nach CPU skaliert, misst die CPU seiner Pods, und bei null Pods gibt es nichts zu messen. Er käme nie wieder hoch. Deshalb braucht er eine Metrik, die auch dann existiert, wenn kein Pod läuft, in der Sprache von Kubernetes eine External Metric.
Daraus folgt noch eine zweite Entscheidung. Als Zieltyp taugt nur AverageValue. Bei den anderen Zieltypen teilt der HPA durch die aktuelle Zahl der Replicas, und bei null hat er nichts, wodurch er teilen könnte.
minReplicas: 0
maxReplicas: 1
metrics:
- type: External
external:
metric:
name: tenant_http_requests_in_flight
selector:
matchExpressions:
- { key: wake_host, operator: In, values: [cms.example.org] }
target:
type: AverageValue
averageValue: "1m"
behavior:
scaleDown:
stabilizationWindowSeconds: 180003 — Die Metrik kommt vom Edge-Proxy
Vor jedem Mandanten läuft ein Edge-Proxy, ein Caddy, und der läuft immer. Er zählt die Anfragen je Host, und Prometheus holt diese Zahlen für den Backend-Host alle 15 Sekunden ab. Der prometheus-adapter übersetzt zwischen Prometheus und Kubernetes und macht daraus External Metrics, die der HPA lesen kann. So misst etwas, das immer läuft, ob jemand das abgeschaltete Backend sucht.
Den Start darf dabei nicht die Anfragerate auslösen, also die Zahl der fertigen Anfragen pro Sekunde. Ein Request, den der Proxy für ein abgeschaltetes Backend festhält, ist noch nicht abgeschlossen und taucht im Zähler der fertigen Anfragen gar nicht auf. Er würde also genau das nicht auslösen, worauf er wartet. Den Start lösen deshalb die Anfragen in Bearbeitung aus, also die, die gerade laufen oder warten. Die sieht Prometheus beim nächsten Abruf, egal wie kurz sie dauern.
Ob das Backend danach weiterläuft, entscheidet dagegen die Rate. Darin steckt allerdings auch das Monitoring, das den Host prüft. Der Adapter zieht deshalb einen Boden von 0,15 Anfragen pro Sekunde ab, knapp oberhalb dessen, was die Prüfungen allein erzeugen. Erst darüber gilt das Backend als benutzt.
Eine Falle steckte im Label. Caddy schreibt den Host so, wie ihn der Client geschickt hat, gelegentlich mit Port. Der Wert eines Label-Selektors darf aber keinen Doppelpunkt enthalten, und genau daran scheiterte der erste Selektor. Seitdem hängt Prometheus beim Abruf ein eigenes Label wake_host ohne Port an.
04 — Der erste Request wartet, statt zu scheitern
Scale-to-Zero heißt: Ohne Last läuft kein einziger Pod. Für den ersten Aufruf danach ist die häufigste Lösung eine Warteseite: Der Proxy antwortet mit 503, und der Browser lädt nach ein paar Sekunden neu. Für eine Redakteurin wäre das gerade noch zumutbar. Für einen KI-Assistenten, der per POST an den MCP-Endpunkt schreibt, ist es ein Fehler, den er nicht wiederholt.
Deshalb hält der Edge-Proxy den Request fest. Findet er für den Backend-Host keinen Pod, versucht er es jede Sekunde erneut, bis zu 90 Sekunden lang, und leitet den Request weiter, sobald ein Pod antwortet. Auch ein POST übersteht so den Start.
reverse_proxy app-backend:443 {
dynamic a {
name app-backend-headless
port 8443
refresh 10s
}
lb_try_duration 90s
lb_try_interval 1s
}
Bleibt noch das Monitoring. Eine Prüfung, die das Backend jede Minute aufruft, würde es dauerhaft laufen lassen. Deshalb beantwortet der Edge-Proxy die Prüfung des Backend-Hosts selbst: Sie testet TLS, Allowlist und Routing, ohne das Backend zu starten. Ob ein abgeschaltetes Backend tatsächlich starten würde, überwacht stattdessen ein eigener Alarm.
05 — Was die Messung zeigt
Bevor ein Mandant umgestellt wurde, lief der ganze Regelkreis mit einem Test-HPA. Nach dem Ende der Last ging er nach 26 Sekunden auf null, im Test mit einem kurzen Ruhefenster. Kam wieder Last, startete der HPA nach 17 Sekunden einen Pod. Bei null meldete der HPA ScaledToZero=True und ScalingActive=True, er war also auf null und hörte weiter zu.
Im Betrieb wartet er länger. Erst nach 30 Minuten ohne Anfrage der Redaktion geht das Backend auf null. Eine Redakteurin, die kurz Kaffee holt, merkt davon nichts. Wer morgens als Erste kommt, wartet einmal länger: Beim ersten echten Start am Abend des 2. Oktober kam die Antwort nach 35 Sekunden. Darin stecken der nächste Abruf durch Prometheus, eine Runde des HPA, der Pod-Start und die Weiterleitung des gehaltenen Requests.
Den Anfang machte am 2. Oktober das Backend meiner eigenen Seite. Die übrigen Mandanten sollten erst folgen, wenn dort eine Woche lang jeder Start unter 60 Sekunden blieb. Inzwischen werden die Backends aller Mandanten auf diese Weise abgeschaltet. Ein einziger Schalter in der Mandantendatei nimmt es für einen Mandanten wieder zurück.
06 — Wo es nicht passt
Nicht jeder Pod sollte abgeschaltet werden. Der Consumer, der Mails und Bildgrößen abarbeitet, liefert seine Zahlen selbst, und bei null schriebe sie niemand. Nichts könnte ihn dann starten. Selbst mit einer anderen Quelle würde eine Mail zur Passwort-Zurücksetzung dann bis zu zwei Minuten warten, um rund 200 MiB zu sparen. Der Consumer läuft deshalb immer.
Nach oben skaliert das Backend mit diesem Aufbau ebenfalls nicht. Die Start-Metrik ist so gewählt, dass jede Anfrage genau einen Pod bedeutet. Wer zwei Pods erlaubt, würde schon bei einer einzigen Anfrage den zweiten starten. Bei einem Bedarf von unter 0,05 CPU-Kernen braucht das Backend ihn ohnehin nicht.
Und ein abgeschalteter Pod darf keinen Alarm auslösen, ein kaputter schon. Der bestehende Alarm für eine App ohne Pod beobachtet deshalb nur die Website. Zwei neue Alarme beobachten das Backend: BackendWakeStuck, wenn der HPA einen Pod will und keiner antwortet, und BackendHPAInactive, wenn der HPA seine Metrik nicht mehr lesen kann. Ohne diesen zweiten Alarm würde ein abgeschaltetes Backend nie wieder starten, und niemand merkte es vor dem nächsten Login.
Häufige Fragen
Merkt die Redaktion, dass das Backend abgeschaltet war?+
Einmal am Morgen: Der erste Aufruf dauert rund eine halbe Minute, gemessen 35 Sekunden. Danach läuft das Backend, solange gearbeitet wird, und erst 30 Minuten nach der letzten Anfrage geht es wieder auf null.
Startet das Monitoring das Backend nicht ständig?+
Nein. Die Prüfung des Backend-Hosts beantwortet der Edge-Proxy selbst, und von der Anfragerate zieht der Adapter einen Boden ab, der knapp über der Last der Prüfungen liegt. Was übrig bleibt, sind echte Besuche der Redaktion.
Warum nicht KEDA oder Sablier?+
Beide hätten funktioniert und wären ein weiterer Controller im Cluster gewesen. Seit Kubernetes 1.37 kann der eingebaute HPA auf null gehen, und Prometheus mit Adapter lief ohnehin. Ein Metrikpfad weniger ist der größere Gewinn.
Fazit
Scale-to-Zero mit Bordmitteln ist in Kubernetes 1.37 angekommen, doch die Zeile minReplicas: 0 ist der kleinste Teil davon. Die Arbeit steckt in der Frage, wer misst, solange kein Pod läuft, und wer den ersten Request so lange hält, bis wieder einer läuft. Bei uns ist beides der Edge-Proxy, der ohnehin vor jedem Mandanten steht.
Ob sich das lohnt, entscheidet der Pod-Start. Mit zwölf Sekunden Pod-Start wartet die Redaktion einmal am Morgen eine halbe Minute, danach nie wieder. Mit zwei Minuten wäre es eine Zumutung gewesen, und der Speicher hätte das nicht aufgewogen.
Läuft dein TYPO3-Backend auch nachts?
Ich sehe mir an, wann dein Backend gebraucht wird, und richte es so ein, dass es außerhalb der Arbeitszeit abgeschaltet ist und beim ersten Aufruf in Sekunden wieder da ist.
Über den Autor

Kai Ole Hartwig
Programmiert seit 2002 – autodidaktisch gelernt, 2012 mit KO-Web selbständig gemacht. Über 100 Projekte, Fokus auf Security, Performance, Automatisierung und Qualität. Heute freiberuflich: DevSecOps-Beratung, Schulungen und Softwareentwicklung.