انتقال للمقال
وقت القراءة: ≈ 30 دقيقة (بمعدل فنجان واحد من القهوة 😊)

إنشاء Dockerfile لتطبيق Node.js

السلام عليكم ورحمة الله وبركاته


المقدمة

في المقالة السابقة تعرفنا على الـ Dockerfile
وتعلمنا كيف نبني Image خاصة بنا ليستضيف موقع باستخدام Nginx
وكيفية إنشاء Container من هذه الـ Image

في هذه المقالة سنقوم أيضًا بإنشاء Dockerfile ولكن هذه المرة ليستضيف تطبيق Node.js
لأن هناك مميزات كثيرة للـ Dockerfile لم تتطرق لها في المثال السابق
لكن مع مثال الـ Node.js سنتمكن من شرح هذه الميزات
بكل بساطة سننشئ تطبيق Node.js بسيط باستخدام Express.js
ثم نكتب Dockerfile لبناء Image تقوم بتجهيز بيئة لتشغيل هذا التطبيق داخل Container
وسنتعرف على أوامر جديدة في الـ Dockerfile مثل WORKDIR و RUN و EXPOSE و CMD

إنشاء تطبيق Node.js بسيط

أولًا لننشئ مجلد جديد لمشروعنا

> mkdir nodejs-app
> cd nodejs-app

الآن لننشئ ملف package.json لمشروعنا:

{
  "name": "nodejs-docker-app",
  "version": "1.0.0",
  "description": "Simple Node.js app for Docker example",
  "main": "app.js",
  "scripts": {
    "start": "node app.js"
  },
  "dependencies": {
    "express": "^5.2.1"
  }
}

الآن لننشئ ملف app.js الذي يحتوي على كود التطبيق:

const express = require("express");
const app = express();
const port = process.env.PORT || 3000;

app.get("/", (req, res) => {
  res.send(
    `
        <h1>Hello from Node.js Docker Container!</h1>
        <p>Visit <a href="https://eltabarani.com">eltabarani.com</a> for more tutorials.</p>
        `,
  );
});

app.listen(port, () => {
  console.log(`Server is running on port ${port}`);
});

كما ترى لدينا تطبيق Node.js بسيط جدًا
يقوم بإنشاء Server يعمل على الـ port المحدد في الـ Environment Variable أو على 3000 في حالة عدم وجود port محدد في الـ Environment Variable

ولدينا emdpoint واحدة فقط وهى خاصة بالصفحة الرئيسية /
وتعرض لنا رسالة ترحيبية مع رابط لمدونة جميلة تدعى eltabarani.com

الآن لنتأكد من أن التطبيق يعمل على جهازنا قبل وضعه في Docker:

> npm install
> npm start

إذا فتحنا المتصفح وذهبنا إلى http://localhost:3000 سنرى الرسالة التي كتبناها
بعد التأكد من أن التطبيق يعمل، يمكننا إيقاف الـ Server بالضغط على Ctrl + C

كتابة الـ Dockerfile

الآن سنكتب Dockerfile لبناء Image تحتوي على تطبيقنا
نحتاج إلى التفكير في الخطوات المطلوبة:

  1. اختيار Base Image لأمر الـ FROM والذي سيكون node:25-alpine
  2. نسخ ملفات المشروع إلى داخل الـ Image
  3. تثبيت المكاتب باستخدام npm install
  4. تشغيل الـ Server عند إنشاء الـ Container بتنفيذ npm start

الـ base image الذي سنستخدمه هو node:25-alpine
وهو Image رسمي من Docker Hub يحتوي على Node.js
ولاحظ أن الـ tag هو 25-alpine مما يعني أننا نستخدم الإصدار 25 من Node.js مع توزيعة Alpine Linux الخفيفة

FROM node:25-alpine

COPY . .

RUN npm install

CMD [ "npm", "start" ]

لدينا هنا أربعة أوامر في الـ Dockerfile
الأمر الأول FROM يحدد الـ base image التي سنبني عليها وهنا وقع اختيارنا على node:25-alpine
الأمر الثاني COPY . . يقوم بنسخ جميع ملفات المشروع من جهازنا إلى داخل الـ Image
تذكر أن النقطة التي على اليسار تشير إلى مجلد المشروع على جهازنا
والنقطة الثانية التي على اليمين تشير إلى مجلد العمل داخل الـ Image والتي تكون داخل الـ Container عند إنشائه
الأمر الثالث RUN npm install يقوم بتثبيت جميع المكاتب التي يحتاجها التطبيق

يمكننا استخدام الأمر RUN لتنفيذ أي أوامر نريدها أثناء بناء الـ Image
وأخيرًا الأمر CMD [ "npm", "start" ] يحدد الأمر الذي سيتم تنفيذه عند إنشاء الـ Container من هذه الـ Image
هل تذكرون حين قلنا أن كل Container يقوم بتنفيذ أمر معين عند إنشائه ؟
هنا مع CMD نحدد هذا الأمر ليكون npm start لتشغيل الـ Server الخاص بتطبيقنا
بتالي أي شخص سيقوم بعمل docker container run من هذه الـ Image سيتم تنفيذ npm start تلقائيًا لتشغيل التطبيق
يمكننا استخدام CMD مرة واحدة فقط في الـ Dockerfile

بناء الـ Image وتشغيل الـ Container

الآن بعد أن كتبنا الـ Dockerfile يمكننا بناء الـ Image الخاصة بنا كما فعلنا في المقالة السابقة

> docker image build -t nodejs-docker-app .
...
 => [1/3] FROM docker.io/library/node:25-alpine@sha256:260b19691dc84090cf0c7773a38d2bce92e1aa86a184868eaed85675053e8c8e                                                                            27.4s
 => => resolve docker.io/library/node:25-alpine@sha256:260b19691dc84090cf0c7773a38d2bce92e1aa86a184868eaed85675053e8c8e                                                                             0.0s
 => => sha256:624e71309d5a3f30285b9cfbe21866b8dc5d551a7d0b41c68095ab59c6fddd2c 1.26MB / 1.26MB                                                                                                      0.8s
 => => sha256:1b50ba8464c5ee2e26c7828d4b77693ab26d0fa0d79d5c76af61d681c3baaccf 54.26MB / 54.26MB                                                                                                   26.0s
 => => sha256:e267896605e615adbf4405187601f4d6228ac1b78fa06321e7673c2367b00a28 448B / 448B                                                                                                          1.0s
 => => extracting sha256:1b50ba8464c5ee2e26c7828d4b77693ab26d0fa0d79d5c76af61d681c3baaccf                                                                                                           1.3s
 => => extracting sha256:624e71309d5a3f30285b9cfbe21866b8dc5d551a7d0b41c68095ab59c6fddd2c                                                                                                           0.0s
 => => extracting sha256:e267896605e615adbf4405187601f4d6228ac1b78fa06321e7673c2367b00a28                                                                                                           0.0s
 => [2/3] COPY . .                                                                                                                                                                                  0.2s
 => [3/3] RUN npm install                                                                                                                                                                           1.6s
...

تذكر أن -t لتسمية الـ Image و . تعني أن الـ Dockerfile موجود في المجلد الحالي

لا داعي لكي أعرض ناتج عملية البناء بالكامل
لذا سأعرض فقط الجزء المتعلق بالخطوات فقط

لاحظ أن عملية البناء تكونت من ثلاث خطوات رئيسية كما في الـ Dockerfile
وهى تحميل الـ node:25-alpine كـ base image ثم نسخ الملفات ثم تثبيت المكاتب باستخدام npm install

بعد بناء الـ Image لنرى جميع الـ Image الموجودة على جهازنا:

> docker image list
REPOSITORY          TAG       IMAGE ID       CREATED         SIZE
nodejs-docker-app   latest    fac1753843f1   3 minutes ago   246MB
static-site-img     latest    0c34822e06c6   2 days ago      92.2MB
nginx               alpine    b0f7830b6bfa   13 days ago     92.2MB
ubuntu              latest    cd1dba651b30   2 weeks ago     117MB
nginx               latest    c881927c4077   2 weeks ago     237MB
alpine              latest    865b95f46d98   5 weeks ago     13MB

لدينا الـ Image التي أنشأناها من المقالة السابقة static-site-img
والآن لدينا Image جديدة تدعى nodejs-docker-app التي أنشأناها للتو
لاحظ أن الـ tag الخاصة بها هي latest بشكل افتراضي لأننا لم نحدد tag أثناء البناء
وكما قلنا سابقى الـ latest لا تعني أنها أحدث إصدار من البرنامج بل هي مجرد tag افتراضي
حتى لو استخدمنا nodejs:18 أو nodejs:20 كـ base image في الـ Dockerfile ستظل الـ tag الخاصة بالـ Image التي أنشأناها هي latest
كان من الممكن أن نحدد الـ tag أثناء البناء باستخدام -t nodejs-docker-app:v1.0 مثلاً
وستكون الـ tag الخاصة بالـ Image هي v1.0 بدلاً من latest ولاحظ أن v1.0 لا تعني شيء سوى مسمى للتوضيح لا أكثر

الآن لننشئ Container من هذه الـ Image ونشغل التطبيق بداخلها:

> docker container run -d -p 8080:3000 --rm --name nodejs-container nodejs-docker-app
d223be4866b4156a309caa5cbf649c9459a868fedf1992fa205b0f7157435498

لا أظن أنني بحاجة لشرح كل جزء من هذا الأمر
أظنك الآن وصلت لمرحلة أنك تفهم كل جزء منه

لكن أود فقط أن أوضح الجزء الخاص بـ -p 8080:3000
كما لاحظت في الكود الخاص بالملف app.js
أننا قمنا بتحديد الـ port الذي سيعمل عليه الـ Server ليكون 3000 بشكل افتراضي
وهذا يعني أن الـ Docker سيقوم بتشغيل الـ Server على الـ port رقم 3000 داخل الـ Container
لذا قلنا في الأمر -p 8080:3000 أن نقوم بربط الـ port رقم 8080 على جهازنا بالـ port رقم 3000 داخل الـ Container
وبالتالي عندما نذهب إلى http://localhost:8080 على جهازنا سنرى أن التطبيق يعمل بنجاح من داخل الـ Container


لنلقي نظرة على Container الذي أنشأناه للتو لأنني أريد أن أوضح بعض النقاط المهمة:

> docker container list --all --no-trunc
CONTAINER ID                                                       IMAGE               COMMAND                                          CREATED         STATUS          PORTS                    NAMES
d223be4866b4156a309caa5cbf649c9459a868fedf1992fa205b0f7157435498   nodejs-docker-app   "docker-entrypoint.sh npm start"                 3 minutes ago   Up 3 minutes    0.0.0.0:8080->3000/tcp   nodejs-container

أولًا الـ --no-trunc تجعلنا نرى قيم كل الأعمدة كاملة بدون اختصار
لاحظ في عمود الـ COMMAND أن الأمر الذي يتم تنفيذه داخل الـ Container هو docker-entrypoint.sh npm start
هل تذكرون أننا في الـ Dockerfile كتبنا CMD [ "npm", "start" ] ؟
هذا الأمر هو الذي يتم تنفيذه عند إنشاء الـ Container من هذه الـ Image كما تلاحظون هنا
أما الجزء docker-entrypoint.sh فهو سكريبت ستجده داخل بعض الـ base images الرسمية مثل node و python و nginx وغيرها
وظيفته هي تجهيز البيئة داخل الـ Container قبل تنفيذ الأمر الذي حددناه في الـ CMD

الدخول داخل الـ Container

الآن بعد أن شغلنا التطبيق داخل الـ Container
قد نرغب في الدخول داخل هذا الـ Container لرؤية الملفات أو التحقق من أن كل شيء يعمل بشكل صحيح
يمكننا فعل ذلك باستخدام الأمر docker container exec مع -it للدخول إلى الـ Container

> docker container exec -it nodejs-container sh
/ #

هنا قمنا بتنفيذ sh داخل الـ Container لكي ندخل إلى الـ Shell داخل الـ Container
الآن لنرى الملفات الموجودة داخل الـ Container:

/ # ls
Dockerfile         dev                lib                node_modules       package.json       run                sys                var
app.js             etc                media              opt                proc               sbin               tmp
bin                home               mnt                package-lock.json  root               srv                usr

كما ترى جميع الملفات التي نسخناها إلى داخل الـ Image موجودة هنا داخل الـ Container
لكن لاحظ أن هناك ملفات ومجلدات أخرى كثيرة هنا والتى تخص توزيعة Alpine Linux التي يستخدمها الـ base image الخاص بنا
ونحن قمنا بنسخ كل الملفات داخل الـ Container باستخدام COPY . . في الـ Dockerfile
بالتالي كل الملفات نسخت في الـ container root filesystem
وهذه تعتبر مشكلة يجب حلها وتجنبها

علينا على الأقل وضع ملفات داخل مجلد معين داخل الـ Container وليس في الـ / الذي يعد جذر النظام
وليكن هذا المجلد /app مثلاً، وبالطبع يمكننا فعل ذلك باستخدام أمر WORKDIR في الـ Dockerfile
لذا دعنا نقوم بإيقاف الـ Container ثم نقوم بتعديل الـ Dockerfile

أولًا لنخرج من داخل الـ Container بكتابة exit أو بالضغط على Ctrl + D

/ # exit

الآن لنقم بإيقاف الـ Container

> docker container stop nodejs-container
nodejs-container

وبالطبع بعد أن قمنا قمنا بإيقاف الـ Container سيتم حذفه تلقائيًا لأننا استخدمنا --rm عند إنشائه

الآن لنعدل الـ Dockerfile ليضع ملفات التطبيق في مجلد /app داخل الـ Container باستخدام WORKDIR

وضع ملفات التطبيق في مجلد معين داخل الـ Container

الـ Docker يوفر لنا أمر يدعى WORKDIR في الـ Dockerfile
لكي نستطيع تحديد المجلد الذي نريد أن تكون فيه ملفات التطبيق داخل الـ Container
إذا لم يكن هذا المجلد موجودًا فسيقوم الـ Docker بإنشائه تلقائيًا أثناء بناء الـ Image
لذا سنضيف أمر WORKDIR /app بعد أمر الـ FROM في الـ Dockerfile

FROM node:25-alpine

WORKDIR /app

COPY . .

RUN npm install

CMD [ "npm", "start" ]

لاحظ أننا لم نغير شيء
فقط قمنا بإضافة أمر WORKDIR /app قبل أمر COPY . .
الآن عند نسخ الملفات باستخدام COPY . . سيتم نسخها داخل مجلد /app داخل الـ Container بدلاً من جذر النظام نفسه /
يمكننا تخيل أن الأمر WORKDIR /app يقوم بتنفيذ mkdir /app ثم cd /app أثناء بناء الـ Image
لذا نحن سنكون داخل مجلد /app عند تنفيذ أي أوامر تالية مثل COPY و RUN

الآن بعد تعديل الـ Dockerfile يجب علينا إعادة بناء الـ Image مرة أخرى لكي تأخذ التعديلات الجديدة التي قمنا بها في الـ Dockerfile بعين الاعتبار

> docker image build -t nodejs-docker-app .
...
 => CACHED [1/4] FROM docker.io/library/node:25-alpine@sha256:260b19691dc84090cf0c7773a38d2bce92e1aa86a184868eaed85675053e8c8e                                                                      0.0s
 => => resolve docker.io/library/node:25-alpine@sha256:260b19691dc84090cf0c7773a38d2bce92e1aa86a184868eaed85675053e8c8e                                                                             0.0s
 => [internal] load build context                                                                                                                                                                   2.7s
 => => transferring context: 39.16kB                                                                                                                                                                2.7s
 => [2/4] WORKDIR /app                                                                                                                                                                              0.0s
 => [3/4] COPY . .                                                                                                                                                                                  0.3s
 => [4/4] RUN npm install                                                                                                                                                                           2.3s
...

الآن قمنا بإعادة بناء الـ Image الخاصة بنا مع التعديلات الجديدة في الـ Dockerfile

قبل أن نكمل، أريدك أن تلاحظ وجود كلمة CACHED في الخطوة الأولى المتعلقة بأمر الـ FROM
وهذا يعني أن الـ Docker لم يقم بتحميل الـ base image مرة أخرى لأنه وجدها مخزنة في الـ cache على جهازنا بالفعل
لكن لاحظ أن خطوة RUN npm install لم يتم عمل CACHED لها برغم بأننا لم نقم بتغيير أي شيء المكاتب المثبتة في package.json
وهذا يعني أن الـ Docker سيقوم بتنفيذ الأمر npm install في كل مرة نقوم فيها بإعادة بناء الـ Image حتى لو لم نقم بتغيير شيء
هذا الأمر أريدك أن تتذكره لأننا سنقوم بتحسينه لاحقًا في هذه المقالة

الآن بعد إعادة بناء الـ Image يمكننا إنشاء Container جديد منها:

> docker container run -d -p 8080:3000 --rm --name nodejs-container nodejs-docker-app
040b1434ad923d9b8fa4a4afd7f3107174e54321339942ade4f61725c40194b5

الآن لنتحقق من أن الملفات داخل الـ Container موجودة في المجلد /app كما أردنا

> docker container exec -it nodejs-container sh
/app #

لاحظ أننا الآن داخل المجلد /app داخل الـ Container ولسنا في جذر النظام / كما كان سابقًا
الآن لنرى الملفات داخل هذا المجلد:

/app # ls
Dockerfile         app.js             node_modules       package-lock.json  package.json

كما ترى جميع ملفات التطبيق موجودة هنا داخل المجلد /app كما أردنا

هنا لدينا مشكلة أخرى وهى أننا حين نقوم بعمل COPY . . في الـ Dockerfile
وهو ينسخ كل شيء من مجلد المشروع على جهازنا إلى داخل الـ Image
لكن بالطبع هنا ملفات لا نريد نسخها إلى داخل الـ Image مثل مجلد node_modules أو ملف .env الذي قد يحتوي على معلومات حساسة
مثل ما هناك ملف يدعى .gitignore ليمنع الـ Git من تتبع بعض الملفات والمجلدات في مشروعنا
هناك ملف شبيه به يدعى .dockerignore يقوم بنفس الوظيفة ولكن للـ Docker
بحيث أنه يمنع الـ Docker من نسخ بعض الملفات والمجلدات إلى داخل الـ Image عند استخدام أمر COPY . . في الـ Dockerfile

لذا لننشئ ملف .dockerignore في مجلد المشروع ونضيف إليه الملفات والمجلدات التي لا نريد نسخها إلى داخل الـ Image

لنقم بالخروج من داخل الـ Container أولًا بكتابة exit أو بالضغط على Ctrl + D
ونقوم بإيقاف الـ Container أيضًا:

/app # exit

> docker container stop nodejs-container
nodejs-container

إنشاء ملف .dockerignore

الآن لننشئ ملف .dockerignore في مجلد المشروع ونضيف إليه الملفات والمجلدات التي لا نريد نسخها إلى داخل الـ Image

node_modules
Dockerfile
.env
.git
.gitignore

بالطبع يمكننا إضافة أي ملفات أو مجلدات أخرى لا نريد نسخها إلى داخل الـ Image
وغالبًا ما يكون مشابهًا لمحتويات ملف .gitignore لأي مشروع

لاحظ أننا في .dockerignore قمنا بإضافة Dockerfile أيضًا
لأنه مجرد ملف يستخدم لبناء الـ Image وليس جزء من التطبيق نفسه
ولاحظ أننا أضفنا أيضًا مجلد node_modules لكي لا نقوم بنسخه إلى داخل الـ Image في كل مرة نقوم فيها بعمل COPY . . في الـ Dockerfile
ولأننا نقوم بتثبيت المكاتب داخل الـ Image باستخدام npm install لذا لا حاجة لنسخ مجلد node_modules من جهازنا إلى داخل الـ Image
وأيضًا أضفنا ملف .git لكي لا نقوم بنسخ معلومات الـ Git الخاصة بالمشروع إلى داخل الـ Image
بالطبع نحن لا نستخدم Git في مثالنا لكنني وضعته لتوضيح الفكرة فقط وأيضًا لأنبهك أنه من الأفضل عدم نسخ مجلد .git إلى داخل الـ Image


الآن بعد إنشاء ملف .dockerignore يمكننا إعادة بناء الـ Image مرة أخرى لكي تأخذ التعديلات الجديدة في الاعتبار

...
 => [internal] load .dockerignore                                                                                                                                                                   0.0s
 => => transferring context: 88B                                                                                                                                                                    0.0s
 => [1/4] FROM docker.io/library/node:25-alpine@sha256:260b19691dc84090cf0c7773a38d2bce92e1aa86a184868eaed85675053e8c8e                                                                             0.0s
 => => resolve docker.io/library/node:25-alpine@sha256:260b19691dc84090cf0c7773a38d2bce92e1aa86a184868eaed85675053e8c8e                                                                             0.0s
 => [internal] load build context                                                                                                                                                                   0.1s
 => => transferring context: 184B                                                                                                                                                                   0.1s
 => CACHED [2/4] WORKDIR /app                                                                                                                                                                       0.0s
 => [3/4] COPY . .                                                                                                                                                                                  0.0s
 => [4/4] RUN npm install                                                                                                                                                                           3.0s
...

ستلاحظ نفس المشكلة التي ذكرناها سابقًا وهى أنه لم يتم عمل CACHED لخطوة RUN npm install برغم أننا لم نقم بتغيير شيء في المكاتب المثبتة في package.json
ونحن سنشرح السبب ونحل هذه المشكلة في القسم التالي من المقالة


إذًا بعد إعادة بناء الـ Image يمكننا إنشاء Container جديد منها:

> docker container run -d -p 8080:3000 --rm --name nodejs-container nodejs-docker-app

لنلقى نظرة سريعة على الملفات داخل الـ Container بدون الدخول إليه
عن طريق استخدام الأمر docker container exec مع ls مباشرةً:

> docker container exec nodejs-container ls
app.js             node_modules       package-lock.json  package.json

كما ترى ملف Dockerfile لم يعد موجودًا داخل الـ Container لأننا أضفناه في ملف .dockerignore
وأيضًا ستلاحظ أن مجلد node_modules ما زال موجودًا داخل الـ Container
لكن في الحقيقة هو لم يتم نسخه من جهازنا إلى داخل الـ Image
بل تم إنشاءه داخل الـ Container أثناء تنفيذ أمر RUN npm install في الـ Dockerfile

وهذا أفضل لأننا هكذا نسرع عملية بناء الـ Image بأننا لا نقوم بنسخ مجلد node_modules من جهازنا إلى داخل الـ Image
تخيل لو كان لدينا مشروع كبير ويحتوي على مئات المكاتب في node_modules
لكنا ننسخ مجلد ضخم جدًا في كل مرة نقوم فيها ببناء الـ Image وأيضًا فوق هذا يتم إنشاءه مجددًا داخل الـ Container أثناء تنفيذ npm install

هكذا نكون قد وفرنا وقت ومساحة تخزين كبيرة أثناء بناء الـ Image

سأكمل الفقرة الأخيرة بشكل أفضل وأكثر وضوحاً:

جعل Docker يستخدم الـ cache بشكل أفضل

الآن لنعد إلى مشكلة عدم استخدام الـ cache أثناء تنفيذ RUN npm install في الـ Dockerfile
لاحظنا أنه في كل مرة نقوم فيها بإعادة بناء الـ Image يتم تنفيذ npm install مجددًا حتى لو لم نقم بتغيير شيء في المكاتب المثبتة في package.json
وذكرنا سابقًا أن ترتيب الأوامر في الـ Dockerfile يؤثر على استخدام الـ cache أثناء بناء الـ Image
لذا يمكننا تحسين الـ Dockerfile بحيث نقوم بتغيير بعض الأوامر وتجزئتها لكي نستفيد من الـ cache بشكل أفضل

مشكلتنا الرئيسية هي أننا نقوم بعمل COPY . . الذي ينسخ كل شيء من مجلد المشروع إلى داخل الـ Image
وبما أن الـ Docker يعتمد على تتبع التغييرات في كل خطوة من خطوات بناء الـ Image
فإن أي تغيير بسيط في أي ملف من ملفات المشروع سيجعل الـ Docker يعتبر أن خطوة COPY . . تغيرت
ولأنه لا يعرف أي من الملفات التي تم نسخها قد تغيرت، لأنه ينظر إليها ككتلة واحدة
وبالتالي سيتم إعادة تنفيذ هذه الخطوة وجميع الخطوات التالية لها بما فيها RUN npm install بدون استخدام الـ cache

الحل هنا هو أننا نجزء عملية النسخ بحيث أننا ننسخ فقط الملفات التي نحتاجها لتثبيت المكاتب أولًا وهى package.json و package-lock.json
ثم نقوم بتنفيذ npm install ثم بعد ذلك ننسخ كل ملفات المشروع

FROM node:25-alpine

WORKDIR /app

COPY package*.json .

RUN npm install

COPY . .

CMD [ "npm", "start" ]

لاحظ أننا قمنا بتقسيم عملية النسخ إلى مرحلتين
المرحلة الأولى ننسخ فقط ملفات package.json و package-lock.json باستخدام COPY package*.json .
هكذا سيتعامل الـ Docker مع كل من هذين الملفين بشكل منفصل بالتالي يستطيع تتبع التغييرات فيهما بشكل أفضل وبالتالي يستطيع استخدام الـ cache بشكل أفضل

ثم نقوم بتثبيت المكاتب باستخدام RUN npm install
لاحظ أننا غيرنا ترتيب هذا الأمر ليكون بعد نسخ ملفات package.json و package-lock.json مباشرةً

وبما أن الأمر npm install يعتمد فقط على ملفات package.json و package-lock.json
فطالما أن هذه الملفات لم تتغير، فالـ Docker يستطيع استخدام الـ cache مع أمر RUN npm install بكل سهولة
بحيث أنه إذا كانت الخطوة السابقة COPY package*.json . لم تتغير، فالـ Docker سيستخدم الـ cache لخطوة RUN npm install أيضًا

وفي النهاية نقوم بنسخ كل ملفات المشروع باستخدام COPY . . مجددًا

بهذه الطريقة إذا قمنا بتعديل ملف app.js مثلًا وأعدنا بناء الـ Image
فإن الـ Docker سيلاحظ أن ملفات package.json و package-lock.json لم تتغير
وبالتالي سيستخدم الـ cache لخطوتي COPY package*.json . و RUN npm install
ولن يقوم بإعادة تثبيت المكاتب مرة أخرى


لنختبر هذا التعديل عمليًا

لنقم بإيقاف الـ Container أولًا

> docker container stop nodejs-container
nodejs-container

الآن لنعيد بناء الـ Image مع التعديلات الجديدة:

> docker image build -t nodejs-docker-app .
...
 => [1/5] FROM docker.io/library/node:25-alpine@sha256:260b19691dc84090cf0c7773a38d2bce92e1aa86a184868eaed85675053e8c8e                                                                             0.0s
 => => resolve docker.io/library/node:25-alpine@sha256:260b19691dc84090cf0c7773a38d2bce92e1aa86a184868eaed85675053e8c8e                                                                             0.0s
 => [internal] load build context                                                                                                                                                                   0.0s
 => => transferring context: 130B                                                                                                                                                                   0.0s
 => CACHED [2/5] WORKDIR /app                                                                                                                                                                       0.0s
 => [3/5] COPY package*.json .                                                                                                                                                                      0.0s
 => [4/5] RUN npm install                                                                                                                                                                           2.3s
 => [5/5] COPY . .                                                                                                                                                                                  0.0s
...

بالطبع لن ترى الـ cashed في خطوتي COPY package*.json . و RUN npm install هذه المرة
لأننا قمنا ببناء الـ Image لأول مرة بعد تعديل الـ Dockerfile

لنقم بإعادة بناء الـ Image مرة أخرى دون تعديل أي شيء في ملفات المشروع هذه المرة:

> docker image build -t nodejs-docker-app .
...
 => [1/5] FROM docker.io/library/node:25-alpine@sha256:260b19691dc84090cf0c7773a38d2bce92e1aa86a184868eaed85675053e8c8e                                                                             0.0s
 => => resolve docker.io/library/node:25-alpine@sha256:260b19691dc84090cf0c7773a38d2bce92e1aa86a184868eaed85675053e8c8e                                                                             0.0s
 => [internal] load build context                                                                                                                                                                   0.0s
 => => transferring context: 130B                                                                                                                                                                   0.0s
 => CACHED [2/5] WORKDIR /app                                                                                                                                                                       0.0s
 => CACHED [3/5] COPY package*.json .                                                                                                                                                               0.0s
 => CACHED [4/5] RUN npm install                                                                                                                                                                    0.0s
 => CACHED [5/5] COPY . .                                                                                                                                                                           0.0s
...

لاحظ الآن أن جميع الخطوات أصبحت CACHED هذه المرة
وهذا يعني أن الـ Docker لم يقم بإعادة تنفيذ أي من هذه الخطوات

حسنًا، لنقم بتعديل ملف app.js فقط لإجراء تغيير بسيط في ملفات المشروع ونرى ماذا سيحدث عند إعادة بناء الـ Image مرة أخرى

ملف app.js بعد التعديل:

const express = require("express");
const app = express();
const port = process.env.PORT || 3000;

app.get("/", (req, res) => {
  res.send(
    `
        <h1>Hello from Node.js Docker Container!</h1>
        <p>Visit <a href="https://eltabarani.com">eltabarani.com</a> for more tutorials.</p>
        <p>Check out my <a href="https://www.linkedin.com/in/ahmedeltabarani/">LinkedIn</a></p>
        <p>I love you all ❤️</p>
        `,
  );
});

app.listen(port, () => {
  console.log(`Server is running on port ${port}`);
});

الآن بعد تعديل ملف app.js، لنقم بإعادة بناء الـ Image مرة أخرى:

> docker image build -t nodejs-docker-app .
 => [1/5] FROM docker.io/library/node:25-alpine@sha256:260b19691dc84090cf0c7773a38d2bce92e1aa86a184868eaed85675053e8c8e                                                                             0.0s
 => => resolve docker.io/library/node:25-alpine@sha256:260b19691dc84090cf0c7773a38d2bce92e1aa86a184868eaed85675053e8c8e                                                                             0.0s
 => [internal] load build context                                                                                                                                                                   0.0s
 => => transferring context: 692B                                                                                                                                                                   0.0s
 => CACHED [2/5] WORKDIR /app                                                                                                                                                                       0.0s
 => CACHED [3/5] COPY package*.json .                                                                                                                                                               0.0s
 => CACHED [4/5] RUN npm install                                                                                                                                                                    0.0s
 => [5/5] COPY . .                                                                                                                                                                                  0.0s
...

لاحظ أن الخطوات WORKDIR /app و COPY package*.json . و RUN npm install كلها كانت CACHED
لأن ملفات package.json و package-lock.json لم تتغير

أما الخطوة COPY . . فتم تنفيذها مجددًا بدون استخدام الـ cache لأن ملف app.js قد تغير

وهكذا نكون قد حسنّا من استخدام الـ cache أثناء بناء الـ Image بشكل كبير
وستلاحظ فرقًا كبيرًا في سرعة بناء الـ Image في المرات التالية التي تقوم فيها بإعادة البناء بعد هذا التعديل في الـ Dockerfile وخصوصًا إذا كان مشروعك يحتوي على العديد من المكاتب في node_modules

مفهوم الـ Layers

كل خطوة في الـ Dockerfile تسمى layer وكل layer يتم تخزينه في الـ cache بشكل منفصل
بالتالي كل أمر مثل FROM, WORKDIR, COPY, RUN ينتج layer جديدة في الـ Docker
ولو docker وجد أن layer معينة لم تتغير، فإنه يستخدم الـ cache لهذا الـ layer بدلاً من إعادة تنفيذه
وأيضًا هذه الـ layers يتم تخزينها بشكل منفصل في الـ cache على جهازك
ويتم تعميمها بين الـ images المختلفة التي تستخدم نفس الـ base image أو نفس الأوامر في الـ Dockerfile
فمثًلا أمر مثل WORKDIR /app إذا تم استخدامه في عدة Dockerfiles مختلفة، فإن الـ Docker سيستخدم نفس الـ layer المخزنة في الـ cache لكل هذه الـ Dockerfiles
كذلك مع أي layer أخرى إذا تكرر في عدة Dockerfiles مختلفة، فإن الـ Docker سيستخدم نفس الـ layer المخزنة في الـ cache لكل هذه الـ Dockerfiles
وهذا يساعد في تسريع عملية بناء الـ images بشكل كبير لأنه لا يتم إعادة تنفيذ الأوامر المتكررة في كل مرة

لكن مع شرطين مهمين:

  1. أن يكون الأمر نفسه مطابق فمثلًا WORKDIR /app يجب أن يكون مكتوبًا بنفس الشكل في كل Dockerfile ليتم استخدام نفس الـ layer
  2. ألا يكون هناك أي تغيير في الـ layers السابقة لهذا الأمر، لأنه إذا تغيرت أي layer قبل هذا الأمر، فإن الـ Docker سيعتبر أن هذا الأمر تغير أيضًا وبالتالي لن يستخدم الـ cache له

بمعنى أخر لو قمنا بتغير أي أمر WORKDIR /app إلى WORKDIR /application فأن الـ Docker سيعتبر أن هذا أمر جديد وكل الخطوات التالية له ستعتبر جديدة أيضًا
بالتالي سيتم إعادة تنفيذ كل الخطوات التالية له بدون استخدام الـ cache
لذا من المهم جدًا أن نكون حذرين في ترتيب الأوامر في الـ Dockerfile لكي نستفيد من الـ cache بشكل أفضل

تخصيص إصدار الـ Image باستخدام Tag

كما ذكرنا سابقًا، عندما نستخدم FROM node:25-alpine في الـ Dockerfile
فإننا نستخدم الـ base image الرسمية الخاصة بـ Node.js والتي تعتمد على توزيعة Alpine Linux
ولاحظ أن الإصدار هو 25 وهو إصدار Major من Node.js
بمعنى أنه أي تحديث يتم في إصدار Minor أو Patch الخاص بإصدار Node.js رقم 25 سيتم تطبيقه تلقائيًا في الـ Image التي نستخدمها node:25-alpine عند بناءها

أننا حاليًا استخدمنا node:25-alpine كـ base image في الـ Dockerfile
لنفترض أن كل اسبوع يتم إصدار patch جديد لإصدار Node.js رقم 25 مثل 25.0.1 ثم 25.0.2 وهكذا
وكل فترة يتم إصدار minor جديد مثل 25.1.0 ثم 25.2.0 وهكذا
كل هذه التحديثات ستتم تطبيقها تلقائيًا في الـ Image الخاصة بنا عند بناءها
فأنت كل مرة تقوم فيها بإعادة بناء الـ Image الخاصة بك، قد تحصل على تحديثات جديدة في إصدار Node.js رقم 25 بدون أن تدري
وهذا قد يسبب مشاكل في مشروعك إذا كان هناك تحديث في إصدار الـ Minor أو Patch غير متوافق مع مشروعك أو يحتوي على تغييرات غير متوافقة مع الكود الخاص بك

لذا يفضل دائمًا يجب أن نحدد إصدار الـ Minor و Patch أيضًا في الـ Dockerfile لكي نضمن أن الـ Image الخاصة بنا ستستخدم إصدارًا ثابتًا من Node.js ولن تتغير تلقائيًا مع أي تحديثات في إصدار الـ Minor أو Patch

وأيضًا يضم الـ cache بشكل أفضل أثناء بناء الـ Image لأنه إذا قمنا بتحديد إصدار ثابت مثل 25.6.1 مثلاً
فأنت تضمن أن لن يحدث أي تغيير في الـ image لأنك حددت إصدارًا ثابتًا
بالتالي سيتم تحميلها مرة واحدة فقط من الـ Docker Hub ثم سيتم تخزينها في الـ cache على جهازك
وبالتالي في المرات التالية التي تقوم فيها بإعادة بناء الـ Image الخاصة بك، لن يتم تحميل الـ base image مرة أخرى من الـ Docker Hub لأنك حددت إصدارًا ثابتًا

لذا دائما عندما تنشيء Dockerfile جديد، تأكد من تحديد إصدار ثابت للـ base image يتكون من Major و Minor و Patch لكي تضمن استقرار الـ Image الخاصة بك وعدم تعرضها لأي تغييرات غير متوقعة في إصدار الـ base image
ويمكنك دائما زيارة صفحة الـ Docker Hub الخاصة بالـ base image التي تستخدمها لمعرفة أحدث إصدار ثابت يمكنك استخدامه في الـ Dockerfile الخاص بك
في حالتنا نحن نستطيع أن نستخدم node:25.6.1-alpine3.23 مثلاً كـ base image في الـ Dockerfile الخاص بنا لكي نضمن أن الـ Image الخاصة بنا ستستخدم إصدارًا ثابتًا من Node.js رقم 25.6.1 مع توزيعة Alpine Linux رقم 3.23

تمرير متغيرات environment إلى الـ Container

الآن ستلاحظ أن في معظم المشاريع نحن نستخدم ملف .env لتخزين بعض المتغيرات والإعدادات الخاصة بالتطبيق
الآن فرضًا أننا نملك ملف .env في مشروعنا يحتوي على بعض المتغيرات التي نريد تمريرها إلى داخل الـ Container عند تشغيل التطبيق
لكن في نفس الوقت لا نريد نسخ ملف .env إلى داخل الـ Image بسبب أنه قد يحتوي على معلومات حساسة
لذا نحتاج لوسيلة لتمرير الـ environment variables إلى داخل الـ Container عند تشغيله بدون الحاجة لنسخ ملف .env إلى داخل الـ Image

هنا لدينا بعض الحيل والخيارات لفعل ذلك
ستلاحظ في مشروعنا الصغير هذا السطر الصغير في

const port = process.env.PORT || 3000;

الذي يحدد الـ port الذي سيعمل عليه الـ Server
هنا هو يستخدم الـ environment variable المسمى PORT إذا كان موجودًا
وإذا لم يكن موجودًا فسيستخدم القيمة الافتراضية 3000

لذا ما رأيك أن نحاول تمرير قيمة للـ PORT عند إنشاء الـ Container لكي يعمل على port مختلف عن 3000 ؟

> docker container run -d -p 8080:4000 -e PORT=4000 --rm --name nodejs-container nodejs-docker-app

لاحظ أننا استخدمنا -e PORT=4000 لتمرير متغير البيئة PORT بقيمة 4000 إلى داخل الـ Container
الـ -e أو --env تستخدم لتمرير متغيرات الـ environment إلى داخل الـ Container عند إنشائه
بالتالي هنا قلنا -e PORT=4000 لكي يكون متغير البيئة PORT داخل الـ Container بقيمة 4000
بالتالي الـ Server الذي يعمل داخل الـ Container سيستخدم الـ port رقم 4000 بدلاً من 3000
لذا ستلاحظأننا في أمر -p 8080:4000 قمنا بربط الـ port رقم 8080 على جهازنا بالـ port رقم 4000 داخل الـ Container
الآن إذا ذهبنا إلى http://localhost:8080 على جهازنا سنرى أن التطبيق يعمل بنجاح على الـ port الجديد 4000 داخل الـ Container


بهذه الطريقة يمكننا تمرير أي متغيرات environment نريدها إلى داخل الـ Container عند إنشائه باستخدام -e أو --env
لكن ماذا لو كان لدينا العديد من المتغيرات في ملف .env نريد تمريرها جميعًا إلى داخل الـ Container ؟

في هذه الحالة يمكننا استخدام خيار --env-file لتمرير جميع المتغيرات من ملف .env إلى داخل الـ Container دفعة واحدة ' بدلاً من كتابة -e لكل متغير على حدة

> docker container run -d -p 8080:3000 --env-file .env --rm --name nodejs-container nodejs-docker-app

لاحظ أننا استخدمنا --env-file .env هكذا نقول للـ Docker أن يقرأ جميع المتغيرات من ملف .env الموجود في مجلد المشروع على جهازنا
ويقوم بتمريرها إلى داخل الـ Container عند إنشائه
دون أن نحتاج لنسخ ملف .env إلى داخل الـ Image أو الـ Container


لنقم بإيقاف الـ Container

> docker container stop nodejs-container
nodejs-container

هناك أمر ENV في الـ Dockerfile يمكننا استخدامه أيضًا لتحديد متغيرات environment داخل الـ Image نفسها
هكذا يمكننا تحديد قيم افتراضية لمتغيرات environment على مستوى الـ Image تكون متاحة لجميع الـ Containers التي سيتم إنشاؤها من هذه الـ Image
وبالطبع يمكن لأي Container أن يقوم بيعمل override لهذه القيم عند إنشائه باستخدام -e أو --env أو --env-file

مثلاً يمكننا إضافة السطر التالي في الـ Dockerfile لتحديد متغير environment افتراضي:

ENV PORT=3000

بهذا نكون قد حددنا متغير environment اسمه PORT بقيمة افتراضية 3000 داخل الـ Image نفسها
وبالتالي أي Container يتم إنشاؤه من هذه الـ Image سيحصل على هذا المتغير PORT بقيمة 3000 بشكل افتراضي
ما لم يتم عمل override له عند إنشاء الـ Container باستخدام -e أو --env أو --env-file

توضيح الـ PORT الإفتراضي في الـ Dockerfile

لاحظ أننا في ملف app.js قمنا بتحديد الـ port الافتراضي ليكون 3000 في حالة عدم وجود متغير بيئة PORT
هذه المعلومة قد لا يعرفها من يستخدم الـ Image الخاصة بنا
بحيث أننا نريد أن نخبر ونوضح لأي شخص يستخدم الـ Image الخاصة بنا أن التطبيق يعمل على الـ port رقم 3000 بشكل افتراضي
لذا يمكنهم استخدام -p لربط أي port على جهازهم بالـ port رقم 3000 داخل الـ Container

لتوضيح هذا في الـ Dockerfile يمكننا استخدام الأمر EXPOSE

FROM node:25-alpine

WORKDIR /app

COPY package*.json .

RUN npm install

COPY . .

ENV PORT=3000
EXPOSE 3000

CMD [ "npm", "start" ]

لاحظ أننا أضفنا أمر EXPOSE 3000 قبل أمر CMD في الـ Dockerfile
وظيفة هذا الأمر هي فقط لتوضيح أن التطبيق داخل الـ Container يستمع على الـ port رقم 3000
الأمر EXPOSE لا يقوم بفعل أي شيء عمليًا أثناء تشغيل الـ Container
هو مثل تعليق توضيحي في الكود لا أكثر ولا أقل
فقط يخبر أي شخص يستخدم الـ Image الخاصة بنا أن التطبيق يعمل على الـ port رقم 3000 داخل الـ Container بشكل افتراضي في حالة عدم تمرير متغير PORT له
قيمته تظهر في عمود الـ PORTS عند تنفيذ docker container list في حالة عندم تمرير -p لربط port على جهازه بالـ port داخل الـ Container

بدون وجود أمر EXPOSE في الـ Dockerfile
إذا قام شخص ما باستخدام الـ Image وأنشأ Container بدون تحديد -p لربط أي port على جهازه بالـ port رقم 3000 داخل الـ Container لأنه لا يعرف الـ port الذي يعمل عليه التطبيق داخل الـ Container
ثم قام بعمل docker container list فأنه سيرى أن قيمة عمود الـ PORTS الخاصة بالـ Container فارغه
لكن في حالة وجود أمر EXPOSE 3000 في الـ Dockerfile
فأنه سيرى في عمود الـ PORTS الخاصة بالـ Container القيمة 3000/tcp فسيدرك أن التطبيق يعمل على هذا الـ port داخل الـ Container لذا سيقوم بعمل -p لربط port على جهازه بالـ port رقم 3000 داخل الـ Container

رؤية معلومات الـ Image

يمكنك أن ترى معلومات عن الـ Image الخاصة بنا باستخدام الأمر docker image inspect <image-name>
ومنها يمكنك رؤية الـ Ports التي تم توضيحها باستخدام أمر EXPOSE في الـ Dockerfile
لتعطي معلومة للشخص الذي يستخدم الـ Image عن الـ Ports التي يعمل عليها التطبيق

> docker image inspect nodejs-docker-app
[
    {
        ...
        "Config": {
            ...
            "ExposedPorts": {
                "3000/tcp": {}
            },
            "Env": [
                "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
                "NODE_VERSION=25.5.0",
                "YARN_VERSION=1.22.22",
                "PORT=3000"
            ],
            "WorkingDir": "/app",
            "Cmd": [
                "npm",
                "start"
            ],
            "Entrypoint": [
                "docker-entrypoint.sh"
            ],
            ...
        }
        ...
    }
]

أمر docker image inspect يعطيك معلومات كثيرة عن الـ Image مثل الـ Layers المستخدمة فيها وحجمها وتاريخ إنشائها وغيرها من المعلومات المفيدة
ومعلومات الـ Ports والـ EntryPoint والـ Cmd والكثير من المعلومات الأخرى
لا أستطيع وضع كل هذه المعلومات هنا لكن يمكنك تجربته بنفسك

لاحظ أن هناك جزء خاص بالـ ExposedPorts الذي يوضح أن التطبيق داخل الـ Container يستمع على الـ port رقم 3000
وهذا بسبب أننا استخدمنا أمر EXPOSE 3000 في الـ Dockerfile
إذا لم نستخدم أمر EXPOSE في الـ Dockerfile فلن نجد هذا الجزء في معلومات الـ Image عند تنفيذ docker image inspect
بالتالي الشخص الذي يستخدم الـ Image الخاصة بنا لن يعرف أن التطبيق داخل الـ Container يستمع على أي port وبالتالي قد يواجه صعوبة في تشغيل التطبيق داخل الـ Container بشكل صحيح

وستجد الـ Env الذي يوضح المتغيرات environment التي تم تحديدها في الـ Dockerfile باستخدام أمر ENV
لكننا حددنا فقط متغير PORT بقيمة 3000 في الـ Dockerfile
مع ذلك نرى أن هناك متغيرات أخرى مثل PATH و NODE_VERSION و YARN_VERSION
هذه المتغيرات تم تحديدها في الـ base image التي استخدمناها node:25-alpine
فأنت ترث كل المعلومات والخصائص من الـ base image التي تستخدمها في الـ Dockerfile

وأخيرًا ستجد الـ Cmd و الـ Entrypoint الذي يوضح الأمر الأساسي الذي سيتم تنفيذه داخل الـ Container عند تشغيله
ستلاحظ أنه لدينا Entrypoint محدد وهو docker-entrypoint.sh غاليبًا أتى مع الـ base image التي استخدمناها
ولدينا الـ Cmd الذي حددناه في الـ Dockerfile وهو npm start

الفرق بين CMD و ENTRYPOINT

في الـ Dockerfile استخدمنا أمر CMD [ "npm", "start" ] لتحديد الأمر الافتراضي الذي سيتم تنفيذه عند إنشاء Container من هذه الـ Image
لكن مشكلة أمر CMD هو أنه يمكن تجاوزه بسهولة عند إنشاء الـ Container
مثلاً إذا قام شخص ما بإنشاء Container من هذه الـ Image وقام بتمرير أمر مختلف مثل sh أو ls
فلن يتم تنفيذ الأمر npm start الذي حددناه في الـ CMD بل سيتم تنفيذ الأمر الذي قام بتمريره هو بدلاً من ذلك

docker container run -p 8080:3000 --name nodejs-container nodejs-docker-app ls
app.js
node_modules
package-lock.json
package.json

لاحظ أننا في نهاية الأمر قمنا بتمرير ls
هكذا نحن قمنا بعمل override لأمر الـ CMD في الـ Dockerfile
فبدلاً من تنفيذ npm start سيتم تنفيذ ls داخل الـ Container

بالتالي ستجد أنه قام بعرض ملفات المشروع بدلاً من تشغيل الـ Server الخاص بالتطبيق بسبب الـ ls
وبالمناسبة لم أكتب هنا -d لتشغيل الـ Container في الخلفية لأننا نريد رؤية ناتج الأمر ls مباشرةً في الـ Terminal

هكذا تم إنشاء الـ Container دون تشغيل الـ Server الخاص بالتطبيق عن طريق npm start
بالتالي إذا حاولنا الوصول إلى التطبيق على http://localhost:8080 فلن يعمل لأن الـ Server لم يتم تشغيله داخل الـ Container

لاحظ أنني لم أستخدم --rm هنا لكي لا يتم حذف الـ Container تلقائيًا بعد إيقافه
لأنه عندما استخدمنا ls كأمر بديل للـ CMD
فأنه قام بعرض الملفات ثم انتهى الأمر وبالتالي تم إيقاف الـ Container فورًا بعد تنفيذ الأمر

بالتالي عندما نقوم بعمل docker container list --all سنرى أن الـ Container في الحالة Exited

> docker container list --all
CONTAINER ID   IMAGE               COMMAND                  CREATED          STATUS                      PORTS                  NAMES
404785f3fe0a   nodejs-docker-app   "docker-entrypoint.s…"   39 seconds ago   Exited (0) 38 seconds ago                          nodejs-container

لو استخدمنا --rm عند إنشاء الـ Container فلن نجده هنا لأنه سيتم حذفه تلقائيًا بعد إيقافه
على أي حال لنتبه إلى عمود الـ COMMAND الذي يخبرنا عن الأمر الذي تم تنفيذه داخل الـ Container عند إنشائه
بالطبع القيمة مقطوعة ولا تظهر كاملة بسبب طولها لكن لو قمنا بعمل --no-trunc مع أمر docker container list --all فسنرى الأمر كاملًا
والذي سيكون docker-entrypoint.sh ls بدلاً من docker-entrypoint.sh npm start بسبب أننا قمنا بتمرير ls كأمر بديل للـ CMD في الـ Dockerfile
بالتالي قمنا بعمل override لأمر الـ CMD الذي كان محدد في الـ Dockerfile

لنقم بحذف هذا الـ Container الآن ثم نفكر في حل لهذه المشكلة

> docker container remove nodejs-container
nodejs-container

السبب في هذا هو أن أمر CMD في الـ Dockerfile ليس في الحقيقة الأمر الأساسي الذي يتم تنفيذه داخل الـ Container
بل هو في الحقيقة يعامل معاملة الـ argument الافتراضي الذي يتم تمريره إلى أمر الأساسي الذي يتم تنفيذه داخل الـ Container
وهذا الأمر الأساسي هو الذي يتم تحديده بواسطة أمر ENTRYPOINT في الـ Dockerfile
قد تلاحظ أننا في معلومات الـ Image التي حصلنا عليها باستخدام docker image inspect
ستجد أن هناك قيمة محددة لأمر Entrypoint وهو docker-entrypoint.sh
بعض الـ base images تأتي مع entrypoint جاهز يتم تنفيذه أولًا داخل الـ Container
ثم بعد ذلك يتم تمرير أمر الـ CMD كـ argument لهذا السكريبت

لذا يمكننا استخدام أمر ENTRYPOINT في الـ Dockerfile لتحديد الأمر الأساسي الذي سيتم تنفيذه داخل الـ Container
وميزته هنا هي أنه لا يمكن تجاوزه بسهولة مثل CMD
بالتالي حتى إذا قام شخص ما بتمرير أمر مختلف عند إنشاء الـ Container فيتم تنفيذ الأمر الذي حددناه في الـ ENTRYPOINT دائمًا

لذا سنقوم بتغير الـ Dockerfile ليستخدم ENTRYPOINT بدلاً من CMD

FROM node:25-alpine

WORKDIR /app

COPY package*.json .

RUN npm install

COPY . .

ENV PORT=3000
EXPOSE 3000

ENTRYPOINT [ "npm", "start" ]
# CMD used as optional argument passed to ENTRYPOINT command

لاحظ أننا قمنا بتغيير CMD إلى ENTRYPOINT
الآن بعد تعديل الـ Dockerfile يجب علينا إعادة بناء الـ Image

> docker image build -t nodejs-docker-app .
...
 => [1/5] FROM docker.io/library/node:25-alpine@sha256:260b19691dc84090cf0c7773a38d2bce92e1aa86a184868eaed85675053e8c8e                                                                             0.0s
 => => resolve docker.io/library/node:25-alpine@sha256:260b19691dc84090cf0c7773a38d2bce92e1aa86a184868eaed85675053e8c8e                                                                             0.0s
 => [internal] load build context                                                                                                                                                                   0.0s
 => => transferring context: 130B                                                                                                                                                                   0.0s
 => CACHED [2/5] WORKDIR /app                                                                                                                                                                       0.0s
 => CACHED [3/5] COPY package*.json .                                                                                                                                                               0.0s
 => CACHED [4/5] RUN npm install                                                                                                                                                                    0.0s
 => CACHED [5/5] COPY . .                                                                                                                                                                           0.0s
...

الآن لنقم بإنشاء Container مع تمرير أمر مختلف مثل ls لنرى ماذا سيحدث هذه المرة

> docker container run -p 8080:3000 --name nodejs-container nodejs-docker-app ls

> nodejs-docker-app@1.0.0 start
> node app.js ls

Server is running on port 3000

لاحظ أننا قمنا بتمرير ls كأمر عند إنشاء الـ Container
لكن هذه المرة لم يتم تنفيذ ls داخل الـ Container كما حدث سابقًا
بل تم تنفيذ الأمر الذي حددناه في الـ ENTRYPOINT وهو npm start
ولاحظ أن ls تم تمريره بعد node app.js كـ argument لهذا الأمر

هذا يعني أن الـ ENTRYPOINT تم تنفيذه أولًا ثم تم تمرير ls كـ argument إلى الأمر الذي تم تنفيذه في الـ ENTRYPOINT
نستنتج هنا الـ ENTRYPOINT هو الأمر الأساسي الذي يتم تنفيذه داخل الـ Container والـ CMD أو الأمر الذي يتم تمريره عند إنشاء الـ Container يتم اعتباره كـ argument لهذا الأمر الأساسي

على أي حال لنقم بزيارة http://localhost:8080 على جهازنا الآن وسنرى أن التطبيق يعمل مجددًا

لنقم بإيقاف الـ Container الآن عن طريق الضغط على Ctrl + C في الـ Terminal الذي قمنا فيه بإنشاء الـ Container

ثم نقوم بحذفه:

> docker container remove nodejs-container
nodejs-container

يمكننا أيضًا الجمع بين ENTRYPOINT و CMD في نفس الـ Dockerfile
بحيث أننا نستخدم ENTRYPOINT لتحديد الأمر الرئيسي الذي سيتم تنفيذه داخل
ونستخدم CMD لتمرير الـ arguments الافتراضية لهذا الأمر
وهذا يسمح لنا بتمرير arguments مختلفة عند إنشاء الـ Container إذا أردنا ذلك
في حالة أن الأمر ENTRYPOINT يحتاج إلى arguments لتشغيله

هذا قد يكون مفيدًا في بعض الحالات المتقدمة
لكن في حالتنا هنا لا نحتاج لذلك لأن الأمر npm start لا يحتاج إلى أي arguments لتشغيله


يمكننا عمل override لأمر ENTRYPOINT أيضًا عند إنشاء الـ Container باستخدام --entrypoint

> docker container run -p 8080:3000 --rm --entrypoint ls --name nodejs-container nodejs-docker-app
app.js
node_modules
package-lock.json
package.json

لاحظ أننا استخدمنا --entrypoint ls لتحديد أمر مختلف عن الـ ENTRYPOINT الذي حددناه في الـ Dockerfile
وهكذا تم تنفيذ ls داخل الـ Container بدلاً من npm start

برغم من أن هناك طرق لتجاوز أمر ENTRYPOINT عند إنشاء الـ Container
لك هذا لا يمنعنا من استخدام ENTRYPOINT ومن أراد تجاوزه فهو حر في ذلك فنحن لن نستطيع إجباره على عدم تجاوزه تمامًا
لأننا لا نستطيع معاقبته أو ضربه من خلف هذه الشاشات الإلكترونية البالدة مع الأسف

الخلاصة

في هذا الجزء من المقالة تعلمنا كيفية تحسين الـ Dockerfile الخاص بنا
بحيث أننا وضعنا ملفات التطبيق في مجلد /app داخل الـ Container باستخدام WORKDIR وتعلمنا كيفية منع نسخ بعض الملفات والمجلدات إلى داخل الـ Image باستخدام ملف .dockerignore
وتعلمنا كيفية تحسين استخدام الـ cache أثناء بناء الـ Image عن طريق ترتيب الأوامر في الـ Dockerfile بشكل أفضل
وتعلمنا كيفية تمرير متغيرات environment إلى داخل الـ Container عند إنشائه باستخدام -e أو --env أو --env-file
وتعلمنا كيفية توضيح الـ port الذي يعمل عليه التطبيق داخل الـ Container باستخدام EXPOSE
وأخيرًا تعلمنا الفرق بين CMD و ENTRYPOINT في الـ Dockerfile وكيفية استخدام كل منهما بشكل صحيح

أرجو أن تكون قد استفدت من هذه المقالة