قلّل حجم Docker image قبل ما تزود سرعة السيرفر
مستوى القارئ: متوسط
لو الـ Docker image عندك كبيرة، هتكسب أسرع deploy وأقل وقت pull قبل ما تفكر في سيرفر أقوى.
المشكلة باختصار
الطريقة الشائعة الغلط إنك تبني التطبيق وتشغله في نفس الـ image. الطريقة دي بتفشل لما أدوات البناء، مثل compiler أو SDK أو ملفات cache، تفضل موجودة في الإنتاج. النتيجة إن الـ image تكبر، والـ deploy يبطأ، وسطح الهجوم الأمني يزيد.
الافتراض إن عندك خدمة API صغيرة أو متوسطة بتطلع image بين 700MB و1GB، وبتعمل deploy أكثر من مرة يوميًا على Kubernetes أو VM عادي. لو عندك 10 nodes وكل node لازم يعمل pull لصورة 940MB، فأنت بتنقل حوالي 9.4GB في كل rollout. لو نزلت الصورة إلى 312MB، النقل يبقى 3.1GB تقريبًا. الفرق 6.3GB في rollout واحد.
الفكرة الأساسية: افصل build عن runtime
ركز: الـ runtime مش محتاج كل اللي استخدمته أثناء البناء. Docker بتسمي ده multi-stage build. بتستخدم أكثر من FROM داخل نفس Dockerfile. مرحلة تبني، ومرحلة نهائية تنسخ الناتج فقط. Docker بتوضح في مستنداتها إنك تقدر تنسخ artifacts من مرحلة لمرحلة وتسيب الأدوات غير المطلوبة خارج الصورة النهائية.
مثال بسيط: تطبيق Go محتاج صورة فيها Go SDK وقت البناء. لكن وقت التشغيل محتاج binary فقط. لو شحنت الـ SDK في الإنتاج، أنت شحنت مئات الميجابايت بلا سبب. نفس الفكرة تنطبق على Node.js لما تبني assets ثم تشغل production dependencies فقط، أو Java لما تبني JAR في JDK ثم تشغله على JRE أخف.
قِس الأول بدل ما تخمّن
قبل أي تعديل، شوف أكبر الطبقات. استخدم docker image history. الأمر ده يعرض تاريخ بناء الصورة وحجم كل طبقة حسب Docker CLI reference.
docker image history --no-trunc my-api:current
docker images my-api:current
لو لقيت طبقة apt-get install build-essential حجمها 350MB، أو npm install داخل صورة production بيضيف 220MB، يبقى عندك هدف واضح. أفضل طريقة هنا إنك تكتب الرقم قبل التعديل وبعده، بدل ما تقول الصورة اتحسنت وخلاص.
Dockerfile عملي يقلل الحجم
ده مثال Node.js مبسط. الفكرة مش إنك تنسخه كما هو لكل مشروع، لكن تفهم مكان الفصل بالظبط.
# syntax=docker/dockerfile:1
FROM node:22-bookworm AS build
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
RUN npm run build
FROM node:22-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev
COPY --from=build /app/dist ./dist
CMD ["node", "dist/server.js"]
اللي بيحصل فعلاً: مرحلة build فيها dev dependencies وأدوات البناء. مرحلة runtime تبدأ من صورة أخف، تثبت production dependencies فقط، ثم تنسخ dist. Docker docs تذكر كمان إن cache mounts بتخلي cache الحزم مستمر بين builds، فلو layer اتبنت من جديد، مش لازم كل الحزم تنزل من الصفر.
قبل وبعد بأرقام معقولة
في سيناريو API على Node.js عنده 50K زيارة يوميًا، شوفنا أرقام تقديرية بالشكل ده: الصورة القديمة 940MB، والصورة بعد multi-stage و--omit=dev وصلت 312MB. زمن pull على اتصال فعلي 100Mbps نزل من حوالي 75 ثانية إلى 25 ثانية لكل node. ده مش وعد عام، لكنه رقم واقعي لو bottleneck عندك هو حجم الصورة والشبكة.
الـ trade-off هنا
بتكسب صورة أصغر، rollout أسرع، وعدد أقل من الأدوات داخل الإنتاج. بتخسر شوية بساطة في Dockerfile. بدل ملف مباشر من 8 أسطر، هيبقى عندك مرحلتين أو ثلاثة. كمان debugging داخل runtime image هيكون أصعب لأنها مش شايلة أدوات كثيرة. لو محتاج debug، اعمل stage منفصل باسم debug وابنيه عند الحاجة باستخدام --target debug.
خد بالك من نقطة مهمة: الصورة الأصغر مش دايمًا أسرع في التشغيل. هي أسرع في النقل والتوزيع غالبًا. أداء التطبيق نفسه يعتمد على الكود والذاكرة والـ CPU. فلا تخلط بين pull time وrequest latency.
متى لا تستخدم هذه الطريقة
لا تبدأ بها لو مشروعك لسه prototype بيتغير كل ساعة ومفيش deploy حقيقي. ولا تستخدم base image شديدة الصغر مثل scratch لو تطبيقك محتاج shell أو شهادات CA أو timezone data وأنت مش فاهم التبعيات. كمان لو عندك image صغيرة أصلًا، مثل 80MB، فالعائد ممكن لا يستحق التعقيد.
مصادر اعتمدت عليها
- Docker Docs: Multi-stage builds
- Docker Docs: Optimize cache usage in builds
- Docker Docs: docker image history
الخطوة التالية
افتح Dockerfile الحالي، شغّل docker image history --no-trunc اسم-الصورة، واكتب أكبر 3 طبقات. بعد كده افصل مرحلة build عن runtime وقارن الحجم قبل وبعد.