Docker المتقدم وأمن الحاويات

البناء متعدد المراحل

18 دقيقة الدرس 1 من 28

البناء متعدد المراحل

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

مشكلة الصور أحادية المرحلة

قبل عمليات البناء متعددة المراحل (Docker 17.05 عام 2017)، كانت الفرق تكتب ملف Dockerfile واحدًا إما تقبل بصورة ضخمة أو تحافظ على رقصة هشة بسكريبتين: سكريبت بناء على المضيف ثم نسخ إلى الصورة. كلا النهجين يعاني من حالات فشل معروفة على النطاق الواسع:

  • انتفاخ الصورة: ملف Go ثنائي نموذجي يُترجم إلى ~10 ميغابايت؛ لكن الصورة الأساسية golang:1.22 تبلغ ~850 ميغابايت. شحن ذلك إلى 500 عقدة في كل نشر يُهدر النطاق الترددي، ويبطئ بدء تشغيل الحاويات، ويرفع فاتورة سجل الحاويات.
  • تسريب الأسرار: تحتاج مراحل البناء أحيانًا إلى بيانات اعتماد — رموز مستودعات الحزم، مفاتيح SSH، متغير NPM_TOKEN. تنفيذ RUN rm -rf ~/.ssh لا يزيل الأسرار من الصورة؛ إذ تظل الطبقات السابقة موجودة وقابلة للاستخراج عبر docker history --no-trunc أو docker save.
  • توسّع سطح الثغرات: تحمل أدوات مثل gcc وmake وcurl وgit وما شابهها ثغرات CVE باستمرار. ستُبلّغ عنها الماسحات مثل Grype وTrivy حتى لو لم تكن متاحة في وقت التشغيل.

كيف يعمل البناء متعدد المراحل

كل تعليمة FROM في ملف Dockerfile تبدأ مرحلة بناء جديدة ومستقلة. يمكنك نسخ المخرجات من مرحلة إلى أخرى باستخدام COPY --from=<stage>. تُحفظ في الصورة النهائية المرحلةُ الأخيرة فقط؛ وتُجاهَل جميع المراحل الوسيطة عند البناء. يظل Docker Daemon يخزن كل مرحلة مؤقتًا بصورة مستقلة، لذا تبقى أوقات إعادة البناء سريعة.

Multi-Stage Build Flow Stage 0: builder FROM golang:1.22 go mod download go build -o app 850 MB total COPY --from =builder /app Stage 1: runtime FROM gcr.io/distroless/base /app binary only ENTRYPOINT ["/app"] ~12 MB total Final Image ~12 MB تُجاهَل عند البناء
البناء متعدد المراحل: تُنتج مرحلة builder الملف الثنائي؛ ولا يعبر إلى مرحلة التشغيل سوى ذلك الملف.

مثال Go جاهز للإنتاج

يعكس ملف Dockerfile التالي الأنماط المستخدمة في خدمات Go الإنتاجية على نطاق واسع. لكل تعليمة سبب متعمد.

# syntax=docker/dockerfile:1.7 # ────────────────────────────────────────────── # Stage 0 — ذاكرة التخزين المؤقت للتبعيات # ────────────────────────────────────────────── FROM golang:1.22-alpine AS deps WORKDIR /src # ننسخ ملفات الوحدات أولاً؛ يُخزّن Docker هذه الطبقة حتى تتغير go.mod/go.sum. COPY go.mod go.sum ./ RUN --mount=type=cache,target=/root/.cache/go \ go mod download -x # ────────────────────────────────────────────── # Stage 1 — البناء # ────────────────────────────────────────────── FROM deps AS builder COPY . . RUN --mount=type=cache,target=/root/.cache/go \ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ go build -ldflags="-s -w" -trimpath -o /out/api ./cmd/api # ────────────────────────────────────────────── # Stage 2 — التشغيل (scratch = لا شيء إطلاقًا) # ────────────────────────────────────────────── FROM scratch AS runtime # نستورد شهادات CA الموثوقة من Alpine (scratch لا تملكها). COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ # ننسخ الملف الثنائي المُترجَم فقط. COPY --from=builder /out/api /api # نشغّل بمعرّف مستخدم غير root؛ scratch لا تملك /etc/passwd لذا نستخدم معرفات رقمية. USER 10001:10001 ENTRYPOINT ["/api"]

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

  • CGO_ENABLED=0 ينتج ملفًا ثنائيًا مرتبطًا ارتباطًا ثابتًا بلا أي تبعيات للمكتبات المشتركة، مما يتيح استخدام FROM scratch.
  • -ldflags="-s -w" يجرد جدول الرموز ومعلومات DWARF للتصحيح، مما يقلص الملف الثنائي بنسبة 20–30 %.
  • -trimpath يزيل مسارات نظام الملفات المحلية المضمنة في الملف الثنائي، متفاديًا تسريب مسارات المضيف في تتبعات المكدس.
  • مرحلة deps مفصولة عمدًا عن مرحلة builder بحيث لا يؤدي تغيير الكود وحده إلى إعادة تنزيل الوحدات.
  • --mount=type=cache هو تحميل ذاكرة تخزين مؤقت في BuildKit — تستمر ذاكرة وحدات Go عبر عمليات البناء على المضيف نفسه دون أن تظهر في أي طبقة مُودَعة.
ترتيب ذاكرة التخزين المؤقت للطبقات أمر بالغ الأهمية. انسخ دائمًا ملفات بيان التبعيات (go.mod وpackage.json وrequirements.txt) وثبّت التبعيات قبل نسخ الكود المصدري. نظرًا لأن الكود المصدري يتغير في كل تسليم، فإن وضع تعليمة COPY . . قبل go mod download سيُبطل ذاكرة التخزين المؤقت في كل بناء ويُفسد الغرض من البناء متعدد المراحل.

مثال Node.js / TypeScript

تستفيد مشاريع اللغات المفسَّرة أيضًا من البناء متعدد المراحل: يمكنك نقل TypeScript، وتشغيل npm ci مع تبعيات التطوير، وشحن ملفات JavaScript المُترجمة فقط مع node_modules الإنتاجية.

# syntax=docker/dockerfile:1.7 FROM node:22-alpine AS base WORKDIR /app COPY package.json package-lock.json tsconfig.json ./ FROM base AS dev-deps RUN --mount=type=cache,target=/root/.npm \ npm ci FROM dev-deps AS build COPY src ./src RUN npm run build # tsc تُخرج إلى /app/dist FROM base AS prod-deps RUN --mount=type=cache,target=/root/.npm \ npm ci --omit=dev FROM node:22-alpine AS runtime WORKDIR /app ENV NODE_ENV=production COPY --from=prod-deps /app/node_modules ./node_modules COPY --from=build /app/dist ./dist USER node CMD ["node", "dist/server.js"]

استهداف مراحل محددة

تُضاعف ملفات Dockerfile متعددة المراحل كمصفوفة بناء. يمكنك بناء المراحل الوسيطة مباشرةً، وهو مفيد لتشغيل الاختبارات داخل بيئة البناء دون تلويث صورة التشغيل:

# البناء وتشغيل اختبارات الوحدة — يُوقف مسار CI إذا فشلت الاختبارات. docker build --target dev-deps --tag myapp:test . docker run --rm myapp:test npm test # بناء صورة الإنتاج النهائية فقط بعد نجاح الاختبارات. docker build --tag myapp:latest .
ذاكرة التخزين المؤقت في CI. على GitHub Actions أو GitLab CI، صدّر ذاكرة التخزين المؤقت لـ BuildKit إلى مانيفيست OCI وخزّنها في السجل. يمنحك ذلك نفس سلوك إعادة البناء السريع كما في التطوير المحلي دون الحاجة إلى إدارة وحدة تخزين مؤقتة مستقلة:

docker buildx build --cache-from type=registry,ref=ghcr.io/org/app:cache --cache-to type=registry,ref=ghcr.io/org/app:cache,mode=max .

حالات الفشل الإنتاجية

تكشف عمليات البناء متعددة المراحل عن فئة من الأخطاء تخفيها عمليات البناء أحادية المرحلة:

  • مكتبات التشغيل المفقودة. إذا لم تستخدم CGO_ENABLED=0 أو ما يعادله من الربط الثابت، فقد يعتمد ملفك الثنائي على glibc أو مكتبات مشتركة أخرى موجودة في Alpine لكنها غير موجودة في scratch أو distroless. تبدأ الحاوية وتنتهي فورًا مع ظهور not found. الحل: استخدم ldd /out/binary في مرحلة builder أو انتقل إلى قاعدة distroless-glibc.
  • بيانات المناطق الزمنية المفقودة. لا تملك FROM scratch مسار /usr/share/zoneinfo. إذا استدعى تطبيقك time.LoadLocation فسيُصدر خطأ حرجًا في وقت التشغيل. الحل: COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo.
  • تسرب وسيطات البناء إلى المرحلة النهائية. الوسيط ARG المُعلَن في المرحلة 0 لا يتوفر تلقائيًا في المرحلة 2. أعد تعريف ARG بعد كل FROM حين تحتاجه — لكن لا تُمرّر الأسرار أبدًا كـARG؛ استخدم --mount=type=secret بدلاً من ذلك.
لا تستخدم ADD لسحب أرشيفات بعيدة في عمليات البناء متعددة المراحل. التعليمة ADD https://example.com/tool.tar.gz /opt/ لا تُخزَّن بناءً على هاش المحتوى — بل تُعيد الجلب في كل بناء. استخدم RUN curl | tar -xz داخل مرحلة مع --mount=type=cache، أو الأفضل تثبيت إصدار محدد باستخدام مجزم رقمي صريح عبر تعليمة FROM مستقلة لتلك الأداة.

قياس الأثر

بعد البناء، تحقق دائمًا من التحسين باستخدام docker image inspect وماسح الثغرات:

# مقارنة الأحجام docker images --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}" # إحصاء الثغرات في كل صورة (يتطلب grype) grype myapp:single-stage --output table grype myapp:multi-stage --output table # فحص بنية الطبقات docker history myapp:multi-stage --no-trunc

على نطاق Google، يُترجَم تخفيض حجم الصورة 10 أضعاف مباشرةً إلى أوقات بدء تشغيل أسرع على Kubernetes (يُعدّ سحب الصورة في الغالب العامل الأكثر تأثيرًا في زمن جدولة الحاوية)، وتكاليف تخزين أقل في السجل، وسجل CVE أصغر بشكل قابل للقياس لفريق الأمن لديك. عمليات البناء متعددة المراحل ليست نظافة اختيارية — إنها حد أدنى مطلوب لأي صورة تُشحن إلى الإنتاج.