Mert Özen Her Satırda Daha İleri
DevOps Kubernetes — #22

Kubernetes'i Genişletmek: CRD, Controller ve Operator Pattern'i, ownerReferences ve Finalizer'lar

Mert Özen 15 Kas 2026 14 dk 6 görüntülenme
Kubernetes'i Genişletmek: CRD, Controller ve Operator Pattern'i, ownerReferences ve Finalizer'lar

Kubernetes'i genişletmek, CRD ile yeni bir resource tipi tanımlayıp onu izleyen bir controller'ın desired state'i sürekli gerçeğe çevirmesi demek. Bookstore için bir Campaign CRD'si, CEL doğrulaması, ownerReferences ile garbage collection, .NET ve KubernetesClient ile yazılmış bir controller, finalizer yüzünden Terminating'de takılan namespace'in güvenli çözümü ve finalizer'ı zorla silmenin bıraktığı sahipsiz kaynak gerçek çıktılarla.

Sürüm notu: Bu yazıdaki örnekler kind 0.33 ile Kubernetes 1.37, kubectl 1.37, .NET 10 ve KubernetesClient 21.0.8 ile denenmiştir; operator kütüphanelerinin durumu ve cert-manager sürümü Kasım 2026 itibarıyladır.

Önce Tahmin Et

Bir namespace'te, kendi yazdığın bir controller'ın yönettiği tek bir custom resource var ve object'in üzerinde controller'ın eklediği bir finalizer duruyor. Controller'ın pod'u bir sebepten çalışmıyor. kubectl delete namespace diyorsun. Namespace kaç saniyede silinir? İnternette bulduğun "finalizer'ı patch ile boşalt" komutu namespace'i hemen siliyor; peki o komut neyi kaybettirir? İki sorunun cevabını da yazının ikinci yarısında, kendi controller'ımızla görüyoruz.

Reconciliation Loop'unun Genel Hâli

Serinin ilk yazısında desired state ve reconciliation loop'u gördük: sen istediğin durumu yazarsın, bir controller gerçek durumu sürekli onunla karşılaştırıp aradaki farkı kapatır. Deployment controller ReplicaSet'leri, ReplicaSet controller pod'ları, EndpointSlice controller endpoint'leri böyle yönetir. Bu yazının konusu aynı mekanizmanın Kubernetes'e senin eklediğin tiplere genişletilmesi. Serinin içinde bunu fark etmeden kullandık: altıncı yazıdaki Gateway ve HTTPRoute, on birinci yazıdaki KEDA'nın (event kaynaklarına göre ölçekleme yapan bileşen) ScaledObject'i, TLS (HTTPS bağlantısını şifreleyen protokol) serisinin beşinci yazısındaki cert-manager Certificate'ı, GitOps serisindeki Argo CD Application'ı Kubernetes'in kendi tipleri değil; hepsi bir CRD (Custom Resource Definition; Kubernetes'e eklenen yeni resource tipinin tanımı) ve onu izleyen bir controller.

Operator pattern bu ikisinin birleşimine verilen ad: yeni bir tip ve o tipi yöneten, bir uygulamayı işletme bilgisini koda dökmüş controller. cert-manager "certificate nasıl alınır ve yenilenir", CloudNativePG "PostgreSQL cluster'ı nasıl kurulur, yedeklenir ve failover edilir" bilgisini taşır.

CRD ile Yeni Bir Resource Tipi

Sitedeki serilerin ortak örnek uygulaması Bookstore'da (bir online kitapçı) kampanyalar var: bir başlık, indirim oranı, başlangıç ve bitiş zamanı, kapsadığı ISBN'ler (kitapların uluslararası standart numarası). Bunu Kubernetes'te Campaign adında bir tip olarak tanımlıyoruz:

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: campaigns.bookstore.example.com
spec:
  group: bookstore.example.com
  scope: Namespaced
  names:
    kind: Campaign
    plural: campaigns
    singular: campaign
    shortNames: ["camp"]
  versions:
    - name: v1alpha1
      served: true
      storage: true
      subresources:
        status: {}
      additionalPrinterColumns:
        - name: Discount
          type: integer
          jsonPath: .spec.discountPercent
        - name: Phase
          type: string
          jsonPath: .status.phase
        - name: Age
          type: date
          jsonPath: .metadata.creationTimestamp
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              required: ["title", "discountPercent", "startsAt", "endsAt"]
              x-kubernetes-validations:
                - rule: "self.endsAt > self.startsAt"
                  message: "endsAt must be after startsAt"
              properties:
                title:
                  type: string
                  maxLength: 80
                discountPercent:
                  type: integer
                  minimum: 1
                  maximum: 70
                startsAt:
                  type: string
                  format: date-time
                endsAt:
                  type: string
                  format: date-time
                isbns:
                  type: array
                  maxItems: 50
                  items:
                    type: string
                    pattern: "^97[89][0-9]{10}$"
            status:
              type: object
              properties:
                phase:
                  type: string
                observedGeneration:
                  type: integer
                  format: int64
                configMapName:
                  type: string
$ kubectl apply -f campaign-crd.yaml
customresourcedefinition.apiextensions.k8s.io/campaigns.bookstore.example.com created
$ kubectl get crd
NAME                              SCOPE        VERSIONS            CREATED AT
campaigns.bookstore.example.com   Namespaced   v1alpha1(storage)   2026-11-15T07:39:29Z
$ kubectl api-resources --api-group=bookstore.example.com
NAME        SHORTNAMES   APIVERSION                       NAMESPACED   KIND
campaigns   camp         bookstore.example.com/v1alpha1   true         Campaign

API server artık /apis/bookstore.example.com/v1alpha1 altında yeni bir endpoint sunuyor; kubectl get, explain, RBAC (rol tabanlı yetkilendirme) ve watch bu tip için de çalışıyor. Grup adı sahip olduğun bir domain olmalı; örnekte rezerve edilmiş example.com'u kullandım. Şema yalnız belge değil, doğrulama: OpenAPI kuralları alan bazında, x-kubernetes-validations ise CEL (Common Expression Language; Kubernetes'in doğrulama kurallarında kullandığı ifade dili) ile alanlar arası kural yazdırıyor; bu özellik 1.29'dan beri GA (kararlı sürüm):

$ kubectl apply -f campaign-invalid.yaml
The Campaign "broken" is invalid:
* spec.discountPercent: Invalid value: 90: spec.discountPercent in body should be less than or equal to 70
* spec.isbns[0]: Invalid value: "12345": spec.isbns[0] in body should match '^97[89][0-9]{10}$'
* spec: Invalid value: endsAt must be after startsAt

Geçersiz kampanya etcd'ye hiç yazılmadı; controller'ın bunları kontrol etmesi gerekmiyor. subresources.status ise status'u ayrı bir endpoint'e taşır: kullanıcı spec'i, controller status'u yazar ve RBAC ile ikisi ayrı yetkilendirilebilir. Bir yan etkisi daha var: metadata.generation yalnız spec değişince artar, controller da status.observedGeneration'a hangi generation'ı işlediğini yazar. İkisi eşitse controller son değişikliği görmüş demektir.

ownerReferences ve Garbage Collection

Bir object başka bir object tarafından yaratıldıysa, sahibini metadata.ownerReferences'ta taşır. Bunu görmenin en kolay yolu önceki yazıda krew ile kurduğumuz tree plugin'i:

$ kubectl tree -n bookstore deployment api
NAMESPACE  NAME                          READY  REASON  STATUS   AGE
bookstore  Deployment/api                -              -        1s
bookstore  └─ReplicaSet/api-7dc5dc5b47   -              -        1s
bookstore    ├─Pod/api-7dc5dc5b47-4mz8g  True           Current  1s
bookstore    ├─Pod/api-7dc5dc5b47-95q9t  True           Current  1s
bookstore    └─Pod/api-7dc5dc5b47-z8w8h  True           Current  1s
$ kubectl -n bookstore get rs -l app.kubernetes.io/name=api -o jsonpath='{.items[0].metadata.ownerReferences}{"\n"}'
[{"apiVersion":"apps/v1","blockOwnerDeletion":true,"controller":true,"kind":"Deployment","name":"api","uid":"<uid>"}]

Sahip silinince sahipsiz kalan object'leri garbage collector siler. Varsayılan background deletion'da sahip hemen gider, bağımlılar arkasından. --cascade=orphan bağımlıları bırakır, --cascade=foreground ise önce bağımlıları siler:

$ kubectl -n bookstore delete deploy api --cascade=orphan
deployment.apps "api" deleted from bookstore namespace
$ kubectl -n bookstore get rs,pods -l app.kubernetes.io/name=api
NAME                             DESIRED   CURRENT   READY   AGE
replicaset.apps/api-7dc5dc5b47   3         3         3       2s

NAME                       READY   STATUS    RESTARTS   AGE
pod/api-7dc5dc5b47-4mz8g   1/1     Running   0          2s
pod/api-7dc5dc5b47-95q9t   1/1     Running   0          2s
pod/api-7dc5dc5b47-z8w8h   1/1     Running   0          2s
$ kubectl -n bookstore apply -f api.yaml
deployment.apps/api created
$ kubectl -n bookstore get rs -l app.kubernetes.io/name=api -o 'custom-columns=NAME:.metadata.name,OWNER:.metadata.ownerReferences[0].name,READY:.status.readyReplicas'
NAME             OWNER   READY
api-7dc5dc5b47   api     3
$ kubectl -n bookstore delete deploy api --cascade=foreground --wait=false
deployment.apps "api" deleted from bookstore namespace
$ kubectl -n bookstore get deploy api -o jsonpath='{.metadata.finalizers} {.metadata.deletionTimestamp}{"\n"}'
["foregroundDeletion"] 2026-11-15T07:41:35Z

İki ayrıntı önemli. Orphan bırakılan ReplicaSet'i, aynı Deployment yeniden oluşturulunca controller sahiplendi (adoption); yeni pod açılmadı, pod adları aynı kaldı. Bu, bir Deployment'ı kesinti olmadan yeniden yaratmanın yolu. Foreground silmede ise Deployment silinmedi, deletionTimestamp aldı ve üzerine foregroundDeletion finalizer'ı kondu: bağımlılar bitene kadar bekliyor. Finalizer'ın ne olduğunu biraz sonra kendi controller'ımızla göreceğiz.

.NET ile Küçük Bir Controller

Bookstore.CampaignController bir .NET 10 console uygulaması ve resmî C# client'ı KubernetesClient 21.0.8'i kullanıyor. Her Campaign için kampanyanın bilgilerini taşıyan bir ConfigMap üretiyor (API onu mount edip okuyabilir) ve banner'ı cluster dışındaki bir storefront cache'ine yazıyor. Lab'da o cache bir JSON dosyası; gerçekte bir CDN (içeriği kullanıcıya yakın sunuculardan dağıtan ağ), arama indeksi ya da ödeme sağlayıcısındaki kampanya kaydı olurdu. Yapı, gerçek controller'ların izlediği yol: watch yalnız "bu ada tekrar bak" der ve adı bir queue'ya koyar, reconcile ise object'in güncel hâlini okuyup ne yapılacağına karar verir.

// Work queue: watch yalnız "bu ada tekrar bak" der; ne yapılacağına reconciler karar verir.
Channel<string> queue = Channel.CreateUnbounded<string>();

Task worker = Task.Run(async () =>
{
    await foreach (string name in queue.Reader.ReadAllAsync(cts.Token))
    {
        try
        {
            await reconciler.ReconcileAsync(ns, name, cts.Token);
        }
        catch (Exception e) when (e is not OperationCanceledException)
        {
            CampaignReconciler.Log($"{ns}/{name}: reconcile failed ({e.GetType().Name}), retrying in 5s");
            _ = Task.Delay(TimeSpan.FromSeconds(5), cts.Token).ContinueWith(_ => queue.Writer.TryWrite(name), TaskScheduler.Default);
        }
    }
});

CampaignReconciler.Log($"watching campaigns in namespace {ns}");
try
{
    while (!cts.IsCancellationRequested)
    {
        // Watch sunucu tarafındaki timeout'ta biter; her turda baştan başlamak kaçırılan event'leri de kapsar.
        try
        {
            await foreach ((WatchEventType type, object item) in client.CustomObjects.WatchListNamespacedCustomObjectAsync(
                Campaign.Group, Campaign.Version, ns, Campaign.Plural, timeoutSeconds: 300, cancellationToken: cts.Token))
            {
                Campaign campaign = KubernetesJson.Deserialize<Campaign>(((JsonElement)item).GetRawText());
                CampaignReconciler.Log($"event {type} {campaign.Metadata.Name} (generation {campaign.Metadata.Generation})");
                queue.Writer.TryWrite(campaign.Metadata.Name);
            }
        }
        catch (HttpOperationException e)
        {
            // Burada 404 genelde CRD'nin (henüz ya da artık) kurulu olmadığı anlamına gelir: çökmek yerine bekle.
            CampaignReconciler.Log($"watch failed ({(int)e.Response.StatusCode}), retrying in 10s");
            await Task.Delay(TimeSpan.FromSeconds(10), cts.Token);
        }
    }
}
catch (OperationCanceledException)
{
}

Reconcile'ın kendisi "level-triggered": hangi event'in geldiğine bakmıyor, her seferinde aynı soruyu soruyor: "şu an olması gereken ne, olan ne?"

    // Level-triggered: her seferinde güncel object'i oku ve dünyayı ona doğru taşı.
    public async Task ReconcileAsync(string ns, string name, CancellationToken ct)
    {
        Campaign campaign;
        try
        {
            campaign = await client.CustomObjects.GetNamespacedCustomObjectAsync<Campaign>(
                Campaign.Group, Campaign.Version, ns, Campaign.Plural, name, ct);
        }
        catch (HttpOperationException e) when (e.Response.StatusCode == HttpStatusCode.NotFound)
        {
            return; // zaten silinmiş; ConfigMap'i garbage collector siler
        }

        string key = $"{ns}/{name}";
        List<string> finalizers = campaign.Metadata.Finalizers?.ToList() ?? [];

        if (campaign.Metadata.DeletionTimestamp is not null)
        {
            if (finalizers.Remove(Finalizer))
            {
                await storefront.RemoveAsync(key, ct);
                Log($"{key}: banner removed from storefront, releasing finalizer");
                await PatchFinalizersAsync(ns, name, finalizers, ct);
            }
            return;
        }

        if (!finalizers.Contains(Finalizer))
        {
            finalizers.Add(Finalizer);
            await PatchFinalizersAsync(ns, name, finalizers, ct);
            Log($"{key}: finalizer added");
            return; // bu güncelleme yeni bir reconcile tetikler
        }

        string configMapName = $"campaign-{name}";
        await EnsureConfigMapAsync(campaign, configMapName, ct);
        await storefront.PublishAsync(key, campaign.Spec.Title, ct);

        DateTimeOffset now = clock.GetUtcNow();
        string phase = now < campaign.Spec.StartsAt ? "Scheduled" : now >= campaign.Spec.EndsAt ? "Expired" : "Active";
        long generation = campaign.Metadata.Generation ?? 0;
        if (campaign.Status is not { } status || status.Phase != phase || status.ObservedGeneration != generation)
        {
            var patch = new { status = new CampaignStatus(phase, generation, configMapName) };
            await client.CustomObjects.PatchNamespacedCustomObjectStatusAsync(
                new V1Patch(patch, V1Patch.PatchType.MergePatch), Campaign.Group, Campaign.Version, ns, Campaign.Plural, name,
                cancellationToken: ct);
            Log($"{key}: generation {generation} reconciled, phase {phase}");
        }
    }

EnsureConfigMapAsync ConfigMap'i ownerReferences'ında Campaign'in adı ve UID'siyle (object'in değişmeyen benzersiz kimliği) oluşturuyor (tam kod snapshot'ta). Controller'ı lab'da build edip cluster dışından, kubeconfig ile çalıştırdım (dotnet bin/Debug/net10.0/Bookstore.CampaignController.dll); cluster içinde aynı kod ServiceAccount token'ını kullanır. Bir kampanya oluşturup indirimi değiştirdim:

$ kubectl -n bookstore get campaigns
NAME            DISCOUNT   PHASE    AGE
classics-week   20         Active   4s
$ kubectl -n bookstore get configmap campaign-classics-week -o jsonpath='{.metadata.ownerReferences}{"\n"}{.data}{"\n"}'
[{"apiVersion":"bookstore.example.com/v1alpha1","blockOwnerDeletion":true,"controller":true,"kind":"Campaign","name":"classics-week","uid":"<uid>"}]
{"discountPercent":"20","endsAt":"2026-11-30T23:59:59.0000000+00:00","isbns":"9780141439518,9780141439549","startsAt":"2026-11-10T00:00:00.0000000+00:00","title":"Classics Week"}
$ kubectl -n bookstore patch campaign classics-week --type=merge -p '{"spec":{"discountPercent":25}}'
campaign.bookstore.example.com/classics-week patched
$ kubectl -n bookstore get campaign classics-week -o jsonpath='{.metadata.generation} {.status}{"\n"}'
2 {"configMapName":"campaign-classics-week","observedGeneration":2,"phase":"Active"}
07:39:42 watching campaigns in namespace bookstore
07:39:46 event Added classics-week (generation 1)
07:39:46 event Modified classics-week (generation 1)
07:39:46 bookstore/classics-week: finalizer added
07:39:46 bookstore/classics-week: configmap campaign-classics-week created
07:39:46 bookstore/classics-week: generation 1 reconciled, phase Active
07:39:46 event Modified classics-week (generation 1)
07:39:50 event Modified classics-week (generation 2)
07:39:50 bookstore/classics-week: configmap campaign-classics-week updated
07:39:50 event Modified classics-week (generation 2)
07:39:50 bookstore/classics-week: generation 2 reconciled, phase Active

Log'da her kendi yazdığımız değişikliğin (finalizer, status) yeni bir Modified event'i ürettiğine dikkat et; reconcile bunları gereksiz iş yapmadan geçiyor, çünkü durum zaten istenen durum. Status patch'i generation'ı artırmadı; spec patch'i artırdı.

Finalizer'lar: Silmeden Önce Temizlik

Finalizer, object'in metadata.finalizers listesindeki bir isim; liste boşalana kadar API server object'i silmez, yalnız deletionTimestamp yazar. Controller bu işareti görünce cluster dışındaki temizliği yapar ve kendi finalizer'ını listeden çıkarır. ConfigMap için finalizer gerekmiyor; onu ownerReferences ve garbage collector hallediyor. Finalizer, Kubernetes'in göremediği şeyler için var: burada storefront'taki banner.

22-finalizer-flow-tr.png

$ kubectl -n bookstore delete campaign classics-week
campaign.bookstore.example.com "classics-week" deleted from bookstore namespace
$ kubectl -n bookstore get configmap campaign-classics-week
Error from server (NotFound): configmaps "campaign-classics-week" not found
$ cat storefront-banners.json; echo
{}
07:40:06 event Modified classics-week (generation 3)
07:40:06 bookstore/classics-week: banner removed from storefront, releasing finalizer
07:40:06 event Deleted classics-week (generation 3)

Terminating'de Takılan Namespace

Önce Tahmin Et'in cevabı. bookstore-promo namespace'inde bir kampanya oluşturdum, controller finalizer'ı ekledikten sonra controller'ı durdurdum ve namespace'i sildim:

$ kubectl delete namespace bookstore-promo --wait=false
namespace "bookstore-promo" deleted
$ kubectl get namespace bookstore-promo
NAME              STATUS        AGE
bookstore-promo   Terminating   25s
$ kubectl get namespace bookstore-promo -o jsonpath='{range .status.conditions[*]}{.type}={.status}: {.message}{"\n"}{end}'
NamespaceDeletionDiscoveryFailure=False: All resources successfully discovered
NamespaceDeletionGroupVersionParsingFailure=False: All legacy kube types successfully parsed
NamespaceDeletionContentFailure=False: All content successfully deleted, may be waiting on finalization
NamespaceContentRemaining=True: Some resources are remaining: campaigns.bookstore.example.com has 1 resource instances
NamespaceFinalizersRemaining=True: Some content in the namespace has finalizers remaining: bookstore.example.com/storefront-cleanup in 1 resource instances

Namespace sonsuza kadar bekler, ve condition'lar sebebi açıkça yazıyor: hangi tipten kaç object kaldı, hangi finalizer bekliyor. Güvenli çözüm finalizer'ın sahibini bulup geri getirmek. Controller'ı yeniden başlattım:

07:40:47 watching campaigns in namespace bookstore-promo
07:40:47 event Added winter-sale (generation 2)
07:40:47 bookstore-promo/winter-sale: banner removed from storefront, releasing finalizer
07:40:47 event Deleted winter-sale (generation 2)
$ kubectl get namespace bookstore-promo
Error from server (NotFound): namespaces "bookstore-promo" not found

Şimdi kestirme yol. Başka bir kampanyada controller kapalıyken finalizer'ı elle boşalttım:

$ kubectl -n bookstore delete campaign spring-sale --wait=false
campaign.bookstore.example.com "spring-sale" deleted from bookstore namespace
$ kubectl -n bookstore patch campaign spring-sale --type=merge -p '{"metadata":{"finalizers":null}}'
campaign.bookstore.example.com/spring-sale patched
$ kubectl -n bookstore get configmap campaign-spring-sale
Error from server (NotFound): configmaps "campaign-spring-sale" not found
$ cat storefront-banners.json; echo
{"bookstore/spring-sale":"Spring Sale"}

Kubernetes tarafı tertemiz görünüyor, ama banner storefront'ta kaldı ve artık onu kaldıracak bir kayıt yok. Gerçek hayatta bu bir bulut load balancer'ı, bir DNS kaydı ya da faturası işlemeye devam eden bir disk olabilir. Namespace'te bir APIService'in (API server'a eklenen harici bir API) erişilemez olması da benzer bir takılma üretir; o durumda NamespaceDeletionDiscoveryFailure=True görürsün. Sıra hep aynı: condition'ları oku, finalizer'ın sahibi olan controller'ı bul ve çalıştır; controller gerçekten artık yoksa dış kaynağı elle temizle, finalizer'ı en son kaldır.

Operator Seçmek ve .NET ile Operator Yazmak

Bir operator kurmak, cluster'ına geniş yetkili ve uzun yaşayan bir program eklemek demek. Seçerken üç şeye bak. Olgunluk: projenin bakımcıları, sürüm sıklığı, CNCF (Kubernetes'i de barındıran açık kaynak vakfı) statüsü, sürümünün Kubernetes sürümünle uyumu. İstediği RBAC yetkisi: kurulum manifest'ini uygulamadan önce oku. cert-manager v1.21.3'ün statik manifest'inde:

$ kubectl create --dry-run=client -f cert-manager.yaml -o json | jq -s -r 'group_by(.kind) | map("\(.[0].kind)=\(length)") | join(" ")'
ClusterRole=13 ClusterRoleBinding=10 CustomResourceDefinition=6 Deployment=3 MutatingWebhookConfiguration=1 Namespace=1 Role=3 RoleBinding=3 Service=3 ServiceAccount=3 ValidatingWebhookConfiguration=1
$ kubectl create --dry-run=client -f cert-manager.yaml -o json | jq -r 'select(.kind == "ClusterRole") | .metadata.name as $n | .rules[] | select(.resources | index("secrets")) | "\($n): \(.verbs | join(","))"'
cert-manager-cainjector: get,list,watch
cert-manager-controller-issuers: get,list,watch,create,update,delete
cert-manager-controller-clusterissuers: get,list,watch,create,update,delete
cert-manager-controller-certificates: get,list,watch,create,update,delete,patch
cert-manager-controller-orders: get,list,watch
cert-manager-controller-challenges: get,list,watch
cert-manager-controller-challenges: get,list,watch

cert-manager'ın controller'ı cluster'daki bütün namespace'lerde Secret oluşturup silebiliyor; işi bu, ama bu yetkinin kimde olduğunu bilmen gerekiyor. External Secrets Operator (Configuration ve Secret Management serisinin altıncı yazısı) ve Argo CD için de aynı soruyu sor. Upgrade yolu: CRD'ler de sürümlenir ve Helm, chart'ın crds/ klasöründeki CRD'leri helm upgrade'de güncellemez, uninstall'da da silmez; operator'ün dokümanındaki upgrade adımlarını takip et.

Kendi controller'ın için .NET tarafında iki seçenek var. KubernetesClient (resmî client, Ekim 2026'da 21.0.8) bu yazıdaki gibi her şeyi elle yazdırır: watch, queue, retry, leader election. KubeOps ise kubebuilder'dan esinlenen bir operator SDK'sı: entity'den CRD üretir, finalizer ve reconcile için hazır yapı verir; son kararlı sürümü 13.3.6, 14.0 prerelease'leri .NET 10 ve 11'i hedefliyor. Ama önce yazmamayı düşün. Tek seferlik bir iş için Job, periyodik iş için CronJob, Helm ya da Kustomize ile çözülen bir paketleme ihtiyacı için chart yeterli. Operator, sürekli izlenmesi ve düzeltilmesi gereken bir durum varsa anlamlı; ve yazdığın an onun upgrade'ini, RBAC'ını ve kesintilerini de üstlenirsin.

Üretimde Dikkat

Controller'ı cluster'a koyarken ona yalnız işinin gerektirdiği yetkiyi ver ve kubectl auth can-i ile test et:

$ kubectl auth can-i patch campaigns.bookstore.example.com --subresource=status -n bookstore --as=system:serviceaccount:bookstore:campaign-controller
yes
$ kubectl auth can-i delete configmaps -n bookstore --as=system:serviceaccount:bookstore:campaign-controller
no
$ kubectl auth can-i get secrets -n bookstore --as=system:serviceaccount:bookstore:campaign-controller
no

Birden fazla replica çalıştıracaksan leader election gerekir; aynı object'i iki controller aynı anda reconcile etmesin. Reconcile idempotent olmalı: aynı object için on kez çalışması tek seferle aynı sonucu vermeli; bizim log'daki tekrar eden Modified event'leri bunun neden şart olduğunu gösteriyor. Finalizer'ın yaptığı dış temizlik başarısız olursa finalizer'ı kaldırma, retry et ve bir event ya da status condition ile görünür kıl. CRD şemasına geriye uyumsuz değişiklik gerekiyorsa yeni bir sürüm (v1beta1) ekle ve sürümler arası dönüşümü planla; mevcut sürümü yerinde bozma. On sekizinci yazıda Kubernetes'in kendi API'lerinin v1beta1'den v1'e geçişini gördük; kurduğun operator'lerin CRD sürümleri de aynı şekilde eskir, upgrade öncesi onları da kontrol et.

Sık Düşülen Tuzaklar

CRD'yi silmek bütün custom resource'ları siler. Controller kapalıyken CRD'yi sildim; bir kampanyada finalizer vardı, birinde yoktu:

$ kubectl delete crd campaigns.bookstore.example.com --wait=false
customresourcedefinition.apiextensions.k8s.io "campaigns.bookstore.example.com" deleted
$ kubectl -n bookstore get campaigns -o custom-columns=NAME:.metadata.name,FINALIZERS:.metadata.finalizers
NAME            FINALIZERS
classics-week   [bookstore.example.com/storefront-cleanup]
$ kubectl get crd campaigns.bookstore.example.com -o jsonpath='{.metadata.finalizers}{"\n"}{range .status.conditions[*]}{.type}={.status}: {.message}{"\n"}{end}'
["customresourcecleanup.apiextensions.k8s.io"]
NamesAccepted=True: no conflicts found
Established=True: the initial names have been accepted
Terminating=True: CustomResource deletion is in progress

Finalizer'sız kampanya hemen gitti; diğeri ve onunla birlikte CRD'nin kendisi, controller yeniden başlayıp temizliği yapana kadar bekledi. Bir chart'ı kaldırırken ya da "kullanılmayan" CRD'leri temizlerken bu, production verisinin tamamını silmek demek olabilir. Bu denemede ilk controller sürümüm de bir ders verdi: CRD gidince watch 404 aldı ve process çöktü; yukarıdaki koddaki catch (HttpOperationException) o yüzden var.

Diğerleri: finalizer'ı zorla silip dış kaynakları sahipsiz bırakmak. ownerReferences'ı farklı namespace'teki bir sahibe vermek; Kubernetes namespace'ler arası sahipliği desteklemez. Status'u spec ile aynı endpoint'ten yazmaya çalışmak (status subresource açıkken bu değişiklik yok sayılır). Reconcile'ı event tipine göre yazmak ("Added gelirse oluştur") ve kaçırılan event'lerde tutarsız kalmak. Ve her ihtiyaç için operator yazmak.

Küçük Bir Deneme

★☆☆ Kendi cluster'ında kubectl get crd çalıştır ve listedeki her grubun hangi operator'e ait olduğunu bul. kubectl tree ile bir Deployment'ın sahiplik ağacını çıkar.

★★☆ Campaign CRD'sine status.conditions ekle ve controller'ın storefront'a yazamadığında Ready=False condition'ı yazmasını sağla. Storefront dosyasını salt okunur yapıp silme sırasında ne olduğunu gözle.

★★★ Controller'ı bir Deployment olarak cluster'a al: image'ını build et, bu yazıdaki Role ile çalıştır, iki replica ve leader election ekle. Bir replica'yı silerken kampanya oluşturup event'lerin kaybolup kaybolmadığını kontrol et.

Bu yazıyla Kubernetes serisinin ek yazıları ve seri tamamlandı. Pod'dan başlayıp managed cluster'a kadar giden yolun üstüne pod'ların nereye yerleştiğini, kubectl'i günlük işte verimli kullanmayı ve Kubernetes'i kendi tiplerinle genişletmeyi ekledik. Buradan sonraki doğal duraklar sitedeki Helm, GitOps ve Argo CD ile Observability serileri: uygulamayı paketlemek, cluster'a Git'ten dağıtmak ve production'da ne olduğunu görmek.