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çenek | Ne kazanırsın | Ne ödersin | Ne 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ıda | Daha 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şletir | On-prem, çok sayıda küçük veritabanı, güçlü platform ekibi |
| Elle StatefulSet | Basitlik, sıfır ek bileşen | Yedek, failover, upgrade tamamen elle | Dev, 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.