Cloud Intelligence™Cloud Intelligence™

Cloud Intelligence™

Validating Admission Policies en Kubernetes: casos de uso avanzados

By Eyal ZekariaJun 5, 20238 min read

Esta página también está disponible en English, Deutsch, Français, Italiano, 日本語 y Português.

En nuestro artículo anterior sobre el tema, hablamos de los retos que implica implementar el Dynamic Admission Control (DAC) en tu cluster de Kubernetes mediante Validating Admission Webhooks (VAW). Presentamos el nuevo recurso Validation Admission Policy (VAP) y mostramos algunos casos de uso sencillos para ilustrar lo simple que es.

Hoy nos vamos a enfocar en un par de funcionalidades más avanzadas de VAP:

  1. Coincidencia y filtrado de recursos
  2. Parámetros en políticas

Por último, vamos a comentar brevemente algunas consideraciones para usar VAPs en lugar de VAWs y algunos consejos para la migración.

Todos los ejemplos del artículo anterior y de este se pueden encontrar en un repositorio de GitHub creado para este fin.

Coincidencia y filtrado de recursos

En el caso de uso del artículo anterior, vimos cómo se puede aplicar una política a un solo tipo de recurso (Deployment), lo cual tiene sentido para validaciones específicas de un recurso.

Pero ¿qué pasa si quisiéramos aplicar una política más genérica en el cluster, como hacer cumplir una convención de nombres?

Aquí tenemos varias opciones. La primera es ir por la vía explícita y crear una regla de coincidencia para cada tipo de recurso que queramos validar:

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

Claro, se puede usar la misma regla para recursos que pertenezcan al mismo grupo/versión de API, pero esa lista crece muy rápido y va a requerir mantenimiento (sobre todo cuando se introduzcan nuevos tipos de recursos en el cluster).

La alternativa sería hacer match con todos los recursos y luego excluir de forma selectiva los que no queremos validar. El filtrado de recursos se puede hacer en dos niveles:

  1. dentro del recurso ValidatingAdmissionPolicy — usando .spec.matchConditions
  2. dentro del recurso ValidatingAdmissionPolicyBinding — usando .spec.matchResources.excludeResources

El primer enfoque está documentado, mientras que el segundo no tanto. .spec.matchConditions usa Common Expression Language (CEL) para filtrar las solicitudes con las que las reglas ya hicieron match (es decir, después del binding y de las restricciones de coincidencia de la política).

Las exclusiones de recursos (.spec.matchResources.excludeResources), en cambio, se aplican antes de que el recurso llegue siquiera a la política y usan la misma sintaxis que se utiliza para hacer match con recursos, pero para crear una lista de exclusión.

Aquí tienes un ejemplo de una política que va a omitir los recursos Lease del grupo de API coordination.k8s.io y cualquier recurso del grupo rbac.authorization.k8s.io usando match conditions de 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' # Cada match condition debe tener un nombre único
      expression: '!(request.resource.group == "coordination.k8s.io" && request.resource.resource == "leases")' # Hacer match con recursos que no sean leases.
    - name: 'rbac' # Omitir las solicitudes de RBAC.
      expression: 'request.resource.group != "rbac.authorization.k8s.io"'
  validations:
    - expression: "!object.metadata.name.contains('demo') || object.metadata.namespace == 'demo'"

Y aquí tienes un binding que va a filtrar solicitudes similares mediante exclusiones:

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

Como ambos logran prácticamente lo mismo, te sugeriría optar por excludeResourceRules siempre que se pueda, ya que el filtrado ocurre antes y se evita ejecutar expresiones CEL sin necesidad (en el post anterior hablamos del costo asociado a ejecutar CEL dentro del API server de Kubernetes). Además, al usar la misma sintaxis que la coincidencia de recursos, me parece un poco más claro y fácil de entender.

Sin embargo, como matchConditions usa expresiones CEL para filtrar y tiene acceso a los mismos objetos que las expresiones de validación (object, oldObject, request y params), también tiene la capacidad de filtrar por cosas que no están disponibles con excludeResourceRules, como usuarios/grupos o cualquier otro atributo de los objetos 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)' # Permitir solicitudes hechas por usuarios que no sean nodos.
  validations:
    - expression: 'object.metadata.name.startsWith("demo-")'

En resumen, te sugeriría un enfoque combinado: usa excludeResourceRules cuando quieras filtrar recursos específicos (por grupo de API, versión, operación, tipo de recurso) y usa matchConditions cuando necesites un filtrado más avanzado (solicitudes de usuarios específicos, grupos, etc.).

Parametrizar políticas

Para que las políticas sean aún más potentes, puedes parametrizarlas. Esto te permite reutilizar una política con distintos valores de parámetros sin tener que redefinirla para cada permutación. En esencia, se separa la configuración de la política en sí.

Para usar parámetros, el operador del cluster tiene que crear un CustomResourceDefinition (CRD) que defina y contenga los parámetros. Esto implica algo de trabajo adicional, pero no es obligatorio usarlos, así que tú decides.

Aquí tienes un ejemplo simple de CRD que se puede modificar o ampliar fácilmente si es necesario:

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

El CRD anterior nos va a permitir crear recursos del tipo Parameters con un único campo personalizado soportado en el esquema, maxReplicas de tipo entero:

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

Una vez que tenemos eso, configuramos nuestra política para que reciba parámetros (.spec.paramKind) apuntando a la versión de API y al kind correctos (los que definimos en el CRD), y ya podemos usarlos en nuestras expresiones de validación:

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

Fíjate que messageExpression también se actualizó para usar el parámetro, lo que nos permite evitar valores hardcodeados en cualquier parte de la política.

Por último, vamos a actualizar el binding para que haga referencia por nombre al recurso Parameters que creamos:

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 vez aplicado todo en el cluster, intentar crear un deployment con más réplicas de las configuradas va a fallar, como era de esperarse:

$ 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

Esto nos permitiría crear distintos recursos Parameters y vincularlos a diferentes recursos/namespaces para reutilizar nuestras políticas parametrizadas.

VAW o VAP, ¿cuál elegir?

Como los VAPs son mucho más simples de implementar que los VAWs, parece probable que con el tiempo terminen reemplazándolos, o que al menos se conviertan en el estándar para arrancar con la validación dentro del cluster en Kubernetes.

El soporte de CEL en Kubernetes ha abierto muchas puertas a mejoras de calidad de vida desde la perspectiva del operador del cluster. Una Mutating Admission Policy es otro ejemplo de funcionalidad que podría hacerse realidad gracias a CEL. Es razonable suponer que los VAPs van a seguir recibiendo atención y desarrollo hasta llegar a la madurez y a la disponibilidad general.

Dicho esto, los VAPs todavía no son un reemplazo completo de los VAWs. Con los VAWs aún puedes ejecutar cualquier lógica de validación personalizada que se pueda plasmar en código, ya sea una simple validación de un atributo de la solicitud o incluso comprobar el estado de un sistema externo antes de admitir una solicitud. Además, el hecho de tener que escribir el servidor de validación por tu cuenta te da la libertad de elegir tu propia tecnología y lenguaje: no estás limitado a usar CEL para la validación.

También hay que considerar que los VAPs todavía están en Alpha (a partir de v1.26), por lo que aún no son una alternativa válida para quienes usan ofertas de Kubernetes administrado de las nubes principales. EKS y AKS no soportan funciones alpha, mientras que GKE sí lo hace, pero solo con fines de prueba (los clusters alpha expiran a los 30 días). Eso significa que, aunque pudieras, probablemente todavía no deberías adoptar los VAPs como el único mecanismo de validación dentro del cluster en tu organización.

Consejos para migrar de VAWs a VAPs

¿Estás leyendo esto en un momento en que los VAPs ya están en beta o GA y estás pensando en usarlos en lugar de tu solución actual? Por suerte, ambas funcionalidades no son mutuamente excluyentes, así que puedes ejecutarlas en paralelo durante la transición.

Como los VAWs y los VAPs hacen match con los recursos de forma similar, crear VAPs debería ser bastante directo. Aquí tienes algunos consejos para empezar:

  • Crea un validatingAdmissionPolicy que tenga los mismos .spec.matchConstraints.resourceRules que los .webhooks.rules de tu recurso ValidatingWebhookConfiguration.
  • Migra tus expresiones de validación del lenguaje actual (probablemente Rego) a CEL. Esta es, de lejos, la tarea que más tiempo lleva, pero te sugiero probar con ChatGPT; las reglas de política suelen ser lo bastante directas como para que haga un trabajo bastante decente.
  • Crea un ValidatingAdmissionPolicyBinding y configura .spec.validationActions en Warn para devolver errores al cliente sin bloquear ninguna solicitud de la API.
  • Decide cómo vincular una política a recursos mediante .spec.matchResources. Puede ser un simple matcher de etiquetas de namespace, o una match expression más avanzada. Etiqueta tus namespaces como corresponda.
  • Intenta crear recursos válidos e inválidos en namespaces administrados, evalúa las advertencias devueltas (o ausentes) y refina tus expresiones a lo largo del proceso.
  • Cuando ya tengas confianza en tu política, cambia .spec.ValidationActions del binding a Deny para ponerla en pleno efecto.
  • Elimina las políticas duplicadas del sistema legado.

Recursos adicionales