ملفات Dockerfile
تحويل الوصفة إلى صورة قابلة لإعادة الإنتاج.
إنشاء ملفات Dockerfile
حتى الآن كنتم تستخدمون صورًا كتبها آخرون. وقد حان وقت إنتاج صوركم بأنفسكم، وهنا يتوقف Docker عن كونه أداة مساعدة ويصبح منهجية عمل: فملف Dockerfile هو الوثيقة التي تنتقل فيها بيئة تشغيل تطبيقكم من كونها معرفة متوارثة شفهيًا إلى كونها شيفرة مصدرية، تُراجَع وتُدار إصداراتها مع بقية المشروع.
الإغراء في البداية أن نعامله كأنه سكربت تثبيت نُقل ببساطة إلى ملف. فنكدّس الأوامر التي نجحت على الجهاز، ثم نبني الصورة، فتعمل، وننتقل إلى شيء آخر. النتيجة تعمل فعلًا، لكنها تزن 1.4 جيجابايت لتطبيق Node مكوّن من ثلاثة ملفات، وتُعاد عملية بنائها في أربع دقائق عند كل فاصلة تُعدَّل، وتعمل بصلاحيات root، وتحمل في إحدى طبقاتها رمز الوصول الخاص الذي ظننتم أنكم حذفتموه في السطر التالي.
لا شيء من هذه العيوب الأربعة قدر محتوم، ولا يتطلب أي منها معرفة متقدمة. فجميعها ناتج عن سوء فهم واحد: ملف Dockerfile ليس سكربتًا يُنفَّذ من الأعلى إلى الأسفل في بيئة واحدة. إنه سلسلة من التحويلات، تنتج كل منها طبقة غير قابلة للتغيير، تُخزَّن مؤقتًا وتُوضع فوق التي قبلها. وفهم هذه الآلية يمنحكم تقريبًا دون مقابل صورًا أخف بعشر مرات وعمليات بناء أسرع بعشر مرات.
يبدأ هذا الدرس من ملف Dockerfile ساذج لكنه يعمل، ثم يصحّحه عيبًا بعد عيب، مع شرح ما يفعله محرك البناء فعليًا في كل خطوة.
أول ملف Dockerfile، سطرًا بسطر
لنأخذ تطبيق Node صغيرًا. في جذر المشروع، ملف اسمه Dockerfile تمامًا، بلا امتداد:
# صورة الانطلاق: كل Dockerfile يبدأ بـ FROM.
# الإصدار مثبّت: لا "latest" ولا "node" وحدها.
FROM node:22-slim
# مجلد العمل للتعليمات التالية.
# WORKDIR ينشئ المجلد إذا لم يكن موجودًا.
WORKDIR /app
# نسخ ملفات تعريف الاعتماديات وحدها، قبل الشيفرة.
# السبب هو التخزين المؤقت: يُشرح بالتفصيل أدناه.
COPY package.json package-lock.json ./
# "npm ci" يلتزم حرفيًا بملف القفل، بخلاف "npm install".
RUN npm ci --omit=dev
# بقية شيفرة التطبيق
COPY . .
# EXPOSE لا ينشر أي شيء: إنه بيانات وصفية توثيقية فقط.
# يتم النشر باستخدام -p عند التشغيل.
EXPOSE 3000
# الأمر الذي يُنفَّذ عند بدء تشغيل الحاوية
CMD ["node", "server.js"]
# -t يضع وسمًا للصورة (الاسم:الوسم). النقطة "." الأخيرة هي سياق
# البناء: المجلد المرسل إلى الـ daemon، وليس موضع ملف Dockerfile.
docker build -t mon-api:1.0 .
docker images mon-api
docker run --rm -p 3000:3000 mon-api:1.0
يعمل هذا الملف، وهو صحيح أصلًا في نقطة مهمة: ترتيب تعليمات COPY. لكن قبل المتابعة، يجب إزالة الغموض عن تلك النقطة الأخيرة.
سياق البناء (build context) هو مجموعة الملفات التي يرسلها العميل إلى الـ daemon قبل أن تبدأ عملية البناء. ولا يملك الـ daemon أي وصول إلى قرصكم: فهو لا يرى سوى هذه الحزمة. ومن هنا تنشأ نتيجتان يصادفهما الجميع مرة واحدة على الأقل. الأمر COPY ../secrets/config.json يفشل برسالة path outside of build context، لأن المسار يخرج عن الحزمة. وإذا كان مجلد node_modules المحلي يزن 800 ميجابايت، فإن هذه الميجابايتات تُنقل عند كل عملية بناء حتى لو لم تُنسخ أبدًا — ومن هنا تأتي أهمية .dockerignore، المشروح لاحقًا.
والسياق وموضع ملف Dockerfile مستقلان عن بعضهما، فالأمر docker build -f docker/Dockerfile.prod -t mon-api:prod . يقرأ الوصفة من مجلد فرعي مع إبقاء المجلد الحالي سياقًا للبناء.
التعليمات الأساسية
تحدد FROM الصورة الأساسية، ويمكن أن تظهر عدة مرات (انظر البناء متعدد المراحل). أما FROM scratch فتشير إلى صورة فارغة تمامًا.
تنفّذ RUN أمرًا أثناء البناء وتثبّت نتيجته في طبقة. كل RUN تنشئ طبقة، لذلك نربطها ببعضها.
# سيئ: ثلاث طبقات، وتبقى ذاكرة apt المؤقتة داخل الصورة
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get install -y git
# جيد: طبقة واحدة، والتنظيف في التعليمة نفسها
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
git \
&& rm -rf /var/lib/apt/lists/*
يجب أن يكون rm -rf في أمر RUN نفسه حتمًا. فلو وُضع في RUN لاحقة لأخفى الملفات دون تقليص حجم الصورة، لأن الطبقات تراكمية. أما --no-install-recommends فيتجنب عشرات الحزم المقترحة التي لا حاجة لكم بها.
تنسخ COPY من السياق إلى الصورة. وتفعل ADD الأمر نفسه مع إضافة سلوكين ضمنيين — فك ضغط أرشيف محلي وتنزيل عنوان URL — وكثيرًا ما يكونان مفاجئين. والقاعدة المتفق عليها هي استخدام COPY افتراضيًا، وADD فقط لاستخراج أرشيف.
COPY ./src /app/src
COPY --chown=node:node ./src /app/src # نسخ مع تحديد المالك
ADD app-v2.tar.gz /opt/ # يستخرج تلقائيًا
تعرّف ENV متغيرًا دائمًا في الصورة والحاويات، بينما ARG متغير متاح أثناء البناء فقط، ويمكن تجاوز قيمته من سطر الأوامر.
ARG NODE_VERSION=22
FROM node:${NODE_VERSION}-slim
ARG APP_ENV=production
ENV NODE_ENV=${APP_ENV}
docker build --build-arg NODE_VERSION=20 --build-arg APP_ENV=staging -t mon-api:test .
# ARG ليس سرًّا: تبقى قيمته مقروءة هنا
docker history --no-trunc mon-api:test
تحدد WORKDIR المجلد الحالي: فضّلوها دائمًا على RUN cd /app التي لا أثر دائم لها لأن كل RUN تبدأ عملية جديدة. وتوثّق EXPOSE منفذًا وتتيح استخدام docker run -P الذي ينشر المنافذ المعلنة تلقائيًا. وأخيرًا، تضيف LABEL org.opencontainers.image.source="..." بيانات وصفية للتتبّع تعرف الأدوات كيف تستفيد منها.
التخزين المؤقت للطبقات: الترتيب يحدد زمن البناء
هذه هي النقطة التي تحقق أكبر المكاسب، وتُختصر في قاعدة واحدة.
لكل تعليمة، يحسب المحرك مفتاح تخزين مؤقت. فإذا لم تتغير التعليمة ولا مدخلاتها، أُعيد استخدام الطبقة كما هي. لكن بمجرد إبطال طبقة، تُبطَل جميع الطبقات التالية لها أيضًا. فالتخزين المؤقت سلسلة: انقطاع في الحلقة 3 يكسر الحلقات من 4 إلى 12. وفي RUN يكون المفتاح هو نص الأمر، أما في COPY فهو محتوى الملفات المنسوخة.
ومن هنا المثال المضاد الكلاسيكي:
FROM node:22-slim
WORKDIR /app
COPY . . # تُبطَل بمجرد تغيّر ملف مصدري واحد
RUN npm ci --omit=dev # ...فتُعاد عملية التثبيت كاملة عند كل فاصلة تُعدَّل
CMD ["node", "server.js"]
والنسخة الصحيحة التي تستفيد من الثبات النسبي للاعتماديات:
FROM node:22-slim
WORKDIR /app
# ملفات التعريف وحدها: نادرًا ما تتغير
COPY package.json package-lock.json ./
# طبقة مكلفة، لكنها تبقى محفوظة ما دامت ملفات التعريف متطابقة
RUN npm ci --omit=dev
# الشيفرة في النهاية: هي الأكثر تغيرًا والأقل كلفة
COPY . .
CMD ["node", "server.js"]
القاعدة العامة، الصالحة لكل اللغات: من الأكثر ثباتًا إلى الأكثر تقلبًا. تثبيت النظام أولًا، ثم الاعتماديات، ثم الشيفرة. لا تتغير سوى أسماء الملفات: requirements.txt في Python (pip install --no-cache-dir -r)، وgo.mod وgo.sum في Go، وpom.xml في Maven، وCargo.toml في Rust.
أثناء البناء، تشير الأسطر CACHED إلى الطبقات المُعاد استخدامها. وهناك خياران لتجاوز التخزين المؤقت: --no-cache يفرض إعادة بناء كاملة، مما يتيح التحقق من أن Dockerfile ما زال يعمل من الصفر، و--pull يجلب صورة أساسية محدّثة بدل الموجودة في الذاكرة المؤقتة المحلية.
ويوفر BuildKit، محرك البناء الافتراضي منذ Docker 23، ذاكرات مؤقتة مُركَّبة (mounted caches) تصمد أمام عمليات الإبطال. وهي تتطلب توجيه الصياغة في السطر الأول:
# syntax=docker/dockerfile:1
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
# ذاكرة npm المؤقتة تُركَّب أثناء RUN ثم لا تُضمَّن
# في الطبقة: سريع، دون تضخيم الصورة.
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev
COPY . .
CMD ["node", "server.js"]
.dockerignore: ضروري وليس اختياريًا أبدًا
يوضع هذا الملف بجانب Dockerfile، ويستبعد مسارات من سياق البناء. وصياغته مأخوذة من صياغة .gitignore.
# الاعتماديات: تُعاد تثبيتها داخل الصورة، وتلك الموجودة على المضيف
# ربما مُصرَّفة لنظام تشغيل آخر
node_modules
__pycache__
vendor
.git # غالبًا مئات الميجابايتات، ولا فائدة منه إطلاقًا
# الأسرار والإعدادات المحلية: الخطر الأشد
.env
.env.*
*.pem
*.key
# النواتج، وتغطية الاختبارات، وملفات المحررات
dist
build
coverage
.vscode
.idea
Dockerfile*
docker-compose*.yml
ويعمل على ثلاثة مستويات في آن واحد. فهو يسرّع: إذ قد ينخفض السياق المنقول من مئات الميجابايتات إلى بضع مئات من الكيلوبايتات. ويحمي: فبدونه، يضمّن COPY . . ملف .env ومفاتيحكم الخاصة في طبقة من الصورة، ويظل استخراجها منها ممكنًا. ويثبّت التخزين المؤقت: فملف سجل يتغير عند كل تشغيل لن يُبطل طبقاتكم بعد الآن.
وتستحق حالة node_modules التأكيد: نسخه من المضيف ليس عديم الفائدة فحسب، بل محفوف بالمخاطر، لأن الوحدات الأصلية (native modules) تُصرَّف لمعمارية الجهاز ونظام تشغيله الذي ثُبّتت عليه.
CMD مقابل ENTRYPOINT
توفر CMD أمرًا افتراضيًا يمكن استبداله بالكامل بوسائط docker run. أما ENTRYPOINT فتثبّت الملف التنفيذي، وتُضاف إليه وسائط docker run.
مع CMD ["echo", "bonjour"] وحدها، يعرض docker run mon-image الكلمة «bonjour»، ويسرد docker run mon-image ls / محتويات الجذر: فقد استُبدل الأمر الافتراضي استبدالًا كاملًا. أما الثنائي ENTRYPOINT + CMD فيتصرف بشكل مختلف تمامًا، وهو النمط الموصى به للتطبيقات.
FROM alpine:3.20
ENTRYPOINT ["ping"]
CMD ["-c", "3", "localhost"]
docker run --rm mon-image # ping -c 3 localhost
docker run --rm mon-image -c 5 8.8.8.8 # ping -c 5 8.8.8.8
# لا يمكن استبدال ENTRYPOINT إلا صراحةً
docker run --rm --entrypoint sh -it mon-image
يبقى فارق أدق، له عواقب حقيقية جدًا: صيغة exec مقابل صيغة shell.
# صيغة exec (مصفوفة JSON): تكون العملية PID 1 مباشرةً
CMD ["node", "server.js"]
# صيغة shell (سلسلة نصية): ينفّذ Docker الأمر /bin/sh -c "node server.js"
CMD node server.js
في صيغة shell، تكون العملية PID 1 هي sh، وsh لا يمرّر SIGTERM إلى العملية الابنة. لذلك لا يتلقى تطبيقكم إشارة الإيقاف أبدًا: ينتظر docker stop عشر ثوانٍ ثم يقتله قسرًا. استخدموا صيغة exec دائمًا للعمليات طويلة الأمد. ولا تُفسَّر متغيرات البيئة فيها؛ فإذا احتجتم إليها فاذكروا ذلك صراحةً:
CMD ["sh", "-c", "node server.js --port=$PORT"]
البناء متعدد المراحل (Multi-stage): البناء في صورة والتسليم في أخرى
هذه أكبر وسيلة متاحة لتقليص حجم الصور. الفكرة: الأدوات اللازمة لبناء تطبيق — المصرِّف، وملفات الترويسة، واعتماديات التطوير — لا مكان لها في الصورة التي تشغّله.
يمكن أن يحتوي Dockerfile على عدة تعليمات FROM. تفتح كل منها مرحلة، وتنسخ المرحلة الأخيرة ملفات من مرحلة سابقة باستخدام COPY --from. وكل ما لم يُنسخ يُهمَل.
# syntax=docker/dockerfile:1
# ---------- المرحلة 1: البناء ----------
FROM node:22 AS builder
WORKDIR /app
COPY package.json package-lock.json ./
# هنا نثبّت كل شيء: TypeScript وأدوات التجميع وأدوات الاختبار
RUN npm ci
COPY . .
RUN npm run build # ينتج /app/dist
# ---------- المرحلة 2: التشغيل ----------
FROM node:22-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
# الناتج المصرَّف فقط. المصرِّف والمصادر
# واعتماديات التطوير تبقى خلفنا.
COPY --from=builder /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
ويكون الأثر أوضح مع لغة مصرَّفة، إذ يمكن للصورة النهائية الاستغناء عن أي بيئة تشغيل:
FROM golang:1.23 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# CGO معطَّل: ملف تنفيذي ساكن، بلا اعتماد على libc
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /bin/api ./cmd/api
# scratch فارغة حرفيًا: لا shell ولا libc ولا مدير حزم
FROM scratch
# الشهادات الجذرية ضرورية لأي استدعاء HTTPS صادر
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /bin/api /api
EXPOSE 8080
ENTRYPOINT ["/api"]
ننتقل من نحو 1 جيجابايت إلى بضعة ميجابايتات، وتتقلص سطح الهجوم بالنسبة نفسها: فمن دون shell أو أدوات مساعدة، لا يجد المهاجم الذي ينجح في تنفيذ شيفرة تقريبًا أي شيء تحت تصرفه. والوجه الآخر أنه لم يعد ممكنًا التشخيص عبر docker exec ... sh. وتمثل صور distroless من Google حلًّا وسطًا شائعًا: بلا shell، لكن ببيئة تشغيل كاملة.
ويمكن أيضًا استخدام المراحل بشكل منفصل، مما يجعل الملف نفسه صالحًا للاستخدام في التكامل المستمر:
docker build --target builder -t mon-api:build .
docker run --rm mon-api:build npm test
لا تعملوا أبدًا بصلاحيات root
افتراضيًا، تعمل عملية الحاوية بصلاحيات root. وهذا ليس root الخاص بالمضيف، لكن اجتماع هذه الصلاحيات المرتفعة مع ثغرة في النواة أو مع وحدة تخزين مُركَّبة من المضيف يكفي لتحويل ثغرة في التطبيق إلى اختراق للجهاز. والحل يقوم على تعليمتين.
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# مستخدم نظام، بلا كلمة مرور ولا shell لتسجيل الدخول
RUN groupadd --system --gid 1001 appgroup \
&& useradd --system --uid 1001 --gid appgroup --no-create-home appuser \
&& chown -R appuser:appgroup /app
# كل ما يلي يُنفَّذ بهذا المستخدم
USER appuser
EXPOSE 8000
CMD ["python", "-m", "app"]
على قاعدة Alpine، تختلف الأدوات: addgroup -S -g 1001 appgroup && adduser -S -u 1001 -G appgroup -H appuser. وتوفر بعض الصور الرسمية أصلًا حسابًا غير مميَّز: ففي صور node يوجد مستخدم node، ويكفي كتابة USER node.
هناك نقطتا انتباه. الترتيب مهم: تأتي USER بعد عمليات التثبيت التي تتطلب صلاحيات، ويسبق chown تغيير المستخدم، وإلا لن يتمكن التطبيق من الكتابة في مجلده الخاص. كما أن المستخدم غير المميَّز لا يستطيع الارتباط بمنفذ أقل من 1024: اجعلوا تطبيقكم يستمع على المنفذ 8080، ثم انشروه حيث تريدون باستخدام -p 80:8080.
ويكفي أمر واحد للتحقق: يجب أن يردّ docker run --rm mon-api:1.0 id بـ uid=1001(appuser) وليس uid=0(root).
الطبقات تراكمية: الأسرار لا تُمحى
خطأ يبدو بسيطًا، لكنه ليس كذلك أبدًا:
# لا تفعلوا هذا
COPY .npmrc /root/.npmrc
RUN npm ci
RUN rm /root/.npmrc # وهمي: يبقى الملف في طبقة COPY
الأمر rm ينشئ طبقة تُخفي الملف، لكنه لا يزيله من الصورة. يستطيع كل من يملك الصورة استخراج الطبقة السابقة واسترجاع الرمز المميز (token). والمنطق نفسه ينطبق على ARG TOKEN الذي يظهر في docker history.
يوفّر BuildKit الحل السليم: سرٌّ يُركَّب (mount) طوال تنفيذ أمر RUN فقط، ولا ينتمي إلى أي طبقة.
# syntax=docker/dockerfile:1
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
# الملف موجود طوال تنفيذ RUN فقط، ثم يختفي دون أي أثر
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci --omit=dev
COPY . .
CMD ["node", "server.js"]
docker build --secret id=npmrc,src=$HOME/.npmrc -t mon-api:1.0 .
البناء والوسم والتحقق
الاسم الذي تمنحه للصورة سيتيح لك أن تعرف، بعد ستة أشهر، أي commit يعمل في بيئة الإنتاج. لذلك ضع الوسوم (tags) عند البناء، وتحقق من ثلاثة أمور قبل اعتبار الصورة جاهزة.
# عدة وسوم لبناء واحد، أحدها مشتق من commit في Git
docker build -t mon-api:1.2.3 -t mon-api:"$(git rev-parse --short HEAD)" .
# الوسم لسجل خاص، وهو خطوة سابقة للـ push
docker tag mon-api:1.2.3 registre.exemple.com/equipe/mon-api:1.2.3
docker images mon-api # هل الحجم معقول؟
docker run --rm mon-api:1.2.3 id # هل تعمل دون صلاحيات؟
docker history mon-api:1.2.3 # أين ذهب الحجم؟
الخلاصة
- ينتج
Dockerfileسلسلة من الطبقات غير القابلة للتغيير، وليس سكربتًا خطيًا. رتّب التعليمات من الأكثر ثباتًا إلى الأكثر تغيرًا: التبعيات قبل الشيفرة، وإلا ستدفع ثمن إعادة تثبيت كاملة عند كل حرف تعدّله. - إبطال طبقة واحدة يُبطل جميع الطبقات التي تليها. الذاكرة المؤقتة (cache) سلسلة، ووضع
COPY . .في موضع مبكر جدًا يكسرها دائمًا. - ملف
.dockerignoreليس اختياريًا: فهو يسرّع البناء، ويثبّت الذاكرة المؤقتة، ويمنع ملفات.envومفاتيحك الخاصة من الانتهاء في طبقة قابلة للنشر. - يحدد
ENTRYPOINTالملف التنفيذي، بينما يوفّرCMDوسائط افتراضية يمكن تجاوزها. استخدم صيغة exec كي تصبح عمليتك هي PID 1 وتستقبلSIGTERM. - يُعد multi-stage أكبر رافعة متاحة: تبقى أدوات البناء في مرحلة
builder، ولا يُنسخ سوى الناتج النهائي. والمكسب المعتاد هو عامل 10 في الحجم وفي سطح الهجوم على حد سواء. - أضف دائمًا
USERغير مميز، بعد عمليات التثبيت وبعدchown، واجعل التطبيق يستمع على منفذ أعلى من 1024. - يظل الملف المحذوف في طبقة لاحقة قابلًا للاستخراج. بالنسبة إلى رموز البناء المميزة، استخدم
RUN --mount=type=secret، ولا تستخدم أبدًاCOPYثمrm، ولاARG.
نصيحة: بعد كل تعديل، شغّل
docker historyعلى صورتك. الطبقة الكبيرة على نحو غير طبيعي تشير في الغالب إلى ذاكرة مؤقتة للحزم نُسيت، أو إلىCOPYواسع أكثر من اللازم.
التعليقات
التعليقات مدعومة عبر GitHub Discussions
سجّل الدخول باستخدام GitHub للمشاركة في النقاش