CRDs : étendre l'état désiré & Anatomie d'un Operator
Comprendre comment les CustomResourceDefinitions étendent le modèle déclaratif de Kubernetes à de nouveaux domaines, et comment un Operator implémente la boucle de réconciliation pour ces ressources — avec du YAML, kubectl et du C#/KubeOps.
CRDs : étendre l’état désiré
L’idée en une phrase
Une CustomResourceDefinition (CRD) enregistre un nouveau type de ressource dans l’API server, permettant de déclarer un état désiré pour un domaine que Kubernetes ne connaît pas nativement — une base de données, un certificat TLS, une file de messages — et d’appliquer la même boucle de réconciliation (observe → diff → agit) que celle qui gère les Deployments ou les Services.
Analogie : Considérons une mairie qui ne gère initialement que les permis de construire. Un jour, la municipalité décide de gérer aussi les autorisations de terrasse. Plutôt que de créer un service parallèle, elle ajoute un nouveau formulaire au guichet existant (la CRD), avec ses champs obligatoires et ses règles de validation. Les administrés déposent des demandes de terrasse au même guichet (l’API server), et un agent dédié (le contrôleur) traite ces demandes selon la même procédure que les permis de construire : réception, vérification, exécution.
Points clés
- Une CRD est elle-même un objet Kubernetes de kind
CustomResourceDefinition, déclaré en YAML et appliqué viakubectl apply. Elle définit le groupe API (group), la version (versions), le nom (names.kind,names.plural) et le schéma OpenAPI v3 (openAPIV3Schema) du nouveau type de ressource. Une fois la CRD acceptée par l’API server, celui-ci expose un nouveau endpoint REST (par exemple/apis/myapp.example.com/v1/webapps). - Les objets créés à partir d’une CRD sont appelés Custom Resources (CR). Ils sont stockés dans etcd exactement comme les ressources natives (Pods, Services), et bénéficient des mêmes mécanismes : validation par schéma, versionnement, RBAC, watches, informers (chapitre 13-14). Un CR sans contrôleur associé est un document inerte — l’état désiré est enregistré mais personne ne le réconcilie.
- Le champ
specd’un CR décrit l’état désiré du domaine métier. Le champstatus(activé via le status subresource dans la CRD) reflète l’état réel observé par le contrôleur. Cette séparationspec/statusreproduit le modèle fondamental de tout objet Kubernetes (chapitre 5-6) : l’utilisateur écrit dansspec, le contrôleur écrit dansstatus. - La validation structurelle est obligatoire depuis Kubernetes 1.25 : chaque champ du CR doit être décrit dans le schéma OpenAPI de la CRD. Les champs non déclarés sont rejetés par l’API server — l’état désiré est garanti conforme avant même qu’un contrôleur ne le traite.
Exemple concret
Une équipe souhaite gérer des applications web via Kubernetes. Elle crée une CRD de kind WebApp dans le groupe myapp.example.com. Le schéma impose un champ spec.image (string, obligatoire) et un champ spec.replicas (entier, minimum 1, défaut 1). Un développeur applique un CR WebApp nommé frontend avec spec.image: "nginx:1.27" et spec.replicas: 3. L’API server valide le CR contre le schéma, le stocke dans etcd, et répond 201 Created. À ce stade, rien ne se passe dans le cluster : aucun pod n’est créé, car aucun contrôleur ne surveille encore les WebApp. Le CR est un état désiré orphelin — il attend son réconciliateur.
CRD vs ressource native
| Critère | Ressource native (Pod, Deployment) | Custom Resource (via CRD) |
|---|---|---|
| Définition | Compilée dans l’API server | Déclarée dynamiquement par un objet CRD |
| Stockage | etcd | etcd (identique) |
| Validation | Schéma intégré | Schéma OpenAPI dans la CRD |
| Contrôleur | Intégré au kube-controller-manager | Externe, déployé séparément (l’Operator) |
| Suppression du type | Impossible (fait partie du binaire) | Supprimer la CRD supprime toutes les CR associées |
Code YAML — définir une CRD
# webapp-crd.yaml — enregistre le type WebApp dans l'API server
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: webapps.myapp.example.com # format : plural.group
spec:
group: myapp.example.com # le groupe API
names:
kind: WebApp # nom au singulier (PascalCase)
plural: webapps # nom au pluriel (endpoint REST)
singular: webapp
shortNames: ["wa"] # kubectl get wa
scope: Namespaced # ou Cluster
versions:
- name: v1
served: true # cette version est active
storage: true # version stockée dans etcd
subresources:
status: {} # active le status subresource
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
required: ["image"]
properties:
image:
type: string
description: "Image du conteneur principal"
replicas:
type: integer
minimum: 1
default: 1
description: "Nombre de replicas désiré"
status:
type: object
properties:
readyReplicas:
type: integer
phase:
type: string
enum: ["Pending", "Running", "Failed"]
Code kubectl — manipuler une Custom Resource
# Appliquer la CRD — le type WebApp est désormais reconnu
kubectl apply -f webapp-crd.yaml
# customresourcedefinition.apiextensions.k8s.io/webapps.myapp.example.com created
# Vérifier que le nouveau type existe
kubectl api-resources | grep webapp
# webapps wa myapp.example.com/v1 true WebApp
# Créer une Custom Resource de type WebApp
cat <<EOF | kubectl apply -f -
apiVersion: myapp.example.com/v1
kind: WebApp
metadata:
name: frontend
namespace: default
spec:
image: "nginx:1.27"
replicas: 3
EOF
# webapp.myapp.example.com/frontend created
# Lire l'état désiré — le CR est stocké dans etcd
kubectl get webapp frontend -o yaml
# spec:
# image: nginx:1.27
# replicas: 3
# status: {} ← vide : aucun contrôleur ne le réconcilie encore
Piège courant : « Créer une CRD suffit pour que Kubernetes gère ma ressource » est une idée fausse fréquente. La CRD enregistre un nouveau type et son schéma de validation — elle étend le vocabulaire de l’état désiré. Mais sans contrôleur dédié qui surveille les CR et agit pour rapprocher l’état réel de l’état désiré, les Custom Resources restent des documents inertes dans etcd. La CRD est la moitié déclarative du puzzle ; l’Operator (sujet de la seconde partie) est la moitié opérationnelle.
Anatomie d’un Operator
L’idée en une phrase
Un Operator est un programme qui couple un ou plusieurs contrôleurs à une ou plusieurs CRDs, implémentant la boucle de réconciliation pour un domaine métier spécifique — il observe les Custom Resources (l’état désiré), compare avec l’état réel du cluster, et agit pour combler l’écart, exactement comme les contrôleurs natifs du kube-controller-manager.
Analogie : Considérons un jardinier automatique. Le plan du jardin (la CR) décrit l’état désiré : « 3 rangées de tomates, 2 de basilic, arrosage quotidien ». Le jardinier (l’Operator) fait sa ronde chaque matin : il compte les rangées plantées (observe), compare au plan (diff), et plante ou arrache ce qui diverge (agit). Si une tempête détruit une rangée de tomates, le jardinier la replante au prochain passage — sans qu’on ait besoin de lui redonner le plan. Le plan reste affiché dans la cabane (etcd), le jardinier le consulte à chaque cycle.
Points clés
- Un Operator se déploie dans le cluster comme un Deployment classique (un ou plusieurs pods). Il utilise les mécanismes standard : un informer (chapitre 13-14) pour surveiller les CR de son type, une work queue (chapitre 15-16) pour traiter les événements de manière asynchrone et avec rate limiting, et un handler
Reconcileinvoqué pour chaque CR à réconcilier. - La méthode Reconcile reçoit l’identifiant de la CR (namespace + nom), lit son
spec(état désiré), interroge le cluster pour déterminer l’état réel (les ressources enfants existent-elles ? sont-elles conformes ?), puis crée, met à jour ou supprime les ressources nécessaires. Elle met enfin à jour le champstatusde la CR pour refléter l’état réel observé. - L’Operator suit le principe de level-triggered reconciliation (chapitre 13-14) : la méthode Reconcile ne reçoit pas un événement ponctuel (« un pod a été supprimé ») mais une demande de réconcilier l’état complet de la CR. Elle doit être idempotente : appelée deux fois avec le même état, elle produit le même résultat sans effet de bord.
- En C#, le framework KubeOps fournit l’infrastructure nécessaire : définition des entités (CRD auto-générées depuis les classes C#), injection du contrôleur, gestion du cycle de vie. L’interface
IEntityController<T>expose la méthodeReconcileAsync— le point d’entrée de la boucle.
Exemple concret
L’équipe déploie un Operator pour le type WebApp. L’Operator tourne dans un pod du namespace operators. Au démarrage, il enregistre un informer sur les objets WebApp. Lorsque le CR frontend (spec: image nginx:1.27, replicas 3) est détecté, la work queue enfile la clé default/frontend. Le handler Reconcile est invoqué : il lit le spec, vérifie si un Deployment nommé frontend existe — il n’existe pas. L’Operator crée un Deployment avec 3 replicas et l’image nginx:1.27, un Service ClusterIP, et met à jour le status de la CR (phase: Running, readyReplicas: 3). Cinq minutes plus tard, un développeur modifie la CR pour passer à 5 replicas. L’informer enfile à nouveau default/frontend. Le Reconcile lit le spec (5 replicas), observe le Deployment existant (3 replicas), calcule l’écart et met à jour le Deployment. L’état réel converge vers l’état désiré.
Composants d’un Operator
| Composant | Rôle | Analogie avec les contrôleurs natifs |
|---|---|---|
| CRD | Définit le schéma de l’état désiré | Équivalent du schéma Deployment compilé dans l’API server |
| Informer + cache | Surveille les CR et les ressources enfants | Même mécanisme que les contrôleurs du kube-controller-manager |
| Work queue | Sérialise et déduplique les reconciliations | Identique (chapitre 15-16) |
| Reconcile handler | Observe, calcule l’écart, agit | La boucle de contrôle elle-même |
| Status subresource | Reporte l’état réel sur la CR | Le champ status de tout objet Kubernetes |
Code YAML — déployer un Operator
# operator-deployment.yaml — l'Operator tourne comme un Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: webapp-operator
namespace: operators
spec:
replicas: 1 # un seul replica (leader election pour HA)
selector:
matchLabels:
app: webapp-operator
template:
metadata:
labels:
app: webapp-operator
spec:
serviceAccountName: webapp-operator # RBAC pour accéder aux CR et aux Deployments
containers:
- name: operator
image: myregistry/webapp-operator:1.0
env:
- name: WATCH_NAMESPACE
value: "" # vide = surveiller tous les namespaces
---
# rbac.yaml — le ServiceAccount doit pouvoir lire/écrire les WebApps et les Deployments
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: webapp-operator
rules:
- apiGroups: ["myapp.example.com"]
resources: ["webapps", "webapps/status"] # CR + status subresource
verbs: ["get", "list", "watch", "update", "patch"]
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "watch", "create", "update", "delete"]
- apiGroups: [""]
resources: ["services"]
verbs: ["get", "list", "watch", "create", "update", "delete"]
Code kubectl — observer un Operator en action
# L'Operator tourne dans le namespace operators
kubectl get pods -n operators
# NAME READY STATUS AGE
# webapp-operator-6b8f9c-xk2m7 1/1 Running 2m
# Créer une WebApp — l'Operator détecte et réconcilie
kubectl apply -f frontend-webapp.yaml
kubectl get webapp frontend
# NAME PHASE READY-REPLICAS AGE
# frontend Running 3 30s
# Observer les ressources enfants créées par l'Operator
kubectl get deployment frontend
# NAME READY UP-TO-DATE AVAILABLE AGE
# frontend 3/3 3 3 30s
kubectl get svc frontend
# NAME TYPE CLUSTER-IP PORT(S) AGE
# frontend ClusterIP 10.96.55.12 80/TCP 30s
# Modifier l'état désiré — l'Operator réconcilie automatiquement
kubectl patch webapp frontend --type merge -p '{"spec":{"replicas":5}}'
kubectl get webapp frontend
# NAME PHASE READY-REPLICAS AGE
# frontend Running 5 2m
Code C# / KubeOps — un Operator complet
// WebApp.cs — la définition de l'entité (génère la CRD automatiquement)
using k8s.Models;
using KubeOps.Abstractions.Entities;
using KubeOps.Abstractions.Entities.Attributes;
// L'attribut définit group, version, kind, plural
[KubernetesEntity(Group = "myapp.example.com", ApiVersion = "v1", Kind = "WebApp", PluralName = "webapps")]
[EntityScope(EntityScope.Namespaced)]
public class V1WebApp : CustomKubernetesEntity<V1WebApp.V1WebAppSpec, V1WebApp.V1WebAppStatus>
{
// Spec = l'état désiré, écrit par l'utilisateur
public class V1WebAppSpec
{
public string Image { get; set; } = string.Empty;
public int Replicas { get; set; } = 1;
}
// Status = l'état réel, écrit par le contrôleur
public class V1WebAppStatus
{
public int ReadyReplicas { get; set; }
public string Phase { get; set; } = "Pending";
}
}
// WebAppController.cs — la boucle de réconciliation
using k8s;
using k8s.Models;
using KubeOps.Abstractions.Controller;
using KubeOps.KubernetesClient;
public class WebAppController : IEntityController<V1WebApp>
{
private readonly IKubernetesClient _client;
private readonly ILogger<WebAppController> _logger;
public WebAppController(IKubernetesClient client, ILogger<WebAppController> logger)
{
_client = client;
_logger = logger;
}
public async Task ReconcileAsync(V1WebApp entity, CancellationToken token)
{
var ns = entity.Namespace() ?? "default";
var name = entity.Name();
// 1. OBSERVE — lire l'état réel : le Deployment enfant existe-t-il ?
var deployments = await _client.List<V1Deployment>(ns,
labelSelector: $"app.kubernetes.io/managed-by=webapp-operator,app.kubernetes.io/name={name}");
var existing = deployments.FirstOrDefault();
// 2. DIFF + AGIR — créer ou mettre à jour le Deployment
if (existing is null)
{
// Le Deployment n'existe pas → le créer (état réel < état désiré)
var deployment = BuildDeployment(entity);
await _client.Create(deployment);
_logger.LogInformation("Deployment {Name} créé avec {Replicas} replicas",
name, entity.Spec.Replicas);
}
else if (existing.Spec.Replicas != entity.Spec.Replicas
|| existing.Spec.Template.Spec.Containers[0].Image != entity.Spec.Image)
{
// L'état réel diverge de l'état désiré → mettre à jour
existing.Spec.Replicas = entity.Spec.Replicas;
existing.Spec.Template.Spec.Containers[0].Image = entity.Spec.Image;
await _client.Update(existing);
_logger.LogInformation("Deployment {Name} mis à jour", name);
}
// Si aucun écart n'est détecté, ne rien faire — idempotence.
// 3. Mettre à jour le STATUS de la CR (état réel observé)
entity.Status.ReadyReplicas = existing?.Status?.ReadyReplicas ?? 0;
entity.Status.Phase = entity.Status.ReadyReplicas >= entity.Spec.Replicas
? "Running"
: "Pending";
await _client.UpdateStatus(entity);
}
private V1Deployment BuildDeployment(V1WebApp entity)
{
// Construction du Deployment enfant — les labels lient la ressource à l'Operator
return new V1Deployment
{
Metadata = new V1ObjectMeta
{
Name = entity.Name(),
NamespaceProperty = entity.Namespace(),
Labels = new Dictionary<string, string>
{
["app.kubernetes.io/managed-by"] = "webapp-operator",
["app.kubernetes.io/name"] = entity.Name()
}
},
Spec = new V1DeploymentSpec
{
Replicas = entity.Spec.Replicas,
Selector = new V1LabelSelector
{
MatchLabels = new Dictionary<string, string>
{
["app"] = entity.Name()
}
},
Template = new V1PodTemplateSpec
{
Metadata = new V1ObjectMeta
{
Labels = new Dictionary<string, string>
{
["app"] = entity.Name()
}
},
Spec = new V1PodSpec
{
Containers = new List<V1Container>
{
new()
{
Name = "app",
Image = entity.Spec.Image
}
}
}
}
}
};
}
}
Piège courant : « Un Operator est un programme externe qui contourne l’API server » est une confusion courante. L’Operator utilise exclusivement l’API Kubernetes pour lire et écrire les ressources — il n’accède jamais directement à etcd ni ne court-circuite les mécanismes de validation et de RBAC. Il est un citoyen de première classe de l’architecture Kubernetes, au même titre que le kube-controller-manager. La seule différence est qu’il tourne dans un pod utilisateur au lieu d’être compilé dans le binaire du control plane.