Update: 2026-07-21 16:59:10
This commit is contained in:
@@ -0,0 +1,45 @@
|
|||||||
|
# ============================================================
|
||||||
|
# انسخه إلى .env واملأه من قيم اللايف — لا تخترع قيماً جديدة
|
||||||
|
# cp .env.example .env
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# --- عام ---
|
||||||
|
PHP_VERSION=8.2
|
||||||
|
HTTP_PORT=8181
|
||||||
|
|
||||||
|
# فارغ أثناء التجربة = حارس REGION_MISMATCH في connect.php يسمح بأي host
|
||||||
|
# عند الربط بدومين حقيقي: ضع الدومين هنا
|
||||||
|
APP_DOMAIN=
|
||||||
|
|
||||||
|
# --- قواعد البيانات (من داخل شبكة الحاويات: host = mysql) ---
|
||||||
|
MYSQL_ROOT_PASSWORD=change-me
|
||||||
|
DB_HOST=mysql
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_USER=root
|
||||||
|
DB_PASS=${MYSQL_ROOT_PASSWORD}
|
||||||
|
# أسماء القواعد — طابقها مع mysql/init/00-databases.sql ومع ما يتوقعه الكود
|
||||||
|
DB_NAME_PRIMARY=siro_primary
|
||||||
|
DB_NAME_RIDE=siro_ride
|
||||||
|
DB_NAME_TRACKING=siro_tracking
|
||||||
|
DB_NAME_WALLET=siro_wallet
|
||||||
|
DB_NAME_LOCATION=siro_location
|
||||||
|
|
||||||
|
# --- Redis (نسخة واحدة تكفي النسخة المستنسخة — الفصل في اللايف كان فصل سيرفرات) ---
|
||||||
|
REDIS_HOST=redis
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_LOCATION_HOST=redis
|
||||||
|
REDIS_LOCATION_PORT=6379
|
||||||
|
|
||||||
|
# --- JWT (انسخ السر الحقيقي من .env اللايف — التطبيق لن يغيّر توكناته) ---
|
||||||
|
JWT_SECRET_KEY=
|
||||||
|
JWT_SECRET=
|
||||||
|
|
||||||
|
# --- التخاطب الداخلي بين الباك إند والسوكيتات (أسماء الخدمات لا IPs) ---
|
||||||
|
LOCATION_SERVER_URL=http://socket_driver:2021
|
||||||
|
RIDE_SOCKET_URL=http://socket_passenger:3031
|
||||||
|
INTERNAL_KEY=
|
||||||
|
|
||||||
|
# --- مزودات خارجية (OTP / دفع / خرائط) — انسخ من اللايف عند الحاجة للاختبار ---
|
||||||
|
# SMS_USERNAME=
|
||||||
|
# SMS_PASSWORD_EGYPT=
|
||||||
|
# ...
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# سيرو في حاويات — دليل التشغيل والفحص
|
||||||
|
|
||||||
|
> الهدف: نسخة سيرو كاملة على سيرفر واحد داخل compose واحدة، والتطبيق لا يتغير فيه
|
||||||
|
> شيء سوى الـ base URLs (تُبنى مرة واحدة بـ `.env` الجديد — envied وقت الترجمة).
|
||||||
|
> القرارات والأسباب في `Tripz/docs/30-siro-port-plan.md` §5.
|
||||||
|
|
||||||
|
## 0) قبل أي شيء — على السيرفر
|
||||||
|
```bash
|
||||||
|
df -h / # ⚠️ الصندوق المشترك مزمنياً ~90% ممتلئ — الصور والقواعد تحتاج ~4-6GB
|
||||||
|
docker image prune -f # نظّف أولاً، وإن لم تكفِ المساحة فالفحص على صندوق آخر
|
||||||
|
```
|
||||||
|
|
||||||
|
## 1) الإعداد (مرة واحدة)
|
||||||
|
```bash
|
||||||
|
cd /path/to/Siro/docker
|
||||||
|
cp .env.example .env && nano .env # املأ الأسرار من قيم اللايف — خاصة JWT_SECRET وكلمات MySQL
|
||||||
|
# ⚠️ طابق أسماء القواعد في mysql/init/00-databases.sql مع ما يتوقعه الكود
|
||||||
|
docker compose build
|
||||||
|
# تبعيات composer للخدمات التي لا تحمل vendor/ داخلها:
|
||||||
|
docker compose run --rm socket_driver composer install
|
||||||
|
docker compose run --rm socket_passenger composer install
|
||||||
|
docker compose run --rm --workdir /var/www/backend php composer install
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2) التشغيل والاستيراد
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
# استيراد السكيمات (أول مرة فقط):
|
||||||
|
docker compose exec -T mysql mysql -uroot -p"$MYSQL_ROOT_PASSWORD" siro_primary < ../backend/schema_primary.sql
|
||||||
|
docker compose exec -T mysql mysql -uroot -p"$MYSQL_ROOT_PASSWORD" siro_ride < ../backend/schema_ride.sql
|
||||||
|
docker compose exec -T mysql mysql -uroot -p"$MYSQL_ROOT_PASSWORD" siro_tracking < ../backend/schema_tracking.sql
|
||||||
|
docker compose exec -T mysql mysql -uroot -p"$MYSQL_ROOT_PASSWORD" siro_wallet < ../payment_server/WalletDB.sql
|
||||||
|
docker compose exec -T mysql mysql -uroot -p"$MYSQL_ROOT_PASSWORD" siro_location < ../loction_server/locationDB.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3) التحقق
|
||||||
|
```bash
|
||||||
|
docker compose ps # الست خدمات up
|
||||||
|
curl -s http://localhost:8080/backend/index.html # nginx→ملفات
|
||||||
|
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8080/backend/connect.php # يرد (401/400 طبيعي بلا JWT)
|
||||||
|
curl -s http://localhost:8080/fpm-status # حالة حوض fpm
|
||||||
|
docker compose logs socket_driver | tail # Workerman أقلع على 2020/2021
|
||||||
|
```
|
||||||
|
اتصال التطبيق للتجربة: `http://IP:8080/backend` + سوكيت `http://IP:2020` — فقط بدّل
|
||||||
|
الـ base URLs في `.env` تبع التطبيق وأعد البناء. `APP_DOMAIN` فارغ = حارس
|
||||||
|
REGION_MISMATCH متعطل أثناء التجربة.
|
||||||
|
|
||||||
|
## 4) اختبار الضغط (الهدف: 10-20 ألف رحلة/ساعة)
|
||||||
|
```bash
|
||||||
|
cd ../stress_test && npm install
|
||||||
|
# دخان أولاً (الأداة القديمة، دفعة صغيرة):
|
||||||
|
node load_test.js --trips=10 -b http://localhost:8080/backend -s http://localhost:2020
|
||||||
|
# ثم المعدل المستمر:
|
||||||
|
node rate_test.js --rate 10000 --duration 10 -b http://localhost:8080/backend -s http://localhost:2020
|
||||||
|
node rate_test.js --rate 20000 --duration 10 -b http://localhost:8080/backend -s http://localhost:2020
|
||||||
|
node rate_test.js --rate 30000 --duration 5 ... # حد الانهيار — أين تبدأ p95 بالقفز؟
|
||||||
|
```
|
||||||
|
**أثناء التشغيل راقب من طرفية ثانية:**
|
||||||
|
```bash
|
||||||
|
docker stats # cpu/ذاكرة كل حاوية
|
||||||
|
docker compose exec mysql sh -c 'tail -f /var/lib/mysql/slow.log' # استعلامات >200ms
|
||||||
|
watch -n2 'curl -s http://localhost:8080/fpm-status' # طابور fpm (listen queue)
|
||||||
|
```
|
||||||
|
|
||||||
|
### قواعد قراءة النتيجة بصدق
|
||||||
|
1. **سيناريو الاختبار أخف من الرحلة الحقيقية بـ 3-5×** (4 نداءات API + 6 نبضات موقع؛
|
||||||
|
الحقيقية فيها تسعير وبحث وقرب ودردشة). لتبنّي رقم "10k رحلة حقيقية بالذروة"
|
||||||
|
يجب أن يعبر الاختبار **30k** بنجاح >99% و p95<500ms.
|
||||||
|
2. الصندوق مشغول ~40% بغيرنا — **افحص خارج الذروة وبجولات قصيرة (5-10 د)**،
|
||||||
|
وأعد الجولة مرتين للتأكد من ثبات الرقم.
|
||||||
|
3. مولّد الحمل على نفس الصندوق يسرق ~5% CPU عند هذه المعدلات — مقبول؛ الأدق
|
||||||
|
تشغيله من جهاز آخر نحو `http://IP:8080`.
|
||||||
|
4. المُلتزم به للعملاء = الرقم الذي عبر الاختبار، لا أكثر.
|
||||||
|
|
||||||
|
## 5) ملاحظات أداء محسومة (لماذا الحاويات لا تبطئنا)
|
||||||
|
- الحاوية على لينكس = عمليات عادية بنفس النواة (namespaces/cgroups) — **لا محاكاة ولا آلة افتراضية**. حمل CPU/ذاكرة ≈ صفر.
|
||||||
|
- المكان الوحيد الذي يُخسر فيه أداء فعلاً: كتابة قاعدة البيانات فوق overlayfs — **محلول** بـ volume مسمّى (`mysql-data`).
|
||||||
|
- أداء PHP الحقيقي يصنعه opcache + مقاس حوض fpm + فهارس MySQL — كلها مضبوطة في `php/` وليست متعلقة بالدوكر أصلاً.
|
||||||
|
- التقسيم إلى 6 حاويات لا يكلف أداءً (نفس العمليات) ويعطي: إعادة تشغيل خدمة وحدها، حدود ذاكرة لكل خدمة، لوغات منفصلة، واستنساخ عميل = `clone + .env + up`.
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# ============================================================
|
||||||
|
# سيرو في حاويات — نسخة واحدة كاملة على سيرفر واحد
|
||||||
|
# ============================================================
|
||||||
|
# المبدأ: التقسيم بالدور (nginx / fpm / سوكيتات / mysql / redis)
|
||||||
|
# لا حاوية-واحدة-فيها-كل-شيء، ولا تفتيت microservices.
|
||||||
|
# الأداء = نفس العمليات على نفس النواة؛ الفرق تشغيلي فقط.
|
||||||
|
#
|
||||||
|
# التشغيل: docker compose up -d --build (انظر README.md)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
name: siro
|
||||||
|
|
||||||
|
services:
|
||||||
|
nginx:
|
||||||
|
image: nginx:1.27-alpine
|
||||||
|
ports:
|
||||||
|
- "${HTTP_PORT:-8080}:80"
|
||||||
|
volumes:
|
||||||
|
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
- ../backend:/var/www/backend:ro
|
||||||
|
- ../payment_server/v2:/var/www/v2:ro
|
||||||
|
depends_on:
|
||||||
|
- php
|
||||||
|
mem_limit: 256m
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# php-fpm واحدة تخدم كل الخدمات الطلبية (backend + payment v2)
|
||||||
|
# التقسيم لأبعد من هذا لا يضيف أداءً — نفس الصورة، نفس النواة.
|
||||||
|
php:
|
||||||
|
build:
|
||||||
|
context: ./php
|
||||||
|
dockerfile: Dockerfile.fpm
|
||||||
|
args:
|
||||||
|
PHP_VERSION: "${PHP_VERSION:-8.2}"
|
||||||
|
volumes:
|
||||||
|
- ../backend:/var/www/backend
|
||||||
|
- ../payment_server/v2:/var/www/v2
|
||||||
|
- ./php/opcache.ini:/usr/local/etc/php/conf.d/zz-opcache.ini:ro
|
||||||
|
- ./php/www-pool.conf:/usr/local/etc/php-fpm.d/zz-pool.conf:ro
|
||||||
|
env_file: .env
|
||||||
|
depends_on:
|
||||||
|
- mysql
|
||||||
|
- redis
|
||||||
|
mem_limit: 2g
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# سوكيت السائقين (Workerman + PHPSocketIO) — WS بورت 2020 + HTTP داخلي 2021
|
||||||
|
# عملية دائمة، لا علاقة لها بـ fpm — حاوية مستقلة إجبارياً.
|
||||||
|
socket_driver:
|
||||||
|
build:
|
||||||
|
context: ./php
|
||||||
|
dockerfile: Dockerfile.socket
|
||||||
|
args:
|
||||||
|
PHP_VERSION: "${PHP_VERSION:-8.2}"
|
||||||
|
command: ["php", "driver_socket.php", "start"]
|
||||||
|
working_dir: /app
|
||||||
|
volumes:
|
||||||
|
- ../loction_server:/app
|
||||||
|
env_file: .env
|
||||||
|
ports:
|
||||||
|
- "2020:2020" # WS للسائقين — عام
|
||||||
|
# 2021 داخلي فقط: الباك إند يناديه عبر http://socket_driver:2021
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
mem_limit: 768m
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# سوكيت الركاب (ride_server) — WS بورت 3030 + HTTP داخلي 3031
|
||||||
|
socket_passenger:
|
||||||
|
build:
|
||||||
|
context: ./php
|
||||||
|
dockerfile: Dockerfile.socket
|
||||||
|
args:
|
||||||
|
PHP_VERSION: "${PHP_VERSION:-8.2}"
|
||||||
|
command: ["php", "passenger_socket.php", "start"]
|
||||||
|
working_dir: /app
|
||||||
|
volumes:
|
||||||
|
- ../ride_server:/app
|
||||||
|
env_file: .env
|
||||||
|
ports:
|
||||||
|
- "3030:3030"
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
mem_limit: 512m
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
mysql:
|
||||||
|
image: mysql:8.4
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: "${MYSQL_ROOT_PASSWORD:?set in .env}"
|
||||||
|
# بيانات القاعدة على volume مسمّى — يتجاوز overlayfs = أداء قرص شبه أصلي.
|
||||||
|
volumes:
|
||||||
|
- mysql-data:/var/lib/mysql
|
||||||
|
- ./mysql/init:/docker-entrypoint-initdb.d:ro
|
||||||
|
command:
|
||||||
|
- --innodb-buffer-pool-size=1G
|
||||||
|
- --max-connections=300
|
||||||
|
- --innodb-flush-log-at-trx-commit=1
|
||||||
|
- --slow-query-log=1
|
||||||
|
- --long-query-time=0.2
|
||||||
|
- --slow-query-log-file=/var/lib/mysql/slow.log
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:33060:3306" # للإدارة من السيرفر نفسه فقط — ليس للعامة
|
||||||
|
mem_limit: 2560m
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
command:
|
||||||
|
- redis-server
|
||||||
|
- --maxmemory
|
||||||
|
- 400mb
|
||||||
|
- --maxmemory-policy
|
||||||
|
- noeviction
|
||||||
|
- --appendonly
|
||||||
|
- "yes"
|
||||||
|
volumes:
|
||||||
|
- redis-data:/data
|
||||||
|
mem_limit: 512m
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mysql-data:
|
||||||
|
redis-data:
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- تُنشأ القواعد فارغة عند أول إقلاع فقط (volume جديد).
|
||||||
|
-- ⚠️ عدّل الأسماء لتطابق ما تتوقعه ملفات .env في اللايف قبل أول تشغيل.
|
||||||
|
CREATE DATABASE IF NOT EXISTS siro_primary CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
CREATE DATABASE IF NOT EXISTS siro_ride CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
CREATE DATABASE IF NOT EXISTS siro_tracking CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
CREATE DATABASE IF NOT EXISTS siro_wallet CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
CREATE DATABASE IF NOT EXISTS siro_location CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# نقطة واحدة لكل الخدمات الطلبية — التطبيق يغيّر الدومين فقط:
|
||||||
|
# /backend/... → الباك إند الرئيسي (نفس شكل مسارات jordan-siro)
|
||||||
|
# /v2/main/... → خادم المدفوعات
|
||||||
|
# السوكيتات تُنشر مباشرة من حاوياتها (2020 / 3030) — لا تمر من هنا.
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /var/www;
|
||||||
|
index index.php index.html;
|
||||||
|
client_max_body_size 25m;
|
||||||
|
|
||||||
|
access_log off; # أثناء اختبار الضغط؛ فعّله عند الحاجة
|
||||||
|
error_log /dev/stderr warn;
|
||||||
|
|
||||||
|
location ~ \.php$ {
|
||||||
|
try_files $uri =404;
|
||||||
|
include fastcgi_params;
|
||||||
|
fastcgi_pass php:9000;
|
||||||
|
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||||
|
fastcgi_read_timeout 60s;
|
||||||
|
fastcgi_buffers 16 16k;
|
||||||
|
fastcgi_buffer_size 32k;
|
||||||
|
}
|
||||||
|
|
||||||
|
# حالة حوض fpm — من داخل شبكة الحاويات أو من السيرفر نفسه فقط
|
||||||
|
location = /fpm-status {
|
||||||
|
allow 172.16.0.0/12;
|
||||||
|
allow 127.0.0.1;
|
||||||
|
deny all;
|
||||||
|
include fastcgi_params;
|
||||||
|
fastcgi_pass php:9000;
|
||||||
|
fastcgi_param SCRIPT_NAME /status;
|
||||||
|
fastcgi_param SCRIPT_FILENAME /status;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ =404;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# صورة الخدمات الطلبية (backend + payment v2)
|
||||||
|
ARG PHP_VERSION=8.2
|
||||||
|
FROM php:${PHP_VERSION}-fpm
|
||||||
|
|
||||||
|
ADD https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/
|
||||||
|
RUN chmod +x /usr/local/bin/install-php-extensions && \
|
||||||
|
install-php-extensions mysqli pdo_mysql redis opcache gd zip @composer
|
||||||
|
|
||||||
|
WORKDIR /var/www
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# صورة السوكيتات الدائمة (Workerman) — driver_socket و passenger_socket
|
||||||
|
# ext-event ضرورية فوق ~1024 اتصال متزامن (Workerman يلتقطها تلقائياً إن وُجدت)
|
||||||
|
ARG PHP_VERSION=8.2
|
||||||
|
FROM php:${PHP_VERSION}-cli
|
||||||
|
|
||||||
|
ADD https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/
|
||||||
|
RUN chmod +x /usr/local/bin/install-php-extensions && \
|
||||||
|
install-php-extensions pcntl posix sockets event redis @composer
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
; opcache هو الفرق الحقيقي في أداء PHP — ليس الدوكر
|
||||||
|
opcache.enable=1
|
||||||
|
opcache.memory_consumption=192
|
||||||
|
opcache.interned_strings_buffer=16
|
||||||
|
opcache.max_accelerated_files=20000
|
||||||
|
; الكود bind-mounted في التجربة → إعادة تحقق كل 60 ثانية
|
||||||
|
; عند التحويل لمنتج (كود مخبوز في الصورة): اجعلها 0 مع validate_timestamps=0
|
||||||
|
opcache.validate_timestamps=1
|
||||||
|
opcache.revalidate_freq=60
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
; حوض fpm مضبوط لصندوق مشترك 6 أنوية / 12GB (حصة الحاوية 2GB)
|
||||||
|
; قانون Little: 170 rps × 0.05 ثانية ≈ 9 طلبات متزامنة فقط — 48 عاملاً فيها هامش واسع
|
||||||
|
[www]
|
||||||
|
pm = dynamic
|
||||||
|
pm.max_children = 48
|
||||||
|
pm.start_servers = 8
|
||||||
|
pm.min_spare_servers = 4
|
||||||
|
pm.max_spare_servers = 16
|
||||||
|
pm.max_requests = 1000
|
||||||
|
pm.status_path = /status
|
||||||
|
slowlog = /proc/self/fd/2
|
||||||
|
request_slowlog_timeout = 3s
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Siro Backend Migration Plan & Docker Architecture (Docs/30)
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
This document outlines the architectural decisions made for migrating the Siro backend to a containerized Docker environment. It addresses questions regarding performance overhead, container granularity, and technology stack choices (PHP vs Node.js).
|
||||||
|
|
||||||
|
## 2. Docker Overhead vs Bare Metal
|
||||||
|
- **Finding:** Docker on Linux is not a virtual machine. It utilizes Linux namespaces and cgroups to isolate processes.
|
||||||
|
- **CPU & Memory:** The CPU and Memory overhead of running PHP inside Docker is effectively **zero** compared to bare metal. The processes run natively on the host kernel.
|
||||||
|
- **Disk I/O:** The only measurable overhead comes from writing to the container's overlayfs (file system).
|
||||||
|
- **Solution:** We mitigate this by using **named volumes** for MySQL data (`mysql-data`), which bypasses overlayfs and provides near-native disk performance.
|
||||||
|
- **Conclusion:** Docker is heavily recommended. It gives you instant reproducibility (clone -> `.env` -> up) with a negligible performance difference (0-3%). The real performance factors are PHP opcache, MySQL indexes, and fpm pool sizes, all of which are properly tuned in the container configurations.
|
||||||
|
|
||||||
|
## 3. Container Strategy: Single vs Multiple Containers
|
||||||
|
- **Finding:** Grouping everything (Nginx, PHP-FPM, MySQL, Redis, Node/Workerman) into a single container is considered a Docker anti-pattern.
|
||||||
|
- **Decision:** The architecture uses **6 specialized containers** running within a single server using Docker Compose:
|
||||||
|
1. `nginx`: Handles incoming HTTP traffic and reverse proxies.
|
||||||
|
2. `php`: A single PHP-FPM container that handles all HTTP API requests (backend + payment).
|
||||||
|
3. `socket_driver`: PHP CLI container running Workerman for driver WebSockets (Port 2020).
|
||||||
|
4. `socket_passenger`: PHP CLI container running Workerman for passenger WebSockets (Port 3030).
|
||||||
|
5. `mysql`: Database container.
|
||||||
|
6. `redis`: Caching container.
|
||||||
|
- **Why?** Performance is identical to a monolithic single-container setup because all processes still run on the same server/kernel. However, this split gives critical operational advantages: independent service restarts, individual memory limits (e.g., preventing MySQL from consuming all RAM and crashing PHP), and separated logs. Over-segmenting beyond this (microservices) would be overkill.
|
||||||
|
|
||||||
|
## 4. WebSockets: PHP (Workerman) vs Node.js
|
||||||
|
- **Finding:** Node.js uses an event-loop and is natively non-blocking, making it extremely lightweight for massive WebSocket concurrency. PHP-FPM allocates a process per request, which is bad for WebSockets.
|
||||||
|
- **However,** the current implementation uses **Workerman** for PHP WebSockets. Workerman implements the exact same event-loop model as Node.js within PHP.
|
||||||
|
- **Scale:** At a target of 10,000 to 20,000 trips per hour, the system experiences roughly 3 to 6 trips per second. This load is well within the capabilities of both Workerman and Node.js.
|
||||||
|
- **Conclusion:** There is no need to rewrite the WebSockets in Node.js. Workerman handles the event-driven load efficiently. The real bottleneck will be the database and complex business logic, not the socket engine.
|
||||||
|
|
||||||
|
## 5. Environment Variables (.env)
|
||||||
|
- The architecture mounts the `backend` directory directly into the `php` container.
|
||||||
|
- The `.env` file can safely reside in the root `backend` or project directory on the server as it currently does.
|
||||||
|
- By passing `env_file: .env` to the containers in `docker-compose.yml`, Docker injects the environment variables natively, making them available via PHP's `getenv()`. This requires zero code changes to the PHP application.
|
||||||
|
|
||||||
|
## 6. Stress Testing
|
||||||
|
- A continuous stress test script (`rate_test.js`) is used instead of a batch load tester.
|
||||||
|
- It generates JWTs natively in Node to avoid blocking on PHP processes.
|
||||||
|
- The success criteria for 10,000 trips/hour is less than 1% failure rate and a p95 response time under 500ms.
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
/**
|
||||||
|
* rate_test.js — اختبار ضغط بمعدل مستمر (رحلات/ساعة) مع percentiles
|
||||||
|
* ==================================================================
|
||||||
|
* يكمل load_test.js (الذي يبقى لفحص الدخان بدفعة واحدة). الفروق:
|
||||||
|
* 1. معدل وصول مستمر: --rate رحلة/ساعة طوال --duration دقيقة (مع ramp)
|
||||||
|
* 2. توليد JWT داخل Node (HS256 عبر crypto) — لا exec لعملية PHP لكل رحلة
|
||||||
|
* (كانت execSync تخنق مولّد الحمل نفسه فوق ~2 رحلة/ثانية)
|
||||||
|
* 3. قياس p50/p95/p99 لكل نقطة نداء + زمن العرض + الرحلة كاملة
|
||||||
|
*
|
||||||
|
* أمثلة:
|
||||||
|
* node rate_test.js --rate 10000 --duration 10 -b http://localhost:8080/backend -s http://localhost:2020
|
||||||
|
* node rate_test.js --rate 20000 --duration 5 --env ../backend/.env
|
||||||
|
*/
|
||||||
|
|
||||||
|
const io = require('socket.io-client');
|
||||||
|
const axios = require('axios');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const yargs = require('yargs/yargs');
|
||||||
|
const { hideBin } = require('yargs/helpers');
|
||||||
|
|
||||||
|
const argv = yargs(hideBin(process.argv))
|
||||||
|
.option('rate', { alias: 'r', type: 'number', default: 10000, description: 'رحلات في الساعة' })
|
||||||
|
.option('duration', { alias: 'd', type: 'number', default: 10, description: 'مدة الاختبار بالدقائق' })
|
||||||
|
.option('ramp', { type: 'number', default: 1, description: 'دقائق التصعيد التدريجي' })
|
||||||
|
.option('base-url', { alias: 'b', type: 'string', default: 'http://localhost:8080/backend' })
|
||||||
|
.option('socket-url', { alias: 's', type: 'string', default: 'http://localhost:2020' })
|
||||||
|
.option('env', { type: 'string', default: path.join(__dirname, '..', 'backend', '.env'), description: 'ملف .env لقراءة سر JWT' })
|
||||||
|
.option('jwt-secret', { type: 'string', description: 'تجاوز مباشر لسر JWT' })
|
||||||
|
.argv;
|
||||||
|
|
||||||
|
// ── سر JWT: نفس أولويات generate_mock_data.php ──
|
||||||
|
function loadSecret() {
|
||||||
|
if (argv.jwtSecret) return argv.jwtSecret;
|
||||||
|
const env = {};
|
||||||
|
try {
|
||||||
|
for (const line of fs.readFileSync(argv.env, 'utf8').split('\n')) {
|
||||||
|
const t = line.trim();
|
||||||
|
if (!t || t.startsWith('#') || !t.includes('=')) continue;
|
||||||
|
const i = t.indexOf('=');
|
||||||
|
env[t.slice(0, i).trim()] = t.slice(i + 1).trim().replace(/^["']|["']$/g, '');
|
||||||
|
}
|
||||||
|
} catch (e) { /* يسقط للفحص أدناه */ }
|
||||||
|
if (env.JWT_SECRET_KEY_PATH && fs.existsSync(env.JWT_SECRET_KEY_PATH))
|
||||||
|
return fs.readFileSync(env.JWT_SECRET_KEY_PATH, 'utf8').trim();
|
||||||
|
return env.JWT_SECRET_KEY || env.JWT_SECRET || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const b64u = (buf) => Buffer.from(buf).toString('base64url');
|
||||||
|
function makeJwt(secret, driverId) {
|
||||||
|
const header = b64u(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
|
||||||
|
const body = b64u(JSON.stringify({ sub: String(driverId), role: 'driver', iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 86400 }));
|
||||||
|
const sig = crypto.createHmac('sha256', secret).update(`${header}.${body}`).digest('base64url');
|
||||||
|
return `${header}.${body}.${sig}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── مقاييس ──
|
||||||
|
const lat = { socket_connect: [], add_ride: [], offer_delay: [], accept: [], start: [], finish: [], trip_e2e: [] };
|
||||||
|
const counters = { launched: 0, ok: 0, fail: 0, socketErr: 0, apiErr: 0, timeout: 0 };
|
||||||
|
let active = 0;
|
||||||
|
|
||||||
|
function pct(arr, p) {
|
||||||
|
if (!arr.length) return 0;
|
||||||
|
const s = [...arr].sort((a, b) => a - b);
|
||||||
|
return s[Math.min(s.length - 1, Math.floor((p / 100) * s.length))];
|
||||||
|
}
|
||||||
|
const fmt = (a) => `p50=${pct(a, 50)}ms p95=${pct(a, 95)}ms p99=${pct(a, 99)}ms max=${Math.max(0, ...a)}ms (n=${a.length})`;
|
||||||
|
|
||||||
|
async function post(name, url, form) {
|
||||||
|
const t0 = Date.now();
|
||||||
|
try {
|
||||||
|
const res = await axios.post(url, new URLSearchParams(form).toString(), {
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, timeout: 15000,
|
||||||
|
});
|
||||||
|
lat[name].push(Date.now() - t0);
|
||||||
|
return res;
|
||||||
|
} catch (e) {
|
||||||
|
counters.apiErr++;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function simulateTrip(i, secret) {
|
||||||
|
const driverId = 900000 + i, passengerId = 800000 + i;
|
||||||
|
const jwt = makeJwt(secret, driverId);
|
||||||
|
const t0 = Date.now();
|
||||||
|
active++;
|
||||||
|
counters.launched++;
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let done = false, addRideAt = 0;
|
||||||
|
const socket = io(argv.socketUrl, {
|
||||||
|
query: { driver_id: String(driverId), platform: 'android', jwt },
|
||||||
|
reconnection: false, timeout: 10000, forceNew: true,
|
||||||
|
});
|
||||||
|
const finish = (ok) => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
ok ? counters.ok++ : counters.fail++;
|
||||||
|
if (ok) lat.trip_e2e.push(Date.now() - t0);
|
||||||
|
active--;
|
||||||
|
try { socket.disconnect(); } catch (_) {}
|
||||||
|
resolve(ok);
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.on('connect_error', () => { counters.socketErr++; finish(false); });
|
||||||
|
socket.on('error', () => { counters.socketErr++; finish(false); });
|
||||||
|
|
||||||
|
socket.on('connect', async () => {
|
||||||
|
lat.socket_connect.push(Date.now() - t0);
|
||||||
|
try {
|
||||||
|
addRideAt = Date.now();
|
||||||
|
await post('add_ride', `${argv.baseUrl}/ride/rides/add_ride.php`, {
|
||||||
|
passenger_id: String(passengerId),
|
||||||
|
start_location: '31.95,35.91', end_location: '31.96,35.92',
|
||||||
|
price: '5', price_token: 'dummy', distance: '2', carType: 'Economy',
|
||||||
|
passenger_name: 'Test Passenger', passenger_phone: '0790000000',
|
||||||
|
start_name: 'Start', end_name: 'End',
|
||||||
|
});
|
||||||
|
} catch (_) { finish(false); }
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('market_new_ride', async (payload) => {
|
||||||
|
const rideId = payload.id || payload[0]?.id;
|
||||||
|
if (!rideId) return finish(false);
|
||||||
|
lat.offer_delay.push(Date.now() - addRideAt);
|
||||||
|
try {
|
||||||
|
await post('accept', `${argv.baseUrl}/ride/rides/acceptRide.php`, { id: String(rideId), driver_id: String(driverId), status: 'accepted' });
|
||||||
|
let steps = 0;
|
||||||
|
const iv = setInterval(async () => {
|
||||||
|
steps++;
|
||||||
|
socket.emit('update_location', { lat: 31.95 + steps * 0.001, lng: 35.91 + steps * 0.001, heading: 90, speed: 40, status: 'on' });
|
||||||
|
try {
|
||||||
|
if (steps === 3) await post('start', `${argv.baseUrl}/ride/rides/start_ride.php`, { id: String(rideId), driver_id: String(driverId), status: 'start' });
|
||||||
|
if (steps === 6) {
|
||||||
|
clearInterval(iv);
|
||||||
|
await post('finish', `${argv.baseUrl}/ride/rides/finish_ride_updates.php`, { id: String(rideId), driver_id: String(driverId), status: 'finish' });
|
||||||
|
finish(true);
|
||||||
|
}
|
||||||
|
} catch (_) { clearInterval(iv); finish(false); }
|
||||||
|
}, 1000);
|
||||||
|
} catch (_) { finish(false); }
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => { if (!done) { counters.timeout++; finish(false); } }, 45000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const secret = loadSecret();
|
||||||
|
if (!secret) {
|
||||||
|
console.error(`❌ لا يوجد سر JWT — مرّر --jwt-secret أو --env لملف فيه JWT_SECRET_KEY (جرّبت: ${argv.env})`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const perSec = argv.rate / 3600;
|
||||||
|
const totalMs = argv.duration * 60 * 1000;
|
||||||
|
const rampMs = Math.min(argv.ramp * 60 * 1000, totalMs);
|
||||||
|
console.log(`\n🚀 اختبار معدل مستمر: ${argv.rate} رحلة/ساعة (${perSec.toFixed(2)}/ث) لمدة ${argv.duration} دقيقة (تصعيد ${argv.ramp} د)`);
|
||||||
|
console.log(`📡 API: ${argv.baseUrl}\n🔌 Socket: ${argv.socketUrl}\n`);
|
||||||
|
|
||||||
|
const t0 = Date.now();
|
||||||
|
let i = 0;
|
||||||
|
const inflight = [];
|
||||||
|
|
||||||
|
const progress = setInterval(() => {
|
||||||
|
const el = ((Date.now() - t0) / 1000).toFixed(0);
|
||||||
|
console.log(`[${el}s] أُطلق=${counters.launched} نجح=${counters.ok} فشل=${counters.fail} جارٍ=${active} | add_ride p95=${pct(lat.add_ride, 95)}ms عرض p95=${pct(lat.offer_delay, 95)}ms`);
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
// مجدول مصحح الانحراف: يحسب كم رحلة كان يجب أن تكون قد انطلقت ويطلق الفرق
|
||||||
|
while (Date.now() - t0 < totalMs) {
|
||||||
|
const el = Date.now() - t0;
|
||||||
|
const rampFactor = el < rampMs ? el / rampMs : 1;
|
||||||
|
const due = Math.floor((el / 1000) * perSec * rampFactor);
|
||||||
|
while (i < due) { i++; inflight.push(simulateTrip(i, secret)); }
|
||||||
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n⏳ توقف الإطلاق (${counters.launched} رحلة) — انتظار الرحلات الجارية (${active})…`);
|
||||||
|
await Promise.allSettled(inflight);
|
||||||
|
clearInterval(progress);
|
||||||
|
|
||||||
|
const durMin = ((Date.now() - t0) / 60000).toFixed(1);
|
||||||
|
const achieved = Math.round((counters.ok / ((Date.now() - t0) / 3600000)));
|
||||||
|
console.log(`\n${'='.repeat(60)}\n📊 النتائج (${durMin} دقيقة)\n${'='.repeat(60)}`);
|
||||||
|
console.log(`أُطلق: ${counters.launched} | نجح: ${counters.ok} | فشل: ${counters.fail} (سوكيت=${counters.socketErr} api=${counters.apiErr} مهلة=${counters.timeout})`);
|
||||||
|
console.log(`المعدل المحقق فعلياً: ~${achieved} رحلة/ساعة\n`);
|
||||||
|
for (const [k, v] of Object.entries(lat)) console.log(` ${k.padEnd(15)} ${fmt(v)}`);
|
||||||
|
console.log(`${'='.repeat(60)}`);
|
||||||
|
const pass = counters.fail / Math.max(1, counters.launched) < 0.01 && pct(lat.add_ride, 95) < 500;
|
||||||
|
console.log(pass ? '✅ الحكم: تحت العتبة (فشل <1% و p95<500ms)' : '⚠️ الحكم: فوق العتبة — انظر أبطأ نقطة أعلاه وسجل slow.log في MySQL');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
Reference in New Issue
Block a user