Nginx في EGPHP

باب أمامي مبنيّ كما ينبغي.

يقرّر Nginx ما يصل إلى موقعك: أي خادم وأي مجمّع PHP يجيب، وما يُقدَّم من الذاكرة المؤقتة، وما يُصَدّ عند الباب. هكذا يُعدّه فريقنا، وهذه إعداداته شاهدة على ذلك.

يعمل على ويُبنى بـ
  • NGINX
  • PHP
  • Redis

طلب واحد، من الباب إلى كودك

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

  1. الاتصال

    مشفّر بـ TLS 1.3، واتصال واحد ينقل كل ملفات الصفحة عبر HTTP/2 أو HTTP/3.

  2. الحدود

    الطلبات الزائدة من عنوان واحد، أو الاتصالات المفتوحة الزائدة، أو جسم طلب أكبر مما يقبله الموقع، كلها يُردّ عليها هنا.

    429 أو 413: يتوقف هنا
  3. المسارات المحظورة

    الملفات المخفية والنسخ الاحتياطية وأي سكربت داخل مجلد للرفع تُرفض قبل أن يصل خبرها إلى PHP.

    403 أو 404: يتوقف هنا
  4. الملفات الثابتة

    الصور وملفات التنسيق والسكربتات تخرج مباشرة من القرص، بمدد تخزين مؤقت طويلة.

    قُدِّم: يتوقف هنا
  5. ذاكرة الصفحات المؤقتة

    الزائر غير المسجّل دخوله يحصل على صفحة جاهزة من الذاكرة.

    إصابة في الذاكرة المؤقتة: يتوقف هنا
  6. موزّع الأحمال

    يختار الخادم ومجمّع PHP القادرَين على استيعاب الطلب.

  7. المجمّع الخاص بالحساب

    تعمل PHP بمستخدم الحساب نفسه، داخل مجلداته، وبملف php.ini الخاص به.

توزيع الأحمال بين الخوادم

حين لا يكفي خادم واحد، يقف Nginx أمام عدة خوادم ويوزّع الزيارات عليها. وطريقة التوزيع هي التي تحدد من يتولى الطلب التالي.

مثال: العناوين والقيم توضيحية

الطريقة الافتراضية. يتولى كل خادم الطلب التالي بالتناوب: وهي المناسبة حين تتشابه الخوادم وتتقارب كلفة الطلبات.

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

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

conf.d/app.conf
upstream app_servers {
    # round robin: the default, no line needed    least_conn;    ip_hash;
    server 10.0.0.11:8080 max_fails=3 fail_timeout=30s;
    server 10.0.0.12:8080 max_fails=3 fail_timeout=30s;
    server 10.0.0.13:8080 max_fails=3 fail_timeout=30s;
    server 10.0.0.14:8080 backup;    # no backup server with ip_hash
    keepalive 32;
}

server {
    server_name example.com;
    location / {
        proxy_pass http://app_servers;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_set_header Host $host;
        proxy_next_upstream error timeout http_502 http_503;
    }
}
max_fails · fail_timeout
فحوص سلامة بالزيارات الحقيقية: بعد ثلاث محاولات فاشلة خلال ثلاثين ثانية يُستبعَد الخادم ثلاثين ثانية، ثم يُجرَّب من جديد.
backup
خادم احتياطي لا يستقبل الزيارات إلا حين تتعذّر كل الخوادم الأخرى.
proxy_next_upstream
الطلب الذي يصادف خطأً أو انتهاء مهلة يُعاد إرساله إلى الخادم التالي. أما الطلب الذي يغيّر البيانات فلا يُرسَل مرتين، فلا يُنفَّذ أي طلب شراء مرتين أبدًا.
keepalive
تبقى الاتصالات بالخوادم مفتوحة بين الطلبات بدل فتحها من جديد لكل طلب.
weight
يمكن منح الخادم الأكبر حصة أكبر من الطلبات.
down
يُخرَج خادم للصيانة بإعادة تحميل الإعداد، بينما تحمل الخوادم الأخرى الزيارات.

توزيع الأحمال بين مجمّعات PHP

الفكرة نفسها داخل خادم واحد. يمكن أن يقف خلف الموقع أكثر من مجمّع PHP-FPM، فيُعاد تشغيل أحدها بينما يجيب الآخر، ولا يستولي العمل البطيء أبدًا على العمّال الذين تحتاجهم واجهة المتجر.

Nginx ~ \.php$^~ /admin/
shop-a.sock واجهة المتجر، المجمّع أ يجيب يجيب، ويتولى طلبات ب
shop-b.sock واجهة المتجر، المجمّع ب يجيب يُعاد تشغيله بإعداد جديد: لا ينتظره أي زائر
shop-admin.sock الإدارة والتقارير عمّاله الخاصون، بمعزل عن المتجر
sites/example.com.confمثال
upstream php_shop {
    server unix:/run/php/shop-a.sock max_fails=2 fail_timeout=10s;
    server unix:/run/php/shop-b.sock max_fails=2 fail_timeout=10s;
    keepalive 8;
}

# server {}
location ~ \.php$ {
    try_files $uri =404;
    include fastcgi.conf;
    fastcgi_pass php_shop;
    fastcgi_keep_conn on;
    fastcgi_next_upstream error timeout;
}

location ^~ /admin/ {
    location ~ \.php$ {
        try_files $uri =404;
        include fastcgi.conf;
        fastcgi_pass unix:/run/php/shop-admin.sock;
    }
}
  • مجمّعان، موقع واحد

    يشغّل المجمّعان الكود نفسه بالحساب نفسه. يصل إعداد PHP الجديد إلى مجمّع ثم إلى الآخر، ويظل الزوار يتلقون الرد طوال الوقت.

  • التحويل عند التعطّل

    حين يرفض مقبس أحد المجمّعين الاتصال أو تنتهي مهلته، ينقل fastcgi_next_upstream الطلب إلى المجمّع الآخر. أما الطلب الذي يغيّر البيانات فلا يُرسَل مرتين.

  • مسار للعمل البطيء

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

القواعد عند الباب

تبدأ الحماية قبل أن تنفّذ PHP سطرًا واحدًا. تدخل هذه القواعد في كل كتلة خادم نكتبها، مع تعديلها بما يناسب الموقع.

sites/example.com.confمثال: تُحدَّد القيم لكل موقع على حدة
# http {}
limit_req_zone  $binary_remote_addr zone=perip:10m rate=10r/s;
limit_req_zone  $binary_remote_addr zone=login:10m rate=5r/m;
limit_conn_zone $binary_remote_addr zone=conns:10m;server {
    listen 443 ssl;
    http2 on;
    server_name example.com;
    limit_req  zone=perip burst=40 nodelay;
    limit_conn conns 30;
    limit_req_status 429;
    location = /wp-login.php {
        limit_req zone=login burst=5 nodelay;
        include fastcgi.conf;
        fastcgi_pass php_shop;
    }    client_max_body_size 64m;
    client_header_timeout 15s;
    client_body_timeout 15s;    location ~ /\.(?!well-known/) { deny all; }
    location ~* \.(?:bak|old|orig|sql|swp)$ { deny all; }    location ~* ^/(?:wp-content/uploads|uploads|files)/.*\.php$ {
        deny all;
    }    location ~ \.php$ {
        try_files $uri =404;
        include fastcgi.conf;
        fastcgi_pass php_shop;
    }    ssl_protocols TLSv1.2 TLSv1.3;
    server_tokens off;
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;}
  1. حدود المعدّل والاتصالات

    يحصل كل عنوان على معدّل ثابت مع متسع لدفعة قصيرة؛ وما يتجاوز ذلك يردّ عليه Nginx بـ 429 دون أن تُوقَظ PHP أصلًا. وتحصل صفحة تسجيل الدخول على حدّ خاص بها أضيق بكثير، يُفرغ محاولات تخمين كلمات المرور من جدواها.

  2. حجم الطلب والمهل

    الملف المرفوع الأكبر مما يقبله الموقع يُرفض فورًا، والطرف الذي يرسل طلبه ببطء شديد يُقطع اتصاله بدل أن يُبقي اتصالًا مفتوحًا.

  3. الملفات المخفية والبقايا

    أي شيء يبدأ اسمه بنقطة، مثل .git أو .env، والنسخ الاحتياطية من الملفات، لا يُقدَّم أبدًا. والاستثناء الوحيد هو المجلد الذي تستخدمه فحوص الشهادات.

  4. المرفوعات لا تشغّل PHP أبدًا

    المجلد الذي يستقبل ملفات من الزوار يقدّمها كملفات فقط. وأي سكربت يوضع فيه يُرفض، أيًّا كان اسمه.

  5. ملف PHP موجود فعلًا، لا غير

    لا يرسل try_files $uri =404 الطلب إلى PHP إلا إذا كان هذا الملف بعينه موجودًا على القرص. وهذا يغلق الفخ الكلاسيكي المرسوم أدناه.

  6. الترويسات وTLS

    يُبقي HSTS المتصفحات على HTTPS، وتمنع الترويسات الأخرى تخمين نوع محتوى الصفحة أو تضمينها في إطار داخل موقع آخر، ولا يُعلن الخادم عن إصداره. TLS 1.2 و1.3 فقط.

    كيف نؤمّن الخادم نفسه

الفخ الذي يفتحه سطر واحد ناقص

/uploads/avatar.jpgملف حقيقي: صورة بداخلها كود PHP مخفي /shell.phpغير موجود
دون القاعدة

في إعداد قديم أو مهمل، ترجع PHP إلى الوراء على طول المسار، فتجد الصورة وتنفّذ الكود الذي بداخلها.

مع القاعدة

خطأ 404، ولا يُنفَّذ شيء. cgi.fix_pathinfo=0 في PHP وsecurity.limit_extensions في المجمّع هما القفلان الثاني والثالث على الباب نفسه.

كل حساب غرفة مسوّرة

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

shopالحساب
مستخدم Linux
shop
مجمّع PHP
/run/php/shop.sock
المجلدات المسموحة
/home/shop
الإعدادات
pool.d/shop.conf
clinicالحساب
مستخدم Linux
clinic
مجمّع PHP
/run/php/clinic.sock
المجلدات المسموحة
/home/clinic
الإعدادات
pool.d/clinic.conf
blogالحساب
مستخدم Linux
blog
مجمّع PHP
/run/php/blog.sock
المجلدات المسموحة
/home/blog
الإعدادات
pool.d/blog.conf
كود المتجر يمدّ يده إلى ملفات العيادة file_get_contents('/home/clinic/public_html/config.php') Permission deniedopen_basedir restriction in effect
  1. مستخدم Linux خاص به

    يعمل كل موقع بمستخدمه الخاص، والمجلد الرئيسي لكل حساب مغلق أمام الحسابات الأخرى. هذا هو الجدار الذي يصمد حتى لو أُخرجت PHP من الصورة.

  2. مجمّع PHP-FPM خاص به

    مجمّع واحد لكل حساب، لا يُوصَل إليه إلا عبر مقبسه الخاص. الموقع المزدحم أو المعطّل يستنفد عمّاله هو، لا عمّال جيرانه.

  3. open_basedir وinclude_path

    لا تستطيع PHP فتح الملفات إلا داخل مجلدات الحساب نفسه، ولا تبحث عن الملفات المضمَّنة إلا هناك. والسكربت الذي يطلب مسارًا خارجها يُرفض قبل أن يُلمَس القرص.

  4. ملفاته المؤقتة وجلساته الخاصة

    الملفات قيد الرفع وملفات الجلسات تبقى داخل الحساب، لا في مجلد /tmp مشترك يستطيع فيه أي موقع قراءة جلسات الدخول لموقع آخر.

  5. لا دوال لا يحتاجها الموقع

    open_basedir ليس بيئة معزولة: فهو لا يوقف برنامجًا يُشغَّل من داخل PHP. لذلك تُعطَّل لكل حساب الدوال التي تنفّذ أوامر النظام، ما لم يكن الموقع يحتاج إحداها فعلًا.

php/8.3/fpm/pool.d/shop.confمثال
[shop]
user  = shop
group = shop
listen = /run/php/shop.sock
listen.owner = www-data
listen.group = www-data
listen.mode  = 0660

pm = ondemand
pm.max_children = 12
pm.process_idle_timeout = 10s

php_admin_value[open_basedir] = /home/shop/public_html:/home/shop/lib:/home/shop/tmp
php_admin_value[include_path] = .:/home/shop/lib
php_admin_value[upload_tmp_dir] = /home/shop/tmp
php_admin_value[sys_temp_dir] = /home/shop/tmp
php_admin_value[session.save_path] = /home/shop/tmp/sessions
php_admin_value[disable_functions] = exec,passthru,shell_exec,system,proc_open,popen
php_admin_flag[allow_url_include] = off

php.ini لكل حساب

لكل حساب إعداداته الخاصة، مكتوبة في مجمّعه الخاص. بعضها نثبّته نحن، وبعضها يستطيع الموقع تغييره، والفرق بينهما كلمة واحدة.

php_admin_value: قيمة ثابتة

تُضبط للحساب. لا يستطيع ini_set() ولا ملف .user.ini تغييرها.

memory_limit = 256M
max_execution_time = 60
open_basedir = /home/shop/…
disable_functions = exec,…

php_value: قيمة مبدئية

قيمتنا الافتراضية للحساب. ويستطيع الموقع رفعها أو خفضها حيث تسمح PHP بذلك.

upload_max_filesize = 32M
post_max_size = 32M
max_input_vars = 3000

.user.ini: إعدادات الموقع نفسه

ملف في مجلد الموقع للإعدادات المسموح له بتغييرها. وتعيد PHP قراءته كل خمس دقائق افتراضيًا.

upload_max_filesize = 64M
post_max_size = 64M
display_errors = Off

مثال: تُحدَّد القيم لكل موقع على حدة

حدود يجب أن تتوافق

حجم الرفع
upload_max_filesizePHP≤ post_max_sizePHP≤ client_max_body_sizeNginx

إن سمح Nginx بأقل، يُرفض الرفع بخطأ 413 قبل أن تراه PHP؛ وإن سمحت PHP بأقل، يصل الملف ثم يُتخلَّص منه.

الوقت
max_execution_timePHP< request_terminate_timeoutPHP-FPM≤ fastcgi_read_timeoutNginx

ينبغي أن توقف PHP نفسها أولًا، بخطأ تستطيع تسجيله؛ ويوقف المجمّع ما عجزت PHP عن إيقافه؛ وينتظر Nginx وقتًا كافيًا حتى لا يُظهر خطأ 504 بينما لا تزال PHP تعمل.

التخزين المؤقت، على طبقات

كل طبقة تحفظ شيئًا مختلفًا، ولا يتعمّق الطلب إلا بقدر ما يلزم. بدّل بين الزائرَين لترى أين يتوقف كلٌّ منهما.

  1. المتصفحملفات التنسيق والسكربتات والصور التي يتضمن اسمها رقم إصدار، تُحفظ لمدة عام.
    الملفات لديه بالفعلالملفات لديه بالفعل
  2. ذاكرة الصفحات المؤقتة في Nginxصفحات جاهزة، تُحفظ من بضع ثوانٍ إلى بضع دقائق.
    إصابة: تُقدَّم الصفحة من هناتُتخطّى: ملف تعريف ارتباط للدخول أو للسلة يعني أن الصفحة شخصية
  3. OPcacheكود PHP مترجَم مرة واحدة ومحفوظ في الذاكرة.
    لا يصل إليها الطلبيُنفَّذ الكود دون أن يُقرأ أو يُترجَم من جديد
  4. Redisإجابات قاعدة البيانات المتكررة، والجلسات.
    لا يصل إليها الطلبمعظم الإجابات تأتي من الذاكرة
  5. قاعدة البياناتالمرجع الأصلي للبيانات.
    لا يصل إليها الطلبلا تُسأل إلا عمّا لم يكن لدى Redis
conf.d/cache.confمثال: تُحدَّد القيم لكل موقع على حدة
# http {}
fastcgi_cache_path /var/cache/nginx/shop levels=1:2
                   keys_zone=shop:50m inactive=60m max_size=2g;

map $http_cookie $skip_cache {
    default                     0;
    ~wordpress_logged_in_       1;
    ~woocommerce_items_in_cart  1;
}

# location ~ \.php$ {}
fastcgi_cache shop;
fastcgi_cache_key "$scheme$request_method$host$request_uri";
fastcgi_cache_valid 200 301 10s;
fastcgi_cache_bypass $skip_cache;
fastcgi_no_cache     $skip_cache;
fastcgi_cache_use_stale updating error timeout http_500 http_503;
fastcgi_cache_background_update on;
fastcgi_cache_lock on;
المفتاح
البروتوكول وطريقة الطلب والمضيف والعنوان الكامل، فلا تتشارك صفحتان مختلفتان مُدخلًا واحدًا أبدًا.
التجاوز
تسجيل الدخول أو السلة يتخطى الذاكرة المؤقتة في الاتجاهين: فلا تُقدَّم الصفحة منها ولا تُحفظ فيها. والنماذج المرسلة بطريقة POST لا تُخزَّن مؤقتًا أبدًا.
النسخة القديمة أثناء التحديث
حين تنتهي صلاحية مُدخل، يحدّثه طلب واحد في الخلفية بينما يظل الجميع يحصلون على النسخة السابقة. وإن تعطلت PHP، تُقدَّم آخر صفحة سليمة.
قاعدة يغفل عنها كثيرون
وجود add_header داخل كتلة مسار يُلغي كل الترويسات المضبوطة فوقها، لذلك نحفظ ترويسات الأمان في مقتطف واحد ونضمّنه أينما أُضيفت ترويسات.

السرعة، وبقية الإعداد الجيد

الإعدادات التي تجعل كل موقع على الخادم أسرع، والعادات التي تُبقي أي تغيير آمنًا.

تغيير يُطبَّق دون أن ينقطع شيء
$ sudo nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
$ sudo systemctl reload nginx

يُختبر الإعداد الجديد أولًا؛ فإن فشل الاختبار لا يتغير شيء. وعند إعادة التحميل، يبدأ عمّال جدد بالإعداد الجديد بينما ينهي القدامى الطلبات التي بين أيديهم، فلا ينقطع أي زائر.

log_format timed '$remote_addr "$request" $status $body_bytes_sent '
                 'rt=$request_time urt=$upstream_response_time '
                 'cache=$upstream_cache_status';

كل سطر في السجل يدوّن الزمن الكلي، والزمن الذي استغرقته PHP، وما فعلته الذاكرة المؤقتة، فيُردّ بطء الصفحة إلى Nginx أو PHP أو الشبكة بدل التخمين.

http2 on; listen 443 quic;
اتصال واحد ينقل الصفحة كاملة؛ ويُضاف HTTP/3 حيث تسمح بذلك نسخة Nginx المبنية والشبكة.
gzip · brotli
يُضغط النص قبل خروجه، بـ Brotli حيث تكون وحدته مدمجة. ولا تُضغط الصور والفيديو مرتين.
sendfile · tcp_nopush
ترسل النواة الملفات من القرص إلى الشبكة بنفسها، في حزم كاملة.
open_file_cache
يتذكر Nginx الملفات الموجودة وأماكنها، بدل أن يسأل القرص مع كل طلب.
worker_processes auto
عامل واحد لكل نواة معالج، يتولى كلٌّ منهم اتصالات كثيرة في حلقة أحداث واحدة.
Cache-Control: immutable
الملفات التي يتضمن اسمها رقم إصدار تُخزَّن مؤقتًا لمدة عام ولا يُعاد فحصها أبدًا.

ما الذي يقف خلف الباب الأمامي

مجمّعة حسب مهمة كل جزء، وكلها تُحدَّد في إعدادات Nginx.

الاتصالات

  • اتصالات مشفّرةيشفّر TLS 1.3 الاتصال، وينقل HTTP/2 كل ملفات الصفحة عبر اتصال واحد.
  • حدود المعدّل والحجمالطلبات الكثيرة أو الاتصالات المفتوحة الزائدة أو المحتوى كبير الحجم يُردّ عليها عند الباب.

التوصيل

  • الملفات الثابتةالصور وملفات التنسيق والسكربتات تخرج مباشرة من القرص مع مدد تخزين مؤقت طويلة.
  • ذاكرة الصفحات المؤقتةالزائر غير المسجّل دخوله يحصل على صفحة جاهزة من الذاكرة.
  • توزيع الحملتذهب الطلبات إلى السيرفر ومجمّع PHP اللذين لديهما متّسع لها.

الحماية

  • المسارات المحظورةالملفات المخفية والنسخ الاحتياطية والسكربتات داخل مجلدات الرفع تُرفض قبل أن تعلم بها PHP.
  • ترويسات الأمانتُحفظ في مقطع واحد وتُضمَّن أينما أُضيفت ترويسات.
  • حسابات معزولة بجدرانكل حساب يشغّل PHP بمستخدمه الخاص وداخل مجلداته الخاصة.

تغيير الإعدادات دون إسقاط أي زائر

يطبّق Nginx الإعدادات الجديدة في مكانها. هذا ترتيب الأحداث كما تصفه وثائقه الرسمية.

  1. اختبار الملف

    يقرأ الأمر nginx -t الإعدادات كلها ويتوقف عند أول خطأ، قبل أن يعمل عليها أي شيء.

  2. إرسال إشارة إعادة التحميل

    تتلقى العملية الرئيسية إشارة إعادة التحميل وتفحص الإعدادات الجديدة. إذا فشل الفحص تتراجع وتواصل الإعدادات القديمة الخدمة.

  3. تبدأ عمليات عاملة جديدة

    إذا نجح الفحص تبدأ العملية الرئيسية عمليات عاملة جديدة تستخدم الإعدادات الجديدة.

  4. تنهي العمليات القديمة عملها

    تتوقف العمليات القديمة عن قبول اتصالات جديدة وتُنهي الطلبات التي بين يديها.

  5. تنسحب العمليات القديمة

    عندما يُخدَم آخر زائر تُغلق، ولا يبقى سوى العمليات العاملة على الإعدادات الجديدة.

وثائق Nginx: التحكم في nginx

أحضر لنا الموقع، ونحن نبني بابه الأمامي.

أخبرنا أين يعمل اليوم وما الذي يحتاجه. نقرأ الإعداد الحالي أولًا، ثم نقترح إعداد Nginx وPHP، ونسعّر العمل بعد هذه المحادثة.

ميكيبيرد في ثواني