Avancé Chapitre 21-22 / 11

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.Operator enregistre dans le conteneur d’injection de dépendances les services nécessaires : informers, cache, work queue et webhook server. L’appel builder.Services.AddKubernetesOperator() dans Program.cs suffit à 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éthode ReconcileAsync. KubeOps enregistre automatiquement un informer pour le type T, crée la work queue associée et invoque ReconcileAsync à chaque réconciliation. Le développeur peut aussi implémenter DeletedAsync (appelé quand la CR est supprimée) et StatusModifiedAsync (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 comme kubectl. La CRD doit être appliquée au cluster au préalable (via kubectl 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èreKubeOps (.NET/C#)Operator SDK (Go)kopf (Python)
LangageC#GoPython
Génération CRDAutomatique depuis les classes C#Automatique depuis les types GoManuelle (YAML)
Boucle de réconciliationReconcileAsyncReconcile(ctx, req)Décorateurs @kopf.on.create
Injection de dépendancesNative (ASP.NET DI)ManuelleLimitée
ÉcosystèmeNuGet, .NET 8+Module Go, kubebuilderpip

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, spec et status partagent le même endpoint API et le même resourceVersion. Un kubectl apply qui modifie le spec entre en conflit (erreur 409 Conflict) avec un UpdateStatus simultané du contrôleur, car les deux tentent de modifier le même resourceVersion. 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é, un PUT ou PATCH sur l’endpoint .../name/status ne modifie que le champ status — toute modification du spec dans ce payload est ignorée par l’API server. Symétriquement, un PUT sur l’endpoint principal ignore les modifications du status.
  • Le RBAC peut alors distinguer les permissions : un ClusterRole accorde update sur webapps/status au contrôleur, et update sur webapps aux 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&lt;TSpec, TStatus&gt;. L’appel _client.UpdateStatus(entity) envoie la requête sur l’endpoint /status, sans toucher au spec. L’appel _client.Update(entity) envoie sur l’endpoint principal, sans toucher au status.

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

AspectSans status subresourceAvec status subresource
Endpoint APIUnique : .../nameDeux : .../name (spec) et .../name/status
resourceVersionPartagé — toute écriture l’incrémenteTraitement séparé par endpoint
Conflit concurrent spec + statusFréquent (erreur 409 Conflict)Éliminé
RBACImpossible de distinguer spec et statusPermissions distinctes
ConventionAucune garantie de séparationL’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, et UpdateStatus (endpoint /status) ignore les modifications du spec. Appeler Update pour 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.


Quiz — validation des acquis
Avancé 7 questions Objectif : 5/7 minimum
0/7
bonnes reponses
Objectif non atteint (minimum 5/7 requis).
Relire la fiche memo ci-dessus en pretant attention aux points manques, puis cliquer sur « Recommencer » pour retenter.