Premier Operator en C# avec KubeOps & Le status subresource
Créer un Operator Kubernetes de bout en bout en C# avec KubeOps — de la définition de l'entité au Reconcile — et maîtriser le status subresource, le canal dédié par lequel le contrôleur rapporte l'état réel.
Premier Operator en C# avec KubeOps
L’idée en une phrase
KubeOps est un framework .NET qui fournit l’infrastructure complète d’un Operator Kubernetes — informers, work queue, leader election, génération de CRD — de sorte que le développeur se concentre exclusivement sur la logique de réconciliation : observer l’état réel, le comparer à l’état désiré porté par la Custom Resource, et agir pour combler l’écart.
Analogie : Considérons une chaîne d’assemblage automobile. L’usine fournit le convoyeur, les bras robotisés, les capteurs de qualité et le circuit électrique qui coordonne le tout. L’ingénieur ne construit pas l’usine : il programme uniquement les gestes spécifiques du bras — souder ici, visser là, vérifier cette cote. KubeOps est l’usine : il gère la mécanique de surveillance (informers), la file d’attente (work queue) et l’ordonnancement (leader election). Le développeur programme le geste de réconciliation.
Points clés
- Un projet KubeOps est une application ASP.NET standard. L’ajout du package
KubeOps.Operatorenregistre dans le conteneur d’injection de dépendances les services nécessaires : informers, cache, work queue et webhook server. L’appelbuilder.Services.AddKubernetesOperator()dansProgram.cssuffit à activer la machinerie. - La définition de l’entité (la classe C# décorée de
[KubernetesEntity]) sert de source de vérité pour la CRD. À la compilation, KubeOps génère le manifeste YAML de la CRD à partir des attributs et des types C#. Il n’est pas nécessaire de rédiger la CRD à la main — la classe C# EST le schéma. - Le contrôleur implémente
IEntityController<T>et sa méthodeReconcileAsync. KubeOps enregistre automatiquement un informer pour le typeT, crée la work queue associée et invoqueReconcileAsyncà chaque réconciliation. Le développeur peut aussi implémenterDeletedAsync(appelé quand la CR est supprimée) etStatusModifiedAsync(appelé quand le status change). - Pour le développement local, l’Operator se lance avec
dotnet run. Il se connecte au cluster via le kubeconfig par défaut, exactement commekubectl. La CRD doit être appliquée au cluster au préalable (viakubectl apply), puis l’Operator surveille les CR et réconcilie en temps réel.
Exemple concret
Une équipe développe un Operator pour gérer des instances Redis dans le cluster. Le développeur crée un projet ASP.NET, ajoute le package KubeOps, définit une entité V1RedisInstance avec un spec contenant version (string) et memoryLimit (string). Il implémente un contrôleur RedisInstanceController dont le ReconcileAsync vérifie si un StatefulSet portant le label managed-by: redis-operator existe pour cette CR. S’il n’existe pas, le contrôleur le crée avec l’image redis à la version déclarée et la limite mémoire spécifiée. Si le StatefulSet existe mais avec une image ou une limite différente, le contrôleur le met à jour. Enfin, il écrit dans le status de la CR le nombre de replicas prêts et la phase (Pending, Running, Failed). Le développeur lance dotnet run, applique une CR RedisInstance nommée cache-prod, et observe le StatefulSet apparaître dans le cluster en quelques secondes.
Frameworks Operator : comparaison
| Critère | KubeOps (.NET/C#) | Operator SDK (Go) | kopf (Python) |
|---|---|---|---|
| Langage | C# | Go | Python |
| Génération CRD | Automatique depuis les classes C# | Automatique depuis les types Go | Manuelle (YAML) |
| Boucle de réconciliation | ReconcileAsync | Reconcile(ctx, req) | Décorateurs @kopf.on.create |
| Injection de dépendances | Native (ASP.NET DI) | Manuelle | Limitée |
| Écosystème | NuGet, .NET 8+ | Module Go, kubebuilder | pip |
Code bash — créer un projet KubeOps
# Créer le projet et ajouter les dépendances
dotnet new web -n RedisOperator
cd RedisOperator
dotnet add package KubeOps.Operator
dotnet add package KubeOps.KubernetesClient
# Structure résultante :
# RedisOperator/
# ├── Program.cs ← point d'entrée, enregistrement KubeOps
# ├── Entities/
# │ └── V1RedisInstance.cs ← définition de l'entité (génère la CRD)
# └── Controllers/
# └── RedisInstanceController.cs ← la boucle de réconciliation
Code C# — point d’entrée et entité
// Program.cs — enregistrement de l'Operator dans le pipeline ASP.NET
var builder = WebApplication.CreateBuilder(args);
// AddKubernetesOperator() enregistre informers, work queues, leader election
builder.Services.AddKubernetesOperator();
var app = builder.Build();
app.Run();
// Entities/V1RedisInstance.cs — la classe qui définit le schéma de la CRD
using KubeOps.Abstractions.Entities;
using KubeOps.Abstractions.Entities.Attributes;
[KubernetesEntity(
Group = "cache.example.com",
ApiVersion = "v1",
Kind = "RedisInstance",
PluralName = "redisinstances")]
[EntityScope(EntityScope.Namespaced)]
public class V1RedisInstance
: CustomKubernetesEntity<V1RedisInstance.RedisSpec, V1RedisInstance.RedisStatus>
{
// Spec = l'état désiré déclaré par l'utilisateur
public class RedisSpec
{
public string Version { get; set; } = "7.2";
public string MemoryLimit { get; set; } = "256Mi";
}
// Status = l'état réel rapporté par le contrôleur
public class RedisStatus
{
public int ReadyReplicas { get; set; }
public string Phase { get; set; } = "Pending";
}
}
Code C# — le contrôleur complet
// Controllers/RedisInstanceController.cs — la boucle observe → diff → agit
using k8s;
using k8s.Models;
using KubeOps.Abstractions.Controller;
using KubeOps.KubernetesClient;
public class RedisInstanceController : IEntityController<V1RedisInstance>
{
private readonly IKubernetesClient _client;
private readonly ILogger<RedisInstanceController> _logger;
public RedisInstanceController(
IKubernetesClient client, ILogger<RedisInstanceController> logger)
{
_client = client;
_logger = logger;
}
public async Task ReconcileAsync(V1RedisInstance entity, CancellationToken token)
{
var ns = entity.Namespace() ?? "default";
var name = entity.Name();
// OBSERVE — le StatefulSet enfant existe-t-il ?
var statefulSets = await _client.List<V1StatefulSet>(ns,
labelSelector: $"app.kubernetes.io/managed-by=redis-operator,"
+ $"app.kubernetes.io/instance={name}");
var existing = statefulSets.FirstOrDefault();
if (existing is null)
{
// État réel absent → créer le StatefulSet
await _client.Create(BuildStatefulSet(entity));
_logger.LogInformation("StatefulSet {Name} créé", name);
}
else
{
// DIFF — l'image ou la limite mémoire diverge-t-elle ?
var container = existing.Spec.Template.Spec.Containers[0];
var desiredImage = $"redis:{entity.Spec.Version}";
var currentMemory = container.Resources?.Limits?["memory"].Value;
if (container.Image != desiredImage
|| currentMemory != entity.Spec.MemoryLimit)
{
container.Image = desiredImage;
container.Resources ??= new V1ResourceRequirements();
container.Resources.Limits ??= new Dictionary<string, ResourceQuantity>();
container.Resources.Limits["memory"] =
new ResourceQuantity(entity.Spec.MemoryLimit);
await _client.Update(existing);
_logger.LogInformation("StatefulSet {Name} mis à jour", name);
}
// Aucun écart → ne rien faire. Idempotence (chapitre 23-24).
}
// Rapporter l'état réel dans le STATUS (endpoint /status — section suivante)
entity.Status.ReadyReplicas = existing?.Status?.ReadyReplicas ?? 0;
entity.Status.Phase = entity.Status.ReadyReplicas > 0 ? "Running" : "Pending";
await _client.UpdateStatus(entity);
}
public Task DeletedAsync(V1RedisInstance entity, CancellationToken token)
{
// Nettoyage délégué au garbage collector via owner references (chapitre 25-26)
_logger.LogInformation("RedisInstance {Name} supprimée", entity.Name());
return Task.CompletedTask;
}
private V1StatefulSet BuildStatefulSet(V1RedisInstance entity) =>
new()
{
Metadata = new V1ObjectMeta
{
Name = entity.Name(),
NamespaceProperty = entity.Namespace(),
Labels = new Dictionary<string, string>
{
["app.kubernetes.io/managed-by"] = "redis-operator",
["app.kubernetes.io/instance"] = entity.Name()
}
},
Spec = new V1StatefulSetSpec
{
Replicas = 1,
ServiceName = entity.Name(),
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 = "redis",
Image = $"redis:{entity.Spec.Version}",
Resources = new V1ResourceRequirements
{
Limits = new Dictionary<string, ResourceQuantity>
{
["memory"] = new ResourceQuantity(
entity.Spec.MemoryLimit)
}
}
}
}
}
}
}
};
}
Code kubectl — lancer et tester l’Operator
# Appliquer la CRD générée par KubeOps dans le cluster
kubectl apply -f RedisOperator/config/crds/redisinstances.cache.example.com.yaml
# customresourcedefinition.apiextensions.k8s.io/redisinstances.cache.example.com created
# Lancer l'Operator en local — il se connecte via le kubeconfig par défaut
cd RedisOperator && dotnet run
# info: KubeOps[0] Starting operator...
# info: KubeOps[0] Watching RedisInstance resources...
# Dans un autre terminal — créer une CR
cat <<EOF | kubectl apply -f -
apiVersion: cache.example.com/v1
kind: RedisInstance
metadata:
name: cache-prod
namespace: default
spec:
version: "7.2"
memoryLimit: "512Mi"
EOF
# redisinstance.cache.example.com/cache-prod created
# Observer les ressources créées par l'Operator
kubectl get statefulset cache-prod
# NAME READY AGE
# cache-prod 1/1 15s
kubectl get redisinstance cache-prod -o jsonpath='{.status}'
# {"readyReplicas":1,"phase":"Running"}
Piège courant : « Il faut écrire la CRD YAML à la main puis garder le code C# synchronisé » est une erreur de méthode. Avec KubeOps, la classe C# décorée de
[KubernetesEntity]est la source de vérité — la CRD est générée automatiquement à la compilation. Modifier le YAML à la main sans modifier la classe C# crée une divergence entre le schéma déclaré et le schéma attendu par le contrôleur, source de bugs silencieux.
Le status subresource
L’idée en une phrase
Le status subresource est un endpoint API distinct (/status) qui sépare physiquement les écritures sur le spec (réservées à l’utilisateur) et les écritures sur le status (réservées au contrôleur), éliminant les conflits de version et permettant un RBAC granulaire — le contrôleur de la boucle de réconciliation peut rapporter l’état réel sans risquer d’écraser l’état désiré modifié simultanément par un utilisateur.
Analogie : Considérons un cahier de laboratoire en deux sections. La première — le protocole — est rédigée par le chercheur avant l’expérience : elle décrit ce qui doit se passer. La seconde — les observations — est remplie uniquement par le technicien qui exécute l’expérience : elle décrit ce qui s’est réellement passé. Les deux sections sont dans le même cahier (le même objet Kubernetes), mais seul le chercheur écrit dans le protocole et seul le technicien écrit dans les observations. Cette séparation empêche toute altération accidentelle du protocole lors de la mise à jour des observations, et inversement.
Points clés
- Sans le status subresource,
specetstatuspartagent le même endpoint API et le même resourceVersion. Unkubectl applyqui modifie lespecentre en conflit (erreur 409 Conflict) avec unUpdateStatussimultané du contrôleur, car les deux tentent de modifier le mêmeresourceVersion. Le status subresource résout ce problème en attribuant un traitement indépendant à chaque endpoint. - Le status subresource s’active dans la CRD via
subresources.status: {}(comme montré au chapitre 19-20). Une fois activé, unPUTouPATCHsur l’endpoint.../name/statusne modifie que le champstatus— toute modification duspecdans ce payload est ignorée par l’API server. Symétriquement, unPUTsur l’endpoint principal ignore les modifications dustatus. - Le RBAC peut alors distinguer les permissions : un ClusterRole accorde
updatesurwebapps/statusau contrôleur, etupdatesurwebappsaux utilisateurs. Le contrôleur ne peut pas modifier le spec, et les utilisateurs ne peuvent pas falsifier le status — chacun écrit dans son canal dédié. - En KubeOps, la séparation est automatique lorsque l’entité hérite de
CustomKubernetesEntity<TSpec, TStatus>. L’appel_client.UpdateStatus(entity)envoie la requête sur l’endpoint/status, sans toucher auspec. L’appel_client.Update(entity)envoie sur l’endpoint principal, sans toucher austatus.
Exemple concret
Un utilisateur applique une CR WebApp avec spec.replicas: 3. Le contrôleur détecte la CR, crée le Deployment, puis met à jour le status : status.readyReplicas: 3, status.phase: Running. Au même instant, l’utilisateur décide de passer à 5 replicas et exécute un kubectl patch sur le spec. Sans le status subresource, le resourceVersion a changé depuis la dernière lecture du contrôleur (car l’utilisateur vient de modifier l’objet entier), et le prochain UpdateStatus du contrôleur échoue avec une erreur 409. Le contrôleur doit relire l’objet et réessayer — un coût inutile à chaque modification concurrente. Avec le status subresource, le patch utilisateur porte sur l’endpoint principal et le UpdateStatus du contrôleur porte sur l’endpoint /status — les deux écritures coexistent sans conflit.
Avec vs sans status subresource
| Aspect | Sans status subresource | Avec status subresource |
|---|---|---|
| Endpoint API | Unique : .../name | Deux : .../name (spec) et .../name/status |
| resourceVersion | Partagé — toute écriture l’incrémente | Traitement séparé par endpoint |
| Conflit concurrent spec + status | Fréquent (erreur 409 Conflict) | Éliminé |
| RBAC | Impossible de distinguer spec et status | Permissions distinctes |
| Convention | Aucune garantie de séparation | L’API server l’impose |
Code YAML — activer le status subresource
# Extrait de la CRD — activer le status subresource
spec:
versions:
- name: v1
served: true
storage: true
subresources:
status: {} # cette ligne active le status subresource
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
replicas:
type: integer
status: # le schéma du status — écrit par le contrôleur seul
type: object
properties:
readyReplicas:
type: integer
phase:
type: string
Code kubectl — observer la séparation spec/status
# Lire le status actuel d'une CR
kubectl get webapp frontend -o jsonpath='{.status}'
# {"readyReplicas":3,"phase":"Running"}
# Tenter de modifier le status via l'endpoint principal — IGNORÉ
kubectl patch webapp frontend --type merge -p '{"status":{"phase":"Failed"}}'
# webapp.myapp.example.com/frontend patched
# MAIS le status n'a PAS changé : l'endpoint principal ignore le champ status
kubectl get webapp frontend -o jsonpath='{.status.phase}'
# Running ← inchangé
# Modifier le status via l'endpoint /status (usage rare en dehors du contrôleur)
kubectl patch webapp frontend --subresource=status \
--type merge -p '{"status":{"phase":"Failed"}}'
# webapp.myapp.example.com/frontend patched ← cette fois le status est modifié
kubectl get webapp frontend -o jsonpath='{.status.phase}'
# Failed
Code C# — UpdateStatus vs Update
// La BONNE façon de rapporter l'état réel dans ReconcileAsync
entity.Status.ReadyReplicas = observedReady;
entity.Status.Phase = observedReady >= entity.Spec.Replicas ? "Running" : "Pending";
await _client.UpdateStatus(entity);
// ↑ Envoie un PUT sur /apis/cache.example.com/v1/.../cache-prod/status
// Seul le champ status est modifié. Le spec est ignoré, pas de conflit 409.
// L'ERREUR COURANTE — utiliser Update pour modifier le status
entity.Status.Phase = "Running";
await _client.Update(entity);
// ↑ Envoie un PUT sur /apis/cache.example.com/v1/.../cache-prod (endpoint principal)
// Le status est SILENCIEUSEMENT IGNORÉ par l'API server.
// Aucune erreur n'est levée — le bug ne se manifeste qu'en constatant
// que le status ne change jamais, un problème subtil à diagnostiquer.
Piège courant : « Le status est un champ comme un autre, on peut le modifier avec
Update» est une confusion fréquente. Lorsque le status subresource est activé, l’API server applique une séparation stricte :Update(endpoint principal) ignore les modifications du status, etUpdateStatus(endpoint/status) ignore les modifications du spec. AppelerUpdatepour modifier le status ne produit aucune erreur — les changements sont silencieusement ignorés, un bug qui ne se manifeste qu’en production quand on constate que le status reste indéfiniment à sa valeur initiale.