Kubernetes

De Justine's wiki
Aller à la navigation Aller à la recherche

Présentation vite fait

Kubernetes (communément appelé « K8s2 ») est un système open source qui vise à fournir une « plate-forme permettant d'automatiser le déploiement, la montée en charge et la mise en œuvre de conteneurs d'application sur des clusters de serveurs »3. Il fonctionne avec toute une série de technologies de conteneurisation, et est souvent utilisé avec Docker. Il a été conçu à l'origine par Google, puis offert à la Cloud Native Computing Foundation.

Un cluster k8s contient deux types de machines (ou nodes):

  • Un Master qui coordonne le cluster: scheduling, maintien de leur état recherché, scaling, application des mises à jour...
  • Des workers, où tournent les applications: Cela peut être une VM ou une machine physique, utilisé comme worker et managée par le Master. Typiquement, un worker contient des outils pour gérer les opérations sur le conteneur (Docker, rkt) et Kubelet, un agent servant à manager le noeud. Un cluster de prod devrait avoir au moins trois noeuds.

Terminologie

Kubernetes contient un certain nombre de termes particuliers. Voir les sources.

Mise en oeuvre

Je pars d'un cluster à 2 machines sous Debian 11.

Je vais utiliser k3s pour la mise en place, une distribution légère de k8s très rapide à installer.

MEP

Prérequis

  • ici
  • 2 nodes ne peuvent pas avoir le même hostname
  • 512Mo de RAm et 1 CPU
  • SSD recommandé

Ports:

  • 6443 pour tous les nodes (protocole ?)
  • 8472/udp si utilisation de VXLAN Flannel
  • Port 10250 pour le serveur de métriques
  • Ports 2379 et 2380 si utilisation de etcd

Install

Mon premier node sera master, le second un simple node. Tout se fait en root.

Sur le master:

#curl -sfL https://get.k3s.io | sh -
#Mieux, sans son traefik intégré
curl -sfL https://get.k3s.io | sh -s - server --no-deploy=traefik
#On peut spécifier le token
curl -sfL https://get.k3s.io | sh -s - server --token=<ayo le token> --no-deploy=traefik
kubectl get node

Le script installe kubernetes et je vois que mon serveur est désormais noeud du cluster.

Mon cluster est identifié par un token présent sur le master. Je vais le récupérer:

/var/lib/rancher/k3s/server/node-token

Sur le worker, c'est un peu la même chose, mais on ajoute l'adresse du master et le token:

curl -sfL https://get.k3s.io | K3S_URL=https://myserver:6443 K3S_TOKEN=mynodetoken sh -

Je peux voir mes 2 machines sur le worker:

kubectl get node

Si je veux pouvoir lancer kubectl sur mon pod depuis une autre machine, je vais sur mon manager et je récupère sa conf dans

/etc/rancher/k3s/k3s.yaml

Et je vais la coller sur mon autre machine dans

~/.kube/config

En prenant soin de modifier la section "adress" en remplacement 127.0.0.1 par l'adresse de mon manager

Désinstallation si besoin

Pour un master:

/usr/local/bin/k3s-uninstall.sh

Pour un worker:

/usr/local/bin/k3s-agent-uninstall.sh

Explications théoriques, déploiement manuel

Je vais tenter de déployer mon premier service, ma propre application (sqnotes). Celle-ci requiert idéalement un accès en http et un volume.

Tout va se faire en utilisant la commande kubectl depuis le master.

Distinction pod / deployment / service / StatefulSet

Un pod est un groupe de conteneurs, que l'on met ensemble pour des raisons d'administration.

Un deployment est l'expression d'un état désiré pour un pod. Schématiquement, "Je veux que mon pod untel soit actif".

Un service est une façon abstraite d'exposer une application tournant sur un ensemble de pods en tant que service réseau.

StatefulSet ?

Un StatefulSet est similaire à un déploiement, sauf que les pods sont "stateful", soit : ils ne sont pas interchangeable ([un statefulSet] 'Gère le déploiement et la mise à l'échelle d'un ensemble de Pods, et fournit des garanties sur l'ordre et l'unicité de ces Pods'). Chaque pod généré par le StatefulSet est unique et est conservé d'un re-scheduling à l'autre. Utile si besoin de:

  • Des identifiants réseau stables et uniques.
  • Un stockage persistant stable.
  • Un déploiement et une mise à l'échelle ordonnés et contrôlés.
  • Des mises à jour continues (rolling update) ordonnées et automatisées.


Déploiement

kubectl

C'est la commande principale servant à gérer un cluster kub.

Sa doc est dispo ici

Un deployment

Déployer mon conteneur en tant que simple déploiement est... plutôt simple:

kubectl create deployment sqnotes --image=squi/sqnotes:1.0

Informations sur mon deployment

Je peux ensuite le voir:

$ kubectl get pods                                      
NAME                       READY   STATUS              RESTARTS   AGE
sqnotes-85dd78574f-p27wb   0/1     ContainerCreating   0          5s
$ kubectl get deployments
NAME      READY   UP-TO-DATE   AVAILABLE   AGE
sqnotes   1/1     1            1           41s

...ainsi que ses évènements

kubectl get events

...avoir une description détaillée de mes deploiements

kubectl describe deployments

Supprimer mon déployment

kubectl delete deployment sqnotes

Un service

Parlons un peu de réseau...

Par défaut, mon pod n'expose pas de port. Un service (le nom est mal choisi) sert à exposer un port d'un pod : non pas forcément à l'extérieur, mais à un autre pod, par exemple; ou bien à un ingress (les ingress passent par les services).

Les pods situés dans le même namespace peuvent discuter entre eux; mais pour que pod1 aillent parler à pod2 sur son port 3306, il faut que ce dernier ait un service qui expose ce port !

Types de services

Il existe 4 types de service:

  • ClusterIP (par défaut): le service n'est pas accessible de l'extérieur, il n'a qu'une IP interne au cluster.
  • NodePort: Expose le service sur l'IP de chaque node avec un port statique : le node port. Un service ClusterIP est automatiquement créé; NodePort redirige sur celui-ci.
  • LoadBalancer: Expose le service en utilisant le lb d'un fournisseur. Les services NodePort et ClusterIP sont créés, et le lb redirige sur ceux-ci.
  • ExternalName: Mappe le service au contenu du champ externalName (par exemple un fqdn foo.bar.example.com) en renvoyant un CNAME. Aucun proxy n'est mis en place.


Faire un service

La commande suivante va donc exposer le port 8080 de mon deployment:

kubectl expose deployment sqnotes --type=LoadBalancer --port=8080
  • --type=LoadBalancer signifie que le service doit être accessible de l'extérieur.

J'expose le port 8080 car celui utilisé par mon image docker.

Voir un service

kubectl get services

Me donne:

NAME         TYPE           CLUSTER-IP      EXTERNAL-IP                   PORT(S)          AGE
kubernetes   ClusterIP      10.43.0.1       <none>                        443/TCP          54m
sqnotes      LoadBalancer   10.43.138.228   192.168.1.208,192.168.1.209   8080:31349/TCP   104s

Delete un service

Supprimer le deployment ne suffit pas, il faut aussi supprimer le service.

kubectl delete service sqnotes

Stockage

Volumes

K8s se base sur des volumes. Ceux-ci sont provisionnés sans se soucier de l'infrastructure sous-jacente; ainsi, un partage réseau par exemple peut servir à stocker les données des conteneurs et à les partager entre eux, comme par exemple avec un cluster Ceph !

Un volume est donc "une unité de stockage abstraite". K8s propose en réalité un tas de plugins de stockage qui peuvent fonctionne sur AWX, Azure, VMWare, d'autres choses...

Volumes et Volumes persistants

Les volumes normaux sont éphémères. Les volumes persistants, déployés de la même façon que les pods, fournissent du stockage à long terme (même si les pods sont arrêtés).

Les nodes peuvent provisionner des volumes en faisant des claim, et en précisant le type de stockage demandé. On peut définir des objets StorageClass qui spécifient quels stockages sont sont dispo. Le cluster va, lors d'un claim, chercher un stockage adapté en fonction de sa StorageClass et faire le lien entre un claim et un volume.

Provisionnement Statique / dynamique

Les volumes peuvent être créés de façon statique ou dynamique. Si c'est statique, le cluster dispose d'un ensemble fixe de volumes; les claims sont rattachés à l'un deux si cela correspond aux critères demandés (débit, prix, etc).

Le provisionnement dynamique fait que les volumes sont créés automatiquement en réponse à un claim. K8s ne fait qu'allouer le stockage, pas les backups ou la HA sur celui-ci.

Types de volumes

Au fond, un volume est un dossier contenant ou pas des données, accessible aux conteneurs d'un pod. Comment ce dossier est créé et accessible dépend du type de volume; contrairement à Docker, k8s propose divers drivers de volumes.

Je ne vais pas faire la liste de tous les types de volumes (rien que pour les volumes AWS, ce serait long...). Les volumes sont déclarés dans .spec.volumes et les points de montage dans .spec.containers[*].volumeMounts. Comme pour Docker, le conteneur verra son FS interne de base plus ses volumes. On ne peut pas mettre un volume dans un volume, mais la doc indique de voir ce lien pour ce genre de choses.

Une liste des types de volumes.

On peut faire du glusterfs, du cephfs, du nfs, de l'iscsi, etc.

Déploiement d'une application (avec un fichier)

* Doc

Le passage par des fichiers va nous permettre de nous y retrouver un peu plus et de faire des parallèles avec docker et docker-compose. Notamment, cela va faciliter la gestion des replicas et la gestion du stockage.

Charts

Les fichiers en yaml utilisés par k8s pour décrire des objets s'appellent des charts. Il s'agit tout simplement de fichiers décrivant un appel à l'API de k8s.

Ses 4 premiers champs sont à peu près toujours les mêmes:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: qqchose
spec:

  • apiVersion: Quelle version de l'API K8s on utilise. Là, c'est un peu le bordel : ça change d'un objet à l'autre.
  • kind : quel genre d'objet on veut créer.
  • metadata : Des données aidant à identifier l'objet : un name, un UID, et éventuellement un namespace.
  • spec : les spécifications pour cet objet.

ReplicaSet

Un ReplicaSet (ensemble de réplicas en français) a pour but de maintenir un ensemble stable de Pods à un moment donné. Cet objet est souvent utilisé pour garantir la disponibilité d'un certain nombre identique de Pods.

C'est en gros un objet qui va gérer la replication des pods. Il est défini par plusieurs choses:

  • un sélecteur qui précise comment savoir quels pods il peut posséder
  • le nombre de replicas qu'il doit maintenir en vie
  • un template de pod qu'il va pouvoir utiliser pour en créer de nouveaux.

Le replicaSet se base sur un champ de métadonnée (metadata.ownerReferences) du pod qui précise son replicaSet. Il peut identifier des nouveaux pods à acquérir avec son sélecteur, si celui-ci n'as pas de "ownerReferences".

Exemple de fichier de replicaSet:

apiVersion: apps/v1
kind: ReplicaSet
metadata:
  name: frontend
  labels:
    app: guestbook
    tier: frontend
spec:
  # modify replicas according to your case
  replicas: 3
  selector:
    matchLabels:
      tier: frontend
  template:
    metadata:
      labels:
        tier: frontend
    spec:
      containers:
      - name: php-redis
        image: gcr.io/google_samples/gb-frontend:v3


Exemple de deployment

Exemple de deployment commenté:

#Version de l'API k8s utilisée pour ce déploiement
#visible avec kubectl api-versions
apiVersion: apps/v1
#Ce fichier représente un déploiement (complet donc)
kind: Deployment
#Section définissant les métadonnées du deploiement
metadata:
  #Son nom
  name: nginx-deployment
  labels:
    #Ce label est réutilisé plus bas pour le replicaSet
    app: nginx
#Sepcifications du déploiement
spec:
  #3 replicas...
  replicas: 3
  #...identifié par le label nginx
  selector:
    matchLabels:
      app: nginx
  #Template de pod pour le replicaSet:
  #c'est en fait ici qu'on va dire quel conteneur on veut.
  template:
    metadata:
      labels:
        #Ne pas oublier le label
        app: nginx
    spec:
      containers:
        #Spécification du conteneur
      - name: nginx
        image: nginx:1.7.9
        ports:
        - containerPort: 80

J'écris donc un déploiement pour mon application de prise de notes.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: sqnotes
  labels:
    app: sqnotes
spec:
  replicas: 3
  selector:
    matchLabels:
      app: sqnotes
  template:
    metadata:
      labels:
        app: sqnotes
    spec:
      containers:
      - name: sqnotes
        image: squi/sqnotes:1.0
        ports:
        - containerPort: 8080

et je le lance avec:

kubectl apply -f sqnotes.yml

Ça fonctionne... sauf que je n'ai pas le service.

Exemple de service

Puisqu'un service est un objet (comme un pod, comme un replicaSet, etc...) on peut le décrire dans un fichier yaml avant de le balancer à l'API.

Exemple:

#version de l'API... toujours pas compris ce champ
apiVersion: v1
#c'est un service
kind: Service
#avec un nom
metadata:
  name: my-service
spec:
  #son type
  type: ClusterIP
  #il s'applique en fonction d'un label
  selector:
    app: MyApp
  ports:
    #le port 80 du node donnera sur le port 9376 du conteneur.
    - protocol: TCP
      port: 80
      targetPort: 9376

Je vais donc écrire un service de type NodePort pour mon appli:

apiVersion: v1
kind: Service
metadata:
  name: sqnotes-ingress
spec:
  type: NodePort
  selector:
    app: sqnotes
  ports:
    - protocol: TCP
      port: 80
      targetPort: 8080
      nodePort: 30081

Et voilà. En accédant au port 30081 de mon node, j'accède à mon appli.

TODO : apprendre à gérer le LB.

Exemple de volume

J'ai un déploiement, un service, il me manque un volume. Nous allons donc devoir créer deux objets:

  • Un persistent volume
  • Un persistent volume claim

Les deux vont dans le même fichier. Je dispose en outre d'un serveur nfs sur nas.sq.lan.

apiVersion: v1
kind: PersistentVolume
metadata:
  name: sqnotes-data
spec:
  capacity:
    storage: 10Gi
  accessModes:
    - ReadWriteOnce
  nfs:
    server: nas.sq.lan
    path: "/k8s-data/sqnotes"
  mountOptions:
    - nfsvers=4.2

---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: sqnotes-data
spec:
  accessModes:
    - ReadWriteOnce
  storageClassName: ""
  resources:
    requests:
      storage: 10Gi
  volumeName: sqnotes-data

Les options sont assez explicites pour la plupart. L'option accessModes offre plusieurs options:

  • ReadWriteOnce – the volume can be mounted as read-write by a single node. => Faux... C'est les déploiements, pas les pods.
  • ReadOnlyMany – the volume can be mounted read-only by many nodes.
  • ReadWriteMany – the volume can be mounted as read-write by many nodes.

Je dois ensuite modifier fichier de déploiement pour qu'il prenne en compte ses volumes.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: sqnotes
  labels:
    app: sqnotes
spec:
  replicas: 3
  selector:
    matchLabels:
      app: sqnotes
  template:
    metadata:
      labels:
        app: sqnotes
    spec:
      containers:
        - name: sqnotes
          image: squi/sqnotes:1.0
          ports:
            - containerPort: 8080
          volumeMounts:
            - mountPath: /app/data
              name: sqnotes-data
            - mountPath: /etc/timezone
              name: sqnotes-timezone
              readOnly: true
            - mountPath: /etc/localtime
              name: sqnotes-localtime
              readOnly: true
      volumes:
        - name: sqnotes-data
          persistentVolumeClaim:
            claimName: sqnotes-data
        - name: sqnotes-timezone
          hostPath:
            path: /etc/timezone
        - name: sqnotes-localtime
          hostPath:
            path: /etc/localtime

Et voilà !

NameSpaces

Un namespace est une façon de découper nos applications : un objet dans un namespace X ne peut pas voir les objets du namespace Y.

Le chart de création d'un NameSpace peut se faire de la façon suivante:

apiVersion: v1
kind: Namespace
metadata:
  name: monitoring

Ici, je créée un namespace "monitoring". Ensuite, pour que mes objets y aillent, il suffit de l'indiquer dans les métadonnées. Par exemple ici avec un PersistentVolumeClaim:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: grafana-conf
  namespace: monitoring
spec:
  accessModes:
    [...]

Ensuite, si je veux effectuer une opération dans un namespace, par exemple lister tous les pods, j'utilise -n :

kubectl get pods -n monitoring

Ingress

SOURCES

Le principe est le suivant:

  • On a un ingress controller : c'est en somme un ensemble de pods, rassemblés dans leur propre namespace, qui vont jouer le rôle de rp pour nos applications. C'est relativement indispensable. Il en existe plusieurs : nginx, traefik, caddy...
  • On a des ingress (un par application en général) : c'est un appel à l'ingress controller. Si je crée un ingress pour mon wiki, celui servira à dire au controleur à quel vhost correspond le wiki, et sur quel port de quel pod (== sur quel service) il doit aller taper. Un ingress se décrit dans un chart.

Je vais utiliser le proxy nginx, qui est le plus courant.

    • Attention** : sur un cluster k3s, il faut désactiver l'ingress traefik intégré, sinon notre nginx ne parviendra jamais à récupérer une IP. Si la commande suivante:
kubectl get services -n ingress-nginx

me dit que l'EXTERNAL-IP de mon nginx est <PENDING>, c'est sûrement que traefik est en place et qui prend l'IP. Voir en haut de la page, section install. Si il donne l'ip de mes nodes, c'est bon. Peu importe que l'IP soit publique ou privée.

Fin de la parenthèse.

Je vais l'installer avec Helm. Deux possibilités :

#Installation avec proxy-protocol v2
 helm upgrade --install ingress-nginx ingress-nginx \
      --repo https://kubernetes.github.io/ingress-nginx \
      --namespace ingress-nginx --create-namespace \
      --set controller.metrics.enabled=true \
      --set controller.config.use-proxy-protocol=true \
      --set controller.config.enable-real-ip=true \
      --set controller.config.use-forwarded-headers=true
#Installation sans
helm upgrade --install ingress-nginx ingress-nginx \
      --repo https://kubernetes.github.io/ingress-nginx \
      --namespace ingress-nginx --create-namespace \
      --set controller.metrics.enabled=true \
      --set controller.service.externalTrafficPolicy=Local

(Dans la deuxième version, controller.service.externalTrafficPolicy signifie que même sans le proxy protocol, on remonte les vraies IP des client, pas celle du cluster)

La différence est que dans le premier car, on utilise Proxy protocol v2, ce qui est utile pour bien remonter les IPs des clients. Si mon ingress est derrière un RP HAproxy, il faudra alors configurer les backends haproxy avec l'option send-proxy-v2 sur le haproxy qui se trouve devant l'ingress:

backend bk_https
        mode tcp
        balance roundrobin
        server manager1 rzkubmanager1:443 check send-proxy-v2
        server worker1 rzkubworker1:443 check send-proxy-v2
        server worker2 rzkubworker2:443 check send-proxy-v2 

Si erreur, problème, etc..., il vaut mieux tout désinstaller:

helm uninstall -n ingress-nginx ingress-nginx

L'ingress controller dispose alors de son propre namespace (ingress-nginx). Je peux vérifier que ses pods sont bien montés:

exploit@sqnotes > kubectl get pods -n ingress-nginx                   
NAME                                        READY   STATUS    RESTARTS   AGE
svclb-ingress-nginx-controller-j8m85        2/2     Running   0          7m21s
svclb-ingress-nginx-controller-bb8cj        2/2     Running   0          7m21s
ingress-nginx-controller-6d9cf8dddd-6xtdf   1/1     Running   0          7m21s

Voilà, j'ai un ingress controller. Maintenant, je vais créer un ingress. Celui-ci va sur un service nommé "notes", lequel appartient à une application du même nom. Je ne rentre pas dans la création du service, voir au-dessus. Voici mon ingress:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  #Nom de l'ingress
  name: notes-ingr
spec:
  #Il s'adresse à Nginx
  ingressClassName: nginx
  rules:
    #VHost
    - host: test.squi.fr
      http:
        paths:
          - pathType: Prefix
            backend:
              service:
                #Nom du service que va aller chercher l'ingress
                name: notes 
                port:
                  #Port du service, correspond au port écouté par le conteneur.
                  number: 8080
            path: /

Pas de HTTPS ici : je ne le gère pas au niveau du cluster kub.

Logs de l'ingress controller

Pour lire les logs de l'ingress controller, il faut d'abord récupérer ses pods:

exploit@sqnotes > kubectl get pods -n ingress-nginx
NAME                                        READY   STATUS    RESTARTS   AGE
svclb-ingress-nginx-controller-j8m85        2/2     Running   0          18m
svclb-ingress-nginx-controller-bb8cj        2/2     Running   0          18m
ingress-nginx-controller-6d9cf8dddd-6xtdf   1/1     Running   0          18m

Ici, je vois bien mes 2 load balancers (un par node, j'ai deux node dans mon cas) et le controller. C'est ce dernier qui va me donner les logs:

kubectl logs -n ingress-nginx ingress-nginx-controller-6d9cf8dddd-6xtdf

J'ai les logs nginx.

Gérer l'authentification via IP et / ou password

Un exemple d'ingress ayant pour but d'accepter les connexions authentifiées via un mot de passe OU via une whitelist ip:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: notes-ingr
  annotations:
    nginx.ingress.kubernetes.io/auth-type: basic
    nginx.ingress.kubernetes.io/auth-secret: basic-auth
    nginx.ingress.kubernetes.io/auth-realm: 'Authentication Required - you fool'
    nginx.ingress.kubernetes.io/whitelist-source-range: '192.168.1.0/24'
    nginx.ingress.kubernetes.io/satisfy: "any"
    kubernetes.io/ingress.class: "nginx"
    kubernetes.io/tls-acme: "true"
    cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
  tls:
    - secretName: notes-tls
      hosts:
        - notes.squi.fr
  rules:
    - host: notes.squi.fr
      http:
        paths:
          - pathType: Prefix
            backend:
              service:
                name: notes 
                port:
                  number: 8080
            path: /

Ici:

  • Les lignes auth-type, auth-secret, auth-realm gèrent l'authentification par mot de passe;
  • La ligne whitelist-source-range gère le passage par IP;
  • La ligne nginx.ingress.kubernetes.io/satisfy: "any" est important : le any signifie que le client doit satisfaire à n'importe lequel des 2 (mot de passe ou ip). Elle peut prendre les valeurs "any" ou "all".

Pour l'authentification par mot de passe, il faut bien lui donner un user / password. Une fois l'ingress déployé:

  • Créer un fichier de type htpasswd
htpasswd -c auth monuser
  • Le transformer en secret pour k8s:
kubectl create secret generic basic-auth --from-file=auth
  • On peut récupérer son yaml
kubectl get secret basic-auth -o yaml

On peut désormais créer un ingress avec authentification (voir l'exemple juste au dessus).

Helm

C'est le gestionnaire de paquets pour k8s. Il permet de partager des charts (les fichiers de conf de k8S sont appellés des charts), un peu à la manière du dockerhub. Je vais commencer par l'installer. Pour ça, je vais passer par un repo.

curl https://baltocdn.com/helm/signing.asc | sudo apt-key add -
sudo apt-get install apt-transport-https --yes
echo "deb https://baltocdn.com/helm/stable/debian/ all main" | sudo tee /etc/apt/sources.list.d/helm-stable-debian.list
sudo apt-get update
sudo apt-get install helm

Helm se base sur une variable d'environnement pour retrouver le cluster kub. Je dois donc ajouter cet export à mon zshrc / bashrc:

export KUBECONFIG=/etc/rancher/k3s/k3s.yaml

Ne pas oublier de le sourcer après !

Voilà. Je peux désormais trouver des charts sur le site officiel et les installer avec helm.

Commandes courantes

Appliquer tous les fichiers du répertoire courant

kubectl apply -f .

Effacer tout sur le namespace courant

kubectl delete all --all

Effacer tout dans un namespace

kubectl delete all --all -n {namespace}

Supprimer tous les objets des fichiers du répertoire courant

kubectl delete -f .

Voir mes pods et leur répartition

kubectl get pods -o wide

Logs d'un pod

kubectl logs <pod_id>

Générer le YAML d'un objet

Ici d'un deployment:

k get deployment <mon-deploiement> -o yaml

Kompose

https://kompose.io/

Ce soft sert à transformer les docker-compose en charts k8s. Il marche apparement pas mal avec des docker-compose sans la section deploy; avec, il faut parfois reprendre un peu les choses mais ce n'est pas inintéressant.

Il a tendance à rajouter beaucoup de choses, y compris ses propres labels (et il faut faire le ménage derrière lui...). C'est un bon moyen d'avoir une base à partir de laquelle faire le projet. Le sed suivant permet d'enlever les annotations et labels inutiles:

sed -i '/annotations:/,/null/d' *;sed -i 's/io.kompose.service/run/g' *

Notions avancées

Horizontal autoscaling, metrics server

L'autoscaling horizontal sert à appliquer le principe suivant : en observant une certaine métrique (d'utilisation de mes services, entre autres), je veux pouvoir "scaler horizontalement" (déployer plus de pods) automatiquement si le besoin s'en fait ressentir; en retirer si ce n'est plus utile. Il est différent du scaling vertical, qui consisterait à adapter les ressources (CPU / RAM) allouées à chaque conteneur. Il existe même du scaling de nodes.

Il s'agit d'une boucle qui va tourner à intervalles réguliers (15 secondes par défaut); elle fait une requête sur l'utilisation de la ressource (deployment, statefulSet, etc). Le controleur va alors regarder la variable "scaleTargetRef" de la ressource, et sélectionner des pods selon leur ".spec.selector", et obtenir des métriques depuis des API de métriques. Pour la récupération des métriques, c'est un peu complexe; je vais me contenter de citer la doc:

The common use for HorizontalPodAutoscaler is to configure it to fetch metrics from aggregated APIs (metrics.k8s.io, custom.metrics.k8s.io, or external.metrics.k8s.io). The metrics.k8s.io API is usually provided by an add-on named Metrics Server, which needs to be launched separately. For more information about resource metrics, see Metrics Server.

L'algo utilisé de base par un HorizontalPodAutoscaler se base sur un ratio entre la métrique désirée et sa valeur actuelle:

desiredReplicas = ceil[currentReplicas * ( currentMetricValue / desiredMetricValue )]

Le principe est donc le suivant : on désigne une métrique (voir la section sur les métriques) ainsi qu'une valeur moyenne sur laquelle se baser : si on est au-dessus, on fait plus de pods; en-dessous, moins de pods...

Mise en place avec une resource metric

Nous allons ici prendre l'exemple tiré du tuto officiel : on va faire un HPA (*Horizontal Pod Autoupdater*) qui va scaler notre pod apache en fonction de la charge CPU. On a déjà un déploiement php-apache:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: php-apache
spec:
  selector:
    matchLabels:
      run: php-apache
  replicas: 1
  template:
    metadata:
      labels:
        run: php-apache
    spec:
      containers:
      - name: php-apache
        image: k8s.gcr.io/hpa-example
        ports:
        - containerPort: 80
        resources:
          limits:
            cpu: 500m
          requests:
            cpu: 200m
---
apiVersion: v1
kind: Service
metadata:
  name: php-apache
  labels:
    run: php-apache
spec:
  ports:
  - port: 8080
    targetPort: 80
    name: 'example-port'
  selector:
    run: php-apache
  type: LoadBalancer

On peut très facilement se baser sur le CPU, 50%, entre 1 et 10 pods:

kubectl autoscale deployment php-apache --cpu-percent=50 --min=1 --max=10

Et vérifier:

kubectl get hpa

Le tuto donne ensuite une commande sympa pour augmenter la charge:

kubectl run -i --tty load-generator --rm --image=busybox:1.28 --restart=Never -- /bin/sh -c "while sleep 0.01; do wget -q -O- http://<ip-publique>; done"

On voit bien le scaling fonctionner ainsi (le scaling peut prendre qq minutes, surtout en descente).

Et en chart ?

kubectl get hpa php-apache -o yaml > /tmp/hpa-v2.yaml

Nous donne :

kind: HorizontalPodAutoscaler
metadata:
  creationTimestamp: "2022-06-21T19:57:34Z"
  name: php-apache
  namespace: default
  resourceVersion: "1878"
  uid: df6fb1de-7b38-418d-90e2-c946fb6fdf7d
spec:
  maxReplicas: 10
  metrics:
  - resource:
      name: cpu
      target:
        averageUtilization: 50
        type: Utilization
    type: Resource
  minReplicas: 1
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: php-apache
status:
  conditions:
  - lastTransitionTime: "2022-06-21T20:05:19Z"
    message: recent recommendations were higher than current one, applying the highest
      recent recommendation
    reason: ScaleDownStabilized
    status: "True"
    type: AbleToScale
  - lastTransitionTime: "2022-06-21T20:05:49Z"
    message: the HPA was able to successfully calculate a replica count from cpu resource
      utilization (percentage of request)
    reason: ValidMetricFound
    status: "True"
    type: ScalingActive
  - lastTransitionTime: "2022-06-21T20:06:19Z"
    message: the desired count is within the acceptable range
    reason: DesiredWithinRange
    status: "False"
    type: ScalingLimited
  currentMetrics:
  - resource:
      current:
        averageUtilization: 0
        averageValue: 1m
      name: cpu
    type: Resource
  currentReplicas: 6
  desiredReplicas: 6
  lastScaleTime: "2022-06-21T20:06:34Z"

Pfiou... En rangeant / débroussaillant un peu on obtient un truc utilisable:

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: php-apache
spec:
  metrics:
  - resource:
      name: cpu
      target:
        averageUtilization: 50
        type: Utilization
    type: Resource
  minReplicas: 1
  maxReplicas: 10
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: php-apache

Ici, pour la partie "metrics", on constate:

  • que notre métrique de cpu fait appel à *ressource* parce qu'elle est issue de l'API de resource metrics.
  • qu'on en indique une "averageUtilization" : c'est un pourcentage. On peut aussi utiliser une donnée brute pour une métrique; on a d'ailleurs pas le choix quand c'est une métrique custom. Pour cela, on remplacerait averageUtilization par "averageValue", et le type deviendrait "AverageValue" au lieu de "Utilization". La RAM est mesurées en Mi, Gi, etc... et le cpu en "millicores" (millièmes de core", par exemple 100m.
  • que le nom n'est PAS au choix ! C'est le nom de la métrique récupérée : dans notre cas on a que "cpu" et "memory".

La partie "scaleTargetRef" sert à faire référence au déploiement sur lequel notre HPA s'applique.

Exemple en pourcentage de CPU et valeur brute de RAM:

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: php-apache
spec:
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        averageUtilization: 50
        type: Utilization
  - type: Resource
    resource:
      name: memory
      target:
        averageValue: 10Mi
        type: AverageValue
  minReplicas: 1
  maxReplicas: 10
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: php-apache

Metrics

Théorie vite fait

Le *metric registry* est le composant qui sert les métriques au sein du cluster. Par exemple, un HPA (Horizontal Pod Autoscaler) peut être configuré pour scaler les pods sur une métrique custom : il va alors interroger le metric registry. Il sera un des clients du registry. Le registry est composé de trois API:

  • Resource metrics : métriques d'utilisation de resources prédéfinies (RAM, CPU) des pods et nodes. Pas configurable.
  • Custom metrics : métriques custom associées à un objet kubernetes.
  • External metrics : métriques custom non associées à un objet k8s.

Ces 3 APIs sont des extensions, c'est-à-dire des ajouts en plus de l'API normale. Ces API sont le seul moyen d'exposer des métriques.

Comment exposer des métriques ?

En ajoutant des composants au cluster. Il faut donc un *metrics collector* qui récolte les données et les fournit à l'API de métriques. Plusieurs collectors existent en fonction de l'API:

  • Resources : il s'agit de cAdvisor, installé par défaut.
  • Custom / External : le choix le plus populaire est Prometheus. Datadog et Google StackDriver existent aussi. L'adapteur Prometheus est un serveur qui intègre Prometheus en tant que collector. Le principe est donc:
  • Installer un collector de métriques (Prometheus) et le configurer pour collecter les métriques désirées (en créant un exporter, je suppose).
  • Installer un serveur d'API de métriques (Prometheus Collector) et le configurer pour exposer les métriques correspondantes.

Top : utilisation de ressources

La commande kubectl top permet de constater les métriques renvoyées le registry dans ses resource metrics : de base, on a juste CPU et RAM. Deux versions sont dispo : k top nodes et k top pods (ou k est un alias pour "kubectl").

root@k8s1:~# k top node
NAME   CPU(cores)   CPU%   MEMORY(bytes)   MEMORY%   
k8s1   68m          3%     1289Mi          66%       
k8s2   8m           0%     754Mi           39%       
k8s3   13m          0%     903Mi           46%       

root@k8s1:~# k top pods
NAME                          CPU(cores)   MEMORY(bytes)   
php-apache-7656945b6b-kmkq6   1m           11Mi            
svclb-php-apache-46n6j        0m           0Mi             
svclb-php-apache-54slc        0m           0Mi             
svclb-php-apache-8f299        0m           0Mi

CertManager

CertManager sert à générer automatiquement les certificats de nos applications : il fonctionne en complément de l'Ingress Nginx.

Installation avec Helm:

    helm repo add jetstack https://charts.jetstack.io
    helm repo update
    helm upgrade --install cert-manager jetstack/cert-manager \
      --namespace cert-manager \
      --create-namespace \
      --set installCRDs=true
      --set 'extraArgs={--acme-http01-solver-nameservers=9.9.9.9:53\,1.1.1.1:53}'

NB : ici, je change de DNS parce que ma config a un bug avec le serveur CoreDNS intégré à k3s. woohoo

Ensuite il faut créer un issuer local/

issuer.yaml:

apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod
  namespace: cert-manager
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: bidule@mail.tld
    privateKeySecretRef:
      name: letsencrypt-prod
    solvers:
    - http01:
        ingress:
          class: nginx

Une fois fait, un ingress passerait de:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: mediawiki-ingr
spec:
  ingressClassName: nginx
  rules:
    - host: wiki.squi.fr
      http:
        paths:
          - pathType: Prefix
            backend:
              service:
                name: mediawiki
                port:
                  number: 80
            path: /

...à...

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: mediawiki-ingr
  annotations:
    kubernetes.io/ingress.class: "nginx"
    kubernetes.io/tls-acme: "true"
    cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
  tls:
    - secretName: gitea-tls
      hosts:
        - wiki.squi.fr
  ingressClassName: nginx
  rules:
    - host: wiki.squi.fr
      http:
        paths:
          - pathType: Prefix
            backend:
              service:
                name: mediawiki
                port:
                  number: 80
            path: /

Et voilà. L'ingress écoute alors sur port 443 en plus du port 80.

Upgrade automatisées de k3s

En quelle version sont mes nodes ?

C'est visible avec:

kubectl get nodes

Explications

Il est possible de mettre en place une upgrade automatisée de k3s. Cela passe par des "plans" : ce sont des objects spécifiques à k3s. Une fois en place, ceux-ci feront les upgrades régulières du cluster, node par node.

Avant de les mettre en place, ils nécessitent la création de certains éléments :

#system-upgrade-controller.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: system-upgrade
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: system-upgrade
  namespace: system-upgrade
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: system-upgrade
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: cluster-admin
subjects:
- kind: ServiceAccount
  name: system-upgrade
  namespace: system-upgrade
---
apiVersion: v1
data:
  SYSTEM_UPGRADE_CONTROLLER_DEBUG: "false"
  SYSTEM_UPGRADE_CONTROLLER_THREADS: "2"
  SYSTEM_UPGRADE_JOB_ACTIVE_DEADLINE_SECONDS: "900"
  SYSTEM_UPGRADE_JOB_BACKOFF_LIMIT: "99"
  SYSTEM_UPGRADE_JOB_IMAGE_PULL_POLICY: Always
  SYSTEM_UPGRADE_JOB_KUBECTL_IMAGE: rancher/kubectl:v1.21.9
  SYSTEM_UPGRADE_JOB_PRIVILEGED: "true"
  SYSTEM_UPGRADE_JOB_TTL_SECONDS_AFTER_FINISH: "900"
  SYSTEM_UPGRADE_PLAN_POLLING_INTERVAL: 15m
kind: ConfigMap
metadata:
  name: default-controller-env
  namespace: system-upgrade
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: system-upgrade-controller
  namespace: system-upgrade
spec:
  selector:
    matchLabels:
      upgrade.cattle.io/controller: system-upgrade-controller
  template:
    metadata:
      labels:
        upgrade.cattle.io/controller: system-upgrade-controller
    spec:
      affinity:
        nodeAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            nodeSelectorTerms:
            - matchExpressions:
              - key: node-role.kubernetes.io/master
                operator: Exists
      containers:
      - env:
        - name: SYSTEM_UPGRADE_CONTROLLER_NAME
          valueFrom:
            fieldRef:
              fieldPath: metadata.labels['upgrade.cattle.io/controller']
        - name: SYSTEM_UPGRADE_CONTROLLER_NAMESPACE
          valueFrom:
            fieldRef:
              fieldPath: metadata.namespace
        envFrom:
        - configMapRef:
            name: default-controller-env
        image: rancher/system-upgrade-controller:v0.9.1
        imagePullPolicy: IfNotPresent
        name: system-upgrade-controller
        volumeMounts:
        - mountPath: /etc/ssl
          name: etc-ssl
        - mountPath: /etc/pki
          name: etc-pki
        - mountPath: /etc/ca-certificates
          name: etc-ca-certificates
        - mountPath: /tmp
          name: tmp
      serviceAccountName: system-upgrade
      tolerations:
      - key: CriticalAddonsOnly
        operator: Exists
      - effect: NoSchedule
        key: node-role.kubernetes.io/master
        operator: Exists
      - effect: NoSchedule
        key: node-role.kubernetes.io/controlplane
        operator: Exists
      - effect: NoSchedule
        key: node-role.kubernetes.io/control-plane
        operator: Exists
      - effect: NoExecute
        key: node-role.kubernetes.io/etcd
        operator: Exists
      volumes:
      - hostPath:
          path: /etc/ssl
          type: Directory
        name: etc-ssl
      - hostPath:
          path: /etc/pki
          type: DirectoryOrCreate
        name: etc-pki
      - hostPath:
          path: /etc/ca-certificates
          type: DirectoryOrCreate
        name: etc-ca-certificates
      - emptyDir: {}
        name: tmp

Plusieurs éléments ici:

  • Un namespace dédié (system-upgrade)
  • Un ServiceAccount : tout comme un compte utilisateur, il s'agit d'un compte qui permettra à nos plans (des pods en réalité) de contacter le serveur d'API pour faire les mises à jour.
  • Un ClusterRoleBinding : ici, il s'agit de "RBAC" (Role-based access control); on donne au ServiceAccount les droits nécessaires.
  • Une ConfigMap : les configmaps servent simplement à donner des configs aux pods.
  • Un déploiement : enfin, on déploie les pods qui gèreront les upgrades. Voilà, la base est en place pour la création des plans.

Plan master

Le premier plan va être récupéré par les pods d'Upgrade pour mettre à jour les masters.

# Server plan
apiVersion: upgrade.cattle.io/v1
kind: Plan
metadata:
  name: server-plan
  namespace: system-upgrade
spec:
  concurrency: 1
  cordon: true
  nodeSelector:
    matchExpressions:
    - key: node-role.kubernetes.io/master
      operator: In
      values:
      - "true"
  serviceAccountName: system-upgrade
  upgrade:
    image: rancher/k3s-upgrade
  channel: stable

Plusieurs points:

  • Ce plan va matcher les nodes selon un label : "node-role.kubernetes.io/master". Les labels des nodes sont visibles avec: kubectl get nodes --show-labels
  • La ligne "concurrency" indique combien de nodes seront traités à la fois
  • La dernière ligne "channel: stable" indique que l'on cherche la dernière version stable de rancher. Il existe le channel "latest". On peut aussi remplacer "channel: stable" par une version: "version: v1.20" par exemple.

Plan agent

Même chose, mais pour les agents:

---
# Agent plan
apiVersion: upgrade.cattle.io/v1
kind: Plan
metadata:
  name: agent-plan
  namespace: system-upgrade
spec:
  concurrency: 1
  cordon: true
  nodeSelector:
    matchExpressions:
    - key: node-role.kubernetes.io/master
      operator: DoesNotExist
  prepare:
    args:
    - prepare
    - server-plan
    image: rancher/k3s-upgrade
  serviceAccountName: system-upgrade
  upgrade:
    image: rancher/k3s-upgrade
  version: stable

Utilisation

Il n'y a plus que mettre ces élements dans 3 fichiers différents (ou même un seul...) et à les appliquer. On peut par la suite voir les plans en place avec:

kubectl -n system-upgrade get plans

Et les maj en cours avec

kubectl -n system-upgrade get jobs

Après essai, ça n'as pas l'air de fonctionner chez moi :(

Upgrade manuelle de k3s

Il suffit de relancer l'install : cela prend par défaut la dernière version stable.

On peut aussi préciser le channel:

curl -sfL https://get.k3s.io | INSTALL_K3S_CHANNEL=latest sh -

Ou la version

curl -sfL https://get.k3s.io | INSTALL_K3S_VERSION=vX.Y.Z-rc1 sh -

Chaos-mesh

Sources:

Chaos-mesh est un utilitaire de "chaos engineering" pour k8s. Le chaos engineering est défini comme "Chaos Engineering is the discipline of experimenting on a system in order to build confidence in the system's capability to withstand turbulent conditions" : on teste les limites du système en lui imposant des failles et des erreurs pendant une durée déterminée.

Chaos-mesh est assez simple à utiliser, dispose d'une interface web sympathique et semble être une des applications les plus populaires en la matière. Le principal défaut que j'ai pu y voir pour l'instant est un certain manque de "polish" : les messages d'erreurs sont peu clairs ou contiennent parfois des typos, par exemple.

Installation

L'installation passe par helm. Il faut, avant d'installer, savoir quel "container runtime" (en français, je ne sais pas : "environnement d'exécution de conteneurs", disons ?) est utilisé par nos nodes. On peut le savoir avec un:

kubectl get nodes -o wide

Dans mon cas, avec mon k3s de base, il s'agit de containerd. Cela pourrait aussi être docker, par exemple.

Ensuite, l'installation passe par helm. La doc n'est pas ultime : il faut d'une part choisir le bon container runtime, et d'autre part on veut aussi le dashboard web. Donc, avec containerd dans mon cas:

helm install chaos-mesh chaos-mesh/chaos-mesh --namespace=chaos-testing --create-namespace --set dashboard.create=true --set chaosDaemon.socketPath=/run/containerd/containerd.sock
  • --namespace : les élements vont s'installer dans un namespace nommé "chaos-testing" que l'on créée (--create-namespace)
  • --set dashboard.create=true : on veut le dashboard
  • --set chaosDaemon.socketPath=/run/containerd/containerd.sock : mon container runtime est containerd.

Ne pas hésiter à voir la doc, cf les sources.

Ingress

Helm va créer un service associé au dashboard, que l'on peut voir et décrire:

k get svc -n chaos-testing
k describe svc chaos-dashboard -n chaos-testing

Je vais lui associer l'ingress suivant pour pouvoir y accéder facilement:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: chaos-ingr
  annotations:
    nginx.ingress.kubernetes.io/proxy-body-size: 500m
spec:
  ingressClassName: nginx
  rules:
    - host: chaos.sq.lan
      http:
        paths:
          - pathType: Prefix
            backend:
              service:
                name: chaos-dashboard
                port:
                  number: 2333
            path: /

Le dashboard devrait être accessible.

Token

En arrivant sur le dashboard, on nous demande un token. Le dashboard propose des instructions pour l'obtenir (création d'un rbac, etc); dans mon cas, ça ne fonctionne pas. Peut-être parce que j'utilise le dashboard k8s qui fonctionne sur le même principe. Dans mon cas, j'ai simplement utilisé le token associé au dashboard k8s. Je ne rentre pas dans les détails.

Utilisation

A compléter / cf https://chaos-mesh.org/docs/run-a-chaos-experiment/