Premier pas dans une architecture autoscalable avec Kubernetes dans GCP

infrastructure autoscalable

On parle d’une infrastructure autoscalable quand on est face à un système qui est capable d’adapter dynamiquement sa capacité ou les services en fonction de la consommation des ressources.
Kubernetes et Google Cloud Plateform permettent de mettre en place des mécaniques de mise à l’échelle afin de garantir un rendement maximum.
Cet article va décrire comment mettre en place un mécanisme HPA (Horizontal pod scaler) et comment placer les nouveaux pods créés sur un pool de ressources spécifique.

Avant de rentrer dans un cas concret, il est important de répondre à la question suivante :

Pourquoi une infrastructure autoscalable ?

Il existe deux raisons principales pour mettre en place une infrastructure autoscalable :

  • Adapter le coût à la demande
    • En effet, on constate que l’utilisation de certaines applications n’est pas constante dans le temps. Il est possible de voir parfois une certaine saisonnalité dans la consommation des ressources. Certains métiers demandent des puissances de calcul ou de stockage très importantes sur des périodes assez courtes (quelques jours à quelques semaines). C’est le cas par exemple d’un de nos clients Arkhé, éditeur de business games, dont certains sont soumis à des pics de fréquentation lors de sessions de formation importantes.
    • En période creuse, inutile de garder toute la puissance en ligne.
  • Pour gérer les pannes (design for failure, self-healing systems). Déjà utilisée sur les baies de disque, la notion de hotspare permet d’avoir des périphériques (des disques par exemple) prêts à prendre le relais dés qu’un composant  réellement utilisé sera défaillant.

Déploiement d’une application dans le cadre d’une infrastructure autoscalable

Dans le cas d’utilisation que nous allons vous décrire, nous avons une application micro-service qui a des pics de charge de l’ordre de 2H en matinée. Pour limiter les coûts, le pool de nodes de débordement est un pool avec des machines GCP preemtibles.

L’application est déployée avec kustomize, il s’agit d’un déploiement standard.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-awesome-app-core
  labels:
    app: my-awesome-app-core
spec:
  replicas: 1
  selector:
    matchLabels:
      app: my-awesome-app-core
  template:
    metadata:
      labels:
        app: my-awesome-app-core
      annotations:
        "prometheus.io/scrape": "true"
        "prometheus.io/port": "19000"
    spec:
      affinity:
        podAntiAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            - labelSelector:
                matchExpressions:
                  - key: app
                    operator: In
                    values:
                      - my-awesome-app-core
              topologyKey: "kubernetes.io/hostname"
      initContainers:
        - name: prometheus-jmx-exporter
          image: europe-west1-docker.pkg.dev/quatreapp/qsh-public/4sh-ops-jmx-exporter-init-container:0.13.0-0
          env:
          - name: SHARED_VOLUME_PATH
            value: /ops
          volumeMounts:
          - mountPath: /ops
            name: jmx-volume
        - name: wait-mongo
          image: mongo:4.2
          env:
            - name: MONGO_URI
              valueFrom:
                configMapKeyRef:
                  name: env-props
                  key: MONGO_URI
          command:
            - sh
            - -c
            - |
              until mongo --host $(MONGO_URI) \
                          --eval "db.adminCommand('ping')"; do
                echo waiting for db
                sleep 2
              done

      volumes:
        - name: jmx-volume
          emptyDir: {}
        - name: tz-paris
          hostPath:
            path: /usr/share/zoneinfo/Europe/Paris
      containers:
      - name: my-awesome-app
        image: europe-docker.pkg.dev/quatreapp/arkhe/arkhe-okweb-my-awesome-app/srv
        imagePullPolicy: Always
        ports:
        - containerPort: 8080
        env:
        - name: MONGO_URI
          valueFrom:
            configMapKeyRef:
              name: env-props
              key: MONGO_URI
        - name: ENV_JAVA_OPTS
          valueFrom:
            configMapKeyRef:
              name: my-awesome-app-config
              key: ENV_JAVA_OPTS
        - name: MEM_JAVA_OPTS
          valueFrom:
            configMapKeyRef:
              name: my-awesome-app-config
              key: MEM_JAVA_OPTS
        - name: STD_JAVA_OPTS
          valueFrom:
            configMapKeyRef:
              name: my-awesome-app-config
              key: STD_JAVA_OPTS
        - name: JAVA_OPTS
          valueFrom:
            configMapKeyRef:
              name: my-awesome-app-config
              key: JAVA_OPTS
        resources:
          requests:
            memory: "512Mi"
            cpu: "250m"
          limits:
            memory: "2048Mi"
            cpu: "1000m"
        livenessProbe:
          httpGet:
            path: /api/version
            port: 8080
          initialDelaySeconds: 60
          periodSeconds: 10
        readinessProbe:
          httpGet:
            path: /api/version
            port: 8080
          initialDelaySeconds: 20
          periodSeconds: 10
        volumeMounts:
          - name: jmx-volume
            mountPath: /ops
          - name: tz-paris
            mountPath: /etc/localtime
---
apiVersion: v1
kind: Service
metadata:
  name: my-awesome-app-service
spec:
  selector:
    app: my-awesome-app-core
  ports:
    - protocol: TCP
      port: 8080
      targetPort: 8080
---

Dans cette configuration, nous noterons quelques éléments importants :

  • Dans spec.template.spec.affinity.podAntiAffinity, nous ne permettons pas que 2 pods de la même application soit exécutés sur le même node. Cette fonctionnalité permet de répartir plus équitablement les charges de calculs sur l’ensemble du cluster. 
  • spec.template.spec.initContainers[0].name,  l’application est une application java, ce container permet d’exporter dans l’outil de supervision prometheus les métriques de la JVM. C’est sur ces métriques que nous allons se baser pour déclencher l’ajout d’un pod.

Configuration de Prometheus pour exposer la métrique du déclencheur

Prometheus a besoin d’exposer nos métriques spécifiques dans l’api metrics. Nous allons utiliser le module prometheus-adapter pour exposer le nombre de threads consommés par l’application.

  config.yaml: |
    rules:
	- metricsQuery: (sum by (app) (jvm_threads_current{<<.LabelMatchers>>}) / on (app) sum by (app)(Catalina_Connector_maxThreads{<<.LabelMatchers>>}))*100
      name:
        as: jvm_threads_occupation
        matches: jvm_threads_current
      resources:
        overrides:
          app:
            group: apps
            resource: deployment
          kubernetes_namespace:
            resource: namespace
      seriesQuery: jvm_threads_current 

La configuration mesure le taux d’occupation des threads java pour une application.

Pour cela, la requête promQL fait  :

  • la somme des thread utilisés par tous les pod ayant un label donné
  • la somme des nombre de thread maximaux de tous les pods ayant ce même label 
  • La division des deux chiffres nous donne un ration d’occupation

Nous pouvons interroger directement l’api k8s pour vérifier que cela fonctionne.

kubectl proxy --port=8080 & 

curl http://localhost:8080/apis/custom.metrics.k8s.io/v1beta1/namespaces/my-awesome-namespace/deployments.apps/my-awesome-app-core/jvm_threads_occupation
{
  "kind": "MetricValueList",
  "apiVersion": "custom.metrics.k8s.io/v1beta1",
  "metadata": {
    "selfLink": "/apis/custom.metrics.k8s.io/v1beta1/namespaces/my-awesome-namespace/deployments.apps/my-awesome-app-core/jvm_threads_occupation"
  },
  "items": [
    {
      "describedObject": {
        "kind": "Deployment",
        "namespace": "my-awesome-namespace",
        "name": "my-awesome-app-core",
        "apiVersion": "apps/v1"
      },
      "metricName": "jvm_threads_occupation",
      "timestamp": "2021-08-19T09:29:32Z",
      "value": "27500m",
      "selector": null
    }
  ]
}

La configuration est fonctionnelle, nous voyons que 27,5 % des thread sont occupés pour l’application “my-awesome-app-core”

Configuration du HPA

Les objets de type hpa (horizontalpodautoscalers) permettent de mettre à l’échelle un controlleur de réplication (deployment, statefulset,replicaset) . Dès que la métrique dépasse la valeur désirée, un nouveau pod est créé dans la limite du nombre maximal de pod.

Il est possible de configurer 3 types de métriques :

  • resource metrics : Les métriques sont généralement issues de metrics-server
  • custom metrics : Les métriques sont issues de prometheus-adapter que nous avons configuré plus haut
  • external metrics : Les métriques sont issues d’un serveur dédié et il est possible d’agréger des données externes comme stackdriver.

Dans notre cas nous allons utiliser les resource metrics et custom metrics.

apiVersion: autoscaling/v2beta2
kind: HorizontalPodAutoscaler
metadata:
  name: my-awesome-app-core
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: my-awesome-app-core
  minReplicas: 1
  maxReplicas: 4
  metrics:
    - type: Object
      object:
        metric:
          name: jvm_threads_occupation
        describedObject:
          apiVersion: apps/v1
          kind: Deployment
          name: my-awesome-app-core
        target:
          type: Value
          value: "50"
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 75

Dans notre cas, nous déclenchons le déploiement d’un nouveau pod selon 2 conditions:

  • Un pod consomme plus de 75% de sa limite CPU
  • Le taux d’occupation des threads de l’application est > 50%

Réduction des coûts de débordement grâce à une infrastructure autoscalable

Nous avons vu précédemment que la montée en charge est relativement courte et nous ne souhaitons pas prendre de nouveaux nodes de manière permanente.

Nous allons de créer un pool de node preemptible pour permettre d’absorber la charge sans pénaliser le reste du cluster. 

gcloud container node-pools create pool-outbreak \
   --cluster "$GKE_NAME" --preemptible\
   --min-nodes "0" --max-nodes "$MAX_NODES" --machine-type "e2-highmem-2" --zone "$GKE_ZONE" \
   --node-version="$NODE_VERSION" --image-type "COS" --disk-type "pd-standard" \
   --disk-size "100" --enable-autorepair --no-enable-autoupgrade \
   --enable-autoscaling \
   --node-labels=machine-type=e2-highmem-2,poolname=4sh-outbreak,preemptible=true \
   --project $PROJECT_ID\
   --node-taints customer=4sh-outbreak:NoSchedule \

Ce script crée un pool de machines préemtible autocalable de 0 à N machines. Nous ajoutons deux valeur importantes aux nodes :

  • Les labels : poolname=4sh-outbreak et preemptible=true qui permettent d’identifier les nodes sur lesquels exécuter les pods
  • Une taint : customer=4sh-outbreak:NoSchedule qui empêche qu’un pod ne s’exécute sur ces nodes

Cette configuration ne permet pas spécifiquement de placer un pod sur un nodepool et les suivant sur le pool de débordement. Il faut modifier les pods à leur création pour les placer sur le bon nodepool.

Création d’un mutating webhook

Un mutating webhook permet de modifier un appel à l’api k8s avant que celle-ci ne soit envoyée. Dans notre cas, il faudra que l’on modifie chaque resource pod pour le placer ou non sur le pool de débordement.

apiVersion: admissionregistration.k8s.io/v1
kind: MutatingWebhookConfiguration
metadata:
  labels:
    app: finegrained-placing
  name: finegrained-placing

webhooks:
  - admissionReviewVersions:
      - v1beta1
    clientConfig:
      caBundle: [REDACTED]
      service:
        name: watchdog
        namespace: my-awesome-namespace
        path: /mutate/placing
        port: 443
    failurePolicy: Ignore
    matchPolicy: Exact
    name: watchdog.my-awesome-namespace.svc.cluster.local
    namespaceSelector:
      matchExpressions:
        - key: customer
          operator: In
          values:
            - arkhe
    reinvocationPolicy: Never
    rules:
      - apiGroups:
          - ""
        apiVersions:
          - v1
        operations:
          - CREATE
        resources:
          - pods
        scope: 'Namespaced'
    sideEffects: None
    timeoutSeconds: 5

Le service watchdog est une Api créée avec flask :

L’api retourne un jsonpach qui ajoute à chaque pod ( n’étant pas le premier du déploiement) 2 éléments pour lui permettre d’être exécuté sur le pool de débordement.

  • La toleration : customer=4sh-outbreak:NoSchedule
  • La nodeAffinity : poolname =  4sh-outbreak

La conjonction de la ressource horizontal pod autoscaler et de cluster autoscaler de GKE est un outil extrêmement puissant permettant à une application de répondre à de fortes sollicitations tout en ajustant au maximum la réservation de ressources physiques au besoin de l’application.

Lors d’une forte demande, le déclenchement d’un nouveau pod applicatif est immédiat et la GCP fourni le node nécessaire en moins de 2 minutes.

Cet article a été publié dans 4SH Facility.

Nicolas Roux
Ingénieur Infrastructure & Cloud
%d blogueurs aiment cette page :