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

Kubernetes'te Storage ve Stateful Uygulamalar: Volume, PersistentVolumeClaim, StatefulSet ve Veritabanı Kararı

Mert Özen 8 Kas 2026 11 dk 3 görüntülenme
Kubernetes'te Storage ve Stateful Uygulamalar: Volume, PersistentVolumeClaim, StatefulSet ve Veritabanı Kararı

Birden fazla replica'nın aynı dosyaya yazması gerekiyorsa çözüm PVC değil object storage'dır; ReadWriteOnce tek pod değil tek node demektir. emptyDir sizeLimit eviction'ını, StorageClass ve reclaim policy'yi, StatefulSet ile PostgreSQL 18'i (yeni PGDATA yolu dahil), managed DB ile operator kararını ve replica'lar arasında kırılan ASP.NET Core Data Protection key ring'ini gerçek çıktılarla görüyoruz.

Sürüm notu: Bu yazıdaki örnekler kind 0.33 ile Kubernetes 1.37, kubectl 1.37, PostgreSQL 18, Redis 7 ve .NET 10 ile denenmiştir.

Önce Tahmin Et

Bookstore API'si (Application Programming Interface) kullanıcıların yüklediği kapak görsellerini /app/uploads klasörüne yazıyor. Klasörü kaybolmasın diye bir PVC'ye (PersistentVolumeClaim) bağlıyorsun, access mode ReadWriteOnce, Deployment'ta 3 replica. Kaç pod çalışır: bir mi, üç mü? Access mode'u ReadWriteOncePod yaparsan ne değişir? Bir de şu: iki replica'lı API'de bir pod'un şifrelediği bir değeri öteki pod çözebilir mi? İlk iki sorunun cevabı access mode'ların adlarından tahmin edilenle aynı değil, üçüncüsü ise volume konusunun .NET'e özgü yüzü.

Container'ın Dosya Sistemi Geçicidir: emptyDir

Container'ın yazdığı her dosya container'la birlikte yok olur. Container restart olduğunda bile dosya sistemi image'dan sıfırlanır. İki container'ın dosya paylaşması ya da restart'tan sağ çıkması gereken geçici veri için en basit volume emptyDir'dir: pod node'a yerleşince boş bir klasör olarak yaratılır, container restart'larında korunur, pod silinince silinir. Raporu önce diske yazıp sonra yükleyen bir export işini taklit eden pod:

apiVersion: v1
kind: Pod
metadata:
  name: report-export
  namespace: bookstore
spec:
  restartPolicy: Never
  containers:
    - name: export
      image: busybox:1.37
      command: ["sh", "-c", "dd if=/dev/zero of=/scratch/report.bin bs=1M count=80 && sleep 3600"]
      volumeMounts:
        - name: scratch
          mountPath: /scratch
  volumes:
    - name: scratch
      emptyDir:
        sizeLimit: 50Mi

80 MB yazan pod bir süre Running kaldı, sonra kubelet'in periyodik kontrolünde yakalandı:

NAME            READY   STATUS   RESTARTS   AGE
report-export   0/1     Error    0          111s
Status:           Failed
Reason:           Evicted
Message:          Usage of EmptyDir volume "scratch" exceeds the limit "50Mi".

sizeLimit yazılmayan bir emptyDir node'un diskini sınırsızca doldurabilir ve node'daki bütün pod'ları disk baskısı yüzünden evict ettirebilir. emptyDir varsayılan olarak node'un diskindedir; medium: Memory ile RAM'e (tmpfs) alınabilir, o zaman yazılan her bayt container'ın memory limitinden düşer. Serinin on beşinci yazısında readOnlyRootFilesystem ile birlikte .NET'in /tmp ihtiyacını da emptyDir ile karşılayacağız.

PV, PVC ve StorageClass: Diski Kim İster, Kim Verir

Kalıcı veri için Kubernetes iki nesneyi ayırır. PV (PersistentVolume) cluster'daki gerçek bir disktir: bir bulut diski, bir NFS (Network File System) paylaşımı, kind'da node üzerindeki bir klasör. PVC ise uygulamanın isteğidir: "bana 1Gi, şu access mode'la bir disk lazım". Aradaki köprü StorageClass'tır; PVC'yi gören provisioner yeni bir PV yaratır ve ikisini bağlar (dynamic provisioning). Uygulama geliştirici olarak genelde yalnız PVC yazarsın; StorageClass'ları cluster'ı yöneten ekip tanımlar. kind'ın getirdiği varsayılan:

NAME                 PROVISIONER             RECLAIMPOLICY   VOLUMEBINDINGMODE      ALLOWVOLUMEEXPANSION   AGE
standard (default)   rancher.io/local-path   Delete          WaitForFirstConsumer   false                  19m

Üç kolon production kararlarını taşır. RECLAIMPOLICY: Delete, PVC silinince altındaki PV'nin ve verinin de silineceği anlamına gelir; aşağıda bunu gerçekten göreceğiz. WaitForFirstConsumer, PV'nin PVC yaratıldığında değil, onu kullanan pod schedule edildiğinde yaratılmasıdır; böylece disk pod'un gideceği node'da (bulutta pod'un zone'unda) açılır. Immediate modunda disk önce rastgele bir zone'da açılır, sonra pod o zone'daki node'lara mahkûm kalır. ALLOWVOLUMEEXPANSION ise PVC'yi sonradan büyütüp büyütemeyeceğini söyler; local-path'te kapalı, bulut sağlayıcılarının CSI (Container Storage Interface) driver'larında genelde açık.

kind'daki local-path provisioner'ın bir özelliği önemli: yarattığı PV bir node'a bağlıdır.

{"key":"kubernetes.io/hostname","operator":"In","values":["k8swork2-1-worker"]}
/var/local-path-provisioner/pvc-929be5b9-77e2-4aaf-a3c9-acf9dafe5746_bookstore_data-postgres-0

Bu PV'yi kullanan pod artık yalnız k8swork2-1-worker'da çalışabilir. Bulut diskleri de benzer şekilde zone'a bağlıdır: Azure Disk ya da EBS (Amazon Elastic Block Store) diski açıldığı zone'un dışındaki bir node'a takılamaz.

Access Mode'lar: ReadWriteOnce Tek Pod Demek Değil

Dört access mode var: ReadWriteOnce (RWO), ReadOnlyMany (ROX), ReadWriteMany (RWX) ve ReadWriteOncePod (RWOP). İsimlerdeki "Once" ve "Many" pod sayısını değil, node sayısını anlatır; RWOP hariç. Önce Tahmin Et'teki Deployment'ı RWO bir PVC ile 3 replica olarak açtık:

NAME READY STATUS NODE
api-uploads-79f7c58494-8bmsc 1/1 Running k8swork2-1-worker
api-uploads-79f7c58494-tnkcg 1/1 Running k8swork2-1-worker
api-uploads-79f7c58494-ztmwn 1/1 Running k8swork2-1-worker

Üçü de çalıştı, üçü de aynı node'da. RWO "tek bir node'a read-write takılabilir" demektir; aynı node'daki birden fazla pod aynı volume'u paylaşabilir. Scheduler pod'ları PV'nin bağlı olduğu node'a toplamak zorunda kaldı. Bu "çalışıyor" görüntüsü tehlikeli: replica sayısı arttıkça pod'lar dağılamaz, node düşerse hepsi birlikte düşer, üç process aynı dosyalara kilitsiz yazar. Bulut diskinde durum daha da kötüdür: başka node'a düşen pod Multi-Attach error ile ContainerCreating'de takılır.

Aynı Deployment'ı ReadWriteOncePod ile açınca Kubernetes tek pod kuralını kendisi uyguladı; RWOP 1.29'dan beri GA (General Availability) durumda:

NAME READY STATUS NODE
api-uploads-79f7c58494-kd499 0/1 Pending <none>
api-uploads-79f7c58494-klvts 0/1 Pending <none>
api-uploads-79f7c58494-lmjnv 1/1 Running k8swork2-1-worker
Warning  FailedScheduling  25s  default-scheduler  0/3 nodes are available: 1 node(s) had untolerated taint(s), 2 node(s) unavailable due to PersistentVolumeClaim with ReadWriteOncePod access mode already in-use by another pod. ...

RWOP dürüsttür ama bir bedeli var: Deployment'ın rolling update'i önce yeni pod'u açıp sonra eskisini kapattığı için yeni pod da Pending'de kalır ve rollout takılır; böyle bir Deployment'ta strategy: Recreate şart. Asıl ders şu: birden fazla replica'nın aynı dosyalara yazması gerekiyorsa çözüm volume değil, object storage'dır (aşağıda).

StatefulSet ile PostgreSQL

Deployment'taki pod'lar birbirinin yerine geçebilir: adları rastgele, hepsi aynı PVC'yi paylaşır ya da hiç paylaşmaz. Veritabanı gibi her instance'ın kendi diski ve kalıcı kimliği olan iş yükleri için StatefulSet vardır: pod adları sıralıdır (postgres-0, postgres-1), her pod volumeClaimTemplates'ten kendi PVC'sini alır ve pod yeniden yaratılsa da aynı PVC'ye döner. Dev cluster'daki PostgreSQL:

apiVersion: v1
kind: Service
metadata:
  name: postgres
  namespace: bookstore
spec:
  clusterIP: None
  selector:
    app.kubernetes.io/name: postgres
  ports:
    - name: postgres
      port: 5432
      targetPort: postgres
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: postgres
  namespace: bookstore
spec:
  serviceName: postgres
  replicas: 1
  selector:
    matchLabels:
      app.kubernetes.io/name: postgres
  persistentVolumeClaimRetentionPolicy:
    whenDeleted: Retain
    whenScaled: Retain
  template:
    metadata:
      labels:
        app.kubernetes.io/name: postgres
    spec:
      terminationGracePeriodSeconds: 60
      containers:
        - name: postgres
          image: postgres:18-alpine
          ports:
            - name: postgres
              containerPort: 5432
          env:
            - name: POSTGRES_DB
              value: bookstore
            - name: POSTGRES_USER
              value: bookstore
            - name: POSTGRES_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: postgres
                  key: password
          readinessProbe:
            exec:
              command: ["pg_isready", "-U", "bookstore", "-d", "bookstore"]
            periodSeconds: 5
          resources:
            requests:
              cpu: 100m
              memory: 256Mi
            limits:
              memory: 512Mi
          volumeMounts:
            - name: data
              mountPath: /var/lib/postgresql
  volumeClaimTemplates:
    - metadata:
        name: data
      spec:
        accessModes: ["ReadWriteOnce"]
        resources:
          requests:
            storage: 1Gi

Mount yolu bilinçli olarak /var/lib/postgresql/data değil. PostgreSQL 18 image'ları PGDATA'yı sürüme göre bir alt klasöre taşıdı; container içinde kontrol ettik: PGDATA=/var/lib/postgresql/18/docker. Eski tutorial'lardaki yolu kopyalarsan container hiç açılmaz; aynı image'ı Docker'da boş bir volume'u /var/lib/postgresql/data'ya bağlayarak denedik ve entrypoint şu hatayla çıktı:

Error: in 18+, these Docker images are configured to store database data in a
       format which is compatible with "pg_ctlcluster" (specifically, using
       major-version-specific directory names).
...
       The suggested container configuration for 18+ is to place a single mount
       at /var/lib/postgresql which will then place PostgreSQL data in a
       subdirectory, allowing usage of "pg_upgrade --link" without mount point
       boundary issues.

Kubernetes'te bu bir CrashLoopBackOff olarak görünür; en azından veri sessizce kaybolmaz. PVC, pod schedule edilene kadar Pending bekledi (WaitForFirstConsumer), sonra bağlandı:

NAME              STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS
data-postgres-0   Bound    pvc-929be5b9-77e2-4aaf-a3c9-acf9dafe5746   1Gi        RWO            standard

PVC adı <template adı>-<pod adı> kuralıyla oluşur. Bir tablo yaratıp iki sipariş ekledik ve pod'u sildik:

NAME STATUS IP NODE
postgres-0 Running 10.244.2.17 k8swork2-1-worker
pod "postgres-0" deleted from bookstore namespace
NAME STATUS RESTARTS IP NODE
postgres-0 Running 0 10.244.2.18 k8swork2-1-worker
  id  | customer | total
------+----------+-------
 1001 | Ada      | 97.40
 1002 | Grace    | 55.00
(2 rows)

IP (Internet Protocol) adresi değişti, ad ve veri aynı kaldı. Bu yüzden uygulama pod IP'sine değil DNS (Domain Name System) adına bağlanır. clusterIP: None olan headless Service her pod'a sabit bir DNS kaydı verir:

Name:	postgres-0.postgres.bookstore.svc.cluster.local
Address: 10.244.2.31

Connection string'de Host=postgres yazmak tek replica'da yeterli; replikasyonlu bir kurulumda yazma işlemleri primary'ye gitmeli ve hangi pod'un primary olduğunu Kubernetes bilmez. Bu, StatefulSet'in veritabanı için yetersiz kaldığı ilk yerdir.

persistentVolumeClaimRetentionPolicy StatefulSet silinince veya küçültülünce PVC'ye ne olacağını belirler. Retain ile StatefulSet'i sildik, PVC kaldı, yeniden apply ettiğimizde veri geri geldi. Sonra PVC'yi elle sildik:

persistentvolumeclaim "data-postgres-0" deleted from bookstore namespace
$ kubectl get pv
No resources found

StorageClass'ın reclaim policy'si Delete olduğu için PV ve node'daki klasör de gitti. Production'daki StorageClass'ın reclaim policy'sini bilmeden PVC silmek, veritabanını silmekle aynı şeydir.

Production'da Veritabanı: Managed mı, Operator mı?

Yukarıdaki StatefulSet dev ortamı için iyi; production için eksik olan şey Kubernetes'in değil veritabanı operasyonunun işi: yedek, PITR (Point-In-Time Recovery), replikasyon, failover, minor/major sürüm yükseltme, bağlantı havuzu, izleme. Üç yol var:

SeçenekNe kazanırsınNe ödersinNe zaman
Managed DB (Azure Database for PostgreSQL, Amazon RDS (Relational Database Service), Cloud SQL (Google Cloud'un managed SQL servisi))Yedek, PITR, HA (High Availability), yama sağlayıcıdaDaha yüksek fatura, sağlayıcıya bağımlılık, ağ gecikmesiÇoğu ekip için varsayılan
Operator (ör. CloudNativePG)Cluster içinde otomatik failover, yedek, replikasyon; Git'te tanımlıOperator'ü ve storage'ı senin ekibin işletirOn-prem, çok sayıda küçük veritabanı, güçlü platform ekibi
Elle StatefulSetBasitlik, sıfır ek bileşenYedek, failover, upgrade tamamen elleDev, test, CI (Continuous Integration) ortamları

Operator, bir veritabanı uzmanının yaptığı işleri controller olarak kodlayan bir Kubernetes uzantısıdır: CloudNativePG'de bir Cluster resource'u yazarsın, operator primary ve replica pod'larını, PVC'leri, yedekleri ve failover'ı yönetir, uygulamaya her zaman primary'yi gösteren bir -rw Service'i verir. Yine de RPO (Recovery Point Objective, kabul edilebilir veri kaybı) ve RTO (Recovery Time Objective, kabul edilebilir kesinti süresi) hedeflerini kim karşılayacak sorusu değişmez; managed servislerde bu, faturaya dahil bir sorumluluktur. Bookstore için önerim açık: production'da managed PostgreSQL, cluster'da yalnız uygulama.

Upload'lar ve Data Protection Key'leri: Volume Değil, Paylaşılan Store

Kapak görselleri için doğru yer PVC değil object storage'dır (Azure Blob Storage, Amazon S3 (Simple Storage Service), Google Cloud Storage): her replica aynı anda okur ve yazar, node'a ve zone'a bağlı değildir, yedek ve CDN (Content Delivery Network) entegrasyonu hazırdır. API görseli doğrudan blob'a yazar ya da istemciye kısa ömürlü bir yükleme URL'i (Uniform Resource Locator) verir.

Daha sinsi olanı ASP.NET Core Data Protection. Cookie authentication, antiforgery token'ları ve IDataProtector ile şifrelenen her şey bir key ring'e dayanır. Hiçbir şey ayarlanmazsa key'ler container'ın dosya sistemine yazılır ve runtime bunu açıkça uyarır:

03:49:38.333 warn: Microsoft.AspNetCore.DataProtection.Repositories.FileSystemXmlRepository[60] Storing keys in a directory '/home/app/.aspnet/DataProtection-Keys' that may not be persisted outside of the container. Protected data will be unavailable when container is destroyed. For more information go to https://aka.ms/aspnet/dataprotectionwarning

Bunu iki replica'lı API'de denedik. Bir pod'da order-1001 değerini /api/diag/protect ile şifreledik, aynı payload'ı önce aynı pod'a, sonra ötekine çözdürdük:

{"pod":"api-5c4bc79dd4-6x4cr","text":"order-1001"}
wget: server returned error: HTTP/1.1 500 Internal Server Error
System.Security.Cryptography.CryptographicException: The key {fc83aeff-ea88-4161-9361-3b8cc0e9ffca} was not found in the key ring.

Kullanıcı açısından bu, load balancer'ın her yeni pod'a gönderdiği isteklerde "oturumum düştü" ya da antiforgery hatası demektir; her deploy'da bütün pod'lar yenilendiği için de herkes login ekranına döner. Key'leri bir PVC'ye koymak RWO yüzünden yine tek node'a kilitlenir. Çözüm key ring'i bütün replica'ların eriştiği bir store'a taşımak:

// paylaşılan bir store ayarlanmazsa key'ler container dosya sisteminde kalır
IDataProtectionBuilder dataProtection = builder.Services.AddDataProtection().SetApplicationName("bookstore");
string? redis = builder.Configuration["DataProtection:Redis"];
if (redis is not null)
{
    dataProtection.PersistKeysToStackExchangeRedis(ConnectionMultiplexer.Connect(redis), "bookstore-dp-keys");
}

Cluster'a bir Redis koyup Deployment'a DataProtection__Redis=redis:6379 verdik, aynı deneme:

{"pod":"api-5fbb756d86-wxm75","text":"order-1001"}
$ kubectl -n bookstore exec deploy/redis -- redis-cli LLEN bookstore-dp-keys
1

Şifreleyen ve çözen pod farklı, sonuç doğru; Redis'te tek bir key var. Bizim Redis'imiz kalıcı değil, yani Redis restart'ı key'leri yine kaybettirir; production'da tercih genelde blob storage'dır (Azure.Extensions.AspNetCore.DataProtection.Blobs ile PersistKeysToAzureBlobStorage) ve key'lerin kendisi Key Vault ile şifrelenir (ProtectKeysWithAzureKeyVault). Uygulamada EF (Entity Framework) Core zaten varsa PersistKeysToDbContext da sağlam bir seçenek. SetApplicationName ise aynı key ring'i paylaşan uygulamaların birbirinin payload'ını çözebilmesi için aynı olmalı.

Üretimde Dikkat

Disk performansı StorageClass'la gelir: bulut sağlayıcılarında standart ve premium sınıfların IOPS (Input/Output Operations Per Second) limitleri çok farklıdır ve küçük bir disk, küçük bir IOPS kotası demektir. Veritabanı yavaşlığını önce sorguda değil, diskin limitine dayanıp dayanmadığında ara. PVC kapasitesini izle; dolan bir disk PostgreSQL'i yazma yapamaz hale getirir ve allowVolumeExpansion açık değilse büyütmek yeni disk ve veri taşıma demektir. Zone'lu cluster'da bir zone kaybında o zone'daki disk'e bağlı pod başka zone'a geçemez; tek replica'lı bir StatefulSet bu yüzden zone kesintisinde ayağa kalkmaz. Yedek, VolumeSnapshot'ı (CSI driver destekliyorsa) ya da veritabanının kendi yedek aracını kullanır, ama ancak geri yüklemesi denenmiş yedek yedektir. Son olarak terminationGracePeriodSeconds: PostgreSQL'in kapanırken checkpoint yazmasına yetecek kadar zaman ver; 30 saniyenin varsayılan olduğunu ve yoğun bir veritabanına yetmeyebileceğini unutma.

Sık Düşülen Tuzaklar

Deployment'a RWO PVC bağlayıp replica artırmak: ya hepsi tek node'a yığılır ya da bulut diskinde Multi-Attach error ile takılır. RWOP kullanıp rolling update'in takılmasına şaşırmak. PostgreSQL 18 image'ında eski /var/lib/postgresql/data yolunu mount etmek. Reclaim policy'si Delete olan bir StorageClass'ta "sonra yeniden yaratırım" diye PVC silmek. emptyDir'e sizeLimit koymamak. Data Protection key'lerini container'da bırakıp çok replica'lı çalışmak, sonra rastgele "oturum düştü" şikâyetleri almak. Uygulama pod'u yeniden başlarken veritabanına IP ile bağlanmaya çalışmak. Ve en pahalısı: cluster'daki tek replica'lı PostgreSQL'i yedeksiz şekilde production'da kullanmak.

Küçük Bir Deneme

★☆☆ PostgreSQL StatefulSet'ini mountPath: /var/lib/postgresql/data ile kur. Pod hangi duruma düşüyor? kubectl logs --previous ile entrypoint'in mesajını oku, sonra doğru yola dönüp kubectl exec ile PGDATA'nın gerçekte nereyi gösterdiğine bak.

★★☆ persistentVolumeClaimRetentionPolicy.whenScaled'i Delete yap, StatefulSet'i 3 replica'ya çıkar, sonra 1'e indir. Hangi PVC'ler silindi, data-postgres-0'a ne oldu?

★★★ Data Protection key ring'ini Redis yerine PostgreSQL'e taşı: EF Core ile bir DbContext yaz, IDataProtectionKeyContext'i uygula ve PersistKeysToDbContext kullan. İki replica'da şifreleme ve çözme denemesini tekrarla, ardından bütün API pod'larını silip eski payload'ın hâlâ çözülebildiğini doğrula.

Tablo yaratmayı bu yazıda psql ile elle yaptık; gerçek bir uygulamada şema EF Core migration'larıyla gelir. Bir sonraki yazıda Job ve CronJob'ları ele alıyoruz ve EF Core migration bundle'ını deploy'un doğru anında, bir kez çalışan bir Job olarak koşuyoruz.