Avancé Chapitre 27-28 / 14

Create-or-update et server-side apply & Gérer les ressources enfants en C#

Maîtriser le pattern create-or-update, comprendre Server-Side Apply et la gestion déclarative des champs, puis orchestrer les ressources enfants d'un Operator en C# avec KubeOps.

Create-or-update et server-side apply

L’idée en une phrase

Le pattern create-or-update permet à un réconciliateur de converger vers l’état désiré d’une ressource sans se soucier de savoir si elle existe déjà : on lit l’état réel, on le compare à l’état voulu, et on crée ou met à jour selon l’écart. Server-Side Apply (SSA) élève ce pattern au rang de primitive de l’API server, en gérant la propriété champ par champ pour que plusieurs acteurs puissent réconcilier le même objet sans conflits.

Analogie : Considérons un registre cadastral partagé entre plusieurs services municipaux. Chaque service (urbanisme, voirie, eau) met à jour les champs qui le concernent sur la fiche d’une parcelle. L’approche classique consisterait à lire la fiche entière, la modifier et la réécrire — avec le risque d’écraser les modifications d’un autre service survenues entre-temps. Le cadastre intelligent (SSA) identifie plutôt chaque service par son nom et enregistre quel service possède quel champ. Un conflit ne survient que si deux services tentent de modifier le même champ.

Points clés

  • Le pattern create-or-update classique suit trois étapes : (1) GET la ressource, (2) si elle n’existe pas (404), CREATE ; (3) si elle existe, calculer l’écart entre l’état réel et l’état désiré, puis UPDATE ou PATCH. Ce pattern est idempotent (chapitre 23-24) à condition de comparer avant d’écrire.
  • Le UPDATE classique (HTTP PUT) remplace l’objet entier et exige un resourceVersion valide pour détecter les conflits de concurrence (optimistic locking). Si un autre acteur a modifié l’objet entre le GET et le PUT, le serveur rejette la requête avec 409 Conflict.
  • Server-Side Apply (stable depuis Kubernetes 1.22) introduit le concept de field manager : chaque acteur (contrôleur, utilisateur, webhook) est identifié par un nom et ne possède que les champs qu’il a explicitement déclarés. L’API server fusionne les champs au lieu de remplacer l’objet entier.
  • Avec SSA, un conflit ne survient que si deux field managers tentent de modifier le même champ. Le paramètre force: true permet au manager de reprendre la propriété du champ contesté — à utiliser avec discernement.
  • SSA simplifie la réconciliation multi-acteurs : le HPA peut posséder spec.replicas pendant que l’Operator possède spec.template — chacun réconcilie son périmètre sans écraser l’autre.

Exemple concret

Un Operator gère une CR Cache et crée un Deployment enfant. En approche classique, le réconciliateur exécute GET deployment/cache-deploy. À t₀, le Deployment n’existe pas : il le crée avec 3 replicas et l’image redis:7. À t₁, l’administrateur active un HPA qui ajuste replicas à 5. À t₂, l’Operator est re-déclenché (resync périodique, chapitre 15-16) : il exécute GET, compare, et repousse replicas: 3 — écrasant la décision du HPA. Avec SSA, l’Operator déclare ses champs (image, labels, resources) sous le field manager cache-operator, et le HPA déclare replicas sous le manager hpa-controller. Chacun ne touche qu’à ses champs. L’Operator applique son manifeste partiel sans jamais écraser replicas — la coexistence est réconciliée par le serveur.

Create-or-update classique vs Server-Side Apply

CritèreCreate-or-update (GET+PUT/PATCH)Server-Side Apply
GranularitéObjet entierChamp par champ
PropriétéImplicite (dernier écrivain gagne)Explicite (field manager par champ)
Conflit409 Conflict sur resourceVersion périméConflit seulement si deux managers touchent le même champ
Multi-acteursRisque d’écrasement mutuelCoexistence native
IdempotenceÀ implémenter (comparer avant d’écrire)Garantie par le serveur (no-op si l’état est déjà atteint)

Code YAML — appliquer avec Server-Side Apply

# cache-deployment.yaml — manifeste partiel pour SSA
# Seuls les champs déclarés ici seront possédés par le field manager.
apiVersion: apps/v1
kind: Deployment
metadata:
  name: cache-deploy
  namespace: default
  labels:
    app.kubernetes.io/managed-by: cache-operator
spec:
  selector:
    matchLabels:
      app: cache
  template:
    metadata:
      labels:
        app: cache
    spec:
      containers:
        - name: redis
          image: redis:7.4
          resources:
            requests:
              memory: "128Mi"
              cpu: "100m"
  # replicas volontairement ABSENT : ce champ est laissé au HPA

Code kubectl — observer les field managers

# Appliquer avec Server-Side Apply (--server-side) et un field manager explicite
kubectl apply -f cache-deployment.yaml --server-side --field-manager=cache-operator
# deployment.apps/cache-deploy serverside-applied

# Inspecter les managed fields pour voir qui possède quoi
kubectl get deployment cache-deploy -o jsonpath='{.metadata.managedFields[*].manager}'
# cache-operator hpa-controller

# Voir le détail des champs possédés par chaque manager
kubectl get deployment cache-deploy -o yaml | grep -A 20 managedFields
# - manager: cache-operator
#   fieldsV1:
#     f:spec:
#       f:template: ...
# - manager: hpa-controller
#   fieldsV1:
#     f:spec:
#       f:replicas: {}

# Forcer la reprise d'un champ contesté (écrase l'autre manager)
kubectl apply -f cache-deployment.yaml --server-side --field-manager=cache-operator --force-conflicts

Code C# — create-or-update classique en KubeOps

// Pattern create-or-update classique dans un reconciler KubeOps.
// Idempotent : on crée seulement si absent, on met à jour seulement si l'écart est non nul.
public async Task ReconcileAsync(V1Cache entity, CancellationToken token)
{
    var ns = entity.Namespace() ?? "default";
    var desired = BuildDesiredDeployment(entity);

    var existing = await _client.Get<V1Deployment>(desired.Name(), ns);

    if (existing is null)
    {
        // L'état réel est « absent » — créer pour combler l'écart.
        desired.SetOwnerReference(entity);  // chapitre 25-26
        await _client.Create(desired);
        return;
    }

    // L'objet existe : comparer l'état réel à l'état désiré.
    if (NeedsUpdate(existing, desired))
    {
        // Préserver le resourceVersion pour l'optimistic locking.
        desired.Metadata.ResourceVersion = existing.ResourceVersion();
        desired.SetOwnerReference(entity);
        await _client.Update(desired);
    }
    // Si NeedsUpdate retourne false : no-op — l'état est déjà réconcilié.
}

Piège courant : « Il suffit de toujours faire un UPDATE pour être idempotent » est inexact. Un UPDATE sans comparaison préalable écrase systématiquement tous les champs, y compris ceux gérés par d’autres acteurs (HPA, admission webhooks, annotations ajoutées par des outils tiers). De plus, un UPDATE sans resourceVersion valide est rejeté par le serveur. Server-Side Apply résout ces deux problèmes en ne touchant qu’aux champs explicitement déclarés par le field manager.


Gérer les ressources enfants en C#

L’idée en une phrase

Un Operator orchestre typiquement plusieurs ressources enfants (Deployment, Service, ConfigMap, Secret) pour matérialiser l’état désiré d’une seule CR parente. Le réconciliateur doit construire l’état voulu de chaque enfant, le comparer à l’état réel du cluster, et agir pour combler chaque écart — le tout de manière idempotente et avec des ownerReferences (chapitre 25-26) pour garantir le nettoyage en cascade.

Analogie : Considérons un chef de projet qui reçoit un cahier des charges (la CR). Il doit en dériver plusieurs livrables : un document d’architecture, un plan de tests, un budget. Chaque livrable est un enfant du projet. Le chef de projet vérifie régulièrement l’avancement de chaque livrable, le met à jour si le cahier des charges évolue, et supprime les livrables devenus obsolètes. Si le projet est annulé, tous les livrables sont archivés. Le réconciliateur est ce chef de projet méthodique.

Points clés

  • La méthode ReconcileAsync d’un Operator suit un schéma récurrent pour chaque type d’enfant : construire l’objet désiré à partir de la spec de la CR, lire l’objet réel dans le cluster, créer ou mettre à jour selon l’écart. Ce schéma se factorise dans une méthode utilitaire CreateOrUpdate.
  • L’ownerReference doit être posée sur chaque enfant avant la création (chapitre 25-26). En KubeOps, entity.SetOwnerReference(child) ou l’inverse selon la direction de la relation. Le garbage collector assure la suppression en cascade à la suppression de la CR parente.
  • Lorsqu’un champ de la CR change (par exemple le nombre de replicas ou la version de l’image), le réconciliateur doit propager la modification à l’enfant concerné. La comparaison doit porter sur les champs sémantiquement significatifs (image, replicas, port) et ignorer les champs gérés par le système (resourceVersion, managedFields, status).
  • Pour les enfants devenus obsolètes (la CR ne les requiert plus), le réconciliateur doit les supprimer explicitement — les ownerReferences ne suffisent pas car elles ne gèrent que la suppression de la CR parente, pas l’évolution de sa spec.
  • L’ordre de création peut importer : un Service doit exister avant qu’un Ingress ne le référence. Le réconciliateur gère cet ordonnancement en appliquant les enfants dans la séquence appropriée au sein d’un même tick de réconciliation.

Exemple concret

Un Operator gère une CR WebApp avec deux champs : image et replicas. Le réconciliateur crée trois enfants : un Deployment (pour les pods), un Service (pour l’exposition réseau) et un ConfigMap (pour la configuration). À t₀, la CR est créée avec image: nginx:1.27 et replicas: 2. Le réconciliateur construit les trois objets désirés, constate qu’aucun n’existe, et les crée dans l’ordre ConfigMap → Deployment → Service. À t₁, l’utilisateur modifie la CR : image: nginx:1.28. Le réconciliateur est re-déclenché, reconstruit l’état désiré du Deployment avec la nouvelle image, compare avec l’état réel (image 1.27), détecte l’écart et met à jour le Deployment. Le ConfigMap et le Service n’ont pas changé : no-op idempotent. À t₂, la CR est supprimée : le garbage collector suit les ownerReferences et supprime les trois enfants en cascade.

Comparaison des stratégies de mise à jour des enfants

StratégieAvantageInconvénientCas d’usage
GET + compare + UPDATEContrôle fin, diff expliciteVerbeux, risque d’écrasement multi-acteursOperator simple, un seul acteur
GET + compare + PATCH (merge)Ne touche qu’aux champs envoyésPas de suppression de champs retirésMises à jour partielles
Server-Side ApplyPropriété par champ, idempotent nativementNécessite Kubernetes 1.22+, gestion du field managerEnvironnement multi-acteurs
Recréation systématique (delete + create)Simple à implémenterInterruption de service, perte du status, non idempotentÀ éviter en production

Code YAML — les trois enfants d’une CR WebApp

# configmap.yaml — configuration de l'application
apiVersion: v1
kind: ConfigMap
metadata:
  name: webapp-config
  namespace: default
  ownerReferences:
    - apiVersion: myapp.example.com/v1
      kind: WebApp
      name: webapp
      uid: "a1b2c3d4-..."
      controller: true
data:
  APP_ENV: "production"
  LOG_LEVEL: "info"
---
# deployment.yaml — les pods applicatifs
apiVersion: apps/v1
kind: Deployment
metadata:
  name: webapp-deploy
  namespace: default
  ownerReferences:
    - apiVersion: myapp.example.com/v1
      kind: WebApp
      name: webapp
      uid: "a1b2c3d4-..."
      controller: true
spec:
  replicas: 2
  selector:
    matchLabels:
      app: webapp
  template:
    metadata:
      labels:
        app: webapp
    spec:
      containers:
        - name: web
          image: nginx:1.27
          envFrom:
            - configMapRef:
                name: webapp-config
---
# service.yaml — exposition réseau
apiVersion: v1
kind: Service
metadata:
  name: webapp-svc
  namespace: default
  ownerReferences:
    - apiVersion: myapp.example.com/v1
      kind: WebApp
      name: webapp
      uid: "a1b2c3d4-..."
      controller: true
spec:
  selector:
    app: webapp
  ports:
    - port: 80
      targetPort: 80

Code kubectl — vérifier les enfants et leur propriétaire

# Lister toutes les ressources possédées par la CR WebApp
kubectl get deploy,svc,configmap -l app=webapp \
  -o jsonpath='{range .items[*]}{.kind}/{.metadata.name} -> owner: {.metadata.ownerReferences[0].kind}/{.metadata.ownerReferences[0].name}{"\n"}{end}'
# Deployment/webapp-deploy -> owner: WebApp/webapp
# Service/webapp-svc -> owner: WebApp/webapp
# ConfigMap/webapp-config -> owner: WebApp/webapp

# Vérifier que la suppression de la CR cascade sur les enfants
kubectl delete webapp webapp
# webapp.myapp.example.com "webapp" deleted

kubectl get deploy,svc,configmap -l app=webapp
# No resources found — les trois enfants ont été supprimés par le GC.

Code C# — réconciliateur complet avec gestion des enfants

// Reconciler WebApp : orchestre ConfigMap, Deployment et Service.
// Chaque enfant suit le pattern create-or-update avec ownerReference.
public class WebAppController : IEntityController<V1WebApp>
{
    private readonly IKubernetesClient _client;

    public WebAppController(IKubernetesClient client) => _client = client;

    public async Task ReconcileAsync(V1WebApp entity, CancellationToken token)
    {
        var ns = entity.Namespace() ?? "default";

        // Ordre de création : ConfigMap d'abord (le Deployment le référence)
        await ReconcileConfigMap(entity, ns, token);
        await ReconcileDeployment(entity, ns, token);
        await ReconcileService(entity, ns, token);
    }

    private async Task ReconcileConfigMap(V1WebApp entity, string ns, CancellationToken token)
    {
        var desired = new V1ConfigMap
        {
            Metadata = new V1ObjectMeta { Name = $"{entity.Name()}-config", NamespaceProperty = ns },
            Data = new Dictionary<string, string>
            {
                ["APP_ENV"] = entity.Spec.Environment ?? "production",
                ["LOG_LEVEL"] = entity.Spec.LogLevel ?? "info"
            }
        };
        desired.SetOwnerReference(entity);

        await CreateOrUpdate<V1ConfigMap>(desired, ns, token,
            (existing, wanted) => existing.Data.SequenceEqual(wanted.Data) is false);
    }

    private async Task ReconcileDeployment(V1WebApp entity, string ns, CancellationToken token)
    {
        var desired = BuildDeployment(entity, ns);
        desired.SetOwnerReference(entity);

        await CreateOrUpdate<V1Deployment>(desired, ns, token,
            (existing, wanted) =>
                existing.Spec.Replicas != wanted.Spec.Replicas ||
                existing.Spec.Template.Spec.Containers[0].Image
                    != wanted.Spec.Template.Spec.Containers[0].Image);
    }

    private async Task ReconcileService(V1WebApp entity, string ns, CancellationToken token)
    {
        var desired = BuildService(entity, ns);
        desired.SetOwnerReference(entity);

        await CreateOrUpdate<V1Service>(desired, ns, token,
            (existing, wanted) =>
                existing.Spec.Ports[0].Port != wanted.Spec.Ports[0].Port);
    }

    // Pattern create-or-update générique, réutilisable pour tout type de ressource.
    // Le prédicat needsUpdate compare l'état réel à l'état désiré
    // sur les champs sémantiquement significatifs uniquement.
    private async Task CreateOrUpdate<T>(
        T desired, string ns, CancellationToken token,
        Func<T, T, bool> needsUpdate) where T : IKubernetesObject<V1ObjectMeta>
    {
        var existing = await _client.Get<T>(desired.Name(), ns);

        if (existing is null)
        {
            await _client.Create(desired);
            return;
        }

        if (needsUpdate(existing, desired))
        {
            // Conserver le resourceVersion pour l'optimistic locking
            desired.Metadata.ResourceVersion = existing.ResourceVersion();
            await _client.Update(desired);
        }
        // Sinon : no-op — l'état réel correspond déjà à l'état désiré.
    }

    private V1Deployment BuildDeployment(V1WebApp entity, string ns) =>
        new()
        {
            Metadata = new V1ObjectMeta
            {
                Name = $"{entity.Name()}-deploy",
                NamespaceProperty = ns,
                Labels = new Dictionary<string, string> { ["app"] = 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 = "web",
                                Image = entity.Spec.Image,
                                Ports = new List<V1ContainerPort>
                                {
                                    new() { ContainerPortValue = 80 }
                                }
                            }
                        }
                    }
                }
            }
        };

    private V1Service BuildService(V1WebApp entity, string ns) =>
        new()
        {
            Metadata = new V1ObjectMeta
            {
                Name = $"{entity.Name()}-svc",
                NamespaceProperty = ns,
                Labels = new Dictionary<string, string> { ["app"] = entity.Name() }
            },
            Spec = new V1ServiceSpec
            {
                Selector = new Dictionary<string, string> { ["app"] = entity.Name() },
                Ports = new List<V1ServicePort>
                {
                    new() { Port = entity.Spec.Port, TargetPort = 80 }
                }
            }
        };
}

Piège courant : « On peut recréer les enfants à chaque réconciliation (delete + create) pour simplifier le code » est une erreur grave en production. La recréation provoque une interruption de service (les pods sont détruits puis recréés), fait perdre le status et les métadonnées accumulées, et viole l’idempotence (chapitre 23-24) : deux exécutions consécutives sans changement de spec produisent pourtant des effets (des redémarrages). Le pattern create-or-update garantit que seules les modifications effectives déclenchent des écritures.


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.