تطوير الويب

Docker multi-stage builds: درس تحسين الصور 2026

6 min de lecture

Multi-stage build في Docker هو التقنية الأنجع لتقليص حجم صورك بـ 5 إلى 10 مرات. بدل stage واحد يحتوي build tools + الكود المصدر + node_modules dev + ثنائي، نُقسّم إلى stages: build (ضخم) ثم runner (مصغّر).

راجع دليلنا الكامل لـ Docker.

نمط Node.js

FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci

FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./
RUN npm ci --only=production
USER node
CMD ["node", "dist/server.js"]

نمط Go

FROM golang:1.23-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /app/myapp ./cmd/myapp

FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/myapp /myapp
USER nonroot:nonroot
ENTRYPOINT ["/myapp"]

الصورة النهائية لـ Go: ~10-20 ميغا. distroless ليس فيها حتى shell، وسطح هجوم في حدّه الأدنى.

نمط Python

FROM python:3.12-slim AS builder
WORKDIR /app
RUN pip install --user --no-cache-dir poetry
COPY pyproject.toml poetry.lock ./
RUN poetry export -f requirements.txt --output requirements.txt
RUN pip install --user --no-cache-dir -r requirements.txt

FROM python:3.12-slim AS runner
ENV PATH=/root/.local/bin:$PATH
COPY --from=builder /root/.local /root/.local
COPY . /app
WORKDIR /app
USER 1000
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0"]

نمط Bun

FROM oven/bun:1 AS builder
WORKDIR /app
COPY package.json bun.lockb ./
RUN bun install --frozen-lockfile
COPY . .
RUN bun build src/index.ts --target=bun --outdir ./dist

FROM oven/bun:1-slim AS runner
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
USER bun
CMD ["bun", "run", "dist/index.js"]

نصائح

  • Order matters: انسخ package.json أولاً، lock، install — Docker يضع كل طبقة في cache
  • .dockerignore: استثنِ node_modules، .git، .env، *.log
  • distroless أو scratch لـ Go/Rust: سطح هجوم أدنى
  • Alpine لـ Node/Python: ~50-80 ميغا قاعدة، انتبه لاختلافات glibc/musl
  • Slim Debian: توازن بين الحجم والتوافق

الخطوة 1: وضع سياق build متعدد المراحل

قبل كتابة سطر Dockerfile واحد، يجب فهم المشكلة التي يحلّها multi-stage build. build كلاسيكي يحمل كل تبعيات التصريف (compilers، headers، مدراء حزم) في الصورة النهائية. النتيجة: صورة Node.js قد تزن 1.2 جيغا بينما runtime يحتاج 180 ميغا فقط. على VPS Contabo بـ 6 يورو/شهر مع 50 جيغا تخزين، هذه السمنة تفجّر فاتورة النقل وتُبطئ كل نشر.

Multi-stage build، الذي أُدخل في Docker 17.05 واستقرّ منذ ذلك، يسمح بسلسلة عدة FROM في نفس الـ Dockerfile. كل stage صورة وسيطة، وstage الأخير فقط ينتج الصورة المنشورة. نُصرّف في stage builder، ننسخ الثنائي أو bundle في stage runtime، ونرمي كل الباقي. تحصل على صورة أخفّ 5 إلى 10 مرات، بلا toolchain، بلا أسرار build منسية في طبقة.

الخطوة 2: Dockerfile multi-stage Node.js الأدنى

إليك Dockerfile كاملاً لـ API Express. الـ stage الأول يثبّت تبعيات التطوير ويصرّف TypeScript. الـ stage الثاني لا يحتوي إلا Node.js والكود المُترجم.

# syntax=docker/dockerfile:1.7
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]

ابنِ بـ docker build -t api:1.0 . ثم تحقّق من الحجم عبر docker images api. يجب أن ترى صورة بين 180 و220 ميغا. إن رأيت 900 ميغا، فالـ stage runtime ورث devDependencies: راجع --omit=dev.

الخطوة 3: multi-stage لـ Go وثنائي ثابت

Go ينتج ثنائيات ثابتة، مما يجعل multi-stage أكثر جذرية. نصرّف على golang:1.23-alpine، ثم ننسخ الثنائي إلى صورة scratch فارغة تماماً.

FROM golang:1.23-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/app ./cmd/api

FROM scratch
COPY --from=builder /out/app /app
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
USER 1000
ENTRYPOINT ["/app"]

الصورة النهائية تزن 8 إلى 15 ميغا. الراية CGO_ENABLED=0 تضمن ثنائياً بلا تبعية C، و-ldflags="-s -w" تحذف رموز التنقيب لربح 30% إضافية. نسخ شهادات CA إلزامي إن قام تطبيقك بمكالمات HTTPS صادرة.

الخطوة 4: cache الطبقات وBuildKit

فعّل BuildKit (افتراضياً منذ Docker 23) للاستفادة من cache متوازٍ. أضِف --mount=type=cache على الأوامر المكلفة:

RUN --mount=type=cache,target=/root/.npm npm ci --prefer-offline

cache npm يبقى بين الـ builds، ما يُنزل npm ci من 90 ثانية إلى 6 ثوانٍ على اتصال ADSL متوسط. رتّب COPY من الأقل تقلباً إلى الأكثر: package.json قبل الكود المصدر، وإلا كل تعديل لملف .ts يبطل كامل الـ cache.

الخطوة 5: الأمان ومستخدم غير root

صورة تشتغل كـ root مخاطرة غير مقبولة في الإنتاج. multi-stage يُسهّل التحوّل إلى مستخدم مخصّص. على Alpine، أنشئ مستخدم app في stage runtime:

RUN addgroup -S app && adduser -S app -G app
USER app

تحقّق بـ docker run --rm api:1.0 whoami. المخرَج يجب أن يكون app لا root. مجموعاً مع --read-only و--cap-drop=ALL في runtime، تحجب 90% من تقنيات تصعيد الحاوية.

الخطوة 6: فحص الصورة بـ Trivy

قبل كل دفع إلى registry، افحص الصورة. Trivy v0.58+ يكشف CVE في حزم OS والتبعيات التطبيقية:

trivy image --severity HIGH,CRITICAL --exit-code 1 api:1.0

الراية --exit-code 1 تُفشل CI إن اكتُشفت CVE حرجة. على GitLab CI أو GitHub Actions، أضف هذا الـ job كبوّابة قبل النشر. صورة multi-stage Alpine حديثة تعرض عادة 0 إلى 2 CVE HIGH، مقابل 30+ لصورة Debian كلاسيكية بلا multi-stage.

الخطوة 7: الدفع إلى registry خاص

لمشروع مستضاف عند OVH أو Scaleway، استخدم الـ registry المدمج أو Harbor مستضاف ذاتياً. صادق ثم وسم الصورة:

docker tag api:1.0 registry.example.com/team/api:1.0
docker push registry.example.com/team/api:1.0

الصورة تزن 200 ميغا بدل 1.2 جيغا: الدفع يستغرق 12 ثانية بدل 3 دقائق على ألياف 100 ميغابت. اضرب في 50 نشراً يومياً، توفّر لفريقك ساعتين ونصف من زمن الدورة اليومي.

الخطوة 8: مزالق شائعة تتجنّبها

ثلاثة أخطاء تتكرر في التدقيقات. أولاً، نسيان .dockerignore: بدونه، سياق الـ build يرسل كل node_modules إلى daemon Docker، مُبطئاً كل build بـ 30 ثانية. ثانياً، نسخ أسرار عبر COPY .env في stage builder: حتى لو لم تحتوها الصورة النهائية، الـ stage الوسيط يبقى متاحاً بـ docker history. استخدم --secret من BuildKit. ثالثاً، تثبيت وسوم :latest: ثبّت دائماً node:22.11.0-alpine3.20، وإلا يصبح build قابل لإعادة الإنتاج مستحيلاً.

الخطوة 9: تحسين صورة Python multi-stage

Python يطرح تحدياً خاصاً: wheels المُسبقة (numpy، psycopg2، pillow) تحمل مكتبات C يجب توفّرها في runtime. الفخ الكلاسيكي صرّف بـ python:3.13 ثم انسخ إلى python:3.13-slim واكتشف أن libpq ناقصة. الحلّ النظيف:

FROM python:3.13-slim AS builder
RUN apt-get update && apt-get install -y --no-install-recommends build-essential libpq-dev && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt

FROM python:3.13-slim AS runtime
RUN apt-get update && apt-get install -y --no-install-recommends libpq5 && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /wheels /wheels
RUN pip install --no-cache /wheels/*
COPY . .
RUN useradd -m -u 1000 app
USER app
CMD ["gunicorn", "app:app", "--bind", "0.0.0.0:8000"]

الـ stage builder ينتج wheels في /wheels، runtime يثبّت هذه wheels دون إعادة تصريف. الصورة النهائية تزن 180 إلى 250 ميغا حسب التبعيات، مقابل 1.1 جيغا في single-stage. إن تباطأ pip install خلف اتصال بطيء، أضف مرآة PyPI إقليمية عبر pip config set global.index-url.

الخطوة 10: multi-stage وملفات frontend الثابتة

لـ SPA React أو Vue يُخدَم بـ Nginx، multi-stage يسمح بسلسلة build Node + خادم ثابت دون حمل Node في الإنتاج.

FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginx:1.27-alpine AS runtime
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

الصورة النهائية تنزل إلى 45 ميغا. ملف nginx.conf يجب أن يضيف رؤوس الأمان (CSP، X-Frame-Options، X-Content-Type-Options) ويفعّل ضغط gzip للأصول. تحقّق بـ curl -I http://localhost/ أن Content-Encoding: gzip حاضر فعلاً.

الخطوة 11: قياس ومقارنة الأحجام

تبنّ انضباط قياس منهجياً. عند كل PR يلمس Dockerfile، قارن حجم الصورة قبلاً وبعداً:

docker images --format "{{.Repository}}:{{.Tag}} {{.Size}}" | grep api
docker history api:1.0 --format "{{.Size}} {{.CreatedBy}}"

المخرَج يسرد كل طبقة وحجمها. طبقة بـ 400 ميغا تحتوي npm install في stage runtime إشارة حمراء: multi-stage مكسور. أداة مكمّلة: dive تسمح باستكشاف الطبقات تفاعلياً وتحديد الملفات المُهدَرة. عبر dive يمكن في 30 ثانية تحديد 180 ميغا من ملفات .git منسية.

الخطوة 12: الإدماج في CI GitHub Actions

إليك workflow GitHub Actions يبني الصورة multi-stage مع cache موزّع ويدفعها إلى GHCR:

name: build
on: [push]
jobs:
  docker:
    runs-on: ubuntu-24.04
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: GITHUB_ACTOR
          password: GITHUB_TOKEN
      - uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ghcr.io/REPO:SHA
          cache-from: type=gha
          cache-to: type=gha,mode=max

cache type=gha يخزّن الطبقات في GitHub Actions Cache (10 جيغا مجانية لكل مستودع). البناء الأول: 4 دقائق. البناءات اللاحقة دون تغيير التبعيات: 35 ثانية. على مشروع بـ 30 commit يومياً، الربح التراكمي حوالي ساعتين من CI يومياً.

الخطوة 13: runtime distroless والتحصين النهائي

لإكمال المنهج، استبدل صورة runtime Alpine بصورة distroless من Google. distroless لا تحتوي shell، ولا مدير حزم، ولا busybox: فقط runtime الضروري لتطبيقك. مهاجم يحصل على RCE لا يستطيع حتى تنفيذ sh أو curl.

FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --production

FROM gcr.io/distroless/nodejs22-debian12 AS runtime
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./
USER nonroot
CMD ["dist/server.js"]

الصورة تزن 160 ميغا، بلا أي ثنائي مساعد. لـ Go، المكافئ gcr.io/distroless/static-debian12 يزن 2 ميغا. تحقّق من غياب shell: docker run --rm --entrypoint sh api:1.0 يجب أن يفشل بـ «executable file not found». هذه إشارة صحة جيدة.

الخطوة 14: قائمة فحص مراجعة Dockerfile

قبل دمج PR يُضيف أو يُعدّل Dockerfile، تحقّق من كل نقطة. وسم صورة مثبَّت على إصدار محدّد وdigest إن أمكن. stagein على الأقل: builder وruntime. --omit=dev أو مكافئ في stage runtime. مستخدم غير root مفعّل عبر USER. ملف .dockerignore يستثني node_modules، .git، .env، coverage، tests. لا أسرار بالنص الصريح (استخدم --secret أو متغيّرات بيئة محقونة في runtime). Healthcheck معرَّف عبر HEALTHCHECK CMD ليعرف المنسّق إعادة تشغيل حاوية معطوبة. الحجم النهائي موثَّق في README. فحص Trivy ناجح بلا CVE HIGH. هذا الانضباط يجعل كل Dockerfile قابل للتدقيق ولإعادة الإنتاج، ما يطلبه عملاء أوروبيون وعملاء كبار في الخليج والمغرب العربي يفرضون ISO 27001 أو PCI-DSS.

الخطوة 15: أتمتة تحديث الصور القاعدية

صور Alpine وDebian تنشر تصحيحات أمنية كل أسبوع. اضبط Renovate أو Dependabot لفتح PR تلقائياً عند نشر إصدار ثانوي جديد لـ node:22-alpine أو postgres:17-alpine. اجمع مع build تلقائي ليلي في CI لإعادة بناء الصورة بآخر تصحيح دون لمس الكود التطبيقي. هذه الممارسة تُنزل نافذة التعرّض لـ CVE من أسابيع إلى 24 ساعة، وتظل متوافقة مع خط GitOps منشور على Kubernetes أو Docker Swarm.

للتعمق

مقالات ذات صلة

Sponsoriser ce contenu

Cet emplacement est à vous

Position premium en fin d'article — c'est l'instant où les lecteurs sont le plus engagés. Réservez cet espace pour votre marque, votre formation ou votre offre.

Recevoir nos tarifs
Publicité