---
title: 'Kubernetes Zero-Downtime Deployments: دليل عملي لعام'
description: 'حقق عمليات نشر Kubernetes حقيقية بـ zero-downtime: الـ rolling updates، والـ readiness probes، والـ preStop hooks، والـ graceful SIGTERM draining. دليل 2026 القابل للتنفيذ.'
url: https://nerdleveltech.com/ar/kubernetes-zero-downtime-deployments-tutorial
language: ar
date: '2026-06-09'
category: cloud-devops
tags:
  - kubernetes
  - zero-downtime deployment
  - graceful shutdown
  - rolling update
  - readiness probe
  - prestop hook
  - devops
  - node.js
markdown_url: https://nerdleveltech.com/ar/kubernetes-zero-downtime-deployments-tutorial.md
attribution: 'Source: NerdLevelTech — https://nerdleveltech.com/ar/kubernetes-zero-downtime-deployments-tutorial'
terms: https://nerdleveltech.com/llms.txt
---

# Kubernetes Zero-Downtime Deployments: دليل عملي لعام

يحتاج نشر Kubernetes بدون وقت توقف (zero-downtime) إلى أربعة أشياء تعمل معًا: مسبار جاهزية (readiness probe) بحيث تصل حركة المرور فقط إلى الـ pods الجاهزة بالفعل، وتحديث متدرج (rolling update) مع `maxUnavailable: 0`، وخطاف `preStop` يستمر لفترة أطول من انتشار نقاط النهاية (endpoint propagation)، وتطبيق يقوم بتصريف الطلبات الجارية عند استقبال `SIGTERM`. يبني هذا الدليل الأربعة جميعًا على عنقود (cluster) حقيقي.

## ملخص

ستقوم ببناء خدمة Node.js صغيرة، وتشغيلها على عنقود [kind](https://kind.sigs.k8s.io/) محلي، وإثبات — من خلال اختبار حمل مباشر — أن الـ Deployment الساذج يُسقط الطلبات أثناء عملية التحديث. ثم ستقوم بإصلاحه خطوة بخطوة: مسبارات الجاهزية تتحكم في حركة المرور أثناء الصعود، و `maxUnavailable: 0` يحافظ على السعة أثناء التحديث، ونوم `preStop` أصلي بالإضافة إلى تصريف `SIGTERM` السلس يغلق نافذة فقدان الاتصال أثناء الهبوط. خصص حوالي 30 دقيقة. كل إصدار هنا مثبت على الإصدارات الحالية — Kubernetes 1.36، و kind v0.32.0، و Node 24 LTS.

## ما ستتعلمه

- لماذا تُسقط تحديثات Kubernetes المتدرجة الاتصالات حتى عندما يتعامل تطبيقك مع `SIGTERM` بشكل مثالي
- كيفية كتابة خادم HTTP بـ Node.js يقوم بتصريف الطلبات الجارية ويخرج بنظافة
- كيف تتحكم مسبارات الجاهزية في حركة المرور إلى الـ pods عند زيادة النطاق (ولماذا يختلف مسبار الحيوية liveness)
- كيفية ضبط `maxUnavailable` و `maxSurge` لتحديث متدرج حقيقي بدون وقت توقف
- كيف يقوم إجراء النوم `preStop` (المتوفر بشكل عام في Kubernetes 1.34) بسد فجوة السباق في إزالة نقاط النهاية
- كيف يتفاعل `terminationGracePeriodSeconds` مع `preStop` ومنطق التصريف الخاص بك
- كيف يحميك PodDisruptionBudget أثناء تفريغ العقد (node drains)

## المتطلبات الأساسية

ثبّت هذه الإصدارات لكي تعمل الأوامر أدناه تمامًا كما هي مكتوبة:

- تشغيل **Docker** (أو Podman) محليًا — يقوم kind ببناء "آلة" العقدة الخاصة به كحاوية.
- **kind v0.32.0**، والذي يأتي افتراضيًا مع **Kubernetes 1.36.1**.[^kind] تعمل إصدارات kind الأقدم أيضًا؛ إجراء النوم `preStop` الأصلي المستخدم في الخطوة 7 متوفر بشكل عام في **Kubernetes 1.34** ويتم شحنه مفعلاً افتراضيًا منذ **1.30** (بيتا).[^kep]
- **kubectl** متوافق مع العنقود الخاص بك (عميل 1.35 أو 1.36 مناسب للعمل مع خادم 1.36).
- **Node.js 24 LTS** (24.16.0 وقت كتابة هذا التقرير) فقط إذا كنت تريد تشغيل التطبيق خارج الحاوية أولاً.[^node]

تحقق من أدواتك:

```bash
kind version          # expect v0.32.0
kubectl version --client
Docker info >/dev/null && echo "Docker is running"
```

جديد على المنصة؟ يغطي [دليل أساسيات Kubernetes](https://nerdleveltech.com/Kubernetes-explained-a-2026-hands-on-guide) الخاص بنا الـ Pods والـ Deployments والـ Services قبل أن تتعمق في أوضاع الفشل أدناه.

## الخطوة 1 — خدمة Node تغلق بسلاسة

أهم قاعدة مفردة للإغلاق السلس: عندما تتلقى عمليتك `SIGTERM`، توقف عن قبول اتصالات جديدة، واترك الطلبات الجارية تنتهي، ثم اخرج. سلوك Node الافتراضي عند استقبال `SIGTERM` هو الموت فورًا، مما يقطع كل اتصال مفتوح — لذا عليك التعامل مع الإشارة بنفسك.

أنشئ `src/server.js`:

```JavaScript
// src/server.js — minimal, zero-dependency HTTP server with graceful shutdown.
// Targets Node 24 LTS. On SIGTERM it stops accepting new connections,
// drains in-flight requests, then exits 0.
import http from 'node:http';

const PORT = Number(process.env.PORT ?? 8080);
const WORK_MS = Number(process.env.WORK_MS ?? 500);
const READY_DELAY_MS = Number(process.env.READY_DELAY_MS ?? 0);
const DRAIN_TIMEOUT_MS = Number(process.env.DRAIN_TIMEOUT_MS ?? 25_000);

const log = (msg, extra = {}) =>
  console.log(JSON.stringify({ ts: new Date().toISOString(), msg, ...extra }));

const server = http.createServer((req, res) => {
  if (req.url === '/healthz') {
    res.writeHead(200, { 'content-type': 'text/plain' });
    res.end('ok\n');
    return;
  }
  // Simulate real work so draining is observable.
  setTimeout(() => {
    res.writeHead(200, { 'content-type': 'text/plain' });
    res.end(`handled by ${process.pid\n`);
  }, WORK_MS);
});

// Simulate warm-up (config load, DB pool, cache prime) before we start listening.
setTimeout(() => {
  server.listen(PORT, () => log('listening', { port: PORT, pid: process.pid }));
}, READY_DELAY_MS);

let shuttingDown = false;
function shutdown(signal) {
  if (shuttingDown) return;
  shuttingDown = true;
  log('signal received, draining', { signal });

  // Stop accepting new connections; the callback runs once all
  // in-flight requests have completed.
  server.close(() => {
    log('drain complete, exiting', { code: 0 });
    process.exit(0);
  });
  // Close idle keep-alive sockets (server.close() also does this on Node 18.19+).
  server.closeIdleConnections();

// Safety net: never hang past the grace period.
  setTimeout(() => {
    log('drain timeout, forcing exit', { code: 1 });
    process.exit(1);
  }, DRAIN_TIMEOUT_MS).unref();
}

process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
```

هناك تفصيلان يقع فيهما الناس عادةً. أولاً، `server.close()` يتوقف عن قبول اتصالات جديدة وينتظر انتهاء الطلبات النشطة؛ منذ إصدار Node 18.19، يقوم أيضًا بإغلاق اتصالات keep-alive الخاملة تلقائيًا، لذا فإن استدعاء `server.closeIdleConnections()` الصريح هنا يهدف بشكل أساسي لتوثيق النية ويظل آمنًا في بيئات التشغيل الأحدث.[^node-close] ثانيًا، يقوم مقبض `READY_DELAY_MS` بمحاكاة بدء تشغيل بطيء؛ سنستخدمه لجعل فشل مسبار الجاهزية واضحًا.

تحقق من سلوك التصريف محليًا قبل وضعه في حاوية. ابدأ الخادم بتأخير طويل لكل طلب، أرسل طلبًا، ثم أرسل `SIGTERM` بينما الطلب لا يزال جاريًا:

```bash
WORK_MS=1500 node src/server.js &     # prints {"msg":"listening",...}
SRV=$!
curl -s -w 'in-flight: HTTP %{http_code} in %{time_total}s\n' http://localhost:8080/ &
sleep 0.4
kill -TERM $SRV                        # terminate mid-request
```

النتيجة المتوقعة — الطلب الجاري **ينتهي بـ 200** على الرغم من أن الخادم تلقى `SIGTERM` في منتصف الطلب، وتخرج العملية بنظافة:

```text
{"ts":"...","msg":"listening","port":8080,"pid":8}
{"ts":"...","msg":"signal received, draining","signal":"SIGTERM"}
in-flight: HTTP 200 in 1.506991s
{"ts":"...","msg":"drain complete, exiting","code":0}
```

أي طلب جديد يبدأ *بعد* `SIGTERM` يتم رفضه (المستمع مغلق) — وهذا بالضبط ما نريده. التطبيق صحيح. الآن شاهد Kubernetes وهو يُسقط الطلبات على أي حال.

## الخطوة 2 — وضعه في حاوية وتحميله في kind

أنشئ `Dockerfile` بجانب `src/`:

```dockerfile
# syntax=Docker/dockerfile:1
FROM node:24-slim
ENV NODE_ENV=production
WORKDIR /app
COPY src/ ./src/
USER node
EXPOSE 8080
CMD ["node", "src/server.js"]
```

ابنِ الصورة وشغّل العنقود. يقوم kind بتشغيل كل عقدة كحاوية، لذا ستقوم بتحميل الصورة مباشرة في العنقود بدلاً من دفعها إلى سجل (registry):

```bash
Docker build -t zdt-demo:v1 .
kind create cluster --name zdt
kind load Docker-image zdt-demo:v1 --name zdt
```

يقوم `kind load Docker-image` بنسخ صورتك المحلية إلى كل عقدة بحيث يجدها `imagePullPolicy: IfNotPresent` بدون الحاجة لسجل. تأكد من أن العنقود يعمل:

```bash
kubectl get nodes   # one control-plane node, STATUS Ready
```

## الخطوة 3 — النشر بالطريقة الساذجة (ومشاهدة سقوط الطلبات)

إليك Deployment بدون أي من آليات عدم التوقف — لا يوجد مسبار جاهزية، استراتيجية تحديث افتراضية، ومحاكاة لبدء تشغيل يستغرق 5 ثوانٍ يحاكي تطبيقًا حقيقيًا يقوم بتحميل الإعدادات وتهيئة مجمع الاتصالات. أنشئ `k8s/deployment-naive.yaml`:

```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
spec:
  replicas: 3
selector:
    matchLabels:
      app: web
  template:
    metadata:
      labels:
        app: web
    spec:
      containers:
        - name: web
          image: zdt-demo:v1
          imagePullPolicy: IfNotPresent
          ports:
            - containerPort: 8080
          env:
            - name: READY_DELAY_MS
              value: "5000"   # 5s warm-up before the server listens
            - name: WORK_MS
              value: "500"
```

وخدمة (Service) لموازنة الحمل عبر الـ pods (`k8s/service.yaml`):

```yaml
apiVersion: v1
kind: Service
metadata:
  name: web
spec:
  selector:
    app: web
  ports:
    - name: http
      port: 80
      targetPort: 8080
```

قم بتطبيق كليهما وانتظر حتى تستقر الـ pods:

```bash
kubectl apply -f k8s/service.yaml
kubectl apply -f k8s/deployment-naive.yaml
kubectl rollout status deployment/web
```

المشكلة غير مرئية في حالة السكون — ثلاث pods، جميعها تعمل. تظهر فقط *أثناء التحديث المتدرج*، وهو بالضبط الوقت الذي يراقب فيه مستخدموك.

## الخطوة 4 — قياس وقت التوقف

لا يمكنك إصلاح ما لا يمكنك رؤيته، لذا قم بتشغيل حلقة حمل مستمرة من داخل العنقود ضد الخدمة، ثم ابدأ عملية التحديث. تقوم pod `busybox` داخل العنقود بإرسال طلب تلو الآخر لمدة 60 ثانية وتحسب النجاحات والإخفاقات:

```bash
kubectl run probe --image=busybox:1.37 --restart=Never --rm -i -- \
  sh -c 'ok=0; fail=0; end=$(( $(date +%s) + 60 ));
         while [ $(date +%s) -lt $end ]; do
           if wget -q -T 2 -O /dev/null http://web/; then ok=$((ok+1));
           else fail=$((fail+1)); echo "drop at $(date +%T)"; fi
         done; echo "RESULT ok=$ok fail=$fail"'
```

بينما تعمل هذه الحلقة، افتح نافذة أوامر ثانية وافرض إعادة تشغيل (هذا يعيد إنشاء جميع الـ pods الثلاثة، تمامًا كما يفعل شحن صورة جديدة):

```bash
kubectl rollout restart deployment/web
```

عندما تنتهي الحلقة، سترى عددًا غير صفري في `fail`، بالإضافة إلى سطر `drop at ...` لكل طلب فاشل. يحدث كل سقوط في النافذة التي تقوم فيها الخدمة بتوجيه طلب إلى pod جديد تمامًا لا تزال عملية Node الخاصة به في فترة بدء التشغيل التي تبلغ 5 ثوانٍ ولم تستمع بعد على المنفذ 8080. بدون مسبار جاهزية، يعتبر Kubernetes أن الـ pod "جاهز" بمجرد بدء تشغيل الحاوية، لذا ينضم الـ pod إلى نقاط نهاية الخدمة قبل أن يتمكن التطبيق فعليًا من الخدمة.[^probes] عند ضرب الخدمة مباشرة بهذا الشكل، يكون كل فشل عبارة عن رفض للاتصال؛ مع وجود ingress أو موازن حمل في المقدمة، تظهر نفس الفجوة عادةً كخطأ 502. دعنا نغلقها.

## الخطوة 5 — مسبارات الجاهزية: إرسال حركة المرور فقط إلى الـ pods الجاهزة

يخبر مسبار الجاهزية Kubernetes ما إذا كان يجب أن يتلقى الـ pod حركة مرور أم لا. عندما يفشل، يتم إزالة الـ pod من نقاط نهاية الخدمة ولكنه يستمر في العمل (لا يتم إعادة تشغيله). مسبار الحيوية (liveness probe) مختلف: عندما يفشل *هو*، يقوم kubelet بإعادة تشغيل الحاوية. الخلط بين الاثنين هو وسيلة كلاسيكية للتسبب في انقطاع الخدمة، لذا اجعل مسبار الحيوية متساهلاً واترك مسبار الجاهزية يقوم بمهمة التحكم في حركة المرور.[^probes]

أنشئ بيان الإنتاج `k8s/deployment.yaml` (سنستمر في الإضافة إليه حتى الخطوة 7):

```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
spec:
  replicas: 3
  selector:
    matchLabels:
      app: web
  template:
    metadata:
      labels:
        app: web
    spec:
      containers:
        - name: web
          image: zdt-demo:v1
          imagePullPolicy: IfNotPresent
          ports:
            - containerPort: 8080
          env:
            - name: READY_DELAY_MS
              value: "5000"
            - name: WORK_MS
              value: "500"
          readinessProbe:
            httpGet:
              path: /healthz
              port: 8080
            initialDelaySeconds: 0
            periodSeconds: 2
            failureThreshold: 3
          livenessProbe:
            httpGet:
              path: /healthz
              port: 8080
            initialDelaySeconds: 10
            periodSeconds: 10
            failureThreshold: 3
```

خلال فترة الإحماء التي تستغرق 5 ثوانٍ، لا يكون الخادم في حالة استماع، لذا فإن فحص الجاهزية `httpGet` إلى `/healthz` يتلقى رفضاً للاتصال ويفشل. يظل الـ pod خارج الـ Service حتى ينجح الفحص، وبعد ذلك يتدفق الزيارات. يبدأ فحص الحيوية (liveness probe) لاحقاً (`initialDelaySeconds: 10`) ويقوم بالفحص ببطء، لذا لا يتعطل أبداً خلال عملية الإحماء العادية.

قم بتطبيقه وأعد تشغيل حلقة التحميل من الخطوة 4 أثناء تنفيذ `kubectl rollout restart`. سيختفي عدد حالات الفشل الناتجة عن إحماء الـ pod الجديد. ولكن لا تزال هناك فجوة في جانب *الإيقاف*، وسؤال أدق حول السعة أثناء التحديث. خطوتان إضافيتان.

## الخطوة 6 — التحديث دون خفض السعة (maxUnavailable و maxSurge)

يقوم التحديث التدريجي (rolling update) باستبدال الـ pods في مجموعات. هناك خياران يتحكمان في ذلك: `maxUnavailable` (كم عدد الـ pods التي يمكن أن تكون متوقفة عن العمل تحت العدد المطلوب) و `maxSurge` (كم عدد الـ pods الإضافية التي يمكن إنشاؤها فوق العدد المطلوب). القيم الافتراضية هي **25% لكل منهما**.[^deploy] مع نسبة 25%، يتم تقريب `maxUnavailable` *للأقل* ويتم تقريب `maxSurge` *للأعلى*، لذا يعتمد السلوك الدقيق على عدد النسخ المتماثلة (replicas) لديك — وهذا هو بالضبط السبب في وجوب تعيينهما صراحةً بدلاً من الاعتماد على التقريب.

لضمان عدم التوقف عن العمل، اضبط `maxUnavailable: 0` حتى لا ينخفض التحديث أبداً عن العدد الجاهز المطلوب، و `maxSurge: 1` بحيث يضيف pod جديداً قبل إيقاف القديم. أضف كتلة `strategy` ووسادة `minReadySeconds` إلى أعلى الـ `spec` في ملف `k8s/deployment.yaml`:

```yaml
spec:
  replicas: 3
  minReadySeconds: 5
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 0
      maxSurge: 1
  selector:
    matchLabels:
      app: web
  # ... template unchanged ...
```

تخبر `minReadySeconds: 5` الـ Deployment أن الـ pod الجديد يجب أن يظل جاهزاً لمدة 5 ثوانٍ قبل أن يُحسب ضمن التوافر ويتم إزالة الـ pod القديم التالي — وهي حماية بسيطة ضد الـ pods التي تصبح جاهزة ثم تنهار فوراً. مع `maxUnavailable: 0`، الطريقة الوحيدة للتحديث هي إنشاء pod جاهز جديد أولاً، لذا لا تنخفض السعة المقدمة أبداً.

## الخطوة 7 — إغلاق سباق الإيقاف باستخدام preStop hook

هذا هو الجزء الذي يسهل الوقوع في الخطأ فيه. تطبيقك يقوم بالفعل بتصريف الطلبات عند تلقي `SIGTERM`، فلماذا قد يؤدي إنهاء pod إلى فقدان طلب؟

لأن شيئين يحدثان **في نفس الوقت** عند حذف pod (أو استبداله بتحديث):

1. يبدأ الـ **kubelet** في إنهاء الـ pod: يبدأ مؤقت `terminationGracePeriodSeconds`، ويقوم kubelet بتشغيل `preStop` hook الخاص بك، ثم يرسل `SIGTERM` إلى الحاوية، وأخيراً يرسل `SIGKILL` إذا كان الـ pod لا يزال حياً عند نفاد تلك الميزانية الزمنية المشتركة.[^lifecycle]
2. يقوم الـ **control plane** بإزالة الـ pod من EndpointSlice الخاص بالـ Service، *ثم* يقوم kube-proxy ومتحكم الـ ingress بمزامنة هذا التغيير في جداول التوجيه الخاصة بهم.

المسار رقم 2 متسق في النهاية (eventually consistent) ويعمل بالتوازي مع المسار رقم 1. لذا لفترة قصيرة، يمكن للعقدة (node) أن تظل توجه طلباً جديداً إلى pod تلقى بالفعل `SIGTERM` وأغلق مستمعه — ويحصل العميل على رفض للاتصال.[^race] التطبيق الذي يصرف الطلبات بشكل مثالي لا يمكنه المساعدة هنا، لأن المشكلة هي أن الزيارات لا تزال *تُرسل* إليه.

الحل هو `preStop` hook يقوم ببساطة بالنوم (sleep). فهو يؤخر `SIGTERM` لفترة كافية لانتشار إزالة نقطة النهاية (endpoint)، ولأن `preStop` يكتمل *قبل* إرسال `SIGTERM`، تظل الحاوية حية وتخدم الطلبات أثناء النوم.[^lifecycle] قامت Kubernetes 1.34 بترقية إجراء `sleep` الأصلي إلى GA، لذا لم تعد بحاجة إلى ملف `sleep` ثنائي في صورتك — فهو يعمل حتى على الصور التي لا تحتوي على نظام تشغيل (distroless).[^kep]

أضف كتلة `lifecycle` وفترة سماح إلى مواصفات الحاوية في `k8s/deployment.yaml`:

```yaml
spec:
  template:
    spec:
      terminationGracePeriodSeconds: 45
      containers:
        - name: web
          # ... image, env, probes unchanged ...
          lifecycle:
            preStop:
              sleep:
                seconds: 15
          resources:
            requests:
              cpu: 25m
              memory: 32Mi
            limits:
              cpu: 100m
              memory: 64Mi
```

تحديد الحجم مهم. تغطي `terminationGracePeriodSeconds` عملية الإنهاء **بأكملها** — الـ `preStop` hook *بالإضافة إلى* تصريف تطبيقك. يبدأ مؤقت السماح عند بدء الإنهاء، لذا يجب أن يتجاوز وقت نوم `preStop` بالإضافة إلى أطول طلب متوقع. هنا: 15 ثانية نوم + طلب لمدة 0.5 ثانية + هامش إضافي، وهو ما يقع ضمن 45 ثانية. التوجيه الرسمي صريح: إذا استغرق الـ hook الخاص بك 55 ثانية واحتاجت الحاوية إلى 10 ثوانٍ أخرى للتوقف، فإن `terminationGracePeriodSeconds` أقل من 65 سيؤدي إلى إرسال `SIGKILL` للحاوية قبل أن تنتهي.[^lifecycle]

> في المجموعات الأقدم من 1.30 (أو مع تعطيل بوابة `PodLifecycleSleepAction`)، استبدل الإجراء الأصلي بالشكل القابل للنقل، والذي يحتاج إلى shell و `sleep` في الصورة: `preStop: { exec: { command: ["/bin/sh","-c","sleep 15"] } }`.

إليك ملف `k8s/deployment.yaml` الكامل بعد الخطوات 5-7، جاهز للنسخ والتطبيق:

```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
spec:
  replicas: 3
  minReadySeconds: 5
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 0
      maxSurge: 1
  selector:
    matchLabels:
      app: web
  template:
    metadata:
      labels:
        app: web
    spec:
      terminationGracePeriodSeconds: 45
      containers:
        - name: web
          image: zdt-demo:v1
          imagePullPolicy: IfNotPresent
          ports:
            - containerPort: 8080
          env:
            - name: READY_DELAY_MS
              value: "5000"
            - name: WORK_MS
              value: "500"
          readinessProbe:
            httpGet:
              path: /healthz
              port: 8080
            initialDelaySeconds: 0
            periodSeconds: 2
            failureThreshold: 3
          livenessProbe:
            httpGet:
              path: /healthz
              port: 8080
            initialDelaySeconds: 10
            periodSeconds: 10
            failureThreshold: 3
          lifecycle:
            preStop:
              sleep:
                seconds: 15
          resources:
            requests:
              cpu: 25m
              memory: 32Mi
            limits:
              cpu: 100m
              memory: 64Mi
```

طبق البيان النهائي:

```bash
kubectl apply -f k8s/deployment.yaml
kubectl rollout status deployment/web
```

## الخطوة 8 — النجاة من إفراغ العقد باستخدام PodDisruptionBudget

تغطي استراتيجية التحديث التدريجي من الخطوة 6 عمليات النشر التي تطلقها أنت. لكن إفراغ العقد (node drains) من أجل الترقيات أو القياس التلقائي (autoscaling) هو اضطراب منفصل *طوعي* — ويمكن لعملية إفراغ واحدة أن تحاول طرد العديد من الـ pods الخاصة بك في وقت واحد. يحدد PodDisruptionBudget (PDB) حداً أقصى لعدد الـ pods التي يمكن طردها طوعاً في المرة الواحدة، مما يجبر `kubectl drain` والقياس التلقائي للمجموعة على الانتظار بدلاً من خفض العدد عن الحد الآمن.[^pdb]

أنشئ ملف `k8s/pdb.yaml`:

```yaml
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: web
spec:
  minAvailable: 2
  selector:
    matchLabels:
      app: web
```

```bash
kubectl apply -f k8s/pdb.yaml
kubectl get pdb web   # ALLOWED DISRUPTIONS should be 1
```

مع وجود ثلاث نسخ متماثلة و `minAvailable: 2`، يمكن طرد pod واحد فقط في كل مرة أثناء عملية الإفراغ. يقيد PDB الاضطرابات **الطوعية** فقط من خلال Eviction API: لا يمكنه إيقاف الاضطرابات غير الطوعية مثل تعطل العقدة (على الرغم من أنها لا تزال تُحسب ضمن الميزانية)، وتخضع التحديثات التدريجية للـ Deployment لاستراتيجية `maxUnavailable`/`maxSurge` الخاصة بك، وليس الـ PDB.[^pdb]

## التحقق: إثبات عدم التوقف عن العمل

قم بتشغيل نفس حلقة التحميل من الخطوة 4 مرة أخرى، وقم بتشغيل التحديث في نافذة terminal ثانية:

```bash
# terminal 1
kubectl run probe --image=busybox:1.37 --restart=Never --rm -i -- \
  sh -c 'ok=0; fail=0; end=$(( $(date +%s) + 60 ));
         while [ $(date +%s) -lt $end ]; do
           if wget -q -T 2 -O /dev/null http://web/; then ok=$((ok+1));
           else fail=$((fail+1)); echo "drop at $(date +%T)"; fi
         done; echo "RESULT ok=$ok fail=$fail"'

# terminal 2
kubectl rollout restart deployment/web
```

هذه المرة يجب أن تكون النتيجة `fail=0`. تتلقى الـ pods الجديدة الزيارات فقط بعد إحمائها (الجاهزية)، ولا تنخفض السعة أبداً (`maxUnavailable: 0`/`maxSurge: 1`)، وتستمر الـ pods التي يتم إنهاؤها في الخدمة حتى يتم تحديث نقاط النهاية (نوم `preStop`) ثم تصريف العمل الجاري (إيقاف `SIGTERM` السلس). لاختبار بدقة أعلى، استبدل حلقة `busybox` المتسلسلة بأداة متزامنة مثل `fortio` أو `hey` وشاهد الرسم البياني لأكواد الاستجابة يظل 100% `200`.

أمران يستحقان المعرفة لأي تحديث:

```bash
kubectl rollout status deployment/web    # watch a roll complete
kubectl rollout undo deployment/web      # roll back to the previous ReplicaSet
```

عند الانتهاء، قم بحذف المجموعة: `kind delete cluster --name zdt`.

## الأخطاء الشائعة

**التحديث يتوقف عند "waiting for deployment to finish."** مع `maxUnavailable: 0` و `maxSurge: 1`، يجب أن يصبح الـ pod الجديد جاهزاً قبل الخطوة التالية. إذا لم ينجح فحص الجاهزية أبداً (مسار خاطئ، منفذ خاطئ، تعطل التطبيق)، فسيتوقف التحديث للأبد حسب التصميم. تحقق من `kubectl describe pod <new-pod>` و `kubectl logs <new-pod>`.

**لا تزال الطلبات تسقط عند الإغلاق.** فترة السكون في `preStop` الخاصة بك أقصر من الوقت الذي يحتاجه ingress/kube-proxy لإيقاف التوجيه. قم بزيادة فترة السكون (10-20 ثانية أمر شائع) وتأكد من أن `terminationGracePeriodSeconds` أكبر من فترة السكون بالإضافة إلى أطول طلب لديك. تأكد أيضًا من أن التطبيق يتعامل بالفعل مع `SIGTERM` — فالعملية التي تتجاهله سيتم إنهاؤها عبر `SIGKILL` في نهاية فترة السماح، مما يؤدي إلى إسقاط كل الطلبات الجارية.

**`error: lifecycle.preStop.sleep ... unknown field`.** إجراء `sleep` الأصلي متاح بشكل عام (GA) في الإصدار 1.34 ومفعل افتراضيًا منذ الإصدار 1.30 (بيتا)؛ فقط المجموعات (clusters) الأقدم من ذلك — أو تلك التي تم تعطيل بوابة الميزات (feature gate) فيها — ترفض هذا الحقل. استخدم صيغة `exec`/`sleep` الموضحة في الخطوة 7، أو قم بالترقية.[^kep]

**فحص الحيوية (Liveness probe) يعيد تشغيل الـ pods أثناء الضغط.** فحص الحيوية الموجه إلى نقطة نهاية (endpoint) بطيئة أو تعتمد بشكل كبير على تبعيات أخرى سيفشل تحت الضغط ويعيد تشغيل pods سليمة، مما يفاقم المشكلة. وجه فحص الحيوية إلى فحص محلي بسيط (مثل `/healthz` هنا)، واجعل `initialDelaySeconds` كافية، واترك فحص الجاهزية (readiness) — وليس الحيوية — يتعامل مع حالات "الانشغال المؤقت".

**`kind load` يقول إن الصورة غير موجودة.** إما أنك قمت ببناء وسم (tag) مختلف عما يشير إليه ملف الـ manifest أو قمت بالتحميل في المجموعة (cluster) الخطأ. طابق `Docker build -t zdt-demo:v1` مع `image:` في الـ manifest ومرر نفس الـ `--name` إلى `kind load`.

## الخطوات التالية ومزيد من القراءة

لديك الآن Deployment يقوم بإصدار نسخ جديدة دون إسقاط طلب واحد. من هنا:

- ضع الخدمة (Service) خلف ingress حقيقي باستخدام [Kubernetes Gateway API](https://nerdleveltech.com/migrate-ingress-to-Kubernetes-gateway-API-tutorial) وتأكد من نفس سلوك عدم التوقف (zero-downtime) من البداية للنهاية.
- قبل الإنتاج، راجع قائمة [أفضل ممارسات الأمان في Kubernetes](https://nerdleveltech.com/Kubernetes-security-best-practices-from-cluster-hardening-to-incident-response) — الحاويات التي لا تعمل بصلاحيات root (تم تنفيذ ذلك هنا بالفعل عبر `USER node`)، وحدود الموارد (resource limits)، وسياسة الشبكة (network policy).
- أضف التسليم التدريجي (progressive delivery) مثل (canary أو blue-green) فوق أساس التحديث المتداول (rolling-update) هذا بمجرد حصولك على مقاييس (metrics) لاتخاذ القرار.

[^kind]: kind v0.32.0 release notes (default node image `kindest/node:v1.36.1`, published 2026-06-02): <https://GitHub.com/Kubernetes-sigs/kind/releases/tag/v0.32.0>

[^kep]: KEP-3960 "Pod lifecycle sleep action" (status: implemented; stable: v1.34): <https://GitHub.com/Kubernetes/enhancements/blob/master/keps/sig-node/3960-pod-lifecycle-sleep-action/README.md> and the v1.33 container-lifecycle update: <https://Kubernetes.io/blog/2025/05/14/Kubernetes-v1-33-updates-to-container-lifecycle/>

[^node]: Node.js releases (24 LTS): <https://nodejs.org/en/about/previous-releases>

[^node-close]: Node.js HTTP `server.close()` and `server.closeIdleConnections()`: <https://nodejs.org/API/http.html#servercloseidleconnections>

[^probes]: Kubernetes — Configure Liveness, Readiness and Startup Probes: <https://Kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/>

[^deploy]: Kubernetes — Deployments (rolling update `maxUnavailable`/`maxSurge` defaults are 25%): <https://Kubernetes.io/docs/concepts/workloads/controllers/deployment/>

[^lifecycle]: Kubernetes — Container Lifecycle Hooks (preStop runs before SIGTERM; grace period covers hook + stop): <https://Kubernetes.io/docs/concepts/containers/container-lifecycle-hooks/>

[^race]: Kubernetes — Pod Lifecycle, Termination of Pods (endpoint removal happens alongside the grace period): <https://Kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/#pod-termination>

[^pdb]: Kubernetes — Specifying a Disruption Budget for your Application: <https://Kubernetes.io/docs/tasks/run-application/configure-pdb/>
