البناء متعدد المراحل
البناء متعدد المراحل
كل صورة إنتاجية تُشحن هي في الوقت ذاته سطح هجوم. الحاوية التي تحمل سلسلة أدوات المترجم الكاملة وأطر الاختبار وذاكرات التخزين المؤقت للبناء إلى جانب الملف الثنائي للتطبيق ليست مُهدِرة للموارد فحسب — بل هي مصدر خطر. فالمهاجم الذي يتمكن من تنفيذ أوامر داخل تلك الحاوية يرث كل أداة تركتها خلفك. تحل عمليات البناء متعددة المراحل هذه المشكلة بفصل البيئة التي تُترجم فيها البرنامج عن البيئة التي تُشغّله، بحيث تحتوي الصورة النهائية فقط على المخرجات التي تنتمي إلى الإنتاج.
مشكلة الصور أحادية المرحلة
قبل عمليات البناء متعددة المراحل (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 يخزن كل مرحلة مؤقتًا بصورة مستقلة، لذا تبقى أوقات إعادة البناء سريعة.
مثال Go جاهز للإنتاج
يعكس ملف Dockerfile التالي الأنماط المستخدمة في خدمات Go الإنتاجية على نطاق واسع. لكل تعليمة سبب متعمد.
قرارات أساسية يجب فهمها والدفاع عنها في مراجعة الكود:
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 الإنتاجية.
استهداف مراحل محددة
تُضاعف ملفات Dockerfile متعددة المراحل كمصفوفة بناء. يمكنك بناء المراحل الوسيطة مباشرةً، وهو مفيد لتشغيل الاختبارات داخل بيئة البناء دون تلويث صورة التشغيل:
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 وماسح الثغرات:
على نطاق Google، يُترجَم تخفيض حجم الصورة 10 أضعاف مباشرةً إلى أوقات بدء تشغيل أسرع على Kubernetes (يُعدّ سحب الصورة في الغالب العامل الأكثر تأثيرًا في زمن جدولة الحاوية)، وتكاليف تخزين أقل في السجل، وسجل CVE أصغر بشكل قابل للقياس لفريق الأمن لديك. عمليات البناء متعددة المراحل ليست نظافة اختيارية — إنها حد أدنى مطلوب لأي صورة تُشحن إلى الإنتاج.