التخزين
إبقاء البيانات حيّة داخل Pods صُمّمت لتزول.
التخزين الدائم
يقوم كل ما تعلمتموه حتى الآن على افتراض مريح: الـ Pods قابلة للاستبدال ويمكن التخلص منها. هذا الافتراض صحيح بالنسبة إلى واجهة API بلا حالة، لكنه ينهار بمجرد وجود بيانات يجب الاحتفاظ بها. فنظام ملفات الحاوية يموت معها: إذا دمّرتم الـ Pod الخاص بقاعدة PostgreSQL نُشرت بسذاجة، ضاعت الجداول. ليست تالفة، بل مفقودة.
ولا يكفي أن «نركّب مجلدًا (volume)». فالصعوبة أن الحوسبة والقرص منفصلان داخل العنقود: يمكن إعادة جدولة الـ Pod على عقدة أخرى، فيصبح المجلد المحلي في العقدة الأصلية غير قابل للوصول. لذلك يجب أن يكون التخزين موردًا من موارد العنقود لا موردًا من موارد العقدة، وأن يتبع هذا المورد الـ Pod.
تستجيب Kubernetes بفصل الأمر إلى ثلاثة كائنات، قد يربكك ذلك في البداية لكن منطقه واضح. يمثّل PersistentVolume قطعة تخزين حقيقية. أما PersistentVolumeClaim فهو طلب يصوغه التطبيق: «أحتاج إلى 10 جيبيبايت بصلاحية قراءة وكتابة». ويصف StorageClass كيفية صنع هذه المجلدات عند الطلب. لا يكتب المطوّر سوى الطلب؛ فهو لا يعرف، ولا حاجة به إلى أن يعرف، هل سيحصل على قرص EBS أم مجلد Ceph أم دليل محلي.
ثم يأتي سؤال لا يمكن تجاهله: حتى مع وجود مجلد ينجو من الزوال، يبقى Deployment غير مناسب لقاعدة بيانات. فالـ Pods فيه مجهولة الهوية، وتُنشأ بالتوازي، وستتشارك المجلد نفسه. وُجد StatefulSet لهذه الحالة بالذات، وسيشرح هذا الدرس لماذا ليس مجرد Deployment محسَّن.
المجلدات المؤقتة وحسن استخدامها
قبل التخزين الدائم، كلمة عن emptyDir الذي كثيرًا ما يُساء فهمه. فهو ينشئ دليلًا فارغًا عند بدء تشغيل الـ Pod، تتشاركه حاوياته، ويُدمَّر عند زوال الـ Pod.
apiVersion: v1
kind: Pod
metadata:
name: pod-avec-cache
spec:
containers:
- name: app
image: mon-api:1.0.0
volumeMounts:
- name: cache
mountPath: /var/cache/app
- name: collecteur-logs
image: busybox:1.36
command: ["sh", "-c", "tail -F /var/cache/app/app.log"]
volumeMounts:
- name: cache
mountPath: /var/cache/app
volumes:
- name: cache
emptyDir:
sizeLimit: 500Mi
هذا ليس حلًّا اضطراريًا: إنه الأداة المناسبة لذاكرة تخزين مؤقت (cache) أو دليل مؤقت أو مساحة تبادل بين حاويتين في الـ Pod نفسه. ولا يصبح emptyDir خطأً إلا حين نوكل إليه بيانات يجب أن تبقى. أضيفوا medium: Memory للحصول على tmpfs في الذاكرة RAM، فهو سريع لكنه يُحتسب ضمن حد ذاكرة الـ Pod.
PersistentVolume وPersistentVolumeClaim
المجلد من جهة البنية التحتية
PersistentVolume كائن في العنقود، مستقل عن أي Pod، وله دورة حياته الخاصة.
apiVersion: v1
kind: PersistentVolume
metadata:
name: pv-donnees-01
spec:
capacity:
storage: 5Gi
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: manuel
hostPath:
path: /mnt/donnees/pv-01 # للعرض التوضيحي المحلي فقط
يستحق hostPath تحذيرًا صريحًا: فهو يشير إلى دليل في العقدة، لذلك لا يستطيع استخدام المجلد إلا Pod موضوع على تلك العقدة. يصلح لعنقود محلي ذي عقدة واحدة لفهم الآلية، ويجب تجنبه في أي مكان آخر. أما في الإنتاج فيُستبدل حقل النوع هذا بمشغّل CSI يتحدث إلى تخزين المزوّد الفعلي.
الطلب من جهة التطبيق
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: donnees-app
namespace: atelier
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
storageClassName: manuel # يجب أن يطابق قيمة PV المستهدف
تبحث Kubernetes عن PersistentVolume متوافق، أي بسعة كافية ووضع وصول متوافق وstorageClassName نفسه، ثم تجري الربط. والـ PV المرتبط بـ PVC محجوز له حصرًا: لن يحصل عليه أي PVC آخر.
kubectl get pv
kubectl get pvc -n atelier
# STATUS = Pending ؟ السبب موجود في الأحداث.
kubectl describe pvc donnees-app -n atelier
يكون لـ PVC العالق في حالة Pending في الغالب أحد هذه الأسباب الثلاثة: لا يوجد PV بسعة كافية، أو storageClassName لا يطابق أي شيء، أو وضع وصول لا يدعمه التخزين. تقول أحداث describe ذلك صراحةً، وهذا من الأماكن القليلة التي تكون فيها رسالة الخطأ في Kubernetes قابلة للاستثمار مباشرة.
استخدام الـ PVC في Pod
apiVersion: apps/v1
kind: Deployment
metadata:
name: app-avec-donnees
spec:
replicas: 1 # انظروا أدناه: ReadWriteOnce يفرض نسخة واحدة
selector:
matchLabels:
app: app-donnees
template:
metadata:
labels:
app: app-donnees
spec:
containers:
- name: app
image: mon-api:1.0.0
volumeMounts:
- name: donnees
mountPath: /var/lib/app/donnees
volumes:
- name: donnees
persistentVolumeClaim:
claimName: donnees-app
ترى الحاوية دليلًا عاديًا. وآلية الربط كلها خفية عنها، وهذا بالضبط هدف الفصل.
أوضاع الوصول
هذا هو الحقل الأكثر إساءةً للفهم، لأنه لا يتعلق بـ Kubernetes بل بتقنية التخزين الكامنة.
| الوضع | الاختصار | المعنى |
|---|---|---|
ReadWriteOnce | RWO | يمكن تركيبه للقراءة والكتابة من عقدة واحدة فقط |
ReadOnlyMany | ROX | يمكن تركيبه للقراءة فقط من عدة عقد |
ReadWriteMany | RWX | يمكن تركيبه للقراءة والكتابة من عدة عقد |
ReadWriteOncePod | RWOP | Pod واحد فقط، حتى على العقدة نفسها |
ما يقبله تخزينكم فعليًا يُقرأ من المجلدات الموجودة، لا من وثائق عامة أبدًا:
kubectl get pv -o custom-columns=NOM:.metadata.name,MODES:.spec.accessModes,CLASSE:.spec.storageClassName
هناك توضيحان حاسمان. الأول أن ReadWriteOnce يتعلق بـالعقدة لا بالـ Pod: يمكن لعدة Pods على العقدة نفسها أن تتشارك مجلد RWO. أما ReadWriteOncePod فهو الذي يضمن الحصرية للـ Pod الواحد، وهذا ما تريده قاعدة البيانات.
الثاني، وهو ما يعرقل المبتدئين: الغالبية العظمى من أنظمة التخزين الكتلي (by blocks) لا توفر إلا RWO. فأقراص EBS في AWS وأقراص Persistent Disks في Google وأقراص Managed Disks في Azure لا تُرفق إلا بجهاز واحد في المرة. ويتطلب ReadWriteMany نظام ملفات شبكيًا، مثل NFS وAWS EFS وAzure Files وCephFS، وهو غالبًا أبطأ ويجب توفيره دائمًا بصورة منفصلة.
والنتيجة العملية الفورية: لن يعمل Deployment بـ replicas: 3 مع PVC بوضع ReadWriteOnce بصورة موثوقة. فإذا استقرت الـ Pods على عقد مختلفة، بقيت التي ليست على العقدة المالكة للمجلد في حالة Pending أو ContainerCreating. وهذا ليس غرابة في Kubernetes، بل طبيعة التخزين الكتلي.
StorageClass والتوفير الديناميكي
إنشاء PersistentVolumes يدويًا لا يصمد أمام التوسع. تصف StorageClass نوعًا من التخزين، ويصنع مزوِّد (provisioner) المجلدات عند الطلب، لحظة وصول PVC.
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: rapide-ssd
provisioner: kubernetes.io/no-provisioner # يُستبدل بالمشغّل الفعلي
volumeBindingMode: WaitForFirstConsumer
reclaimPolicy: Delete
allowVolumeExpansion: true
ثلاثة حقول ينبغي فهمها لا نسخها:
- يحدد
provisionerالمشغّل الذي ينشئ التخزين. وهو خاص بالمزوّد:ebs.csi.aws.comعلى AWS، وpd.csi.storage.gke.ioعلى Google Cloud، وdisk.csi.azure.comعلى Azure، وrancher.io/local-pathمع المزوّد المحلي في kind. - يؤجل
volumeBindingMode: WaitForFirstConsumerإنشاء المجلد إلى أن تُجدوَل Pod. وهذا هو الإعداد الصحيح في الغالب: إذ يُنشأ المجلد حينئذ في منطقة التوافر نفسها التي يوجد فيها الـ Pod. أما معImmediateفقد يُولد المجلد في منطقة لا يمكن وضع الـ Pod فيها أبدًا، فيبقى الـ Pod عالقًا. - يسمح
allowVolumeExpansion: trueبتكبير الـ PVC لاحقًا. وبدون هذا الحقل يكون الطريق الوحيد: نسخ احتياطي، ثم مجلد جديد، ثم استعادة.
مع StorageClass يصبح الـ PVC أقصر، ولا يُكتب أي PV يدويًا:
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: donnees-postgres
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 20Gi
storageClassName: rapide-ssd
# الأصناف المتاحة؛ (default) تشير إلى الصنف المستخدم عند إغفال الحقل
kubectl get storageclass
# أُنشئ الـ PV تلقائيًا لحظة الربط
kubectl get pv
لنقلها بوضوح: ما تحصلون عليه يعتمد كليًا على المزوّد. فالـ StorageClass نفسه لا يوجد من عنقود إلى آخر، وملف manifest يحتوي على storageClassName: gp3 يفشل في أي مكان غير AWS، ولا يوجد اسم عالمي. هنا تتوقف قابلية النقل في Kubernetes، ومن الأفضل أن تعرفوا ذلك قبل الوعد بنشر متعدد السحابات.
وإغفال storageClassName يؤدي إلى تطبيق الصنف المعلَّم افتراضيًا، إن وُجد في العنقود. وهذا مريح أثناء التطوير، لكنه هش بمجرد أن ينتقل ملف manifest بين بيئات.
سياسة الاسترجاع
تقرر مصير التخزين الفعلي عند حذف الـ PVC:
Delete: يُحذف المجلد والبيانات معه. وهي القيمة الافتراضية في التوفير الديناميكي.Retain: يبقى المجلد مع بياناته في حالةReleased. ويلزم تدخل يدوي لإعادة استخدامه.
لأي بيانات مهمة، ضعوا Retain. فهي حمايتكم الوحيدة من kubectl delete pvc مؤسف، إضافةً إلى نسخكم الاحتياطية التي تبقى ضرورية: Kubernetes لا تنسخ شيئًا احتياطيًا.
# تحويل PV موجود إلى Retain
kubectl patch pv pv-donnees-01 \
-p '{"spec":{"persistentVolumeReclaimPolicy":"Retain"}}'
لماذا لا يناسب Deployment قاعدة البيانات
لنجمع الأسباب، لأن هذا هو السؤال الأكثر تكرارًا ويستحق جوابًا دقيقًا.
- الـ Pods في Deployment مجهولة الهوية. يحتوي اسمها على لاحقة عشوائية تتغير عند كل إعادة إنشاء. أما نسخ PostgreSQL المتماثل (replication) فيحتاج إلى مخاطبة نسخة بعينها بصورة ثابتة.
- تتشارك الـ PVC نفسه. قالب الـ Pod واحد، فـ
claimNameهو نفسه للجميع. ثلاث نسخ ستكتب في المجلد نفسه، وهذا يُتلف قاعدة البيانات، إن سمح التخزين بذلك، وهو ما لا يسمح به RWO عادةً. - تُنشأ وتُدمَّر بالتوازي وبترتيب غير محدد. القاعدة ذات النسخة الأساسية والنسخ الثانوية تتطلب أن تكون الأساسية جاهزة قبل الثانوية.
- الحذف يدمّر كل شيء. لا تبقى أي هوية ثابتة.
يظهر العَرَض فورًا في Deployment بثلاث نسخ تتشارك PVC بوضع ReadWriteOnce:
kubectl get pods -l app=postgres
# postgres-6b9f7c4d8-2xk4m 1/1 Running (على عقدة المجلد)
# postgres-6b9f7c4d8-7fd2p 0/1 ContainerCreating (المجلد مرفق بالفعل في مكان آخر)
# postgres-6b9f7c4d8-q8v1t 0/1 ContainerCreating
StatefulSet
يجيب StatefulSet عن هذه القائمة نقطةً نقطة.
apiVersion: v1
kind: Service
metadata:
name: postgres
labels:
app: postgres
spec:
clusterIP: None # خدمة «headless»: مطلوبة لـ StatefulSet
selector:
app: postgres
ports:
- port: 5432
name: postgres
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: postgres
spec:
serviceName: postgres # يجب أن يشير إلى خدمة headless أعلاه
replicas: 3
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: postgres:16-alpine
ports:
- containerPort: 5432
name: postgres
env:
- name: POSTGRES_DB
value: "appdb"
- name: POSTGRES_USER
valueFrom:
secretKeyRef:
name: db-credentials
key: DB_USER
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: db-credentials
key: DB_PASSWORD
# يتطلب PostgreSQL دليلًا فرعيًا حين تحتوي نقطة التركيب
# على lost+found: بدون PGDATA تفشل التهيئة.
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
volumeMounts:
- name: donnees
mountPath: /var/lib/postgresql/data
readinessProbe:
exec:
command: ["pg_isready", "-U", "app_user", "-d", "appdb"]
initialDelaySeconds: 10
periodSeconds: 5
volumeClaimTemplates:
# يُنشأ PVC مستقل لكل نسخة، تلقائيًا.
- metadata:
name: donnees
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi
ما ينتجه هذا الـ manifest، ولا ينتجه أي Deployment:
kubectl get pods -l app=postgres
# postgres-0, postgres-1, postgres-2 — أسماء ثابتة ومرتبة ويمكن التنبؤ بها
kubectl get pvc
# donnees-postgres-0, donnees-postgres-1, donnees-postgres-2
أربعة ضمانات ملموسة:
- هوية ثابتة. الـ Pod
postgres-1إذا أُعيد إنشاؤه يُسمّىpostgres-1من جديد ويجدdonnees-postgres-1ببياناته نفسها. - مجلد مخصص لكل نسخة، بفضل
volumeClaimTemplates. لا مزيد من التشارك ولا من التلف. - ترتيب مضمون. إنشاء 0 ثم 1 ثم 2، وينتظر كل Pod جاهزية سابقه. ويجري الحذف بالترتيب العكسي.
- DNS فردي. تمنح خدمة headless كل Pod اسمًا يمكن الوصول إليه:
postgres-0.postgres.atelier.svc.cluster.local. وهذا ما يتيح ضبط النسخ المتماثل.
نقطتا يقظة. أولًا، لا تُحذف الـ PVC المنشأة عبر volumeClaimTemplates عند حذف StatefulSet: وهذه حماية مقصودة، وتعني أن التنظيف الكامل يتطلب حذفًا صريحًا.
kubectl delete statefulset postgres
kubectl get pvc # الـ PVC لا تزال موجودة، والبيانات سليمة
kubectl delete pvc donnees-postgres-0 # حذف صريح إن كان مقصودًا
ثانيًا، ملاحظة صريحة بشأن التشغيل: يوفر StatefulSet هوية ومجلدًا ثابتين، ولا شيء غير ذلك. فهو لا يضبط النسخ المتماثل، ولا يدير تحويل النسخة الأساسية عند العطل، ولا ينسخ شيئًا احتياطيًا. وللقاعدة في الإنتاج يُستخدم مشغّل (operator) مخصص (CloudNativePG أو Zalando Postgres Operator) يوفر هذا المنطق، أو خدمة مُدارة خارج العنقود. StatefulSet هو الأساس، لا الحل الكامل.
تكبير مجلد
إذا كان StorageClass يحمل allowVolumeExpansion: true، يكفي رفع قيمة الطلب:
kubectl patch pvc donnees-postgres-0 \
-p '{"spec":{"resources":{"requests":{"storage":"20Gi"}}}}'
kubectl get pvc donnees-postgres-0
هناك ثلاثة قيود يجب معرفتها: لا يمكن تقليص الحجم إطلاقًا، وتتطلب بعض المشغّلات (drivers) إعادة تشغيل الـ Pod حتى يتم توسيع نظام الملفات، ولا تعمل هذه العملية إطلاقًا مع PV تم توفيره يدويًا. لذلك احرص على تحديد الحجم مع هامش كافٍ منذ البداية.
النقاط الأساسية
- يزول نظام ملفات الحاوية بزوالها. إن
emptyDirمخصص للذاكرة المؤقتة (cache) وللتبادل بين حاويات الـ Pod نفسه، ولا يصلح أبدًا للبيانات التي يجب الاحتفاظ بها. - يفصل الثالوث PV / PVC / StorageClass البنية التحتية عن التطبيق: لا يكتب المطوّر سوى طلب (claim)، ويتولى المزوِّد (provisioner) إنشاء الحجم. ويرجع بقاء PVC في حالة
Pendingفي الغالب إلى فئة غير موجودة أو إلى وضع وصول غير مدعوم. - يرتبط
ReadWriteOnceبـالعقدة (node)، وهو الوضع الوحيد الذي تتيحه معظم وسائط التخزين على مستوى الكتل (block storage). أماReadWriteManyفيتطلب نظام ملفات شبكيًا يتم توفيره بشكل منفصل — ولن ينجح Deployment متعدد النسخ على PVC بوضع RWO. - تعتمد StorageClass على المزوِّد: فالمزوِّد (provisioner) وأسماء الفئات وسياسات الاسترجاع تختلف من سحابة إلى أخرى. اضبط
Retainللبيانات المهمة، وأنشئ نسخًا احتياطية: فـ Kubernetes لا ينشئ أي نسخ احتياطية. - تتطلب قاعدة البيانات StatefulSet: أسماء ترتيبية ثابتة، وPVC مستقل لكل نسخة عبر
volumeClaimTemplates، وبدء تشغيل مرتب، وDNS خاص بكل نسخة عبر Service من نوع headless. فهو يوفّر الأساس، لا النسخ المتماثل (replication) ولا عمليات التحويل (failover) — وهذه من اختصاص مشغّل (operator) أو خدمة مُدارة.
نصيحة: تدرّب على حذف Pod تابع لـ StatefulSet (
kubectl delete pod postgres-1) وتحقق من عودته بالاسم نفسه والبيانات نفسها. هذا يوضح ما لا يستطيع Deployment توفيره.
التعليقات
التعليقات مدعومة عبر GitHub Discussions
سجّل الدخول باستخدام GitHub للمشاركة في النقاش