لماذا نضع 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.
الخطوات التالية
العنقود يعمل ويوفّق نفسه. ثلاثة اتجاهات تضيف أكبر قيمة بعد ذلك:
- قابلية الملاحظة. أرسل التتبّعات من التطبيق نفسه — Next.js مع OpenTelemetry يتكامل طبيعياً مع مقاييس Prometheus على مستوى العنقود، بينما يغطي Sentry لـ Next.js 16 تتبّع الأخطاء عبر إعادة تشغيل الـ Pods.
- التسليم التدريجي. يستبدل Argo Rollouts مورد
DeploymentبموردRolloutيدعم استراتيجيات canary و blue-green، مع ترقية آلية مشروطة بمقاييس حقيقية. - التوسّع متعدد البيئات. مولّد 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، ويكفّ سؤال "ما الذي يعمل في الإنتاج" عن كونه تخميناً لأحد.