طَبِّقْهاتعلّم المبدأ، وطبّقه بيدكمقرر 242 · أطر الويب
الدرس 22 · الوحدة 9: تجميع البرامج ونشرها

Docker والنشر السحابي

من جهازك إلى رابط يفتحه أي أحد ⏱ نحو 45 دقيقة.

بعد هذا الدرس ستستطيع أن:
  • تشرح الصورة والحاوية ولماذا نستخدم Docker.
  • تكتب Dockerfile متعدد المراحل لتطبيق React + Express.
  • تشغّل عدة خدمات بـ docker compose.
  • تجهّز الخادم للإنتاج وتنشره على منصة سحابية.

١«يعمل على جهازي!»

تطبيقك يعمل على جهازك لأن عليه إصدارًا معيّنًا من Node، ومكتبات مثبتة، ومتغيرات بيئة. على خادم آخر قد يختلف أي منها فيتعطل. الحاوية Container تحزم التطبيق مع كل ما يحتاجه (نظام تشغيل مصغّر، و Node، والمكتبات، والكود) في وحدة واحدة تعمل بالطريقة نفسها في أي مكان فيه Docker.

المصطلحالمعنىتشبيه
الصورة Imageقالب جاهز للقراءة فقط: النظام + Node + الكودوصفة مطبوخة ومجمّدة
الحاوية Containerنسخة تعمل من الصورةطبق مسخّن من الوصفة
Dockerfileتعليمات بناء الصورة خطوة بخطوةالوصفة نفسها
السجل Registryمستودع صور مثل Docker Hubمكتبة الوصفات

٢Dockerfile لتطبيقنا الكامل

تطبيقنا جزآن: واجهة React تُبنى بـ Vite، وخادم Express يقدّم الواجهة المبنية والـ API (كما في الدرس السابق). نستخدم بناءً متعدد المراحل: مرحلة فيها أدوات البناء، ثم صورة نهائية صغيرة فيها ما يلزم للتشغيل فقط:

shop/
├── client/          ← مشروع Vite
├── server/          ← Express (server.js, routes/, models/)
├── Dockerfile
└── .dockerignore
# ---------- المرحلة 1: بناء الواجهة ----------FROM node:24-alpine AS clientWORKDIR /app/clientCOPY client/package*.json ./RUN npm ci                          # تثبيت دقيق من package-lock.jsonCOPY client/ ./RUN npm run build                   # ← /app/client/dist # ---------- المرحلة 2: صورة التشغيل ----------FROM node:24-alpineWORKDIR /appENV NODE_ENV=productionCOPY server/package*.json ./RUN npm ci --omit=dev               # مكتبات التشغيل فقطCOPY server/ ./COPY --from=client /app/client/dist ./distUSER node                           # لا تشغّل التطبيق بصلاحيات rootEXPOSE 3000HEALTHCHECK CMD wget -qO- http://localhost:3000/health || exit 1CMD ["node", "server.js"]
node_modules**/node_modulesclient/dist.env.git*.log
التعليمةوظيفتها
FROMالصورة الأساس: Node 24 (إصدار طويل الدعم LTS) على Alpine الصغير
WORKDIRمجلد العمل داخل الحاوية
COPY package*.json ثم RUN npm ciنسخ ملفات الاعتماديات أولًا: إن لم تتغير يُعاد استخدام طبقة التثبيت المخزّنة فيكون البناء أسرع
ENVمتغير بيئة ثابت في الصورة
EXPOSEتوثيق المنفذ الذي يستمع عليه التطبيق
CMDالأمر الذي يعمل عند تشغيل الحاوية

٣البناء والتشغيل

🖥 أوامر Docker الأساسية
$ docker build -t shop:1.0 .$ docker images shop$ docker run -d --name shop -p 8080:3000 -e JWT_SECRET=طويل-وعشوائي shop:1.0$ docker ps$ docker logs -f shop$ docker stop shop && docker rm shop
الأمرالمعنى
-t shop:1.0اسم الصورة ووسمها (الإصدار)
-dتشغيل في الخلفية
-p 8080:3000منفذ جهازك 8080 ← منفذ الحاوية 3000؛ افتح http://localhost:8080
-e KEY=valueمتغير بيئة للحاوية
docker logs -fمتابعة ما يطبعه التطبيق (console.log)

٤عدة خدمات معًا: docker compose

التطبيق الحقيقي يحتاج قاعدة بيانات أيضًا. ملف compose.yaml يصف الخدمات وشبكتها ومتغيراتها، ويشغّلها كلها بأمر واحد. وفي Sequelize لا يتغير إلا الاتصال: من SQLite إلى PostgreSQL:

services:  app:    build: .    ports: ["8080:3000"]    environment:      DATABASE_URL: postgres://shop:secret@db:5432/shop      JWT_SECRET: ${JWT_SECRET}    depends_on: [db]  db:    image: postgres:17-alpine    environment:      POSTGRES_USER: shop      POSTGRES_PASSWORD: secret      POSTGRES_DB: shop    volumes: ["dbdata:/var/lib/postgresql/data"]   # البيانات تبقى بعد إيقاف الحاويةvolumes:  dbdata:
// db.js في الإنتاجconst sequelize = process.env.DATABASE_URL  ? new Sequelize(process.env.DATABASE_URL, { logging: false })     // PostgreSQL  : new Sequelize({ dialect: 'sqlite', storage: 'dev.sqlite' });    // جهازك
🖥 الطرفية
$ docker compose up -d --build$ docker compose logs app$ docker compose down

٥خادم جاهز للإنتاج

قبل النشر يحتاج الخادم أربعة أشياء: أن يقرأ المنفذ من البيئة (المنصات تحدده هي)، وأن يعرف أنه في الإنتاج، وأن يوفّر مسار فحص صحة تسأله المنصة دوريًّا، وألا يكشف تفاصيل الأخطاء. جرّب الفرق بين وضعي التطوير والإنتاج في صفحة الخطأ الافتراضية:

const express = require('express');const app = express();const PORT = process.env.PORT || 3000; app.get('/health', (req, res) => {  res.json({ status: 'ok', env: app.get('env'), uptime: Math.round(process.uptime()) });}); app.get('/boom', () => { throw new Error('تفاصيل داخلية سرية'); }); app.listen(PORT, () => console.log(`يعمل على ${PORT} في وضع ${app.get('env')}`));

شغّلنا الخادم هنا بالبيئة PORT=8080 و NODE_ENV=production (انظر الطرفية). في الإنتاج يرد Express على الخطأ بعبارة عامة «Internal Server Error» فقط ويطبع التفاصيل في السجل؛ أما إن نسيت ضبط NODE_ENV فيعمل في وضع التطوير ويعرض مكدّس الخطأ كاملًا للزائر.

البندلماذا
PORT من البيئةالمنصة تحدد المنفذ
NODE_ENV=productionأداء أفضل في Express وإخفاء تفاصيل الأخطاء
/healthالمنصة تعيد تشغيل الخدمة إن فشل
الأسرار في متغيرات المنصةلا في الكود ولا في الصورة
قاعدة بيانات مُدارةملفات الحاوية تُمحى مع كل نشر
HTTPSالمنصات توفره تلقائيًّا بشهادة مجانية
السجلات Logsconsole.log يظهر في لوحة المنصة

٦النشر على منصة سحابية

منصات «المنصة كخدمة» PaaS تأخذ مستودع GitHub (أو Dockerfile) وتبنيه وتشغّله وتعطيك رابطًا بشهادة HTTPS. الخطوات العامة متشابهة:

  1. ارفع المشروع إلى GitHub (بلا .env ولا node_modules).
  2. أنشئ خدمة ويب جديدة في المنصة واربطها بالمستودع.
  3. حدّد أمر البناء والتشغيل، أو اختر أنها تُبنى من Dockerfile.
  4. أضف متغيرات البيئة: NODE_ENV و JWT_SECRET و DATABASE_URL.
  5. أنشئ قاعدة بيانات مُدارة (PostgreSQL) من المنصة نفسها وانسخ عنوانها.
  6. كل دفع push جديد إلى الفرع الرئيسي يعيد النشر تلقائيًّا.
ما تنشرهخيارات شائعةملاحظة
الواجهة وحدها (ملفات dist)Cloudflare Pages، Netlify، Vercel، GitHub Pagesاستضافة ثابتة سريعة، وغالبًا مجانية للمشاريع الصغيرة
خادم Express (أو الكل معًا)Render، Railway، Fly.io، أو خادم افتراضي VPSالخطط المجانية محدودة وتتغير كثيرًا
قاعدة البياناتPostgreSQL مُدارة من المنصة، أو Neon، أو Supabaseلا تعتمد على ملف SQLite داخل الحاوية

توقّع وتحقّق

ما الفرق بين الصورة والحاوية؟

وصفة وطبق.

ما معنى -p 8080:3000 ؟

host:container.

لماذا ننسخ package.json ونثبّت قبل نسخ بقية الكود؟

طبقات التخزين.

أين توضع JWT_SECRET عند النشر؟

الأسرار خارج الصورة.

الخلاصة

تحدٍّ برمجي: جاهز للإنتاج

متوسط

خادم صغير كُتب للتطوير. عدّله ليصبح جاهزًا للنشر على منصة سحابية (يشغّله التحقق بالبيئة PORT=8080 و NODE_ENV=production).

المطلوب:
  • يستمع على process.env.PORT (أو 3000 إن لم يوجد)، ويطبع عند البدء listening on ثم المنفذ.
  • GET /health يرد {"status": "ok", "uptime": رقم}.
  • السر JWT_SECRET يُقرأ من البيئة، ولا يُكتب نصًّا في الكود.
  • وسيط أخطاء في النهاية يرد {"error": "خطأ في الخادم"} بالحالة 500 دون كشف رسالة الخطأ الأصلية.
👀 اعرض الاستجابات المطلوبة
✓ التحقق الآلي: يشغّل المختبر خادمك من جديد ويرسل إليه هذه الطلبات بالترتيب ثم يفحص الردود
  1. GET /health
  2. GET /api/data
  3. GET /api/fail
  • الخادم يعمل ويرد على كل الطلبات بلا أخطاء غير معالَجة
  • يستمع على المنفذ من البيئة (8080)
  • /health فيه status = ok
  • … و uptime رقم
  • السر يُقرأ من البيئة ويعمل
  • الخطأ يرد برسالة عامة
  • … ولا يكشف الرسالة الأصلية
  • لا سر مكتوبًا في الكود

💡 تلميح 1
const PORT = process.env.PORT || 3000;
💡 تلميح 2
وسيط الأخطاء بأربعة معاملات يطبع err.message في السجل ويرسل رسالة عامة.

حاول بنفسك أولًا؛ للمسألة أكثر من حلّ صحيح.

const express = require('express');const app = express();const SECRET = process.env.JWT_SECRET;const PORT = process.env.PORT || 3000; app.get('/health', (req, res) => res.json({ status: 'ok', uptime: process.uptime() }));app.get('/api/data', (req, res) => res.json({ items: [1, 2, 3], signedWith: SECRET.length }));app.get('/api/fail', () => { throw new Error('كلمة سر قاعدة البيانات: abc'); }); app.use((err, req, res, next) => {  console.error(err.message);  res.status(500).json({ error: 'خطأ في الخادم' });}); app.listen(PORT, () => console.log('listening on ' + PORT));
انتقل إلى تقييم الدرساختيار من متعدد، وصح وخطأ، وأكمل الفراغ، وتتبّع الناتج.ابدأ التقييم ←

📝 معلم؟ أرسل ملاحظة للمؤلف عن هذا الدرس