الكتابات/tutorial/2026/07
Tutorial28 يوليو 2026·35 دقيقة

نشر Next.js على Kubernetes باستخدام Helm 4 و Argo CD GitOps

دليل إنتاجي متكامل لتشغيل Next.js 16 على Kubernetes: صورة Docker مصغّرة بوضع standalone، ومخطط Helm 4 مع server-side apply، وفحوصات الصحة والتوسّع التلقائي، ثم تسليم مستمر بقيادة Argo CD 3.3 و GitOps.

لماذا نضع Next.js على Kubernetes أصلاً؟

معظم تطبيقات Next.js لا تحتاج إلى Kubernetes. منصة Vercel، أو خادم VPS واحد مع Kamal، أو Cloudflare Workers ستخدمك بشكل أفضل وبتكلفة أقل. لكن Kubernetes يستحق تعقيده في حالات محدّدة: أن تكون تشغّل خدمات أخرى على العنقود وتريد قصة نشر واحدة، أو أن تحتاج توسّعاً أفقياً مرتبطاً بضغط الطلبات الحقيقي، أو أن تعمل تحت قواعد سيادة بيانات تمنع المنصات المُدارة، أو أن تحتاج عدة بيئات متطابقة بشكل يمكن إثباته.

إن كان هذا وضعك، فإن المسار الساذج — تنفيذ kubectl apply -f على مجلد من ملفات YAML مكتوبة يدوياً — ينهار خلال أسابيع. تتباعد الملفات بين البيئات، ولا أحد يعرف أي commit أنتج الـ Pod العامل حالياً، وتتحوّل عمليات التراجع إلى تنقيب أثري.

هذا الدليل يبني النسخة المنضبطة بدلاً من ذلك. في نهايته سيكون لديك:

  • صورة Next.js 16 مبنية من output: "standalone"، أصغر عادةً بنسبة 30–60% من البناء الساذج
  • مخطط Helm 4 قابل لإعادة الاستخدام مع values-staging.yaml و values-production.yaml
  • فحوصات liveness و readiness و startup موصولة بنقطة صحة حقيقية
  • مورد HorizontalPodAutoscaler و PodDisruptionBudget حتى لا يسبّب التوسّع وتفريغ العُقد أي انقطاع
  • Ingress مع TLS عبر cert-manager
  • Argo CD 3.3 يوفّق العنقود باستمرار مع Git، مع مزامنة آلية وشفاء ذاتي وتراجع بأمر واحد

النمط ينطبق على أي تطبيق Node.js داخل حاوية. Next.js هو المثال الملموس فقط.

المتطلبات المسبقة

يُفترض أن تكون مرتاحاً مع Docker وأن تكون قد نشرت تطبيق Next.js في الإنتاج من قبل. ستحتاج:

  • Node.js 20+ وتطبيق Next.js 16 (مثال App Router أدناه يفترض الإصدار 16.x)
  • Docker 27+ لبناء الصور
  • عنقود Kubernetes بإصدار 1.33 أو أحدث. للعمل المحلي، يكفي kind أو العنقود المدمج في Docker Desktop. للإنتاج، أي عرض مُدار (DOKS أو EKS أو GKE أو AKS) مناسب.
  • kubectl 1.34+ مُهيّأ على عنقودك
  • Helm 4.2+ — هذا الدليل يستخدم ميزات Helm 4 غير الموجودة في Helm 3
  • سجل حاويات يمكنك الدفع إليه (GHCR أو Docker Hub أو سجل مزوّدك السحابي)
  • مستودع Git يستطيع عنقودك قراءته — سيصبح هذا مصدر الحقيقة

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

kubectl version --output=yaml | grep gitVersion
helm version --short
docker --version

يجب أن ترى إصدار Helm بصيغة v4.2.x أو أحدث. إن ظهر لك v3.x، فرقِّ قبل المتابعة — عدة خطوات أدناه تعتمد على سلوك Helm 4.

غيّر Helm 4 اسم الخيار --force إلى --force-replace، ولم يعد يقبل ملفاً تنفيذياً عشوائياً في --post-renderer. إن كنت تُرحِّل إعداد Helm 3 قائماً، فراجع سكربتات CI بحثاً عن الاثنين قبل الترقية.

ما الذي ستبنيه

مستودع Git (مصدر الحقيقة)
        │
        │  Argo CD يراقب هذا المسار
        ▼
charts/nextjs-app/           ← مخطط Helm
  ├── Chart.yaml
  ├── values.yaml            ← القيم الافتراضية
  ├── values-staging.yaml
  ├── values-production.yaml
  └── templates/
      ├── deployment.yaml
      ├── service.yaml
      ├── ingress.yaml
      ├── hpa.yaml
      ├── pdb.yaml
      └── configmap.yaml
        │
        │  Argo CD يُصيّر ويطبّق
        ▼
عنقود Kubernetes
  Ingress → Service → Deployment (3+ Pods مع توسّع تلقائي)

دفعة إلى main تُحدِّث وسم الصورة في Git. يلاحظ Argo CD ذلك خلال ثوانٍ، ويُصيّر المخطط، ويطبّق الفرق، وينتظر حتى يصبح الطرح سليماً. لا يحتفظ أي مُشغِّل CI ببيانات اعتماد العنقود إطلاقاً.

الخطوة 1: بناء صورة Next.js بوضع standalone

أكبر خطأ في وضع Next.js داخل حاوية هو شحن شجرة node_modules كاملة. يستطيع Next.js تتبّع الملفات التي يستوردها الخادم فعلياً ونسخ تلك الملفات وحدها إلى .next/standalone.

فعّل ذلك في next.config.ts:

import type { NextConfig } from "next";
 
const nextConfig: NextConfig = {
  // يتتبع واردات الخادم ويُخرج مجلد .next/standalone مكتفياً ذاتياً
  output: "standalone",
 
  // Kubernetes ينهي TLS عند Ingress، لذا يتكلّم التطبيق نفسه HTTP.
  poweredByHeader: false,
 
  experimental: {
    // حزمة خادم أصغر بعدم تضمين خرائط المصدر في الإنتاج
    serverSourceMaps: false,
  },
};
 
export default nextConfig;

الآن ملف Dockerfile. ثلاث مراحل تُبقي التخزين المؤقت للطبقات نظيفاً والصورة النهائية صغيرة:

# syntax=docker/dockerfile:1.7
 
# ---- المرحلة 1: الاعتماديات ----------------------------------------------
FROM node:20-alpine AS deps
WORKDIR /app
 
# ننسخ ملف القفل أولاً حتى تبقى هذه الطبقة مخزّنة حتى تتغير الاعتماديات
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
 
# ---- المرحلة 2: البناء ---------------------------------------------------
FROM node:20-alpine AS builder
WORKDIR /app
 
COPY --from=deps /app/node_modules ./node_modules
COPY . .
 
# المتغيرات العامة وقت البناء يجب أن تكون هنا — فهي تُدمج داخل الحزمة
ARG NEXT_PUBLIC_SITE_URL
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
ENV NEXT_TELEMETRY_DISABLED=1
 
RUN corepack enable && pnpm build
 
# ---- المرحلة 3: التشغيل --------------------------------------------------
FROM node:20-alpine AS runner
WORKDIR /app
 
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
 
# لا تشغّل كـ root أبداً. Kubernetes سيفرض ذلك أيضاً، لكن الدفاع بالعمق مطلوب.
RUN addgroup --system --gid 1001 nodejs \
 && adduser  --system --uid 1001 nextjs
 
# مخرجات standalone تحتوي أصلاً على node_modules مصغّر و server.js
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
 
USER nextjs
EXPOSE 3000
 
CMD ["node", "server.js"]

تفصيلتان تُوقعان الكثيرين:

المتغيّر HOSTNAME=0.0.0.0 إلزامي. خادم standalone يرتبط بـ localhost افتراضياً، ما يعني أن kubelet لا يستطيع الوصول إليه وأن كل فحص readiness سيفشل. هذا هو السبب الأول لبقاء Pod الخاص بـ Next.js في حالة CrashLoopBackOff دون سجلات مفيدة.

الأصول الثابتة تُنسخ منفصلة. يستبعد .next/standalone عمداً كلاً من .next/static و public، لأنهما يُقدَّمان من CDN على Vercel. في عنقود مُستضاف ذاتياً عليك نسخهما بنفسك.

ابنِ وادفع:

export REGISTRY=ghcr.io/your-org
export IMAGE=$REGISTRY/nextjs-app
export TAG=$(git rev-parse --short HEAD)
 
docker build \
  --build-arg NEXT_PUBLIC_SITE_URL=https://app.example.com \
  -t $IMAGE:$TAG -t $IMAGE:latest .
 
docker push $IMAGE:$TAG
docker push $IMAGE:latest

وسم الصور بمعرّف Git SHA، لا بـ latest وحده. يعتمد GitOps على كون وسم الصورة معرّفاً ثابتاً وقابلاً للتتبع — وهذا ما يتيح لك الإجابة عن سؤال "أي commit يعمل في الإنتاج الآن" دون تخمين.

الخطوة 2: أضف نقطة فحص صحة حقيقية

يحتاج Kubernetes إلى التمييز بين ثلاث حالات: هل بدأت العملية، وهل هي حيّة، وهل هي جاهزة لاستقبال حركة المرور. مسار يعيد 200 دون شرط لا يجيب عن أي منها بصدق.

أنشئ app/api/health/route.ts:

import { NextResponse } from "next/server";
import { db } from "@/lib/db";
 
// لا تخزّن فحص الصحة مؤقتاً أبداً
export const dynamic = "force-dynamic";
export const revalidate = 0;
 
export async function GET() {
  const checks: Record<string, "ok" | "fail"> = {};
 
  // liveness يخص العملية. readiness يخص الاعتماديات.
  // نُبلّغ عن الاثنين وندع إعداد الفحص يقرّر ما يهم.
  try {
    await db.execute("SELECT 1");
    checks.database = "ok";
  } catch {
    checks.database = "fail";
  }
 
  const healthy = Object.values(checks).every((v) => v === "ok");
 
  return NextResponse.json(
    {
      status: healthy ? "healthy" : "degraded",
      checks,
      uptime: Math.floor(process.uptime()),
      version: process.env.APP_VERSION ?? "unknown",
    },
    { status: healthy ? 200 : 503 },
  );
}

ثم مسار liveness بسيط عمداً في app/api/live/route.ts:

import { NextResponse } from "next/server";
 
export const dynamic = "force-dynamic";
 
// liveness يجيب عن سؤال واحد فقط: هل ما زالت حلقة الأحداث تدور؟
// يجب ألّا يفحص قاعدة البيانات — انقطاع قاعدة البيانات ينبغي أن يُفرّغ
// حركة المرور، لا أن يُطلق حلقة إعادة تشغيل لا نهائية على كل الـ Pods دفعة واحدة.
export async function GET() {
  return NextResponse.json({ status: "alive" });
}

هذا الفصل أهم مما يبدو. إذا كان فحص liveness يفحص قاعدة البيانات وتوقفت قاعدة البيانات تسعين ثانية، فسيعيد Kubernetes تشغيل كل Pod في وقت واحد، ويبقى تطبيقك معطّلاً عند عودة قاعدة البيانات — لكن الآن بذاكرات تخزين باردة وموجة عارمة من إعادة الاتصالات.

الخطوة 3: تجهيز مخطط Helm

helm create charts/nextjs-app
rm -rf charts/nextjs-app/templates/tests charts/nextjs-app/templates/serviceaccount.yaml

استبدل charts/nextjs-app/Chart.yaml:

apiVersion: v2
name: nextjs-app
description: Production Next.js deployment
type: application
 
# version = إصدار المخطط، يُرفع عند تغيّر القوالب
version: 1.0.0
# appVersion = إصدار التطبيق الافتراضي، يُستبدل عادةً لكل بيئة
appVersion: "1.0.0"
 
kubeVersion: ">=1.33.0-0"
 
maintainers:
  - name: Platform Team
    email: platform@example.com

والقيم الافتراضية في values.yaml:

replicaCount: 2
 
image:
  repository: ghcr.io/your-org/nextjs-app
  pullPolicy: IfNotPresent
  tag: "" # يعود إلى .Chart.AppVersion عند تركه فارغاً
 
nameOverride: ""
fullnameOverride: ""
 
service:
  type: ClusterIP
  port: 80
  targetPort: 3000
 
ingress:
  enabled: true
  className: nginx
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
    nginx.ingress.kubernetes.io/proxy-body-size: 10m
  hosts:
    - host: app.example.com
      paths:
        - path: /
          pathType: Prefix
  tls:
    - secretName: nextjs-app-tls
      hosts:
        - app.example.com
 
resources:
  requests:
    cpu: 100m
    memory: 256Mi
  limits:
    # لا حد لوحدة المعالجة عمداً — راجع الملاحظة في الخطوة 5
    memory: 512Mi
 
autoscaling:
  enabled: true
  minReplicas: 2
  maxReplicas: 10
  targetCPUUtilizationPercentage: 70
  targetMemoryUtilizationPercentage: 80
 
podDisruptionBudget:
  enabled: true
  minAvailable: 1
 
# إعدادات تشغيل غير حسّاسة
env:
  NODE_ENV: production
  NEXT_TELEMETRY_DISABLED: "1"
 
# أسماء Secrets موجودة مسبقاً لإسقاطها كمتغيرات بيئة
envFromSecrets: []
 
probes:
  startup:
    failureThreshold: 30
    periodSeconds: 2
  liveness:
    path: /api/live
    initialDelaySeconds: 0
    periodSeconds: 10
    failureThreshold: 3
  readiness:
    path: /api/health
    initialDelaySeconds: 0
    periodSeconds: 5
    failureThreshold: 3
 
nodeSelector: {}
tolerations: []
affinity: {}

الخطوة 4: كتابة قالب Deployment

هذا قلب المخطط. الملف charts/nextjs-app/templates/deployment.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "nextjs-app.fullname" . }}
  labels:
    {{- include "nextjs-app.labels" . | nindent 4 }}
spec:
  {{- if not .Values.autoscaling.enabled }}
  replicas: {{ .Values.replicaCount }}
  {{- end }}
 
  revisionHistoryLimit: 5
 
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0    # لا تنزل أبداً تحت السعة المطلوبة
 
  selector:
    matchLabels:
      {{- include "nextjs-app.selectorLabels" . | nindent 6 }}
 
  template:
    metadata:
      annotations:
        # أعد تشغيل الـ Pods تلقائياً عند تغيّر الإعدادات غير السرّية
        checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
      labels:
        {{- include "nextjs-app.selectorLabels" . | nindent 8 }}
    spec:
      # امنح الطلبات الجارية وقتاً لتكتمل قبل موت العملية
      terminationGracePeriodSeconds: 30
 
      securityContext:
        runAsNonRoot: true
        runAsUser: 1001
        fsGroup: 1001
        seccompProfile:
          type: RuntimeDefault
 
      containers:
        - name: {{ .Chart.Name }}
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
          imagePullPolicy: {{ .Values.image.pullPolicy }}
 
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: ["ALL"]
 
          ports:
            - name: http
              containerPort: {{ .Values.service.targetPort }}
              protocol: TCP
 
          envFrom:
            - configMapRef:
                name: {{ include "nextjs-app.fullname" . }}-config
            {{- range .Values.envFromSecrets }}
            - secretRef:
                name: {{ . }}
            {{- end }}
 
          env:
            - name: APP_VERSION
              value: {{ .Values.image.tag | default .Chart.AppVersion | quote }}
            - name: POD_NAME
              valueFrom:
                fieldRef:
                  fieldPath: metadata.name
 
          # فحص startup يمتص البدايات الباردة البطيئة ليبقى liveness صارماً
          startupProbe:
            httpGet:
              path: {{ .Values.probes.liveness.path }}
              port: http
            failureThreshold: {{ .Values.probes.startup.failureThreshold }}
            periodSeconds: {{ .Values.probes.startup.periodSeconds }}
 
          livenessProbe:
            httpGet:
              path: {{ .Values.probes.liveness.path }}
              port: http
            periodSeconds: {{ .Values.probes.liveness.periodSeconds }}
            failureThreshold: {{ .Values.probes.liveness.failureThreshold }}
 
          readinessProbe:
            httpGet:
              path: {{ .Values.probes.readiness.path }}
              port: http
            periodSeconds: {{ .Values.probes.readiness.periodSeconds }}
            failureThreshold: {{ .Values.probes.readiness.failureThreshold }}
 
          lifecycle:
            preStop:
              exec:
                # دع متحكم Ingress يلاحظ إزالة نقطة النهاية
                # قبل أن تبدأ عملية Node بالإغلاق.
                command: ["sh", "-c", "sleep 5"]
 
          resources:
            {{- toYaml .Values.resources | nindent 12 }}
 
          # readOnlyRootFilesystem يتطلب نقاط تحميل قابلة للكتابة
          # لكل ما يكتبه Next.js أثناء التشغيل
          volumeMounts:
            - name: cache
              mountPath: /app/.next/cache
            - name: tmp
              mountPath: /tmp
 
      volumes:
        - name: cache
          emptyDir: {}
        - name: tmp
          emptyDir: {}
 
      {{- with .Values.nodeSelector }}
      nodeSelector:
        {{- toYaml . | nindent 8 }}
      {{- end }}
      {{- with .Values.tolerations }}
      tolerations:
        {{- toYaml . | nindent 8 }}
      {{- end }}
 
      # وزّع الـ Pods عبر العُقد حتى لا يُسقط عطل عقدة واحدة التطبيق
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: kubernetes.io/hostname
          whenUnsatisfiable: ScheduleAnyway
          labelSelector:
            matchLabels:
              {{- include "nextjs-app.selectorLabels" . | nindent 14 }}

عدة خيارات هنا تستحق تبريراً صريحاً.

القيمة maxUnavailable: 0 تعني أن الطرح يضيف Pod جديداً قبل إزالة القديم. تدفع سعة إضافية بسيطة أثناء النشر مقابل إصدارات بلا انقطاع فعلاً.

خطاف preStop والنوم موجود لأن إنهاء الـ Pod وإزالة نقطة النهاية يحدثان بالتوازي لا بالتسلسل. بدونه، يستطيع متحكم Ingress توجيه طلب إلى Pod بدأ الإغلاق أصلاً. خمس ثوانٍ من عدم فعل شيء هي الحل القياسي.

الخيار readOnlyRootFilesystem: true خطوة تحصين حقيقية، لكن Next.js يكتب في .next/cache من أجل ISR وتحسين الصور. نقاط التحميل من نوع emptyDir تمنحه مكاناً للكتابة دون فتح بقية نظام الملفات.

التعليق التوضيحي checksum/config يفرض إعادة تشغيل متدرّجة كلما تغيّر الـ ConfigMap. بدونه، يغيّر تعديل الإعدادات في Git محتوى الـ ConfigMap لكن تبقى الـ Pods العاملة محمّلة بالقيم القديمة — وهو نمط فشل محيّر فعلاً.

الخطوة 5: الخدمة والتوسّع التلقائي وميزانية التعطيل

الملف templates/service.yaml:

apiVersion: v1
kind: Service
metadata:
  name: {{ include "nextjs-app.fullname" . }}
  labels:
    {{- include "nextjs-app.labels" . | nindent 4 }}
spec:
  type: {{ .Values.service.type }}
  ports:
    - port: {{ .Values.service.port }}
      targetPort: http
      protocol: TCP
      name: http
  selector:
    {{- include "nextjs-app.selectorLabels" . | nindent 4 }}

الملف templates/hpa.yaml:

{{- if .Values.autoscaling.enabled }}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: {{ include "nextjs-app.fullname" . }}
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: {{ include "nextjs-app.fullname" . }}
  minReplicas: {{ .Values.autoscaling.minReplicas }}
  maxReplicas: {{ .Values.autoscaling.maxReplicas }}
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
    - type: Resource
      resource:
        name: memory
        target:
          type: Utilization
          averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
  behavior:
    scaleUp:
      # استجب بسرعة لموجات حركة المرور
      stabilizationWindowSeconds: 30
      policies:
        - type: Percent
          value: 100
          periodSeconds: 30
    scaleDown:
      # قلّص ببطء لتجنّب التذبذب مع حركة المرور المتقطّعة
      stabilizationWindowSeconds: 300
      policies:
        - type: Pods
          value: 1
          periodSeconds: 60
{{- end }}

الملف templates/pdb.yaml:

{{- if .Values.podDisruptionBudget.enabled }}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: {{ include "nextjs-app.fullname" . }}
spec:
  minAvailable: {{ .Values.podDisruptionBudget.minAvailable }}
  selector:
    matchLabels:
      {{- include "nextjs-app.selectorLabels" . | nindent 6 }}
{{- end }}

من السهل تجاهل ميزانية التعطيل، ومن المكلف إغفالها. بدونها، قد يُخلي تفريغ عقدة أثناء ترقية العنقود كل الـ Pods دفعة واحدة — فيتعطّل تطبيقك خلال صيانة مجدولة ظننتها آمنة.

لاحظ أن values.yaml يضع حداً للذاكرة لكن بلا حدّ لوحدة المعالجة. حدود المعالج في Kubernetes تُفرض عبر خنق CFS، وهو ما يضيف قفزات كمون إلى حلقات أحداث Node.js حتى حين تكون سعة العقدة فائضة. اضبط طلبات المعالج ليضع المجدول الـ Pods بشكل معقول، واترك حدود المعالج مُطفأة إلا إن كنت تشغّل أحمالاً غير موثوقة فعلاً.

الخطوة 6: الإعدادات والأسرار

يتولّى templates/configmap.yaml القيم غير الحسّاسة:

apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ include "nextjs-app.fullname" . }}-config
  labels:
    {{- include "nextjs-app.labels" . | nindent 4 }}
data:
  {{- range $key, $value := .Values.env }}
  {{ $key }}: {{ $value | quote }}
  {{- end }}

الأسرار لا مكان لها في المخطط أبداً. أشِر بدلاً من ذلك إلى Secrets موجودة مسبقاً في العنقود:

kubectl create secret generic nextjs-app-secrets \
  --namespace production \
  --from-literal=DATABASE_URL='postgresql://...' \
  --from-literal=AUTH_SECRET='...'

ثم في values-production.yaml:

envFromSecrets:
  - nextjs-app-secrets

في إعداد GitOps حقيقي، أنشئ تلك الأسرار عبر External Secrets Operator (بالسحب من Vault أو AWS Secrets Manager أو Doppler) أو عبر Sealed Secrets (قيم مشفّرة يمكن إيداعها بأمان). كلاهما يحافظ على خاصية "كل شيء في Git" دون وضع بيانات اعتماد صريحة هناك.

الآن طبقات البيئات. الملف values-staging.yaml:

replicaCount: 1
 
image:
  tag: main-latest
 
ingress:
  hosts:
    - host: staging.example.com
      paths:
        - path: /
          pathType: Prefix
  tls:
    - secretName: nextjs-app-staging-tls
      hosts:
        - staging.example.com
 
autoscaling:
  enabled: false
 
podDisruptionBudget:
  enabled: false
 
resources:
  requests:
    cpu: 50m
    memory: 128Mi
  limits:
    memory: 256Mi
 
env:
  NODE_ENV: production
  NEXT_PUBLIC_ENVIRONMENT: staging
 
envFromSecrets:
  - nextjs-app-secrets-staging

الملف values-production.yaml:

replicaCount: 3
 
autoscaling:
  enabled: true
  minReplicas: 3
  maxReplicas: 20
  targetCPUUtilizationPercentage: 65
 
podDisruptionBudget:
  enabled: true
  minAvailable: 2
 
resources:
  requests:
    cpu: 250m
    memory: 512Mi
  limits:
    memory: 1Gi
 
env:
  NODE_ENV: production
  NEXT_PUBLIC_ENVIRONMENT: production
 
envFromSecrets:
  - nextjs-app-secrets

الخطوة 7: التحقّق والتثبيت باستخدام Helm 4

لا تطبّق أبداً مخططاً لم تُصيّره. افحص أولاً، ثم تفقّد المخرجات:

helm lint charts/nextjs-app -f charts/nextjs-app/values-production.yaml
 
helm template nextjs-app charts/nextjs-app \
  -f charts/nextjs-app/values-production.yaml \
  --set image.tag=$TAG \
  | less

اقرأ تلك المخرجات بجدّية. معظم حوادث Kubernetes تكون مرئية في الملف المُصيَّر قبل أن تصل إلى العنقود إطلاقاً.

ثبّت مع server-side apply، وهي استراتيجية التوفيق الافتراضية في Helm 4 للإصدارات الجديدة:

kubectl create namespace production --dry-run=client -o yaml | kubectl apply -f -
 
helm install nextjs-app charts/nextjs-app \
  --namespace production \
  -f charts/nextjs-app/values-production.yaml \
  --set image.tag=$TAG \
  --atomic \
  --timeout 5m

الخيار --atomic هو ما يحوّل النشر الفاشل إلى حدث بلا أثر: إذا لم يصبح الإصدار سليماً خلال المهلة، يتراجع Helm تلقائياً بدل أن يتركك في نشر نصفي.

راقب إقلاعه:

kubectl -n production rollout status deployment/nextjs-app
kubectl -n production get pods -l app.kubernetes.io/name=nextjs-app

إن علق أحد الـ Pods، فالأوامر المفيدة بالترتيب هي:

kubectl -n production describe pod <pod-name>          # الأحداث وإخفاقات الفحص
kubectl -n production logs <pod-name> --previous       # سجلات الحاوية المنهارة
kubectl -n production get events --sort-by=.lastTimestamp | tail -20

وللترقية اللاحقة، يتيح لك Helm 4 ترحيل إصدار قائم إلى server-side apply صراحةً:

helm upgrade nextjs-app charts/nextjs-app \
  --namespace production \
  -f charts/nextjs-app/values-production.yaml \
  --set image.tag=$NEW_TAG \
  --server-side \
  --atomic

يتيح Helm 4 أيضاً أن يحتوي ملف قيم واحد على عدة مستندات YAML مفصولة بـ ---. هذا مفيد فعلاً لتقسيم ملف values-production.yaml الكبير إلى أقسام معلّقة دون التلاعب بستة خيارات -f في CI.

الخطوة 8: سلّم زمام الأمور إلى Argo CD

كل ما سبق ما زال يتطلّب إنساناً يشغّل helm upgrade — ما يعني أن CI يحتاج بيانات اعتماد العنقود، وأن العنقود قد ينحرف بصمت عن Git. يعكس Argo CD ذلك: يعمل داخل العنقود، ويسحب من Git، ويوفّق باستمرار.

ثبّت Argo CD 3.3:

kubectl create namespace argocd
 
kubectl apply -n argocd \
  -f https://raw.githubusercontent.com/argoproj/argo-cd/v3.3.11/manifests/install.yaml
 
kubectl -n argocd rollout status deployment/argocd-server

احصل على كلمة مرور المدير الأولية وسجّل الدخول:

kubectl -n argocd get secret argocd-initial-admin-secret \
  -o jsonpath="{.data.password}" | base64 -d; echo
 
kubectl -n argocd port-forward svc/argocd-server 8080:443
# ثم افتح https://localhost:8080

الآن عرّف تطبيق الإنتاج تصريحياً. أودِع هذا كملف argocd/production.yaml:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: nextjs-app-production
  namespace: argocd
  finalizers:
    # يضمن أن حذف الـ Application يزيل موارده أيضاً
    - resources-finalizer.argocd.argoproj.io
spec:
  project: default
 
  source:
    repoURL: https://github.com/your-org/your-repo.git
    targetRevision: main
    path: charts/nextjs-app
    helm:
      valueFiles:
        - values.yaml
        - values-production.yaml
 
  destination:
    server: https://kubernetes.default.svc
    namespace: production
 
  syncPolicy:
    automated:
      # احذف الموارد التي أُزيلت من Git
      prune: true
      # أعِد تعديلات kubectl اليدوية إلى حالة Git
      selfHeal: true
    syncOptions:
      - CreateNamespace=true
      - ServerSideApply=true
      - PruneLast=true
    retry:
      limit: 5
      backoff:
        duration: 10s
        factor: 2
        maxDuration: 3m
 
  revisionHistoryLimit: 10

طبّقه مرة واحدة، ومن ثم يصبح Git هو الواجهة الوحيدة:

kubectl apply -f argocd/production.yaml
argocd app get nextjs-app-production

الإعدادان اللذان يغيّران طريقة عمل فريقك هما prune و selfHeal. مع selfHeal: true، من يشغّل kubectl edit deployment في الإنتاج سيجد تغييره مُلغى بصمت خلال ثوانٍ. وهذا هو المقصود — العنقود إسقاط لـ Git، لا مكان تُحرّره.

فعّل selfHeal في بيئة الاختبار أولاً وعِش معه أسبوعاً. الفرق المعتادة على الإصلاح السريع مباشرةً على العنقود تجده مُربكاً في البداية فعلاً، واكتشاف ذلك أثناء حادثة إنتاج هو التوقيت الخاطئ.

الخطوة 9: أغلق الحلقة من CI

الآن لدى خط أنابيب CI مهمتان فقط: بناء صورة، وكتابة الوسم الجديد في Git. لا يلمس العنقود إطلاقاً.

الملف .github/workflows/deploy.yml:

name: Build and Deploy
 
on:
  push:
    branches: [main]
 
permissions:
  contents: write
  packages: write
 
jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      tag: ${{ steps.meta.outputs.tag }}
    steps:
      - uses: actions/checkout@v4
 
      - id: meta
        run: echo "tag=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
 
      - uses: docker/setup-buildx-action@v3
 
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
 
      - uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ steps.meta.outputs.tag }}
          build-args: |
            NEXT_PUBLIC_SITE_URL=https://app.example.com
          cache-from: type=gha
          cache-to: type=gha,mode=max
 
  promote:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
 
      # عملية "النشر" كلها هي commit. وArgo CD يتولّى الباقي.
      - name: Bump image tag in the production values file
        run: |
          sed -i "s|^  tag: .*|  tag: \"${{ needs.build.outputs.tag }}\"|" \
            charts/nextjs-app/values-production.yaml
 
      - name: Commit and push
        run: |
          git config user.name  "ci-bot"
          git config user.email "ci-bot@example.com"
          git add charts/nextjs-app/values-production.yaml
          git commit -m "chore(deploy): production -> ${{ needs.build.outputs.tag }}"
          git push

أضف مفتاح image.tag إلى values-production.yaml ليستهدفه أمر sed:

image:
  tag: "placeholder"

التراجع الآن عملية Git:

# تراجع عن commit النشر — يزامن Argo CD الصورة السابقة خلال ثوانٍ
git revert <deploy-commit-sha> && git push
 
# أو، في حالة طارئة، تراجع مباشرةً في Argo CD
argocd app history nextjs-app-production
argocd app rollback nextjs-app-production <history-id>

انتبه إلى أن التراجع عبر Argo CD يترك العنقود غير متزامن مع Git عمداً. اتبعه بـ git revert، وإلّا سيسحب selfHeal النسخة المعطوبة مجدداً.

اختبار عملية النشر

تحقّق من كل طبقة على حدة بدل تحميل الموقع مرة واحدة فقط.

الصورة تعمل بشكل مستقل:

docker run --rm -p 3000:3000 -e HOSTNAME=0.0.0.0 $IMAGE:$TAG
curl -f http://localhost:3000/api/live

الفحوصات تتصرف بشكل صحيح عند الفشل. قلّص قاعدة بياناتك وتأكّد أن readiness يفشل بينما يبقى liveness ناجحاً — يجب أن تخرج الـ Pods من موازن التحميل دون إعادة تشغيل:

kubectl -n production get pods -w
# يجب أن تنتقل READY من 1/1 إلى 0/1، لكن RESTARTS يجب أن تبقى صفراً

الطروحات بلا انقطاع فعلاً. شغّل مولّد حِمل على Ingress أثناء إطلاق نشر:

kubectl run loadtest --rm -it --image=williamyeh/hey -- \
  -z 120s -c 20 https://app.example.com/api/live

أي استجابة خارج نطاق 2xx تشير إلى خطاف preStop مفقود، أو فحص readiness يعيد 200 مبكراً جداً، أو قيمة maxUnavailable أعلى من صفر.

التوسّع التلقائي يستجيب:

kubectl -n production get hpa nextjs-app -w

تحت حِمل مستمر، يجب أن ترتفع النسخ خلال دقيقة تقريباً وتعود للانخفاض عبر نافذة الاستقرار ذات الخمس دقائق.

الشفاء الذاتي يعمل. أثبت أن حلقة GitOps حقيقية:

kubectl -n production scale deployment nextjs-app --replicas=1
sleep 20
kubectl -n production get deployment nextjs-app   # يعود إلى العدد المعلَن في Git

استكشاف الأخطاء

Pods في حالة CrashLoopBackOff بسجلات فارغة. يكاد يكون السبب دائماً ربط HOSTNAME. تأكّد عبر kubectl exec داخل Pod عامل وتحقّق أن الخادم يستمع على 0.0.0.0:3000 لا على 127.0.0.1:3000.

أخطاء 404 على CSS و JavaScript بينما يُحمَّل HTML بشكل سليم. لم تنسخ .next/static إلى مرحلة التشغيل، أو نسختها إلى مسار خاطئ. يجب أن تصل إلى /app/.next/static.

الخطأ EROFS: read-only file system في السجلات. يحاول Next.js الكتابة في مكان لم تُحمِّله. أضف emptyDir لذلك المسار، أو اضبط outputFileTracingRoot ليكتب داخل /app/.next/cache.

صفحات ISR تتصرف بشكل متضارب بين الـ Pods. لكل Pod ذاكرة emptyDir خاصة، لذا فإن إعادة التحقّق على Pod واحد غير مرئية للبقية. اضبط معالج ذاكرة مشتركاً مدعوماً بـ Redis عبر cacheHandler في next.config.ts، أو اقبل التخزين لكل Pod وقصّر نوافذ إعادة التحقق.

Argo CD عالق دائماً في حالة OutOfSync بلا فرق ظاهر. السبب عادةً خطاف قبول مُعدِّل (حاقن sidecar لشبكة خدمات مثلاً) يعدّل الموارد بعد التطبيق. أضف ServerSideApply=true — وهو موجود أصلاً في الملف أعلاه — أو علّم الحقل المحدّد بـ argocd.argoproj.io/compare-options: IgnoreExtraneous.

فشل ترقية Helm بتعارض ملكية حقل. هذا هو server-side apply يعمل كما ينبغي: شيء آخر عدّل حقلاً يديره Helm. حدّد المالك الآخر عبر kubectl get deployment nextjs-app -o yaml | grep -A20 managedFields، ثم إمّا أزل المتحكم المتعارض أو مرّر --force-conflicts=true إن كان يجب أن ينتصر Helm فعلاً.

انخفاض حركة المرور أثناء ترقيات العُقد. ميزانية PodDisruptionBudget مفقودة أو قيمة minAvailable منخفضة جداً مقارنة بـ replicaCount.

الخطوات التالية

العنقود يعمل ويوفّق نفسه. ثلاثة اتجاهات تضيف أكبر قيمة بعد ذلك:

  1. قابلية الملاحظة. أرسل التتبّعات من التطبيق نفسه — Next.js مع OpenTelemetry يتكامل طبيعياً مع مقاييس Prometheus على مستوى العنقود، بينما يغطي Sentry لـ Next.js 16 تتبّع الأخطاء عبر إعادة تشغيل الـ Pods.
  2. التسليم التدريجي. يستبدل Argo Rollouts مورد Deployment بمورد Rollout يدعم استراتيجيات canary و blue-green، مع ترقية آلية مشروطة بمقاييس حقيقية.
  3. التوسّع متعدد البيئات. مولّد ApplicationSet في Argo CD يُنشئ تطبيقاً لكل بيئة أو لكل فرع معاينة من قالب واحد، وهكذا تحصل على بيئات معاينة مؤقتة على بنيتك التحتية الخاصة.

إن بدا لك هذا الإعداد أثقل مما يستحقه تطبيقك — فهو على الأرجح كذلك. يوفّر Kamal 2 على VPS نشراً بلا انقطاع بجزء يسير من الأجزاء المتحركة، ويمنحك Coolify تجربة منصة مستضافة ذاتياً. اذهب إلى Kubernetes حين يكون لديك عدة خدمات، أو متطلبات توسّع حقيقية، أو فريق منصّة يتولّاه.

الخاتمة

القطع التي تجعل هذا الإعداد جاهزاً للإنتاج ليست بدائيات Kubernetes نفسها — بل التفاصيل المحيطة بها. صورة standalone مرتبطة بـ 0.0.0.0. فحوصا liveness و readiness يجيبان عن سؤالين مختلفين، فيؤدي انقطاع قاعدة البيانات إلى تفريغ حركة المرور بدل إعادة تشغيل كل Pod. القيمة maxUnavailable: 0 وخطاف preStop معاً يجعلان الطروحات سلسة فعلاً. ميزانية تعطيل حتى لا تكون الصيانة المجدولة انقطاعاً. طلبات معالج بلا حدود معالج، لإبقاء حلقة الأحداث بعيدة عن خنق CFS.

وفوق ذلك، يمنحك Helm 4 مخططاً واحداً بقيم لكل بيئة وترقيات ذرّية تتراجع من تلقاء نفسها، بينما يجعل Argo CD من Git الطريقة الوحيدة لتغيير العنقود. تصبح عمليات النشر commits، ويصبح التراجع reverts، ويكفّ سؤال "ما الذي يعمل في الإنتاج" عن كونه تخميناً لأحد.