Kubernetes, vous trouvez ça génial, mais rien ne vous empêche d’exécuter des commandes qui vont casser votre beau jouet. Alors, comment réduire ces risques ? En implémentant un « Admission Controller » !
Pour gérer les clusters Kubernetes, nous utilisons principalement la commande kubectl. Or, un jour, un collègue a voulu détruire tous les Pods existants en exécutant la commande « kubectl delete po --all ». Seulement, il a fait une coquille et a exécuté la commande « kubectl delete no --all » ! Voyez-vous la différence ? Il a répondu « yes » à la question posée et a donc détruit tous les Nodes au lieu de détruire tous les Pods ! Vent de panique, tout est en rade... Que faire ? Pas le choix, il faut appliquer la procédure de restauration du cluster etcd et espérer qu’elle fonctionne !
Le calme revient et une réflexion s’impose : que pourrait-on faire pour éviter que cette mauvaise expérience ne se reproduise ? L’architecture de Kubernetes nous propose une solution plutôt simple : écrivons un Admission Controller qui va interdire les destructions d’objets jugés sensibles !
1. Kubernetes et les Admission Controllers
1.1 Petit rappel sur l’architecture de Kubernetes
La figure 1 ci-dessous rappelle les étapes qui interviennent lorsqu’on soumet une requête à l’Apiserver de Kubernetes :
Après une étape d’authentification, puis de vérification des autorisations, la requête est passée à un ensemble de Controllers dits Admission Controllers dont le rôle est double : soit ils décident de modifier la requête originale et on parle alors de Mutating Admission Controllers, soit ils décident de rejeter la requête et on parle alors de Validating Admission Controllers. Un Admission Controller peut remplir les deux rôles.
Certains Admission Controllers sont compilés dans le code de l’Apiserver (c’est le cas de LimitRanger, ResourceQuota, DefaultStorageClass, etc.). Dans le cas contraire, les Admission Controllers sont des programmes externes qui proposent un service HTTP/S appelé par le MutatingAdmissionWebhook Controller ou le ValidatingAdmissionWebhook Controller. Ces programmes externes sont alors appelés des Admission Webhooks.
1.2 Notre objectif et ses composants
Nous allons donc développer un Validating Admission Webhook qui va rejeter les requêtes de destruction (DELETE) des objets sensibles comme, par exemple, les Nodes, les Secrets ou bien certains Pods. La figure 2 décrit les objets que nous allons devoir définir ainsi que leurs relations.
Pour des raisons évidentes de haute disponibilité et de facilité d’exploitation, notre Admission Webhook sera « containerisé » et démarré dans un Pod contrôlé indirectement par un Deployment (nous disons « indirectement », car rappelez-vous qu’un Deployment contrôle un ReplicaSet qui contrôle un ou plusieurs Pod !).
Notre Admission Webhook aura besoin d’être configuré, en particulier il faudra lui donner les spécifications des règles à appliquer pour autoriser ou interdire les demandes de destruction. Il faudra que la configuration puisse être communiquée lors du lancement du Pod et non pas « codée en dur » dans l’image du Container. Plusieurs solutions sont disponibles : nous pourrions faire en sorte que notre Admission Webhook gère ses propres types d’objets, et nous avons déjà écrit un article à ce sujet [2]. Nous allons faire plus simple ici : les règles de configuration seront stockées dans une ConfigMap qui sera montée sous forme de Volume, dans le Container (voir §3.2).
Pour finir, comme notre Webhook communique via HTTPS, il aura besoin d’une clé privée et d’un certificat. Ces informations lui seront transmises sous forme de Secret (voir §3.2).
2. Implémentation de l’Admission Webhook
2.1 Squelette du service web
Comme un Admission Webhook propose avant tout un service HTTP/S qui sera appelé par l’Apiserver, il faut écrire… une application web ! Le langage de prédilection dans le monde des Containers est le langage Go et nous allons l’utiliser ici, mais son usage n’est absolument pas une obligation (d’ailleurs, l’article précité [2] montre une réalisation de Custom Controller en Python).
Notre 1re étape consiste à écrire le corps d’un service web, ce qui donne le code suivant (oui, ce code ne « compile » pas encore et comporte des importations inutiles pour l’instant !) :
Il nous faut compléter ce squelette de code !
2.2 Génération et intégration des clés et certificats
Comme vous pouvez le lire, le service web doit utiliser HTTPS. Dans ce cas, l’Apiserver va contacter notre Admission Webhook et va vouloir vérifier la validité de son certificat. Il faut donc créer une paire de clés publiques/privées et un certificat signé par une autorité de certification de confiance (nous-mêmes), puis il faut donner à notre Admission Webhook le nom des fichiers contenant la clé privée et le certificat.
Le dépôt du projet [1] contient le script generate-certs.sh qui peut vous servir pour créer une clé privée et un certificat pour une AC ainsi qu’une clé privée et un certificat pour notre Webhook. Les fichiers sont créés dans le sous-répertoire ./certs, comme le montre la commande ci-dessous :
Le certificat est créé pour le service k8s-delete-protection.kube-system.svc, mais vous pouvez éditer le script shell pour modifier cette valeur via les variables service et namespace situées au début du script.
Nous ajoutons à notre programme des options pour que les noms des fichiers contenant le certificat et la clé privée puissent être passés en paramètre. On en profite aussi pour paramétrer la valeur du port TCP sur lequel va écouter le service. Le code devient :
Compilons notre service web et testons-le :
Dans une autre fenêtre, testons l’accès au service web :
Aucun page n’est disponible, mais au moins, notre Webhook répond !
2.3 Gestion des requêtes
Notre service est prêt à l’emploi, mais il ne supporte aucune requête. Nous devons choisir la valeur de l’URL qui sera appelée par l’Apiserver pour valider les ordres de destruction des objets. Nous appellerons arbitrairement /validate cette URL. Nous devons aussi connecter cette URL à la fonction de traitement de la requête. Le code précédent :
devient :
Le code de la fonction handleAdmissionRequest est contenu dans le fichier server.go. La première chose à faire est de convertir le contenu de la requête HTTP reçue en une structure AdmissionReview :
La variable request est une structure de type AdmissionReview et son champ Request contient des informations utiles pour notre Admission Webhook [3] :
- request.Request.Operation indique le type de l’opération demandée (CREATE, UPDATE, DELETE…) ;
- request.request.Kind.Kind donne le type d’objet concerné (Scale, Pod, Deployment, Node…) ;
- request.Request.Name est le nom de l’objet impacté par l’opération ;
- request.Request.Namespace donne le Namespace auquel appartient la ressource impactée.
Nous invoquons maintenant le « cerveau » de notre Webhook, à savoir la fonction checkRequest() qui doit décider du sort de l’opération : autorisée ou interdite ?
La fonction checkRequest() retourne un booléen qui autorise ou non l’opération. Dans le cas d’un refus (false), elle retourne aussi une error contenant un message qui sera affiché sur le terminal de l’utilisateur. Muni de ces informations, nous pouvons construire un objet de type AdmissionReview et le retourner à l’Apiserver.
La structure AdmissionReview contient certains éléments issus de l’objet request ainsi que notre réponse (structure AdmissionResponse) qui contient l’UID de la requête et le résultat de la fonction checkRequest().
Nous pouvons compléter maintenant le code de la fonction handleAdmissionRequest() :
2.4 Gestion des règles
2.4.1 Exemple de règles
La fonction checkRequest() doit donc prendre une décision : faut-il ou non autoriser l’opération ? Comme nous ne voulons pas coder « en dur » une règle comme « il est interdit de détruire les Nodes qui ne possèdent pas le label can-be-deleted=true », nous souhaitons externaliser les règles pour les fournir à notre Webhook dynamiquement lors de son lancement.
Nous proposons de définir les règles au format YAML. Il y aura 2 types de règles :
- les must rules qui définiront les objets qui devront posséder un certain label pour pouvoir être détruits ;
- les must-not rules qui définiront les objets qui ne devront pas posséder un certain label pour pouvoir être détruits.
Prenons un exemple de must rules :
La première règle interdira la destruction de Pod et Deployment du Namespace default s’il ne possède pas le label allowed-for-deletion.
La deuxième règle interdira la destruction de tout Node ne possédant pas ce même label.
Prenons un exemple de must-not rules :
Cette règle unique interdira la destruction de tout objet, quel que soit son Namespace, s’il possède le label protected-against-deletion.
2.4.2 Chargement des règles
Pour charger les fichiers de règles, on ajoute le code suivant dans la fonction main() :
Il faut lire le contenu des fichiers de règles écrites en YAML et les transformer en structure de données. Cette opération est réalisée par la fonction load_rules_file() définie dans le fichier rules.go :
2.4.3 Application des règles
À cette étape, nous avons les variables must_rules et must_not_rules qui contiennent les règles à appliquer par la fonction checkRequest().
Le code n’est pas très complexe. Tout d’abord, on élimine les requêtes qui ne sont pas des DELETE d’objets. Ensuite, on applique les must rules : si une règle interdit la destruction, on le signale à l’Apiserver. Puis, on fait de même avec les must-not rules.
La fonction doesRuleApply() retourne un booléen qui indique si la règle passée en paramètre s’applique à la requête de l’Apiserver. Pour cela, on compare les Namespaces et les types des objets (Kinds) :
Si une règle s’applique à une requête de l’Apiserver, la fonction checkRequest() doit obtenir les labels de l’objet examiné en appelant la fonction getObjectLabels() :
Notons que cette fonction obtient les labels de tout type d’objets grâce à une conversion générique des metadata de l’objet. En effet, il aurait été beaucoup trop fastidieux de traiter spécifiquement chaque type d’objets (les Pods, les Deployments, les StatefulSets, etc.).
3. Intégration dans le cluster Kubernetes
3.1 Création de l’image du Container
Comme notre Admission Webhook est un processus, il semble évident de l’encapsuler dans un Container pour le lancer en tant que Pod !
Le Dockerfile ci-dessous va compiler notre programme et copier le binaire obtenu sous /go/bin/k8s-delete-protection. Les paramètres nécessaires au lancement seront définis dans la spécification du Pod.
Les commandes de build et push utilisées sont les suivantes :
3.2 Configuration de l’Admission Webhook
Au paragraphe 2.4, nous avons expliqué que les règles à appliquer seraient fournies au processus sous forme de fichiers. Kubernetes propose une solution idéale pour cela : les ConfigMaps ! Nous allons donc créer une ConfigMap contenant 2 clés. Une clé must et une clé must-not. Leur contenu sera l’équivalent des fichiers YAML présentés précédemment :
Nous pouvons créer cette ConfigMap :
De la même manière, nous devons passer à notre programme sa clé privée et son certificat. Comme ce sont des données sensibles, nous allons les embarquer dans un Secret :
Attention, remplacez les valeurs de server-key.pem et server.pem par le résultat de l’exécution de la commande qui est indiquée !
Nous créons le Secret ainsi :
3.3 Déploiement de l’Admission Webhook
Notre Admission Webhook est paré pour son activation ! Nous définissons un objet de type Deployment pour assurer sa surveillance.
Remarquez comment nous avons « mappé » la ConfigMap sur le répertoire /config et le Secret sur le répertoire /certs. Créons le Deployment :
3.4 Configuration de l’Apiserver
Il nous reste un dernier lien à définir : le lien entre l’Apiserver et notre Admission Webhook !
Comme l’Apiserver doit invoquer un Service, il faut commencer par en définir un. Ce sera un service de type ClusterIP qui va rediriger le port 443/TCP sur le port 8443/TCP d’un des Pods qui encapsule notre Webhook (dans notre cas, le Deployment n’a lancé qu’un seul Pod, mais on aurait pu appliquer un facteur de réplication).
Créons ce Service :
Ultime étape d’intégration, il faut créer un objet de type ValidatingWebhookConfiguration pour configurer l’Apiserver :
Que nous dit ce fichier ? Tout d’abord, notre Webhook est joignable via le nom de service k8s-delete-protection qui fait partie du Namespace kube-system. On retrouve aussi le nom de l’URL à invoquer : /validate.
Il faut aussi donner la valeur du certificat de l’autorité qui a signé le certificat qui sera présenté par notre Webhook. Pensez à remplacer la valeur de la clé caBundle par le contenu du certificat de l’AC !
Pour des raisons d’optimisation, nous ne voulons pas que l’Apiserver appelle systématiquement notre Webhook, nous pouvons pour cela filtrer les appels via la définition des rules.
Dans notre cas, nous ne voulons recevoir que les AdmissionReview concernant les destructions de Nodes et de Pods (ce sont des objets gérés par l’API core, donc on spécifie une chaîne vide dans la clé apiGroups) et nous voulons recevoir les demandes de destruction des Deployments (objets gérés par l’API apps).
Pour être complet, indiquons que le paramètre failurePolicy a pour valeurs possibles Fail ou Ignore. Il indique à l’Apiserver comment se comporter en cas d’erreur inconnue ou de timeout retourné par notre Webhook. Le paramètre sideEffects a pour valeur None, ce qui indique que notre Webhook n’impacte que l’objet indiqué par la structure AdmissionReview.
Appliquons la définition de notre WebHook :
4. Tests & exemples d’utilisation
Si les règles que nous avons appliquées sont bien prises en compte, alors nous pouvons créer un Pod dans le Namespace default et nous ne devrions pas être capables de le détruire… facilement :
Bingo ! Ça marche. Vérifions qu’en ajoutant le label, on peut détruire le Pod :
Puisque le test est concluant, essayons de détruire un Node !
Notre but initial est atteint, personne ne pourra plus détruire un Node par erreur !
Les règles de type must-not permettent de protéger tout objet qui possède le label protected-against-deletion. Mais attention ! Rappelez-vous que nous avons configuré l’Apiserver pour qu’il ne contacte notre Webhook que pour la destruction des Pods, des Nodes et des Deployments ! Nous allons donc protéger contre la destruction un Deployment qui ne fait pas partie du Namespace default. Dans notre cas, c’est le metrics-server :
Conclusion
Êtes-vous maintenant convaincu qu’étendre Kubernetes à l’aide de nouveaux Admissions Controllers n’est pas une tâche difficile ? Si vous consultez le code (disponible sur GitHub [1]), vous constaterez que nous y avons inclus une Liveness Probe (/health/liveness) qui pourra être invoquée par kubelet pour relancer le Controller en cas de dysfonctionnement. Notez aussi que si vous souhaitez modifier les règles, vous devrez modifier la ConfigMap et relancer de nouveaux Pods. En effet, notre Controller ne gère pas dynamiquement les modifications apportées à la ConfigMap... mais il existe des Custom Controllers qui rendent cela possible !
Références
[1] Dépôt GitHub du projet : https://github.com/majeinfo/k8s-delete-protection
[2] Lire à ce sujet l’article « Codez un Custom Controller pour Kubernetes » paru dans GNU/Linux Magazine n°243 : https://connect.ed-diamond.com/GNU-Linux-Magazine/glmf-243/creer-un-custom-resource-controller-pour-kubernetes
[3] Documentation du Webhook de validation :
https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/