Cloud Intelligence™Cloud Intelligence™

Cloud Intelligence™

Validating Admission Policies sur Kubernetes : cas d'usage avancés

By Eyal ZekariaJun 5, 20238 min read

Cette page est également disponible en English, Deutsch, Español, Italiano, 日本語 et Português.

Dans notre précédent article sur le sujet, nous avons abordé les défis liés à la mise en place du Dynamic Admission Control (DAC) dans un cluster Kubernetes via les Validating Admission Webhooks (VAW). Nous avons présenté la nouvelle ressource Validation Admission Policy (VAP) et illustré sa simplicité à travers quelques cas d'usage basiques.

Aujourd'hui, place à deux fonctionnalités plus avancées des VAP :

  1. La correspondance et le filtrage des ressources
  2. Les paramètres dans les politiques

Pour finir, nous évoquerons brièvement les critères de choix entre VAP et VAW, ainsi que quelques conseils de migration.

Tous les exemples du précédent article et de celui-ci sont disponibles dans un dépôt GitHub créé à cet effet.

Correspondance et filtrage des ressources

Dans le cas d'usage présenté précédemment, nous avons vu comment appliquer une politique à un seul type de ressource (Deployment), ce qui est pertinent pour des validations spécifiques à une ressource.

Mais comment faire pour appliquer une politique plus générique à l'ensemble du cluster, par exemple imposer une convention de nommage ?

Plusieurs options s'offrent à nous. La première, explicite, consiste à créer une règle de correspondance pour chaque type de ressource à valider :

apiVersion: admissionregistration.k8s.io/v1alpha1
kind: ValidatingAdmissionPolicy
metadata:
  name: "demo-policy.example.com"
spec:
  failurePolicy: Fail
  matchConstraints:
    resourceRules:
      - apiGroups:   ["apps"]
        apiVersions: ["v1"]
        operations:  ["CREATE", "UPDATE"]
        resources:   ["deployments", "statefulsets"]
      - apiGroups:   ["autoscaling"]
        apiVersions: ["v2"]
        operations:  ["CREATE", "UPDATE"]
        resources:   ["horizontalpodautoscalers"]
  validations:
    - expression: "!object.metadata.name.contains('demo') || object.metadata.namespace == 'demo'"

On peut bien sûr réutiliser la même règle pour des ressources d'un même groupe/version d'API, mais cette liste s'allonge très vite et devra être maintenue (notamment lors de l'ajout de nouveaux types de ressources dans le cluster).

L'autre option consiste à faire correspondre toutes les ressources, puis à exclure sélectivement celles que l'on ne souhaite pas valider. Le filtrage des ressources s'effectue à deux niveaux :

  1. au sein de la ressource ValidatingAdmissionPolicy — via .spec.matchConditions
  2. au sein de la ressource ValidatingAdmissionPolicyBinding — via .spec.matchResources.excludeResources

La première approche est documentée, contrairement à la seconde. .spec.matchConditions s'appuie sur le Common Expression Language (CEL) pour filtrer les requêtes déjà capturées par les règles (autrement dit, après le binding et les contraintes de correspondance de la politique).

Les exclusions de ressources (.spec.matchResources.excludeResources), quant à elles, s'appliquent avant même que la ressource n'atteigne la politique et reposent sur la même syntaxe que celle utilisée pour la correspondance, afin de constituer une liste d'exclusion.

Voici un exemple de politique qui ignore les ressources Lease du groupe d'API coordination.k8s.io ainsi que toute ressource du groupe d'API rbac.authorization.k8s.io, en s'appuyant sur des conditions de correspondance CEL :

apiVersion: admissionregistration.k8s.io/v1alpha1
kind: ValidatingAdmissionPolicy
metadata:
  name: "demo-policy.example.com"
spec:
  failurePolicy: Fail
  matchConstraints:
    resourceRules:
      - apiGroups:   ["*"]
        apiVersions: ["*"]
        operations:  ["CREATE", "UPDATE"]
        resources:   ["*"]
  matchConditions:
    - name: 'exclude-leases' # Each match condition must have a unique name
      expression: '!(request.resource.group == "coordination.k8s.io" && request.resource.resource == "leases")' # Match non-lease resources.
    - name: 'rbac' # Skip RBAC requests.
      expression: 'request.resource.group != "rbac.authorization.k8s.io"'
  validations:
    - expression: "!object.metadata.name.contains('demo') || object.metadata.namespace == 'demo'"

Et voici un binding qui filtre des requêtes similaires à l'aide d'exclusions de correspondance :

apiVersion: admissionregistration.k8s.io/v1alpha1
kind: ValidatingAdmissionPolicyBinding
metadata:
  name: "demo-binding-test.example.com"
spec:
  policyName: "demo-policy.example.com"
  validationActions: [Deny]
  matchResources:
    excludeResourceRules:
      - apiGroups:   ["rbac.authorization.k8s.io"]
        apiVersions: ["*"]
        operations:  ["*"]
        resources:   ["*"]
      - apiGroups:   ["coordination.k8s.io"]
        apiVersions: ["*"]
        operations:  ["*"]
        resources:   ["leases"]
    namespaceSelector:
      matchLabels:
        environment: demo

Les deux approches aboutissant pratiquement au même résultat, je recommanderais d'opter pour excludeResourceRules dès que possible : le filtrage intervient plus tôt et évite l'exécution inutile d'expressions CEL (nous avons abordé le coût d'exécution de CEL au sein du serveur d'API Kubernetes dans l'article précédent). Par ailleurs, le fait de réutiliser la syntaxe de correspondance de ressources rend cette approche, à mon sens, un peu plus claire et plus lisible.

Cela dit, comme matchConditions repose sur des expressions CEL pour le filtrage et a accès aux mêmes objets que les expressions de validation (object, oldObject, request et params), il permet aussi de filtrer sur des éléments inaccessibles via excludeResourceRules, comme les utilisateurs/groupes ou tout autre attribut des objets disponibles :

apiVersion: admissionregistration.k8s.io/v1alpha1
kind: ValidatingAdmissionPolicy
metadata:
 name: "demo-policy.example.com"
spec:
  failurePolicy: Fail
  matchConstraints:
    resourceRules:
      - apiGroups:   ["*"]
        apiVersions: ["*"]
        operations:  ["CREATE", "UPDATE"]
        resources:   ["*"]
  matchConditions:
    - name: 'exclude-kubelet-requests'
      expression: '!("system:nodes" in request.userInfo.groups)' # Allow requests made by non-node users.
  validations:
    - expression: 'object.metadata.name.startsWith("demo-")'

En résumé, je recommanderais une approche combinée : excludeResourceRules pour exclure des ressources spécifiques (par groupe d'API, version, opération, type de ressource) et matchConditions pour un filtrage plus avancé (requêtes provenant de certains utilisateurs ou groupes, etc.).

Paramétrer les politiques

Pour rendre les politiques encore plus puissantes, vous pouvez les paramétrer. Vous réutilisez ainsi une même politique avec différentes valeurs de paramètres, sans avoir à la redéfinir pour chaque variation. La configuration est ainsi dissociée de la politique elle-même.

L'utilisation de paramètres impose à l'opérateur du cluster de créer une CustomResourceDefinition (CRD) pour les définir et les héberger. Cela représente un effort supplémentaire, mais leur usage reste optionnel : à vous de voir.

Voici un exemple simple de CRD, facilement modifiable ou extensible si besoin :

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: parameters.example.com
spec:
  group: example.com
  names:
    kind: Parameters
    plural: parameters
  versions:
  - name: v1
    schema:
      openAPIV3Schema:
        type: object
        properties:
          maxReplicas:
            type: integer
    served: true
    storage: true
  scope: Cluster

Cette CRD nous permet de créer des ressources de type Parameters avec un seul champ personnalisé pris en charge dans le schéma, maxReplicas de type entier :

apiVersion: example.com/v1
kind: Parameters
metadata:
  name: demo-parameters
maxReplicas: 3

Une fois cela en place, on configure la politique pour qu'elle accepte des paramètres (.spec.paramKind) en la pointant vers la version d'API et le kind appropriés (tels que définis dans la CRD), avant de les utiliser dans les expressions de validation :

apiVersion: admissionregistration.k8s.io/v1alpha1
kind: ValidatingAdmissionPolicy
metadata:
  name: "demo-policy.example.com"
spec:
  failurePolicy: Fail
  paramKind:
    apiVersion: example.com/v1
    kind: Parameters
  matchConstraints:
    resourceRules:
      - apiGroups:   ["apps"]
        apiVersions: ["v1"]
        operations:  ["CREATE", "UPDATE"]
        resources:   ["deployments"]
  validations:
    - expression: "object.spec.replicas <= params.maxReplicas"
      messageExpression: "'Deployments cannot have more than ' + string(params.maxReplicas) + ' replicas, this one has ' + string(object.spec.replicas)"

Notez que messageExpression est lui aussi adapté pour exploiter le paramètre, ce qui évite de coder en dur des valeurs où que ce soit dans la politique.

Enfin, on met à jour le binding pour référencer par son nom la ressource Parameters que nous avons créée :

apiVersion: admissionregistration.k8s.io/v1alpha1
kind: ValidatingAdmissionPolicyBinding
metadata:
  name: "demo-binding-test.example.com"
spec:
  policyName: "demo-policy.example.com"
  validationActions: [Deny]
  paramRef:
    name: "demo-parameters"
  matchResources:
    namespaceSelector:
      matchLabels:
        environment: demo

Une fois le tout appliqué au cluster, toute tentative de création d'un deployment dépassant le nombre de replicas configuré échoue, comme prévu :

$ k create deployment nginx — image=nginx — replicas=6
error: failed to create deployment: deployments.apps "nginx" is forbidden: ValidatingAdmissionPolicy 'demo-policy.example.com' with binding 'demo-binding-test.example.com' denied request: Deployments cannot have more than 3 replicas, this one has 6

On peut ainsi créer différentes ressources Parameters et les associer à différentes ressources/namespaces, afin de réutiliser nos politiques paramétrées.

VAW ou VAP : que choisir ?

Les VAP étant bien plus simples à mettre en œuvre que les VAW, elles finiront probablement par les remplacer, ou du moins par s'imposer comme le point de départ standard pour la validation in-cluster sur Kubernetes.

La prise en charge de CEL dans Kubernetes a ouvert la voie à de nombreuses améliorations de confort côté opérateur de cluster. La Mutating Admission Policy est un autre exemple de fonctionnalité qui pourrait voir le jour grâce à CEL. On peut raisonnablement parier que les VAP continueront de bénéficier d'une attention soutenue et d'évolutions jusqu'à atteindre leur maturité et leur disponibilité générale.

Cela étant dit, les VAP ne remplacent pas encore totalement les VAW. Avec ces dernières, vous pouvez implémenter toute logique de validation traduisible en code, qu'il s'agisse d'une simple vérification d'attribut de requête ou du contrôle de l'état d'un système externe avant d'admettre une requête. Devoir écrire vous-même le serveur de validation vous laisse également libre du choix de la technologie et du langage : vous n'êtes pas cantonné à CEL.

Enfin, les VAP étant encore en phase Alpha (depuis la v1.26), elles ne constituent pas encore une alternative viable pour qui exploite les offres Kubernetes managées des grands fournisseurs cloud. EKS et AKS ne prennent pas en charge les fonctionnalités alpha, tandis que GKE le permet, mais uniquement à des fins de test (les clusters alpha expirent au bout de 30 jours). Autrement dit, même si c'était techniquement faisable, mieux vaut ne pas faire des VAP l'unique mécanisme de validation in-cluster de votre organisation pour l'instant.

Conseils pour migrer des VAW vers les VAP

Vous lisez ces lignes alors que les VAP sont déjà en bêta ou en GA, et vous envisagez de les adopter à la place de votre solution actuelle ? Bonne nouvelle : ces deux fonctionnalités ne sont pas mutuellement exclusives. Vous pouvez donc les exécuter en parallèle pendant la phase de transition.

Comme les VAW et les VAP gèrent la correspondance des ressources de façon similaire, créer des VAP devrait être assez simple. Quelques conseils pour bien démarrer :

  • Créez une validatingAdmissionPolicy dont les .spec.matchConstraints.resourceRules reprennent les .webhooks.rules de votre ressource ValidatingWebhookConfiguration.
  • Migrez vos expressions de validation depuis le langage actuel (probablement Rego) vers CEL. C'est de loin la tâche la plus chronophage, mais essayez ChatGPT à cette fin : les règles de politique sont en général suffisamment simples pour qu'il s'en sorte plutôt bien.
  • Créez un ValidatingAdmissionPolicyBinding et positionnez .spec.validationActions sur Warn, afin de remonter les erreurs au client sans bloquer les requêtes API.
  • Décidez comment associer une politique aux ressources via .spec.matchResources. Cela peut être un simple sélecteur de label de namespace, ou une match expression plus avancée. Étiquetez vos namespaces en conséquence.
  • Tentez de créer des ressources valides et invalides dans les namespaces gérés, analysez les avertissements renvoyés (ou absents) et affinez vos expressions au fil de l'eau.
  • Une fois la politique éprouvée, passez le .spec.ValidationActions du binding à Deny pour la rendre pleinement effective.
  • Supprimez toutes les politiques redondantes du système hérité.

Ressources complémentaires