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
- Qué significa extender la API de Kubernetes
- Las tres formas de extender Kubernetes
- Anatomía del objeto
CustomResourceDefinition - El esquema OpenAPI v3: validación declarativa
- Subrecursos:
statusyscale additionalPrinterColumns:kubectl getútil- Ejemplo completo: el CRD
RutaProgramada - Usar el recurso como cualquier objeto nativo
- Versionado y conversión
- Cuándo NO crear un CRD
- 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:
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:41ZY comprobar cuáles de los tipos disponibles son nativos y cuáles añadidos:
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
- 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.
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".
- Anatomía del objeto
CustomResourceDefinition
CustomResourceDefinitionUn 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:
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.
- 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: stringRestricciones 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.
- Subrecursos:
status y scale
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:
- Se habilita el endpoint
/status, que se actualiza de forma independiente del objeto principal. - Las escrituras sobre el objeto principal ignoran cambios en
.status. - Las escrituras sobre
/statusignoran 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 | Sí | 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: stringEs 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 gestionadosLo 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.
additionalPrinterColumns: kubectl get útil
additionalPrinterColumns: kubectl get útilSin configurar nada, kubectl get de un recurso personalizado muestra dos columnas inútiles:
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.creationTimestampResultado:
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 3mDetalles:
typeadmitestring,integer,number,booleanydate. Condate, kubectl formatea como antigüedad relativa ("3m", "2d").priority: 0(por defecto) muestra la columna siempre;priority: 1solo con-o wide.- La columna
NAMEestá 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.
- Ejemplo completo: el CRD
RutaProgramada
RutaProgramadaJuntamos 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: 512A 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: 1490rutaprogramada.rutasnorte.example/rn-041-bilbao-santander created
rutaprogramada.rutasnorte.example/rn-088-oviedo-leon createdLa 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á permitidoThe 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"
EOFThe RutaProgramada "ruta-incompleta" is invalid:
* spec.destino: Required value
* spec.horarios: Required value
* spec.plazas: Required valueEsta 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.
- Usar el recurso como cualquier objeto nativo
Todo lo que sabes de kubectl funciona ya sobre RutaProgramada.
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 2mLa 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 -30apiVersion: 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: 95Obsérvese activa: true: no estaba en el manifiesto y el apiserver lo escribió a partir del default del esquema.
kubectl explain
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
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 --watchEscribir 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 wideNAME 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 8mY 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=30sEso 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.
- 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)
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 av1.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"}'# 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.
- 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:
- ¿Quién va a observar este recurso y qué va a hacer? Si no hay respuesta concreta, no crees el CRD.
- ¿Qué escribirá en
.status? Si nada, probablemente querías un ConfigMap. - ¿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 defectotrue)servicios(array de strings del enumtaquilla,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:
- Los mensajes de la interfaz de
tienda-weben español, catalán e inglés. - Una definición de "corredor comercial" que, al crearse, debe provocar automáticamente el despliegue de un CronJob de informes y una NetworkPolicy propios.
- La lista de dominios permitidos para CORS en
api-reservas. - Un "entorno de pruebas efímero" que un desarrollador solicita y que debe crear un namespace, una copia de
postgres-reservasdesde 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"]NAME SHORTNAMES APIVERSION NAMESPACED KIND
paradasautobus parada,paradas rutasnorte.example/v1 true ParadaAutobusParada 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-devparadaautobus.rutasnorte.example/p-0041-bilbao-termibus created
NAME CÓDIGO LOCALIDAD ANDENES ANTIGÜEDAD
p-0041-bilbao-termibus P-0041 Bilbao 12 9sParada 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"]
EOFThe 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"}'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: stringkubectl 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-devComprobació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-devLos 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# Limpieza (borra el CRD y todas sus instancias)
kubectl delete crd paradasautobus.rutasnorte.exampleSolució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
- ¿Qué es Kubernetes?
- Arquitectura de Kubernetes
- Conceptos y Terminología Clave
- Configuración de un Clúster de Kubernetes
- La CLI de Kubernetes: kubectl
- Objetos, Manifiestos YAML y el Modelo Declarativo
- El Proyecto del Curso: la Plataforma Rutas Norte
Módulo 2: Componentes Principales de Kubernetes
- Pods
- ReplicaSets
- Deployments
- Actualizaciones, Rollbacks y Estrategias de Despliegue
- Servicios
- Namespaces
- Etiquetas, Selectores y Anotaciones
Módulo 3: Gestión de Configuración y Secretos
- ConfigMaps
- Secrets
- Variables de Entorno
- Cuotas y Límites de Recursos
- LimitRanges y Clases de Calidad de Servicio (QoS)
- ServiceAccounts y Acceso a la API desde los Pods
Módulo 4: Redes en Kubernetes
- Redes de Clúster
- Tipos de Servicios
- DNS Interno y Descubrimiento de Servicios
- Controladores de Ingress
- TLS y Gestión de Certificados con cert-manager
- Políticas de Red
Módulo 5: Almacenamiento en Kubernetes
- Volúmenes
- Volúmenes Persistentes
- Reclamaciones de Volúmenes Persistentes
- Clases de Almacenamiento
- Aprovisionamiento Dinámico, Expansión y Snapshots
- Copias de Seguridad y Restauración de Datos
Módulo 6: Conceptos Avanzados de Kubernetes
- StatefulSets
- DaemonSets
- Trabajos y CronJobs
- Init Containers, Sidecars y Patrones Multi-Contenedor
- Planificación: Afinidad, Taints y Tolerations
- Definiciones de Recursos Personalizados (CRDs)
- Operadores y el Patrón Controlador
Módulo 7: Monitoreo y Registro
- Verificaciones de Salud y Sondas
- Servidor de Métricas y kubectl top
- Monitoreo con Prometheus
- Visualización y Alertas con Grafana y Alertmanager
- Registro Centralizado con Elasticsearch, Fluentd y Kibana (EFK)
- Depuración de Aplicaciones y Eventos del Clúster
Módulo 8: Seguridad en Kubernetes
- Control de Acceso Basado en Roles (RBAC)
- Contextos de Seguridad y Endurecimiento del Contenedor
- Políticas de Seguridad de Pods y Pod Security Standards
- Seguridad de Red
- Seguridad de Imágenes
- Auditoría, Escaneo y Gestión de Vulnerabilidades
Módulo 9: Escalado y Rendimiento
- Autoescalado Horizontal de Pods
- Autoescalado Vertical de Pods
- Autoescalado de Clúster
- Escalado por Eventos y Métricas Personalizadas con KEDA
- Alta Disponibilidad: PodDisruptionBudgets y Topología
- Ajuste de Rendimiento
Módulo 10: Ecosistema y Herramientas de Kubernetes
- Minikube y Entornos Locales con kind
- Kubeadm
- Helm
- Kustomize
- GitOps con Argo CD y Flux
- Kubernetes Gestionado: EKS, AKS y GKE
Módulo 11: Estudios de Caso y Aplicaciones del Mundo Real
- Despliegue de una Aplicación Web
- Ejecución de Aplicaciones con Estado
- CI/CD con Kubernetes
- Estrategias de Despliegue: Blue-Green y Canary
- Gestión Multi-Clúster
- Operación en Producción: Incidencias, Runbooks y Costes
