Cloud Intelligence™Cloud Intelligence™

Cloud Intelligence™

Validating Admission Policies in Kubernetes: casi d'uso avanzati

By Eyal ZekariaJun 5, 20238 min read

Questa pagina è disponibile anche in English, Deutsch, Español, Français, 日本語 e Português.

Nel precedente articolo dedicato all'argomento abbiamo affrontato le criticità legate all'implementazione del Dynamic Admission Control (DAC) in un cluster Kubernetes tramite i Validating Admission Webhooks (VAW). Abbiamo presentato la nuova risorsa Validation Admission Policy (VAP) e illustrato alcuni semplici casi d'uso per metterne in evidenza la semplicità.

Oggi ci concentreremo su un paio di funzionalità più avanzate dei VAP:

  1. Resource matching e filtering
  2. Parametri nelle policy

Per concludere, vedremo brevemente quando conviene scegliere i VAP rispetto ai VAW e qualche consiglio utile per la migrazione.

Tutti gli esempi dell'articolo precedente e di questo sono disponibili in un repository GitHub creato appositamente.

Resource matching e filtering

Nel caso d'uso illustrato nell'articolo precedente abbiamo visto come applicare una policy a un solo tipo di risorsa (Deployment): un approccio sensato per validazioni specifiche di una singola risorsa.

Ma se invece volessimo applicare una policy più trasversale al cluster, ad esempio per imporre una convenzione di denominazione?

In questo caso le opzioni sono diverse. La prima è quella esplicita: creare una regola di matching per ciascun tipo di risorsa che si vuole validare:

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'"

Certo, si può riutilizzare la stessa regola per risorse appartenenti allo stesso gruppo/versione di API, ma l'elenco rischia di allungarsi rapidamente e di richiedere manutenzione costante (soprattutto quando si introducono nuovi tipi di risorse nel cluster).

L'alternativa è fare il match di tutte le risorse ed escludere selettivamente quelle che non si vogliono validare. Il filtraggio può avvenire su due livelli:

  1. all'interno della risorsa ValidatingAdmissionPolicy — tramite .spec.matchConditions
  2. all'interno della risorsa ValidatingAdmissionPolicyBinding — tramite .spec.matchResources.excludeResources

Il primo approccio è documentato, il secondo molto meno. .spec.matchConditions sfrutta il Common Expression Language (CEL) per filtrare le richieste già intercettate dalle regole (cioè quelle che hanno superato il binding e i match constraints della policy).

Le esclusioni di risorse (.spec.matchResources.excludeResources), invece, vengono applicate prima ancora che la richiesta arrivi alla policy e usano la stessa sintassi del matching per definire una lista di esclusione.

Ecco un esempio di policy che ignora le risorse Lease del gruppo API coordination.k8s.io e qualsiasi risorsa del gruppo API rbac.authorization.k8s.io tramite match conditions in 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'"

E qui di seguito un binding che filtra richieste analoghe usando le match exclusions:

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

Visto che i due approcci portano sostanzialmente allo stesso risultato, quando possibile consiglio excludeResourceRules: il filtraggio avviene prima ed evita di valutare inutilmente espressioni CEL (nel post precedente abbiamo parlato del cost budget legato all'esecuzione di CEL all'interno dell'API server di Kubernetes). In più, usando la stessa sintassi del matching delle risorse, risulta a mio avviso anche più chiaro e immediato da leggere.

Detto ciò, dato che matchConditions usa espressioni CEL per il filtraggio e ha accesso agli stessi oggetti delle espressioni di validazione (object, oldObject, request e params), permette di filtrare anche su criteri che excludeResourceRules non copre, come utenti/gruppi o qualunque altro attributo degli oggetti disponibili:

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-")'

In sintesi, l'ideale è un approccio combinato: excludeResourceRules per filtrare risorse specifiche (per gruppo API, versione, operazione, tipo di risorsa) e matchConditions quando serve un filtraggio più sofisticato (richieste da utenti specifici, gruppi e così via).

Parametrizzare le policy

Per rendere le policy ancora più potenti è possibile parametrizzarle. In questo modo si può riutilizzare la stessa policy con valori di parametri diversi, senza doverla ridefinire per ogni variante. In pratica, si separa la configurazione dalla policy vera e propria.

Per usare i parametri, l'operatore del cluster deve creare una CustomResourceDefinition (CRD) per definirli e contenerli. Significa qualche passaggio in più, ma il loro utilizzo non è obbligatorio: la scelta resta a chi implementa la policy.

Ecco un semplice esempio di CRD, facilmente modificabile o estendibile all'occorrenza:

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

Il CRD qui sopra ci consente di creare risorse di tipo Parameters con un solo campo personalizzato definito nello schema, maxReplicas di tipo intero:

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

A questo punto configuriamo la policy in modo che accetti parametri (.spec.paramKind), facendola puntare alla versione e al kind dell'API corretti (definiti nel nostro CRD), per poi utilizzarli nelle espressioni di validazione:

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)"

Da notare che anche messageExpression è stato aggiornato per fare riferimento al parametro, evitando così di scrivere valori in modo statico in qualunque punto della policy.

Per finire, aggiorniamo il binding affinché faccia riferimento per nome alla risorsa Parameters appena creata:

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

Una volta applicato tutto al cluster, il tentativo di creare un deployment con un numero di repliche superiore a quello configurato fallirà come previsto:

$ 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

Così facendo possiamo creare diverse risorse Parameters e associarle a risorse o namespace differenti, riutilizzando le nostre policy parametrizzate.

VAW o VAP, quale scegliere

Visto che i VAP sono molto più semplici da implementare rispetto ai VAW, è plausibile che finiscano per sostituirli o, quanto meno, che diventino lo standard di riferimento per chi inizia a fare validazione in-cluster su Kubernetes.

Il supporto a CEL in Kubernetes ha aperto numerose strade verso miglioramenti significativi della quality of life dal punto di vista dell'operatore del cluster. Una Mutating Admission Policy è un altro esempio di funzionalità che potrebbe diventare realtà proprio grazie a CEL. È ragionevole aspettarsi che i VAP continuino a ricevere attenzione e investimenti in sviluppo fino a raggiungere maturità e disponibilità generale.

Detto questo, i VAP non sono ancora un sostituto a tutti gli effetti dei VAW. Con i VAW si può ancora implementare qualunque logica di validazione personalizzata esprimibile in codice: dalla semplice convalida di un attributo della richiesta fino alla verifica dello stato di un sistema esterno prima di ammetterla. Inoltre, dovendo scrivere voi stessi il server di validazione, siete liberi di scegliere tecnologia e linguaggio: nessun vincolo all'uso di CEL.

Considerando poi che i VAP sono ancora in Alpha (a partire dalla v1.26), per chi utilizza offerte Kubernetes gestite dei principali cloud provider non rappresentano ancora un'alternativa praticabile. EKS e AKS non supportano le funzionalità alpha, mentre GKE le supporta, ma solo a scopo di test (gli alpha cluster scadono dopo 30 giorni). In altre parole, anche se foste tecnicamente in grado di farlo, oggi è meglio non adottare i VAP come unico meccanismo di validazione in-cluster della vostra organizzazione.

Consigli per migrare dai VAW ai VAP

State leggendo questo articolo in un momento in cui i VAP sono già in beta o GA e state pensando di adottarli al posto della soluzione attuale? Buona notizia: le due funzionalità non si escludono a vicenda, quindi potete farle convivere durante la fase di transizione.

Dato che VAW e VAP intercettano le risorse in modo simile, il passaggio dovrebbe risultare abbastanza lineare. Ecco qualche consiglio per partire con il piede giusto:

  • Create una validatingAdmissionPolicy con gli stessi .spec.matchConstraints.resourceRules definiti nei .webhooks.rules della vostra risorsa ValidatingWebhookConfiguration.
  • Migrate le espressioni di validazione dal linguaggio attuale (verosimilmente Rego) a CEL. È di gran lunga l'attività più dispendiosa, ma vale la pena provare a farsi aiutare da ChatGPT: le regole delle policy sono di solito sufficientemente lineari da consentirgli di restituire un risultato più che dignitoso.
  • Create una ValidatingAdmissionPolicyBinding e impostate .spec.validationActions su Warn per restituire errori al client senza però bloccare le richieste API.
  • Decidete come legare la policy alle risorse tramite .spec.matchResources: può bastare un semplice matcher su label di namespace, oppure una match expression più sofisticata. Etichettate i namespace di conseguenza.
  • Provate a creare risorse valide e non valide nei namespace gestiti, valutate gli warning restituiti (o eventualmente assenti) e affinate progressivamente le espressioni.
  • Quando la policy vi convince, modificate .spec.ValidationActions del binding impostandolo su Deny per renderla pienamente operativa.
  • Rimuovete eventuali policy duplicate dal sistema legacy.

Risorse aggiuntive