لنفرض أن لديك تطبيقاً على السيرفر فيه صفحة لرفع الملفات، ويختار المستخدم ملفاً حجمه 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، ويعبر الطلب هذه الطبقات بالترتيب:
- المتصفح، وفيه الواجهة الأمامية Frontend التي تختار الملف وترسله.
- Cloudflare، إذا كان النطاق Domain يمر عبر شبكتها بالسحابة البرتقالية، أو كنت تستخدم Cloudflare Tunnel.
- ال Reverse Proxy على السيرفر، مثل Nginx أو Nginx Proxy Manager أو Traefik أو Caddy أو HAProxy.
- سيرفر التطبيق وبيئة التشغيل Runtime، مثل PHP أو Node.js أو Kestrel أو Tomcat.
- إطار العمل Framework وقواعد التحقق Validation في تطبيقك.
- التخزين Storage، سواءً كان القرص أو تخزين الكائنات Object Storage مثل S3 وR2 وMinIO.
والصورة التالية تبين هذه الطبقات مع الحد الافتراضي في كل منها:

ولكل طبقة حدان مستقلان، حد للحجم وحد للزمن، لذلك قد يكون الحجم مسموحاً في كل الطبقات ومع ذلك يفشل الرفع، والسبب أن اتصال المستخدم بطيء فتنتهي مهلة إحدى الطبقات قبل أن يصل الملف كاملاً. والجدول التالي يلخص القيم الافتراضية، وتجد تفاصيلها في الأقسام القادمة:
| الطبقة | حد الحجم الافتراضي | حد الزمن الافتراضي | ما يحدث عند التجاوز |
|---|---|---|---|
| Cloudflare (Free وPro) | 100 MB للطلب | 125 ثانية بانتظار رد السيرفر | 413 من Cloudflare، أو 524 |
| Nginx | 1m | 60 ثانية بين كل قراءتين | 413، أو 408، أو 504 |
| Nginx Proxy Manager | 2000m | 90 ثانية للتطبيق | مثل Nginx |
| Traefik | لا يوجد حد | 60 ثانية للطلب كاملاً | قطع الاتصال بعد 60 ثانية |
| Caddy | لا يوجد حد | دقيقة من التوقف عن الإرسال | قطع الاتصال |
| HAProxy | لا يوجد حد | بحسب timeout client وtimeout server | 408 أو 504 |
| PHP | upload_max_filesize = 2M وpost_max_size = 8M | max_execution_time = 30 | رد 200 مع ملفات فارغة |
| إطارات العمل | تختلف كثيراً | غالباً لا يوجد حد | 413 أو 400 أو 500 |
والحل الأول الذي يخطر على بال أي مطور هو أن يرفع حد الطبقة التي ظهر منها الخطأ، فيضيف client_max_body_size في Nginx ويعيد المحاولة، فينتقل الخطأ إلى الطبقة التالية، فيرفض PHP الملف بصمت، فيرفع حدوده أيضاً، ثم يكتشف أن الملف لا يصل أصلاً لأن Cloudflare ترفض كل طلب فوق 100 ميجابايت مهما فعل على السيرفر. لذلك الطريقة الصحيحة ليست مطاردة الخطأ من طبقة إلى طبقة، وإنما أن تعرف حد كل طبقة مسبقاً، وتضع حداً صريحاً في كل منها، ثم تنتقل إلى الرفع المجزأ أو الرفع المباشر إلى التخزين عندما تكون ملفاتك أكبر من حد أي طبقة لا تملكها، وهذا ما سوف نقوم به في بقية المقال.
Cloudflare: الحد الذي لا تملك تعديله
حجم الطلب بحسب الخطة
إذا كان سجل DNS لنطاقك يمر عبر Cloudflare، أي في وضع Proxied، فإن Cloudflare تستقبل الطلب قبل السيرفر، وتفرض عليه حداً أقصى لحجم الرفع بحسب خطتك كما يلي:
| الخطة | أقصى حجم للطلب الواحد |
|---|---|
| Free | 100 MB |
| Pro | 100 MB |
| Business | 200 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_filesize | 2M | أقصى حجم للملف الواحد |
post_max_size | 8M | أقصى حجم للطلب كله، ويجب أن يكون أكبر من upload_max_filesize |
max_file_uploads | 20 | أقصى عدد للملفات في الطلب الواحد |
max_execution_time | 30 ثانية | أقصى مدة لتنفيذ السكربت |
max_input_time | -1، أي يأخذ قيمة max_execution_time | أقصى مدة لقراءة المدخلات |
memory_limit | 128M | أقصى ذاكرة للسكربت، ويوصي التوثيق بأن تكون أكبر من 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=105MBGo
مكتبة 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: nginx | Nginx |
الصفحة نفسها ولكن تنتهي بـ openresty | Nginx Proxy Manager أو OpenResty |
نص قصير Request Entity Too Large دون ترويسة Server | Traefik |
| رد 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 Proxy | client_max_body_size 110m لمسار الرفع وحده |
| Cloudflare | 100 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 أو openresty | Nginx أو NPM | ارفع client_max_body_size للمسار أو المضيف |
413 بنص Request Entity Too Large فقط | Traefik | ارفع maxRequestBodyBytes في Buffering Middleware |
انقطاع بعد 60 ثانية تماماً، و499 في سجل Traefik | Traefik | ارفع respondingTimeouts.readTimeout على نقطة الدخول |
408 أثناء الرفع | Nginx أو HAProxy | client_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 limit | PHP | ارفع post_max_size |
$_FILES['file']['error'] يساوي 1 | PHP | ارفع upload_max_filesize |
413 برسالة The POST data is too large. | Laravel وحد PHP | ارفع post_max_size |
413 برسالة request entity too large مع JSON | Express | أرسل الملف بـ multipart، أو ارفع limit |
500 برسالة File too large | multer | عالج LIMIT_FILE_SIZE وأعد 413 |
| ملف محفوظ أصغر من الأصل دون خطأ | busboy | تحقق من truncated في نهاية التدفق |
400 وRequestDataTooBig | Django | DATA_UPLOAD_MAX_MEMORY_SIZE، أو أرسل الملف بـ multipart |
| رفض الطلبات فوق 28.6 ميجابايت تقريباً | Kestrel أو IIS | [RequestSizeLimit] أو MaxRequestBodySize أو maxAllowedContentLength |
MaxUploadSizeExceededException مع ملف أكبر من 1 ميجابايت | Spring Boot | max-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: كتابة المقال.