الأدلة التقنية

قبل أن تنشر تطبيقك على خادمك: قائمة تحقق للمطور

إذا كان تطبيقك سيعمل في Docker خلف Reverse Proxy، فهذه قائمة بالأخطاء التي لا تظهر عادة إلا بعد النشر، ومع كل بند ما يحدث إذا أهملته وكيف تعالجه قبل أن يصل إلى المستخدمين.

قبل أن تنشر تطبيقك على خادمك: قائمة تحقق للمطور

لنفرض أنك أنهيت تطبيقك، وكل الاختبارات تعمل على جهازك بدون أي مشكلة، ثم رفعته على السيرفر داخل Container وخلف Reverse Proxy، وربما خلف Cloudflare أيضاً، فسوف تتوقع أن يعمل كما كان يعمل عندك. ولكن في اليوم الأول قد تفتح السجلات Logs فتجد عنوان IP واحداً لكل الزوار، ويشتكي مستخدم من أن صفحة الدخول تدور في إعادة توجيه لا تنتهي، وتنقطع اتصالات WebSocket بعد دقيقة واحدة. والسبب في الغالب ليس خطأ في الكود نفسه، وإنما افتراض كان صحيحاً على جهازك ولم يعد صحيحاً بعد أن أصبح بين التطبيق والمستخدم عدة طبقات.

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

  • ما يتغير عندما يقف ال Reverse Proxy أمام تطبيقك: عنوان الزائر الحقيقي، وHTTPS، والرابط الأساسي، وWebSocket، وحجم الرفع.
  • ما يتغير داخل ال Container: الإعدادات والأسرار، والسجلات، والإيقاف النظيف، والبيانات الدائمة، وبناء ال Image، وترتيب التشغيل.
  • ما يظهر في بيئة الإنتاج بعد أسابيع: الترحيل، وتعدد النسخ، وفحوص الصحة، وSSRF، وCORS، والتخزين المؤقت خلف Cloudflare.
  • ما يخص التطبيقات العربية: الترميز، والبحث، والمناطق الزمنية.
  • ثم قائمة مختصرة تراجعها قبل كل نشر.

خلف الـ Reverse Proxy

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

عنوان IP الحقيقي للزائر والوكلاء الموثوقون (Trusted Proxies)

ال Proxy هو من فتح الاتصال بالتطبيق، لذلك يرى التطبيق عنوانه بدلاً من عنوان الزائر، والعنوان الحقيقي يصل في الترويسة X-Forwarded-For، وإذا كان Cloudflare في الطريق فإنه يصل أيضاً في الترويسة CF-Connecting-IP.

وإذا تجاهلت ذلك فسوف تعد حدود معدل الطلبات Rate Limiting كل الزوار زائراً واحداً، فإما أن يحظر التطبيق الجميع أو لا يحظر أحداً، وتفقد السجلات قيمتها عندما تحقق في أي حادثة. والخطأ المعاكس أخطر، وهو أن يثق التطبيق بهذه الترويسات أياً كان مصدرها، وعندها يستطيع أي شخص أن يرسل X-Forwarded-For: 127.0.0.1 ويظهر للتطبيق بالعنوان الذي يختاره.

والحل أن تحدد لإطار العمل Framework عدد ال Proxies التي تقف أمامه أو عناوينها بالضبط، ولا شيء غيرها، ففي Express يقوم بذلك الإعداد trust proxy، وفي Laravel يقوم بالمهمة نفسها إعداد Trusted Proxies:

// Proxy واحد أمام التطبيق، ومنفذ التطبيق غير منشور على الخادم
app.set('trust proxy', 1);

والرقم 1 يعني محطة واحدة، فيأخذ Express أول عنوان من يمين X-Forwarded-For، وإذا كان Cloudflare أمام ال Proxy فاكتب 2. ولا تثق بكل الشبكات الخاصة بقيمة مثل uniquelocal، والسبب أنها تشمل بوابة شبكة Docker، وقد يصل بعض الزوار من عنوان هذه البوابة كما شرحنا في الدليل المفصل، فيأخذ التطبيق عندها العنوان الذي كتبه الزائر نفسه. وإذا كان Cloudflare أمام ال Proxy، فاجعل ال Proxy يأخذ العنوان من CF-Connecting-IP فقط حين يأتي الطلب من عناوين Cloudflare المنشورة، وهذا ما تفعله الوحدة Module realip في Nginx.

🛑
إذا كان السيرفر يقبل الاتصال المباشر على المنفذين 80 و443 من أي عنوان، فبوسع المهاجم أن يتجاوز Cloudflare ويرسل CF-Connecting-IP بالقيمة التي يريدها، لذلك اقصر الاتصال على عناوين Cloudflare في الجدار الناري Firewall، أو استخدم Cloudflare Tunnel فلا يبقى على السيرفر منفذ مفتوح أصلاً.

التطبيق يظن أنه يعمل على HTTP

يستقبل ال Proxy الطلب عبر HTTPS ثم يمرره إلى التطبيق عبر HTTP داخل السيرفر، فإذا لم يقرأ التطبيق الترويسة X-Forwarded-Proto فسوف يظن أن الاتصال غير مشفر.

والنتائج معروفة لكل من مر بها، فقد يحول التطبيق الزائر إلى HTTPS في كل طلب فتدخل الصفحة في حلقة إعادة توجيه Redirect Loop، وقد يرفض إرسال Cookies عليها الخاصية Secure، أو يكتب في الصفحات والرسائل روابط تبدأ بـ http://، والمتصفحات تمنع تحميل السكربتات عبر HTTP داخل صفحة HTTPS، وهذا ما يسمى المحتوى المختلط Mixed Content.

والحل أن تفعل قراءة الترويسة في إطار العمل، ففي Django تفعلها بالإعداد SECURE_PROXY_SSL_HEADER، بشرط أن يضع ال Proxy الترويسة بنفسه ويحذف أي قيمة يرسلها الزائر، والسبب أن التطبيق سوف يصدق أي قيمة تصله فيها:

SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
CSRF_TRUSTED_ORIGINS = ["https://app.example.com"]

وإذا ظهرت الحلقة نفسها خلف Cloudflare فراجع وضع التشفير SSL Mode، ففي وضع Flexible يتصل Cloudflare بالسيرفر عبر HTTP، فيعيد التطبيق التوجيه إلى HTTPS بلا نهاية، والوضع المناسب هو Full (strict).

الرابط الأساسي (Base URL) والمسار الفرعي (Subpath)

يكتب التطبيق روابط كاملة في رسائل البريد وروابط إعادة تعيين كلمة المرور وصفحات ال API، ولهذا يحتاج إلى معرفة عنوانه العام، وكثير من التطبيقات لا تستنتج هذا العنوان من الطلب وإنما تقرؤه من إعداد صريح، مثل APP_URL في Laravel أو ROOT_URL في Gitea.

وإذا تركت القيمة الافتراضية فسوف تصل إلى المستخدمين روابط مثل http://localhost:3000/reset، وإذا نشرت التطبيق تحت مسار مثل example.com/app/ فقد تعمل الصفحة الرئيسية وتتعطل الملفات الثابتة والروابط الداخلية. ومزود الدخول عبر OAuth يرفض أي رابط عودة Redirect URI لا يطابق المسجل عنده حرفاً بحرف، كما توصي ممارسات أمان OAuth 2.0.

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

WebSocket والأحداث المرسلة من الخادم (SSE)

يبدأ اتصال WebSocket بطلب HTTP يحمل الترويسة Upgrade ثم يبقى مفتوحاً، والأحداث المرسلة من السيرفر Server-Sent Events هي استجابة HTTP طويلة يرسل فيها السيرفر البيانات على دفعات، وكلاهما يصطدم بإعدادات صممت للطلبات القصيرة.

فإذا لم يمرر ال Proxy الترويسة Upgrade فسوف يفشل الاتصال من بدايته، وقد يعمل الاتصال ثم ينقطع بعد دقيقة من الصمت، والسبب أن مهلة القراءة Timeout في Nginx هي 60 ثانية افتراضياً. وفي SSE قد يحبس ال Proxy البيانات في الذاكرة المؤقتة Buffer، فتصل الأحداث إلى المتصفح متأخرة دفعة واحدة.

والحل في Nginx أن تضيف ترويسات WebSocket وترفع المهلة لمسار الاتصال، وفي Nginx Proxy Manager فعل الخيار Websockets Support، أما Caddy وTraefik فيمرران WebSocket دون إعداد إضافي، ولكن Caddy يغلق هذه الاتصالات عندما يعيد تحميل إعداده.

proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 1h;

ولـ SSE أرسل من التطبيق الترويسة X-Accel-Buffering: no فيتوقف Nginx عن التخزين المؤقت لهذه الاستجابة، ثم أرسل رسالة نبض Heartbeat كل 20 أو 30 ثانية حتى لا يبدو الاتصال خاملاً. واكتب في الواجهة كوداً يعيد الاتصال تلقائياً، والسبب أن Cloudflare قد يقطع اتصالات WebSocket عندما يعيد تشغيل سيرفراته، وهذا يحدث مهما كان إعدادك صحيحاً.

حجم الرفع والطلبات الطويلة

كل ملف يرفعه المستخدم يمر بثلاث طبقات على الأقل، Cloudflare ثم ال Proxy ثم التطبيق، ولكل طبقة حد لحجم الطلب ومهلة للانتظار، والحد الذي يصطدم به المستخدم هو أصغرها.

وإذا تجاهلت ذلك فسوف يرى المستخدم الخطأ 413 مع ملفات كان يرفعها بلا مشكلة في بيئة التطوير، فالقيمة الافتراضية لـ client_max_body_size في Nginx هي ميجابايت واحد فقط. ورفع الملفات الكبيرة أو التقارير الثقيلة قد ينتهي بالخطأ 504 من ال Proxy، أو بالخطأ 524 من Cloudflare الذي ينتظر الرد 125 ثانية افتراضياً، ولا يمكن رفع هذه المهلة إلا في خطة Enterprise.

والحل أن تحدد أكبر ملف تقبله وتضبط الحد نفسه في كل طبقة، والمهام التي تستغرق دقائق انقلها إلى عمل في الخلفية Background Job يتابع المستخدم تقدمه، والسبب أن المهلة في Cloudflare ثابتة لا تملك تغييرها. وتجد القيم والإعدادات لكل أداة في دليل حدود حجم رفع الملفات.

داخل الـ Container

يختلف ال Container عن السيرفر العادي في أمور قليلة ولكنها حاسمة، فإن Docker يحذفه ويعيد إنشاءه مع كل تحديث، وتطبيقك فيه هو العملية الأولى، ويتصل بالخدمات الأخرى عبر شبكة Docker وليس عبر localhost. وإذا لم يكن Docker جاهزاً على السيرفر بعد، فابدأ بدليل تثبيت Docker على Ubuntu.

الإعدادات والأسرار (Secrets) خارج الـ Image

كلمات مرور قاعدة البيانات Database ومفاتيح ال API وإعدادات كل بيئة لا مكان لها داخل ال Image ولا في مستودع Git، والسبب أن ال Image ينتقل بين الأجهزة والسجلات Registries، وما يدخل Git يبقى في تاريخه حتى لو حذفته بعد ذلك.

وإذا تجاهلت ذلك فإن كل من يسحب ال Image يستطيع قراءة الأسرار منه، وقيم ARG تظهر لمن يشغل الأمر docker history، وقيم ENV محفوظة في إعدادات ال Image نفسه ويقرؤها أي شخص لديه نسخة منه. وسوف تحتاج كذلك إلى Image مختلف لكل بيئة، بدلاً من Image واحد تنقله من الاختبار إلى الإنتاج كما هو.

والحل أن تقرأ الإعدادات من متغيرات البيئة Environment Variables وتضع قيمها في ملف .env على السيرفر خارج Git، وللقيم الحساسة استخدم Secrets في Docker Compose فتظهر داخل ال Container ملفاً في /run/secrets/، وكثير من ال Images، ومنها PostgreSQL الرسمي، تقبل متغيرات تنتهي بـ _FILE تشير إلى هذا الملف. وإذا احتاج البناء نفسه إلى سر، مثل توكن الوصول إلى مستودع حزم خاص، فاستخدم Build Secrets بدلاً من ARG.

السجلات (Logs) إلى stdout وstderr

يجمع Docker كل ما يكتبه التطبيق إلى stdout وstderr ويعرضه لك الأمر docker compose logs، أما الملفات التي يكتبها التطبيق داخل ال Container فتضيع عندما يعاد إنشاؤه.

وإذا تجاهلت ذلك فلن تجد السجل الذي تحتاجه وقت المشكلة، والسجلات المكتوبة نصاً حراً يصعب البحث فيها ويصعب ربط أسطر الطلب الواحد ببعضها. ولاحظ أن مشغل السجلات الافتراضي json-file لا يقوم بتدوير السجلات Log Rotation افتراضياً، فقد يمتلئ القرص بسجلات تطبيق كثير الكلام وأنت لا تدري.

والحل أن تكتب السجلات إلى المخرج القياسي بصيغة JSON، سطراً لكل حدث، وأن تعطي كل طلب معرفاً Request ID تأخذه من الترويسة X-Request-ID إن أرسلها ال Proxy أو تنشئه بنفسك، ثم تضعه في كل سطر وفي الاستجابة. ثم اجعل المشغل local المشغل الافتراضي على السيرفر، والسبب أنه يقوم بتدوير السجلات تلقائياً، ولاحظ أن هذا الإعداد يسري على ال Containers الجديدة فقط:

{
  "log-driver": "local"
}

ضع هذا المحتوى في الملف /etc/docker/daemon.json ثم أعد تشغيل Docker، أما ال Containers الموجودة فتحتاج إلى إعادة إنشائها حتى تأخذ المشغل الجديد.

الإيقاف النظيف (Graceful Shutdown)

مع كل تحديث يرسل Docker الإشارة Signal SIGTERM إلى العملية الأولى في ال Container، ثم ينتظر قليلاً قبل أن يرسل SIGKILL، والمهلة الافتراضية 10 ثوان وتغيرها في Compose بالخاصية stop_grace_period.

فإذا لم يستقبل التطبيق الإشارة فسوف يقتل وهو في منتصف عمله، فتنقطع الطلبات على المستخدمين، ولا تكتمل المعاملات Transactions، وتتوقف المهام الخلفية في منتصفها. وهذا يحدث غالباً عندما تكتب CMD بالصيغة النصية Shell Form، والسبب أن هذه الصيغة تشغل تطبيقك عبر /bin/sh -c ولا تمرر الإشارات إلى التطبيق.

والحل أن تستخدم صيغة exec، فيصبح التطبيق نفسه العملية الأولى PID 1:

CMD ["node", "server.js"]

وإذا كان لديك سكربت بدء Entrypoint Script فاختمه بالأمر exec "$@"، ثم اكتب في التطبيق دالة تعالج SIGTERM، فيتوقف عن قبول طلبات جديدة، ويكمل الطلبات الجارية، ويغلق اتصالات قاعدة البيانات، ثم يخرج.

وللعملية الأولى في Linux وضع خاص، فهي لا تتوقف بالإشارة إلا إذا عالجتها بنفسها، وعليها أن تنظف العمليات الفرعية المنتهية Zombie Processes. لذلك إذا كان التطبيق يشغل عمليات فرعية فأضف init: true في Compose، وعندها يضع Docker أمام تطبيقك عملية init صغيرة مبنية على tini، تمرر إليه الإشارات وتنظف العمليات المنتهية.

البيانات الدائمة في Volumes وصلاحيات المستخدم

كل ما يكتبه التطبيق داخل ال Container يختفي عندما يعاد إنشاؤه، أي مع كل تحديث لل Image، لذلك ضع الملفات المرفوعة وقواعد بيانات SQLite وكل ما يجب أن يبقى في Volume أو في مجلد مربوط من السيرفر Bind Mount.

وإذا تجاهلت ذلك فسوف تكتشف بعد أول تحديث أن ملفات المستخدمين اختفت. والتطبيق الذي يعمل بمستخدم غير root، وهذا هو الصحيح، سوف يصطدم بالخطأ Permission denied عندما يكتب في مجلد يملكه root على السيرفر، والسبب أن الصلاحيات Permissions تعتمد على رقمي المستخدم والمجموعة UID/GID وليس على اسميهما.

والحل أن تحدد في Compose المجلدات التي يكتب فيها التطبيق وتربط كل مجلد منها بـ Volume، ثم تجعل مالك المجلد المربوط هو المستخدم الذي يعمل به ال Container:

sudo chown -R 1000:1000 /opt/app/uploads

أما ال Volume المسمى Named Volume، فإذا كان فارغاً عند أول استخدام فإن Docker ينسخ إليه محتوى المجلد من ال Image مع مالكه، لذلك يكفي أن تنشئ المجلد في ال Dockerfile وتغير مالكه قبل التعليمة USER.

Image نظيف وصغير

ال Image الذي تبنيه بخطوة واحدة يحمل معه أدوات البناء ومكتبات التطوير، وأحياناً ملفات لم تقصد إدخالها مثل .git و.env، والوسم latest لل Image الأساسي Base Image قد يشير إلى إصدار مختلف في كل بناء دون أن تنتبه.

والنتيجة Image أكبر وأبطأ في السحب، وفيه ثغرات أكثر في أدوات لا يحتاجها التطبيق، وقد يتعطل البناء فجأة لأن ال Base Image انتقل إلى إصدار جديد. وإذا عمل التطبيق بالمستخدم root فإن أي ثغرة فيه تعطي المهاجم صلاحيات أوسع داخل ال Container.

والحل أن تستخدم البناء متعدد المراحل Multi-stage Build، وأن تثبت إصدار ال Base Image، وأن تشغل التطبيق بمستخدم عادي عبر التعليمة USER، وتجد هذه النصائح وغيرها في أفضل ممارسات البناء في توثيق Docker:

FROM node:24-trixie-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:24-trixie-slim
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]

وأضف ملف .dockerignore حتى لا تدخل هذه الملفات في سياق البناء من الأساس:

.git
.env
node_modules
*.log

ترتيب التشغيل والاتصال بين الخدمات

يشغل Docker Compose الخدمات كلها في وقت واحد تقريباً، فقد يحاول التطبيق الاتصال بقاعدة البيانات قبل أن تجهز لاستقبال الاتصالات فيتوقف، وهناك خطأ شائع آخر وهو أن تكتب localhost في عنوان قاعدة البيانات كما كنت تفعل على جهاز التطوير.

وقد يتساءل البعض: لماذا لا يعمل localhost وقاعدة البيانات على السيرفر نفسه؟ والإجابة أن localhost داخل ال Container يشير إلى ال Container نفسه، وليس إلى السيرفر ولا إلى الخدمات الأخرى، لذلك يفشل الاتصال برسالة connection refused. والصحيح أن تستخدم اسم الخدمة في Compose، مثل db:5432، وإذا احتجت فعلاً إلى خدمة تعمل على السيرفر نفسه خارج Docker، فاستخدم الاسم host.docker.internal مع القيمة host-gateway.

والحل لمشكلة الترتيب أن تجعل التطبيق ينتظر حتى ينجح فحص الصحة Healthcheck الخاص بقاعدة البيانات، كما يشرح دليل ترتيب التشغيل في توثيق Docker:

services:
  app:
    environment:
      DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/app
    depends_on:
      db:
        condition: service_healthy
        restart: true
  db:
    image: postgres:18
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 10s
      retries: 5

في الإعداد أعلاه لاحظ أن condition: service_healthy يؤخر تشغيل التطبيق حتى تنجح قاعدة البيانات في الفحص، وأن restart: true يعيد تشغيل التطبيق إذا أعاد Compose تشغيل قاعدة البيانات بأمر منك مثل docker compose restart. ولكن هذا لا يغطي إعادة التشغيل التي تحدث من تلقاء نفسها بعد ساعات من بدء التطبيق، لذلك اكتب في التطبيق محاولات اتصال متكررة بفواصل تزداد تدريجياً، ولا تجعله يخرج عند أول فشل.

في بيئة الإنتاج (Production)

هذه البنود لا تظهر غالباً في الأسبوع الأول، وإنما تظهر عندما تنشر أول تغيير على قاعدة البيانات، أو تشغل نسخة ثانية من التطبيق، أو تضع Cloudflare أمامه.

ترحيل قاعدة البيانات (Migration) مع كل نشر

أغلب الإصدارات الجديدة تحتاج إلى تعديل في قاعدة البيانات، والسؤال هنا متى يتم الترحيل، ومن يقوم به، وماذا يحدث للإصدار القديم الذي ما زال يعمل أثناء ذلك؟

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

والحل أن تشغل الترحيل خطوة مستقلة قبل الإصدار الجديد، ثم تشغل التطبيق:

docker compose run --rm app npm run migrate
docker compose up -d

واكتب التغييرات بحيث يعمل معها الإصداران القديم والجديد، بأسلوب التوسعة ثم التقليص Expand/Contract. لنأخذ مثالاً تغيير اسم عمود: أضف العمود الجديد أولاً واكتب في العمودين، ثم انقل البيانات، ثم احذف القديم في نشر لاحق بعد أن يتوقف كل كود يقرأ منه. وخذ نسخة احتياطية Backup من القاعدة قبل كل ترحيل، والسبب أن بعض التغييرات لا رجوع عنها إلا من هذه النسخة.

أكثر من نسخة (Instance) من التطبيق

عندما تشغل نسختين من التطبيق خلف Load Balancer، أو حتى نسخة جديدة بجانب القديمة أثناء النشر، فإن كل الافتراضات المبنية على نسخة واحدة تسقط.

فإذا كانت الجلسات Sessions في ذاكرة النسخة فسوف يخرج المستخدم من حسابه كلما وصل طلبه إلى نسخة أخرى، وإذا حفظت كل نسخة الملفات المرفوعة على قرصها فسوف يرى المستخدم ملفه مرة ولا يراه مرة، والمهام المجدولة Cron Jobs سوف تعمل مرتين، فتصل رسالتا تذكير لكل مستخدم وتخرج فاتورتان بدلاً من واحدة.

والحل أن تحفظ الجلسات في Redis أو في قاعدة البيانات، وتضع الملفات في Volume مشترك أو في تخزين الكائنات Object Storage المتوافق مع S3، وتشغل المهام المجدولة في خدمة واحدة مخصصة لها، أو تحميها بقفل في قاعدة البيانات مثل Advisory Locks في PostgreSQL:

-- يعيد true لنسخة واحدة فقط، والبقية تتخطى المهمة
SELECT pg_try_advisory_lock(42);

فحوص الصحة: هل التطبيق حي؟ وهل هو جاهز؟

هنا سؤالان مختلفان، الأول هل العملية حية وتستجيب؟ وهذا فحص الحياة Liveness، والثاني هل هي جاهزة لخدمة الطلبات الآن، أي متصلة بقاعدة البيانات وأكملت التحميل؟ وهذا فحص الجاهزية Readiness، ويفصل Kubernetes بينهما بوضوح، والفكرة نفسها مفيدة خارجه.

إذا ربطت فحص الحياة بقاعدة البيانات فقد يعيد النظام تشغيل كل نسخ التطبيق لأن القاعدة أبطأت لحظة، فيتحول البطء إلى انقطاع كامل، وإذا لم يكن هناك فحص أصلاً فسوف يرسل ال Proxy الطلبات إلى نسخة لم يكتمل تشغيلها. ولاحظ أن Docker وحده لا يعيد تشغيل ال Container عندما يصبح unhealthy، والسبب أن سياسة إعادة التشغيل Restart Policy تعمل فقط عند خروج العملية.

والحل أن تجعل في التطبيق مسارين، الأول /healthz للحياة دون أي اعتماد خارجي، والثاني /readyz للجاهزية ويفحص قاعدة البيانات، ثم تكتب الفحص في Compose بأداة موجودة داخل ال Image، والسبب أن كثيراً من ال Images الصغيرة لا تحتوي على curl:

healthcheck:
  test: ["CMD", "node", "healthcheck.js"]
  interval: 30s
  timeout: 5s
  retries: 3
  start_period: 20s

جلب الروابط التي يرسلها المستخدم (SSRF)

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

فقد يرسل المهاجم رابطاً مثل http://db:5432، أو عنوان لوحة إدارة داخلية لا تطلب كلمة مرور لأنها "داخلية"، وعلى السيرفرات السحابية قد يطلب http://169.254.169.254/، وهو عنوان خدمة البيانات الوصفية Metadata Service التي قد تعيد مفاتيح وصول، واسم هذا الهجوم تزوير الطلبات من جهة السيرفر SSRF.

🛑
لا تكتف بفحص النص المكتوب في الرابط، والسبب أن النطاق قد يشير إلى عنوان داخلي، وإعادة التوجيه قد تنقل الطلب إلى عنوان آخر بعد الفحص. لذلك استعلم أولاً عن العنوان الذي يشير إليه النطاق DNS Resolution، وارفض العناوين الخاصة Private والمحلية Loopback وعناوين 169.254.0.0/16، ثم اتصل بالعنوان نفسه الذي فحصته، وأعد الفحص مع كل إعادة توجيه أو أوقف إعادة التوجيه تماماً.

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

CORS وCookies والنطاقات الفرعية

عندما تكون الواجهة على app.example.com وال API على api.example.com، فإن المتصفح يعد كل واحد منهما مصدراً Origin مختلفاً، ويطبق على الطلبات بينهما قواعد مشاركة الموارد بين المصادر CORS.

وعند أول خطأ يلجأ كثيرون إلى Access-Control-Allow-Origin: *، ولكن المتصفح يرفض هذه القيمة مع الطلبات التي تحمل Cookies فيعود الخطأ نفسه، والأسوأ أن يعيد السيرفر أي Origin يصله، فيسمح لأي موقع بقراءة بيانات المستخدم المسجل. وفي Cookies نفسها إذا ضبطت Domain=example.com فسوف تصل إلى كل نطاق فرعي، حتى نطاق تستضيف عليه محتوى لا تتحكم فيه.

والحل أن تكتب في السيرفر قائمة صريحة بالمصادر المسموح بها، ولا تضع Domain في Cookie الجلسة إلا إذا احتجت إلى مشاركتها، والسبب أنها بدونه لا تصل إلا إلى النطاق الذي أنشأها. واستخدم الخصائص Secure وHttpOnly وSameSite=Lax، والبادئة __Host- كما يشرحها توثيق Set-Cookie، وتذكر أن app.example.com وapi.example.com موقع واحد Same-Site في نظر SameSite، فهو لا يحميك من طلبات أحدهما إلى الآخر.

التخزين المؤقت (Cache) خلف Cloudflare

لا يخزن Cloudflare صفحات HTML ولا استجابات JSON في الإعداد الافتراضي، ولكن كثيرين يضيفون قاعدة تخزين Cache Rule تخزن كل شيء حتى يسرعوا الموقع.

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

📌
في 25 ديسمبر 2015 تعرض متجر Steam لهجوم حجب خدمة DoS رفع الزيارات بنسبة 2000% فوق المعتاد، فطبقت Valve قواعد تخزين مؤقت لتخفيف الضغط، وكانت إحداها تخزن صفحات المستخدمين المسجلين بالخطأ، فرأى بعض الزوار لمدة ساعة ونصف صفحات متجر أنشئت لغيرهم، وقد تحتوي على عنوان الفواتير وسجل المشتريات والبريد الإلكتروني، وشمل ذلك قرابة 34 ألف مستخدم، كما شرحت Valve في بيانها عن الحادثة. والدرس أن قاعدة التخزين التي تضيفها على عجل وقت الضغط هي أخطر قاعدة، لذلك اجعل الصفحات الخاصة ترفض التخزين من السيرفر نفسه.
⚠️
أرسل Cache-Control: private, no-store مع كل استجابة تخص مستخدماً مسجلاً، واستثن مسارات الحساب وال API من أي قاعدة تخزين، فبحسب توثيق Cloudflare تعني private أن الاستجابة لمستخدم واحد ولا يجوز لذاكرة مشتركة مثل Cloudflare أن تخزنها. ولا تختر في إعدادات القاعدة خيار Ignore cache-control header لمسارات فيها صفحات خاصة، والسبب أن Cloudflare عندها يتجاهل ترويسة Cache-Control التي يرسلها السيرفر بالكامل.

أما الملفات الثابتة فضع في أسمائها بصمة المحتوى Hash، مثل app.3f9c2a.js، وأرسل معها Cache-Control: public, max-age=31536000, immutable، والسبب أن الملف إذا تغير تغير اسمه، فلا يبقى لدى أحد إصدار قديم منه، وتجد معنى كل قيمة في توثيق Cache-Control.

إذا كان تطبيقك عربياً

النص العربي يكشف أخطاء لا تظهر مع الإنجليزية، فهناك الترميز الناقص، والبحث الذي لا يجد الكلمة لأن المستخدم كتبها بهمزة أو بدونها، والتوقيت الذي يزيد أو ينقص ثلاث ساعات.

الترميز utf8mb4 في MySQL وMariaDB

الترميز utf8 في MySQL هو في الحقيقة utf8mb3، ولا يتسع إلا لثلاثة بايتات للحرف، والحروف العربية تدخل فيه، ولكن الرموز التعبيرية Emoji وبعض الرموز الأخرى لا تدخل، والترميز الكامل هو utf8mb4.

وإذا تجاهلت ذلك فسوف يفشل حفظ أول تعليق فيه رمز تعبيري بالخطأ Incorrect string value، وإذا اتصل التطبيق بترميز غير ترميز الجداول فسوف تتحول الحروف العربية إلى علامات استفهام أو رموز غريبة، وقد لا تكتشف ذلك إلا بعد أن تتراكم البيانات التالفة.

والحل أن تضبط الترميز صراحة في القاعدة والجداول والاتصال، ولا تعتمد على القيمة الافتراضية للسيرفر، والسبب أنها تختلف بين الإصدارات وبين MySQL وMariaDB:

CREATE DATABASE app CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;

هذا في MySQL، أما في MariaDB فاختر Collation مثل utf8mb4_uca1400_ai_ci، وهو موجود منذ الإصدار 10.10 وأصبح الافتراضي لـ utf8mb4 منذ الإصدار 11.5. وأضف charset=utf8mb4 إلى عنوان الاتصال في التطبيق، ثم اختبر بنفسك كيف يقارن ال Collation الذي اخترته الكلمات العربية، ولا تفترض النتيجة:

SELECT 'احمد' = 'أحمد' COLLATE utf8mb4_0900_ai_ci;

البحث العربي وتوحيد الحروف

لنفرض أن المستخدم كتب «احمد» والمحفوظ «أحمد»، أو بحث عن «مدرسه» والمحفوظ «مدرسة»، أو عن «مستشفي» والمحفوظ «مستشفى»، وقد يحتوي النص المحفوظ على التشكيل Diacritics مثل الفتحة والشدة، أو على حرف التطويل Tatweel الذي يمد الكلمة شكلاً فقط، وفي كل هذه الحالات لا يجد البحث الحرفي ما يريده المستخدم، فيقول إن البحث معطل، وهو محق من جهته.

والحل أن تحفظ مع كل نص نسخة موحدة للبحث في عمود مستقل، وتوحد عبارة البحث بالدالة نفسها قبل المقارنة، والتوحيد المعتاد كما يلي: الألف بهمزة فوقها أو تحتها أو بمدة تصبح ألفاً، والتاء المربوطة تصبح هاء، والألف المقصورة تصبح ياء، ويحذف التشكيل والتطويل. والدالة التالية بلغة JavaScript تقوم بذلك:

function normalizeArabic(text) {
  return text
    .replace(/[ً-ْٰ]/g, '')       // حذف التشكيل
    .replace(/ـ/g, '')                      // حذف التطويل
    .replace(/[آأإ]/g, 'ا')  // آ أ إ إلى ا
    .replace(/ة/g, 'ه')                // ة إلى ه
    .replace(/ى/g, 'ي');               // ى إلى ي
}

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

وإذا استخدمت البحث النصي الكامل Full-Text Search في PostgreSQL، ففيه منذ الإصدار 12 إعداد اسمه arabic يستخرج جذور الكلمات Stemming، ولكن له حدوداً يجب أن تعرفها:

  • إذا لم تحدد الإعداد في الاستعلام فسوف تأخذ الدوال قيمة default_text_search_config، وهي غالباً english، لذلك اكتب 'arabic' صراحة في الفهرس وفي الاستعلام.
  • لا يأتي PostgreSQL بقائمة عربية للكلمات الشائعة Stop Words مثل «في» و«من»، فتدخل هذه الكلمات في الفهرس.
  • استخراج الجذور تقريبي، فقد يجمع كلمات مختلفة المعنى، أو يفرق بين صيغ يعدها المستخدم واحدة، لذلك وحد النص بنفسك قبل الفهرسة واختبر النتائج بكلمات حقيقية من بياناتك.
CREATE INDEX posts_search_idx ON posts
  USING GIN (to_tsvector('arabic', search_text));

SELECT id FROM posts
WHERE to_tsvector('arabic', search_text) @@ plainto_tsquery('arabic', 'الخوادم');

المناطق الزمنية (Time Zones)

السيرفر وال Container يعملان غالباً بتوقيت UTC، والمستخدم في الرياض بتوقيت UTC+3، والخلط بين الاثنين يظهر في مواعيد المهام والتقارير اليومية وأوقات الرسائل.

وإذا تجاهلت ذلك فسوف تظهر الأوقات متأخرة ثلاث ساعات، أو يبدأ تقرير "اليوم" عند الثالثة فجراً بتوقيت المستخدم. وفي MySQL يحول النوع TIMESTAMP القيمة إلى UTC عند الحفظ ويعيدها بتوقيت الجلسة، أما DATETIME فيحفظها كما هي دون أي منطقة زمنية، فإذا خلطت بين النوعين فلن تعرف بعد سنة أي وقت مكتوب بأي توقيت.

والحل أن تحفظ كل الأوقات بتوقيت UTC، وفي PostgreSQL استخدم timestamptz، ثم تحول إلى Asia/Riyadh عند العرض فقط. وفي JavaScript لاحظ أن اللغة ar-SA تستخدم الأرقام الهندية (٠١٢٣) افتراضياً، أما التقويم فيعتمد على بيانات CLDR المضمنة في المتصفح أو في Node، فالإصدارات الأقدم تعرض التاريخ الهجري والأحدث تعرض الميلادي، لذلك حدد ما تريده صراحة عبر Intl.DateTimeFormat حتى يرى كل المستخدمين التاريخ نفسه:

new Intl.DateTimeFormat('ar-SA-u-ca-gregory-nu-latn', {
  timeZone: 'Asia/Riyadh',
  dateStyle: 'medium',
  timeStyle: 'short',
}).format(new Date());

واترك ال Containers على UTC، وإذا احتجت إلى توقيت محلي، مثل مهمة مجدولة تعمل منتصف الليل بتوقيت الرياض، فاضبط TZ: Asia/Riyadh لتلك الخدمة وحدها، ولكن تأكد أولاً أن ال Image فيه بيانات المناطق الزمنية (الحزمة tzdata)، والسبب أن Images مثل Alpine لا تحتوي عليها افتراضياً، وعندها يتجاهل النظام القيمة ويبقى على UTC دون أي رسالة خطأ.

القائمة المختصرة

راجع هذه البنود قبل أول نشر، ثم قبل كل نشر يغير البنية أو قاعدة البيانات.

خلف الـ Reverse Proxy

  • التطبيق يثق بترويسات ال Proxy من عناوينه فقط، ويسجل عنوان الزائر الحقيقي.
  • السيرفر لا يقبل اتصالاً مباشراً يتجاوز Cloudflare.
  • التطبيق يعرف أنه على HTTPS، ولا توجد حلقات إعادة توجيه، ووضع Cloudflare هو Full (strict).
  • العنوان العام مضبوط، وروابط العودة في OAuth مطابقة حرفياً.
  • WebSocket وSSE يعملان أكثر من دقيقة، والواجهة تعيد الاتصال تلقائياً.
  • حد الرفع والمهلة متسقان في Cloudflare وال Proxy والتطبيق.

داخل الـ Container

  • لا توجد أسرار في ال Image ولا في Git.
  • السجلات إلى stdout بصيغة JSON مع معرف لكل طلب، وتدوير السجلات مفعل على السيرفر.
  • CMD بصيغة exec، والتطبيق يعالج SIGTERM.
  • كل البيانات الدائمة في Volumes، والصلاحيات تطابق مستخدم ال Container.
  • بناء متعدد المراحل، وBase Image بإصدار ثابت، و.dockerignore، ومستخدم غير root.
  • الاتصال بالخدمات باسمها وليس بـ localhost، وانتظار قاعدة البيانات حتى تصبح جاهزة.

في بيئة الإنتاج

  • الترحيل خطوة مستقلة، والتغييرات متوافقة مع الإصدار السابق، ونسخة احتياطية قبله.
  • الجلسات والملفات مشتركة بين النسخ، والمهام المجدولة لا تعمل مرتين.
  • فحص حياة لا يعتمد على قاعدة البيانات، وفحص جاهزية يعتمد عليها.
  • جلب الروابط يرفض العناوين الداخلية ويفحص كل إعادة توجيه.
  • قائمة صريحة بالمصادر في CORS، وCookies بخصائص Secure وHttpOnly وSameSite.
  • الصفحات الخاصة بالمستخدم ترسل Cache-Control: private, no-store.

للتطبيقات العربية

  • utf8mb4 في القاعدة والجداول والاتصال.
  • عمود بحث موحد الحروف، والدالة نفسها لعبارة البحث.
  • الأوقات محفوظة بتوقيت UTC، ومعروضة بتوقيت Asia/Riyadh بتقويم وأرقام محددة.

الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

  • أغلب أخطاء النشر ليست في الكود، وإنما في افتراضات جهاز التطوير: أن الزائر يتصل بالتطبيق مباشرة، وأن الاتصال HTTP، وأن localhost هو السيرفر، وأن الملفات تبقى بعد التحديث.
  • ال Reverse Proxy وCloudflare يضيفان طبقات لكل منها ترويسات وحدود ومهل، فاضبط الثقة والحدود في كل طبقة وليس في واحدة فقط.
  • ال Container يحذف ويعاد إنشاؤه، فالأسرار خارج ال Image، والسجلات إلى stdout، والبيانات في Volumes، والتطبيق يستقبل SIGTERM.
  • الصفحات الخاصة ترفض التخزين من السيرفر نفسه، والسبب أن قاعدة واحدة خاطئة في ال Cache قد تعرض حساب مستخدم لغيره.
  • النص العربي يحتاج إلى utf8mb4 وعمود بحث موحد الحروف، والأوقات تحفظ بتوقيت UTC وتعرض بتقويم وأرقام محددة.

ولن تحتاج إلى كل هذه البنود في كل تطبيق، فالتطبيق الداخلي الذي يخدم عشرين موظفاً قد يعمل بنسخة واحدة ولا يمر عبر Cloudflare، ولكن مراجعة القائمة كاملة تأخذ دقائق، وأغلب ما فيها أسهل إصلاحاً قبل النشر منه بعد أن يبلغك المستخدمون بالمشكلة. وإذا كنت تنقل تطبيقاً قائماً إلى سيرفر جديد، فراجعها مع دليل نقل الـ Containers إلى خادم جديد.

سجل التحديثات

  • أكتوبر 2026: كتابة المقال.
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.