0%
التخزين الدائم

التخزين

إبقاء البيانات حيّة داخل Pods صُمّمت لتزول.

15-20 دقيقة

التخزين الدائم

يقوم كل ما تعلمتموه حتى الآن على افتراض مريح: الـ 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 بل بتقنية التخزين الكامنة.

الوضعالاختصارالمعنى
ReadWriteOnceRWOيمكن تركيبه للقراءة والكتابة من عقدة واحدة فقط
ReadOnlyManyROXيمكن تركيبه للقراءة فقط من عدة عقد
ReadWriteManyRWXيمكن تركيبه للقراءة والكتابة من عدة عقد
ReadWriteOncePodRWOPPod واحد فقط، حتى على العقدة نفسها

ما يقبله تخزينكم فعليًا يُقرأ من المجلدات الموجودة، لا من وثائق عامة أبدًا:

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 قاعدة البيانات

لنجمع الأسباب، لأن هذا هو السؤال الأكثر تكرارًا ويستحق جوابًا دقيقًا.

  1. الـ Pods في Deployment مجهولة الهوية. يحتوي اسمها على لاحقة عشوائية تتغير عند كل إعادة إنشاء. أما نسخ PostgreSQL المتماثل (replication) فيحتاج إلى مخاطبة نسخة بعينها بصورة ثابتة.
  2. تتشارك الـ PVC نفسه. قالب الـ Pod واحد، فـ claimName هو نفسه للجميع. ثلاث نسخ ستكتب في المجلد نفسه، وهذا يُتلف قاعدة البيانات، إن سمح التخزين بذلك، وهو ما لا يسمح به RWO عادةً.
  3. تُنشأ وتُدمَّر بالتوازي وبترتيب غير محدد. القاعدة ذات النسخة الأساسية والنسخ الثانوية تتطلب أن تكون الأساسية جاهزة قبل الثانوية.
  4. الحذف يدمّر كل شيء. لا تبقى أي هوية ثابتة.

يظهر العَرَض فورًا في 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 للمشاركة في النقاش

تم نسخ الرابط!