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

موازنة الحمل بـ Nginx كـ Load Balancer

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


المقدمة

في المقالة السابقة مقدمة عن Docker Compose قمنا بتشغيل مشروع Node.js مع MySQL من خلال ملف docker-compose.yml واحد فقط، وتخلصنا من الأوامر الطويلة والمتكررة
بحيث أنه كان لدينا Server واحد فقط لكل من Node.js و MySQL، وكان كل منهما يعمل في container واحد منفرد بذاته
وربطناهم ببعضهم البعض من خلال أوامر الـ Docker Compose

في هذه المقالة سنأخذ نفس المشروع، لكن سنضيف له طبقة جديدة مهمة جدًا في عالم التطبيقات الحقيقية، وهي طبقة الـ Load Balancing باستخدام Nginx كـ Reverse Proxy
وبالطبع سنشرح ما هو الـ Load Balancing، وما هو الـ Reverse Proxy، ولماذا نحتاجهم، وكيف نستخدم Nginx لهذا الدور، وكل هذا من خلال مثال عملي بسيط

قبل أن نبدأ، دعنا نتذكر كيف كان شكل مشروعنا في المقالة السابقة

استرجاع المثال الذي بنيناه في المقالة السابقة

أريدك أن تتذكر ملف الـ docker-compose.yml الذي قمنا بإنشائه في المقالة السابقة، والذي كان يحتوي على خدمتين فقط هما mysql-service و nodejs-service

services:
  mysql-service:
    image: mysql:9.6.0
    container_name: mysql-container
    environment:
      - MYSQL_ROOT_PASSWORD=tabarani-very-secret
      - MYSQL_DATABASE=tabarani-app
    volumes:
      - mysql-tabarani-db:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      timeout: 20s
      retries: 10

  nodejs-service:
    build: .
    container_name: nodejs-container
    ports:
      - "3000:3000"
    env_file:
      - .env
    depends_on:
      mysql-service:
        condition: service_healthy

volumes:
  mysql-tabarani-db:

هنا كان لدينا خدمتين فقط الـ mysql-service و nodejs-service
الـ nodejs-service كان يجهز Server الـ Node.js ويجعله يعمل على port رقم 3000
وكان يربط نفسه بقاعدة بيانات MySQL من خلال الـ environment variables التي قرأناها من ملف .env

# APP Port
PORT=3000

# Database configuration
DB_HOST=mysql-service
DB_PORT=3306
DB_USER=root
DB_PASSWORD=tabarani-very-secret
DB_NAME=tabarani-app

لاحظ أن في ملف الـ .env قمنا بوضع DB_HOST=mysql-service وليس DB_HOST=mysql-container
لأننا حين نستخدم Docker Compose نستطيع الوصول إلى أي Service من خلال اسمه أو من خلال اسم الـ container
لكن من الأفضل أن نستخدم اسم الـ Service في كل حال لأن الـ Service قد تضم أكثر من container مختلفة بأسماء تلقائية، وليس container واحد فقط

وهذا ما سنفعله لاحقًا في هذه المقالة عندما نكرر نسخ الـ nodejs-service إلى أكثر من نسخة، وكل نسخة ستمثل container مختلف باسم تلقائي مميز يتم إنشاؤه من قبل Docker Compose
وبالتالي سيكون لدينا أكثر من container مختلفة بأسماء تلقائية
وعندما نستخدم اسم الـ Service فإننا نضمن أن كل النسخ والـ containers الخاصة بهذا الـ Service ستكون متاحة لنا من خلال اسم الـ Service نفسه بغض النظر عن عدد النسخ أو الـ containers التي نملكها داخل هذا الـ Service وما هي أسمائهم


ثم لدينا ملف الـ Dockerfile الذي نستخدمه لعمل build للـ nodejs-service الذي يحتوي على إعدادات تشغيل Server الـ Node.js

FROM node:25-alpine

WORKDIR /app

COPY package*.json .

RUN npm install

COPY . .

ENV PORT=3000
EXPOSE 3000

ENTRYPOINT ["npm", "start"]

ثم لدينا mysql-service الذي يستخدم الـ image الرسمي لـ MySQL، ويجهز قاعدة البيانات، ويضع كلمة سر قوية جدًا جدًا وهي tabarani-very-secret، ويضع اسم قاعدة البيانات tabarani-app، ويستخدم volumes لتخزين البيانات بشكل دائم
ويوجد healthcheck للتأكد من أن قاعدة البيانات جاهزة قبل أن يبدأ nodejs-service في محاولة الاتصال بها

و nodejs-service يعتمد على mysql-service، ويبدأ فقط عندما تكون قاعدة البيانات جاهزة عن طريق depends_on مع شرط service_healthy


وأخيرًا ملف الكود الخاص بتطبيق Node.js الخاص بنا، وهو تطبيق بسيط جدًا يقدم API للتعامل مع المنتجات في قاعدة البيانات

const express = require("express");
const mysql = require("mysql2/promise");
const os = require("os");

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

const dbConfig = {
  host: process.env.DB_HOST || "localhost",
  port: process.env.DB_PORT || 3306,
  user: process.env.DB_USER || "root",
  password: process.env.DB_PASSWORD || "tabarani-very-secret",
  database: process.env.DB_NAME || "tabarani-app",
};

let connection;

async function connectDB() {
  connection = await mysql.createConnection(dbConfig);
  await connection.execute(`
    CREATE TABLE IF NOT EXISTS products (
      id INT AUTO_INCREMENT PRIMARY KEY,
      name VARCHAR(255) NOT NULL,
      price DECIMAL(10, 2) NOT NULL
    )
  `);
  console.log("Connected to MySQL and products table is ready");
}

app.use(express.json());

app.get("/", (req, res) => {
  res.json({
    message: `Hello from the server running on host ${os.hostname()}`,
  });
});

app.get("/products", async (req, res) => {
  const [rows] = await connection.execute("SELECT * FROM products");
  res.json({
    products: rows,
    message: `This response is from the server running on host ${os.hostname()}`,
  });
});

app.post("/products", async (req, res) => {
  const { name, price } = req.body;
  const [result] = await connection.execute(
    "INSERT INTO products (name, price) VALUES (?, ?)",
    [name, price],
  );
  res.status(201).json({
    id: result.insertId,
    name,
    price,
    message: `Product created on server running on host ${os.hostname()}`,
  });
});

connectDB().then(() => {
  app.listen(port, () => {
    console.log(
      `Server is running on host ${os.hostname()} at port ${port}`,
    );
  });
});

لا داعي لفهم كل شيء في هذا الكود، المهم فقط أن نعلم أنه تطبيق بسيط يتصل بقاعدة بيانات MySQL ويقدم بعض الـ endpoints للتعامل مع المنتجات مثل GET /products و POST /products

لاحظ أن الكود يستخدم مكتبة os لعرض اسم الـ host في كل response، لكي أوضح اسم host الذي يعمل عليه الـ Server، وهذا سيكون مهمًا جدًا في الخطوات القادمة عندما نكرر نسخ الـ nodejs-service
والـ os.hostname() سيمثل الـ id الخاص بالـ container الذي يعمل عليه الـ Server، وهذا يعني أنه إذا كان لدينا ثلاث نسخ من الـ nodejs-service، كل نسخة ستعمل على container مختلف، وبالتالي كل نسخة ستعطيك id الخاص بالـ container التي تعمل عليه
وبالتالي عندما نرسل طلبات إلى الـ Server من الخارج، سنتمكن من رؤية كيف يتم توزيع الطلبات بين النسخ المختلفة من خلال الـ id الذي يظهر في كل response

بالطبع هذا فقط لغرض التجربة والتوضيح في هذه المقالة، في الواقع العملي لا تقم بعرض أي معلومة عن أي container أو host في كل response


هذا تلخيص لشكل المشروع الذي بنيناه في المقالة السابقة، والآن سنبدأ فقط بتكبير المثال عن طريق تكرار نسخ الـ nodejs-service ليعمل على أكثر من نسخة وكل نسخة ستكون Server منفرد بذاته داخل container مختلف لكن على نفس الـ port الداخلي 3000 ثم نضيف طبقة Nginx كـ Reverse Proxy و Load Balancer لتوزيع الطلبات بين هذه النسخ


وبالطبع لا أستطيع أن أنسى جمهوري العزيز الذي يحب الرسوم التوضيحية الخاصة بي
إليك الرسمة التوضيحية التي توضح كيف كان شكل مشروعنا في المقالة السابقة
هذا سيساعد على تخيل المشروع بشكل أفضل وكيف سيبدو بعد إضافة طبقة الـ Load Balancing و Nginx

+-------------------------------------------------------------------+
|                       Your Device (Host)                          |
|                                                                   |
| localhost:3000 ----+                                              |
|                    |                                              |
|                Port Mapping                                       |
|                    |                                              |
|  +-----------------|-------------------------------------------+  |
|  |                 |    Private Network (Bridge)               |  |
|  |                 |    Created by Docker Compose              |  |
|  |  nodejs-service V                 mysql-service             |  |
|  |  +----------------------+         +----------------------+  |  |
|  |  | nodejs-container     |         | mysql-container      |  |  |
|  |  | IP: 172.18.0.3       | ------> | IP: 172.18.0.4       |  |  |
|  |  | Port: 3000           |   DNS   | Port: 3306           |  |  |
|  |  |                      |         |                      |  |  |
|  |  | connects using name: |         | MySQL Server         |  |  |
|  |  | mysql-service:3306   |         |                      |  |  |
|  |  +----------------------+         +----------------------+  |  |
|  |                                     |                       |  |
|  |                                     |    mysql-tabarani-db  |  |
|  |                                     |    +--------------+   |  |
|  |                                     |    |              |   |  |
|  |                                     |    |              |   |  |
|  |                                     +--> |              |   |  |
|  |                                          |              |   |  |
|  |                                          +--------------+   |  |
|  |                                                             |  |
|  +-------------------------------------------------------------+  |
|                                                                   |
+-------------------------------------------------------------------+

أولًا على جهازنا الخاص والذي نسميه الـ Host في عالم الـ Docker
لدينا nodejs-service الذي يعمل على port رقم 3000، والـ mysql-service الذي يعمل على port رقم 3306
وبسبب أن الـ nodejs-service والـ mysql-service يعملان داخل نفس الـ Docker Network، فإنهما يستطيعان التواصل مع بعضهم البعض من خلال أسماء الـ services الخاصة بهم
وبالتالي الـ nodejs-service يربط نفسه بقاعدة بيانات MySQL من خلال اسم الـ service الخاص بـ mysql-service و Docker هو الذي يقوم بعملية الـ DNS الداخلي لترجمة هذا الاسم إلى الـ IP الخاص بالـ container الذي يعمل عليه mysql-service

ولاحظ أن mysql-service لا يحتاج لعمل الـ port mapping بينه وبين الـ host لأنه لا يحتاج أن يكون متاحًا من الخارج، بل هو فقط يحتاج أن يكون متاحًا داخل الـ Docker Network لكي يتواصل مع nodejs-service فقط لا أكثر
والـ nodejs-service هو الذي يحتاج لعمل الـ port mapping بينه وبين الـ host لكي يكون متاحًا من الخارج، وبالتالي يستطيع أي Client الوصول إليه من خلال الـ host
وأخيرًا لدينا volume خاص بقاعدة البيانات mysql-tabarani-db الذي يربط بين مجلد داخل الـ container الخاص بـ mysql-service

كل هذا تم بواسطة ملف docker-compose.yml واحد فقط، وهذا بالطبع من جماليات الـ Docker Compose

ماذا لو كررنا Server الـ Node.js إلى أكثر من نسخة ؟

لنفكر قليلًا ونسأل أنفسنا سؤال ماذا لو قررنا تشغيل ثلاث نسخ من الـ nodejs-service بدل نسخة واحدة ؟
أو لماذا نريد أن يكون لدينا أكثر من نسخة من Server الـ nodejs-service يعمل في نفس الوقت ؟

حسنًا سبب تكرار نسخ التطبيق هو ببساطة تحمل الضغط
كلما زاد عدد المستخدمين الذين يستخدمون تطبيقك، زاد الضغط على Server الخاص بك، وإذا كان لديك نسخة واحدة فقط، فقد يحدث أن يتوقف عن الاستجابة أو يصبح بطيئًا جدًا تحت الضغط
لذلك من الأفضل أن يكون لديك أكثر من نسخة من Server الخاص بك، بحيث إذا زاد الضغط، يمكن للنسخ الأخرى أن تساعد في التعامل مع هذا الضغط وتوزيعه بشكل أفضل


هنا يظهر سؤال آخر مهم جدًا
عند تكرار نسخ الـ nodejs-service على سبيل المثال إلى ثلاث نسخ هكذا سيكون لديك ثلاث containers مختلفة تعمل كل منها نسخة من الـ nodejs-service، وكل نسخة تعمل على نفس الـ port الداخلي 3000 داخل الـ container الخاص بها
كيف سيختار الـ Frontend النسخة التي سيرسل إليها الطلب ؟
وهل من المنطقي أن يقوم الـ Frontend بنفسه بتوزيع الطلبات بين هذه النسخ ويتعامل مع النسخة المتوقفة أو البطيئة

الإجابة ببساطة هذا ليس دور الـ Frontend أصلًا
لأن الـ Frontend ما هو إلا مجرد Client و الـ client لا يجب أن يعرف تفاصيل البنية التحتية للتطبيق، ولا يجب أن يكون مسؤولًا عن توزيع الطلبات بين نسخ التطبيق
وفوق هذا الـ client قد يكون Web Browser أو Mobile App أو حتى Third Party Service، وكل هذه لا يجب أن تهتم بكيفية توزيع الطلبات على الـ Server الخاص بك
وهل لو قررت زيادة عدد النسخ إلى 5 أو 10 أو 100 هل ستجعل كل client يعرف كل هذه التفاصيل ويقوم بتوزيع الطلبات بنفسه ؟ نحن نحتاج إلى Server وسيط يكون جزء من التحتية للـ Backend
وهذا الـ Server الوسيط هو الذي سيستقبل كل الطلبات أولًا، ثم يوزعها على النسخ الداخلية للـ nodejs-service بناءً على سياسة توزيع معينة، ويقوم أيضًا بالتعامل مع النسخ المتوقفة أو البطيئة، ويضمن أن كل الطلبات تصل إلى نسخة صحية من التطبيق

هذا الـ Server الوسيط هو الذي يعمل كـ Reverse Proxy وكـ Load Balancer
تستطيع أن تقوم بنفسك ببناء هذا الـ Server الوسيط باستخدام أي لغة برمجة أو إطار عمل
لكن في واقعنا العملي فنحن نستخدم أدوات جاهزة متخصصة لهذا الدور، وأشهرها هو Nginx

وفي مثالنا سنستخدم Nginx لهذا الدور
بمعنى أن الـ Frontend سيتعامل فقط مع port واحد خاص بـ Nginx، وسنلغي الثلاثة published ports الخاصة بنسخ الـ nodejs-service


سنصادف مصطلح جديد هنا وهو الـ Replicas
والـ Replica هو نسخة من نفس الـ Service، بمعنى أنه إذا قررت تشغيل ثلاث نسخ من الـ nodejs-service، فهذا يعني أن لديك ثلاث replicas من نفس الـ Service
وكل replica تعمل في container مختلف، لكن كلها تكون نسخة من نفس الـ Service، وتشارك نفس الكود ونفس الإعدادات ولنتفق في هذه المقالة أنه حين أقول نسخة أو Replica فأنا أعني نفس الشيء، وهو نسخة من نفس الـ Service
وكل نسخة أو Replica تمثل container واحد

ما هو الـ Load Balancing ؟

كما قلنا فالـ Load Balancing هو ببساطة توزيع الطلبات على أكثر من نسخة من نفس التطبيق بدل إرسال كل الطلبات إلى نسخة واحدة فقط

فعلى سبيل المثال تخيل أن لديك مطعم واحد فقط فريد من نوعه في المدينة
في الأيام العادية المطعم يتعامل مع الطلبات بشكل طبيعي ولا توجد مشكلة
لكن في أيام الأجازات على سبيل المثال يصل عدد كبير من الزبائن ويتكون طابور طويل جدًا
المطعم الواحد لا يستطيع التعامل مع كل هذه الطلبات بسرعة والزبائن ينتظرون وبعضهم قد يغادر بسبب طول الانتظار

الحل؟ نفتح أكثر من فرع من المطعم في أماكن مختلفة في المدينة، وكل فرع يقدم نفس الخدمة ونفس الجودة
ونقوم بعمل تطبيق على الهاتف أو موقع إلكتروني يوجه الزبائن إلى أقرب فرع متاح لهم

هذا التطبيق الخاص بحجز الطلبات وتوجيه الزبائن لأقرب فرع هو الـ Load Balancer

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

وهذا بالضبط ما سنفعله في هذه المقالة
سنشغل ثلاث نسخ من الـ nodejs-service ونستخدم Nginx كـ Load Balancer يوزع الطلبات عليها

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

فبدلًا من أن يكون عندك Server واحد يتحمل كل شيء، يصبح عندك عدة نسخ تتشارك الحمل

محاولة نسخ الـ nodejs-service إلى أكثر من نسخة

لدينا طريقتين لتشغيل أكثر من نسخة من نفس الـ Service في Docker Compose
الطريقة الأولى هي تحديد عدد النسخ في ملف docker-compose.yml باستخدام replicas

services:
  ...
  nodejs-service:
    ...
    deploy:
      replicas: 3
    ...

هنا قمنا بإضافة قسم يدعى deploy داخل الـ nodejs-service، ووضعنا فيه replicas: 3 لكي نخبر Docker Compose أننا نريد تشغيل ثلاث نسخ من هذا الـ Service عندما نقوم بتشغيل الأمر docker compose up -d

والطريقة الثانية هي تشغيل الأمر --scale أثناء تشغيل docker compose up -d --scale nodejs-service=3 لتحديد عدد النسخ أثناء التشغيل

يمكننا استخدام أي طريقة من الطريقتين، أو يمكننا استخدام كلا الطريقتين معًا، بحيث يكون لدينا عدد نسخ افتراضي في ملف docker-compose.yml عن طريق replicas، ونستطيع تعديله أثناء التشغيل باستخدام --scale إذا أردنا


لكن قبل أن نتمكن من استخدام أي من الطريقتين، علينا أن نجهز ملف docker-compose.yml ليتناسب مع هذا التغيير
أولًا لنرى الملف الحالي الذي نملكه:

services:
  mysql-service:
    image: mysql:9.6.0
    container_name: mysql-container
    environment:
      - MYSQL_ROOT_PASSWORD=tabarani-very-secret
      - MYSQL_DATABASE=tabarani-app
    volumes:
      - mysql-tabarani-db:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      timeout: 20s
      retries: 10

  nodejs-service:
    build: .
    container_name: nodejs-container
    ports:
      - "3000:3000"
    env_file:
      - .env
    depends_on:
      mysql-service:
        condition: service_healthy

volumes:
  mysql-tabarani-db:

هنا سنبقى إعدادات الـ mysql-service كما هى دون تعديل
نحن نريد نسخ الـ nodejs-service فقط، أما الـ mysql-service فلن نقوم بنسخه

الآن بالنسبة للـ nodejs-service، سنحتاج إلى تعديل بعض الأشياء في إعداداته لكي نتمكن من تشغيل أكثر من نسخة منه بدون مشاكل
أول شيء قبل أن نعدل لنرى ماذا سيحدث إذا قمنا بنسخ الـ nodejs-service إلى ثلاث نسخ بدون أي تعديل في ملف docker-compose.yml

سنقوم بتشغيل الأمر docker compose up -d --scale nodejs-service=3
لاحظ أننا قمنا بتمرير الخيار --scale لتحديد عدد النسخ أثناء التشغيل، وهذا يعني أننا نريد تشغيل ثلاث نسخ من الـ nodejs-service
والـ --scale يحتاج إلى اسم الـ service الذي نريد نسخه، وهو في حالتنا nodejs-service، ثم نضع علامة = ثم عدد النسخ الذي نريده، وهو 3 في هذا المثال

> docker compose up -d --scale nodejs-service=3
[+] Running 3/3
 ✔ Network nginx-load-balancer_default             Created                                                                                                                                                       0.1s
 ✔ Volume "nginx-load-balancer_mysql-tabarani-db"  Created                                                                                                                                                       0.0s
 ✔ Container mysql-container                       Created                                                                                                                                                       0.2s
WARNING: The "nodejs-service" service is using the custom container name "nodejs-container". Docker requires each container to have a unique name. Remove the custom name to scale the service.

أولًا ملف docker-compose.yml موجود في مجلد يدعى nginx-load-balancer لهذا ستجده يضع اسم المجلد قبل أي شيء سواء في اسم الـ network أو في اسم الـ volume أو في اسم الـ container
على أي حال لاحظ أنه قام بإنشاء الـ network ثم قام بإنشاء الـ volume ثم قام بإنشاء الـ container الخاص بـ mysql-service بنجاح
لكن عندما حاول تشغيل نسخ الـ nodejs-service الثلاثة، ظهر تحذير يقول أن خدمة nodejs-service تستخدم اسم container مخصص وهو nodejs-container، و Docker يتطلب أن يكون لكل container اسم فريد، لذلك يجب إزالة الاسم المخصص لكي نتمكن من تشغيل أكثر من نسخة من نفس الخدمة

حسنًا بسبب أننا قمنا بعمل أكثر من نسخة من الـ nodejs-service فكل نسخة ستكون container مختلفة
فعندما قلنا أننا نريد عمل ثلاث نسخ من الـ nodejs-service، فهكذا سنملك ثلاث containers
بالتالي لو حددنا container_name ثابت في الـ Service فكل نسخة ستحاول أن تأخذ نفس اسم الـ container وهذا غير مسموح به في Docker لأن كل container يجب أن يكون لها اسم مميز

لذا سنحتاج إلى إزالة container_name لكي نترك لـ Docker Compose مهمة تسمية الـ containers تلقائيًا، بالتالي لن نواجه أي تعارض في الأسماء بين النسخ المختلفة، وكل نسخة ستحصل على اسم container مختلف يتم إنشاؤه تلقائيًا من قبل Docker Compose

حسنًا لنقم بإزالة الـ container_name من الـ nodejs-service في ملف docker-compose.yml ثم نقوم بعمل docker compose down -v لكي نبدأ من جديد

> docker compose down -v
[+] Running 3/3
 ✔ Container mysql-container                     Removed                                                                                                                                                         0.0s
 ✔ Volume nginx-load-balancer_mysql-tabarani-db  Removed                                                                                                                                                         0.0s
 ✔ Network nginx-load-balancer_default           Removed                                                                                                                                                         0.6s

ثم نعيد تشغيل الأمر docker compose up -d --scale nodejs-service=3

> docker compose up -d --scale nodejs-service=3
[+] Running 5/6
 ✔ Network nginx-load-balancer_default             Created                                                                                                                                                                                                       0.0s
 ✔ Volume "nginx-load-balancer_mysql-tabarani-db"  Created                                                                                                                                                                                                       0.0s
 ✔ Container mysql-container                       Healthy                                                                                                                                                                                                      30.6s
 ✔ Container nginx-load-balancer-nodejs-service-3  Started                                                                                                                                                                                                      30.7s
 ✔ Container nginx-load-balancer-nodejs-service-1  Created                                                                                                                                                                                                       0.2s
 - Container nginx-load-balancer-nodejs-service-2  Starting                                                                                                                                                                                                     30.7s
Error response from daemon: Ports are not available: exposing port TCP 0.0.0.0:3000 -> 127.0.0.1:0: listen tcp 0.0.0.0:3000: bind: Only one usage of each socket address (protocol/network address/port) is normally permitted.

لاحظ أنه قام بإنشاء الـ network والـ volume والـ container الخاص بـ mysql-service دون مشاكل
ثم قام بإنشاء الثلاث نسخ من الـ nodejs-service، وقام بتشغيل nginx-load-balancer-nodejs-service-3 دون مشاكل
لكن عندما حاول تشغيل nginx-load-balancer-nodejs-service-1 و nginx-load-balancer-nodejs-service-2، ظهر خطأ يقول أن الـ ports غير متاحة، لا يمكن فتح port رقم 3000 على الـ host لأن هناك container أخرى بالفعل تستخدم هذا الـ port

وهنا يظهر لنا نفس المشكلة مع الـ ports أيضًا بحيث أننا لو حاولنا عمل port mapping ثابت في الـ service بالتالي كل container من الثلاثة سيحاول أن يفتح نفس الـ port رقم 3000 على جهازنا الشخصي
وهذا غير ممكن لأن لا يمكن أن يكون لدينا أكثر من container يستخدم نفس الـ port في نفس الوقت على جهازنا الشخصي

+-------------------------------------------------------------------------+
| Host localhost:3000                                                     |
|   |                                                                     |
|   |                                                                     |
|   +--> nodejs-container ports 3000:3000 (Replica No. 1) ---> (Success)  |
|   +--> nodejs-container ports 3000:3000 (Replica No. 2) ---> (Conflict) |
|   +--> nodejs-container ports 3000:3000 (Replica No. 3) ---> (Conflict) |
|                                                                         |
| Replica No. 1 -> Successfully created                                   |
| Replica No. 2 -> Name and host port already in use by another container |
| Replica No. 3 -> Name and host port already in use by another container |
+-------------------------------------------------------------------------+

هذه الرسمة توضح شكل المشكلة التي سنواجهها إذا قمنا بعمل أكثر من نسخة من الـ nodejs-service مع وجود container_name ثابت و ports ثابت
كل نسخة ستحاول أن تأخذ نفس اسم الـ container ونفس الـ port على الـ host
وهذا سيجعل أول نسخة تنجح في الإنشاء، بينما النسخ الأخرى ستفشل بسبب تعارض الأسماء وتعارض الـ ports

أيضًا بسبب أننا سنستخدم Nginx كـ Reverse Proxy و Load Balancer فلا حاجة أساسًا لفتح port mapping ما بين الـ nodejs-service والـ host
لأن الـ Nginx هو الذي سيتعامل مع كل الطلبات التي تأتي من الخارج
كل ما في الأمر أننا سنمنع الـ nodejs-service من عمل أي port mapping مع الـ host، وبالتالي لن يكون هناك أي published ports خاصة بأي نسخة من الـ nodejs-service
وسنجعل فقط الـ nginx-service هو الذي يفتح port بينه وبين الـ host، وهذا هو الـ port الذي سيتعامل معه كل الـ clients من الخارج
بالتالي الـ Nginx سيكون هو الرابط الأساسي بين العالم الخارجي والذي في حالتنا هو الـ Client أي ما كان وبين الـ Backend الخاص بنا أي ما كان عدد النسخ التي سنقوم بها
والـ Nginx هو الذي سيقوم بتوزيع الطلبات بين نسخ الـ nodejs-service الداخلية بناءً على سياسة توزيع معينة وهذا هو جوهر الـ Load Balancing

+-------------------------------------------------------------+
| Host localhost:3000                                         |
|   |                                                         |
|   |                                                         |
|   nginx-service (Load Balancer)                             |
|   |                                                         |
|   +--> nodejs-service: Replica No. 1 ---> (Success)         |
|   +--> nodejs-service: Replica No. 2 ---> (Success)         |
|   +--> nodejs-service: Replica No. 3 ---> (Success)         |
|                                                             |
| Replica No. 1 -> Successfully created                       |
| Replica No. 2 -> Successfully created                       |
| Replica No. 3 -> Successfully created                       |
|                                                             |
| Replicas name are automatically generated by Docker Compose |
| No replica does port mapping to the host                    |
+-------------------------------------------------------------+

الآن قبل أن نضيف طبقة الـ Nginx، دعنا نقوم بتنفيذ docker compose down -v لنحذف كل شيء ونبدأ من جديد، ثم نعدل ملف docker-compose.yml ليتناسب مع هذا التغيير

> docker compose down -v
[+] Running 6/6
 ✔ Container nginx-load-balancer-nodejs-service-2  Removed                                                                                                                                                                                                       0.1s
 ✔ Container nginx-load-balancer-nodejs-service-1  Removed                                                                                                                                                                                                       0.1s
 ✔ Container nginx-load-balancer-nodejs-service-3  Removed                                                                                                                                                                                                       1.0s
 ✔ Container mysql-container                       Removed                                                                                                                                                                                                       0.9s
 ✔ Volume nginx-load-balancer_mysql-tabarani-db    Removed                                                                                                                                                                                                       0.0s
 ✔ Network nginx-load-balancer_default             Removed                                                                                                                                                                                                       0.6s

الآن شكل ملف docker-compose.yml بعد إزالة أمر container_name و ports كالتالي:

services:
  mysql-service:
    image: mysql:9.6.0
    environment:
      - MYSQL_ROOT_PASSWORD=tabarani-very-secret
      - MYSQL_DATABASE=tabarani-app
    volumes:
      - mysql-tabarani-db:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      timeout: 20s
      retries: 10

  nodejs-service:
    build: .
    env_file:
      - .env
    depends_on:
      mysql-service:
        condition: service_healthy

volumes:
  mysql-tabarani-db:

بشكل عام لا تستخدم container_name في ملفات docker-compose.yml وتعامل دايمًا مع الـ services وليس مع الـ containers، لأن الـ service هو الذي يحدد البنية التحتية للتطبيق، والـ containers هي مجرد نسخ من نفس الـ service

وحين تريد عمل أكثر من نسخة من نفس الـ service، لا تستخدم container_name بل اترك لـ Docker Compose مهمة تسمية الـ containers تلقائيًا
وهذا هو الأفضل في كل الحالات، لأنه يضمن أن كل نسخة ستحصل على اسم مميز دون تعارض مع النسخ الأخرى

أيضًا لا تستخدم ports في الـ service إذا كنت تخطط لعمل أكثر من نسخة منه، لأن كل نسخة ستحاول أن تفتح نفس الـ port على الـ host وهذا غير ممكن
في هذه الحالة ستحتاج لـ Service وسيط مثل Nginx ليكون هو الوحيد الذي يفتح port على الـ host ويتعامل مع كل الطلبات من الخارج، ثم يوزعها بين نسخ الـ nodejs-service الداخلية بدون الحاجة لفتح port لكل نسخة على الـ host

إضافة طبقة Nginx كـ Reverse Proxy

الآن سنجهز ملف docker-compose.yml لكي نضيف service جديدة خاصة بـ Nginx
سنقوم بإضافة nginx-service الذي يستخدم الـ image الرسمي

services:
  mysql-service: ...
  nodejs-service: ...

  nginx-service:
    image: nginx:latest
    ports:
      - "8080:80"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - nodejs-service
...

الآن أضفنا nginx-service الذي سيستخدم الـ image الرسمية الخاصة بالـ Nginx
وسنجعله يعمل على port رقم 8080 على الـ host ويربطه بـ port رقم 80 الخاص بـ Nginx داخل الـ container
بالتالي أي Request يأتي إلى localhost:8080 سيتم توجيهه داخل الـ container الخاص بـ Nginx إلى port رقم 80

هكذا لدينا nginx-service وهو الوحيد الذي قمنا بفتح وعمل port mapping للـ Host
بينما منعنا وقفلنا كل الـ ports الخاصة بالـ nodejs-service والـ mysql-service بحيث لا يمكن الوصول إليهم إلا من داخل الـ Docker Network
والطريقة الوحيدة للوصول إلى الـ nodejs-service الآن هي من خلال الـ nginx-service الذي يعمل كوسيط بين العالم الخارجي والـ Backend الخاص بنا

وستلاحظ في volumes أننا نكتب ./nginx.conf:/etc/nginx/conf.d/default.conf
وهذا يعد bind mount لملف nginx.conf الذي سننشئه بعد قليل، لكي نضع إعدادات Nginx الخاصة بنا
هكذا نحن نربط ملف nginx.conf بالملف الافتراضي default.conf الخاص بـ Nginx داخل الـ container
بالتالي أي تعديل نقوم به على ملف nginx.conf الموجود في الجهاز الخاص بنا وهو الـ host سيتم تطبيقه تلقائيًا على الملف default.conf داخل الـ container الخاص بـ Nginx

وفي الأخير نضع depends_on لكي نضمن أن nginx-service يبدأ فقط بعد أن يبدأ nodejs-service


الآن ننشئ ملف nginx.conf لنضع فيه الإعدادات الخاصة بـ Nginx كـ Reverse Proxy و Load Balancer
ملف الـ nginx.conf سيكون بجوار ملف docker-compose.yml في نفس المجلد، ومحتوى الملف سيكون كالتالي:

server {
    listen 80;
    resolver 127.0.0.11 valid=10s ipv6=off;

    set $upstream http://nodejs-service:3000;

    location / {
        proxy_pass $upstream;
    }
}

قبل أن نكمل عليك أن تعرف أن أوامر إعدادات الـ Nginx كثيرة جدًا ولأكون صريح معك نحن هنا نريد أن نتعلم عن مفهوم الـ Load Balancing
ونستخدم Nginx فقط كأداة لتطبيق هذا المفهوم، لذا لا نريد أن نغوص في كل تفاصيل إعدادات Nginx
لكنني هنا في هذا المثال التوضيحي أحاول أن أبسطها قدر الإمكان لأشرح المفهوم فقط، وليس الهدف أن نغطي كل التفاصيل الدقيقة لإعدادات Nginx
لذلك في هذا المثال سأستخدم أبسط إعدادات ممكنة لشرح فكرة الـ Load Balancing فقط، وسأشرح كل سطر في الإعدادات بشكل مبسط

في ملف nginx.conf لدينا إعدادات الـ server الخاص بـ Nginx
هنا نخبر Nginx أن يستمع على port رقم 80، وهذا هو الـ port الذي سيستخدمه الـ Nginx بشكل افتراضي داخل الـ container

ثم نستخدم resolver 127.0.0.11 وهذا أمر بسبب أننا نتعامل مع Docker ووظيفة الأمر هو أنه يحدد لـ Nginx عنوان الـ DNS resolver الداخلي الخاص بـ Docker والذي يكون دائمًا 127.0.0.11
و valid=10s تعني أن Nginx سيقوم بسؤال الـ DNS resolver عن عنوان nodejs-service كل 10 ثواني لكي يحصل على أحدث قائمة بالـ containers
و ipv6=off تعني أننا لا نستخدم عناوين IPv6 في هذا المشروع، فقط IPv4

ثم نعرف متغير جديد في Nginx اسمه $upstream ونخزن فيه عنوان الـ upstream الخاص بنا، وهو http://nodejs-service:3000
بسبب أننا نستخدم Nginx داخل Docker Network فنستطيع الوصول إلى nodejs-service من خلال اسمه فقط، و 3000 هو الـ port الداخلي الذي يعمل عليه Node.js داخل الـ container
لهذا نستطيع أن نكتب nodejs-service:3000 عوضًا عن كتابة الـ IP الخاص بالـ container
وبسبب أننا سنقوم بتشغيل أكثر من نسخة من الـ nodejs-service لاحقًا، فإن nodejs-service:3000 سيشير إلى كل النسخ التي تنتمي لهذا الـ service بشكل تلقائي بفضل الـ DNS الداخلي الخاص بـ Docker

وهذا سبب آخر لنقول أنه يفضل دائمًا داخل عالم الـ Docker Compose أن تستخدم اسم الـ service وليس اسم الـ container، لأن اسم الـ service ثابت، يكون أعم لأنه قد يضم عدة containers من نفس الـ service

وفي قسم الـ location نستخدم proxy_pass $upstream لكي نخبر Nginx أن يوجه كل الطلبات التي تأتي إلى الـ Nginx إلى العنوان المخزن في المتغير $upstream
وبما أن nodejs-service يشير إلى كل النسخ التي تنتمي لهذا الـ service فهذا يعني أن Nginx سيقوم بتوزيع الطلبات بين كل النسخ بشكل تلقائي

ملحوظة: الـ resolver في Nginx لا يعمل إلا إذا كان الـ proxy_pass يستخدم متغيرًا، مثل ما عرفنا متغير باسم $upstream
أما إذا استخدمنا قيمة ثابتة مباشرةً مثل proxy_pass http://nodejs-service:3000 فإن Nginx سيقوم بتحديد الـ DNS مرة واحدة فقط عند بدء التشغيل ولن يجدد أبدًا
وهذا يعني أنه لو أعدنا تشغيل أحد الـ containers وتغير الـ IP الخاص به فإن Nginx لن يعرف بهذا التغيير وسيظل يرسل الطلبات إلى الـ IP القديم
أما بسبب أننا وضعنا العنوان في متغير $upstream واستخدمنا resolver فإن Nginx في كل مرة يحاول جلب قيمة المتغير $upstream سيقوم بسؤال الـ DNS resolver عن العنوان الجديد
بالتالي هذا يضمن أن Nginx سيظل يعرف كل النسخ الجديدة التي تنضم إلى nodejs-service وسيقوم بتوزيع الطلبات عليها بشكل صحيح


هكذا نكون إنتهينا من إعداد Nginx كـ Reverse Proxy و Load Balancer لتوزيع الطلبات بين نسخ الـ nodejs-service
الآن كل ما علينا هو تشغيل أكثر من replica من الـ nodejs-service لكي نرى كيف يعمل هذا التوزيع في الواقع

تشغيل أكثر من Replica من Service الـ Node.js

لتشغيل أكثر من نسخة من نفس الـ Service في Docker Compose، كما ذكرنا سابقًا، لدينا طريقتين: إما نحدد عدد النسخ في ملف docker-compose.yml باستخدام replicas، أو نستخدم الخيار --scale أثناء تشغيل الأمر docker compose up
سنقوم هذه المرة بتجربة الطريقة الأولى وهي تحديد عدد النسخ في ملف docker-compose.yml

services:
  mysql-service: ...

  nodejs-service:
    build: .
    env_file:
      - .env
    depends_on:
      mysql-service:
        condition: service_healthy
    deploy:
      replicas: 3

  nginx-service: ...

لقد أضفنا قسم deploy داخل الـ nodejs-service ووضعنا فيه replicas: 3 لكي نخبر Docker Compose أننا نريد تشغيل ثلاث نسخ من هذا الـ Service
الآن لنقم بتنفيذ الأمر docker compose up -d لرؤية النتيجة

> docker compose up -d
[+] Running 7/7
 ✔ Network nginx-load-balancer_default             Created                                                                                                                                                                   0.1s
 ✔ Volume "nginx-load-balancer_mysql-tabarani-db"  Created                                                                                                                                                                   0.0s
 ✔ Container nginx-load-balancer-mysql-service-1   Healthy                                                                                                                                                                  30.8s
 ✔ Container nginx-load-balancer-nodejs-service-3  Started                                                                                                                                                                  31.1s
 ✔ Container nginx-load-balancer-nodejs-service-1  Started                                                                                                                                                                  30.8s
 ✔ Container nginx-load-balancer-nodejs-service-2  Started                                                                                                                                                                  31.0s
 ✔ Container nginx-load-balancer-nginx-service-1   Started                                                                                                                                                                  31.2s

بمجرد قراءة النتيجة، نرى أنه تم إنشاء الـ network والـ volume ثم تم تشغيل الـ mysql-service بنجاح
ثم نرى أنه تم تشغيل ثلاث نسخ من الـ nodejs-service دون أي مشاكل
وهم nginx-load-balancer-nodejs-service-1 و nginx-load-balancer-nodejs-service-2 و nginx-load-balancer-nodejs-service-3

ثم لدينا nginx-load-balancer-nginx-service-1 وهو الـ container الخاص بـ nginx-service الذي يعمل كـ Reverse Proxy و Load Balancer بين العالم الخارجي ونسخ الـ nodejs-service

بالطبع اسم nginx-load-balancer هو كما ذكرنا اسم المجلد الذي يحتوي على ملف docker-compose.yml، وهذا هو السبب في أن أسماء الـ containers تبدأ بـ nginx-load-balancer ثم يأتي اسم الـ service ثم رقم النسخة

المهم الآن لدينا ثلاث نسخ من الـ nodejs-service تعمل بشكل صحيح، وكل نسخة لها اسم container مختلف يتم إنشاؤه تلقائيًا من قبل Docker Compose


لكي نتأكد من أنه تم إنشاء replicas حقيقية من نفس الـ Service يمكننا تنفيذ الأمر docker compose ps لرؤية كل الـ containers التي تعمل حاليًا في هذا المشروع

> docker compose ps
NAME                                   IMAGE                                COMMAND                  SERVICE          CREATED         STATUS                   PORTS
nginx-load-balancer-mysql-service-1    mysql:9.6.0                          "docker-entrypoint.s…"   mysql-service    4 minutes ago   Up 4 minutes (healthy)   3306/tcp, 33060/tcp
nginx-load-balancer-nginx-service-1    nginx:latest                         "/docker-entrypoint.…"   nginx-service    4 minutes ago   Up 4 minutes             0.0.0.0:8080->80/tcp
nginx-load-balancer-nodejs-service-1   nginx-load-balancer-nodejs-service   "npm start"              nodejs-service   4 minutes ago   Up 4 minutes             3000/tcp
nginx-load-balancer-nodejs-service-2   nginx-load-balancer-nodejs-service   "npm start"              nodejs-service   4 minutes ago   Up 4 minutes             3000/tcp
nginx-load-balancer-nodejs-service-3   nginx-load-balancer-nodejs-service   "npm start"              nodejs-service   4 minutes ago   Up 4 minutes             3000/tcp

أمر docker compose ps يعرض لنا كل الـ containers التي تعمل حاليًا في هذا المشروع، مثل أمر الـ docker container list
لكن أمر docker compose ps مخصص لعرض الـ containers التي أنشأها الـ Docker Compose للمشروع الحالي

كما ترى لدينا container واحد خاص بـ mysql-service، و container واحد خاص بـ nginx-service، وثلاث containers خاصة بـ nodejs-service
لاحظ أيضًا أنه في عمود الـ PORTS لا يوجد أي published ports في أي من containers الخاصة بالـ mysql-service أو نسخ الـ nodejs-service الثلاثة
بحيث أنك ترى 3306/tcp و 3000/tcp بدون أي -> مما يعني أن هذه الـ containers لا تفتح هذه الـ ports على الـ host، بل تفتحها فقط داخل الـ container الخاص بها
أما الـ nginx-service فستجد أن قيمته في عمود الـ PORTS هي 0.0.0.0:8080->80/tcp
فتدرك أن هذا يعني أن nginx-service هو الوحيد الذي يفتح port رقم 80 داخل الـ container ويربطه بـ port رقم 8080 على الـ host


يمكنك أن تتخيل شكل الـ Load Balancing الذي قمنا بإنشائه باستخدام Nginx في هذا المشروع كالتالي:

                     +-----------------+
   Client ---------> |    Nginx 8080   |
                     |  Load Balancer  |
                     +-----------------+
                              |
         +--------------------+-------------------+
         |                    |                   |
         v                    v                   v
 +----------------+  +----------------+  +----------------+
 | nodejs-service |  | nodejs-service |  | nodejs-service |
 | replica #1     |  | replica #2     |  | replica #3     |
 | internal:3000  |  | internal:3000  |  | internal:3000  |
 +----------------+  +----------------+  +----------------+
          \                   |                  /
           \                  |                 /
            +-----------------+----------------+
                              |
                              v
                       +-------------+      +-------------+
                       |    MySQL    | ---> |    Volume   |
                       +-------------+      +-------------+

بحيث لدينا Client الذي يرسل الطلبات إلى Nginx الذي يعمل كـ Load Balancer على port رقم 8080 على الـ host
ثم Nginx يقوم بتوزيع هذه الطلبات بين النسخ الثلاثة من الـ nodejs-service التي تعمل داخليًا على port رقم 3000 داخل الـ containers الخاصة بها
وكل نسخة من نسخ الـ nodejs-service تتعامل مع نفس قاعدة البيانات MySQL التي تعمل أيضًا نفس الـ Docker Network

اختبار الـ Load Balancing عبر Nginx

أريد أن أذكرك أن كل النسخ الثلاثة من الـ nodejs-service تشترك في نفس قاعدة البيانات MySQL، وهذا يعني أن أي بيانات يتم كتابتها من خلال أي نسخة ستظهر في كل النسخ الأخرى، لأنهم جميعًا يتعاملون مع نفس قاعدة البيانات
وأيضًا لدينا ثلاثة endpoints في الـ nodejs-service وهم:

  • GET / لعرض رسالة ترحيب
  • GET /products لقراءة المنتجات
  • POST /products لإضافة منتج جديد

لنلقي نظرة عليها في الكود

app.get("/", (req, res) => {
  res.json({
    message: `Hello from the server running on host ${os.hostname()}`,
  });
});

app.get("/products", async (req, res) => {
  const [rows] = await connection.execute("SELECT * FROM products");
  res.json({
    products: rows,
    message: `This response is from the server running on host ${os.hostname()}`,
  });
});

app.post("/products", async (req, res) => {
  const { name, price } = req.body;
  const [result] = await connection.execute(
    "INSERT INTO products (name, price) VALUES (?, ?)",
    [name, price],
  );
  res.status(201).json({
    id: result.insertId,
    name,
    price,
    message: `Product created on server running on host ${os.hostname()}`,
  });
});

لاحظ أن كل endpoint يرسل في الرد رسالة تحتوي على الـ hostname الخاص بالنسخة التي تعاملت مع الطلب، وفي هذه الحالة الـ hostname سيمثل الـ id الخاص بالـ container الذي تعامل مع الطلب، وهذا سيساعدنا للتأكد من أن الطلبات تتوزع بين النسخ المختلفة وليس كلها تذهب إلى نفس النسخة

الآن لنتفقد الـ id الخاص بكل container يمكننا تنفيذ الأمر العام الذي يعرض كل الـ containers وهو docker container list

> docker container list
CONTAINER ID   IMAGE                                COMMAND                  CREATED         STATUS                   PORTS                  NAMES
c31a8d447cc4   nginx:latest                         "/docker-entrypoint.…"   9 minutes ago   Up 8 minutes             0.0.0.0:8080->80/tcp   nginx-load-balancer-nginx-service-1
b022b7d70c11   nginx-load-balancer-nodejs-service   "npm start"              9 minutes ago   Up 8 minutes             3000/tcp               nginx-load-balancer-nodejs-service-1
dce84e7bb5b9   nginx-load-balancer-nodejs-service   "npm start"              9 minutes ago   Up 8 minutes             3000/tcp               nginx-load-balancer-nodejs-service-2
6e9b6c68bd92   nginx-load-balancer-nodejs-service   "npm start"              9 minutes ago   Up 8 minutes             3000/tcp               nginx-load-balancer-nodejs-service-3
794d0b84d790   mysql:9.6.0                          "docker-entrypoint.s…"   9 minutes ago   Up 9 minutes (healthy)   3306/tcp, 33060/tcp    nginx-load-balancer-mysql-service-1

سنركز فقط على الـ CONTAINER ID الخاص بنسخ الـ nodejs-service الثلاثة، وهم: b022b7d70c11 و dce84e7bb5b9 و 6e9b6c68bd92

الآن لنختبر الـ Load Balancing عن طريق إرسال عدة Requests من المتصفح إلى nginx-service الذي يعمل على port رقم 8080 على الـ host، ثم نرى كيف يتم توزيع هذه الطلبات بين النسخ الثلاثة من الـ nodejs-service

الآن لنجرب إرسال GET إلى http://localhost:8080/ عدة مرات متتالية، ونرى كيف يتم توزيع الطلبات بين النسخ الثلاثة من الـ nodejs-service من خلال الرسائل التي تحتوي على الـ id الخاص بالـ container

> curl http://localhost:8080/
{"message":"Hello from the server running on host b022b7d70c11"}

> curl http://localhost:8080/
{"message":"Hello from the server running on host dce84e7bb5b9"}

> curl http://localhost:8080/
{"message":"Hello from the server running on host 6e9b6c68bd92"}

لاحظ أنك في كل مرة ترسل فيها GET إلى http://localhost:8080/ تحصل على رسالة مختلفة تحتوي على الـ id الخاص بالنسخة التي تعاملت مع الطلب
في المرة الأولى كان b022b7d70c11، في المرة الثانية كان dce84e7bb5b9، وفي المرة الثالثة كان 6e9b6c68bd92
وهذا يعني أن الطلبات تتوزع بين النسخ الثلاثة من الـ nodejs-service بشكل صحيح، وليس كلها تذهب إلى نفس النسخة
وهذا هو جوهر الـ Load Balancing، حيث أن لدينا عدة نسخ من نفس الـ Service، والـ Reverse Proxy يقوم بتوزيع الطلبات بين هذه النسخ بشكل متساوي أو بناءً على سياسة معينة

+------------------------------------------------------------------+
|                        Your device (host)                        |
|                                                                  |
|  localhost:8080                                                  |
|       |                                                          |
|   Port Mapping                                                   |
|       |                                                          |
|  +----|-------------------------------------------------------+  |
|  |    | Private network (bridge) generated by Docker Compose  |  |
|  |    |                                                       |  |
|  |    |       +-------------------------------+               |  |
|  |    +-----> | nginx-service (Load Balancer) |               |  |
|  |            | IP: 172.18.0.2 on port 80     |               |  |
|  |            +------------------------------+                |  |
|  |                            |                               |  |
|  |                            v                               |  |
|  |  +--------------------------------------------------+      |  |
|  |  |  nodejs-service has three instances              |      |  |
|  |  |  +------------+  +------------+  +------------+  |      |  |
|  |  |  | nodejs-1   |  | nodejs-2   |  | nodejs-3   |  |      |  |
|  |  |  | 172.18.0.3 |  | 172.18.0.4 |  | 172.18.0.5 |  |      |  |
|  |  |  | port 3000  |  | port 3000  |  | port 3000  |  |      |  |
|  |  |  +------------+  +------------+  +------------+  |      |  |
|  |  |                                                  |      |  |
|  |  +--------------------------------------------------+      |  |
|  |                           |                                |  |
|  |                 DNS: mysql-service:3306                    |  |
|  |                           |                                |  |
|  |                           v                                |  |
|  |            +------------------------------+                |  |
|  |            | mysql-service                |                |  |
|  |            | IP: 172.18.0.6 on port 3306  |                |  |
|  |            +------------------------------+                |  |
|  |                           |                                |  |
|  |                           v                                |  |
|  |            +------------------------------+                |  |
|  |            | mysql-tabarani-db            |                |  |
|  |            | Named volume                 |                |  |
|  |            +------------------------------+                |  |
|  |                                                            |  |
|  +------------------------------------------------------------+  |
|                                                                  |
+------------------------------------------------------------------+

هذا شكل تخيلي للتوزيع الذي يحدث بين الـ nginx-service والنسخ الثلاثة من الـ nodejs-service، حيث أن كل طلب يأتي إلى Nginx يتم توجيهه إلى واحدة من النسخ الثلاثة بشكل متساوي، وكل نسخة تتعامل مع نفس قاعدة البيانات MySQL


ستلاحظ أن الـ endpoints الثلاثة موجودة الأصل في الـ nodejs-service والـ nginx-service النسخ التي تم إنشاؤها هي مجرد نسخ من نفس الـ service، وبالتالي كل نسخة تحتوي على نفس الـ endpoints
وأيضًا كل نسخة تعمل داخل container مختلف وتعمل على port رقم 3000 داخل الـ container

نحن عندما أرسلنا GET إلى http://localhost:8080/ فإننا هنا نتعامل مع الـ port رقم 8080 الخاص بـ nginx-service والـ nginx-service بحد ذاته لا يحتوي على أي من الـ endpoints الخاصة بالـ nodejs-service بل هو فقط يقوم بتوجيه الطلبات إلى النسخ الثلاثة من الـ nodejs-service التي تحتوي على هذه الـ endpoints
لذلك عندما أرسلنا GET إلى http://localhost:8080/ فالـ nginx-service سيقرر أن يوجه إلى nodejs-service-1:3000 أو nodejs-service-2:3000 أو nodejs-service-3:3000 بناءً على سياسة التوزيع الخاص به كما قلنا

تخزين البيانات في MySQL ومشاركتها بين النسخ المختلفة

عرفنا أن كل من النسخ الثلاثة من الـ nodejs-service تشترك في نفس قاعدة البيانات MySQL، وهذا يعني أن أي بيانات يتم كتابتها من خلال أي نسخة ستظهر في كل النسخ الأخرى، لأنهم جميعًا يتعاملون مع نفس قاعدة البيانات

لذلك لنقم بإضافة منتج جديد من خلال POST http://localhost:8080/products ثم نقرأ المنتجات من خلال GET http://localhost:8080/products عدة مرات لنرى كيف يتم مشاركة البيانات بين النسخ المختلفة

> curl -X POST http://localhost:8080/products -H "Content-Type: application/json" -d '{"name":"Laptop","price":1200}'
{"id":1,"name":"Laptop","price":1200,"message":"Product created on server running on host 6e9b6c68bd92"}

> curl http://localhost:8080/products
{"products":[{"id":1,"name":"Laptop","price":"1200.00"}],"message":"This response is from the server running on host b022b7d70c11"}

> curl http://localhost:8080/products
{"products":[{"id":1,"name":"Laptop","price":"1200.00"}],"message":"This response is from the server running on host dce84e7bb5b9"}

> curl http://localhost:8080/products
{"products":[{"id":1,"name":"Laptop","price":"1200.00"}],"message":"This response is from the server running on host 6e9b6c68bd92"}

لاحظ أننا أرسلنا POST لإضافة منتج جديد، والرد جاء من النسخة التي تحمل الـ id الخاص بها 6e9b6c68bd92
ثم عندما أرسلنا GET لقراءة المنتجات، حصلنا على نفس المنتج في كل مرة، لكن الرد جاء من نسخ مختلفة في كل مرة، في المرة الأولى جاء من النسخة b022b7d70c11
في المرة الثانية جاء من النسخة dce84e7bb5b9، وفي المرة الثالثة جاء من النسخة 6e9b6c68bd92

ولاحظ أنه نفس المنتج الذي أضفناه في النسخة الأولى ظهر في كل النسخ الأخرى، وهذا بسبب أن كل النسخ تشترك في نفس قاعدة البيانات MySQL كما قلنا
فأي نسخة من الـ nodejs-service يمكنها قراءة وكتابة البيانات في قاعدة البيانات، وكل النسخ سترى نفس البيانات لأنهم جميعًا يتعاملون مع نفس قاعدة البيانات

زيادة وتقليل عدد النسخ - Scale Up/Down

ستقابل مصطلح يدعى Scaling في عالم الـ Docker Compose، أو عالم الـ DevOps بشكل عام، وهو يعني زيادة أو تقليل عدد النسخ من نفس الـ Service بحسب الحاجة
بالطبع سيتم عمل هذا بشكل تلقائي بواسطة أدوات مثل Kubernetes أو Docker Swarm لتقليل أو زيادة عدد النسخ بناءً على الحمل

على أي حال من أجل الشرح، سنقوم بعمل scale up لزيادة عدد النسخ من 3 إلى 5 ثم سنقوم بعمل scale down لتقليل العدد من 5 إلى 2 لنرى كيف يتكيف الـ Load Balancer مع هذه التغييرات

نحن في ملف الـ docker-compose.yml قمنا بوضع replicas: 3 داخل قسم deploy الخاص بالـ nodejs-service، وهذا يعني أن عند تشغيل الأمر docker compose up -d سيتم تشغيل ثلاث نسخ من هذا الـ service
لكننا يمكننا تعديل وعمل override لهذا العدد من خلال --scale أثناء تشغيل الأمر docker compose up

الآن لنجرب زيادة العدد من 3 إلى 5 عن طريق تنفيذ الأمر التالي:

> docker compose up -d --scale nodejs-service=5
[+] Running 7/7
 ✔ Container nginx-load-balancer-mysql-service-1   Healthy                                                                                                                                                                                                       0.8s
 ✔ Container nginx-load-balancer-nodejs-service-1  Running                                                                                                                                                                                                       0.0s
 ✔ Container nginx-load-balancer-nodejs-service-2  Running                                                                                                                                                                                                       0.0s
 ✔ Container nginx-load-balancer-nodejs-service-3  Running                                                                                                                                                                                                       0.0s
 ✔ Container nginx-load-balancer-nodejs-service-5  Started                                                                                                                                                                                                       1.3s
 ✔ Container nginx-load-balancer-nodejs-service-4  Started                                                                                                                                                                                                       1.1s
 ✔ Container nginx-load-balancer-nginx-service-1   Running                                                                                                                                                                                                       0.0s

لاحظ أن النسختين الرابعة والخامسة من الـ nodejs-service تم إنشاؤهما بنجاح، وهم nginx-load-balancer-nodejs-service-4 و nginx-load-balancer-nodejs-service-5 ومكتوب في النتيجة أنهم Started
أما النسخ الثلاثة الأولى فهم Running لأنهم كانوا يعملون بالفعل من قبل، ستلاحظ أن Docker Compose ذكي بما يكفي ليدرك أن النسخ الثلاثة الأولى لا تحتاج إلى إعادة تشغيل، بل فقط تم إضافة نسختين جديدتين والنسخ الثلاثة الأولى استمرت في العمل بدون أي انقطاع

الآن لنقم بتنفيذ docker compose ps لنرى كل النسخ التي تعمل حاليًا

> docker compose ps
NAME                                   IMAGE                                COMMAND                  SERVICE          CREATED             STATUS                       PORTS
nginx-load-balancer-mysql-service-1    mysql:9.6.0                          "docker-entrypoint.s…"   mysql-service    About an hour ago   Up About an hour (healthy)   3306/tcp, 33060/tcp
nginx-load-balancer-nginx-service-1    nginx:latest                         "/docker-entrypoint.…"   nginx-service    About an hour ago   Up About an hour             0.0.0.0:8080->80/tcp
nginx-load-balancer-nodejs-service-1   nginx-load-balancer-nodejs-service   "npm start"              nodejs-service   About an hour ago   Up About an hour             3000/tcp
nginx-load-balancer-nodejs-service-2   nginx-load-balancer-nodejs-service   "npm start"              nodejs-service   About an hour ago   Up About an hour             3000/tcp
nginx-load-balancer-nodejs-service-3   nginx-load-balancer-nodejs-service   "npm start"              nodejs-service   About an hour ago   Up About an hour             3000/tcp
nginx-load-balancer-nodejs-service-4   nginx-load-balancer-nodejs-service   "npm start"              nodejs-service   2 minutes ago       Up 2 minutes                 3000/tcp
nginx-load-balancer-nodejs-service-5   nginx-load-balancer-nodejs-service   "npm start"              nodejs-service   2 minutes ago       Up 2 minutes                 3000/tcp

الآن لدينا خمس نسخ من الـ nodejs-service

لنقم بإرسال عدة GET إلى http://localhost:8080/ لنرى كيف يتم توزيع الطلبات بين النسخ الخمسة من الـ nodejs-service

> curl http://localhost:8080
{"message":"Hello from the server running on host 6928e2b2cf38"}

> curl http://localhost:8080
{"message":"Hello from the server running on host 6e9b6c68bd92"}

> curl http://localhost:8080
{"message":"Hello from the server running on host d6fbb3daef6a"}

> curl http://localhost:8080
{"message":"Hello from the server running on host dce84e7bb5b9"}

> curl http://localhost:8080
{"message":"Hello from the server running on host b022b7d70c11"}

لاحظ أن الطلبات تتوزع بين النسخ الخمسة بشكل صحيح، وكل نسخة تعطي رد مختلف يحتوي على الـ id الخاص بها
وهذا يؤكد أن Nginx يقوم بتوزيع الطلبات بين النسخ الخمسة من الـ nodejs-service بشكل صحيح

الآن لنقم بعمل scale down لتقليل العدد من 5 إلى 2 عن طريق تنفيذ الأمر التالي:

> docker compose up -d --scale nodejs-service=2
[+] Running 7/7
 ✔ Container nginx-load-balancer-mysql-service-1   Healthy                                                                                                                                                                                                       1.9s
 ✔ Container nginx-load-balancer-nodejs-service-1  Running                                                                                                                                                                                                       0.0s
 ✔ Container nginx-load-balancer-nodejs-service-2  Running                                                                                                                                                                                                       0.0s
 ✔ Container nginx-load-balancer-nodejs-service-5  Removed                                                                                                                                                                                                       1.1s
 ✔ Container nginx-load-balancer-nodejs-service-4  Removed                                                                                                                                                                                                       1.2s
 ✔ Container nginx-load-balancer-nodejs-service-3  Removed                                                                                                                                                                                                       1.4s
 ✔ Container nginx-load-balancer-nginx-service-1   Running                                                                                                                                                                                                       0.0s

ستلاحظ أنه هذه المرة قام بإزالة النسخ الثلاثة الأخيرة من الـ nodejs-service وهم nginx-load-balancer-nodejs-service-3 و nginx-load-balancer-nodejs-service-4 و nginx-load-balancer-nodejs-service-5
والنسختين الأولى والثانية من الـ nodejs-service استمروا في العمل

هكذا نرى أنه من السهل جدًا زيادة أو تقليل عدد النسخ من نفس الـ Service باستخدام --scale أثناء تشغيل الأمر docker compose up
وكل ما عليك هو تحديد اسم الـ service وعدد النسخ الذي تريده

ولا ننسى أمر replicas الذي وضعناه في ملف docker-compose.yml لكي نحدد عدد افتراضي للنسخ في حالة عدم استخدام --scale


تذكر أننا في ملف nginx.conf استخدمنا resolver مع متغير $upstream وقلنا أننا يجب أن نستخدم متغير في proxy_pass لكي يعمل الـ resolver بشكل صحيح ويقوم بتحديث قائمة النسخ الجديدة تلقائيًا
لأننا لو قمنا بكتابة proxy_pass http://nodejs-service:3000 مباشرةً بدون استخدام متغير، فإن Nginx سيقوم بتحديد الـ DNS للـ container الخاص بـ nodejs-service مرة واحدة فقط عند بدء التشغيل، ولن يجدد هذه القائمة أبدًا في حالة إضافة أو إزالة نسخ جديدة من الـ nodejs-service

لذلك قمنا أولًا بتعريف متغير $upstream ووضعنا فيه العنوان http://nodejs-service:3000، ثم استخدمنا هذا المتغير في proxy_pass بهذا الشكل proxy_pass $upstream
لكي يقوم Nginx في كل مرة يحاول جلب قيمة المتغير $upstream سيسأل الـ DNS resolver عن العنوان الجديد الخاص بـ nodejs-service ويحدث قائمة النسخ الجديدة تلقائيًا
لذلك نستطيع أن نضيف أو نزيل نسخ جديدة من الـ nodejs-service في أي وقت، والـ Nginx سيظل يعرف كل النسخ الجديدة التي تنضم إلى nodejs-service وسيقوم بتوزيع الطلبات عليها بشكل صحيح بدون الحاجة لإعادة تشغيل الـ nginx-service أو تعديل إعداداته


لنقم بإيقاف المشروع الآن لأننا وصلنا لنهاية المقالة

> docker compose down -v
[+] Running 6/6
 ✔ Container nginx-load-balancer-nginx-service-1   Removed                                                                                                                                                                                                       0.7s
 ✔ Container nginx-load-balancer-nodejs-service-2  Removed                                                                                                                                                                                                       1.3s
 ✔ Container nginx-load-balancer-nodejs-service-1  Removed                                                                                                                                                                                                       1.3s
 ✔ Container nginx-load-balancer-mysql-service-1   Removed                                                                                                                                                                                                       2.4s
 ✔ Volume nginx-load-balancer_mysql-tabarani-db    Removed                                                                                                                                                                                                       0.0s
 ✔ Network nginx-load-balancer_default             Removed                                                                                                                                                                                                       0.7s

هكذا نكون قد حذفنا كل الـ containers وتذكر أن -v يعني حذف الـ volumes أيضًا، وبالتالي تم حذف الـ volume الخاص بقاعدة البيانات MySQL بالبيانات التي كانت موجودة فيه، وتم حذف الـ network الخاص بالمشروع أيضًا

في حال أردت حذف الـ containers فقط بدون حذف الـ volumes فلا تستخدم -v

ملف الـ docker-compose.yml النهائي

ولكي لا أنسى، هذا هو ملف الـ docker-compose.yml النهائي الذي يحتوي على كل الإعدادات التي تحدثنا عنها في هذه المقالة

services:
  mysql-service:
    image: mysql:9.6.0
    environment:
      - MYSQL_ROOT_PASSWORD=tabarani-very-secret
      - MYSQL_DATABASE=tabarani-app
    volumes:
      - mysql-tabarani-db:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      timeout: 20s
      retries: 10

  nodejs-service:
    build: .
    env_file:
      - .env
    depends_on:
      mysql-service:
        condition: service_healthy
    deploy:
      replicas: 3

  nginx-service:
    image: nginx:latest
    ports:
      - "8080:80"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf
    depends_on:
      - nodejs-service

volumes:
  mysql-tabarani-db:

تمرين عملي

أريد الآن أن تفكر وتحاول وضع طبقة جديدة في Docker Compose ولتكن Redis كـ Cache
لأنك ستحتاج في المشاريع الحقيقية إلى Cache، وخصوصًا عندما تقوم بعمل Scaling لزيادة عدد النسخ من نفس الـ Service ستجد نفسك بحاجة إلى Server مستقل ليكون Cache مشترك بين كل النسخ
بحيث أن كل النسخ من الـ nodejs-service ستتعامل مع نفس الـ Redis لتخزين واسترجاع البيانات المؤقتة، وهذا سيساعد في تحسين الأداء وتقليل الحمل على قاعدة البيانات MySQL
وحل مشاكل مثل الـ Race Conditions و الـ Idempotency Keys على مستوى عدة نسخ من نفس الـ Service التي ستشترك في نفس الـ Cache

قد أقوم بعمل مقالة عن الـ Redis في المستقبل

الخلاصة

على أي حال هذا مجرد مثال بسيط لتوضيح المفهوم، لكن في المشاريع الحقيقية سنتعامل مع أدوات مثل Kubernetes أو Docker Swarm التي توفر لنا إمكانيات أكثر تقدمًا في إدارة الـ Load Balancing وتوزيع الطلبات بين النسخ المختلفة بشكل ذكي بناءًا على الحمل والموارد المتاحة
وعمل Auto Scaling لزيادة أو تقليل عدد النسخ بشكل تلقائي بناءًا على معايير معينة بدون أي تدخل يدوي


في هذه المقالة أخذنا مشروعًا بسيطًا يعمل بـ Node.js و MySQL، ثم قمنا بإضافة طبقة Load Balancing باستخدام Nginx داخل Docker Compose
وبدل أن يكون لدينا Server واحدة تتحمل كل الطلبات، أصبح لدينا عدة نسخ من نفس الـ Service تتشارك الحمل والبيانات بشكل عملي وواضح

تعلمنا كيف نزيد ونقلل عدد النسخ من نفس الـ Service بسهولة باستخدام --scale أثناء تشغيل الأمر docker compose up
أو بتعريف عدد النسخ في ملف docker-compose.yml باستخدام replicas

واستخدما Nginx كـ Reverse Proxy و Load Balancer لتوزيع الطلبات بين النسخ المختلفة من الـ nodejs-service بشكل متساوي
وعرفنا أننا لا يجب أن نستخدم container_name في الـ Docker Compose لكي لا يحدث تعارض في الأسماء بين النسخ المختلفة، بل يجب أن نستخدم اسم الـ service فقط لكي نستفيد من الـ DNS الداخلي الخاص بـ Docker للوصول إلى كل النسخ بشكل تلقائي
ونترك مسؤولية تسمية الـ containers لـ Docker Compose الذي سيعطي كل نسخة اسم فريد

وونفس الأمر مع الـ ports حيث عرفنا أن الـ Port Mapping يحدث مشكلة عندما نريد تشغيل أكثر من نسخة من نفس الـ Service لأن كل نسخة ستحاول أن تفتح نفس الـ port على الـ host مما يسبب تعارض، لذلك الحل هو أن نلغي الـ Port Mapping من نسخ الـ nodejs-service ونتركه فقط في الـ nginx-service الذي سيقوم بتوجيه الطلبات إلى النسخ المختلفة داخل الـ Docker Network بدون الحاجة لفتح هذه الـ ports على الـ host

قمنا بتجربة الـ Load Balancing عن طريق إرسال عدة Requests إلى nginx-service ورأينا كيف يتم توزيع الطلبات بين النسخ المختلفة من الـ nodejs-service بشكل متساوي، وكل نسخة تعطي رد مختلف يحتوي على الـ id الخاص بها مما يؤكد أن الطلبات تتوزع بين النسخ المختلفة بشكل صحيح
وعرفنا كيف نقوم بعمل scale up و scale down لزيادة أو تقليل عدد النسخ من نفس الـ Service بشكل يدوي باستخدام --scale أثناء تشغيل الأمر docker compose up، ورأينا كيف يتكيف الـ Load Balancer مع هذه التغييرات ويستمر في توزيع الطلبات بين النسخ الجديدة أو المتبقية بشكل صحيح

في النهاية، هذا هو مفهوم الـ Load Balancing وكيفية تطبيقه باستخدام Nginx داخل Docker Compose لتوزيع الطلبات بين نسخ متعددة من نفس الـ Service بشكل عملي وواضح


رسالة خاصة

أرسل ملاحظاتك أو رأيك بشكل خاص — لن يظهر للآخرين

التعليقات

شاركنا رأيك في هذه المقالة أو اسأل عن أي شيء يخصها