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.
1 Commentaire