Kubernetes auf Nachfrage skalieren

Keda und Keda HTTP Add-On

Posted by eumel8 on November 05, 2023 · 13 mins read

Nachhaltiges Computern

Der Klimawandel ist in aller Munde. Währenddessen summt und brummt es in den Rechenzentren dieser Welt fröhlich vor sich hin. Zum Thema Nachhaltiges Computern werden wir uns im nächsten Beitrag widmen. Hier geht es erstmal um Skalieren auf Nachfrage, also unsere Workload im Kubernetes Cluster wird je nach Bedarf skaliert, mit dem Horizontal Pod Autoscaler von 1 auf unendlich. Aber was wäre jetzt, wenn das 0 auf unendlich möglich wäre?

Keda

Keda - Kubernetes Event Driven Autoscaling. Ein markiger Begriff und ebenso genial. Ich habe im Cluster meine Workload installiert und das Deployment auf 0 skaliert. Alles ist “ready to go”. Das Go kommt dann von einem Event, old school wäre jetzt ein Cronjob, der die Workload um 8 Uhr hoch und um 18 Uhr wieder runterskaliert. Immerhin. Was wäre jetzt aber das Szenario eines wenig benutzen Dienstes wie etwa eine Webseite, die nur abundzu jemand besucht, wie etwa diese hier? Okay, klammern wir den ganzen Spam und Bots mal aus, um die können wir uns später kümmern. Am Tag kommen vielleicht ein oder zwei Besucher hier vorbei. Und für die öffnen wir unseren Laden, indem wir nach der ersten Anfrage im Browser einen Pod starten, welcher einen Nginx-Webserver beherbergt, der dann diesen Content hier ausliefert und dem Besucher im Browser mit geringer Verzögerung manifestiert. Wenn die Seite geladen ist, warten Keda ein Weilchen und skaliert das Deployment wieder auf 0. Wir sparen also Computer Resourcen, Strom und schonen somit die Umwelt.

Vorbereitung

Unser Kubernetes Cluster ist ein K3S, der sich selbst mit Rancher verwaltet. K3S kommt standardmässig mit Traefik als Ingress-Controller. Dort müssen wir schon mal ein paar Anpassungen durchführen:

kubectl -n kube-system edit helmchartconfigs.helm.cattle.io  traefik

Wir fügen folgende Zeilen hinzu:

spec:
  valuesContent: |-
    providers:
      kubernetesCRD:
        allowemptyservices: true
        allowExternalNameServices: true
      kubernetesIngress:
        allowemptyservices: true
        allowExternalNameServices: true

Warum, das sehen wir gleich.

Keda und Keda HTTP Addon installieren

Eins vorweg: Keda ist sehr sauber und strukturiert aufgebaut. In der Dokumentation findet man schnell die Möglichkeiten, um Keda zu installieren. Die Dokumentation ist auch nach Standards für technische Dokumentation aufgebaut: Es geht von leichten Schritten bis zu komplizierten, oberflächliche und allgemeingültige Beschreiben führen zu sehr viel Tiefe, wie etwa die detailierte Beschreibung aller Helm Chart Parameter. Sowas findet man selten und zeugt von sehr viel Liebe zum Projekt. Dank der hohen Sicherheitsstandards sind kaum Anpassungen notwendig. Wir können beginnen mit der Helm-Installation:

helm repo add keda https://kedacore.github.io/charts
helm repo update
helm upgrade -i keda keda/keda --namespace keda --create-namespace --set rbac.aggregateToDefaultRoles=true
helm upgrade -i http-add-on keda/keda-add-ons-http --namespace keda --set rbac.aggregateToDefaultRoles=true

Wir haben uns hier zur cluster-weiten Installation entschieden. Es werden die CRDs installiert und cluster-weites RBAC, wobei die Rechte an die jeweiligen Default-User wie admin aggregiert werden. Damit können dann später Projekt-Owner in ihrem Rancher-Projekt Keda für ihre App verwalten. Eine andere Möglichkeit wäre der Keda-Operator oder die Namespaced Installation. Wir wollen den Benutzer aber nicht mit der Verwaltung der Kedas-Komponenten belasten. Er soll es so einfach wie möglich haben, deswegen dieser Ansatz hier.

Demoapp & Demouser

Der Demouser besitzt im Rancher ein Projekt und ist dort Projektowner. In dem demoapp Projekt erstellen wir einen demoapp Namespace.

Als App dient uns eine Demo-App, nehmen wir die Flask App hier. Diese installieren wir in den demoapp Namespace.

kubectl -n demoapp apply -f https://raw.githubusercontent.com/mcsps/use-cases/master/flask/deployment.yaml
kubectl -n demoapp apply -f https://raw.githubusercontent.com/mcsps/use-cases/master/flask/service.yaml

Jetzt bräuchten wir nur noch einen Ingress und unsere App wäre von der Welt erreichbar. Aber schauen wir uns nochmal das Keda Architekturbild an:

Zwischen Ingress und Service gibt es den Interceptor, durch den wir den Verkehr schleusen müssen. Unser Schleuser heisst relink und wird mit im demoapp Namespace deployt:

cat <<EOF | kubectl -n demoapp apply -f -
apiVersion: v1
kind: Service
metadata:
  name: relink
spec:
  type: ExternalName
  externalName: keda-add-ons-http-interceptor-proxy.keda.svc.cluster.local
EOF

Der Interceptor Service läuft in einem anderen Namespace. Dieser Service leitet den Verkehr dorthin um. Der Ingress hat jetzt relink als Backend:

cat <<EOF | kubectl -n demoapp apply -f -
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: demoapp
spec:
  ingressClassName: traefik
  rules:
  - host: demoapp.otc.mcsps.de
    http:
      paths:
      - backend:
          service:
            name: relink
            port:
              number: 8080
        path: /
        pathType: Prefix
EOF

Damit ExternalName als Ingress Endpunkt von Traefik akzeptiert wird, waren die Änderungen an der Helmchartconfig von Traefik am Anfang notwendig.

HTTPScaledObject

Nach unserem letzten Arbeitsschritt landet unsere Workload irgendwo im Nirvana. Es ist für den Projekt-Owner nicht transparent, wohin er den Verkehr sendet. Hier ist natürlich Rücksprache mit dem Cluster-Owner notwendig.

Um jetzt die Skalierung anzugehen, brauchen wir ein HTTPScaledObject:

cat <<EOF | kubectl -n demoapp apply -f -
kind: HTTPScaledObject
apiVersion: http.keda.sh/v1alpha1
metadata:
    name: demoapp
spec:
    hosts:
    - demoapp.otc.mcsps.de
    scaledownPeriod: 10
    scaleTargetRef:
        deployment: demoapp
        service: demoapp
        port: 80
    replicas:
        min: 0
        max: 10
    targetPendingRequests: 1
EOF

Der Keda HTTP Interceptor soll also unseren Verkehr auf den demoapp Service in unserem demoapp Namespace weiterleiten. Das minimale Replica ist 0, also es laufen keine Pods der app:

$ kubectl -n demoapp get deployments.apps demoapp
NAME      READY   UP-TO-DATE   AVAILABLE   AGE
demoapp   0/0     0            0           23h

Den Status vom Keda können wir auch abfragen und sieht dann ungefähr so aus:

kubectl -n demoapp describe httpscaledobjects.http.keda.sh demoapp
Status:
  Conditions:
    Message:    Identified HTTPScaledObject creation signal
    Reason:     PendingCreation
    Status:     Unknown
    Timestamp:  2023-11-04T16:42:14Z
    Type:       Pending
    Message:    App ScaledObject created
    Reason:     AppScaledObjectCreated
    Status:     True
    Timestamp:  2023-11-04T16:42:14Z
    Type:       Created
    Message:    Finished object creation
    Reason:     HTTPScaledObjectIsReady
    Status:     True
    Timestamp:  2023-11-04T16:42:14Z
    Type:       Ready
Events:         <none>

Wenn wir jetzt unsere Demoapp aufrufen, dauert es einen kleinen Moment und dann ist die App verfügbar:

$ curl http://demoapp.otc.mcsps.de/a
You requested: a

Das Inaktiv-Timeout haben wir auf 10 Sekunden eingestellt. Die App schläft also schon wieder, ehe wir im Event-Log nachschauen können, was passiert ist:

$ kubectl -n demoapp get events -w=1
LAST SEEN   TYPE     REASON                       OBJECT                          MESSAGE
77s         Normal   KEDAScaleTargetActivated     scaledobject/demoapp            Scaled apps/v1.Deployment demoapp/demoapp from 0 to 1
77s         Normal   ScalingReplicaSet            deployment/demoapp              Scaled up replica set demoapp-6b6f4bc684 to 1 from 0
77s         Normal   SuccessfulCreate             replicaset/demoapp-6b6f4bc684   Created pod: demoapp-6b6f4bc684-s4gng
77s         Normal   Scheduled                    pod/demoapp-6b6f4bc684-s4gng    Successfully assigned demoapp/demoapp-6b6f4bc684-s4gng to k3s-test-server-2
77s         Normal   Pulled                       pod/demoapp-6b6f4bc684-s4gng    Container image "mtr.devops.telekom.de/mcsps/mcsps-python:latest" already present on machine
77s         Normal   Created                      pod/demoapp-6b6f4bc684-s4gng    Created container demoapp
77s         Normal   Started                      pod/demoapp-6b6f4bc684-s4gng    Started container demoapp
66s         Normal   KEDAScaleTargetDeactivated   scaledobject/demoapp            Deactivated apps/v1.Deployment demoapp/demoapp from 1 to 0
66s         Normal   ScalingReplicaSet            deployment/demoapp              Scaled down replica set demoapp-6b6f4bc684 to 0 from 1
66s         Normal   SuccessfulDelete             replicaset/demoapp-6b6f4bc684   Deleted pod: demoapp-6b6f4bc684-s4gng
66s         Normal   Killing                      pod/demoapp-6b6f4bc684-s4gng    Stopping container demoapp

Das wars! Das Konzept ist den Idle Instances von Openshift adaptiert.

Es bieten sich aber noch viele andere Möglichkeiten des Skalierens an. Erwähnt sei noch:

Prometheus

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: demo-keda-scaledobject
spec:
  scaleTargetRef:
    apiVersion:    apps/v1
    kind:          Deployment
    name:          demoapp
  pollingInterval:  10                               # Optional. Default: 30 seconds
  cooldownPeriod:   300                              # Optional. Default: 300 seconds
  minReplicaCount:  0                                # Optional. Default: 0
  maxReplicaCount:  6                                # Optional. Default: 100
  fallback:                                          # Optional. Section to specify fallback options
    failureThreshold: 3                              # Mandatory if fallback section is included
    replicas: 1
  advanced: # Optional. Section to specify advanced options
    horizontalPodAutoscalerConfig: # Optional. Section to specify HPA related options
      behavior: # Optional. Use to modify HPA's scaling behavior
        scaleDown:
          stabilizationWindowSeconds: 150
          policies:
            - type: Percent
              value: 100
              periodSeconds: 15
  triggers:
    - type: prometheus
      metadata:
        serverAddress: http://prometheus-operated.demoapp:9090/
        metricName: flask_http_request_duration_seconds # Note: name to identify the metric, generated value would be `prometheus-http_requests_total`
        query: sum(rate(flask_http_request_duration_seconds_count{path="a"}[1m])) # Note: query must return a vector/scalar single element response
        threshold: '1'
        # Optional fields:
        ignoreNullValues: "true" # Default is `true`, which means ignoring the empty value list from Prometheus. Set to `false` the scaler will return error when Prometheus target is lost

In diesem Beispiel läuft noch eine Monitoring-Instanz mit Prometheus. Wir installieren noch einen ServiceMonitor:

kubectl -n demoapp apply -f https://raw.githubusercontent.com/mcsps/use-cases/master/monitoring/servicemonitor-demoapp.yaml

und könnten dann mit PromQL Abfragen unsere Skalierung steuern. Klappt natürlich nicht mit den Flask-Metriken, denn dazu müsste unsere Flask-App mindestens einmal laufen. Aber es ist vielleicht für grössere Applikationen geeignet, wo zum Beispiel ein kleinerer Teil permanent läuft und der grosse Java-Container nur bei bestimmten Requests gestartet wird. Das nur so als Idee.

Antispam

Unsere Skalierung nach Bedarf funktioniert jetzt einwandfrei. Wenn wir ihn im Internet loslassen, würde er aber kaum zur Ruhe kommen, da Unmengen von Bots unterwegs sind, die unsere App ausspionieren wollen.

Hier ein paar Ideen, um den Verkehr einzudämmen:

Traefik bietet die Middleware Resource an, um IPs zu whitelisten:

apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: test-ipwhitelist
spec:
  ipWhiteList:
    sourceRange:
      - 127.0.0.1/32
      - 192.168.1.7

Umgekehr geht Blacklisten:

apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: test-ipwhitelist
spec:
  ipWhiteList:
    ipStrategy:
      excludedIPs:
        - 127.0.0.1/32
        - 192.168.1.7

Eine tiefere Möglichkeit ist eine Applikations-Firewall vor dem Ingress-Controller. Oder man bindet dynamische Spamlisten (DNBL) im Nginx des Ingress Controllers ein.

Fazit

Skalieren nach Bedarf mag nur ein kleiner Beitrag sein, um die Umwelt und den Geldbeutel zu schonen, wenn man für CPU und Memory bezahlen muss. Es ist aber ein Anfang und Dank des hervorragenden Keda Projekts auch in Open Source möglich.