لنفرض أن لديك شركة ناشئة تدير منصة لإعلانات العقارات، فيها قرابة مليوني صورة لشقق وفلل رفعها الملاك والوسطاء خلال ثلاث سنوات، وكان خط المعالجة عند الرفع يصنع من كل صورة ثلاثة مقاسات ثابتة: 1200 و600 و200 بكسل بصيغة JPEG. ثم قرر فريق التصميم أن بطاقة العقار في الصفحة الرئيسية صارت بعرض 420 بكسلاً، وأن تطبيق الهاتف الجديد يحتاج صوراً مربعة بعرض 360، وأن الوقت قد حان للانتقال إلى WebP وAVIF. وعندما حسب المطور ما يعنيه ذلك وجد أن إعادة معالجة مليوني صورة بأربعة مقاسات جديدة تحتاج قرابة خمسة أيام على سيرفر بثمانية معالجات، كما سوف نرى بالأرقام، وأن هذا سوف يتكرر مع كل تغيير في التصميم، وأن ثلث المقاسات التي سوف يصنعها لن يطلبها أحد أبداً، لأن أغلب الإعلانات القديمة لا يفتحها أحد.
والحل الذي تستخدمه أغلب المواقع الكبيرة اليوم هو أن تعكس الفكرة: لا تصنع المقاسات مسبقاً، وإنما احفظ الصورة الأصلية فقط، واجعل رابط الصورة نفسه يحمل الوصفة، أي المقاس والقص والصيغة والجودة، فتصنع خدمة متخصصة الصورة المطلوبة في أول طلب، ثم يحفظها Cache أمامها لكل من بعده. وهذا ما يسمى خدمة الصور عند الطلب On-demand Image Processing، وأشهر أداتين مفتوحتي المصدر لها هما imgproxy وThumbor. وقد شرحنا في مقال معالجة الصور على السيرفر، ضمن نفس المرحلة من خارطة الطريق، كيف يعمل محركا libvips وImageMagick من الداخل، وكيف تبني خطاً يعالج الصور مرة واحدة عند الرفع، وهذا المقال يكمل من هناك: متى تكون المعالجة عند الطلب أفضل، وكيف تعمل الأداتان، وكيف تحميهما من أن يتحولا إلى طريقة سهلة لإيقاف سيرفرك.
وسوف نناقش في هذا المقال ما يلي:
- الصناعة المسبقة Pre-generation مقابل المعالجة عند الطلب، وحساب تكلفة كل منهما بأرقام حقيقية.
- كيف تعمل خدمة الصور عند الطلب: الرابط كوصفة، والطبقات من ال CDN إلى مصدر الصورة.
- imgproxy وThumbor: من بناهما، وبأي لغة، وبأي محرك صور، وما الذي يميز كل منهما.
- توقيع الروابط Signed URLs بخوارزمية HMAC، ولماذا لا يجوز تشغيل الخدمة دونه.
- اختيار الصيغة بحسب المتصفح Content Negotiation، والخطأ الذي يجعل المتصفح القديم يستقبل صورة لا يفهمها.
- تجربة عملية: الأداتان خلف nginx، مع قياس الزمن والإنتاجية والمعالج والذاكرة، والصور الضخمة والقنبلة، والفخ الذي جعل Thumbor أبطأ أربع مرات.
- التشغيل في الإنتاج، وحساب السعة، وكيف تختار بين الأداتين.
الصناعة المسبقة Pre-generation أم المعالجة عند الطلب On-demand؟
الطريقة الأولى، وهي التي بدأت بها المنصة، أن يصنع خط المعالجة كل المقاسات لحظة الرفع ويحفظها في التخزين، ثم تقدمها شبكة توصيل المحتوى CDN كملفات ثابتة، وهذه طريقة ممتازة ما دامت المقاسات قليلة وثابتة، والسبب أن كل صورة تعالج مرة واحدة فقط، والتقديم بعدها لا يكلف إلا قراءة ملف. لكنها تفشل في ثلاث حالات تظهر مع الوقت في أي منتج حي:
- تغيير التصميم: كل مقاس جديد يعني أن تعيد معالجة المكتبة كلها، وكلما كبرت المكتبة طالت المهمة وزادت تكلفتها.
- كثرة الشاشات: الهاتف والحاسب والتطبيق وشاشات الكثافة العالية، وكل منها يحتاج مقاساً مختلفاً، فإذا ضربت أربعة مقاسات في ثلاث صيغ في كثافتين صار لديك 24 ملفاً لكل صورة، أغلبها لا يطلب.
- الصور التي لا يراها أحد: في منصة إعلانات مثل منصتنا يفتح الناس الإعلانات الحديثة غالباً، والإعلان الذي مضى عليه سنة نادراً ما يفتح، لكن الصناعة المسبقة تدفع ثمن معالجة صوره كاملة.
أما المعالجة عند الطلب فتدفع ثمن المعالجة عند أول طلب فقط لكل تركيبة من صورة ومقاس وصيغة، ثم تقدمها من ال Cache، فلا يعالج ما لا يطلبه أحد، ويصبح المقاس الجديد تغييراً في كود الواجهة فقط. وثمنها أن أول طلب أبطأ، وأن لديك خدمة إضافية يجب أن تتحمل الذروة، وأن الرابط صار مدخلاً يتحكم فيه الزائر، وهذه نقطة أمنية سوف نخصص لها قسماً كاملاً. والجدول التالي يلخص الفرق:
| المعيار | الصناعة المسبقة | المعالجة عند الطلب |
|---|---|---|
| متى تعالج الصورة؟ | مرة عند الرفع لكل المقاسات | مرة عند أول طلب لكل تركيبة |
| مقاس جديد في التصميم | إعادة معالجة المكتبة كلها | تغيير الرابط في الواجهة فقط |
| التخزين | الأصل وكل المقاسات | الأصل فقط، والمقاسات في Cache يمكن حذفه |
| زمن أول طلب | زمن قراءة ملف | مئات المللي ثانية للمعالجة |
| الخطر الأمني | محدود بالرفع | كل رابط مدخل، ويحتاج توقيعاً وحدوداً |
| تناسب | مقاسات قليلة ثابتة، أو صور تطلب كلها | واجهات تتغير، ومكتبات كبيرة أغلبها قديم |
ولاحظ أن الطريقتين ليستا متعارضتين، فكثير من الفرق تبقي خط المعالجة عند الرفع للتحقق والتنظيف وحفظ نسخة أصلية نظيفة بحد أقصى معقول، ثم تقدم كل المقاسات عند الطلب من هذه النسخة، وهذا ما سوف ننصح به في قسم الإنتاج.
كيف تعمل خدمة الصور عند الطلب؟
الفكرة الأساسية أن الرابط نفسه هو الوصفة، فبدل أن يطلب المتصفح /photos/123-600.jpg وهو ملف موجود، يطلب رابطاً يقول «خذ الصورة الأصلية رقم 123، وصغرها لتدخل في عرض 420، وأعطني أفضل صيغة يفهمها متصفحي»، وتنفذ الخدمة الوصفة في كل مرة يصلها الطلب. والصورة التالية تبين الطبقات التي يمر بها هذا الطلب:

وفي الشكل أعلاه لاحظ أن الخدمة نفسها لا تحفظ شيئاً في الأداتين بإعداداتهما الافتراضية، فهي تقرأ الأصل وتعالجه وتعيد الناتج، والحفظ مسؤولية الطبقات التي أمامها، وهذا قرار تصميم مقصود، لأن الخدمة بلا حالة Stateless تكبر بإضافة نسخ دون أي تنسيق بينها، ولأن ال Cache أداة متخصصة في الحفظ والطرد والصلاحية، وقد شرحنا ذلك في شبكة توصيل المحتوى CDN والتخزين المؤقت أمام موقعك. والنتيجة العملية أن نسبة الطلبات التي تصل إلى الخدمة فعلاً، أي نسبة الفشل في ال Cache، هي الرقم الذي يحدد عدد السيرفرات التي تحتاجها، وليس عدد زوار الموقع.
وفي الطرف الآخر يطلب المتصفح المقاس المناسب لشاشته بالسمتين srcset وsizes في وسم الصورة، كما يشرح دليل MDN للصور المتجاوبة، وقد شرحناها في صيغ الصور، فتكتب الواجهة عدة روابط لنفس الصورة بعروض مختلفة، ويختار المتصفح واحداً منها، ولا يعرف السيرفر عن ذلك شيئاً إلا الرابط الذي وصله.
ماذا يحدث داخل الخدمة عندما لا يجد ال Cache الصورة Cache Miss؟
عندما يصل طلب لم يره ال Cache من قبل تمر الخدمة بخطوات ثابتة، وفهم ترتيبها هو ما يجعلك تعرف أين يضيع الزمن وأين تضع الحدود. أولاً تتحقق من التوقيع، وهذه عملية هاش على نص قصير تأخذ أجزاء من المللي ثانية، فإذا فشلت انتهى الطلب قبل أي شيء آخر. ثم تفسر خيارات الرابط وتتحقق أنها مسموحة، ثم تحمل الصورة الأصلية من المصدر، سواء كان ملفاً محلياً أو رابط HTTP أو Bucket في S3، وهنا أول نقطة قد تعلق فيها الخدمة إذا كان المصدر بطيئاً، لذلك لكل أداة مهلة تحميل Download Timeout. ثم تقرأ ترويسة الصورة لتعرف أبعادها وصيغتها، وترفض ما يتجاوز حد البكسلات قبل أن تفك أي بكسل، ثم تبني خط المعالجة: التصغير أثناء القراءة إن كانت الصيغة تسمح، ثم التدوير بحسب اتجاه الكاميرا، ثم التصغير الدقيق والقص، ثم تحويل الألوان، ثم الترميز بالصيغة المختارة، ثم ترسل الناتج مع ترويسات الصلاحية.
وفي التجربة سوف ترى أن أغلب الزمن يذهب إلى خطوتين: فك الصورة الأصلية وترميز الناتج، فالصورة التي أعادتها imgproxy بصيغة JPEG أخذت 113 مللي ثانية، وبصيغة AVIF أخذت 266، لنفس المقاس ونفس الأصل، أي أن اختيار الصيغة وحده ضاعف زمن المعالجة. وهذا يعني أن قرار تفعيل AVIF ليس قرار حجم فقط، وإنما قرار معالج أيضاً، لكنه يدفع مرة واحدة لكل تركيبة، ثم يوفر في النقل مع كل طلب بعدها.
الأداتان: imgproxy وThumbor
imgproxy خدمة مكتوبة بلغة Go فوق مكتبة libvips، وقد قدمتها شركة Evil Martians في تدوينة التعريف بها على أنها خدمة سريعة وآمنة لتصغير الصور، ونسختها المفتوحة برخصة Apache-2.0 كما في ملف الرخصة، ولها نسخة تجارية Pro تضيف ميزات مثل اختيار الجودة تلقائياً والقص الذكي المتقدم كما في صفحة الأسعار. وطريقتها في العمل أنها تأخذ الصورة من مصدر تحدده، مثل رابط HTTP أو S3 أو ملف محلي، ثم تمرر التصغير إلى libvips بالمعالجة حسب الطلب والتصغير أثناء القراءة اللذين شرحناهما في المقال السابق، لذلك ذاكرتها صغيرة وسرعتها عالية مع الصور الكبيرة.
أما Thumbor فهو أقدم، بنته شركة الإعلام البرازيلية Globo.com بلغة Python فوق إطار Tornado، ورخصته MIT كما في ملف الرخصة، ومحركه الافتراضي مكتبة Pillow، ويستطيع أن يستخدم محركات أخرى عبر إضافات. وأشهر ما يميزه القص الذكي Smart Cropping، حيث يكتشف الوجوه أو النقاط المهمة في الصورة بخوارزميات الكشف التي يشرحها توثيق خوارزميات الكشف، فيقص الصورة حولها بدل أن يقص من المنتصف، وهذا مفيد جداً لصور الأشخاص في المواقع الإخبارية. وله نظام إضافات واسع للمحملات Loaders والتخزين والمرشحات Filters، وتجد مكتبات توليد الروابط لأغلب اللغات في صفحة المكتبات.
| المعيار | imgproxy | Thumbor |
|---|---|---|
| اللغة والمحرك | Go فوق libvips | Python وTornado فوق Pillow افتراضياً |
| الرخصة | Apache-2.0، ونسخة Pro تجارية | MIT بالكامل |
| التوازي | عملية واحدة تعالج عدة صور معاً، وعددها IMGPROXY_WORKERS | عملية واحدة افتراضياً، وتزيدها بالخيار --processes |
| توقيع الروابط | HMAC-SHA256 بمفتاح وملح Salt | HMAC-SHA1 بمفتاح |
| حد الصور الضخمة | 50 ميجابكسل افتراضياً | 75 ميجابكسل في الإعداد، ويرفض Pillow ما فوق ضعفها |
| القص الذكي | جاذبية Gravity بسيطة ونقطة اهتمام، والكشف المتقدم في Pro | كشف الوجوه والنقاط المهمة في النسخة المفتوحة |
| تمديد السلوك | الإعداد بمتغيرات البيئة، دون إضافات | إضافات Python للمحملات والتخزين والمرشحات |
الرابط كوصفة: كيف تقرأ رابط كل أداة؟
في imgproxy يتكون الرابط من توقيع، ثم خيارات المعالجة مفصولة بشرطة مائلة، ثم مصدر الصورة، كما يشرح توثيق خيارات المعالجة. وفي Thumbor يتكون من توقيع، ثم خيارات بترتيب ثابت مثل fit-in والمقاس والمرشحات، ثم مسار الصورة كما في توثيق الاستخدام. والصورة التالية تفكك رابطين حقيقيين من التجربة:

ولاحظ أن المصدر في رابط imgproxy يمكن أن يكون رابطاً كاملاً لصورة على موقع آخر، وهذا مفيد إذا كانت صورك في تخزين S3 أو CDN آخر، لكنه يعني أيضاً أن الخدمة تستطيع أن تحمل أي رابط يكتبه الزائر إذا لم تقيدها، فتصبح أداة يستخدمها المخترق ليجعل سيرفرك يطلب عناوين داخلية في شبكتك، وهو ما يسمى تزوير الطلب من جهة الخادم SSRF، لذلك تقيد imgproxy المصادر بالمتغير IMGPROXY_ALLOWED_SOURCES، وتمنع افتراضياً الاتصال بالعناوين الخاصة وعنوان الجهاز نفسه كما في قائمة الإعدادات، ويقيدها Thumbor بالإعداد ALLOWED_SOURCES.
توقيع الروابط Signed URLs: لماذا لا تشغل الخدمة دونه؟
لنفترض أنك شغلت الخدمة دون توقيع، فرابط الصورة صار /rs:fit:420:0/plain/… ويستطيع أي شخص أن يكتبه. والمشكلة ليست في أن الزائر يرى صورة بمقاس آخر، وإنما في أن كل مقاس جديد يطلبه يمر من ال Cache إلى الخدمة، لأنه رابط لم يطلبه أحد قبله، فيكفي المخترق أن يرسل الرابط نفسه مع العرض 421 ثم 422 ثم 423، وهكذا آلاف المرات، فلا يصيب ال Cache أبداً، وتعمل الخدمة بكامل معالجها على صور لا يريدها أحد. وسوف ترى في التجربة أن سيرفراً بثمانية خيوط يعالج قرابة 17 صورة في الثانية من صور الهاتف، أي أن مخترقاً يرسل 20 طلباً في الثانية من حاسب واحد يكفي ليجعل كل الصور الجديدة على المنصة بطيئة، وهذا هجوم حجب خدمة Denial of Service رخيص جداً على المهاجم.
والحل أن يحمل كل رابط توقيعاً لا يستطيع أن يصنعه إلا تطبيقك، وهذا ما يفعله رمز مصادقة الرسالة بالهاش HMAC الموصوف في RFC 2104: يحسب تطبيقك هاشاً للرابط بمفتاح سري لا يعرفه غيره وغير الخدمة، ويضعه في بداية الرابط، فتعيد الخدمة الحساب عند كل طلب، وإذا لم يتطابق رفضت الطلب في أجزاء من المللي ثانية قبل أن تقرأ أي صورة. وبهذا الشكل لا توجد في العالم إلا الروابط التي أنشأها تطبيقك، أي المقاسات التي قررتها الواجهة، ويصبح عدد التركيبات الممكنة محدوداً ومعروفاً. ويشرح توثيق التوقيع في imgproxy أن التوقيع معطل افتراضياً، وأن تفعيله موصى به بشدة في الإنتاج، ويشرح توثيق الأمان في Thumbor الإعداد ALLOW_UNSAFE_URL الذي يجب أن يكون False.
وهناك طبقة ثانية في imgproxy تقيد الوصفات نفسها وليس الروابط فقط، وهي الإعدادات المسبقة Presets: تعرف أسماء مثل card وpage لكل منها خيارات ثابتة، ثم تفعل وضع الإعدادات المسبقة فقط IMGPROXY_ONLY_PRESETS كما في توثيق الإعدادات المسبقة، فلا يقبل أي رابط إلا بأحد هذه الأسماء، حتى لو تسرب المفتاح. وهذا يجعل الرابط أقصر وأوضح، ويجعل قرار المقاسات في مكان واحد على السيرفر بدل أن يتوزع في كود الواجهة.
أين تحفظ المفتاح، وكيف تغيره؟
المفتاح والملح سر مثل كلمة مرور قاعدة البيانات، يعرفه تطبيقك الذي يكتب الروابط والخدمة التي تتحقق منها فقط، فلا تضعه في كود الواجهة الذي يصل إلى المتصفح أبداً، والسبب أن من يحصل عليه يستطيع أن يوقع أي رابط فيعود الهجوم كما كان. وقد يتساءل البعض: وماذا لو تسرب المفتاح، أو أردنا تغييره دورياً؟ والمشكلة هنا أن كل الروابط الموجودة في صفحات الموقع وفي ال CDN وفي ذاكرة المتصفحات موقعة بالمفتاح القديم، فإذا غيرته فجأة توقفت كل الصور التي لم تحفظ في ال Cache. ويحل imgproxy ذلك بأن يقبل عدة أزواج من المفتاح والملح مفصولة بفاصلة، ويجرب التوقيع بكل زوج كما يذكر توثيق الإعدادات، فتضيف الزوج الجديد في الخدمة أولاً، ثم تجعل تطبيقك يوقع به، ثم تحذف القديم بعد أن تنتهي صلاحية الصفحات المحفوظة. وكل ذلك دون أن تتوقف صورة واحدة.
والمكان الطبيعي لتوليد الروابط هو الدالة التي يستدعيها القالب لكل صورة، فتأخذ معرف الصورة وقائمة العروض وتعيد قيمة السمة srcset كاملة، وبهذا الشكل يبقى قرار المقاسات والتوقيع في مكان واحد في الكود، ولا يكتب أي مطور رابطاً بيده. والمثال التالي يستخدم الدالة imgproxy_url من التجربة:
WIDTHS = (360, 420, 840, 1200) # the only sizes the design uses
def srcset(photo_key):
return ", ".join(f"https://img.example.com{imgproxy_url(f'rs:fit:{w}:0', f's3://listings/{photo_key}')} {w}w"
for w in WIDTHS)
# <img src="…420…" srcset="{{ srcset(photo.key) }}" sizes="(max-width: 600px) 100vw, 420px" alt="…">ولاحظ أن قائمة العروض ثابتة في الكود، فعدد التركيبات لكل صورة أربعة مضروبة في عدد الصيغ، وهذا ما تحسب به حجم ال Cache وعدد مرات الفشل فيه.
اختيار الصيغة بحسب المتصفح Content Negotiation
الميزة الثانية الكبيرة في الخدمة عند الطلب أنها تختار الصيغة بحسب المتصفح، فكل متصفح يرسل مع طلب الصورة ترويسة Accept تقول ما يفهمه، مثل image/avif,image/webp,*/*، فإذا فعلت IMGPROXY_AUTO_WEBP وIMGPROXY_AUTO_AVIF في imgproxy، أو AUTO_WEBP في Thumbor، أعادت الخدمة أصغر صيغة يفهمها هذا المتصفح من نفس الرابط، وأضافت إلى الرد ترويسة Vary: Accept، وهذه الآلية هي التفاوض على المحتوى الذي يشرحه دليل MDN.
وهنا يقع خطأ شائع جداً، فالترويسة Vary: Accept تقول لكل Cache في الطريق إن الرد يختلف بحسب ترويسة Accept، فيجب أن يحفظ نسخة لكل قيمة منها. لكن بعض شبكات CDN وبعض إعدادات ال Cache تتجاهل Vary، أو تحفظ نسخة واحدة لكل رابط، وعندها يطلب متصفح حديث الصورة أولاً فيحفظ ال Cache نسخة AVIF، ثم يطلبها متصفح قديم لا يفهم AVIF فيستقبل نفس النسخة، ولا تظهر له صورة الإعلان إطلاقاً. والحل أن تجعل الصيغة جزءاً من مفتاح ال Cache بنفسك، لكن ليس بترويسة Accept كاملة، لأن قيمها كثيرة جداً بين المتصفحات وإصداراتها فتملأ ال Cache بنسخ متطابقة، وإنما بتحويلها أولاً إلى ثلاث قيم فقط: avif أو webp أو الأصل، كما سوف ترى في إعداد nginx في التجربة، وتشرح وثيقة proxy_cache_key المتغير الذي يحدد المفتاح. والصورة التالية تبين الخطأ والحل:

تجربة عملية: imgproxy وThumbor خلف nginx
الآن سوف نشغل الأداتين جنباً إلى جنب على نفس الصور، ونضع أمامهما nginx يعمل كطبقة Cache، ثم نقيس الزمن والإنتاجية والمعالج والذاكرة، ونجرب العطل بالروابط المزورة والصور الضخمة. والصور هي أربع قصاصات بمقاس صورة هاتف 3024 في 4032 من صورة شروق الشمس في مركز Kennedy، وبانوراما المريخ PIA20332 بمقاس 195 ميجابكسل، وصورة PNG رمادية مقاسها 40000 في 40000 حجمها 1.6 ميجابايت، والصورتان من NASA وهما ملكية عامة.
ملفات التجربة
أولاً نولد المفاتيح السرية: مفتاح وملح Salt بترميز Hex ل imgproxy، ومفتاح ل Thumbor، ونضعها في ملف لا يدخل مستودع الكود:
printf "IMGPROXY_KEY=%s\nIMGPROXY_SALT=%s\nTHUMBOR_KEY=%s\n" \
$(openssl rand -hex 32) $(openssl rand -hex 32) $(openssl rand -hex 16) > keys.env
chmod 600 keys.envثم إعداد Thumbor في ملف thumbor.conf، وهو ملف Python يقرأ المفتاح من البيئة:
import os
SECURITY_KEY = os.environ["THUMBOR_KEY"]
ALLOW_UNSAFE_URL = False
LOADER = 'thumbor.loaders.file_loader'
FILE_LOADER_ROOT_PATH = '/srv/img'
AUTO_WEBP = True
QUALITY = 80
RESPECT_ORIENTATION = Trueفي الإعداد أعلاه لاحظ أن ALLOW_UNSAFE_URL = False يرفض أي رابط دون توقيع، وأن المحمل file_loader يقرأ الصور من مجلد محلي بدل HTTP لتبقى التجربة بسيطة، وفي الإنتاج تستخدم محمل HTTP أو S3 مع قائمة مصادر مسموحة. ثم إعداد nginx في nginx.conf:
proxy_cache_path /var/cache/nginx/img levels=1:2 keys_zone=img:10m max_size=1g inactive=30d use_temp_path=off;
map $http_accept $img_fmt { default "orig"; "~image/avif" "avif"; "~image/webp" "webp"; }
server {
listen 80;
location /ip/ {
proxy_pass http://imgproxy:8080/;
proxy_cache img;
proxy_cache_key "ip$uri|$img_fmt";
add_header X-Cache-Status $upstream_cache_status always;
}
location /tb/ {
proxy_pass http://thumbor:8888/;
proxy_cache img;
proxy_cache_key "tb$uri|$img_fmt";
proxy_cache_valid 200 30d;
add_header X-Cache-Status $upstream_cache_status always;
}
}في الإعداد أعلاه لاحظ التالي:
- السطر
mapيحول ترويسة Accept إلى ثلاث قيم فقط، ثم يدخل المتغير$img_fmtفي مفتاح ال Cache، وهذا هو الحل الذي شرحناه في القسم السابق. - imgproxy يرسل
Cache-Control: max-age=31536000افتراضياً فيحترمه nginx، أما Thumbor فيرسل يوماً واحداً، لذلك أضفناproxy_cache_validلنحفظ نتائجه ثلاثين يوماً. - الترويسة
X-Cache-Statusتبين لنا في كل رد هل جاء من ال Cache أم من الخدمة.
وأخيراً compose.yml، والصور في Volume للقراءة فقط، والمنفذ الوحيد المنشور هو nginx على 127.0.0.1:
name: imgsvc
services:
imgproxy:
image: darthsim/imgproxy:v4.0.20
env_file: keys.env
environment:
IMGPROXY_LOCAL_FILESYSTEM_ROOT: /srv/img
IMGPROXY_ALLOWED_SOURCES: "local://"
IMGPROXY_AUTO_WEBP: "true"
IMGPROXY_AUTO_AVIF: "true"
volumes: ["srv:/srv/img:ro"]
thumbor:
image: thumbororg/thumbor:7.8.0-py-3.12
command: ["-c", "/etc/thumbor.conf", "-p", "8888", "-l", "info"]
env_file: keys.env
volumes: ["srv:/srv/img:ro", "./thumbor.conf:/etc/thumbor.conf:ro"]
edge:
image: nginx:1.31.6-alpine
volumes: ["./nginx.conf:/etc/nginx/conf.d/default.conf:ro"]
ports: ["127.0.0.1:19190:80"]
volumes:
srv:ولاحظ أن Thumbor في هذا الإعداد يعمل بعملية واحدة لأننا لم نذكر عدد العمليات، وهذا مقصود في البداية، وسوف نرى أثره في قسم الإنتاجية. نشغل الخدمات ونقرأ سجل imgproxy:
docker compose up -d
docker compose logs imgproxy | tail -3imgproxy-1 | time="2026-10-28 10:14:57" level="INFO" msg="OpenTelemetry monitoring" enabled=false logs=false metrics=false
imgproxy-1 | time="2026-10-28 10:14:57" level="INFO" msg="Source URL scheme registered, you can now use local:// source URLs" scheme="local"
imgproxy-1 | time="2026-10-28 10:14:57" level="INFO" msg="Starting server at :8080"توقيع الرابط من تطبيقك
السكربت التالي sign.py هو ما يضعه تطبيقك في القالب الذي يكتب وسم الصورة، وفيه دالة لكل أداة:
import base64, hashlib, hmac, os, sys
def imgproxy_url(opts, src):
key, salt = bytes.fromhex(os.environ["IMGPROXY_KEY"]), bytes.fromhex(os.environ["IMGPROXY_SALT"])
path = f"/{opts}/plain/{src}"
sig = base64.urlsafe_b64encode(hmac.new(key, salt + path.encode(), hashlib.sha256).digest()).rstrip(b"=").decode()
return f"/{sig}{path}"
def thumbor_url(path):
sig = base64.urlsafe_b64encode(hmac.new(os.environ["THUMBOR_KEY"].encode(), path.encode(), hashlib.sha1).digest()).decode()
return f"/{sig}/{path}"
if __name__ == "__main__":
kind = sys.argv[1]
print(imgproxy_url(sys.argv[2], sys.argv[3]) if kind == "ip" else thumbor_url(sys.argv[2]))في الكود أعلاه لاحظ أن imgproxy يضع الملح قبل المسار ثم يحسب HMAC-SHA256، ويحذف علامات = من نهاية الترميز، بينما يحسب Thumbor HMAC-SHA1 للمسار مباشرة ويبقي العلامات، والخطأ في أي تفصيل من هذه يعطيك رفضاً لكل الروابط، لذلك قارن دائماً برابط واحد تحسبه يدوياً عند أول تشغيل. الآن نوقع رابطاً لكل أداة ونطلبه من متصفح يقبل AVIF وWebP:
U=$(python sign.py ip "rs:fit:800:0" "local:///listing-1.jpg"); echo "$U"
V=$(python sign.py tb "fit-in/800x0/listing-1.jpg"); echo "$V"
for p in "/ip$U" "/tb$V"; do
curl -s -o /tmp/x -D - -H "Accept: image/avif,image/webp,*/*" "http://127.0.0.1:19190$p" \
| grep -iE "^HTTP|content-type|content-length|x-cache|cache-control|vary"; file /tmp/x
doneوالمخرج سوف يكون كما يلي:
/CFsq4Jzdlxp7K1qeeCGFAUj6U7EhzxWjuBnfPZ_4Fl8/rs:fit:800:0/plain/local:///listing-1.jpg
/6gDfrqEPZZxHcBbgimamou0TMdc=/fit-in/800x0/listing-1.jpg
HTTP/1.1 200 OK
Content-Type: image/avif
Content-Length: 36729
Cache-Control: max-age=31536000, public
Vary: Accept
X-Cache-Status: MISS
/tmp/x: ISO Media, AVIF Image
HTTP/1.1 200 OK
Content-Type: image/webp
Content-Length: 35398
Cache-Control: max-age=86400,public
Vary: Accept
X-Cache-Status: MISS
/tmp/x: RIFF (little-endian) data, Web/P image, ICC profile, 799+1x1066+1في المخرج أعلاه لاحظ التالي:
- imgproxy أعاد AVIF لأن المتصفح يقبلها وفعلنا اختيارها، أما Thumbor فأعاد WebP لأننا فعلنا فيه WebP فقط.
- الأداتان أرسلتا
Vary: Accept، لكن صلاحية Thumbor يوم واحد وimgproxy سنة كاملة، وهذا ما عالجناه في إعداد nginx. - Thumbor أبقى ملف الألوان ICC داخل WebP، بينما حوله imgproxy إلى sRGB وحذفه، وهو السلوك الافتراضي للإعداد
IMGPROXY_STRIP_COLOR_PROFILE، وقد شرحنا في المقال السابق لماذا يجب أن يسبق التحويل الحذف.
الصيغة وال Cache: نفس الرابط لثلاثة متصفحات
السكربت التالي probe.py يطلب نفس الرابط بعرض 640 من nginx أربع مرات لكل أداة: متصفح حديث مرتين، ثم متصفح يقبل WebP فقط، ثم متصفح قديم لا يرسل إلا */*، ويطبع الرمز والصيغة والحجم وحالة ال Cache والزمن:
import sys, time, urllib.request, urllib.error
from sign import imgproxy_url, thumbor_url
E = "http://edge"
def get(path, accept="*/*"):
t = time.perf_counter()
try:
with urllib.request.urlopen(urllib.request.Request(E + path, headers={"Accept": accept}), timeout=120) as r:
b = r.read(); return r.status, r.headers.get("Content-Type"), len(b), r.headers.get("X-Cache-Status"), (time.perf_counter()-t)*1000
except urllib.error.HTTPError as e:
b = e.read(); return e.code, e.headers.get("Content-Type"), b[:120], e.headers.get("X-Cache-Status"), (time.perf_counter()-t)*1000
def show(label, r): print(f"{label:<34} {r[0]} {r[1]} {r[2]} {r[3]} {r[4]:.0f} ms")
ip = "/ip" + imgproxy_url("rs:fit:640:0", "local:///listing-3.jpg")
tb = "/tb" + thumbor_url("fit-in/640x0/listing-3.jpg")
for name, p in [("imgproxy", ip), ("thumbor", tb)]:
show(f"{name} avif,webp (1st)", get(p, "image/avif,image/webp,*/*"))
show(f"{name} avif,webp (2nd)", get(p, "image/avif,image/webp,*/*"))
show(f"{name} webp only", get(p, "image/webp,*/*"))
show(f"{name} old browser */*", get(p, "*/*"))والمخرج سوف يكون كما يلي:
imgproxy avif,webp (1st) 200 image/avif 24465 MISS 266 ms
imgproxy avif,webp (2nd) 200 image/avif 24465 HIT 2 ms
imgproxy webp only 200 image/webp 19532 MISS 264 ms
imgproxy old browser */* 200 image/jpeg 41415 MISS 113 ms
thumbor avif,webp (1st) 200 image/webp 20894 MISS 153 ms
thumbor avif,webp (2nd) 200 image/webp 20894 HIT 2 ms
thumbor webp only 200 image/webp 20894 MISS 132 ms
thumbor old browser */* 200 image/jpeg 41498 MISS 83 msفي المخرج أعلاه لاحظ التالي:
- الطلب الثاني جاء من ال Cache في 2 مللي ثانية بدل 266، وهذا هو الفرق الذي يجعل الخدمة عند الطلب ممكنة أصلاً، فالمعالجة تحدث مرة لكل تركيبة.
- كل صيغة أخذت نسخة مستقلة في ال Cache، فالمتصفح القديم استقبل JPEG وليس AVIF المحفوظة قبله، وهذا بفضل
$img_fmtفي المفتاح. - في هذه الصورة كانت AVIF أكبر من WebP، 24.5 مقابل 19.5 كيلوبايت، والسبب أن جودة AVIF الافتراضية في imgproxy 63 وجودة WebP 79، وهما رقمان على مقياسين مختلفين، فلا تفترض أن AVIF أصغر دائماً، وقس على صورك قبل أن تقرر، أو استخدم ميزة اختيار أفضل صيغة التي يشرحها توثيق Best Format.
- JPEG كان الأسرع في الترميز، 113 مللي ثانية، وAVIF وWebP أبطأ لأنهما يبحثان أكثر عن طريقة لتقليل الحجم.
ولكي نرى الخطأ الذي شرحناه في قسم التفاوض، أضفنا إلى nginx مساراً ثالثاً /ipbad/ مفتاحه الرابط وحده، وجعلناه يتجاهل ترويسة Vary كما تفعل بعض شبكات CDN، ثم طلبنا نفس الرابط بمتصفح حديث ثم قديم:
location /ipbad/ {
proxy_pass http://imgproxy:8080/;
proxy_cache img;
proxy_cache_key "bad$uri";
proxy_ignore_headers Vary;
add_header X-Cache-Status $upstream_cache_status always;
}Content-Type: image/avif
X-Cache-Status: MISS
Content-Type: image/avif
X-Cache-Status: HITالمتصفح القديم، الذي قال صراحة إنه لا يعرف إلا */*، استقبل AVIF من ال Cache، وفي الواقع لن تظهر له الصورة إطلاقاً. ولاحظ أن الخطأ لا يظهر في اختبارك على متصفحك الحديث، وإنما عند جزء من المستخدمين بحسب من طلب الصورة أولاً، لذلك هو من أصعب الأخطاء اكتشافاً.
نجرب العطل: روابط مزورة وصور ضخمة
نكمل في probe.py بأربعة طلبات عن قصد: نغير العرض في رابط موقع من 640 إلى 641 دون أن نغير التوقيع، ونطلب رابطاً دون توقيع في Thumbor، ثم نطلب بانوراما المريخ والقنبلة:
show("imgproxy width changed in URL", get(ip.replace("640", "641")))
show("thumbor width changed in URL", get(tb.replace("640", "641")))
show("thumbor unsafe URL", get("/tb/unsafe/fit-in/640x0/listing-3.jpg"))
show("imgproxy mars-pano 195 MP", get("/ip" + imgproxy_url("rs:fit:1600:0", "local:///mars-pano.jpg")))
show("thumbor mars-pano 195 MP", get("/tb" + thumbor_url("fit-in/1600x0/mars-pano.jpg")))
show("imgproxy bomb.png 1600 MP", get("/ip" + imgproxy_url("rs:fit:800:0", "local:///bomb.png")))
show("thumbor bomb.png 1600 MP", get("/tb" + thumbor_url("fit-in/800x0/bomb.png")))imgproxy width changed in URL 403 text/plain b'Forbidden' MISS 3 ms
thumbor width changed in URL 400 text/html; charset=UTF-8 b'' MISS 4 ms
thumbor unsafe URL 400 text/html; charset=UTF-8 b'' MISS 3 ms
imgproxy mars-pano 195 MP 422 text/plain b'Invalid source image' MISS 4 ms
thumbor mars-pano 195 MP 400 text/html; charset=UTF-8 b'' MISS 100 ms
imgproxy bomb.png 1600 MP 422 text/plain b'Invalid source image' MISS 6 ms
thumbor bomb.png 1600 MP 400 text/html; charset=UTF-8 b'' MISS 7 msوالرد وحده لا يقول لك السبب، فالخدمة لا تشرح للمخترق لماذا رفضته، والسبب في السجل:
docker compose logs imgproxy thumbor | grep -iE "warn" | tail -8imgproxy-1 | … msg="Completed in 289.23µs /fw3IMKV2…/rs:fit:641:0/plain/local:/listing-3.jpg" … status=403 … error="Invalid signature"
imgproxy-1 | … msg="Completed in 1.701378ms /gNosnnjx…/rs:fit:1600:0/plain/local:/mars-pano.jpg" … status=422 … error="Source image resolution is too big"
imgproxy-1 | … msg="Completed in 2.632876ms /nSAIB0vq…/rs:fit:800:0/plain/local:/bomb.png" … status=422 … error="Source image resolution is too big"
thumbor-1 | 2026-10-28 10:37:19 thumbor:WARNING Malformed URL: /IPksk9PUcoYK2xD13gCB6mYUWZ0=/fit-in/641x0/listing-3.jpg
thumbor-1 | 2026-10-28 10:37:19 thumbor:WARNING URL has unsafe but unsafe is not allowed by the config: /unsafe/fit-in/640x0/listing-3.jpg
thumbor-1 | 2026-10-28 10:37:19 thumbor:WARNING [PILEngine] create_image failed: Image size (195450426 pixels) exceeds limit of 150000000.0 pixels, could be decompression bomb DOS attack.
thumbor-1 | 2026-10-28 10:37:19 thumbor:WARNING [PILEngine] create_image failed: Image size (1600000000 pixels) exceeds limit of 150000000.0 pixels, could be decompression bomb DOS attack.في المخرج أعلاه لاحظ التالي:
- الرابط المزور رفض في أقل من ثلث مللي ثانية في imgproxy، أي قبل أن يقرأ أي بايت من الصورة، وهذا ما يجعل التوقيع حماية من حجب الخدمة وليس من الاطلاع على الصور فقط.
- imgproxy رفض البانوراما والقنبلة من الترويسة في مللي ثانيتين، لأن حده الافتراضي
IMGPROXY_MAX_SRC_RESOLUTIONهو 50 ميجابكسل. - Thumbor رفضهما عن طريق Pillow، والحد في السجل 150 مليون بكسل، وهو ضعف الإعداد
MAX_PIXELSالافتراضي 75 مليوناً، لأن Pillow يحذر عند الحد ويرفض عند ضعفه كما شرحنا في المقال السابق. ولاحظ أن رفض البانوراما أخذ 100 مللي ثانية في Thumbor، لأنه قرأ الملف كاملاً، 110 ميجابايت، قبل أن يفحص الأبعاد.
فماذا لو كانت منصتك تحتاج فعلاً صوراً بانورامية كبيرة؟ رفعنا الحد في الأداتين إلى 200 ميجابكسل، بالمتغير IMGPROXY_MAX_SRC_RESOLUTION: "200" وبالسطر MAX_PIXELS = 200000000 في thumbor.conf، ثم طلبنا البانوراما بعرض 1600 مرتين من كل خدمة مباشرة دون ال Cache:
imgproxy run1 200 image/webp 94954 bytes 2.74 s
imgproxy run2 200 image/webp 94954 bytes 1.50 s
thumbor run1 200 image/webp 100626 bytes 1.74 s
thumbor run2 200 image/webp 100626 bytes 1.55 sوالأداتان عالجتا الصورة في أقل من 3 ثوان، والسبب في الحالتين هو التصغير أثناء القراءة في JPEG، فlibvips في imgproxy وPillow في Thumbor فكا الصورة بثمن مقاسها تقريباً. وفي محاولتنا الأولى بعد تعديل thumbor.conf استمر Thumbor في الرفض، والسبب أن الملف مربوط بال Container فتغير محتواه، لكن docker compose up -d لا يعيد تشغيل ال Container إذا لم يتغير إعداده في compose.yml، فبقيت العملية تعمل بالإعداد القديم حتى نفذنا docker compose restart thumbor. والدرس أن ترفع الحد لمسار أو Bucket محدد يحتاجه، وليس للخدمة كلها، لأن كل ميجابكسل إضافي في الحد هو ذاكرة ومعالج إضافيان يستطيع أي رافع أن يطلبهما.
الإنتاجية Throughput: كم صورة في الثانية؟
الرقم الأهم لتخطيط السعة هو عدد الصور التي تعالجها الخدمة في الثانية عندما لا يساعدها ال Cache، أي في أسوأ حالة. والسكربت التالي bench.py يرسل 120 طلباً مختلفاً مباشرة إلى الخدمة دون nginx، بأربع صور وعروض من 300 إلى 1133 بكسلاً، مرة بطلب واحد في كل لحظة ومرة بثمانية طلبات متوازية، ويحسب الإنتاجية والوسيط p50 والنسبة 95 من زمن الاستجابة:
import sys, time, urllib.request, concurrent.futures as cf
from sign import imgproxy_url, thumbor_url
def get(url, accept="image/webp,*/*"):
t = time.perf_counter()
with urllib.request.urlopen(urllib.request.Request(url, headers={"Accept": accept}), timeout=120) as r:
body = r.read(); ct = r.headers["Content-Type"]; st = r.status
return time.perf_counter() - t, len(body), ct, st
def url(svc, img, w):
return ("http://imgproxy:8080" + imgproxy_url(f"rs:fit:{w}:0", f"local:///{img}")) if svc == "ip" \
else ("http://thumbor:8888" + thumbor_url(f"fit-in/{w}x0/{img}"))
svc, conc = sys.argv[1], int(sys.argv[2])
urls = [url(svc, f"listing-{i%4+1}.jpg", 300 + 7*i) for i in range(120)]
t0 = time.perf_counter()
with cf.ThreadPoolExecutor(conc) as ex: res = list(ex.map(get, urls))
wall = time.perf_counter() - t0; lat = sorted(r[0]*1000 for r in res)
print(f"{svc} conc={conc} n={len(res)} wall={wall:.1f}s rps={len(res)/wall:.1f} p50={lat[len(lat)//2]:.0f}ms "
f"p95={lat[int(len(lat)*.95)]:.0f}ms max={lat[-1]:.0f}ms errors={sum(r[3]!=200 for r in res)}")وفي نفس الوقت قرأنا أعلى استهلاك للمعالج والذاكرة من docker stats كل ثانية تقريباً، والنتائج كما يلي:
ip conc=1 n=120 wall=36.9s rps=3.2 p50=294ms p95=565ms max=880ms errors=0 max cpu 100% mem 63 MiB
ip conc=8 n=120 wall=13.1s rps=9.1 p50=707ms p95=1646ms max=2193ms errors=0 max cpu 632% mem 211 MiB
tb conc=1 n=120 wall=30.1s rps=4.0 p50=207ms p95=584ms max=753ms errors=0 max cpu 99% mem 73 MiB
tb conc=8 n=120 wall=31.8s rps=3.8 p50=1962ms p95=3804ms max=4096ms errors=0 max cpu 100% mem 72 MiBالرقم الذي يلفت النظر هو السطر الأخير: Thumbor مع ثمانية طلبات متوازية لم يعالج أكثر مما عالجه مع طلب واحد، 3.8 صورة في الثانية، وزاد زمن الاستجابة إلى قرابة ثانيتين، والمعالج لم يتجاوز 100%، أي نواة واحدة من ثمانية. والسبب أن Thumbor يعمل بعملية Python واحدة افتراضياً، والعمل الثقيل في Pillow يحدث في هذه العملية، فتصطف الطلبات خلف بعضها. وهذا هو الفخ الذي يقع فيه كثيرون عندما يقارنون الأداتين، فيحكمون على Thumbor بإعداده الافتراضي. والحل هو الخيار --processes الذي يظهر في thumbor --help، فأضفنا "--processes", "8" إلى أمر التشغيل وأعدنا القياس، ثم كررنا الأداتين مرتين:
tb conc=8 n=120 wall=7.5s rps=16.1 p50=412ms p95=1076ms max=1404ms errors=0 max cpu 762% mem 343 MiB
ip conc=8 n=120 wall=7.3s rps=16.3 p50=449ms p95=854ms max=927ms errors=0
tb conc=8 n=120 wall=6.3s rps=19.1 p50=361ms p95=744ms max=1021ms errors=0
ip conc=8 n=120 wall=6.9s rps=17.3 p50=444ms p95=765ms max=881ms errors=0
tb conc=8 n=120 wall=6.4s rps=18.6 p50=402ms p95=720ms max=951ms errors=0والصورة التالية تضع كل النتائج في رسم واحد:

في النتائج أعلاه لاحظ التالي:
- بعد إصلاح عدد العمليات صار Thumbor مثل imgproxy أو أسرع قليلاً على صور الهاتف، بين 16 و19 صورة في الثانية للأداتين، فلا تختر بينهما على أساس السرعة في هذا النوع من الصور، وإنما على أساس ما سوف نذكره في قسم الاختيار.
- الثمن كان الذاكرة: ثماني عمليات Python استخدمت 343 ميجابايت، بينما استخدم imgproxy بعملية واحدة 211 ميجابايت في أعلى نقطة، والسبب أن كل عملية Python تحمل نسختها من المكتبات.
- أول تشغيل ل imgproxy بثمانية طلبات كان 9.1 صورة في الثانية، والتشغيلان بعده 16 و17، والسبب أن الصور قرئت من القرص لأول مرة ثم صارت في ذاكرة نظام التشغيل، لذلك لا تحكم من قياس واحد، وكرر كل قياس مرتين على الأقل.
- المعالج الكامل بثمانية خيوط يعالج قرابة 17 صورة في الثانية، وهذا هو الرقم الذي استخدمناه في قسم التوقيع لنعرف كم طلباً يكفي لحجب الخدمة.
ثم أرسلنا نفس الطلبات ال 120 عبر nginx مرتين، الأولى وال Cache فارغ والثانية بعد أن امتلأ:
edge cold conc=8 rps=17.4 p50=357.3ms p95=970.3ms
edge warm conc=8 rps=565.1 p50=9.9ms p95=34.0msأي 565 طلباً في الثانية من ال Cache، بزمن وسيط أقل من 10 مللي ثانية، والسقف هنا هو سكربت Python الذي يرسل الطلبات وليس nginx، وهذا يبين لماذا قلنا إن نسبة الفشل في ال Cache هي ما يحدد عدد السيرفرات.
الاتجاه والخصوصية: هل تنظف الخدمة الصورة؟
صنعنا صورة كما يحفظها الهاتف، بكسلاتها بالعرض مع وسم الاتجاه 6 وموقع GPS تجريبي، ثم طلبناها من الأداتين بصيغة JPEG وقرأنا الناتج ب Pillow:
import io, urllib.request
from PIL import Image
from sign import imgproxy_url, thumbor_url
for name, p in [("imgproxy", "/ip" + imgproxy_url("rs:fit:600:0/f:jpg", "local:///phone-gps.jpg")),
("thumbor", "/tb" + thumbor_url("fit-in/600x0/phone-gps.jpg"))]:
b = urllib.request.urlopen(urllib.request.Request("http://edge" + p, headers={"Accept": "*/*"})).read()
im = Image.open(io.BytesIO(b)); e = im.getexif()
print(f"{name:<9} {im.format} size={im.size} orientation={e.get(274)} gps={dict(e.get_ifd(0x8825)) or None} "
f"make={e.get(271)} icc={'yes' if im.info.get('icc_profile') else 'no'}")imgproxy JPEG size=(600, 800) orientation=1 gps=None make=None icc=no
thumbor JPEG size=(600, 800) orientation=None gps=None make=None icc=yesالأداتان أدارتا الصورة إلى الطول الصحيح 600 في 800، وحذفتا الموقع واسم الجهاز، وهذا افتراضي في imgproxy بالإعدادين IMGPROXY_AUTO_ROTATE وIMGPROXY_STRIP_METADATA، وفي Thumbor بالإعداد RESPECT_ORIENTATION الذي فعلناه. لكن لا تفترض أن هذا السلوك ثابت في كل إصدار وكل إعداد، وافحص صورة من موقعك بعد كل ترقية بنفس الطريقة، لأن خطأ واحداً هنا يكشف مواقع بيوت المستخدمين لكل زائر.
وضع الإعدادات المسبقة فقط في imgproxy
أخيراً أضفنا إلى imgproxy إعدادين مسبقين وفعلنا وضعها الحصري:
IMGPROXY_PRESETS: "card=rs:fit:400:0,page=rs:fit:1200:0"
IMGPROXY_ONLY_PRESETS: "true"ثم وقعنا ثلاثة روابط، اثنان بالاسمين وواحد بخيارات صريحة:
== /6uDUML6KkfCIIGNusJjnfB2jZozR4ZSb2GHv_26hJIw/card/plain/local:///listing-2.jpg
200 image/webp 12252
== /nTxsMdYcbnC02hs-ET_Fcxn1S3WhdcPKzY8DmK9Tk90/page/plain/local:///listing-2.jpg
200 image/webp 89410
== /36EZkqjFaHbvPAmb0FGCVGiTIRvMkggNkABh84Ihisg/rs:fit:401:0/plain/local:///listing-2.jpg
404 text/plain 11الرابط الثالث موقع توقيعاً صحيحاً، ومع ذلك رفضته الخدمة وكتبت في السجل Unknown preset: rs، لأنها في هذا الوضع تفسر كل جزء على أنه اسم إعداد مسبق. وهذا يعني أن من يحصل على المفتاح يوماً لا يستطيع أن يطلب إلا المقاسين اللذين قررتهما، وهي حماية ثانية رخيصة جداً.
حذف التجربة
docker compose down -v
docker image rm darthsim/imgproxy:v4.0.20 thumbororg/thumbor:7.8.0-py-3.12
rm keys.envالتشغيل في الإنتاج
بعد التجربة صار شكل المنصة واضحاً، والصورة التالية تقارن بين ما كانت المنصة ستدفعه في إعادة المعالجة وما تدفعه مع الخدمة عند الطلب:

والحساب في الصورة من أرقام التجربة: مليونا صورة في أربعة مقاسات جديدة تعني 8 ملايين عملية، وبمعدل 17 صورة في الثانية على سيرفر بثمانية خيوط تحتاج قرابة 470 ألف ثانية، أي خمسة أيام ونصف من المعالج الكامل، وتتكرر مع كل تغيير في التصميم. وهذه النقاط هي ما يجب أن تنتبه له عندما تشغل الخدمة فعلاً:
- ضع CDN أمام الخدمة دائماً: ال Cache في nginx على نفس السيرفر يحمي الخدمة من التكرار، لكن ال CDN يقرب الصورة من المستخدم ويمتص أغلب الطلبات قبل أن تصل إلى سيرفرك. وتأكد أن مفتاح ال CDN يشمل الصيغة كما شرحنا، أو أن ال CDN يحترم
Vary: Acceptفعلاً، واختبر ذلك بمتصفحين. - الأصل في تخزين الكائنات: اجعل مصدر الصور S3 أو تخزيناً متوافقاً معه، مع قائمة مصادر مسموحة بالبادئة الدقيقة، وقد شرحنا الصلاحيات والروابط في S3 من داخل تطبيقك. وتذكر أن كل فشل في ال Cache يقرأ الأصل كاملاً من التخزين، فصورة 3 ميجابايت تقرأ لتعطي 20 كيلوبايت، وهذا نقل داخلي يدخل في التكلفة.
- حد للصور وللزمن: أبق
IMGPROXY_MAX_SRC_RESOLUTIONوMAX_PIXELSعند أقل قيمة تحتاجها، واضبطIMGPROXY_TIMEOUTوIMGPROXY_DOWNLOAD_TIMEOUTحتى لا تعلق الطلبات على مصدر بطيء، وفي imgproxy حد لطول الطابورIMGPROXY_REQUESTS_QUEUE_SIZEيرد ب 429 بدل أن يتراكم الطلب في الذاكرة. - التوازي بعدد الأنوية: في Thumbor عملية لكل نواة بالخيار
--processes، وفي imgproxy القيمة الافتراضية لIMGPROXY_WORKERSضعف عدد الأنوية، وحد ال Container بالذاكرة التي قستها تحت الحمل مع هامش. - لا تخلط الصور الخاصة بالعامة: إذا كانت بعض الصور خاصة، مثل صور الهويات في التحقق من المستخدمين، فلا تمررها عبر نفس الخدمة وال CDN العام، لأن الرابط الموقع يبقى صالحاً للأبد ما لم تضف إليه انتهاء صلاحية، مثل الخيار
expiresفي imgproxy. - راقب نسبة الإصابة في ال Cache: إذا انخفضت فجأة فإما أن الواجهة صارت تطلب مقاسات كثيرة، وإما أن أحداً يحاول تجاوزها، وفي الحالتين سوف ترى معالج الخدمة يرتفع قبل أن يشتكي أحد.
- اجمع مقاييس الخدمة نفسها: imgproxy يستطيع أن ينشر مقاييس Prometheus على منفذ منفصل بالإعداد
IMGPROXY_PROMETHEUS_BINDكما في توثيق المراقبة، وأهم ما تراقبه زمن المعالجة بالنسب المئوية، وعدد الطلبات في الطابور، وعدد الردود 4xx و5xx. والرد 403 المتكرر يعني غالباً أن أحداً يجرب روابط مزورة، و422 المتكرر يعني صوراً تتجاوز حدودك، وهذا سؤال لفريق المنتج: هل نرفع الحد أم نصغر الصور عند الرفع؟ وفي Thumbor تجد نفس المعلومات في السجل كما رأينا في التجربة، فاجمعها في نظام السجلات Logging.
أما السعة فاحسبها من نسبة الفشل وليس من الزوار. لنفترض أن المنصة تقدم 300 صورة في الثانية في ساعة الذروة، وأن نسبة الإصابة في ال CDN وال Cache معاً 97%، فيصل إلى الخدمة 9 طلبات في الثانية، وهذا أقل من نصف ما عالجه سيرفر التجربة بثمانية خيوط، فيكفيك سيرفر واحد بهامش مريح، ونسخة ثانية للتوفر فقط. لكن إذا نزلت الإصابة إلى 90% بعد تغيير في التصميم صار الحمل 30 طلباً في الثانية، أي سيرفرين، وهذا هو السبب في أن تغيير المقاسات في الواجهة يجب أن يكون قراراً يعرف به فريق التشغيل.
كيف تختار بين imgproxy وThumbor؟
| إذا كان لديك... | فالأنسب | والسبب |
|---|---|---|
| صور كبيرة جداً أو ذاكرة محدودة | imgproxy | libvips يبقي الذاكرة صغيرة، وعملية واحدة بدل عملية لكل نواة |
| صور أشخاص وتريد القص حول الوجوه في النسخة المفتوحة | Thumbor | كشف الوجوه والنقاط المهمة جزء من المشروع المفتوح |
| فريق يكتب Python ويريد مرشحات أو مصادر خاصة | Thumbor | الإضافات كود Python عادي |
| تريد أقل قدر من الإعداد والتشغيل | imgproxy | ملف تنفيذي واحد يضبط بمتغيرات البيئة، وإعدادات أمان افتراضية جيدة |
| تحتاج اختيار الجودة تلقائياً أو ميزات متقدمة مع دعم تجاري | imgproxy Pro | هذه الميزات في النسخة التجارية |
| مقاسات قليلة ثابتة لا تتغير، وصور تطلب كلها | لا تحتاج أياً منهما | الصناعة المسبقة عند الرفع أبسط وأرخص في هذه الحالة |
وفي حالة منصة العقارات اخترنا imgproxy، والسبب أن صورها صور عقارات وليست صور أشخاص، فلا تحتاج القص حول الوجوه، وأن الفريق لا يكتب Python، وأن وضع الإعدادات المسبقة فقط يناسب واجهة لها خمسة مقاسات معروفة.
أخطاء شائعة وكيف تكتشفها
- تشغيل الخدمة دون توقيع: اكتشفه بأن تغير رقماً في رابط صورة من موقعك، فإذا عادت صورة بالمقاس الجديد فأنت مكشوف لهجوم حجب الخدمة.
- Thumbor بعملية واحدة على سيرفر متعدد الأنوية: اكتشفه بأن المعالج لا يتجاوز 100% بينما يزيد زمن الاستجابة مع الحمل.
- مفتاح Cache لا يشمل الصيغة، أو CDN يتجاهل Vary: اكتشفه بطلب نفس الرابط بترويستي Accept مختلفتين ومقارنة
Content-Type. - رفع حد البكسلات للخدمة كلها من أجل حالة واحدة: اكتشفه بأن تطلب صورة كبيرة من مصدر عام، فإذا قبلت فكل رافع يستطيع ذلك.
- مصادر غير مقيدة: إذا قبلت الخدمة رابطاً لأي موقع فقد تستخدم ل SSRF أو كخدمة مجانية لغيرك، والحل قائمة مصادر مسموحة.
- تعديل ملف إعداد مربوط دون إعادة تشغيل: الخدمة تعمل بالإعداد القديم كما حدث لنا مع
MAX_PIXELS، والحل إعادة التشغيل والتحقق من السلوك. - الحكم من قياس واحد بارد: أول قياس يقرأ من القرص، فكرر وقارن.
قائمة تحقق قبل التشغيل
| البند | imgproxy | Thumbor |
|---|---|---|
| التوقيع | IMGPROXY_KEY وIMGPROXY_SALT | SECURITY_KEY وALLOW_UNSAFE_URL = False |
| تقييد الوصفات | IMGPROXY_PRESETS مع IMGPROXY_ONLY_PRESETS | توليد الروابط من مكان واحد في الكود |
| المصادر | IMGPROXY_ALLOWED_SOURCES | ALLOWED_SOURCES |
| حد الصور | IMGPROXY_MAX_SRC_RESOLUTION وIMGPROXY_MAX_SRC_FILE_SIZE | MAX_PIXELS |
| التوازي | IMGPROXY_WORKERS وIMGPROXY_REQUESTS_QUEUE_SIZE | --processes بعدد الأنوية |
| الصيغ | IMGPROXY_AUTO_WEBP وIMGPROXY_AUTO_AVIF | AUTO_WEBP |
| ال Cache | CDN ثم nginx، والمفتاح يشمل الصيغة المختصرة، ومراقبة نسبة الإصابة | |
| الخصوصية | فحص صورة بموقع GPS ووسم اتجاه بعد كل ترقية | |
الخلاصة
- الخدمة عند الطلب تحفظ الأصل فقط وتصنع كل مقاس عند أول طلب، فيصبح المقاس الجديد تغييراً في الواجهة بدل إعادة معالجة المكتبة، وفي حالة منصتنا وفرت خمسة أيام ونصف من المعالج مع كل تغيير في التصميم.
- ال Cache هو ما يجعلها ممكنة: 2 إلى 10 مللي ثانية من ال Cache مقابل مئات المللي ثانية للمعالجة، وعدد السيرفرات يحدده عدد الطلبات التي تفشل في ال Cache.
- التوقيع بخوارزمية HMAC إلزامي، لأن الخدمة دونه تعالج أي مقاس يكتبه أي شخص، و20 طلباً في الثانية تكفي لإشغال سيرفر بثمانية خيوط، والإعدادات المسبقة في imgproxy طبقة ثانية رخيصة.
- اختيار الصيغة بترويسة Accept مفيد، لكن مفتاح ال Cache يجب أن يشمل الصيغة، وإلا استقبل المتصفح القديم AVIF لا يفهمها كما رأينا.
- الأداتان متقاربتان في السرعة على صور الهاتف عندما يعمل Thumbor بعملية لكل نواة، وimgproxy أقل ذاكرة وأبسط تشغيلاً، وThumbor أغنى بالإضافات والقص حول الوجوه.
وفي المقال التالي من نفس المرحلة ننتقل من تحويل الصور إلى قراءة ما فيها، باستخراج النص من الصور والمستندات بأداة Tesseract مع نصوص عربية، وهذه مقالات قريبة من الموضوع:
دليلصيغ الصور: كيف تختار الصيغة المناسبة لموقعككيف تحفظ كل صيغة صور البكسلات والألوان، ولماذا يصغر الملف نفسه إلى الثلث أو أقل عندما تختار الصيغة والمقاس المناسبين.
دليلكيف تعمل شبكة توصيل المحتوى CDN وما الذي تحفظهلماذا يبطؤ موقعك على الزائر البعيد، وكيف تقرب شبكة CDN ملفاتك منه، وكيف تمنعها من حفظ الصفحات الخاصة.
دليلالتخزين المؤقت أمام موقعك: Varnish أم Nginx؟كيف تضع Varnish أو Nginx أمام موقعك ليرد من ال Cache، وتمسح الصفحات، وتقدم نسخة قديمة عندما يتوقف السيرفر.
دليلS3 من داخل تطبيقك: الصلاحيات والروابط الموقعة والتكلفةكيف يرفع تطبيقك الملفات إلى S3 بمفتاح محدود وروابط موقعة، ويحمي الملفات من الكتابة فوقها، ويتجنب البنود التي تضخم الفاتورة.
دليلتحديد معدل الطلبات Rate Limiting وخوارزمياتهكيف تعمل خوارزميات تحديد معدل الطلبات، وأيها يناسب واجهتك، وكيف تطبقها على عدة سيرفرات وترد بالرمز 429 الصحيح.
دليللماذا يفشل رفع الملفات الكبيرة؟ وأين تعدل حد الحجميمر الملف المرفوع بعدة طبقات لكل منها حد للحجم والزمن، فكيف تعرف الطبقة التي رفضته من شكل الخطأ وأين تعدل حدها.سجل التحديثات
- أكتوبر 2026: كتابة المقال، وتنفيذ التجربة على imgproxy 4.0.20 وThumbor 7.8.0 مع Pillow 12.2.0 وnginx 1.31.6 في Docker Engine 29.8.0.