Al cerrar la lección anterior quedó apuntada una anomalía. En el módulo 4 instalamos cert-manager y empezamos a escribir manifiestos con kind: Certificate y kind: ClusterIssuer. En el módulo 5 creamos objetos kind: VolumeSnapshot. Ninguno de esos tipos existe en Kubernetes: no vienen en el binario del apiserver, no aparecen en la especificación original de la API. Y sin embargo el clúster los trata exactamente igual que a un Deployment: kubectl get, kubectl describe, kubectl explain, validación de campos, control de acceso RBAC, almacenamiento en etcd, kubectl edit.

Eso no es magia. Es la característica más importante de Kubernetes desde el punto de vista del ecosistema: la API se puede extender. Cualquiera puede añadir tipos de objeto nuevos y, a partir de ese momento, el clúster los gestiona como si siempre hubieran estado ahí.

En esta lección aprenderemos a hacerlo. Definiremos el tipo RutaProgramada para Rutas Norte —origen, destino, horarios, plazas, vehículo— con validación real, y lo usaremos con las mismas herramientas de siempre. Y terminaremos con la advertencia más importante del tema: un CRD sin un controlador detrás es solo una base de datos con formulario. Quien pone el software detrás es el operador, y ese es el tema de la lección siguiente.

Contenido

  1. Qué significa extender la API de Kubernetes
  2. Las tres formas de extender Kubernetes
  3. Anatomía del objeto CustomResourceDefinition
  4. El esquema OpenAPI v3: validación declarativa
  5. Subrecursos: status y scale
  6. additionalPrinterColumns: kubectl get útil
  7. Ejemplo completo: el CRD RutaProgramada
  8. Usar el recurso como cualquier objeto nativo
  9. Versionado y conversión
  10. Cuándo NO crear un CRD

  1. Qué significa extender la API de Kubernetes

Conviene tener claro qué es realmente el apiserver. No es "el cerebro de Kubernetes": es un servidor REST con almacenamiento en etcd, autenticación, autorización, validación y notificación de cambios. Los objetos que sirve —Pods, Services, Deployments— son datos con esquema. Toda la inteligencia está en los controladores que observan esos datos y actúan.

Extender la API significa enseñarle un tipo de objeto nuevo. A partir de ese momento, ese tipo obtiene gratis toda la infraestructura del apiserver:

Lo que obtienes gratis Qué significa
Endpoints REST /apis/<grupo>/<versión>/namespaces/<ns>/<plural> con GET, POST, PUT, PATCH, DELETE, WATCH
Persistencia en etcd Alta disponibilidad, transacciones, historial de revisiones
Validación Rechazo de manifiestos mal formados antes de guardarlos
RBAC Roles y permisos sobre tu tipo, como sobre cualquier otro (08-01)
kubectl completo get, describe, edit, apply, delete, explain, label, patch
Auditoría Cada cambio queda registrado en el log de auditoría
Watch Notificación en tiempo real de cambios: la base de los controladores
metadata estándar labels, annotations, ownerReferences, finalizers, resourceVersion

Esa lista es la razón por la que todo el ecosistema de Kubernetes se construye así. Cuando cert-manager quiso modelar "un certificado que debe existir y renovarse", no inventó un formato de configuración propio ni un servidor aparte: definió un tipo Certificate y dejó que Kubernetes hiciera el resto.

Recursos personalizados que ya has usado en este curso, probablemente sin darte cuenta:

Recurso Grupo de API Quién lo aporta Dónde apareció
Certificate, Issuer, ClusterIssuer cert-manager.io cert-manager 04-05
VolumeSnapshot, VolumeSnapshotClass snapshot.storage.k8s.io snapshot-controller 05-05
Backup, Restore, Schedule velero.io Velero 05-06
ServiceMonitor, PrometheusRule monitoring.coreos.com Prometheus Operator 07-03
IPPool, NetworkSet crd.projectcalico.org Calico 04-01

Puedes verlos en tu propio clúster:

kubectl get crds
NAME                                         CREATED AT
certificates.cert-manager.io                 2026-07-12T09:14:22Z
challenges.acme.cert-manager.io              2026-07-12T09:14:22Z
clusterissuers.cert-manager.io               2026-07-12T09:14:22Z
issuers.cert-manager.io                      2026-07-12T09:14:23Z
volumesnapshotclasses.snapshot.storage.k8s.io  2026-07-19T11:02:41Z
volumesnapshotcontents.snapshot.storage.k8s.io 2026-07-19T11:02:41Z
volumesnapshots.snapshot.storage.k8s.io        2026-07-19T11:02:41Z

Y comprobar cuáles de los tipos disponibles son nativos y cuáles añadidos:

kubectl api-resources --api-group=cert-manager.io
NAME              SHORTNAMES   APIVERSION              NAMESPACED   KIND
certificaterequests  cr,crs    cert-manager.io/v1      true         CertificateRequest
certificates         cert,certs cert-manager.io/v1     true         Certificate
clusterissuers                 cert-manager.io/v1      false        ClusterIssuer
issuers                        cert-manager.io/v1      true         Issuer

  1. Las tres formas de extender Kubernetes

Hay tres mecanismos, con propósitos distintos. Conviene distinguirlos porque a menudo se confunden.

Mecanismo Qué añade Complejidad Cuándo usarlo
CustomResourceDefinition (CRD) Tipos de objeto nuevos, servidos por el propio apiserver Baja: un YAML El 95 % de los casos
Capa de agregación de la API Un servidor de API propio que el apiserver delega Alta: hay que escribir y operar un servidor Datos que no van en etcd o lógica de lectura especial
Webhooks de admisión Interceptar y modificar o rechazar objetos ya existentes Media: un servidor HTTPS Validar o inyectar sobre tipos nativos o propios

CRD

Declaras el esquema en un YAML y el apiserver empieza a servir el tipo. Los objetos se guardan en etcd como cualquier otro. Es lo que usan cert-manager, Velero, Prometheus Operator y prácticamente todo el ecosistema.

Limitaciones: los datos viven en etcd (no es sitio para volúmenes de datos grandes ni para escrituras muy frecuentes) y no puedes personalizar cómo se leen o se almacenan.

Capa de agregación

Registras un objeto APIService que le dice al apiserver "para el grupo metrics.k8s.io, delega en este Service". El apiserver actúa de proxy hacia tu servidor.

El ejemplo canónico es el metrics-server que activamos como addon en 01-04: sus métricas son datos volátiles de alta frecuencia que no deben ir a etcd, así que se sirven desde memoria mediante agregación. Lo veremos en 07-02.

kubectl get apiservices | grep -v Local
NAME                     SERVICE                      AVAILABLE   AGE
v1beta1.metrics.k8s.io   kube-system/metrics-server   True        14d

Requiere escribir un servidor de API completo (autenticación, autorización, versionado) y operarlo con alta disponibilidad. La regla es sencilla: si dudas, usa un CRD.

Webhooks de admisión

No añaden tipos: interceptan peticiones a tipos que ya existen, en el camino entre la validación del apiserver y el almacenamiento.

  • Mutating webhook: modifica el objeto. Es lo que hace una malla de servicios al inyectar un sidecar en cada pod.
  • Validating webhook: acepta o rechaza. Es lo que aplica políticas de seguridad complejas.

Un CRD puede tener un webhook de validación asociado para reglas que el esquema OpenAPI no puede expresar, como "la hora de llegada debe ser posterior a la de salida" o "no puede haber dos rutas con el mismo código".

  1. Anatomía del objeto CustomResourceDefinition

Un CRD es, él mismo, un objeto de Kubernetes del grupo apiextensions.k8s.io/v1. Esta es su estructura, con el ejemplo de RutaProgramada que desarrollaremos:

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  # OBLIGATORIO: el nombre debe ser exactamente <plural>.<group>
  name: rutasprogramadas.rutasnorte.example
spec:
  group: rutasnorte.example
  scope: Namespaced
  names:
    kind: RutaProgramada
    listKind: RutaProgramadaList
    plural: rutasprogramadas
    singular: rutaprogramada
    shortNames: ["ruta", "rutas"]
    categories: ["rutasnorte", "all"]
  versions:
    - name: v1
      served: true
      storage: true
      schema:
        openAPIV3Schema:
          # ... el esquema, apartado 4 ...

group

El grupo de API bajo el que vive el tipo. Debe ser un nombre de dominio que controles o que sea claramente tuyo, para evitar colisiones. Convención habitual: <producto>.<tu-dominio>.

El grupo se combina con la versión para formar el apiVersion de tus objetos: rutasnorte.example/v1.

names

Campo Qué es Ejemplo
kind El kind: del manifiesto, en CamelCase singular RutaProgramada
listKind El kind de la lista; por convención <kind>List RutaProgramadaList
plural Nombre en la URL de la API y en kubectl, en minúsculas rutasprogramadas
singular Alias singular para kubectl rutaprogramada
shortNames Abreviaturas: kubectl get rutas ["ruta", "rutas"]
categories Grupos para kubectl get <categoria> ["rutasnorte", "all"]

Los categories son más útiles de lo que parecen. Con categories: ["rutasnorte"], un solo comando lista todos los recursos personalizados de tu plataforma:

kubectl get rutasnorte -n rutas-norte-pro

Cuidado con incluir all: hace que tus objetos aparezcan en kubectl get all, que ya es un comando ruidoso. Úsalo solo si tu recurso es realmente de primer nivel para los usuarios del clúster.

scope

Valor Significado Ejemplos reales
Namespaced El objeto vive en un namespace Certificate, VolumeSnapshot, RutaProgramada
Cluster Es global al clúster, sin namespace ClusterIssuer, VolumeSnapshotClass, StorageClass

La decisión es importante y no se puede cambiar después sin borrar el CRD (y con él todos sus objetos). Criterio: si distintos equipos o entornos deben tener versiones separadas y aisladas del recurso, es Namespaced. Si representa configuración global de infraestructura, es Cluster.

Para RutaProgramada elegimos Namespaced: las rutas de rutas-norte-dev son datos de prueba y no deben mezclarse con las de rutas-norte-pro.

versions

Una lista, porque un CRD puede servir varias versiones a la vez:

  versions:
    - name: v1alpha1
      served: false      # ya no se sirve: los clientes antiguos reciben error
      storage: false
      schema: {...}
    - name: v1beta1
      served: true       # se sirve, para clientes que aún la usan
      storage: false
      schema: {...}
    - name: v1
      served: true
      storage: true      # EXACTAMENTE UNA versión puede tener storage: true
      schema: {...}
Campo Significado
served Si el apiserver acepta peticiones en esa versión
storage Si los objetos se guardan en etcd con ese esquema. Solo una versión puede tenerlo

La distinción es sutil pero crucial. Un objeto creado como v1beta1 se convierte a la versión de almacenamiento antes de guardarse, y se convierte de vuelta al leerlo en v1beta1. Los datos en etcd tienen una sola forma; las versiones son vistas sobre ellos. Volveremos a esto en el apartado 9.

  1. El esquema OpenAPI v3: validación declarativa

Sin esquema, un recurso personalizado aceptaría cualquier YAML. Con esquema, el apiserver valida antes de guardar y rechaza lo que no encaje, con un mensaje concreto. Es lo que hace que un CRD se sienta como un tipo nativo.

El esquema va en versions[].schema.openAPIV3Schema y es un subconjunto de OpenAPI v3.

Tipos y estructura básica

      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              required: ["origen", "destino", "horarios", "plazas"]
              properties:
                origen:
                  type: string
                  minLength: 2
                  maxLength: 60
                plazas:
                  type: integer
                  minimum: 1
                  maximum: 90
                activa:
                  type: boolean
                  default: true
                horarios:
                  type: array
                  minItems: 1
                  maxItems: 24
                  items:
                    type: string

Restricciones disponibles por tipo:

Tipo Restricciones Uso típico
string minLength, maxLength, pattern, enum, format Códigos, nombres, matrículas
integer minimum, maximum, exclusiveMinimum, multipleOf Plazas, precios en céntimos
number Igual que integer, con decimales Coordenadas
boolean Interruptores
array minItems, maxItems, uniqueItems, items Horarios, paradas
object properties, required, additionalProperties Estructuras anidadas

required y default

                origen:
                  type: string
                  # sin default: si está en required, es obligatorio
                activa:
                  type: boolean
                  default: true      # si no se indica, el apiserver lo pone
                clase:
                  type: string
                  enum: ["estandar", "supra", "nocturno"]
                  default: "estandar"

Los valores por defecto los aplica el apiserver al guardar, no kubectl. Consecuencia práctica: si lees el objeto después de crearlo, verás los defaults ya escritos. Eso los hace fiables para cualquier consumidor.

Validación por patrón y por enumeración

                codigo:
                  type: string
                  # Formato de Rutas Norte: dos letras, guion, tres dígitos (RN-041)
                  pattern: '^[A-Z]{2}-[0-9]{3}$'
                matriculaVehiculo:
                  type: string
                  pattern: '^[0-9]{4}[A-Z]{3}$'
                horaSalida:
                  type: string
                  pattern: '^([01][0-9]|2[0-3]):[0-5][0-9]$'
                diasOperacion:
                  type: array
                  minItems: 1
                  maxItems: 7
                  uniqueItems: true
                  items:
                    type: string
                    enum: ["L", "M", "X", "J", "V", "S", "D"]

Los pattern usan sintaxis RE2 (la de Go), no PCRE. No admiten lookahead ni lookbehind ni referencias hacia atrás. Para reglas más complejas hay dos opciones: reglas de validación CEL (x-kubernetes-validations) o un webhook de validación.

x-kubernetes-preserve-unknown-fields

Por defecto, el apiserver elimina silenciosamente cualquier campo que no esté en el esquema. Esto se llama poda (pruning) y es una de las causas más desconcertantes de "mi campo desapareció": aplicas un manifiesto con un campo mal escrito, kubectl no protesta, y al leer el objeto ese campo no está.

Para permitir campos arbitrarios en una parte concreta:

                metadatosExternos:
                  type: object
                  x-kubernetes-preserve-unknown-fields: true
                  description: "Datos libres del sistema de venta heredado"

Úsalo con moderación: cada punto donde lo pones es un punto donde pierdes validación y donde un error tipográfico pasa desapercibido.

Descripciones

                plazas:
                  type: integer
                  minimum: 1
                  maximum: 90
                  description: "Plazas totales del vehículo asignado a esta ruta"

Las description no son decorativas: son lo que devuelve kubectl explain. Escribirlas convierte tu CRD en un tipo autodocumentado, y es la diferencia entre un recurso que la gente sabe usar y uno que requiere leer el código fuente.

  1. Subrecursos: status y scale

    - name: v1
      served: true
      storage: true
      subresources:
        status: {}
        scale:
          specReplicasPath: .spec.vehiculosAsignados
          statusReplicasPath: .status.vehiculosActivos
          labelSelectorPath: .status.selector
      schema:
        openAPIV3Schema: {...}

El subrecurso status

Activar status: {} tiene tres efectos concretos:

  1. Se habilita el endpoint /status, que se actualiza de forma independiente del objeto principal.
  2. Las escrituras sobre el objeto principal ignoran cambios en .status.
  3. Las escrituras sobre /status ignoran cambios en .spec.

Esa separación no es burocracia: refleja la división fundamental del modelo declarativo que estudiamos en 01-06.

spec status
Quién escribe El usuario o el sistema de despliegue El controlador
Qué expresa El estado deseado El estado observado
Se versiona en Git No
Se puede reconstruir No: es la intención Sí: es una observación

Sin este subrecurso, un controlador que quisiera actualizar el estado tendría que hacer un update del objeto completo, y correría el riesgo de sobrescribir un cambio de spec que el usuario acabase de hacer. Y a la inversa: un kubectl apply del usuario borraría el estado que el controlador acababa de escribir.

Actívalo siempre en cualquier CRD que vaya a tener un controlador.

Un patrón universal en Kubernetes para el status son las condiciones:

            status:
              type: object
              properties:
                fase:
                  type: string
                  enum: ["Pendiente", "Programada", "Activa", "Cancelada"]
                plazasVendidas:
                  type: integer
                condiciones:
                  type: array
                  items:
                    type: object
                    required: ["type", "status"]
                    properties:
                      type:
                        type: string
                      status:
                        type: string
                        enum: ["True", "False", "Unknown"]
                      lastTransitionTime:
                        type: string
                        format: date-time
                      reason:
                        type: string
                      message:
                        type: string

Es la misma estructura que has visto en kubectl describe pod (Ready, PodScheduled, Initialized) y en kubectl describe node. Seguir la convención hace que tus objetos se lean como los nativos y que herramientas genéricas como kubectl wait --for=condition=... funcionen sobre ellos.

El subrecurso scale

Habilita kubectl scale sobre tu propio recurso, y con él la posibilidad de que un HorizontalPodAutoscaler (09-01) actúe sobre él.

        scale:
          specReplicasPath: .spec.vehiculosAsignados      # dónde está el número deseado
          statusReplicasPath: .status.vehiculosActivos    # dónde está el observado
          labelSelectorPath: .status.selector             # selector de los objetos gestionados
kubectl scale rutaprogramada rn-041-bilbao-santander -n rutas-norte-pro --replicas=3
rutaprogramada.rutasnorte.example/rn-041-bilbao-santander scaled

Lo que hace el comando es escribir un 3 en .spec.vehiculosAsignados. Que eso se traduzca en tres autobuses es trabajo del controlador; el subrecurso solo estandariza la interfaz.

  1. additionalPrinterColumns: kubectl get útil

Sin configurar nada, kubectl get de un recurso personalizado muestra dos columnas inútiles:

NAME                      AGE
rn-041-bilbao-santander   3m

Con additionalPrinterColumns se declara qué mostrar, mediante rutas JSONPath sobre el objeto:

      additionalPrinterColumns:
        - name: Código
          type: string
          jsonPath: .spec.codigo
        - name: Origen
          type: string
          jsonPath: .spec.origen
        - name: Destino
          type: string
          jsonPath: .spec.destino
        - name: Plazas
          type: integer
          jsonPath: .spec.plazas
        - name: Fase
          type: string
          jsonPath: .status.fase
        - name: Vendidas
          type: integer
          jsonPath: .status.plazasVendidas
          priority: 1        # solo con -o wide
        - name: Antigüedad
          type: date
          jsonPath: .metadata.creationTimestamp

Resultado:

NAME                      CÓDIGO   ORIGEN    DESTINO      PLAZAS   FASE     ANTIGÜEDAD
rn-041-bilbao-santander   RN-041   Bilbao    Santander    55       Activa   3m
rn-088-oviedo-leon        RN-088   Oviedo    León         38       Activa   3m

Detalles:

  • type admite string, integer, number, boolean y date. Con date, kubectl formatea como antigüedad relativa ("3m", "2d").
  • priority: 0 (por defecto) muestra la columna siempre; priority: 1 solo con -o wide.
  • La columna NAME está siempre y no se configura.

Es un detalle pequeño con un impacto enorme en la usabilidad. Compara mentalmente kubectl get pods —que muestra READY, STATUS, RESTARTS y AGE— con lo que sería si solo mostrara nombre y edad.

  1. Ejemplo completo: el CRD RutaProgramada

Juntamos todo. Rutas Norte quiere modelar sus rutas como objetos de Kubernetes, de forma que el equipo de operaciones las gestione con las mismas herramientas y el mismo flujo de GitOps que el resto de la plataforma.

# k8s/base/crd-rutaprogramada.yaml
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: rutasprogramadas.rutasnorte.example
  labels:
    app.kubernetes.io/part-of: rutas-norte
spec:
  group: rutasnorte.example
  scope: Namespaced
  names:
    kind: RutaProgramada
    listKind: RutaProgramadaList
    plural: rutasprogramadas
    singular: rutaprogramada
    shortNames: ["ruta", "rutas"]
    categories: ["rutasnorte"]
  versions:
    - name: v1
      served: true
      storage: true
      subresources:
        status: {}
      additionalPrinterColumns:
        - name: Código
          type: string
          jsonPath: .spec.codigo
        - name: Origen
          type: string
          jsonPath: .spec.origen
        - name: Destino
          type: string
          jsonPath: .spec.destino
        - name: Plazas
          type: integer
          jsonPath: .spec.plazas
        - name: Fase
          type: string
          jsonPath: .status.fase
        - name: Vendidas
          type: integer
          jsonPath: .status.plazasVendidas
          priority: 1
        - name: Antigüedad
          type: date
          jsonPath: .metadata.creationTimestamp
      schema:
        openAPIV3Schema:
          type: object
          description: "Una ruta de autobús programada de Rutas Norte S.L."
          required: ["spec"]
          properties:
            spec:
              type: object
              description: "Estado deseado de la ruta"
              required: ["codigo", "origen", "destino", "horarios", "plazas"]
              properties:
                codigo:
                  type: string
                  description: "Código comercial de la ruta, formato XX-999 (ej. RN-041)"
                  pattern: '^[A-Z]{2}-[0-9]{3}$'
                origen:
                  type: string
                  description: "Ciudad de origen del trayecto"
                  minLength: 2
                  maxLength: 60
                destino:
                  type: string
                  description: "Ciudad de destino del trayecto"
                  minLength: 2
                  maxLength: 60
                duracionMinutos:
                  type: integer
                  description: "Duración estimada del trayecto en minutos"
                  minimum: 10
                  maximum: 1440
                  default: 120
                plazas:
                  type: integer
                  description: "Plazas totales ofertadas en cada salida"
                  minimum: 1
                  maximum: 90
                clase:
                  type: string
                  description: "Categoría comercial del servicio"
                  enum: ["estandar", "supra", "nocturno"]
                  default: "estandar"
                activa:
                  type: boolean
                  description: "Si la ruta admite ventas actualmente"
                  default: true
                horarios:
                  type: array
                  description: "Horas de salida diarias en formato HH:MM"
                  minItems: 1
                  maxItems: 24
                  uniqueItems: true
                  items:
                    type: string
                    pattern: '^([01][0-9]|2[0-3]):[0-5][0-9]$'
                diasOperacion:
                  type: array
                  description: "Días de la semana en que opera la ruta"
                  minItems: 1
                  maxItems: 7
                  uniqueItems: true
                  default: ["L", "M", "X", "J", "V", "S", "D"]
                  items:
                    type: string
                    enum: ["L", "M", "X", "J", "V", "S", "D"]
                paradasIntermedias:
                  type: array
                  description: "Paradas entre origen y destino, en orden"
                  maxItems: 20
                  items:
                    type: object
                    required: ["localidad", "minutoDesdeSalida"]
                    properties:
                      localidad:
                        type: string
                        minLength: 2
                        maxLength: 60
                      minutoDesdeSalida:
                        type: integer
                        minimum: 1
                        maximum: 1439
                vehiculo:
                  type: object
                  description: "Vehículo asignado a la ruta"
                  required: ["matricula"]
                  properties:
                    matricula:
                      type: string
                      description: "Matrícula española sin separadores (ej. 4471BCD)"
                      pattern: '^[0-9]{4}[A-Z]{3}$'
                    modelo:
                      type: string
                      maxLength: 80
                    accesible:
                      type: boolean
                      default: true
                precioBaseCentimos:
                  type: integer
                  description: "Precio base del billete en céntimos de euro"
                  minimum: 0
                  maximum: 100000
                metadatosVentaHeredada:
                  type: object
                  description: "Campos libres del sistema de venta heredado"
                  x-kubernetes-preserve-unknown-fields: true
            status:
              type: object
              description: "Estado observado, escrito por el controlador"
              properties:
                fase:
                  type: string
                  enum: ["Pendiente", "Programada", "Activa", "Cancelada"]
                plazasVendidas:
                  type: integer
                  minimum: 0
                ultimaSalidaProgramada:
                  type: string
                  format: date-time
                observedGeneration:
                  type: integer
                  description: "metadata.generation que el controlador procesó por última vez"
                condiciones:
                  type: array
                  items:
                    type: object
                    required: ["type", "status"]
                    properties:
                      type:
                        type: string
                      status:
                        type: string
                        enum: ["True", "False", "Unknown"]
                      lastTransitionTime:
                        type: string
                        format: date-time
                      reason:
                        type: string
                        maxLength: 128
                      message:
                        type: string
                        maxLength: 512
kubectl apply -f k8s/base/crd-rutaprogramada.yaml
customresourcedefinition.apiextensions.k8s.io/rutasprogramadas.rutasnorte.example created
kubectl get crd rutasprogramadas.rutasnorte.example
NAME                                   CREATED AT
rutasprogramadas.rutasnorte.example    2026-08-05T20:11:34Z

A partir de este instante, RutaProgramada es un tipo de primera clase en el clúster. Nadie ha reiniciado nada, y todos los kubectl del mundo que apunten a este clúster ya lo conocen.

Instancias

# k8s/entornos/pro/rutas-programadas.yaml
apiVersion: rutasnorte.example/v1
kind: RutaProgramada
metadata:
  name: rn-041-bilbao-santander
  namespace: rutas-norte-pro
  labels:
    app.kubernetes.io/part-of: rutas-norte
    entorno: pro
    corredor: cantabrico
spec:
  codigo: "RN-041"
  origen: "Bilbao"
  destino: "Santander"
  duracionMinutos: 95
  plazas: 55
  clase: "estandar"
  horarios: ["07:00", "09:30", "12:00", "15:30", "18:00", "20:30"]
  diasOperacion: ["L", "M", "X", "J", "V", "S", "D"]
  paradasIntermedias:
    - localidad: "Castro Urdiales"
      minutoDesdeSalida: 40
    - localidad: "Laredo"
      minutoDesdeSalida: 58
  vehiculo:
    matricula: "4471BCD"
    modelo: "Setra S 415 (ficticio)"
    accesible: true
  precioBaseCentimos: 1150
---
apiVersion: rutasnorte.example/v1
kind: RutaProgramada
metadata:
  name: rn-088-oviedo-leon
  namespace: rutas-norte-pro
  labels:
    app.kubernetes.io/part-of: rutas-norte
    entorno: pro
    corredor: noroeste
spec:
  codigo: "RN-088"
  origen: "Oviedo"
  destino: "León"
  duracionMinutos: 130
  plazas: 38
  clase: "supra"
  horarios: ["06:45", "14:15", "19:45"]
  diasOperacion: ["L", "M", "X", "J", "V"]
  vehiculo:
    matricula: "9902XKL"
    accesible: true
  precioBaseCentimos: 1490
kubectl apply -f k8s/entornos/pro/rutas-programadas.yaml
rutaprogramada.rutasnorte.example/rn-041-bilbao-santander created
rutaprogramada.rutasnorte.example/rn-088-oviedo-leon created

La validación en acción

Esta es la parte que demuestra que el esquema no es decorativo. Un manifiesto con varios errores:

apiVersion: rutasnorte.example/v1
kind: RutaProgramada
metadata:
  name: ruta-invalida
  namespace: rutas-norte-dev
spec:
  codigo: "rn41"                    # no cumple ^[A-Z]{2}-[0-9]{3}$
  origen: "A"                       # minLength es 2
  destino: "Gijón"
  plazas: 250                       # maximum es 90
  clase: "premium"                  # no está en el enum
  horarios: ["25:00"]               # hora inválida
  vehiculo:
    matricula: "4471-BCD"           # el guion no está permitido
kubectl apply -f /tmp/ruta-invalida.yaml
The RutaProgramada "ruta-invalida" is invalid:
* spec.codigo: Invalid value: "rn41": spec.codigo in body should match '^[A-Z]{2}-[0-9]{3}$'
* spec.origen: Invalid value: "A": spec.origen in body should be at least 2 chars long
* spec.plazas: Invalid value: 250: spec.plazas in body should be less than or equal to 90
* spec.clase: Unsupported value: "premium": supported values: "estandar", "supra", "nocturno"
* spec.horarios[0]: Invalid value: "25:00": spec.horarios[0] in body should match '^([01][0-9]|2[0-3]):[0-5][0-9]$'
* spec.vehiculo.matricula: Invalid value: "4471-BCD": spec.vehiculo.matricula in body should match '^[0-9]{4}[A-Z]{3}$'

Seis errores, todos detectados antes de guardar nada, con la ruta exacta del campo y la regla violada. Y también los omitidos:

kubectl apply -f - <<'EOF'
apiVersion: rutasnorte.example/v1
kind: RutaProgramada
metadata:
  name: ruta-incompleta
  namespace: rutas-norte-dev
spec:
  codigo: "RN-999"
  origen: "Burgos"
EOF
The RutaProgramada "ruta-incompleta" is invalid:
* spec.destino: Required value
* spec.horarios: Required value
* spec.plazas: Required value

Esta validación gratuita, sin escribir una línea de código, es la razón por la que merece la pena invertir tiempo en un buen esquema.

  1. Usar el recurso como cualquier objeto nativo

Todo lo que sabes de kubectl funciona ya sobre RutaProgramada.

kubectl get rutas -n rutas-norte-pro
NAME                      CÓDIGO   ORIGEN   DESTINO     PLAZAS   FASE   ANTIGÜEDAD
rn-041-bilbao-santander   RN-041   Bilbao   Santander   55              2m
rn-088-oviedo-leon        RN-088   Oviedo   León        38              2m

La columna FASE está vacía porque .status no lo escribe nadie: no hay controlador. Ese vacío es la lección del apartado 10.

kubectl get rutas -n rutas-norte-pro -l corredor=cantabrico
kubectl get rutaprogramada rn-041-bilbao-santander -n rutas-norte-pro -o yaml | head -30
apiVersion: rutasnorte.example/v1
kind: RutaProgramada
metadata:
  creationTimestamp: "2026-08-05T20:14:02Z"
  generation: 1
  labels:
    app.kubernetes.io/part-of: rutas-norte
    corredor: cantabrico
    entorno: pro
  name: rn-041-bilbao-santander
  namespace: rutas-norte-pro
  resourceVersion: "184722"
  uid: 3f1a9c04-8e2b-4c71-9a55-71b0e2d8c4f3
spec:
  activa: true                  # <- default aplicado por el apiserver
  clase: estandar
  codigo: RN-041
  destino: Santander
  diasOperacion: [L, M, X, J, V, S, D]
  duracionMinutos: 95

Obsérvese activa: true: no estaba en el manifiesto y el apiserver lo escribió a partir del default del esquema.

kubectl explain

kubectl explain rutaprogramada.spec.vehiculo
GROUP:      rutasnorte.example
KIND:       RutaProgramada
VERSION:    v1

FIELD: vehiculo <Object>

DESCRIPTION:
    Vehículo asignado a la ruta

FIELDS:
  accesible <boolean>
  matricula <string> -required-
    Matrícula española sin separadores (ej. 4471BCD)
  modelo    <string>

La documentación sale directamente del esquema. Un compañero que nunca haya visto este CRD puede descubrirlo por sí solo, sin leer código ni buscar un wiki.

describe, edit, patch, label

kubectl describe rutaprogramada rn-088-oviedo-leon -n rutas-norte-pro
Name:         rn-088-oviedo-leon
Namespace:    rutas-norte-pro
Labels:       app.kubernetes.io/part-of=rutas-norte
              corredor=noroeste
              entorno=pro
API Version:  rutasnorte.example/v1
Kind:         RutaProgramada
Spec:
  Activa:               true
  Clase:                supra
  Codigo:               RN-088
  Destino:              León
  Dias Operacion:       L, M, X, J, V
  Duracion Minutos:     130
  Horarios:             06:45, 14:15, 19:45
  Origen:               Oviedo
  Plazas:               38
  Precio Base Centimos: 1490
  Vehiculo:
    Accesible:  true
    Matricula:  9902XKL
Events:         <none>
# Editar interactivamente, con validación al guardar
kubectl edit rutaprogramada rn-041-bilbao-santander -n rutas-norte-pro

# Parchear un campo
kubectl patch rutaprogramada rn-041-bilbao-santander -n rutas-norte-pro \
  --type=merge -p '{"spec":{"precioBaseCentimos":1250}}'

# Etiquetar
kubectl label rutaprogramada rn-041-bilbao-santander -n rutas-norte-pro temporada=verano

# Observar cambios en tiempo real: la base de cualquier controlador
kubectl get rutas -n rutas-norte-pro --watch

Escribir el status

Como habilitamos el subrecurso, el estado se escribe por su propio endpoint. Un controlador haría esto mediante la API; a mano, con kubectl:

kubectl patch rutaprogramada rn-041-bilbao-santander -n rutas-norte-pro \
  --subresource=status --type=merge -p '{
    "status": {
      "fase": "Activa",
      "plazasVendidas": 37,
      "observedGeneration": 1,
      "condiciones": [{
        "type": "VehiculoAsignado",
        "status": "True",
        "reason": "MatriculaValida",
        "message": "Vehículo 4471BCD asignado y disponible",
        "lastTransitionTime": "2026-08-05T20:20:00Z"
      }]
    }
  }'

kubectl get rutas -n rutas-norte-pro -o wide
NAME                      CÓDIGO   ORIGEN   DESTINO     PLAZAS   FASE     VENDIDAS   ANTIGÜEDAD
rn-041-bilbao-santander   RN-041   Bilbao   Santander   55       Activa   37         8m
rn-088-oviedo-leon        RN-088   Oviedo   León        38                           8m

Y ahora funciona incluso kubectl wait sobre una condición propia:

kubectl wait --for=condition=VehiculoAsignado \
  rutaprogramada/rn-041-bilbao-santander -n rutas-norte-pro --timeout=30s
rutaprogramada.rutasnorte.example/rn-041-bilbao-santander condition met

Eso es lo que se gana siguiendo las convenciones: herramientas genéricas que nunca supieron de rutas de autobús funcionan sobre tu tipo.

RBAC sobre el recurso propio

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: gestor-rutas
  namespace: rutas-norte-pro
rules:
  - apiGroups: ["rutasnorte.example"]
    resources: ["rutasprogramadas"]
    verbs: ["get", "list", "watch", "create", "update", "patch"]
  - apiGroups: ["rutasnorte.example"]
    resources: ["rutasprogramadas/status"]
    verbs: ["get", "update", "patch"]

Nótese que rutasprogramadas/status es un recurso separado a efectos de permisos: se puede dar acceso de escritura al spec sin permitir falsear el status. Esta granularidad la habilita el subrecurso, y es otra razón para activarlo. RBAC en profundidad es la lección 08-01.

  1. Versionado y conversión

El versionado de un CRD es la parte más difícil, y conviene saberlo antes de publicar la primera versión.

La progresión habitual

Versión Estabilidad Compromisos
v1alpha1 Experimental Puede cambiar o desaparecer sin aviso; desactivada por defecto en muchos proyectos
v1beta1 En pruebas Cambios incompatibles posibles pero anunciados; suele haber migración
v1 Estable Compatibilidad hacia atrás garantizada; los campos existentes no cambian de significado

Una vez en v1, no puedes borrar un campo ni cambiar su semántica. Puedes añadir campos opcionales con valor por defecto, y poco más. Por eso conviene empezar en v1alpha1 y no promover a v1 hasta que el modelo esté asentado.

El problema que hace difícil la migración

Recordemos el mecanismo: solo una versión tiene storage: true. Todos los objetos se guardan con el esquema de esa versión, y las demás versiones son vistas que se convierten al vuelo.

Supongamos que v1alpha1 tenía un solo campo horario y v1 lo sustituye por una lista horarios. ¿Cómo se convierte un objeto guardado con el esquema antiguo?

conversion: None (por defecto)

spec:
  conversion:
    strategy: None

No se convierte nada: el objeto se devuelve tal cual, cambiando solo el apiVersion. Funciona únicamente si los esquemas son compatibles campo a campo, es decir, si entre versiones solo has añadido campos opcionales. Para cualquier cambio estructural es insuficiente.

conversion: Webhook

spec:
  conversion:
    strategy: Webhook
    webhook:
      conversionReviewVersions: ["v1"]
      clientConfig:
        service:
          namespace: rutas-norte-sistema
          name: rutas-webhook-conversion
          path: /convertir
          port: 443
        caBundle: <certificado en base64>

El apiserver llama a tu servidor HTTPS cada vez que alguien lee o escribe un objeto en una versión distinta a la de almacenamiento. Tu servidor recibe el objeto en una versión y lo devuelve en otra.

Lo que eso implica en la práctica:

  • Hay que escribir, desplegar y operar un servidor HTTPS con certificado válido (aquí cert-manager de 04-05 es el aliado natural).
  • Debe ser rápido y altamente disponible: si el webhook no responde, nadie puede leer ni escribir esos objetos. Un webhook de conversión caído bloquea el recurso entero.
  • La conversión debe ser bidireccional y sin pérdida. Si v1alpha1.horario (string) se convierte a v1.horarios (lista de uno) y de vuelta, hay que decidir qué pasa cuando la lista tiene tres elementos. La convención es guardar lo que no cabe en una anotación.

La migración completa

# 1. Publicar la nueva versión sirviéndola, sin cambiar el almacenamiento
#    versions: v1alpha1 (served, storage) + v1 (served)

# 2. Cambiar el almacenamiento a v1
#    versions: v1alpha1 (served) + v1 (served, storage)

# 3. Reescribir todos los objetos existentes para que se guarden con el esquema nuevo
kubectl get rutasprogramadas -A -o json | kubectl replace -f -

# 4. Comprobar qué versiones quedan registradas como almacenadas
kubectl get crd rutasprogramadas.rutasnorte.example \
  -o jsonpath='{.status.storedVersions}{"\n"}'
["v1alpha1","v1"]
# 5. Una vez todos los objetos están en v1, quitar v1alpha1 de storedVersions
#    editando .status.storedVersions, y solo entonces dejar de servirla.

El paso 3 es el que se olvida. Mientras storedVersions contenga la versión antigua, no puedes eliminarla del CRD: el apiserver se niega, porque habría objetos en etcd que ya no sabría interpretar.

Conclusión práctica: diseña el esquema con cuidado desde el principio. Es mucho más barato pensar dos días el modelo de datos que operar un webhook de conversión durante años.

  1. Cuándo NO crear un CRD

Los CRD son fáciles de crear y por eso se crean de más. Antes de escribir uno, pasa este filtro.

No lo crees si un ConfigMap basta

Si lo único que necesitas es guardar configuración que alguien lee, un ConfigMap (03-01) hace el trabajo con cero infraestructura.

Indicio Herramienta
Datos de configuración que una aplicación lee al arrancar ConfigMap
Datos que un controlador debe reconciliar continuamente CRD
Estructura sencilla, un consumidor, sin validación ConfigMap
Estructura compleja que varios equipos escriben y que debe validarse CRD
Necesitas RBAC granular por tipo de dato CRD
Necesitas watch, status y condiciones CRD

No lo crees si nadie va a reconciliar nada

Esta es la advertencia central de la lección, y merece enunciarse sin rodeos:

Un CRD sin controlador es una base de datos con formulario de validación.

Nuestro RutaProgramada está creado. Las dos rutas existen en etcd. Se validan, se etiquetan, se versionan en Git, salen en kubectl get. Y no pasa absolutamente nada. No arranca ningún autobús. No se crea ningún Deployment. La columna FASE sigue vacía salvo cuando la rellenamos a mano.

El valor de un recurso personalizado no está en el recurso: está en el bucle de reconciliación que lo observa y actúa. Sin él tienes un YAML validado, y eso lo consigues más barato con un esquema JSON en tu repositorio y una comprobación en el pipeline de CI.

Preguntas de control:

  1. ¿Quién va a observar este recurso y qué va a hacer? Si no hay respuesta concreta, no crees el CRD.
  2. ¿Qué escribirá en .status? Si nada, probablemente querías un ConfigMap.
  3. ¿Qué pasa si alguien lo borra? Si la respuesta es "nada", no es un recurso de Kubernetes: es un documento.

Otras señales de alarma

  • Datos de alta frecuencia. etcd no es una base de datos de series temporales. Un CRD que se actualiza cada segundo lo degradará. Para eso está la capa de agregación (metrics-server) o un sistema externo (Prometheus, 07-03).
  • Muchos objetos. Miles de instancias de un CRD ocupan etcd y ralentizan los list. Si esperas decenas de miles, replantéate el modelo.
  • Objetos grandes. El límite práctico de un objeto en etcd es aproximadamente 1 MiB. Un CRD no es sitio para adjuntar ficheros.
  • Datos que cambian a mano constantemente. Si el recurso lo va a editar una persona diez veces al día, quizá lo que necesitas es una aplicación con interfaz, no un CRD.
  • Modelar dominio de negocio puro. Aquí conviene ser honesto con nuestro propio ejemplo: modelar rutas de autobús como objetos de Kubernetes tiene sentido si las rutas se traducen en recursos del clúster (un Deployment de venta por ruta, un CronJob de informes por corredor). Si son solo filas de una tabla, su sitio es postgres-reservas, no etcd.

Alternativas antes de decidirte

Necesidad Alternativa al CRD
Configuración por entorno ConfigMap + Kustomize (10-04)
Plantillas de manifiestos Helm (10-03)
Validación de manifiestos en el pipeline Esquemas JSON + kubeconform en CI
Políticas sobre objetos existentes Webhook de admisión o Kyverno
Datos de negocio Una base de datos

Errores Comunes y Consejos

Nombrar mal el CRD. metadata.name debe ser exactamente <plural>.<group>. Si no, el apiserver rechaza el objeto con un mensaje que despista bastante.

Campos que desaparecen sin aviso. El apiserver poda todo lo que no esté en el esquema, en silencio. Si un campo "no se guarda", casi siempre está mal escrito o falta en el esquema. Compara con kubectl get ... -o yaml.

Olvidar el subrecurso status. Sin él, el controlador y el usuario se pisan las escrituras. Actívalo desde el primer día: añadirlo después obliga a revisar todo el código del controlador.

Empezar directamente en v1. Te ata a compatibilidad para siempre con un modelo que aún no has probado. Empieza en v1alpha1 mientras el diseño se asienta.

x-kubernetes-preserve-unknown-fields: true en la raíz. Desactiva la validación de todo el objeto. Si lo necesitas, acótalo al subárbol concreto.

Regex con sintaxis PCRE. Los pattern usan RE2. Un (?=...) provoca un error al crear el CRD, no al usarlo.

Borrar un CRD sin pensarlo. kubectl delete crd <nombre> borra todas las instancias de ese tipo en todos los namespaces, sin confirmación. Es irreversible salvo copia de seguridad.

Objetos huérfanos tras desinstalar un operador. Si borras el operador pero no sus CRD, quedan tipos registrados sin nadie que los atienda. Y si borras los CRD antes de que el controlador retire sus finalizadores, los objetos se quedan colgados en Terminating para siempre.

Consejo: escribe las description de todos los campos. Alimentan kubectl explain y convierten el CRD en autodocumentado. Es el mejor retorno por esfuerzo de toda la lección.

Consejo: pon additionalPrinterColumns desde el principio. Un kubectl get que solo muestra nombre y edad hace inutilizable un recurso que por lo demás está bien diseñado.

Consejo: prueba la validación con manifiestos deliberadamente rotos. Es la única forma de comprobar que el esquema hace lo que crees. Guarda esos manifiestos como pruebas de regresión del CRD.

Consejo: usa kubectl explain --recursive para ver el árbol completo del esquema de un vistazo, muy útil al revisar un CRD ajeno.

Ejercicios

Ejercicio 1: crear un CRD con validación

Crea un CRD ParadaAutobus en el grupo rutasnorte.example, alcance Namespaced, versión v1, con nombre corto parada y categoría rutasnorte. Su spec debe tener:

  • codigo (string, obligatorio, patrón ^P-[0-9]{4}$)
  • localidad (string, obligatorio, entre 2 y 60 caracteres)
  • andenes (entero, entre 1 y 20, por defecto 1)
  • accesible (booleano, por defecto true)
  • servicios (array de strings del enum taquilla, cafeteria, consigna, wc, sin repeticiones)

Añade columnas para código, localidad, andenes y antigüedad. Crea una parada válida y otra inválida y comprueba los mensajes de error.

Ejercicio 2: subrecurso status y condiciones

Amplía el CRD anterior con el subrecurso status, con los campos operativa (booleano), viajerosDia (entero) y condiciones (array con la estructura estándar). Añade una columna Operativa. Escribe el estado mediante --subresource=status y comprueba que un kubectl apply posterior del spec no lo borra.

Ejercicio 3: decidir entre CRD y ConfigMap

Para cada uno de estos cuatro casos de Rutas Norte, decide si corresponde un CRD o un ConfigMap y justifica la respuesta en una o dos frases:

  1. Los mensajes de la interfaz de tienda-web en español, catalán e inglés.
  2. Una definición de "corredor comercial" que, al crearse, debe provocar automáticamente el despliegue de un CronJob de informes y una NetworkPolicy propios.
  3. La lista de dominios permitidos para CORS en api-reservas.
  4. Un "entorno de pruebas efímero" que un desarrollador solicita y que debe crear un namespace, una copia de postgres-reservas desde un snapshot y borrarse solo a los 7 días.

Soluciones

Solución 1

# /tmp/crd-paradaautobus.yaml
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: paradasautobus.rutasnorte.example
  labels:
    app.kubernetes.io/part-of: rutas-norte
spec:
  group: rutasnorte.example
  scope: Namespaced
  names:
    kind: ParadaAutobus
    listKind: ParadaAutobusList
    plural: paradasautobus
    singular: paradaautobus
    shortNames: ["parada", "paradas"]
    categories: ["rutasnorte"]
  versions:
    - name: v1
      served: true
      storage: true
      additionalPrinterColumns:
        - name: Código
          type: string
          jsonPath: .spec.codigo
        - name: Localidad
          type: string
          jsonPath: .spec.localidad
        - name: Andenes
          type: integer
          jsonPath: .spec.andenes
        - name: Antigüedad
          type: date
          jsonPath: .metadata.creationTimestamp
      schema:
        openAPIV3Schema:
          type: object
          description: "Una parada física de la red de Rutas Norte"
          required: ["spec"]
          properties:
            spec:
              type: object
              required: ["codigo", "localidad"]
              properties:
                codigo:
                  type: string
                  description: "Código de parada, formato P-9999"
                  pattern: '^P-[0-9]{4}$'
                localidad:
                  type: string
                  description: "Localidad donde se encuentra la parada"
                  minLength: 2
                  maxLength: 60
                andenes:
                  type: integer
                  description: "Número de andenes disponibles"
                  minimum: 1
                  maximum: 20
                  default: 1
                accesible:
                  type: boolean
                  description: "Si la parada tiene accesibilidad completa"
                  default: true
                servicios:
                  type: array
                  description: "Servicios disponibles en la parada"
                  uniqueItems: true
                  maxItems: 4
                  items:
                    type: string
                    enum: ["taquilla", "cafeteria", "consigna", "wc"]
kubectl apply -f /tmp/crd-paradaautobus.yaml
kubectl api-resources --api-group=rutasnorte.example
NAME              SHORTNAMES        APIVERSION                  NAMESPACED   KIND
paradasautobus    parada,paradas    rutasnorte.example/v1       true         ParadaAutobus

Parada válida:

kubectl apply -f - <<'EOF'
apiVersion: rutasnorte.example/v1
kind: ParadaAutobus
metadata:
  name: p-0041-bilbao-termibus
  namespace: rutas-norte-dev
  labels:
    app.kubernetes.io/part-of: rutas-norte
    entorno: dev
spec:
  codigo: "P-0041"
  localidad: "Bilbao"
  andenes: 12
  servicios: ["taquilla", "cafeteria", "wc"]
EOF

kubectl get paradas -n rutas-norte-dev
paradaautobus.rutasnorte.example/p-0041-bilbao-termibus created

NAME                     CÓDIGO   LOCALIDAD   ANDENES   ANTIGÜEDAD
p-0041-bilbao-termibus   P-0041   Bilbao      12        9s

Parada inválida:

kubectl apply -f - <<'EOF'
apiVersion: rutasnorte.example/v1
kind: ParadaAutobus
metadata:
  name: parada-mala
  namespace: rutas-norte-dev
spec:
  codigo: "PARADA41"
  localidad: "X"
  andenes: 50
  servicios: ["taquilla", "taquilla", "parking"]
EOF
The ParadaAutobus "parada-mala" is invalid:
* spec.codigo: Invalid value: "PARADA41": spec.codigo in body should match '^P-[0-9]{4}$'
* spec.localidad: Invalid value: "X": spec.localidad in body should be at least 2 chars long
* spec.andenes: Invalid value: 50: spec.andenes in body should be less than or equal to 20
* spec.servicios: Invalid value: ["taquilla","taquilla","parking"]: spec.servicios in body should have unique items
* spec.servicios[2]: Unsupported value: "parking": supported values: "taquilla", "cafeteria", "consigna", "wc"

Cinco reglas distintas comprobadas —patrón, longitud, rango, unicidad y enumeración— sin una línea de código.

# El default también se aplicó
kubectl get parada p-0041-bilbao-termibus -n rutas-norte-dev \
  -o jsonpath='{.spec.accesible}{"\n"}'
true

Solución 2

Se añade al CRD el bloque subresources, el esquema de status y la columna:

    - name: v1
      served: true
      storage: true
      subresources:
        status: {}
      additionalPrinterColumns:
        - name: Código
          type: string
          jsonPath: .spec.codigo
        - name: Localidad
          type: string
          jsonPath: .spec.localidad
        - name: Andenes
          type: integer
          jsonPath: .spec.andenes
        - name: Operativa
          type: boolean
          jsonPath: .status.operativa
        - name: Antigüedad
          type: date
          jsonPath: .metadata.creationTimestamp
      schema:
        openAPIV3Schema:
          type: object
          required: ["spec"]
          properties:
            spec:
              # ... igual que en la solución 1 ...
            status:
              type: object
              description: "Estado observado de la parada"
              properties:
                operativa:
                  type: boolean
                viajerosDia:
                  type: integer
                  minimum: 0
                condiciones:
                  type: array
                  items:
                    type: object
                    required: ["type", "status"]
                    properties:
                      type:
                        type: string
                      status:
                        type: string
                        enum: ["True", "False", "Unknown"]
                      lastTransitionTime:
                        type: string
                        format: date-time
                      reason:
                        type: string
                      message:
                        type: string
kubectl apply -f /tmp/crd-paradaautobus.yaml

kubectl patch parada p-0041-bilbao-termibus -n rutas-norte-dev \
  --subresource=status --type=merge -p '{
    "status": {
      "operativa": true,
      "viajerosDia": 3184,
      "condiciones": [{
        "type": "AndenesDisponibles",
        "status": "True",
        "reason": "TodosLibres",
        "message": "12 de 12 andenes operativos",
        "lastTransitionTime": "2026-08-05T21:02:00Z"
      }]
    }
  }'

kubectl get paradas -n rutas-norte-dev
NAME                     CÓDIGO   LOCALIDAD   ANDENES   OPERATIVA   ANTIGÜEDAD
p-0041-bilbao-termibus   P-0041   Bilbao      12        true        6m

Comprobación de la independencia entre spec y status:

# Reaplicar el spec original, que NO contiene status
kubectl apply -f - <<'EOF'
apiVersion: rutasnorte.example/v1
kind: ParadaAutobus
metadata:
  name: p-0041-bilbao-termibus
  namespace: rutas-norte-dev
  labels:
    app.kubernetes.io/part-of: rutas-norte
    entorno: dev
spec:
  codigo: "P-0041"
  localidad: "Bilbao"
  andenes: 14
  servicios: ["taquilla", "cafeteria", "wc", "consigna"]
EOF

kubectl get paradas -n rutas-norte-dev
NAME                     CÓDIGO   LOCALIDAD   ANDENES   OPERATIVA   ANTIGÜEDAD
p-0041-bilbao-termibus   P-0041   Bilbao      14        true        8m

Los andenes se actualizaron a 14 y OPERATIVA sigue en true: el apply no tocó el status, porque el subrecurso lo aísla. Sin él, ese apply habría borrado todo el estado que el controlador acababa de calcular. Este es exactamente el motivo por el que hay que activarlo.

kubectl wait --for=condition=AndenesDisponibles \
  parada/p-0041-bilbao-termibus -n rutas-norte-dev --timeout=10s
paradaautobus.rutasnorte.example/p-0041-bilbao-termibus condition met
# Limpieza (borra el CRD y todas sus instancias)
kubectl delete crd paradasautobus.rutasnorte.example

Solución 3

1. Mensajes de la interfaz en tres idiomas → ConfigMap. Son datos de configuración que tienda-web lee al arrancar. Nadie tiene que reconciliar nada: no hay estado deseado que perseguir, solo texto que montar como volumen o inyectar como variables. Un ConfigMap por idioma, versionado en Git, resuelve el caso sin infraestructura adicional.

2. Corredor comercial que despliega un CronJob y una NetworkPolicy → CRD. Este es el caso canónico. Hay un estado deseado ("existe el corredor Cantábrico") que debe traducirse en recursos reales del clúster, y algo tiene que crearlos, mantenerlos si alguien los borra a mano y limpiarlos cuando el corredor desaparezca. Eso es un bucle de reconciliación, es decir, un CRD más un controlador. Sin el controlador el CRD no valdría nada.

3. Dominios permitidos para CORS → ConfigMap. Es una lista de cadenas que la aplicación lee. Si además quieres validarla, un esquema JSON en el pipeline de CI es más barato que un CRD. La prueba de que no necesita CRD: nadie escribiría nada en su .status.

4. Entorno de pruebas efímero con caducidad → CRD. Requiere reconciliación activa y continua: crear un namespace, restaurar postgres-reservas desde un snapshot (05-05), vigilar el paso del tiempo y borrarlo todo a los 7 días. El status tendría campos con sentido (fase, fechaCaducidad, namespaceCreado) y el borrado en cascada se resolvería con ownerReferences. Es un operador de manual, y la lección siguiente explica cómo se escribe.

Conclusión

Extender la API de Kubernetes significa enseñarle un tipo de objeto nuevo, y a cambio ese tipo hereda gratis los endpoints REST, la persistencia en etcd, la validación, el RBAC, la auditoría, el watch y todo kubectl. Es la característica que explica el ecosistema entero: los Certificate de cert-manager, los VolumeSnapshot del módulo 5 y los Backup de Velero que ya has usado son recursos personalizados.

De las tres formas de extender —CRD, capa de agregación y webhooks de admisión—, el CRD cubre la inmensa mayoría de los casos y solo requiere un YAML. Hemos recorrido su anatomía: group, names con sus plurales y atajos, scope, y versions con la distinción crucial entre served (se puede pedir) y storage (se guarda así, y solo una versión puede tenerlo).

El esquema OpenAPI v3 es lo que convierte un CRD en un tipo de verdad: tipos, required, default, enum, pattern, rangos y x-kubernetes-preserve-unknown-fields cuando hay que permitir campos libres. Los subrecursos status —que separa lo deseado de lo observado y evita que el usuario y el controlador se pisen— y scale —que habilita kubectl scale— y las additionalPrinterColumns completan la experiencia.

Hemos construido el CRD RutaProgramada con validación real, hemos comprobado que rechaza seis errores distintos con mensajes precisos, y lo hemos manejado con get, describe, explain, edit, patch, label, wait y RBAC exactamente igual que a un Deployment. También hemos visto por qué el versionado es la parte difícil, y por qué un webhook de conversión caído bloquea el recurso entero.

Y hemos terminado en el sitio donde había que terminar: nuestras dos rutas existen, se validan y salen en kubectl get, pero no ocurre nada. La columna FASE está vacía porque nadie la escribe. Un CRD sin controlador es una base de datos con formulario.

Lo que falta es el software que observe esos objetos, compare lo deseado con lo observado y actúe: el bucle de reconciliación que conocemos desde 01-02. Un recurso personalizado más un controlador que lo reconcilia es, exactamente, un operador. Es lo que hace cert-manager con los Certificate, lo que haría un controlador de RutaProgramada, y lo que resolverá por fin la carencia que dejamos abierta en 06-01: que postgres-reservas sigue siendo un StatefulSet artesanal que no sabe replicarse ni conmutar por error. Ese es el tema de la siguiente lección, la última del módulo: Operadores y el Patrón Controlador.

Curso de Kubernetes

Módulo 1: Introducción a Kubernetes

Módulo 2: Componentes Principales de Kubernetes

Módulo 3: Gestión de Configuración y Secretos

Módulo 4: Redes en Kubernetes

Módulo 5: Almacenamiento en Kubernetes

Módulo 6: Conceptos Avanzados de Kubernetes

Módulo 7: Monitoreo y Registro

Módulo 8: Seguridad en Kubernetes

Módulo 9: Escalado y Rendimiento

Módulo 10: Ecosistema y Herramientas de Kubernetes

Módulo 11: Estudios de Caso y Aplicaciones del Mundo Real

Módulo 12: Preparación para la Certificación de Kubernetes

© Copyright 2026. Todos los derechos reservados