ليه صورة Docker بتاعتك 1.2 جيجا؟ الحل في الـ Multi-Stage Build
مستوى المقال: مبتدئ — لو بتبني أول Dockerfile ليك، أو صورك بتطلع تقيلة وانت مش عارف ليه، المقال ده مكتوب ليك من الصفر.
تقدر تنزّل حجم صورة Docker من 1.2 جيجابايت لـ 80 ميجابايت من غير ما تغيّر سطر واحد في كود تطبيقك. الطريقة اسمها multi-stage build، وهنعملها خطوة بخطوة دلوقتي.
المشكلة باختصار
لمّا تبني صورة عادية، Docker بيحطّ جوّاها كل حاجة استخدمتها وقت البناء: المترجم، أدوات البناء، مكتبات التطوير، الكود الخام، وملفات الكاش. بس تطبيقك وهو شغّال مش محتاج ولا واحدة من دي. النتيجة صورة تقيلة، بطيئة في الرفع والتنزيل على كل سيرفر، وسطح هجوم أكبر لأن كل أداة زيادة معناها ثغرات محتملة زيادة.
المفهوم بمثال بسيط الأول
تخيّل إنك بتطبخ طبق لضيف. مطبخك فيه بوتاجاز وحلل وسكاكين ومكوّنات خام وزبالة. اللي بتقدّمه للضيف هو الطبق النهائي بس، مش المطبخ كله. لو رفعت المطبخ بحاله وحطيته على السفرة، هتشغّل مكان ضخم بلا أي داعي.
الصورة العادية بتعمل كده بالظبط: بتشحن "المطبخ" كله مع الطبق. الـ multi-stage build بيخلّيك تطبخ في مطبخ منفصل، وبعدين تنقل "الطبق" بس لطبق نضيف تقدّمه للضيف. المطبخ بيتساب ورا، والضيف بياخد الأكل من غير الزبالة.
وبعد كده الشرح الدقيق
صورة Docker مبنية طبقات (layers). كل أمر في الـ Dockerfile بيضيف طبقة فوق اللي قبلها. من غير multi-stage، الطبقات اللي فيها أدوات البناء واعتماديات التطوير بتفضل جوّه الصورة النهائية حتى لو التطبيق مش محتاجها وقت التشغيل. الـ multi-stage build بيسمحلك تكتب أكتر من تعليمة FROM في نفس الملف؛ المرحلة الأخيرة بس هي اللي بتتشحن كصورة نهائية، وانت بتنقل ليها اللي محتاجه فعلًا من المرحلة اللي قبلها عبر COPY --from.
الحل خطوة بخطوة
ده Dockerfile عادي بمرحلة واحدة لتطبيق Node. لاحظ إنه بيسيب كل حاجة جوّه الصورة النهائية:
FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
CMD ["node", "dist/server.js"]
ودي نفس التطبيق بـ multi-stage. مرحلة للبناء، ومرحلة نحيفة للتشغيل بتاخد الناتج بس:
# المرحلة 1: البناء (المطبخ)
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# المرحلة 2: التشغيل (الطبق النضيف)
FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]
ابنِ الاتنين وقيس الفرق بنفسك:
docker build -t myapp:single -f Dockerfile.single .
docker build -t myapp:multi -f Dockerfile.multi .
docker images | grep myapp
# myapp single 1.2GB
# myapp multi 80MB
السبب في الأرقام: القاعدة node:20 لوحدها حوالي 1.1 جيجابايت لأنها فيها سلسلة أدوات كاملة، بينما node:20-slim حوالي 220 ميجابايت. لمّا تضيف عليها اعتماديات الإنتاج بس (--omit=dev) والناتج المبني، بتنزل لحوالي 80 ميجابايت. ده انخفاض قرب 93% من غير ما تلمس منطق تطبيقك.
المقايضات وحاجات تنتبه لها
ركّز في التلاتة دول قبل ما تفرح:
- بتكسب: صورة أصغر بحوالي 93%، رفع وتنزيل أسرع على كل عقدة، وسطح هجوم أقل لأن الأدوات اللي مش موجودة مفيش فيها ثغرات.
- بتخسر: Dockerfile أطول وأعقد شوية، ولازم تعرف بالظبط أنهي ملفات تنقلها في المرحلة النهائية. لو نسيت ملف محتاجه التطبيق، هيبني تمام بس يقع وقت التشغيل.
- الافتراض: المثال مبني على تطبيق Node بيتبني لمجلد
dist. نفس الفكرة بتنطبق على Go وRust وJava (تبني الـ binary أو الـ jar في مرحلة، وتنقله لصورة نحيفة)، بس الأوامر بتختلف.
متى لا تستخدم هذه الطريقة
مش كل حالة محتاجاها:
- لو تطبيقك سكربت صغير أو صورتك أصلاً نحيفة، المكسب بسيط والتعقيد الزيادة مش مستاهل.
- لو محتاج أدوات البناء نفسها وقت التشغيل (مثلاً بتترجم إضافات native عند الإقلاع)، متشيلهاش من الصورة النهائية.
- لو لغتك مفسّرة والكود نفسه هو الناتج والاعتماديات كلها runtime، الفايدة بتقل لأن مفيش "ناتج بناء" منفصل تنقله.
الخطوة التالية
افتح Dockerfile بتاعك دلوقتي، وشغّل docker images وشوف حجم صورتك الحالي. لو فوق 300 ميجابايت، اقسمها لمرحلتين زي المثال فوق، أعد البناء، وقارن الرقم. لو الحجم منزلش، غالبًا انت بتنقل ملفات زيادة في المرحلة النهائية — راجع سطور الـ COPY --from وسيب اللي محتاجه التشغيل بس.
المصادر
- توثيق Docker الرسمي — Multi-stage builds: docs.docker.com/build/building/multi-stage
- توثيق Docker — Building best practices: docs.docker.com/build/building/best-practices
- صورة Node.js الرسمية وإصدار slim: hub.docker.com/_/node
- أمر npm ci الرسمي: docs.npmjs.com/cli/v10/commands/npm-ci