Capítulo 4. Utilizar recursos personalizados

Este trabajo se ha traducido utilizando IA. Agradecemos tus opiniones y comentarios: translation-feedback@oreilly.com

En este capítulo te presentamos los recursos personalizados (RC), uno de los mecanismos centrales de extensión utilizados en todo el ecosistema de Kubernetes.

Recursos personalizados se utilizan para objetos de configuración pequeños e internos, sin la correspondiente lógica de controlador, definidos de forma puramente declarativa. Pero los recursos personalizados también desempeñan un papel central para muchos proyectos de desarrollo serios sobre Kubernetes que quieren ofrecer una experiencia de API nativa de Kubernetes. Algunos ejemplos son las mallas de servicios como Istio, Linkerd 2.0 y AWS App Mesh, todas ellas con recursos personalizados en su núcleo.

¿Recuerdas "Un ejemplo motivador" del capítulo 1? En el fondo, tiene una RC parecida a ésta:

apiVersion: cnat.programming-kubernetes.info/v1alpha1
kind: At
metadata:
  name: example-at
spec:
  schedule: "2019-07-03T02:00:00Z"
status:
  phase: "pending"

Los recursos personalizados de están disponibles en todos los clústeres de Kubernetes desde la versión 1.7. Se almacenan en la misma instancia etcd que los recursos principales de la API de Kubernetes y los sirve el mismo servidor de la API de Kubernetes. Como se muestra en la Figura 4-1, las solicitudes vuelven a apiextensions-apiserver, que sirve los recursos definidos mediante CRD, si no son ninguno de los siguientes:

  • Gestionado por servidores API agregados (ver Capítulo 8).

  • Recursos nativos de Kubernetes.

API Extensions API server inside of the Kubernetes API server
Figura 4-1. El servidor API Extensions dentro del servidor API de Kubernetes

Una CustomResourceDefinition (CRD) es un recurso Kubernetes en sí mismo. Describe los CR disponibles en el clúster. Para el ejemplo de CR anterior, la CRD correspondiente tiene este aspecto:

apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata:
  name: ats.cnat.programming-kubernetes.info
spec:
  group: cnat.programming-kubernetes.info
  names:
    kind: At
    listKind: AtList
    plural: ats
    singular: at
  scope: Namespaced
  subresources:
    status: {}
  version: v1alpha1
  versions:
  - name: v1alpha1
    served: true
    storage: true

El nombre del CRD -en este caso, ats.cnat.programming-kubernetes.info- debe coincidir con el nombre plural seguido del nombre del grupo. Define el tipo At CR en el grupo API cnat.programming-kubernetes.info como un recurso con espacio de nombres llamado ats.

Si este CRD se crea en un clúster, kubectl detectará automáticamente el recurso, y el usuario podrá acceder a él a través de:

$ kubectl get ats
NAME                                         CREATED AT
ats.cnat.programming-kubernetes.info         2019-04-01T14:03:33Z

Información sobre el descubrimiento

Detrás de las escenas, kubectl utiliza la información de descubrimiento del servidor API para conocer los nuevos recursos. Profundicemos un poco más en este mecanismo de descubrimiento.

Tras aumentar el nivel de verbosidad de kubectl, podemos ver realmente cómo aprende sobre el nuevo tipo de recurso:

$ kubectl get ats -v=7
... GET https://XXX.eks.amazonaws.com/apis/cnat.programming-kubernetes.info/
                                      v1alpha1/namespaces/cnat/ats?limit=500
... Request Headers:
... Accept: application/json;as=Table;v=v1beta1;g=meta.k8s.io,application/json
      User-Agent: kubectl/v1.14.0 (darwin/amd64) kubernetes/641856d
... Response Status: 200 OK in 607 milliseconds
NAME         AGE
example-at   43s

Los pasos del descubrimiento en detalle son

  1. Inicialmente, kubectl no conoce ats.

  2. Por lo tanto, kubectl pregunta al servidor API sobre todos los grupos API existentes a través del punto final de descubrimiento /apis.

  3. A continuación, kubectl pregunta al servidor API por los recursos de todos los grupos API existentes a través de los puntos finales de descubrimiento de grupos /apis/group version.

  4. A continuación, kubectl traduce el tipo dado, ats, a un triple de:

    • Grupo (aquí cnat.programming-kubernetes.info)

    • Versión (aquí v1alpha1)

    • Recurso (aquí ats).

Los puntos finales de descubrimiento proporcionan toda la información necesaria para realizar la traducción en el último paso:

$ http localhost:8080/apis/
{
  "groups": [{
    "name": "at.cnat.programming-kubernetes.info",
    "preferredVersion": {
      "groupVersion": "cnat.programming-kubernetes.info/v1",
      "version": "v1alpha1“
    },
    "versions": [{
      "groupVersion": "cnat.programming-kubernetes.info/v1alpha1",
      "version": "v1alpha1"
    }]
  }, ...]
}

$ http localhost:8080/apis/cnat.programming-kubernetes.info/v1alpha1
{
  "apiVersion": "v1",
  "groupVersion": "cnat.programming-kubernetes.info/v1alpha1",
  "kind": "APIResourceList",
  "resources": [{
    "kind": "At",
    "name": "ats",
    "namespaced": true,
    "verbs": ["create", "delete", "deletecollection",
      "get", "list", "patch", "update", "watch"
    ]
  }, ...]
}

Todo esto se implementa mediante el descubrimiento RESTMapper. También vimos este tipo muy común de RESTMapper en "Mapeo REST".

Advertencia

La CLI kubectl también mantiene una caché de tipos de recursos en ~/.kubectl para no tener que volver a recuperar la información de descubrimiento en cada acceso. Esta caché se invalida cada 10 minutos. Por tanto, un cambio en el CRD puede aparecer en la CLI del usuario correspondiente hasta 10 minutos después.

Definiciones de tipo

Ahora veamos el CRD y las funciones que ofrece con más detalle: como en el ejemplo de cnat, los CRD son recursos de Kubernetes en el grupo de API apiextensions.k8s.io/v1beta1 proporcionados por el apiextensions-apiserver dentro del proceso del servidor de API de Kubernetes.

El esquema de los CRD es el siguiente:

apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata:
  name: name
spec:
  group: group name
  version: version name
  names:
    kind: uppercase name
    plural: lowercase plural name
    singular: lowercase singular name # defaulted to be lowercase kind
    shortNames: list of strings as short names # optional
    listKind: uppercase list kind # defaulted to be kindList
    categories: list of category membership like "all" # optional
  validation: # optional
    openAPIV3Schema: OpenAPI schema # optional
  subresources: # optional
    status: {} # to enable the status subresource (optional)
    scale: # optional
      specReplicasPath: JSON path for the replica number in the spec of the
                        custom resource
      statusReplicasPath: JSON path for the replica number in the status of
                          the custom resource
      labelSelectorPath: JSON path of the Scale.Status.Selector field in the
                         scale resource
  versions: # defaulted to the Spec.Version field
  - name: version name
    served: boolean whether the version is served by the API server # defaults to false
    storage: boolean whether this version is the version used to store object
  - ...

Muchos de los campos son opcionales o están predeterminados. Explicaremos los campos con más detalle en las secciones siguientes.

Después de que cree un objeto CRD, el apiextensions-apiserver dentro de kube-apiserver comprobará los nombres y determinará si entran en conflicto con otros recursos o si son coherentes en sí mismos. Tras unos instantes, informará del resultado en el estado del CRD, por ejemplo:

apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata:
  name: ats.cnat.programming-kubernetes.info
spec:
  group: cnat.programming-kubernetes.info
  names:
    kind: At
    listKind: AtList
    plural: ats
    singular: at
  scope: Namespaced
  subresources:
    status: {}
  validation:
    openAPIV3Schema:
      type: object
      properties:
        apiVersion:
          type: string
        kind:
          type: string
        metadata:
          type: object
        spec:
          properties:
            schedule:
              type: string
          type: object
        status:
          type: object
  version: v1alpha1
  versions:
  - name: v1alpha1
    served: true
    storage: true
status:
    acceptedNames:
      kind: At
      listKind: AtList
      plural: ats
      singular: at
    conditions:
    - lastTransitionTime: "2019-03-17T09:44:21Z"
      message: no conflicts found
      reason: NoConflicts
      status: "True"
      type: NamesAccepted
    - lastTransitionTime: null
      message: the initial names have been accepted
      reason: InitialNamesAccepted
      status: "True"
      type: Established
    storedVersions:
    - v1alpha1

Puedes ver que los campos de nombre que faltan en la especificación se establecen por defecto y se reflejan en el estado como nombres aceptados. Además, se establecen las siguientes condiciones:

  • NamesAccepted describe si los nombres dados en la especificación son coherentes y están libres de conflictos.

  • Established describe que el servidor API sirve el recurso dado con los nombres que figuran en status.acceptedNames.

Ten en cuenta que algunos campos pueden modificarse mucho después de haber creado el CRD. Por ejemplo, puedes añadir nombres cortos o columnas. En este caso, se puede establecer un CRD -es decir, servirlo con los nombres antiguos- aunque los nombres de las especificaciones tengan conflictos. Por tanto, la condición NamesAccepted sería falsa y los nombres de las especificaciones y los nombres aceptados diferirían.

Funciones avanzadas de los recursos personalizados

En esta sección hablamos de las características avanzadas de los recursos personalizados, como la validación o los subrecursos.

Validar recursos personalizados

Los CR pueden ser validados por el servidor API durante su creación y actualización. Esto se hace basándose en el esquema OpenAPI v3 especificado en los campos validation de la especificación CRD.

Cuando una solicitud crea o muta un CR, el objeto JSON de la especificación se valida con respecto a esta especificación y, en caso de error, el campo conflictivo se devuelve al usuario en una respuesta de código HTTP 400. La Figura 4-2 muestra dónde tiene lugar la validación en el gestor de solicitudes dentro de apiextensions-apiserver.

Las validaciones más complejas pueden implementarse en webhooks de admisión de validaciones, es decir, en un lenguaje de programación Turing-completo. La Figura 4-2 muestra que estos webhooks se llaman directamente después de las validaciones basadas en OpenAPI descritas en esta sección. En "Webhooks de admisión", veremos cómo se implementan y despliegan los webhooks de admisión. Allí veremos las validaciones que tienen en cuenta otros recursos y, por tanto, van mucho más allá de la validación OpenAPI v3. Por suerte, para muchos casos de uso los esquemas OpenAPI v3 son suficientes.

Validation step in the handler stack of the `apiextensions-apiserver`
Figura 4-2. Paso de validación en la pila de manejadores del apiextensions-apiserver

El lenguaje de esquemas OpenAPI se basa en el estándar JSON Schema, que utiliza el propio JSON/YAML para expresar un esquema. Aquí tienes un ejemplo:

type: object
properties:
  apiVersion:
    type: string
  kind:
    type: string
  metadata:
    type: object
  spec:
    type: object
    properties:
      schedule:
        type: string
        pattern: "^\d{4}-([0]\d|1[0-2])-([0-2]\d|3[01])..."
      command:
        type: string
    required:
    - schedule
    - command
  status:
    type: object
    properties:
      phase:
        type: string
required:
- metadata
- apiVersion
- kind
- spec

Este esquema especifica que el valor es en realidad un objeto JSON;1 es decir, es un mapa de cadenas y no una porción o un valor como un número. Además, tiene (aparte de metadata, kind, y apiVersion, que se definen implícitamente para los recursos personalizados) dos propiedades adicionales: spec y status.

Cada uno de ellos es también un objeto JSON. spec tiene los campos obligatorios schedule y command, ambos son cadenas. schedule tiene que coincidir con un patrón para una fecha ISO (esbozado aquí con algunas expresiones regulares). La propiedad opcional status tiene un campo de cadena llamado phase.

Crear esquemas OpenAPI manualmente puede ser tedioso. Por suerte, se está trabajando para hacer esto mucho más fácil mediante la generación de código: el proyecto Kubebuilder -ver "Kubebuilder"- hadesarrollado crd-gen en sig.k8s.io/controller-tools, y se está ampliando paso a paso para que sea utilizable en otros contextos. El generador crd-schema-gen es una bifurcación de crd-gen en esta dirección.

Nombres cortos y categorías

Al igual que los recursos nativos de , los recursos personalizados pueden tener nombres de recursos largos. Son geniales a nivel de API, pero tediosos de escribir en la CLI. Los CR también pueden tener nombres cortos, como el recurso nativo daemonsets, que puede consultarse con kubectl get ds. Estos nombres cortos también se conocen como alias, y cada recurso puede tener cualquier número de ellos.

Para ver en todos los nombres cortos disponibles, utiliza el comando kubectl api-resources del siguiente modo:

$ kubectl api-resources
NAME                   SHORTNAMES  APIGROUP NAMESPACED  KIND
bindings                                    true        Binding
componentstatuses      cs                   false       ComponentStatus
configmaps             cm                   true        ConfigMap
endpoints              ep                   true        Endpoints
events                 ev                   true        Event
limitranges            limits               true        LimitRange
namespaces             ns                   false       Namespace
nodes                  no                   false       Node
persistentvolumeclaims pvc                  true       PersistentVolumeClaim
persistentvolumes      pv                   false       PersistentVolume
pods                   po                   true        Pod
statefulsets           sts         apps     true        StatefulSet
...

De nuevo, kubectl se entera de los nombres cortos a través de la información de descubrimiento(consulta "Información de descubrimiento"). He aquí un ejemplo:

apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata:
  name: ats.cnat.programming-kubernetes.info
spec:
  ...
  shortNames:
  - at

Después, un kubectl get at listará todas las CR de cnat en el espacio de nombres.

Además, los RC -como cualquier otro recurso- pueden formar parte de categorías. El uso más común es la categoría all, como en kubectl get all. Enumera todos los recursos orientados al usuario en un clúster, como pods y servicios.

Los CR definidos en el clúster pueden unirse a una categoría o crear su propia categoría a través del campo categories:

apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata:
  name: ats.cnat.programming-kubernetes.info
spec:
  ...
  categories:
  - all

Con esto, kubectl get all también listará el cnat CR en el espacio de nombres.

Columnas de impresión

La herramienta kubectl CLI utiliza la impresión del lado del servidor para mostrar la salida de kubectl get. Esto significa que consulta al servidor de la API las columnas a mostrar y los valores de cada fila.

Los recursos personalizados también admiten columnas de impresión del lado del servidor, a través de additionalPrinterColumns. Se llaman "adicionales" porque la primera columna es siempre el nombre del objeto. Estas columnas se definen así:

apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata:
  name: ats.cnat.programming-kubernetes.info
spec:
  additionalPrinterColumns: (optional)
  - name: kubectl column name
    type: OpenAPI type for the column
    format: OpenAPI format for the column (optional)
    description: human-readable description of the column (optional)
    priority: integer, always zero supported by kubectl
    JSONPath: JSON path inside the CR for the displayed value

El campo name es el nombre de la columna, el type es un tipo OpenAPI tal y como se define en la sección de tipos de datos de la especificación, y el format (tal y como se define en el mismo documento) es opcional y podría ser interpretado por kubectl u otros clientes.

Además, description es una cadena opcional legible por humanos, que se utiliza con fines de documentación. priority controla en qué modo de verbosidad de kubectl se muestra la columna. En el momento de escribir esto (con Kubernetes 1.14), sólo se admite el cero, y todas las columnas con mayor prioridad están ocultas.

Por último, JSONPath define qué valores se van a mostrar. Se trata de una ruta JSON simple dentro de la CR. Aquí, "simple" significa que admite la sintaxis de campos objeto como .spec.foo.bar, pero no rutas JSON más complejas que hagan bucles sobre matrices o similares.

Con esto, el ejemplo de CRD de la introducción podría ampliarse con additionalPrinterColumns así:

additionalPrinterColumns: #(optional)
- name: schedule
  type: string
  JSONPath: .spec.schedule
- name: command
  type: string
  JSONPath: .spec.command
- name: phase
  type: string
  JSONPath: .status.phase

Entonces kubectl representaría un recurso cnat de la siguiente manera:

$ kubectl get ats
NAME  SCHEDULER             COMMAND             PHASE
foo   2019-07-03T02:00:00Z  echo "hello world"  Pending

A continuación, echaremos un vistazo a los subrecursos.

Subrecursos

En mencionamos brevemente los subrecursos en "Subrecursos de estado: ActualizarEstado". Los subrecursos son puntos finales HTTP especiales, que utilizan un sufijo añadido a la ruta HTTP del recurso normal. Por ejemplo, la ruta HTTP estándar del pod es /api/v1/namespace/namespace/pods/ name. Los pods tienen una serie de subrecursos, como /logs, /portforward, /exec y /status. Las rutas HTTP de los subrecursos correspondientes son:

  • /api/v1/namespace/namespace /pods/name /logs

  • /api/v1/namespace/namespace /pods/name /portforward

  • /api/v1/namespace/namespace /pods/name /exec

  • /api/v1/namespace/namespace /pods/name /status

Los puntos finales de los subrecursos utilizan un protocolo diferente al del punto final del recurso principal.

En el momento de escribir esto, los recursos personalizados admiten dos subrecursos: /escala y /estado. Ambos son opcionales, es decir, deben activarse explícitamente en el CRD.

Subrecurso de estado

El subrecurso /status se utiliza para separar la especificación proporcionada por el usuario de una instancia de CR del estado proporcionado por el controlador. La principal motivación para ello es la separación de privilegios:

  • Normalmente, el usuario no debe escribir los campos de estado.

  • El controlador no debe escribir campos de especificación.

El mecanismo RBAC de para el control de acceso no permite reglas a ese nivel de detalle. Esas reglas son siempre por recurso. El subrecurso /status resuelve esto proporcionando dos puntos finales que son recursos por sí mismos. Cada uno puede controlarse con reglas RBAC de forma independiente. A esto se le suele llamar división especificación-estado. Aquí tienes un ejemplo de una regla de este tipo para el recurso ats, que sólo se aplica al subrecurso /status (mientras que "ats" coincidiría con el recurso principal):

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata: ...
rules:
- apiGroups: [""]
  resources: ["ats/status"]
  verbs: ["update", "patch"]

Los recursos (incluidos los recursos personalizados) que tienen un subrecurso /status han cambiado de semántica, también para el punto final del recurso principal:

  • Ignoran los cambios de estado en el punto final HTTP principal durante la creación (el estado sólo se elimina durante una creación) y las actualizaciones.

  • Del mismo modo, el punto final del subrecurso /estado ignora los cambios ajenos al estado de la carga útil. No es posible realizar una operación de creación en el punto final /estado.

  • Siempre que cambie algo fuera de metadata y fuera de status (esto significa especialmente cambios en la especificación), el punto final del recurso principal aumentará el valor de metadata.generation. Esto puede utilizarse como desencadenante para que un controlador indique que el deseo del usuario ha cambiado.

Ten en cuenta que normalmente se envían tanto spec como status en las peticiones de actualización, pero técnicamente podrías omitir la otra parte respectiva en la carga útil de una petición.

Ten en cuenta también que el punto final /estado ignorará todo lo que quede fuera del estado, incluidos los cambios de metadatos como etiquetas o anotaciones.

La división especificación-estado de un recurso personalizado se activa de la siguiente manera:

apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
spec:
  subresources:
    status: {}
  ...

Observa aquí que al campo status de ese fragmento YAML se le asigna el objeto vacío. Esta es la forma de establecer un campo que no tiene otras propiedades. Basta con escribir

subresources:
  status:

dará lugar a un error de validación porque en YAML el resultado es un valor null para status.

Advertencia

Activar la división especificación-estado es un cambio de ruptura para una API. Los controladores antiguos escribirán en el punto final principal. No se darán cuenta de que el estado siempre se ignora a partir del momento en que se activa la división. Del mismo modo, un nuevo controlador no podrá escribir en el nuevo punto final /estado hasta que se active la división.

En Kubernetes 1.13 y posteriores, los subrecursos pueden configurarse por versión. Esto nos permite introducir el subrecurso /status sin un cambio de ruptura:

apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
spec:
  ...
  versions:
  - name: v1alpha1
    served: true
    storage: true
  - name: v1beta1
    served: true
    subresources:
      status: {}

Esto habilita el subrecurso /status para v1beta1, pero no para v1alpha1.

Nota

La semántica de concurrencia optimista(ver "Concurrencia optimista") es la misma que para los puntos finales del recurso principal; es decir, status y spec comparten el mismo contador de versión del recurso y las actualizaciones de /status pueden entrar en conflicto debido a escrituras en el recurso principal, y viceversa. En otras palabras, no hay división de spec y status en la capa de almacenamiento.

Subrecurso Escala

El segundo subrecurso disponible para los recursos personalizados es /escala. El subrecurso /scale es una vista (proyectiva)2 sobre el recurso, permitiéndonos ver y modificar únicamente los valores de las réplicas. Este subrecurso es bien conocido para recursos como Implementaciones y Conjuntos de réplicas en Kubernetes, que obviamente pueden escalarse hacia arriba y hacia abajo.

El comando kubectl scale utiliza el subrecurso /escala; por ejemplo, lo siguiente modificará el valor de réplica especificado en la instancia dada:

$ kubectl scale --replicas=3 your-custom-resource -v=7
I0429 21:17:53.138353   66743 round_trippers.go:383] PUT
https://host/apis/group/v1/your-custom-resource/scale
apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
spec:
  subresources:
    scale:
      specReplicasPath: .spec.replicas
      statusReplicasPath: .status.replicas
      labelSelectorPath: .status.labelSelector
  ...

Con esto, se escribe una actualización del valor de la réplica en spec.replicas y se devuelve desde allí durante una GET.

El selector de etiquetas no puede modificarse a través del subrecurso /estado, sólo leerse. Su finalidad es dar a un controlador la información para contar los objetos correspondientes. Por ejemplo, el controlador ReplicaSet cuenta las vainas correspondientes que satisfacen este selector.

El selector de etiquetas es opcional. Si la semántica de tus recursos personalizados no se ajusta a los selectores de etiqueta, simplemente no especifiques la ruta JSON para uno.

En el ejemplo anterior de kubectl scale --replicas=3 ... el valor 3 se escribe en spec.replicas. Por supuesto, se puede utilizar cualquier otra ruta JSON simple; por ejemplo, spec.instances o spec.size sería un nombre de campo sensato, dependiendo del contexto.

El tipo de objeto leído o escrito en el punto final es Scale desde el grupo de la API autoscaling/v1. Esto es lo que parece:

type Scale struct {
    metav1.TypeMeta `json:",inline"`
    // Standard object metadata; More info: https://git.k8s.io/
    // community/contributors/devel/api-conventions.md#metadata.
    // +optional
    metav1.ObjectMeta `json:"metadata,omitempty"`

    // defines the behavior of the scale. More info: https://git.k8s.io/community/
    // contributors/devel/api-conventions.md#spec-and-status.
    // +optional
    Spec ScaleSpec `json:"spec,omitempty"`

    // current status of the scale. More info: https://git.k8s.io/community/
    // contributors/devel/api-conventions.md#spec-and-status. Read-only.
    // +optional
    Status ScaleStatus `json:"status,omitempty"`
}

// ScaleSpec describes the attributes of a scale subresource.
type ScaleSpec struct {
    // desired number of instances for the scaled object.
    // +optional
    Replicas int32 `json:"replicas,omitempty"`
}

// ScaleStatus represents the current status of a scale subresource.
type ScaleStatus struct {
    // actual number of observed instances of the scaled object.
    Replicas int32 `json:"replicas"`

    // label query over pods that should match the replicas count. This is the
    // same as the label selector but in the string format to avoid
    // introspection by clients. The string will be in the same
    // format as the query-param syntax. More info about label selectors:
    // http://kubernetes.io/docs/user-guide/labels#label-selectors.
    // +optional
    Selector string `json:"selector,omitempty"`
}

Una instancia tendrá este aspecto:

metadata:
  name: cr-name
  namespace: cr-namespace
  uid: cr-uid
  resourceVersion: cr-resource-version
  creationTimestamp: cr-creation-timestamp
spec:
  replicas: 3
  status:
    replicas: 2
    selector: "environment = production"

Ten en cuenta que la semántica de concurrencia optimista es la misma para el recurso principal y para el subrecurso /escala. Es decir, las escrituras del recurso principal pueden entrar en conflicto con las escrituras de /escala, y viceversa.

El punto de vista de un desarrollador sobre los recursos personalizados

A los recursos personalizados se puede acceder desde Golang utilizando varios clientes. Nos centraremos en:

La elección de qué cliente utilizar depende principalmente del contexto del código que se vaya a escribir, especialmente de la complejidad de la lógica implementada y de los requisitos (por ejemplo, ser dinámico y admitir GVK desconocidos en tiempo de compilación).

La lista anterior de clientes:

  • Disminuye la flexibilidad para manejar GVK desconocidos.

  • Aumenta la seguridad de los tipos.

  • Aumento de la exhaustividad de las funciones de la API de Kubernetes que proporcionan.

Cliente dinámico

El cliente dinámico en k8s.io/client-go/dynamic es totalmente agnóstico a los GVK conocidos. Ni siquiera utiliza ningún tipo Go aparte de unstructured.Unstructured, que sólo envuelve json.Unmarshal y su salida.

El cliente dinámico no utiliza ni un esquema ni un RESTMapper. Esto significa que el programador tiene que proporcionar manualmente todo el conocimiento sobre los tipos proporcionando un recurso (ver "Recursos") en forma de GVR:

schema.GroupVersionResource{
  Group: "apps",
  Version: "v1",
  Resource: "deployments",
}

Si dispone de una configuración de cliente REST (consulta "Crear y utilizar un cliente"), el cliente dinámico puede crearse en una sola línea:

client, err := NewForConfig(cfg)

El acceso REST a un GVR determinado es igual de sencillo:

client.Resource(gvr).
   Namespace(namespace).Get("foo", metav1.GetOptions{})

Esto te da la implementación foo en el espacio de nombres dado.

Nota

Debes conocer el ámbito del recurso (es decir, si es de ámbito de nombre o de ámbito de grupo). Los recursos de ámbito clúster simplemente omiten la llamada a Namespace(namespace).

La entrada y salida del cliente dinámico es un *unstructured.Unstructured, es decir, un objeto que contiene la misma estructura de datos que json.Unmarshal emitiría al desmarcarse:

  • Los objetos se representan mediante map[string]interface{}.

  • Las matrices se representan mediante []interface{}.

  • Los tipos primitivos son string, bool, float64, o int64.

El método UnstructuredContent() proporciona acceso a esta estructura de datos dentro de un objeto no estructurado (también podemos acceder simplemente a Unstructured.Object). Existen ayudantes en el mismo paquete para facilitar la recuperación de los campos y la manipulación del objeto, por ejemplo:

name, found, err := unstructured.NestedString(u.Object, "metadata", "name")

que devuelve el nombre de la implementación:"foo" en este caso. found es verdadero si el campo se encontró realmente (no sólo vacío, sino realmente existente). err informa si el tipo de un campo existente es inesperado (es decir, no es una cadena en este caso). Otras ayudas son las genéricas, una con copia profunda del resultado y otra sin ella:

func NestedFieldCopy(obj map[string]interface{}, fields ...string)
  (interface{}, bool, error)
func NestedFieldNoCopy(obj map[string]interface{}, fields ...string)
  (interface{}, bool, error)

Hay otras variantes tipadas que hacen una conversión de tipo y devuelven un error si falla:

func NestedBool(obj map[string]interface{}, fields ...string) (bool, bool, error)
func NestedFloat64(obj map[string]interface{}, fields ...string)
  (float64, bool, error)
func NestedInt64(obj map[string]interface{}, fields ...string) (int64, bool, error)
func NestedStringSlice(obj map[string]interface{}, fields ...string)
  ([]string, bool, error)
func NestedSlice(obj map[string]interface{}, fields ...string)
  ([]interface{}, bool, error)
func NestedStringMap(obj map[string]interface{}, fields ...string)
  (map[string]string, bool, error)

Y, por último, un colocador genérico:

func SetNestedField(obj, value, path...)

El cliente dinámico se utiliza en el propio Kubernetes para los controladores que son genéricos, como el controlador de recogida de basura, que borra los objetos cuyos padres han desaparecido. El controlador de recogida de basura funciona con cualquier recurso del sistema y, por tanto, hace un uso extensivo del cliente dinámico.

Clientes mecanografiados

Los clientes tipados no utilizan estructuras de datos genéricas similares a map[string]interface{}, sino que utilizan tipos Golang reales, que son diferentes y específicos para cada GVK. Son mucho más fáciles de usar, tienen una seguridad de tipos considerablemente mayor y hacen que el código sea mucho más conciso y legible. En el lado negativo, son menos flexibles porque los tipos procesados tienen que conocerse en tiempo de compilación, y esos clientes se generan, y esto añade complejidad.

Antes de entrar en dos implementaciones de clientes tipados, veamos la representación de los tipos en el sistema de tipos de Golang (consulta "Maquinaria API en profundidad" para conocer la teoría que hay detrás del sistema de tipos de Kubernetes).

Anatomía de un tipo

Kinds se representan como Golang structs. Normalmente, la estructura se denomina como el tipo (aunque técnicamente no tiene por qué ser así) y se coloca en un paquete correspondiente al grupo y versión del GVK de que se trate. Una convención habitual es colocar el GVK group/version.Kind en un paquete Go:

pkg/apis/group/version

y define una estructura Golang Kind en el archivo types.go.

Cada tipo Golang correspondiente a un GVK incorpora la estructura TypeMeta del paquete k8s.io/apimachinery/pkg/apis/meta/v1. TypeMeta sólo consta de los campos Kind y ApiVersion:

type TypeMeta struct {
    // +optional
    APIVersion string `json:"apiVersion,omitempty" yaml:"apiVersion,omitempty"`
    // +optional
    Kind string `json:"kind,omitempty" yaml:"kind,omitempty"`
}

Además, cada tipo de nivel superior -es decir, el que tiene su propio punto final y, por tanto, uno (o varios) GVR correspondientes (véase "Mapeo REST")- tiene que almacenar un nombre, un espacio de nombres para los recursos con espacio de nombres y un número bastante largo de otros campos de metanivel. Todos ellos se almacenan en una estructura denominada ObjectMeta en el paquete k8s.io/apimachinery/pkg/apis/meta/v1:

type ObjectMeta struct {
    Name string `json:"name,omitempty"`
    Namespace string `json:"namespace,omitempty"`
    UID types.UID `json:"uid,omitempty"`
    ResourceVersion string `json:"resourceVersion,omitempty"`
    CreationTimestamp Time `json:"creationTimestamp,omitempty"`
    DeletionTimestamp *Time `json:"deletionTimestamp,omitempty"`
    Labels map[string]string `json:"labels,omitempty"`
    Annotations map[string]string `json:"annotations,omitempty"`
    ...
}

Hay varios campos adicionales. Te recomendamos encarecidamente que leas la extensa documentación en línea, porque te da una buena idea de la funcionalidad básica de los objetos Kubernetes.

Los tipos de nivel superior de Kubernetes (es decir, los que tienen un TypeMeta incrustado y un ObjectMeta incrustado y -en este caso- se persisten en etcd) se parecen mucho entre sí en el sentido de que suelen tener un spec y un status. Consulta este ejemplo de implementación de k8s.io/kubernetes/apps/v1/types.go:

type Deployment struct {
    metav1.TypeMeta `json:",inline"`
    metav1.ObjectMeta `json:"metadata,omitempty"`

    Spec DeploymentSpec `json:"spec,omitempty"`
    Status DeploymentStatus `json:"status,omitempty"`
}

Aunque el contenido real de los tipos para spec y status difiere significativamente entre los distintos tipos, esta división en spec y status es un tema común o incluso una convención en Kubernetes, aunque no sea técnicamente necesaria. Por tanto, es una buena práctica seguir también esta estructura de los CRD. Algunas características de los CRD requieren incluso esta estructura; por ejemplo, el subrecurso /status para los recursos personalizados (ver "Subrecurso de estado")-cuando está activado- se aplica siempre sólo a la subestructura status de la instancia del recurso personalizado. No se le puede cambiar el nombre.

Estructura de los paquetes Golang

Como hemos visto en, los tipos Golang se colocan tradicionalmente en un archivo llamado types. go en el paquete pkg/apis/group/ version. Además de ese archivo, hay un par de archivos más que queremos revisar ahora. Algunos de ellos los escribe manualmente el desarrollador, mientras que otros se generan con generadores de código. Para más detalles, consulta el Capítulo 5.

El archivo doc. go describe la finalidad de la API e incluye una serie de etiquetas de generación de código global del paquete:

// Package v1alpha1 contains the cnat v1alpha1 API group
//
// +k8s:deepcopy-gen=package
// +groupName=cnat.programming-kubernetes.info
package v1alpha1

A continuación, register.go incluye ayudantes para registrar los tipos Golang de recursos personalizados en un esquema (consulta "Esquema"):

package version

import (
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    "k8s.io/apimachinery/pkg/runtime"
    "k8s.io/apimachinery/pkg/runtime/schema"

    group "repo/pkg/apis/group"
)

// SchemeGroupVersion is group version used to register these objects
var SchemeGroupVersion = schema.GroupVersion{
    Group: group.GroupName,
    Version: "version",
}

// Kind takes an unqualified kind and returns back a Group qualified GroupKind
func Kind(kind string) schema.GroupKind {
    return SchemeGroupVersion.WithKind(kind).GroupKind()
}

// Resource takes an unqualified resource and returns a Group
// qualified GroupResource
func Resource(resource string) schema.GroupResource {
    return SchemeGroupVersion.WithResource(resource).GroupResource()
}

var (
    SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes)
    AddToScheme   = SchemeBuilder.AddToScheme
)

// Adds the list of known types to Scheme.
func addKnownTypes(scheme *runtime.Scheme) error {
    scheme.AddKnownTypes(SchemeGroupVersion,
        &SomeKind{},
        &SomeKindList{},
    )
    metav1.AddToGroupVersion(scheme, SchemeGroupVersion)
    return nil
}

A continuación, zz_generated.deepcopy.go define los métodos de copia profunda en los tipos de nivel superior Golang de recursos personalizados (es decir, SomeKind y SomeKindList en el código de ejemplo anterior). Además, todas las subestructuras (como las de los tipos spec y status) pasan a ser también copiables en profundidad.

Como el ejemplo utiliza la etiqueta +k8s:deepcopy-gen=package en doc.go, la generación de deep-copy se realiza sobre una base de exclusión; es decir, se generan métodos DeepCopy para todos los tipos del paquete que no se excluyan con +k8s:deepcopy-gen=false. Consulta el Capítulo 5 y especialmente "Etiquetas deepcopy-gen" para más detalles.

Cliente tipificado creado mediante client-gen

Con el paquete API pkg/apis/group / version en su sitio, el generador de clientes client-gen crea un cliente tipado (para más detalles, véase el Capítulo 5, especialmente "Etiquetas del generador de clientes"), en pkg/generated/clientset/versioned por defecto (pkg/client/clientset/versioned en versiones antiguas del generador). Más concretamente, el objeto de nivel superior generado es un conjunto de clientes. Subsume una serie de grupos de API, versiones y recursos.

El archivo de nivel superior tiene el siguiente aspecto:

// Code generated by client-gen. DO NOT EDIT.

package versioned

import (
    discovery "k8s.io/client-go/discovery"
    rest "k8s.io/client-go/rest"
    flowcontrol "k8s.io/client-go/util/flowcontrol"

    cnatv1alpha1 ".../cnat/cnat-client-go/pkg/generated/clientset/versioned/
)

type Interface interface {
    Discovery() discovery.DiscoveryInterface
    CnatV1alpha1() cnatv1alpha1.CnatV1alpha1Interface
}

// Clientset contains the clients for groups. Each group has exactly one
// version included in a Clientset.
type Clientset struct {
    *discovery.DiscoveryClient
    cnatV1alpha1 *cnatv1alpha1.CnatV1alpha1Client
}

// CnatV1alpha1 retrieves the CnatV1alpha1Client
func (c *Clientset) CnatV1alpha1() cnatv1alpha1.CnatV1alpha1Interface {
    return c.cnatV1alpha1
}

// Discovery retrieves the DiscoveryClient
func (c *Clientset) Discovery() discovery.DiscoveryInterface {
   ...
}

// NewForConfig creates a new Clientset for the given config.
func NewForConfig(c *rest.Config) (*Clientset, error) {
    ...
}

El conjunto de clientes está representado por la interfaz Interface y da acceso a la interfaz de cliente del grupo API de cada versión; por ejemplo, CnatV1alpha1Interface en este código de ejemplo:

type CnatV1alpha1Interface interface {
    RESTClient() rest.Interface
    AtsGetter
}

// AtsGetter has a method to return a AtInterface.
// A group's client should implement this interface.
type AtsGetter interface {
    Ats(namespace string) AtInterface
}

// AtInterface has methods to work with At resources.
type AtInterface interface {
    Create(*v1alpha1.At) (*v1alpha1.At, error)
    Update(*v1alpha1.At) (*v1alpha1.At, error)
    UpdateStatus(*v1alpha1.At) (*v1alpha1.At, error)
    Delete(name string, options *v1.DeleteOptions) error
    DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error
    Get(name string, options v1.GetOptions) (*v1alpha1.At, error)
    List(opts v1.ListOptions) (*v1alpha1.AtList, error)
    Watch(opts v1.ListOptions) (watch.Interface, error)
    Patch(name string, pt types.PatchType, data []byte, subresources ...string)
        (result *v1alpha1.At, err error)
    AtExpansion
}

Se puede crear una instancia de un conjunto de clientes con la función de ayuda NewForConfig. Esto es análogo a los clientes para los recursos centrales de Kubernetes que se tratan en "Crear y utilizar un cliente":

import (
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    "k8s.io/client-go/tools/clientcmd"

    client "github.com/.../cnat/cnat-client-go/pkg/generated/clientset/versioned"
)

kubeconfig = flag.String("kubeconfig", "~/.kube/config", "kubeconfig file")
flag.Parse()
config, err := clientcmd.BuildConfigFromFlags("", *kubeconfig)
clientset, err := client.NewForConfig(config)

ats := clientset.CnatV1alpha1Interface().Ats("default")
book, err := ats.Get("kubernetes-programming", metav1.GetOptions{})

Como puedes ver, la maquinaria de generación de código nos permite programar lógica para recursos personalizados del mismo modo que para los recursos principales de Kubernetes. También existen herramientas de nivel superior, como los informadores; consulta informer-gen en el Capítulo 5.

controller-runtime Cliente de Operator SDK y Kubebuilder

En aras de la exhaustividad, queremos echar un vistazo rápido al tercer cliente, que aparece como segunda opción en "La visión de un desarrollador sobre los recursos personalizados". El proyecto controller-runtime proporciona la base para las soluciones de operador Operator SDK y Kubebuilder presentadas en el Capítulo 6. Incluye un cliente que utiliza los tipos Go presentados en "Anatomía de un tipo".

A diferencia del cliente generado por client-gendel anterior "Cliente tipificado creado mediante cliente-gen" , y de forma similar al "Cliente dinámico", este cliente es una instancia, capaz de manejar cualquier tipo que se registre en un esquema determinado.

En se utiliza la información de descubrimiento del servidor API para asignar los tipos a rutas HTTP. Ten en cuenta que en el Capítulo 6 se explicará con más detalle cómo se utiliza este cliente como parte de esas dos soluciones de operador.

Aquí tienes un ejemplo rápido de cómo utilizar controller-runtime:

import (
    "flag"

    corev1 "k8s.io/api/core/v1"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    "k8s.io/client-go/kubernetes/scheme"
    "k8s.io/client-go/tools/clientcmd"

    runtimeclient "sigs.k8s.io/controller-runtime/pkg/client"
)

kubeconfig = flag.String("kubeconfig", "~/.kube/config", "kubeconfig file path")
flag.Parse()
config, err := clientcmd.BuildConfigFromFlags("", *kubeconfig)

cl, _ := runtimeclient.New(config, client.Options{
    Scheme: scheme.Scheme,
})
podList := &corev1.PodList{}
err := cl.List(context.TODO(), client.InNamespace("default"), podList)

El método List() del objeto cliente acepta cualquier runtime.Object registrado en el esquema dado, que en este caso es el tomado de client-go con todos los tipos estándar de Kubernetes registrados. Internamente, el cliente utiliza el esquema dado para asignar el tipo Golang *corev1.PodList a un GVK. En un segundo paso, el método List() utiliza la información de descubrimiento para obtener el GVR para pods, que es schema.GroupVersionResource{"", "v1", "pods"}, y por tanto accede a /api/v1/namespace/default/pods para obtener la lista de pods en el espacio de nombres pasado.

La misma lógica puede utilizarse con recursos personalizados. La principal diferencia es utilizar un esquema personalizado que contenga el tipo Go pasado:

import (
    "flag"

    corev1 "k8s.io/api/core/v1"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    "k8s.io/client-go/kubernetes/scheme"
    "k8s.io/client-go/tools/clientcmd"

    runtimeclient "sigs.k8s.io/controller-runtime/pkg/client"
    cnatv1alpha1 "github.com/.../cnat/cnat-kubebuilder/pkg/apis/cnat/v1alpha1"
)

kubeconfig = flag.String("kubeconfig", "~/.kube/config", "kubeconfig file")
flag.Parse()
config, err := clientcmd.BuildConfigFromFlags("", *kubeconfig)

crScheme := runtime.NewScheme()
cnatv1alpha1.AddToScheme(crScheme)

cl, _ := runtimeclient.New(config, client.Options{
    Scheme: crScheme,
})
list := &cnatv1alpha1.AtList{}
err := cl.List(context.TODO(), client.InNamespace("default"), list)

Observa cómo la invocación del comando List() no cambia en absoluto.

Imagina que escribes un operador que accede a muchos tipos diferentes utilizando este cliente. Con el cliente tipado de "Cliente tipado creado mediante client-gen", tendrías que pasar muchos clientes distintos al operador, lo que haría que el código de fontanería fuera bastante complejo. En cambio, el cliente controller-runtime presentado aquí es un solo objeto para todos los tipos, suponiendo que todos ellos estén en un mismo esquema.

Los tres tipos de clientes tienen su utilidad, con ventajas e inconvenientes según el contexto en que se utilicen. En los controladores genéricos que manejan objetos desconocidos, sólo se puede utilizar el cliente dinámico. En los controladores en los que la seguridad de tipos ayuda mucho a reforzar la corrección del código, los clientes generados son una buena opción. El propio proyecto Kubernetes tiene tantos colaboradores que la estabilidad del código es muy importante, incluso cuando es ampliado y reescrito por tanta gente. Si lo importante es la comodidad y la alta velocidad con una fontanería mínima, el cliente controller-runtime es una buena opción.

Resumen

En este capítulo te hemos presentado los recursos personalizados, los mecanismos centrales de extensión utilizados en el ecosistema Kubernetes. A estas alturas ya deberías conocer bien sus características y limitaciones, así como los clientes disponibles.

Pasemos ahora a la generación de código para gestionar dichos recursos.

1 No confundas aquí los objetos Kubernetes y JSON. Estos últimos no son más que otro término para un mapa de cadenas, utilizado en el contexto de JSON y en OpenAPI.

2 "Proyectivo" significa aquí que el objeto scale es una proyección del recurso principal, en el sentido de que sólo muestra determinados campos y oculta todo lo demás.

Get Programación de Kubernetes now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.