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

لماذا يفشل رفع الملفات الكبيرة؟ حدود الحجم في Cloudflare والـ Reverse Proxy والتطبيق

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

لماذا يفشل رفع الملفات الكبيرة؟ حدود الحجم في Cloudflare والـ Reverse Proxy والتطبيق

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

فالملف في طريقه إلى التخزين يمر بعدة طبقات Layers، ولكل طبقة حد أقصى لحجم الطلب ومهلة Timeout لاستقباله، وأصغر هذه الحدود هو الذي يحكم الرفع كله. والمشكلة أن كل طبقة ترفض الطلب بطريقتها، فترد Cloudflare بالرمز 413 في صفحة خاصة بها، ويرد Nginx بالرمز نفسه في صفحة أخرى، ويقطع Traefik الاتصال بعد دقيقة واحدة، أما PHP فيستقبل الطلب ثم يحذف منه الملفات دون أن يظهر أي خطأ.

وسوف نناقش في هذا المقال ما يلي:

  • الطبقات التي يمر بها الملف المرفوع، والحد الافتراضي للحجم والزمن في كل طبقة كما يذكره التوثيق الرسمي.
  • حدود Cloudflare التي لا تملك تعديلها، وحدود ال Reverse Proxy في Nginx وNginx Proxy Manager وTraefik وCaddy وHAProxy.
  • حدود التطبيق نفسه في PHP وLaravel وExpress وDjango وASP.NET Core وSpring Boot وGo، وفي تطبيقات الاستضافة الذاتية الشائعة.
  • كيف تعرف الطبقة التي رفضت الملف من شكل الخطأ، وكيف تختبر كل طبقة بملف تولده أنت.
  • تصميم مقترح لرفع الملفات الكبيرة، ثم جدول يلخص الأعراض وحلولها.

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

ما الطبقات التي يمر بها الملف؟

عندما يرفع المتصفح Browser ملفاً فإنه يرسله في جسم الطلب Request Body، وغالباً بصيغة multipart/form-data، ويعبر الطلب هذه الطبقات بالترتيب:

  1. المتصفح، وفيه الواجهة الأمامية Frontend التي تختار الملف وترسله.
  2. Cloudflare، إذا كان النطاق Domain يمر عبر شبكتها بالسحابة البرتقالية، أو كنت تستخدم Cloudflare Tunnel.
  3. ال Reverse Proxy على السيرفر، مثل Nginx أو Nginx Proxy Manager أو Traefik أو Caddy أو HAProxy.
  4. سيرفر التطبيق وبيئة التشغيل Runtime، مثل PHP أو Node.js أو Kestrel أو Tomcat.
  5. إطار العمل Framework وقواعد التحقق Validation في تطبيقك.
  6. التخزين Storage، سواءً كان القرص أو تخزين الكائنات Object Storage مثل S3 وR2 وMinIO.

والصورة التالية تبين هذه الطبقات مع الحد الافتراضي في كل منها:

مخطط يوضح الطبقات التي يمر بها الملف المرفوع من المتصفح إلى Cloudflare ثم الـ Reverse Proxy ثم التطبيق ثم التخزين، مع الحد الافتراضي لكل طبقة والخطأ الذي تعيده
الحدود الافتراضية في كل طبقة: يرفض الملف عند أول طبقة يتجاوز حدها

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

الطبقةحد الحجم الافتراضيحد الزمن الافتراضيما يحدث عند التجاوز
Cloudflare (Free وPro)100 MB للطلب125 ثانية بانتظار رد السيرفر413 من Cloudflare، أو 524
Nginx1m60 ثانية بين كل قراءتين413، أو 408، أو 504
Nginx Proxy Manager2000m90 ثانية للتطبيقمثل Nginx
Traefikلا يوجد حد60 ثانية للطلب كاملاًقطع الاتصال بعد 60 ثانية
Caddyلا يوجد حددقيقة من التوقف عن الإرسالقطع الاتصال
HAProxyلا يوجد حدبحسب timeout client وtimeout server408 أو 504
PHPupload_max_filesize = 2M وpost_max_size = 8Mmax_execution_time = 30رد 200 مع ملفات فارغة
إطارات العملتختلف كثيراًغالباً لا يوجد حد413 أو 400 أو 500

والحل الأول الذي يخطر على بال أي مطور هو أن يرفع حد الطبقة التي ظهر منها الخطأ، فيضيف client_max_body_size في Nginx ويعيد المحاولة، فينتقل الخطأ إلى الطبقة التالية، فيرفض PHP الملف بصمت، فيرفع حدوده أيضاً، ثم يكتشف أن الملف لا يصل أصلاً لأن Cloudflare ترفض كل طلب فوق 100 ميجابايت مهما فعل على السيرفر. لذلك الطريقة الصحيحة ليست مطاردة الخطأ من طبقة إلى طبقة، وإنما أن تعرف حد كل طبقة مسبقاً، وتضع حداً صريحاً في كل منها، ثم تنتقل إلى الرفع المجزأ أو الرفع المباشر إلى التخزين عندما تكون ملفاتك أكبر من حد أي طبقة لا تملكها، وهذا ما سوف نقوم به في بقية المقال.

Cloudflare: الحد الذي لا تملك تعديله

حجم الطلب بحسب الخطة

إذا كان سجل DNS لنطاقك يمر عبر Cloudflare، أي في وضع Proxied، فإن Cloudflare تستقبل الطلب قبل السيرفر، وتفرض عليه حداً أقصى لحجم الرفع بحسب خطتك كما يلي:

الخطةأقصى حجم للطلب الواحد
Free100 MB
Pro100 MB
Business200 MB
Enterpriseحتى 5 GB، وأكثر بالتنسيق مع فريق الحساب

وتجد هذا الحد في إعداد Maximum Upload Size بصفحة Network الخاصة بالنطاق، وعملاء Enterprise فقط يستطيعون رفعه بأنفسهم حتى 5 GB، أما في الخطط الأخرى فلن يرفعه أي إعداد على السيرفر. ولاحظ أن الحد على الطلب كله وليس على الملف وحده، فطلب يحمل ثلاثة ملفات حجم كل منها 40 ميجابايت يتجاوز حد الخطة المجانية رغم أن كل ملف أقل من نصف الحد.

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

مهلة انتظار الرد: الخطأ 524

بعد أن يصل الملف إلى السيرفر تنتظر Cloudflare رد التطبيق مدة محددة، ويذكر جدول حدود الاتصال أن مهلة القراءة من السيرفر الأصلي Proxy Read Timeout هي 125 ثانية، فإذا لم يرد التطبيق خلالها ظهر للمستخدم الخطأ 524. ولا يعدل هذه المهلة إلا عملاء Enterprise، وتصل عندهم إلى 6000 ثانية.

ويحدث هذا عادة عندما يعالج التطبيق الملف قبل أن يرد، كأن يحول فيديو إلى صيغة أخرى أو يستورد ملف CSV كبيراً إلى قاعدة البيانات Database. وقد يتساءل البعض: لماذا لا نرفع المهلة ونرتاح؟ والإجابة أنك لا تملك ذلك إلا في خطة Enterprise، وحتى لو ملكته فالمستخدم سوف يبقى أمام شريط متوقف دقائق لا يعرف هل نجح الرفع أم لا. لذلك الحل الأنسب أن يحفظ التطبيق الملف ويرد فوراً، ثم يعالجه في مهمة خلفية Background Job، وتسأل الواجهة عن حالة المعالجة كل بضع ثوان.

ماذا عن Cloudflare Tunnel؟

إذا نشرت تطبيقك عبر Cloudflare Tunnel كما في دليل تشغيل خادمك من إنترنت المنزل، فكل طلب يمر بشبكة Cloudflare قبل أن يصل إلى النفق، وبالتالي تنطبق عليه حدود الخطة نفسها ومهلة 125 ثانية. ولا يوجد لديك هنا خيار السحابة الرمادية DNS Only، والسبب أن السجل يشير إلى النفق وليس إلى عنوان عام Public IP للسيرفر، لذلك إذا احتجت إلى رفع ملفات أكبر من حد الخطة فاختر الرفع المجزأ أو الرفع المباشر إلى التخزين، أو اتصل بالسيرفر عبر شبكة خاصة افتراضية VPN.

ثلاثة حلول للملفات الأكبر من حد الخطة

الرفع المجزأ Chunked Upload. تقسم الواجهة الملف إلى أجزاء أصغر من الحد، مثل 50 ميجابايت للجزء، وترسل كل جزء في طلب مستقل، ثم يجمعها السيرفر، فإذا انقطع الاتصال استأنف الرفع من آخر جزء وصل، وهذا ما يسمى الرفع القابل للاستئناف Resumable Upload. ولا تحتاج إلى كتابة ذلك بنفسك، فقد عرفه بروتوكول tus المفتوح، وله مكتبات جاهزة للسيرفر والمتصفح، منها Uppy.

الرفع المباشر إلى التخزين بروابط موقعة مسبقاً Presigned URLs. يطلب المتصفح من تطبيقك إذناً بالرفع، فيولد التطبيق رابطاً موقعاً لملف واحد صالحاً لمدة قصيرة، ثم يرفع المتصفح الملف بهذا الرابط إلى التخزين مباشرة، فلا يمر بـ Cloudflare ولا بال Reverse Proxy ولا بالتطبيق. وتدعم ذلك Amazon S3 وCloudflare R2 وMinIO وغيرها من الخدمات المتوافقة مع S3، وأقصى حجم للطلب الواحد 5 GB في S3، وأقل من 5 GiB بقليل في R2، وما فوق ذلك يحتاج إلى الرفع متعدد الأجزاء Multipart Upload. ولا تنس أن تضبط سياسة CORS على ال Bucket حتى يقبل الطلبات من نطاق تطبيقك.

نطاق فرعي للرفع دون Proxy. تنشئ نطاقاً فرعياً Subdomain مثل upload.example.com بوضع DNS Only، فيصل الرفع إلى السيرفر مباشرة دون حد Cloudflare. ولكن لهذا الحل ثمن، فهو يكشف العنوان العام للسيرفر، وبعدها يستطيع أي شخص أن يتصل بالسيرفر مباشرة ويتجاوز حماية Cloudflare عن كل النطاقات التي يخدمها نفس السيرفر، وتخسر على هذا النطاق جدار حماية تطبيقات الويب WAF والحماية من هجمات حجب الخدمة DDoS، وتحتاج إلى شهادة TLS على السيرفر. لذلك لا تلجأ إليه إلا إذا تعذر الحلان السابقان.

ال Reverse Proxy: الحد الذي ينساه الجميع

Nginx

القيمة الافتراضية للتوجيه client_max_body_size هي 1m، أي ميجابايت واحد، فإذا لم تغيرها فشل أي رفع أكبر من ذلك بالرمز 413 Request Entity Too Large. والتوجيه يقبل في http وserver وlocation، وبالتالي تستطيع أن ترفع الحد لمسار الرفع وحده وتبقيه منخفضاً لبقية الموقع. أما القيمة 0 فتلغي الفحص تماماً، فلا تستخدمها على موقع عام. والمثال التالي يبين ذلك:

server {
    server_name app.example.com;
    client_max_body_size 10m;

    location /api/upload {
        client_max_body_size 110m;
        client_body_timeout 120s;
        proxy_send_timeout 300s;
        proxy_read_timeout 300s;
        proxy_pass http://app:3000;
    }
}

ثم تحقق من الإعداد وأعد تحميله:

sudo nginx -t && sudo systemctl reload nginx

في الإعداد أعلاه لاحظ التالي، مع العلم أن القيمة الافتراضية للمهل الثلاث هي 60 ثانية:

  • client_body_timeout هي أقصى مدة بين قراءتين متتاليتين من المستخدم وليست مدة الرفع كله، فإذا توقف الإرسال مدة أطول منها أنهى Nginx الطلب بالرمز 408.
  • proxy_send_timeout هي أقصى مدة بين عمليتي كتابة متتاليتين نحو التطبيق.
  • proxy_read_timeout هي أقصى مدة ينتظر فيها Nginx رد التطبيق، فإذا كان التطبيق يعالج الملف قبل أن يرد ظهر الخطأ 504 Gateway Time-out. وإذا كان التطبيق يعمل على PHP-FPM فالتوجيه المقابل هو fastcgi_read_timeout، وقيمته الافتراضية 60 ثانية أيضاً.

ويجدر الإشارة هنا إلى أن Nginx يخزن جسم الطلب كاملاً قبل أن يرسله إلى التطبيق، والسبب أن القيمة الافتراضية لـ proxy_request_buffering هي on، وما يزيد على client_body_buffer_size (من 8 إلى 16 كيلوبايت) يكتبه في ملف مؤقت Temporary File على القرص. ولهذا السلوك فائدة، فالتطبيق لا ينشغل بمستخدم بطيء ولا يصله الطلب إلا كاملاً، ولكنه يحتاج إلى مساحة على القرص تكفي كل الرفعات المتزامنة. وإذا أردت أن يصل الملف إلى التطبيق أولاً بأول، كما في سيرفرات tus، فاجعل القيمة off لهذا المسار وحده.

Nginx Proxy Manager

أما Nginx Proxy Manager فيرفع الحد الافتراضي كثيراً، ففي ملف nginx.conf الخاص به القيمة client_max_body_size 2000m، والمهلتان proxy_send_timeout وproxy_read_timeout على 90 ثانية. لذلك إذا ظهر 413 مع ملف صغير خلف NPM، فالأرجح أن الحد مفروض في Cloudflare أو في التطبيق، أو أن أحداً كتب حداً أصغر في الإعدادات المتقدمة للمضيف.

ولتعديل الحد أو المهل لمضيف واحد افتح ال Proxy Host ثم تبويب Advanced (رمز الترس)، وأضف هذه الأسطر واحفظ:

client_max_body_size 5g;
proxy_send_timeout 600s;
proxy_read_timeout 600s;

ويكتب NPM سجل الأخطاء لكل مضيف في الملف /data/logs/proxy-host-<id>_error.log داخل ال Container، فإذا كان NPM هو الذي رفض الطلب فسوف تجد رسالة الرفض فيه.

Traefik

لا يضع Traefik حداً لحجم الطلب افتراضياً، فإذا أردت حداً فأضف Middleware من نوع Buffering وحدد فيه maxRequestBodyBytes بالبايت، وقيمته الافتراضية 0 أي دون حد، وعند التجاوز يرد Traefik بالرمز 413 ونص قصير هو Request Entity Too Large. ولاحظ أن هذا ال Middleware يقرأ الطلب كاملاً قبل إرساله، فيحفظ أول ميجابايت في الذاكرة Memory والباقي على القرص:

    labels:
      - "traefik.http.middlewares.upload-limit.buffering.maxRequestBodyBytes=115343360"
      - "traefik.http.routers.app.middlewares=upload-limit"

أما المهلة فهي التي تفاجئ أكثر المستخدمين، فالقيمة الافتراضية لـ transport.respondingTimeouts.readTimeout على نقطة الدخول EntryPoint هي 60 ثانية، وهي أقصى مدة لقراءة الطلب كاملاً بما فيه الجسم. وهذا يعني أنه إذا استغرق رفع الملف أكثر من دقيقة على اتصال بطيء قطع Traefik الطلب بعد 60 ثانية بالضبط مهما كان الحجم صغيراً، وقد يسجله في سجل الوصول Access Log بالرمز 499 Client Closed Request، فتظن أن المستخدم أغلق الصفحة والحقيقة أن المهلة انتهت. وتعدل هذه المهلة في الإعداد الثابت traefik.yml ثم تعيد تشغيل Traefik:

entryPoints:
  websecure:
    address: ":443"
    transport:
      respondingTimeouts:
        readTimeout: 30m

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

Caddy

ولا يحد Caddy حجم الطلب افتراضياً أيضاً، وتضيف الحد بالتوجيه request_body وخياره max_size، ويرد Caddy بالرمز 413 عند تجاوزه:

app.example.com {
	request_body /api/upload* {
		max_size 110MB
	}
	reverse_proxy app:3000
}

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

HAProxy

أما HAProxy فلا يحد حجم الطلب افتراضياً، ولا يوجد فيه توجيه مخصص لذلك، ولكنك تستطيع رفض الطلب بحسب الترويسة Header Content-Length بقاعدة http-request deny في قسم frontend:

    http-request deny deny_status 413 if { req.hdr_val(content-length) gt 115343360 }

وهذه القاعدة لا تشمل الطلبات المرسلة بترميز Chunked Transfer Encoding، والسبب أنها لا تحمل Content-Length، لذلك أبق الحد الفعلي في التطبيق أيضاً. ولكل مهلة معنى محدد في توثيق HAProxy كما يلي:

  • timeout client: مدة عدم النشاط Inactivity المسموحة من جهة المستخدم، والرفع البطيء لا يتأثر بها ما دامت البيانات تصل.
  • timeout http-request: أقصى مدة لاستقبال الطلب، وتشمل افتراضياً الترويسات فقط، فإذا فعلت option http-buffer-request شملت الجسم أيضاً، وعندها يقطع HAProxy الرفع الطويل بالرمز 408.
  • timeout server: مدة انتظار التطبيق، فإذا تأخر رده بعد وصول الملف ظهر الخطأ 504.

حدود التطبيق وإطار العمل

PHP

حدود PHP هي أكثر الحدود إرباكاً، والسبب أنه لا يرفض الطلب برمز خطأ، وإنما يقبله ويحذف منه ما تجاوز الحد. وفيما يلي أهم الإعدادات وقيمها الافتراضية كما في توثيق php.ini وإعدادات وقت التشغيل:

الإعدادالقيمة الافتراضيةما يحدده
upload_max_filesize2Mأقصى حجم للملف الواحد
post_max_size8Mأقصى حجم للطلب كله، ويجب أن يكون أكبر من upload_max_filesize
max_file_uploads20أقصى عدد للملفات في الطلب الواحد
max_execution_time30 ثانيةأقصى مدة لتنفيذ السكربت
max_input_time-1، أي يأخذ قيمة max_execution_timeأقصى مدة لقراءة المدخلات
memory_limit128Mأقصى ذاكرة للسكربت، ويوصي التوثيق بأن تكون أكبر من post_max_size

والفرق بين الحدين الأولين يغير ما تراه أمامك:

  • إذا تجاوز الملف upload_max_filesize وبقي الطلب تحت post_max_size، يصل الطلب إلى السكربت بالرمز 200 والملف فيه دون محتوى، والحقل $_FILES['file']['error'] يحمل القيمة 1، أي UPLOAD_ERR_INI_SIZE.
  • وإذا تجاوز الطلب كله post_max_size، تصل المصفوفتان $_POST و$_FILES فارغتين تماماً، ويكتب PHP تحذيراً مثل POST Content-Length of 9437394 bytes exceeds the limit of 8388608 bytes، فإذا كان تطبيقك يتحقق من حقل في النموذج قبل الملف فسوف يرى المستخدم خطأ لا علاقة له بالحجم، مثل «حقل العنوان مطلوب»، وهذه من أكثر الأخطاء التي تضيع وقت المطور في المكان الخطأ.

وال Image الرسمي لـ PHP لا يحمل ملف php.ini مفعلاً، لذلك تعمل القيم الافتراضية السابقة، وتعدلها بملف .ini تضعه في المجلد $PHP_INI_DIR/conf.d/، وهو /usr/local/etc/php/conf.d/. ونفس الطريقة تصلح لل Image الرسمي لـ WordPress كما في دليل تثبيت WordPress مع Docker Compose. الآن سوف نقوم بإنشاء الملف uploads.ini بجانب ملف Compose:

upload_max_filesize = 100M
post_max_size = 105M
max_execution_time = 300
max_input_time = 300
memory_limit = 256M

في الإعداد أعلاه لاحظ أن post_max_size أكبر قليلاً من upload_max_filesize، والسبب أن الطلب يحمل مع الملف حقول النموذج، وأن memory_limit أكبر من الاثنين كما يوصي التوثيق. ثم اربط الملف داخل ال Container في الخدمة التي تشغل PHP:

    volumes:
      - ./uploads.ini:/usr/local/etc/php/conf.d/uploads.ini:ro

بعد ذلك أعد إنشاء ال Container، وتأكد أن القيم الجديدة مفعلة:

docker compose up -d
docker compose exec app php -i | grep -E "upload_max_filesize|post_max_size"

وإذا كان PHP-FPM خلف Nginx فارفع client_max_body_size وfastcgi_read_timeout في Nginx أيضاً، وإلا بقي حد Nginx هو الأصغر وعدت إلى نفس الخطأ.

Laravel

قاعدة التحقق max تحسب حجم الملفات بالكيلوبايت وليس بالبايت، فحد 100 ميجابايت يكتب max:102400:

$request->validate([
    'file' => ['required', 'file', 'max:102400'],
]);

وفي Laravel يوجد Middleware اسمه ValidatePostSize يقارن الترويسة Content-Length بقيمة post_max_size في PHP، فإذا كان الطلب أكبر رد Laravel بالرمز 413 ورسالة The POST data is too large.، لذلك إذا رأيت هذه الرسالة فالحل في uploads.ini وليس في ال Reverse Proxy.

Node.js وExpress

القيمة الافتراضية للخيار limit في express.json() وexpress.urlencoded() هي 100kb، وعند تجاوزها يرد Express بالرمز 413 ورسالة request entity too large. وتظهر هذه المشكلة عادة عندما ترسل الواجهة الملف داخل JSON بترميز Base64، وهذا الأسلوب يزيد الحجم بنحو الثلث ويحمل الملف كاملاً في الذاكرة، لذلك أرسل الملفات بصيغة multipart/form-data، فهذان المحللان لا يقرآنها أصلاً.

ولقراءة الملفات تستخدم multer، وهي مبنية على busboy، وقيمة limits.fileSize فيها افتراضياً Infinity أي دون حد. فإذا حددتها رمت multer عند التجاوز خطأ MulterError بالرمز LIMIT_FILE_SIZE، وهذا الخطأ لا يحمل رمز HTTP، فيرد معالج الأخطاء الافتراضي في Express بالرمز 500 ويظن المستخدم أن السيرفر معطل، لذلك عالجه بنفسك كما يلي:

const upload = multer({
  dest: '/var/app/uploads',
  limits: { fileSize: 100 * 1024 * 1024 },
});

app.post('/api/upload', upload.single('file'), (req, res) => {
  res.json({ size: req.file.size });
});

app.use((err, req, res, next) => {
  if (err instanceof multer.MulterError && err.code === 'LIMIT_FILE_SIZE') {
    return res.status(413).json({ error: 'حجم الملف أكبر من 100 ميجابايت' });
  }
  next(err);
});

واستخدم dest أو diskStorage، ولا تستخدم memoryStorage التي تحفظ الملف كاملاً في الذاكرة. وإذا استخدمت busboy مباشرة فتذكر أنه لا يرفض الطلب عند تجاوز fileSize، وإنما يقطع الملف ويضع truncated على true، فإذا لم تتحقق من هذه الخاصية في نهاية التدفق فسوف تحفظ ملفاً ناقصاً دون أي خطأ.

Django

يخلط كثير من المطورين بين إعدادين في إعدادات Django، والقيمة الافتراضية لكل منهما 2.5 ميجابايت:

  • DATA_UPLOAD_MAX_MEMORY_SIZE: حد حجم الطلب من غير الملفات المرفوعة، أي الحقول وجسم JSON، فإذا تجاوزه الطلب رمى Django خطأ RequestDataTooBig ورد بالرمز 400، ولهذا يصطدم به من يرسل الملفات داخل JSON.
  • FILE_UPLOAD_MAX_MEMORY_SIZE: ليس حداً للرفع، وإنما الحجم الذي ينتقل بعده Django من حفظ الملف في الذاكرة إلى ملف مؤقت على القرص.

وهذا يعني أن Django لا يحد حجم الملفات المرفوعة افتراضياً، لذلك ضع الحد في ال Reverse Proxy، وتحقق من file.size في النموذج Form أو في ال View. ويوصي التوثيق تحت ASGI بحماية إضافية أمام Django، والسبب أن الطلب قد يحفظ كاملاً على القرص قبل أن يطبق أي حد.

ASP.NET Core

يفرض Kestrel حداً افتراضياً للطلب قدره 30,000,000 بايت، أي نحو 28.6 ميجابايت، كما يشرح توثيق رفع الملفات، وفوقه حد MultipartBodyLengthLimit في FormOptions وقيمته الافتراضية 128 MB لكل جزء من النموذج. وإذا كان التطبيق خلف IIS فحد IIS نفسه maxAllowedContentLength هو 30,000,000 بايت أيضاً، ويرفض IIS الطلب قبل أن يصل إلى التطبيق. والطريقة الأنسب لرفع الحد لمسار واحد هي السمة [RequestSizeLimit] على ال Action:

[HttpPost("api/upload")]
[RequestSizeLimit(115_343_360)]
[RequestFormLimits(MultipartBodyLengthLimit = 115_343_360)]
public async Task<IActionResult> Upload(IFormFile file)
{
    // ...
}

وإذا أردت حداً عاماً لكل الطلبات فاضبط MaxRequestBodySize في إعداد Kestrel:

builder.WebHost.ConfigureKestrel(options =>
{
    options.Limits.MaxRequestBodySize = 115_343_360;
});

Spring Boot

القيم الافتراضية في خصائص Spring Boot صغيرة، فالخاصية spring.servlet.multipart.max-file-size هي 1MB للملف الواحد، وspring.servlet.multipart.max-request-size هي 10MB للطلب كله، وعند تجاوزهما يرمي Spring خطأ MaxUploadSizeExceededException، وتلتقطه في @ControllerAdvice لترد برسالة واضحة. وترفع الحدين في application.properties:

spring.servlet.multipart.max-file-size=100MB
spring.servlet.multipart.max-request-size=105MB

Go

مكتبة net/http لا تحد حجم الطلب افتراضياً، والمعامل maxMemory في ParseMultipartForm ليس حداً للرفع، وإنما مقدار ما يحفظ من الملفات في الذاكرة، والباقي يكتب في ملفات مؤقتة، وFormFile يستدعي ParseMultipartForm تلقائياً. لذلك ضع الحد الفعلي بـ http.MaxBytesReader قبل قراءة الطلب، ورد بالرمز 413 إذا تجاوزه:

func upload(w http.ResponseWriter, r *http.Request) {
	r.Body = http.MaxBytesReader(w, r.Body, 110<<20)
	if err := r.ParseMultipartForm(32 << 20); err != nil {
		var tooBig *http.MaxBytesError
		if errors.As(err, &tooBig) {
			http.Error(w, "حجم الملف أكبر من المسموح", http.StatusRequestEntityTooLarge)
			return
		}
		http.Error(w, "طلب غير صالح", http.StatusBadRequest)
		return
	}
	// ...
}

تطبيقات الاستضافة الذاتية الشائعة

  • WordPress: يعتمد على حدود PHP، فعدلها بملف uploads.ini كما سبق، ثم ارفع حد ال Reverse Proxy.
  • Nextcloud: يرفع من المتصفح بأجزاء حجمها الافتراضي 100 MiB كما يذكر دليل رفع الملفات الكبيرة، وهذا الحجم أكبر قليلاً من حد Cloudflare في الخطة المجانية، لذلك اجعل الأجزاء 50 ميجابايت إذا كان Nextcloud خلف Cloudflare:
sudo -E -u www-data php occ config:system:set --type int --value 52428800 files.chunked_upload.max_size
  • Seafile: لا يحد حجم الرفع إذا لم تضبط max_upload_size في قسم [fileserver] من ملف seafile.conf، وعندها يبقى حد ال Reverse Proxy هو الحاكم.

كيف تعرف الطبقة التي رفضت الملف؟

اقرأ الرد قبل أن تغير أي إعداد

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

ما تراه في الردالطبقة المرجحة
صفحة HTML تنتهي بكلمة cloudflare، مع الترويستين server: cloudflare وcf-ray، ولا شيء في سجلات السيرفرCloudflare
صفحة HTML تنتهي بـ nginx أو nginx/1.27.5، والترويسة Server: nginxNginx
الصفحة نفسها ولكن تنتهي بـ openrestyNginx Proxy Manager أو OpenResty
نص قصير Request Entity Too Large دون ترويسة ServerTraefik
رد JSON أو صفحة خطأ بتصميم تطبيقكإطار العمل أو التطبيق
رد 200 والملف غير موجود، أو أخطاء تحقق غريبةPHP وpost_max_size

ثم راجع السجلات، فإذا رفض Nginx الطلب بسبب الحجم فسوف تجد في سجل الأخطاء سطراً مثل هذا:

client intended to send too large body: 15728851 bytes, client: 203.0.113.50, server: app.example.com, request: "POST /api/upload HTTP/1.1"

اختبر كل طبقة بملف مولد

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

head -c 150M /dev/urandom > test.bin

ثم ارفعه من الداخل إلى الخارج طبقة بعد طبقة، وابدأ بالتطبيق مباشرة من السيرفر نفسه دون المرور بال Reverse Proxy:

curl -sS -o /dev/null -w "%{http_code}\n" -F [email protected] http://127.0.0.1:3000/api/upload

بعد ذلك ارفعه عبر ال Reverse Proxy دون Cloudflare، بتوجيه النطاق إلى العنوان العام للسيرفر من سطر الأوامر، والخيار -D - يطبع ترويسات الرد فتعرف مصدره:

curl -sS -o /dev/null -D - --resolve app.example.com:443:203.0.113.10 -F [email protected] https://app.example.com/api/upload

وأخيراً ارفعه عبر النطاق العام نفسه، أي عبر Cloudflare:

curl -sS -o /dev/null -D - -F [email protected] https://app.example.com/api/upload

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

مشكلات المهلة على الاتصالات البطيئة

قد ينجح الرفع من مكتبك على شبكة سريعة، ثم يفشل عند مستخدم على شبكة جوال ضعيفة، والسبب هنا الزمن وليس الحجم. وتستطيع أن تحاكي الاتصال البطيء بالخيار --limit-rate، فالأمر التالي يرفع بسرعة 100 كيلوبايت في الثانية تقريباً:

time curl -sS -o /dev/null -w "%{http_code}\n" --limit-rate 100k -F [email protected] https://app.example.com/api/upload

ثم انظر بعد كم من الوقت فشل الطلب، فإذا انقطع بعد 60 ثانية بالضبط خلف Traefik فالسبب المهلة readTimeout، وإذا فشل بالرمز 408 من Nginx فالمستخدم توقف عن الإرسال مدة أطول من client_body_timeout، وإذا وصل الملف كاملاً ثم ظهر 504 أو 524 فالتطبيق تأخر في الرد بعد الرفع.

تصميم مقترح لرفع الملفات الكبيرة

حد صريح في كل طبقة، والتطبيق هو من يرفض

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

الطبقةالقيمة
الواجهة الأماميةتمنع اختيار ملف أكبر من 100 ميجابايت
التطبيقmax:102400 أو fileSize: 100 * 1024 * 1024
PHP عند الحاجةupload_max_filesize = 100M وpost_max_size = 105M
ال Reverse Proxyclient_max_body_size 110m لمسار الرفع وحده
Cloudflare100 MB في الخطة المجانية

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

رسالة واضحة للمستخدم

افحص الحجم في المتصفح قبل أن يبدأ الرفع بالخاصية File.size، حتى لا ينتظر المستخدم دقائق ليعرف أن ملفه أكبر من المسموح:

const MAX_BYTES = 100 * 1024 * 1024;

input.addEventListener('change', () => {
  const file = input.files[0];
  if (file && file.size > MAX_BYTES) {
    showError('حجم الملف أكبر من 100 ميجابايت');
    input.value = '';
  }
});

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

الرفع المجزأ أو المباشر للملفات الكبيرة

إذا كانت ملفاتك تتجاوز مئة ميجابايت بانتظام، مثل مقاطع الفيديو والنسخ الاحتياطية Backups وملفات الأقراص، فلا ترفعها في طلب واحد، والسبب أن الطلب الطويل يفشل مع أي انقطاع بسيط ويعود المستخدم إلى الصفر. واختر أحد الحلين الموصوفين في قسم Cloudflare، فالرفع المجزأ بـ tus يناسبك إذا أردت أن تبقى الملفات على السيرفر، والروابط الموقعة مسبقاً تناسبك إذا كان التخزين أصلاً في S3 أو R2 أو MinIO، وهي ترفع الحمل عن السيرفر تماماً.

لا تحمل الملف في الذاكرة، وتحقق من محتواه

  • اكتب الملف إلى القرص أو التخزين وأنت تستقبله، ولا تحمله كاملاً في الذاكرة، فعشرة مستخدمين يرفعون في وقت واحد ملفات حجمها 500 ميجابايت يحتاجون إلى 5 جيجابايت من الذاكرة إذا حملتها، وعندما تنفد الذاكرة يتوقف التطبيق.
  • راقب مساحة المجلدات المؤقتة، والسبب أن Nginx وPHP وإطارات العمل تكتب فيها قبل أن يصل الملف إلى مكانه النهائي، وداخل ال Container يكون /tmp غالباً على نفس القرص الذي يعمل عليه النظام.
  • تحقق من نوع الملف بقراءة محتواه وليس بامتداده، وأعطه اسماً عشوائياً تولده أنت، واحفظه خارج المجلد الذي يخدمه الموقع مباشرة.
  • إذا كان المستخدمون يتبادلون الملفات فيما بينهم فافحصها ببرنامج مكافحة البرمجيات الخبيثة Malware مثل ClamAV في مهمة خلفية قبل أن تتيحها للتنزيل.

ملخص الأعراض وحلولها (Troubleshooting)

العرضالطبقةالحل
413 في صفحة تنتهي بـ cloudflare، ولا أثر للطلب في سجلاتكCloudflareرفع مجزأ، أو روابط موقعة مسبقاً، أو نطاق فرعي بوضع DNS Only
524 بعد نحو دقيقتينCloudflare تنتظر رد التطبيقرد فوراً بعد حفظ الملف، وعالجه في مهمة خلفية
413 في صفحة تنتهي بـ nginx أو openrestyNginx أو NPMارفع client_max_body_size للمسار أو المضيف
413 بنص Request Entity Too Large فقطTraefikارفع maxRequestBodyBytes في Buffering Middleware
انقطاع بعد 60 ثانية تماماً، و499 في سجل TraefikTraefikارفع respondingTimeouts.readTimeout على نقطة الدخول
408 أثناء الرفعNginx أو HAProxyclient_body_timeout، أو timeout http-request مع http-buffer-request
504 بعد اكتمال الرفعال Reverse Proxy ينتظر التطبيقproxy_read_timeout أو fastcgi_read_timeout أو timeout server، أو معالجة خلفية
رد 200 و$_FILES فارغة، وتحذير POST Content-Length ... exceeds the limitPHPارفع post_max_size
$_FILES['file']['error'] يساوي 1PHPارفع upload_max_filesize
413 برسالة The POST data is too large.Laravel وحد PHPارفع post_max_size
413 برسالة request entity too large مع JSONExpressأرسل الملف بـ multipart، أو ارفع limit
500 برسالة File too largemulterعالج LIMIT_FILE_SIZE وأعد 413
ملف محفوظ أصغر من الأصل دون خطأbusboyتحقق من truncated في نهاية التدفق
400 وRequestDataTooBigDjangoDATA_UPLOAD_MAX_MEMORY_SIZE، أو أرسل الملف بـ multipart
رفض الطلبات فوق 28.6 ميجابايت تقريباًKestrel أو IIS[RequestSizeLimit] أو MaxRequestBodySize أو maxAllowedContentLength
MaxUploadSizeExceededException مع ملف أكبر من 1 ميجابايتSpring Bootmax-file-size وmax-request-size

الخلاصة

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

  • الملف المرفوع يمر بعدة طبقات، ولكل طبقة حد للحجم وحد للزمن، والحد الأصغر هو الذي يقرر، لذلك لا تطارد الخطأ من طبقة إلى طبقة، وإنما ضع حداً صريحاً في كل طبقة واجعل التطبيق هو من يرفض.
  • Cloudflare ترفض كل طلب فوق 100 MB في الخطتين Free وPro قبل أن يصل إلى السيرفر، وتنتظر رد التطبيق 125 ثانية فقط، ولا يغير ذلك أي إعداد على السيرفر، والحل هو الرفع المجزأ أو الروابط الموقعة مسبقاً.
  • Nginx يرفض افتراضياً كل ما فوق 1m، وTraefik يقطع أي طلب يستغرق أكثر من 60 ثانية، فراجع client_max_body_size وreadTimeout قبل أي شيء آخر.
  • PHP لا يرفض الطلب برمز خطأ وإنما يفرغه، واجعل post_max_size أكبر قليلاً من upload_max_filesize دائماً.
  • اقرأ الرد وترويساته في أدوات المطور قبل أن تغير أي إعداد، واختبر من الداخل إلى الخارج بملف مولد، وأول طبقة يفشل عندها الرفع هي صاحبة الحد.
  • لا تحمل الملف كاملاً في الذاكرة، وعالج المعالجة الثقيلة في مهمة خلفية حتى يرد التطبيق فوراً.

سجل التحديثات (Changelog)

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

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

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

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

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