Zum Inhalt springen
Platform Engineering

Crossplane 2.3 desde cero: una API de plataforma propia con la que los equipos piden su propio almacenamiento

Crossplane gestiona infraestructura cloud a través de la API de Kubernetes. Describes un almacén o una base de datos como objeto dentro del cluster, y un controlador se encarga de que el recurso real nazca en el proveedor y se quede exactamente en ese estado. La diferencia con una herramienta de infraestructura clásica está menos en la descripción que en la operación: no hay una ejecución que alguien lanza, hay un lazo de control que no termina nunca.

Este artículo es un tutorial completo y no da por supuesto ningún conocimiento previo de Crossplane. Empieza por la instalación, pasa por el primer Provider, el primer recurso gestionado y la API propia de plataforma, y termina en los sitios donde la cosa se pone incómoda: al borrar, con el drift y con la cantidad de tipos de recurso que un Provider escribe en el cluster. Todo lo de aquí está escrito contra Crossplane 2.3, es decir, contra la versión en la que los Composite Resources y los Managed Resources viven por defecto dentro de un Namespace.

Contenido

La tienda de herramientas y el ticket que se queda tres días

Una tienda online vende herramienta: taladros, sierras de calar, atornilladores a batería, además de accesorios y repuestos. La tienda hace tiempo que dejó de ser una única aplicación, ahora son un puñado de servicios. Catálogo, valoraciones, búsqueda de repuestos, cada uno con su propio equipo.

Cada uno de esos servicios necesita la misma clase de almacén. Imágenes de producto en varios tamaños, más los manuales de montaje en PDF, porque quien compra una sierra de calar quiere ver el manual en la tienda y no encontrarlo primero dentro de la caja. Las reglas son las mismas en todas partes: versionado activado, para poder recuperar una imagen sobrescrita por error, y desde fuera nada es accesible públicamente, porque delante de los ficheros hay un servicio de entrega.

El camino hasta ahí pasa por un ticket. Un equipo comunica la necesidad, el equipo de plataforma añade un bloque a la configuración de infraestructura, alguien revisa el plan, alguien aplica. En el buen caso eso dura un día, en el normal tres, y la semana antes de una release dura más, porque entonces todos quieren algo a la vez.

Dos veces salió algo mal, y las dos veces de la misma manera. Al crear el almacén para la búsqueda de repuestos faltaba el versionado, porque el bloque copiado venía de un sitio más antiguo. Y cuando el compañero que montó el almacén de las valoraciones abrió el acceso un momento a mano, para revisar un fichero, se olvidó de volver a cerrarlo. Las dos cosas se notaron semanas después.

Nadie estuvo despistado. Este procedimiento permite los dos fallos de forma estructural: copiar sin control, y un toque a mano entre dos ejecuciones que nadie percibe. El resto de este artículo construye el mismo almacén otra vez, pero de modo que ninguno de los dos fallos quepa ya dentro. Al final, el equipo de la búsqueda de repuestos escribe ocho líneas de YAML en su propio Namespace y recibe un almacén que cumple las reglas y que vuelve a cumplirlas también cuando alguien lo toca a mano.

Qué es Crossplane y por qué vive en el cluster

Crossplane es un proyecto de código abierto de la Cloud Native Computing Foundation y allí alcanzó a finales de octubre de 2025 el estado «Graduated», es decir, el nivel de madurez más alto. Amplía Kubernetes con la capacidad de gestionar recursos fuera del cluster.

El núcleo es una idea que Kubernetes ya lleva de por sí. Creas un objeto que describe un estado deseado, y un controlador trabaja sin descanso en ajustar la realidad a ese deseo. En un Deployment la realidad es un conjunto de pods en marcha. En Crossplane la realidad es un almacenamiento de objetos, una base de datos o una red en el proveedor cloud.

De ahí sale la diferencia que más pesa en la práctica. Una ejecución de infraestructura es un evento: empieza, termina, y después ya nadie mira. Un controlador es un estado: compara lo deseado con lo real a un ritmo fijo y corrige lo que se desvía. Quien cambie a mano un ajuste gestionado se lo encuentra revertido en la siguiente comparación.

La segunda diferencia es el control de acceso. Como todo es un objeto de Kubernetes, valen las reglas que ya valen en el cluster de todos modos. Quién puede pedir un almacén y quién no es una cuestión de RBAC y no una cuestión de quién tiene acceso a la configuración de infraestructura.

El precio es igual de claro: el cluster pasa a ser él mismo infraestructura de producción. Hay que parchearlo, respaldarlo y vigilarlo, también para recursos que no tienen nada que ver con Kubernetes. Quien no quiera pagar ese precio está mejor servido con una ejecución. La sección del final vuelve sobre esto.

Qué necesitas

Para este tutorial necesitas un cluster de Kubernetes en el que tengas permisos de administrador. Para empezar basta un cluster local con kind o k3d, deberías tener libres entre tres y cuatro gigabytes de memoria. Además, kubectl y helm.

Del lado del proveedor necesitas credenciales con permisos sobre el almacenamiento de objetos. Los ejemplos de aquí usan el Provider de AWS y sus recursos de S3, porque es el mejor documentado. El procedimiento es idéntico con otros proveedores, cambian los nombres de los tipos de recurso y los campos.

Todos los manifiestos de este artículo están escritos contra Crossplane 2.3. La versión importa, porque con la versión 2 cambiaron los modelos de objeto y la mayoría de guías que hay por la red siguen mostrando el modelo antiguo. Cómo lo reconoces está en la sección sobre Namespaces y Claims.

Instalar Crossplane

Crossplane se instala con Helm y deja sus propias piezas en un Namespace propio:

helm repo add crossplane-stable https://charts.crossplane.io/stable
helm repo update
helm install crossplane crossplane-stable/crossplane 
  --namespace crossplane-system 
  --create-namespace

Después corren dos pods, el núcleo y un gestor de paquetes:

kubectl get pods -n crossplane-system
NAME                                       READY   STATUS    RESTARTS   AGE
crossplane-7d4b8f9c56-2xk9p                1/1     Running   0          62s
crossplane-rbac-manager-6c9f7d4b8f-lm4qt   1/1     Running   0          62s

Más interesante que los pods es lo que Crossplane ha añadido a la API del cluster:

kubectl api-resources --api-group=pkg.crossplane.io
NAME                    SHORTNAMES   APIVERSION            NAMESPACED   KIND
configurations                       pkg.crossplane.io/v1  false        Configuration
functions                            pkg.crossplane.io/v1  false        Function
providers                            pkg.crossplane.io/v1  false        Provider

Esos tres tipos son todo el comienzo. Un Provider trae el conocimiento sobre un proveedor, una Function procesa plantillas, y una Configuration junta ambas cosas en un paquete. De recursos cloud, Crossplane no conoce a estas alturas ni uno solo.

El Provider: Crossplane aprende almacenamiento de objetos

Un Provider es un paquete que conoce la API de un proveedor y que escribe en el cluster un recurso de Kubernetes propio por cada tipo de recurso. Después de la instalación, el cluster sabe manejar un Bucket igual que un Deployment.

Aquí acecha la primera propiedad incómoda, por eso está al principio y no entre las trampas. El Provider grande y monolítico de AWS trae más de 900 tipos de recurso. Cada uno de ellos es una CustomResourceDefinition, y tantas de golpe ponen la API de Kubernetes bajo una presión que se nota. En casos documentados, el API server estuvo hasta una hora sin responder durante el escalado posterior del núcleo del cluster.

La respuesta a eso se llama Provider Families. En lugar de un paquete para el proveedor entero instalas uno por servicio, y en el cluster entra solo lo que de verdad necesitas:

apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
  name: provider-aws-s3
spec:
  package: xpkg.upbound.io/upbound/provider-aws-s3:v2.6.3

Aplicar y esperar hasta que el paquete se reporte como sano:

kubectl apply -f provider.yaml
kubectl get providers
NAME              INSTALLED   HEALTHY   PACKAGE                                          AGE
provider-aws-s3   True        True      xpkg.upbound.io/upbound/provider-aws-s3:v2.6.3   48s

El Provider ha traído ahora sus tipos de recurso. En lugar de los más de 900 del monolito son solo los del almacenamiento de objetos:

kubectl api-resources --api-group=s3.aws.m.upbound.io
NAME                             APIVERSION                     NAMESPACED   KIND
buckets                          s3.aws.m.upbound.io/v1beta1    true         Bucket
bucketlifecycleconfigurations    s3.aws.m.upbound.io/v1beta1    true         BucketLifecycleConfiguration
bucketpublicaccessblocks         s3.aws.m.upbound.io/v1beta1    true         BucketPublicAccessBlock
bucketversionings                s3.aws.m.upbound.io/v1beta1    true         BucketVersioning

La salida está recortada a los cuatro tipos que necesita este artículo. El paquete trae más, por ejemplo para políticas y replicación, pero se queda en unas dos docenas largas en lugar de más de 900.

Dos cosas de ahí importan. La m de s3.aws.m.upbound.io está por el modelo nuevo de Crossplane 2, el que sabe de Namespaces. Y la columna NAMESPACED está en true, así que esos recursos viven dentro de un Namespace como un Deployment cualquiera. Si en una guía encuentras un grupo sin la m, entonces describe el modelo antiguo.

Credenciales y ProviderConfig

El Provider ya sabe cómo es un almacenamiento de objetos, pero no en qué cuenta debe crearlo. Para eso necesitas dos cosas: un Secret con las credenciales y una configuración que apunte a él.

Primero el Namespace en el que trabaja el equipo. Todo lo demás de este artículo vive dentro de él:

kubectl create namespace equipo-repuestos

Después las credenciales. Para un tutorial con claves estáticas basta un fichero pequeño:

[default]
aws_access_key_id = TU_KEY
aws_secret_access_key = TU_SECRET
kubectl create secret generic aws-acceso 
  --namespace equipo-repuestos 
  --from-file=credentials=./credenciales.txt

Para producción ese es el camino equivocado, y no es una formalidad. Las claves estáticas están de forma permanente en el cluster, se pueden copiar y no caducan. El camino mejor se llama Workload Identity: el pod del Provider recibe un token de vida corta que el proveedor canjea por un rol, y así no llega a existir ninguna clave. Para la primera pasada nos quedamos aquí con la variante sencilla, en la sección sobre trampas está a qué prestar atención al cambiar.

Ahora la configuración. Vive en el mismo Namespace que los recursos a los que debe servir:

apiVersion: aws.m.upbound.io/v1beta1
kind: ProviderConfig
metadata:
  name: predeterminado
  namespace: equipo-repuestos
spec:
  credentials:
    source: Secret
    secretRef:
      namespace: equipo-repuestos
      name: aws-acceso
      key: credentials

También aquí el Namespace es el punto de verdad. Una ProviderConfig vale solo para recursos del mismo Namespace. Si una cuenta debe valer para todo el cluster, existe para eso un segundo tipo, ClusterProviderConfig, que se crea sin Namespace. Con eso se puede separar de forma limpia: el equipo de la búsqueda de repuestos trabaja contra otra cuenta que el equipo de las valoraciones, sin que ninguno de los dos equipos pueda ver la cuenta del otro.

El primer Managed Resource

Ahora el primer almacén, directo y sin abstracción. Un recurso que representa un objeto en el proveedor se llama Managed Resource:

apiVersion: s3.aws.m.upbound.io/v1beta1
kind: Bucket
metadata:
  name: manuales-herramientas
  namespace: equipo-repuestos
spec:
  forProvider:
    region: eu-central-1
  providerConfigRef:
    kind: ProviderConfig
    name: predeterminado

Dos campos sostienen toda la estructura. Bajo forProvider está todo lo que el proveedor conoce por su cuenta, es decir, exactamente los campos de su API. Todo lo que está por encima es asunto de Crossplane: qué configuración vale, qué debe pasar al borrar, adónde se escriben los datos de conexión.

Aplicar y mirar:

kubectl apply -f bucket.yaml
kubectl get bucket -n equipo-repuestos -w
NAME                    SYNCED   READY   EXTERNAL-NAME           AGE
manuales-herramientas   False                                    3s
manuales-herramientas   True     False   manuales-herramientas   9s
manuales-herramientas   True     True    manuales-herramientas   14s

Las dos columnas SYNCED y READY son la razón de que esta salida esté aquí. SYNCED significa que Crossplane pudo hablar con la API del proveedor y que el deseo llegó. READY significa que el recurso es de verdad utilizable en el proveedor. Entre las dos está el tiempo que el proveedor necesita, y con una base de datos o un cluster eso son minutos en lugar de segundos.

Si SYNCED se queda en False, el motivo está casi siempre en las credenciales o en los permisos. El propio recurso te dice dónde se atasca:

kubectl describe bucket manuales-herramientas -n equipo-repuestos

Qué hace el controlador mientras esperas

Hasta aquí esto parece un camino engorroso para crear un almacén. La diferencia se ve solo después, en la operación continua.

Por cada recurso gestionado corre un bucle. Le pregunta al proveedor por el estado real, lo compara con lo que dice el objeto y devuelve la diferencia. Si no encuentra diferencia, no hace nada y espera a la siguiente pasada.

El ritmo de ese bucle es ajustable y merece una mirada antes de que te apoyes en él. El propio núcleo de Crossplane comprueba por defecto cada minuto. Los Provider generados a partir de un provider de Terraform, y entre ellos están los grandes de cloud, comprueban por defecto cada diez minutos por recurso. Así que «inmediato» no es, y para alertar este mecanismo no sirve. Para lo que debe hacer, alcanza: una desviación no sobrevive de forma duradera.

Importante es el límite de esa afirmación, y se suele trazar con demasiada generosidad. Crossplane solo corrige los recursos que él mismo gestiona, y ahí solo los campos que están en el manifiesto. Un almacén que alguien crea a mano en la consola queda intacto, porque Crossplane no sabe nada de él. No es un vigilante de la cuenta, sino de sus propios objetos.

La prueba que separa Crossplane de una ejecución

El acceso abierto a mano de la historia inicial se puede reproducir ahora.

Primero el bloqueo que cierra el acceso público, como recurso propio:

apiVersion: s3.aws.m.upbound.io/v1beta1
kind: BucketPublicAccessBlock
metadata:
  name: manuales-herramientas-bloqueo
  namespace: equipo-repuestos
spec:
  forProvider:
    region: eu-central-1
    bucketRef:
      name: manuales-herramientas
    blockPublicAcls: true
    blockPublicPolicy: true
    ignorePublicAcls: true
    restrictPublicBuckets: true
  providerConfigRef:
    kind: ProviderConfig
    name: predeterminado

bucketRef es aquí más que una comodidad al escribir. Crossplane resuelve la referencia, pone el nombre real y con eso establece también el orden: el bloqueo no se aplica hasta que el almacén existe.

Ahora el toque a mano. Abre el acceso público en la consola del proveedor o con su línea de comandos, tal como lo hizo el compañero de la historia inicial. Después espera el ritmo y vuelve a mirar allí: el bloqueo está cerrado.

La prueba va expresamente en la consola del proveedor y no en el cluster. En el cluster no se ha movido nada, y eso también se puede enseñar:

kubectl get bucketpublicaccessblock -n equipo-repuestos

SYNCED y READY estuvieron todo el rato en True, porque el estado deseado nunca se fue. Lo que se corrigió fue en el proveedor, no en el objeto.

Nadie ha lanzado una ejecución, nadie se ha dado cuenta del toque, y aun así el estado vuelve a estar donde debe. Ese es el punto en el que el modelo se paga, y es también el punto en el que puede volverse incómodo: quien intervenga a mano a propósito durante una incidencia tiene que saber que el controlador le va a llevar la contraria. Para esos casos existen las Management Policies, con las que un recurso se puede dejar temporalmente solo en observación en lugar de gestionado.

El objetivo: una ficha de pedido propia para la tienda

Hasta aquí ha cambiado poco en el núcleo del problema. En lugar de un bloque en la configuración de infraestructura, ahora alguien escribe tres ficheros YAML, y ese alguien tiene que saber qué es un Public Access Block. Para un equipo de plataforma eso está bien, para el equipo de la búsqueda de repuestos es demasiado.

Para eso existe la parte de Crossplane que justifica el esfuerzo. Defines un tipo de recurso propio que se parece a como piensa tu empresa, y fijas qué pasa detrás de él. Para la tienda ese tipo se llama ShopAlmacen, y un equipo pide así:

apiVersion: shop.tienda-herramientas.example/v1
kind: ShopAlmacen
metadata:
  name: manuales
  namespace: equipo-repuestos
spec:
  region: eu-central-1
  diasRetencion: 30

Ni una palabra ya sobre bloqueos y versionado. Esas reglas valen porque están en la plantilla, y no porque alguien se haya acordado de ellas. El fallo de la historia inicial, el bloque que faltaba en la sección copiada, aquí ya no tiene sitio, porque no queda nada que copiar.

Dos piezas entran en juego. La CompositeResourceDefinition, XRD para abreviar, describe la ficha de pedido: cómo se llama el tipo y qué campos tiene. La Composition describe qué nace a raíz del pedido.

La XRD: definir la ficha de pedido

apiVersion: apiextensions.crossplane.io/v2
kind: CompositeResourceDefinition
metadata:
  name: shopalmacenes.shop.tienda-herramientas.example
spec:
  scope: Namespaced
  group: shop.tienda-herramientas.example
  names:
    kind: ShopAlmacen
    plural: shopalmacenes
  versions:
  - name: v1
    served: true
    referenceable: true
    schema:
      openAPIV3Schema:
        type: object
        properties:
          spec:
            type: object
            properties:
              region:
                description: Región en la que nace el almacén.
                type: string
              diasRetencion:
                description: Días que se conserva una versión antigua de imagen.
                type: integer
                default: 30
            required:
            - region
          status:
            type: object
            properties:
              nombreAlmacen:
                description: Nombre del almacén en el proveedor.
                type: string

El esquema es un esquema OpenAPI corriente, el mismo lenguaje en el que están descritos también los tipos integrados de Kubernetes. Con eso te llevas la validación de regalo: quien escriba diasRetencion: treinta es rechazado ya al aplicar y no cuando una API del proveedor se queje.

El campo decisivo es scope: Namespaced. Es el valor por defecto en la versión 2 y hace que la ficha de pedido viva en un Namespace y solo cree recursos en el mismo Namespace. La separación entre los equipos no depende así de un acuerdo, va dentro del objeto.

Aplicar y comprobar si Crossplane ha aceptado el tipo nuevo:

kubectl apply -f xrd.yaml
kubectl get xrd
NAME                                             ESTABLISHED   OFFERED   AGE
shopalmacenes.shop.tienda-herramientas.example   True                    6s

ESTABLISHED en True significa que a partir de ahora el cluster conoce ShopAlmacen, igual que conoce Deployment.

La Composition: qué pasa detrás de la ficha de pedido

La Composition es la plantilla. Dice qué recursos gestionados nacen cuando alguien pide un ShopAlmacen.

Desde la versión 2, una Composition es una serie de funciones que corren una tras otra y van construyendo la lista de recursos a crear. El procedimiento anterior, en el que la Composition componía ella misma los campos directamente, está declarado obsoleto desde la 1.17. La función más habitual para empezar es function-patch-and-transform, y hay que instalarla como un Provider:

apiVersion: pkg.crossplane.io/v1
kind: Function
metadata:
  name: function-patch-and-transform
spec:
  package: xpkg.crossplane.io/crossplane-contrib/function-patch-and-transform:v0.8.2

Con eso ya está la plantilla:

apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
  name: shopalmacen-s3
spec:
  compositeTypeRef:
    apiVersion: shop.tienda-herramientas.example/v1
    kind: ShopAlmacen
  mode: Pipeline
  pipeline:
  - step: construir-recursos
    functionRef:
      name: function-patch-and-transform
    input:
      apiVersion: pt.fn.crossplane.io/v1beta1
      kind: Resources
      resources:
      - name: almacen
        base:
          apiVersion: s3.aws.m.upbound.io/v1beta1
          kind: Bucket
          spec:
            forProvider: {}
            providerConfigRef:
              kind: ProviderConfig
              name: predeterminado
      - name: versionado
        base:
          apiVersion: s3.aws.m.upbound.io/v1beta1
          kind: BucketVersioning
          spec:
            forProvider:
              bucketSelector:
                matchControllerRef: true
              versioningConfiguration:
                status: Enabled
            providerConfigRef:
              kind: ProviderConfig
              name: predeterminado
      - name: bloqueo-publico
        base:
          apiVersion: s3.aws.m.upbound.io/v1beta1
          kind: BucketPublicAccessBlock
          spec:
            forProvider:
              bucketSelector:
                matchControllerRef: true
              blockPublicAcls: true
              blockPublicPolicy: true
              ignorePublicAcls: true
              restrictPublicBuckets: true
            providerConfigRef:
              kind: ProviderConfig
              name: predeterminado

Un detalle dentro de eso es el truco de verdad. En el primer recurso todavía estaba bucketRef con un nombre fijo, aquí está bucketSelector con matchControllerRef: true. Un nombre fijo ya no vale, porque la plantilla no sabe cómo se va a llamar el almacén. El selector dice en su lugar: coge el almacén que pertenece al mismo pedido que yo. Con eso, la misma plantilla funciona para cada equipo y cada pedido.

Lo que todavía falta es la conexión entre ficha de pedido y recursos. La región hasta ahora no está en ninguna parte.

Patches: cómo llega el deseo a los recursos

Un Patch copia un valor de la ficha de pedido al recurso creado. En el almacén son dos: la región y el plazo de retención, y el segundo recibe otra forma por el camino.

El caso sencillo, directo en la entrada del almacén:

      - name: almacen
        base:
          apiVersion: s3.aws.m.upbound.io/v1beta1
          kind: Bucket
          spec:
            forProvider: {}
            providerConfigRef:
              kind: ProviderConfig
              name: predeterminado
        patches:
        - type: FromCompositeFieldPath
          fromFieldPath: spec.region
          toFieldPath: spec.forProvider.region
        - type: ToCompositeFieldPath
          fromFieldPath: metadata.annotations[crossplane.io/external-name]
          toFieldPath: status.nombreAlmacen

El primer Patch va de la ficha de pedido al recurso, el segundo de vuelta. El camino de vuelta es el que más usan los equipos: pone en el estado de la ficha de pedido el nombre con el que el almacén existe realmente en el proveedor. Sin él, cada equipo tendría que rebuscar entre los recursos creados para enterarse de cómo se llama su almacén.

El segundo caso es el más interesante, porque el valor tiene que cambiar por el camino. La ficha de pedido nombra un número de días, la regla de limpieza del proveedor espera una estructura anidada:

      - name: limpieza
        base:
          apiVersion: s3.aws.m.upbound.io/v1beta1
          kind: BucketLifecycleConfiguration
          spec:
            forProvider:
              bucketSelector:
                matchControllerRef: true
              rule:
              - id: versiones-antiguas-imagenes
                status: Enabled
                noncurrentVersionExpiration:
                - noncurrentDays: 30
            providerConfigRef:
              kind: ProviderConfig
              name: predeterminado
        patches:
        - type: FromCompositeFieldPath
          fromFieldPath: spec.region
          toFieldPath: spec.forProvider.region
        - type: FromCompositeFieldPath
          fromFieldPath: spec.diasRetencion
          toFieldPath: spec.forProvider.rule[0].noncurrentVersionExpiration[0].noncurrentDays

La ruta con los índices tiene pinta de estorbo y aun así es el sitio donde la ficha de pedido enseña su valor. De un número que un equipo entiende sale aquí la estructura que el proveedor exige. Esa traducción es el contenido real de una API de plataforma, y alguien tiene que mantenerla.

El mismo Patch de región lo necesitan también el versionado, el bloqueo y la regla de limpieza, porque region es un campo obligatorio en cada uno de esos recursos. Por eso en los manifiestos completos del final está puesto en todos.

El desarrollador se pide su almacén

Todo está en pie. Ahora la vista para la que se construyó todo esto:

apiVersion: shop.tienda-herramientas.example/v1
kind: ShopAlmacen
metadata:
  name: manuales
  namespace: equipo-repuestos
spec:
  region: eu-central-1
  diasRetencion: 30
kubectl apply -f almacen.yaml
kubectl get shopalmacen -n equipo-repuestos
NAME       SYNCED   READY   COMPOSITION       AGE
manuales   True     True    shopalmacen-s3    41s

Y debajo, lo que ha salido de ahí:

kubectl get managed -n equipo-repuestos
NAME                                    SYNCED   READY   AGE
bucket/manuales-x7k2m                   True     True    41s
bucketversioning/manuales-p4n8w         True     True    38s
bucketpublicaccessblock/manuales-d9     True     True    38s
bucketlifecycleconfiguration/manua-q3   True     True    37s

Cuatro recursos a partir de ocho líneas, y las tres reglas que antes alguien tenía que recordar a mano ya no son opcionales. Quien pide un ShopAlmacen recibe versionado, bloqueo y regla de limpieza, conozca los términos o no.

El camino de vuelta es igual de corto. Si se borra la ficha de pedido, desaparecen los cuatro recursos, porque le pertenecen.

Namespaces en lugar de Claims: qué cambió la versión 2

Si sigues guías más antiguas, en este punto vas a tropezar con un término que aquí no aparece: el Claim. Merece la pena situarlo una vez, porque la mayoría de ejemplos de la red todavía parten de él.

En el modelo antiguo, los Composite Resources vivían en el cluster, sin Namespace. Para que un equipo pudiera pedir algo de todos modos, había un segundo objeto dentro del Namespace, el Claim, que apuntaba al recurso real. Dos objetos para una cosa, solo para que la separación entre equipos funcionara.

En la versión 2, los Composite Resources viven ellos mismos en el Namespace, y con eso desaparece el rodeo. Los modos nuevos Namespaced y Cluster no conocen Claims. Quien todavía los necesite pone scope: LegacyCluster, que es de forma expresa el modo de compatibilidad hacia atrás.

Lo mismo vale para los recursos gestionados. También ellos están en casa dentro del Namespace en la versión 2, reconocible por la m del grupo de API. La variante antigua, a nivel de cluster, sigue funcionando, pero cuenta como herencia y está previsto retirarla más adelante. Para un montaje nuevo no hay entonces ningún motivo para empezar con el modelo antiguo.

En la práctica, al leer ejemplos ajenos eso significa: si allí pone kind: XPostgreSQLInstance junto a un Claim PostgreSQLInstance, es el modelo antiguo. Si allí pone apiextensions.crossplane.io/v2 y scope: Namespaced, es el nuevo.

Devolver estado: qué debe ver el desarrollador

Una API de plataforma que solo recibe y no devuelve nada es solo media API. El equipo de la búsqueda de repuestos tiene que enterarse de cómo se llama su almacén, si no, no puede ponerlo en la aplicación.

El camino de vuelta lo hace el Patch con ToCompositeFieldPath de la sección anterior. Rellena el campo declarado en la XRD bajo status:

kubectl get shopalmacen manuales -n equipo-repuestos -o jsonpath='{.status.nombreAlmacen}'
manuales-x7k2m

El nombre lleva un sufijo aleatorio, y eso es a propósito. Los nombres de almacenamiento de objetos son únicos a nivel global en muchos proveedores, un nombre fijo chocaría con el del segundo equipo. Por eso Crossplane genera un nombre único y lo escribe en la anotación crossplane.io/external-name. Quien necesite un nombre fijo pone esa misma anotación por su cuenta.

Para las credenciales existe un camino propio. Los detalles de conexión de un recurso, por ejemplo usuario y contraseña de una base de datos, no acaban en el estado sino en un Secret. El estado lo puede leer cualquiera que tenga permiso para ver la ficha de pedido, un secreto no pinta nada ahí.

Borrar: la regla que duele una vez

Borrar es la parte que las guías suelen dejarse fuera, y la que en la práctica más daño hace.

La regla básica es sencilla: si se borra la ficha de pedido, se borran también los recursos creados, y con ellos los objetos en el proveedor. Un almacén con manuales de montaje desaparece entonces, contenido incluido.

Eso es correcto casi siempre y a veces fatal. Por eso cada recurso gestionado tiene un campo para ello:

spec:
  deletionPolicy: Orphan

Con Orphan desaparece solo el objeto de Kubernetes, el objeto en el proveedor se queda. El valor por defecto es Delete. Para todo lo que guarde datos, Orphan en la plantilla es la elección más prudente, porque un almacén borrado por error no se recupera aplicando otra vez.

El segundo caso, el más incómodo, es aquel en el que ya no pasa nada. Si un objeto se queda colgado al borrar, casi siempre es por un Finalizer: Crossplane ha lanzado la orden de borrado en el proveedor, pero no recibe confirmación, porque faltan permisos o porque el recurso todavía está ocupado. Un almacén con contenido no se deja borrar en la mayoría de proveedores mientras haya ficheros dentro. El objeto se queda entonces en Terminating, y el motivo está en los events:

kubectl describe bucket manuales-x7k2m -n equipo-repuestos

Quitar el Finalizer a mano resuelve el síntoma y deja el recurso en el proveedor sin que ya nadie sepa de él. Es el freno de emergencia, no la solución.

Los manifiestos completos

Todo junto, en el orden en el que se aplica. El Provider, la Function y la ProviderConfig de las secciones anteriores se dan por hechos.

La ficha de pedido:

apiVersion: apiextensions.crossplane.io/v2
kind: CompositeResourceDefinition
metadata:
  name: shopalmacenes.shop.tienda-herramientas.example
spec:
  scope: Namespaced
  group: shop.tienda-herramientas.example
  names:
    kind: ShopAlmacen
    plural: shopalmacenes
  versions:
  - name: v1
    served: true
    referenceable: true
    schema:
      openAPIV3Schema:
        type: object
        properties:
          spec:
            type: object
            properties:
              region:
                description: Región en la que nace el almacén.
                type: string
              diasRetencion:
                description: Días que se conserva una versión antigua de imagen.
                type: integer
                default: 30
            required:
            - region
          status:
            type: object
            properties:
              nombreAlmacen:
                description: Nombre del almacén en el proveedor.
                type: string

La plantilla:

apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
  name: shopalmacen-s3
spec:
  compositeTypeRef:
    apiVersion: shop.tienda-herramientas.example/v1
    kind: ShopAlmacen
  mode: Pipeline
  pipeline:
  - step: construir-recursos
    functionRef:
      name: function-patch-and-transform
    input:
      apiVersion: pt.fn.crossplane.io/v1beta1
      kind: Resources
      resources:
      - name: almacen
        base:
          apiVersion: s3.aws.m.upbound.io/v1beta1
          kind: Bucket
          spec:
            forProvider: {}
            deletionPolicy: Orphan
            providerConfigRef:
              kind: ProviderConfig
              name: predeterminado
        patches:
        - type: FromCompositeFieldPath
          fromFieldPath: spec.region
          toFieldPath: spec.forProvider.region
        - type: ToCompositeFieldPath
          fromFieldPath: metadata.annotations[crossplane.io/external-name]
          toFieldPath: status.nombreAlmacen
      - name: versionado
        base:
          apiVersion: s3.aws.m.upbound.io/v1beta1
          kind: BucketVersioning
          spec:
            forProvider:
              bucketSelector:
                matchControllerRef: true
              versioningConfiguration:
                status: Enabled
            providerConfigRef:
              kind: ProviderConfig
              name: predeterminado
        patches:
        - type: FromCompositeFieldPath
          fromFieldPath: spec.region
          toFieldPath: spec.forProvider.region
      - name: bloqueo-publico
        base:
          apiVersion: s3.aws.m.upbound.io/v1beta1
          kind: BucketPublicAccessBlock
          spec:
            forProvider:
              bucketSelector:
                matchControllerRef: true
              blockPublicAcls: true
              blockPublicPolicy: true
              ignorePublicAcls: true
              restrictPublicBuckets: true
            providerConfigRef:
              kind: ProviderConfig
              name: predeterminado
        patches:
        - type: FromCompositeFieldPath
          fromFieldPath: spec.region
          toFieldPath: spec.forProvider.region
      - name: limpieza
        base:
          apiVersion: s3.aws.m.upbound.io/v1beta1
          kind: BucketLifecycleConfiguration
          spec:
            forProvider:
              bucketSelector:
                matchControllerRef: true
              rule:
              - id: versiones-antiguas-imagenes
                status: Enabled
                noncurrentVersionExpiration:
                - noncurrentDays: 30
            providerConfigRef:
              kind: ProviderConfig
              name: predeterminado
        patches:
        - type: FromCompositeFieldPath
          fromFieldPath: spec.region
          toFieldPath: spec.forProvider.region
        - type: FromCompositeFieldPath
          fromFieldPath: spec.diasRetencion
          toFieldPath: spec.forProvider.rule[0].noncurrentVersionExpiration[0].noncurrentDays

El pedido:

apiVersion: shop.tienda-herramientas.example/v1
kind: ShopAlmacen
metadata:
  name: manuales
  namespace: equipo-repuestos
spec:
  region: eu-central-1
  diasRetencion: 30

Los elementos de un vistazo

Elemento Grupo de API Dónde vive Para qué
Provider pkg.crossplane.io/v1 a nivel de cluster trae los tipos de recurso de un servicio
Function pkg.crossplane.io/v1 a nivel de cluster procesa la plantilla de una Composition
ProviderConfig <proveedor>.m.upbound.io/v1beta1 Namespace credenciales para recursos del mismo Namespace
ClusterProviderConfig <proveedor>.m.upbound.io/v1beta1 a nivel de cluster credenciales para todos los Namespaces
Managed Resource <servicio>.<proveedor>.m.upbound.io/v1beta1 Namespace un único objeto en el proveedor
CompositeResourceDefinition apiextensions.crossplane.io/v2 a nivel de cluster define el tipo propio y su esquema
Composition apiextensions.crossplane.io/v1 a nivel de cluster plantilla de lo que nace a raíz de un pedido
Composite Resource el grupo propio Namespace el pedido en sí

Las versiones mezcladas de la tabla no son una errata. La API de la XRD saltó a v2 con la versión 2, porque allí se añadió el campo scope, la Composition se quedó en v1.

Dos abreviaturas te van a salir al paso todo el rato: XRD para CompositeResourceDefinition y XR para Composite Resource.

¿Crossplane o una ejecución de infraestructura?

La pregunta se plantea casi siempre como comparación de herramientas y no lo es. Los dos describen infraestructura de forma declarativa, los dos son de código abierto, los dos pueden crear los mismos recursos. La diferencia está en el modelo de operación y en la cuestión de quién pide.

Una ejecución tiene un principio claro y un final claro. Eso la hace fácil de seguir, porque una persona lee el plan antes de ejecutar y después queda un registro. Para todo lo que pasa rara vez y quiere estar bien pensado, esa es la forma adecuada. Una red, un cluster, el fundamento vaya.

En el controlador falta ese momento. Nadie lee un plan antes, a cambio sostiene todo lo que pasa a menudo y siempre debe tener el mismo aspecto. El almacén de este artículo es ese caso: siempre lo mismo, pedido por gente cambiante de seis equipos.

El reparto habitual se sigue por eso de los dos modelos. La ejecución pone los cimientos, Crossplane entrega encima lo que los equipos se piden a sí mismos. Quien mezcle las dos cosas debería trazar el límite de forma consciente y anotarlo, porque dos herramientas sobre el mismo recurso acaban en un conflicto que ya nadie resuelve.

Trampas frecuentes

  • El Provider monolítico de un proveedor grande instala más de 900 tipos de recurso y puede bloquear el API server durante bastante tiempo. Instala un paquete por servicio en lugar del paquete recopilatorio.
  • Si en el grupo de API falta la m, o si aparece un Claim, la guía describe Crossplane 1. Mucho de eso sigue funcionando, pero para un montaje nuevo estás incorporando herencia desde el primer día.
  • Un nombre fijo en bucketRef no funciona en una plantilla, porque los nombres nacen solo con el pedido. El selector con matchControllerRef: true coge el recurso hermano del mismo pedido.
  • Una ProviderConfig vale solo en su propio Namespace. Si falta ahí, el recurso se queda en SYNCED: False, aunque en otro Namespace exista una con el mismo nombre. Para el uso entre cuentas está pensada ClusterProviderConfig.
  • SYNCED significa que el deseo ha llegado al proveedor, READY significa que el recurso es utilizable. La automatización que espera a SYNCED arranca demasiado pronto.
  • La entrada mediante un Secret con credenciales es cómoda y permanente. Al pasar a Workload Identity, el paso que se olvida con facilidad es la limpieza: hay que retirar las claves antiguas en el proveedor, si no siguen funcionando.
  • deletionPolicy está en Delete si no pone otra cosa. Para todo lo que guarde datos, Orphan va en la plantilla.
  • Crossplane solo corrige lo que él mismo gestiona, al ritmo del intervalo de sondeo, y solo en los campos que están en el manifiesto. Los recursos creados a mano y los campos sin poner quedan intactos.

Cuándo compensa Crossplane y cuándo no

Merece la pena cuando la misma clase de infraestructura se pide una y otra vez y quienes la piden no son los especialistas en ella. La tienda de herramientas con seis equipos que necesitan todos el mismo almacén es ese caso. El esfuerzo de la XRD y la Composition se paga a partir del tercer o cuarto pedido, antes de eso es esfuerzo extra y nada más.

A eso se suma el caso de que Kubernetes ya sea de todos modos el sitio donde se opera. Entonces el cluster no es una obra adicional, y RBAC, GitOps y monitorización valen también para la infraestructura.

En montajes de una sola vez el modelo no aguanta. Una red que nace una vez y luego se queda diez años gana poco con una comparación permanente y pierde el plan legible antes de ejecutar.

Tampoco aguanta cuando nadie quiere operar el cluster. Crossplane presupone un cluster que es él mismo infraestructura de producción, con actualizaciones, respaldo y guardia. Quien tuviera que montar ese cluster solo para Crossplane está comprando operación para ahorrar operación.

Y contra la voluntad de los equipos no sirve nunca. Una API de plataforma que nadie pide sale más cara que el ticket al que debía sustituir.

FAQ

¿Crossplane sustituye a una herramienta de infraestructura clásica?
Por regla general no. El patrón habitual es un reparto: la ejecución pone el fundamento como la red y el cluster, Crossplane entrega encima los recursos que los equipos se piden a sí mismos. Operar las dos cosas sobre el mismo recurso lleva a conflictos.

¿Qué pasa si alguien cambia un recurso a mano?
Si Crossplane gestiona ese recurso y el campo cambiado está puesto en el manifiesto, el cambio se revierte en la siguiente comparación. El núcleo de Crossplane comprueba por defecto cada minuto, los paquetes generados a partir de providers de Terraform comprueban por defecto cada diez minutos por recurso.

¿Necesita cada equipo su propio cluster?
No, ese es el propósito de los Namespaces en la versión 2. Los Composite Resources y los Managed Resources viven en el Namespace, las credenciales a través de la ProviderConfig también, y de la separación se encarga RBAC.

¿Cuál es la diferencia entre XRD y Composition?
La XRD define el tipo y su esquema, es decir, qué se puede pedir. La Composition define qué nace a raíz de un pedido. Para una XRD puede haber varias Compositions, por ejemplo una por cloud.

¿Sigue existiendo Crossplane con Claims?
Los modos nuevos Namespaced y Cluster no conocen Claims. Quien los necesite pone scope: LegacyCluster, el modo de compatibilidad hacia atrás. Para un montaje nuevo no hay motivo para eso.

¿Cuántas CustomResourceDefinitions instala un Provider?
En el paquete monolítico de un proveedor grande son más de 900. Las Provider Families lo resuelven porque instalas solo el paquete por servicio, y para el almacenamiento de objetos son entonces unas dos docenas largas de tipos.

¿Y las tareas recurrentes que no crean ningún recurso?
Para eso existe desde la versión 2 el tipo Operation, que ejecuta una pipeline de funciones hasta el final una sola vez, de forma planificada o a raíz de un evento, parecido a un Job. Está marcado como alpha, así que todavía no está pensado para producción.

Conclusión

Crossplane desplaza la pregunta. Ya no es cómo se crea un almacén, sino quién puede pedirse uno y qué vale automáticamente al hacerlo. La tienda de herramientas del principio no se ha ahorrado solo un ticket, ha excluido dos clases de fallo: la copia con omisión, porque ya no queda nada que copiar, y el toque a mano inadvertido, porque el controlador lo revierte.

Eso se paga con un cluster que pasa a ser infraestructura de producción, y con una API de plataforma que tiene que pertenecer a alguien. Las dos cosas son manejables, pero las dos son trabajo de verdad y deberían estar decididas antes de la primera XRD.

El siguiente paso sensato es pequeño: coge un cluster local, instala el paquete de un único servicio y crea un único recurso gestionado. Después cámbialo a mano en el proveedor y espera la comparación. Ese único intento explica el modelo mejor que cualquier descripción.

Fuentes

  • Documentación de Crossplane, secciones «What’s New in v2», «Composite Resource Definitions», «Compositions», «Managed Resources», «Providers» y «Get Started With Composition», estado de la versión 2.3
  • Anuncio de Crossplane 2.0 en el Crossplane-Blog
  • Crossplane-Blog sobre el crecimiento de las CustomResourceDefinitions y sobre las Provider Families
  • Anuncio de la CNCF sobre la graduación de Crossplane
  • Referencia del provider de AWS S3 en el Upbound Marketplace para los nombres de campo de los recursos

Todos los manifiestos y el ejemplo de la tienda de herramientas son propios y están escritos contra Crossplane 2.3. Las salidas de la línea de comandos están abreviadas y adaptadas en los nombres.

$ lang DE EN ES