Digital Transformation

Stabilität im Kubernetes Nodepool: Die Herausforderung mit Cronjobs

Cronjobs in Kubernetes können Nodepools destabilisieren. Optimierungsstrategien werden angewandt, doch bleiben offene Probleme bestehen. Wie geht man damit um?

11.05.2023

Image credits: https://unsplash.com/photos/Bfxry3eVqwo
Image credits: https://unsplash.com/photos/Bfxry3eVqwo
Image credits: https://unsplash.com/photos/Bfxry3eVqwo
Image credits: https://unsplash.com/photos/Bfxry3eVqwo

In einer Kubernetes-Umgebung erlaubt uns das Container-Prinzip nicht mehr den crontab für wiederkehrende Prozesse zu nutzen. Dafür gibt es eigens dafür geschaffene Cronjob Deployments. Das funktioniert gut. Aber nur solange, wie man wenige Cronjobs hat, die gleichzeitig starten wollen. Wir aber haben Cronjobs im zweistelligen Bereich, die alle gleichzeitig starten wollen, und das jede Minute! Das führt dazu, dass Nodes instabil werden können. Also wie können wir Abhilfe schaffen?


Ausgangssituation

Unser SaaS Produkt läuft in einem Kubernetes Cluster. Wir haben für jeden unserer Kunden separate Deployments (wir nennen sie Workspaces) und jeder dieser Workspaces hat drei Cronjobs:

  1. Einen in der Nacht

  2. einen am Mittag und

  3. einen zu jeder Minute

Das sind in der Summe > 100 Deployments in einem Nodepool. Unser PROD-Nodepool hat drei Nodes. Per se sollte das kein Problem sein, denn unsere Deployments verbrauchen nicht viel Ressourcen. Weder in der CPU, noch im RAM. Die Cronjobs zu jeder Minute aber sorgen für Unruhe auf den Nodes. Wenn eine Anzahl von Cronjobs im zweistelligen Bereich zur selben Zeit hochfahren, um ihre Arbeit zu verrichten, dann bringt das einen Node schon mal durcheinander und beeinflusst die anderen Container, die auch darauf laufen. Der minütliche Cronjob sieht erst einmal harmlos aus:


apiVersion: batch/v1
kind: CronJob
metadata:
  name: uhub-${WORKSPACE}-always-cron
spec:
  concurrencyPolicy: Forbid
  failedJobsHistoryLimit: 1
  jobTemplate:
    spec:
      template:
        spec:
          containers:
            - command:
                - sh
                - -c
                - php bin/console uhub:check-post-statuses && php bin/console uhub:delete-obsolete-tasks
              env:
                - name: WORKSPACE
                  value: ${WORKSPACE}
                - name: DATABASE_URL
                  valueFrom:
                    secretKeyRef:
                      key: database_url
                      name: ${WORKSPACE}-db-credentials
              envFrom:
                - configMapRef:
                    name: symfony-config
                - secretRef:
                    name: ${WORKSPACE}-config
              image: gcr.io/${GCLOUD_PROJECT_ID}/php-fpm:${IMAGE_TAG}
              imagePullPolicy: IfNotPresent
              name: cron
              resources:
                requests:
                  cpu: "0.05"
                  memory: "50M"
              terminationMessagePath: /dev/termination-log
              terminationMessagePolicy: File
          dnsPolicy: ClusterFirst
          restartPolicy: Never
          schedulerName: default-scheduler
          securityContext: {}
          terminationGracePeriodSeconds: 30
  schedule: '*/1 * * * *'
  successfulJobsHistoryLimit: 0
  suspend: false
apiVersion: batch/v1
kind: CronJob
metadata:
  name: uhub-${WORKSPACE}-always-cron
spec:
  concurrencyPolicy: Forbid
  failedJobsHistoryLimit: 1
  jobTemplate:
    spec:
      template:
        spec:
          containers:
            - command:
                - sh
                - -c
                - php bin/console uhub:check-post-statuses && php bin/console uhub:delete-obsolete-tasks
              env:
                - name: WORKSPACE
                  value: ${WORKSPACE}
                - name: DATABASE_URL
                  valueFrom:
                    secretKeyRef:
                      key: database_url
                      name: ${WORKSPACE}-db-credentials
              envFrom:
                - configMapRef:
                    name: symfony-config
                - secretRef:
                    name: ${WORKSPACE}-config
              image: gcr.io/${GCLOUD_PROJECT_ID}/php-fpm:${IMAGE_TAG}
              imagePullPolicy: IfNotPresent
              name: cron
              resources:
                requests:
                  cpu: "0.05"
                  memory: "50M"
              terminationMessagePath: /dev/termination-log
              terminationMessagePolicy: File
          dnsPolicy: ClusterFirst
          restartPolicy: Never
          schedulerName: default-scheduler
          securityContext: {}
          terminationGracePeriodSeconds: 30
  schedule: '*/1 * * * *'
  successfulJobsHistoryLimit: 0
  suspend: false
apiVersion: batch/v1
kind: CronJob
metadata:
  name: uhub-${WORKSPACE}-always-cron
spec:
  concurrencyPolicy: Forbid
  failedJobsHistoryLimit: 1
  jobTemplate:
    spec:
      template:
        spec:
          containers:
            - command:
                - sh
                - -c
                - php bin/console uhub:check-post-statuses && php bin/console uhub:delete-obsolete-tasks
              env:
                - name: WORKSPACE
                  value: ${WORKSPACE}
                - name: DATABASE_URL
                  valueFrom:
                    secretKeyRef:
                      key: database_url
                      name: ${WORKSPACE}-db-credentials
              envFrom:
                - configMapRef:
                    name: symfony-config
                - secretRef:
                    name: ${WORKSPACE}-config
              image: gcr.io/${GCLOUD_PROJECT_ID}/php-fpm:${IMAGE_TAG}
              imagePullPolicy: IfNotPresent
              name: cron
              resources:
                requests:
                  cpu: "0.05"
                  memory: "50M"
              terminationMessagePath: /dev/termination-log
              terminationMessagePolicy: File
          dnsPolicy: ClusterFirst
          restartPolicy: Never
          schedulerName: default-scheduler
          securityContext: {}
          terminationGracePeriodSeconds: 30
  schedule: '*/1 * * * *'
  successfulJobsHistoryLimit: 0
  suspend: false

Optimierung 1: Eigener Nodepool nur für Cronjobs

Also haben wir den Cronjobs einen eigenen Nodepool gegeben und sie somit von allen anderen Anwendungen separiert. Das haben wir mit NodeSelectors gemacht. NodeSelectors sorgen dafür, dass Container nur dort deployed werden, wo sie dürfen. Sie reagieren auf die Labels der Nodepools. Also haben wir ein Label “Cronjob: allowed” auf einen neuen Nodepool gesetzt. Und die Cronjobs haben folgenden NodeSelector erhalten.

spec:
 jobTemplate:
  spec:
   template:
    spec:
     containers:
      nodeSelector:
       cronjob: allowed

Somit werden die Cronjobs nur auf Nodes erstellt, die das Label “cronjob: allowed” haben. Ansonsten läuft dort nichts. Warum? Weil wir alle unsere Anwendungen mit NodeSelectors versehen haben und sie somit zwingen auf ihren zugewiesenen Nodepools zu laufen. Ein Nodepool für die Cronjobs, ein Nodepool für die Kunden und ein Nodepool für die Anwendungen, die alle brauchen, wie IAM etc.

Das hat erst einmal geholfen und das Gewissen beruhigt. Aber schnell war zu erkennen, dass dies nicht wirklich geholfen hat. Die Nodes im Nodepool für die Cronjobs hatten immer noch dieselben Probleme und wurden instabil. Es half nur diese Nodes zu bereinigen, zu löschen und den Nodepool automatisch neue Nodes generieren zu lassen.

Um manuelle Arbeit zu vermeiden, mussten wir uns etwas anderes überlegen. Es lag auf der Hand. Wir müssen diese Cronjobs abschaffen!


Optimierung 2: Keine Cronjobs mehr!

Keine Cronjobs mehr wäre schön. Leider haben wir keine Lösung gefunden, die die Cronjobs abgeschafft hätten. Ich hätte Euch gerne gesagt, dass es ein Cronjob “” “ ” “ ”" nicht mehr braucht. Aber für uns braucht es den noch. Wir haben für uns eine Lösung gefunden, dass die Cronjobs nicht mehr vom Cluster verwaltet werden müssen und dass es somit keine Instabilität mehr gibt.

Wir haben eine zentrale Anwendung, welche alle Workspaces kennt. Von Anfang an hatten wir diese zentrale Verwaltung. Diese Verwaltung lassen wir nun alle Workspaces notifizieren, dass ein Cronjob ausgeführt werden soll. Dazu hat jeder Workspace eine API, die dann den genannten Cronjob in der Shell startet. Wir haben dadurch mehr Last auf dem Workspace, aber meistens passiert nichts, weil nichts zu tun ist.

Somit haben wir denselben Effekt, wie mit separaten Cronjob-Deployments. So vermeiden wir zu viel Last auf unserem Cluster durch dauerndes Hochfahren von Cronjobs.


Noch mehr Optimierungspotential

Leider hat auch diese Lösung einen Haken. Sie funktioniert nur solange, solange wir weniger als 300 Kunden haben: Ein Request an einen Workspace zum Starten eines Cronjobs dauert ca. 200ms. Innerhalb einer Minute können wir somit bis zu 300 Cronjobs starten. Danach dauert die Aktion alle Cronjobs zu starten länger als eine Minute und wir haben einen Versatz und ein Cronjob läuft nicht mehr jede Minute, sondern etwas seltener.

Aber auch das bekommt man in den Griff… man kann die Requests an die Workspaces in parallelen Prozessen ausführen lassen. Aber darüber schreiben wir ein andermal.


Sind wir alleine mit instabilen Nodes aufgrund von Cronjobs?

Wie geht Ihr mit Cronjobs um, wenn ganz viele Anwendungen zur selben Zeit einen Cronjob starten wollen?


Hast du eine Frage oder ein Feedback? Schreib mir ein email

Bleiben wir in Kontakt

Melde dich für mehr Inspiration an.