الوحدة 2 · Images جاهزة للإنتاج
الـ Multi-Stage Builds وملف .dockerignore
20 دقراءة 4 داكتشف الخطأ
الدرس في لمحة
قسّم الـ Dockerfile الخاص بـ notes-api إلى مراحل للبناء والاعتماديات والتشغيل، حتى لا تحتوي الـ image المشحونة إلا على الكود المترجَم واعتماديات الإنتاج، وأبعِد الملفات غير الضرورية عن الـ build context بملف .dockerignore.
- 1Dockerfile واحد، ومراحل عدّة
- 2استخدام المراحل الوسيطة عن قصد
- 3ملف .dockerignore: تحكّم فيما تراه أداة البناء
لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.
الـ image من القسم الأول تشحن إلى الإنتاج TypeScript، وتعريفات الأنواع، ومشغّل الاختبارات، ومجلد src/، وكل اعتمادية تطوير أخرى. لا شيء من هذا يعمل في الإنتاج. وكله سطح هجوم إضافي، ووقت سحب إضافي، وضجيج في تقارير الفحص. الحل أن تبني في مكان وتشحن من مكان آخر.
Dockerfile واحد، ومراحل عدّة
كل FROM تبدأ مرحلة (stage) جديدة بنظام ملفات جديد. سمِّ المراحل بـ AS، وانسخ الملفات بينها بـ COPY --from. والمرحلة الأخيرة وحدها هي التي تصبح الـ image التي تضع عليها الـ tag.
# syntax=docker/dockerfile:1
# 1. Compile TypeScript (needs dev dependencies)
FROM node:24-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
# 2. Production dependencies only
FROM node:24-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev
# 3. What actually ships
FROM node:24-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY package.json ./
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/server.js"]مرحلة runtime تحصل على ثلاثة أشياء بالضبط: package.json، وnode_modules الخاص بالإنتاج، وdist/ المترجَم. أما TypeScript وsrc/ وذاكرة تنزيلات npm فتبقى في مراحل لا يشحنها أحد.
يحسب BuildKit مخطّط الاعتماديات بين المراحل بنفسه. لا تعتمد build وdeps إحداهما على الأخرى، فتعملان بالتوازي، وأي مرحلة لا يحتاج إليها الهدف تُتخطّى كليًا.
في notes-api قلّص هذا حجم الـ image بنحو الثلث، وفحص الثغرات الذي ستشغّله لاحقًا في هذا القسم انتقل من عشرات النتائج في أدوات التطوير إلى بضع نتائج فقط في بيئة التشغيل. الدرس التالي يقلّص الـ base image نفسها.
لماذا ننسخ package.json إلى مرحلة التشغيل أصلًا؟ لأن package.json في notes-api يحتوي على "type": "module"، الذي يطلب من Node أن يعامل ملفات .js على أنها ES modules. إن حذفته، فسيفشل الـ container عند البدء برسالة SyntaxError: Cannot use import statement outside a module، وهي تبدو خللًا في البناء لكنها في الحقيقة ملف مفقود. إذا كان مشروعك يستخدم CommonJS فيمكنك تخطّيه، لكنه لا يكلّف شيئًا ويُبقي البيانات الوصفية لـ npm متاحة.
قد ترى نمطًا مختلفًا: مرحلة واحدة تشغّل npm ci، ثم تبني، ثم تشغّل npm prune --omit=dev قبل نسخ node_modules إلى الخارج. هذا يعمل، ويوفّر عملية تثبيت واحدة. أنا أفضّل مرحلة deps المنفصلة لأنها تُخزَّن في الـ cache مستقلةً: ترقية أداة خاصة بالتطوير مثل الـ linter لا تُبطل layer اعتماديات الإنتاج، فيُعاد استخدام تلك الـ layer عبر عمليات بناء كثيرة، وتتغيّر image التشغيل بوتيرة أقل.
استخدام المراحل الوسيطة عن قصد
المراحل أهداف مفيدة أيضًا. شغّل مجموعة الاختبارات في البيئة التي تحتوي على اعتماديات التطوير، دون لمس الـ image المشحونة:
docker build --target build -t notes-api:test .
docker run --rm notes-api:test npm testالخيار --target build يتوقّف عند مرحلة build. يستطيع الـ CI تشغيل هذا قبل بناء image التشغيل، ويتشارك البناءان الـ cache نفسه.
ملف .dockerignore: تحكّم فيما تراه أداة البناء
حين تشغّل docker build .، يُرسل المجلد كاملًا إلى أداة البناء بوصفه الـ context، مطروحًا منه كل ما يطابق .dockerignore. من دونه، ينتقل إلى أداة البناء node_modules المحلي لديك (مئات الميغابايتات، وربما فيه وحدات أصلية (native modules) مترجَمة لـ macOS)، ومجلد .git، وأي ملف .env فيه بيانات اعتماد حقيقية. وأي COPY . . يضعها كلها في layer.
node_modules
dist
coverage
.git
.env
.env.*
*.log
Dockerfile
compose*.yamlثلاثة مكاسب دفعة واحدة: يهبط الـ context من مئات الميغابايتات إلى بضع مئات من الكيلوبايتات، ولا تعود تعديلات الملفات المتجاهَلة تُبطل الـ cache، ولا يمكن نسخ الأسرار الموجودة في مجلد عملك إلى image بالخطأ.
الصيغة تشبه .gitignore: نمط واحد في كل سطر، والرمزان * و** للمطابقة، و! لإعادة تضمين شيء ما. أبقِه في جذر الـ build context. وإن احتجت رغم ذلك إلى ملف واحد من الملفات المتجاهَلة، فأعِد تضمينه تحت القاعدة العامة، مثلًا .env.* يليه !.env.example، حتى يمكن نسخ ملف القالب بينما لا يمكن نسخ الملفات الحقيقية.
عند الشك، افحص ما استلمته أداة البناء فعلًا: الأمر docker build --progress=plain . يطبع حجم الـ context المنقول في أسطره الأولى. إذا كان هذا الرقم بالميغابايتات لـ API صغير، فهناك شيء ناقص في .dockerignore.
في الدرس التالي ستستبدل الـ base image بأخرى أصغر، وتكفّ عن تشغيل التطبيق بالمستخدم root.
أهم النقاط
- الـ multi-stage build يستخدم عدّة مراحل
FROM؛ ولا يتحوّل إلى image إلا المرحلة الأخيرة (أو المرحلة التي تحدّدها بـ--target). - التعليمة
COPY --from=<stage>تسحب ملفات محدّدة من مرحلة سابقة، فلا تصل المترجِمات واعتماديات التطوير إلى الإنتاج أبدًا. - ثبّت اعتماديات الإنتاج بـ
npm ci --omit=devفي مرحلة خاصة بها، وانسخnode_modulesوdistفقط إلى مرحلة التشغيل. - ملف
.dockerignoreيُبقيnode_modulesو.gitوملفات.envومخرجات البناء خارج الـ context، فيصير البناء أسرع وأكثر أمانًا.