لنفرض أن لديك شركة ناشئة تدير سوقاً إلكترونياً لبيع الأثاث المستعمل، يصور فيه البائع قطعته بهاتفه ويرفع ثماني صور أو عشراً، ويرى المشتري في صفحة الإعلان صورة كبيرة وصوراً مصغرة تحتها، وفي نتائج البحث صورة صغيرة لكل إعلان. وفي النسخة الأولى من التطبيق كتب المطور سطراً واحداً يستدعي ImageMagick داخل طلب الرفع نفسه ليصنع ثلاثة مقاسات من كل صورة، وعمل كل شيء بشكل ممتاز مع عشرات البائعين. ثم أطلق الفريق حملة إعلانية في نهاية الأسبوع، فرفع أربعون بائعاً صورهم في نفس الساعة، وتوقف السيرفر الذي فيه 2 جيجابايت من الذاكرة ثلاث مرات بخطأ نفاد الذاكرة Out of Memory، وفي صباح اليوم التالي وصلت ثلاث رسائل غريبة: بائع يسأل لماذا تظهر صورة الكنبة مقلوبة على جنبها في صفحة الإعلان رغم أنها سليمة في هاتفه، ومشتر يقول إنه عرف موقع بيت البائع من بيانات الصورة التي حملها، وفحص أمني دوري يحذر من أن نسخة ImageMagick على السيرفر قديمة وفيها ثغرات تسمح بتنفيذ أوامر على السيرفر.
وهذه الرسائل الثلاث، مع انقطاع السيرفر، هي بالضبط موضوع معالجة الصور على السيرفر Server-side Image Processing: كيف تصنع المقاسات والصيغ التي يحتاجها موقعك من صور يرفعها أناس لا تعرفهم، دون أن تستهلك الذاكرة كلها، ودون أن تقلب الصورة أو تفسد ألوانها، ودون أن تسرب بيانات المستخدم أو تفتح باباً للمخترق. وقد شرحنا الصيغ نفسها وكيف تحفظ كل صيغة البكسلات والألوان في صيغ الصور وكيف تختار بينها، فلن نكرر ذلك هنا، وسوف نركز على المكتبتين الأشهر لهذا العمل: libvips وImageMagick، وكيف تعمل كل منهما من الداخل، ولماذا يختلف استهلاكهما للذاكرة بهذا الشكل، مع تجربة حقيقية تقيس الوقت والذاكرة على صور حقيقية.
وهذا المقال من مرحلة معالجة الصور والمستندات في مسار المجالات المتخصصة، وسوف نناقش فيه ما يلي:
- لماذا تعالج الصور على السيرفر أصلاً، وما المهام التي يجب أن يقوم بها خط المعالجة Pipeline لكل صورة مرفوعة.
- الخيارات المتاحة: مكتبة داخل الكود، أو أداة سطر أوامر، أو خدمة تصغير عند الطلب.
- كيف يعمل ImageMagick من الداخل: ذاكرة البكسلات Pixel Cache وحدود الموارد وملف السياسة policy.xml.
- كيف يعمل libvips من الداخل: المعالجة حسب الطلب Demand-driven والتصغير أثناء القراءة Shrink-on-load.
- تجربة عملية بأرقام حقيقية: صورة 30 ميجابكسل، ثم صورة بانورامية 195 ميجابكسل، ثم Worker محدود بذاكرة 512 ميجابايت، ثم 24 رفعاً في نفس اللحظة.
- الأخطاء التي ظهرت في قصة السوق: الصورة المقلوبة واتجاه EXIF، والموقع المسرب في بيانات GPS، والألوان الباهتة بسبب ملف ICC، وقنبلة فك الضغط Decompression Bomb.
- تصميم خط المعالجة في الإنتاج، والأمان من ثغرة ImageTragick إلى عزل ال Worker، والمكتبات من داخل لغات البرمجة، وحساب السعة.
لماذا تعالج الصور على السيرفر Server-side أصلاً؟
الصورة التي يرفعها البائع من هاتف حديث مقاسها في الغالب 12 ميجابكسل، أي 4032 في 3024 بكسلاً، وحجمها بين 3 و5 ميجابايت بصيغة JPEG أو HEIC، بينما تعرض صفحة الإعلان الصورة بعرض 800 بكسل تقريباً على الحاسب، ونتائج البحث تعرضها بعرض 300 بكسل أو أقل. فإذا أرسلت الملف الأصلي إلى كل مشتر فأنت تنقل أكثر من خمسين ضعف ما يحتاجه، ويدفع المشتري ذلك من باقة هاتفه ووقت تحميل الصفحة، وتدفعه أنت من فاتورة النقل Egress، وقد شرحنا أثر المقاس الصحيح على حجم الملف بالأرقام في قسم الصور المتجاوبة. لذلك يحتاج كل موقع يستقبل صوراً من المستخدمين خطاً يمر عليه كل ملف مرفوع قبل أن يراه أحد، وهذا الخط يقوم بالمهام التالية:
- التحقق Validation: هل الملف صورة فعلاً من الصيغ التي تقبلها، وهل أبعاده معقولة؟ والسبب أن امتداد الملف يكتبه المستخدم، وقد يكون الملف شيئاً آخر تماماً.
- التصحيح Normalization: تدوير الصورة بحسب اتجاه الكاميرا، وتحويل الألوان إلى فضاء sRGB الذي تفترضه الشاشات والمتصفحات.
- التنظيف Sanitization: حذف البيانات الوصفية Metadata مثل موقع GPS ورقم الهاتف التسلسلي، والسبب أن البائع لا يعرف أنها داخل الصورة.
- صناعة المقاسات Variants: نسخة كبيرة لصفحة الإعلان، ومتوسطة، وصغيرة لنتائج البحث، بصيغة حديثة مثل WebP أو AVIF.
- الحفظ: الملف الأصلي في مكان خاص، والمقاسات في تخزين الكائنات Object Storage خلف شبكة توصيل المحتوى CDN.
وقد يتساءل البعض: لماذا لا يصغر تطبيق الهاتف أو المتصفح الصورة قبل رفعها، فنوفر على السيرفر هذا العمل كله؟ والإجابة أن التصغير في جهاز المستخدم فكرة جيدة لتوفير وقت الرفع، ويفعلها كثير من التطبيقات، لكنه لا يغني عن المعالجة على السيرفر، والسبب أن كل ما يأتي من جهاز المستخدم يمكن أن يتغير، فالمخترق لا يستخدم تطبيقك وإنما يرسل الطلب مباشرة بالملف الذي يريده، لذلك يبقى التحقق والتنظيف وصناعة المقاسات النهائية على السيرفر دائماً، وهذه نفس القاعدة التي شرحناها في حدود رفع الملفات: لا تثق بما يقوله الكلاينت عن ملفه.
الخيارات: مكتبة في الكود أم أداة أم خدمة عند الطلب On-demand؟
هناك ثلاث طرق شائعة لتنفيذ هذا الخط، وكلها تعتمد في النهاية على نفس المحركات، والفرق بينها في مكان العمل وتوقيته:
| الطريقة | كيف تعمل | متى تناسبك | ما ثمنها |
|---|---|---|---|
| مكتبة داخل كود التطبيق | التطبيق يستدعي المكتبة مباشرة، مثل sharp في Node.js أو pyvips وPillow في Python أو Imagick في PHP | عدد الصور قليل، أو تريد التحكم الكامل في كل خطوة | المعالجة تشارك التطبيق ذاكرته ومعالجه، وخطأ في المكتبة قد يوقف التطبيق كله |
| أداة سطر أوامر في Worker | Worker منفصل يأخذ المهمة من طابور ويشغل vipsthumbnail أو magick | أغلب المواقع التي تستقبل صوراً من المستخدمين | زمن تشغيل الأداة في كل مرة، وطابور تديره |
| خدمة تصغير عند الطلب | تحفظ الأصل فقط، ويطلب المتصفح رابطاً فيه المقاس المطلوب، فتصنعه الخدمة وتحفظه في Cache | مقاسات كثيرة تتغير مع تصميم الواجهة، أو مكتبة صور ضخمة قديمة | خدمة إضافية تديرها، وأول طلب لكل مقاس أبطأ |
والطريقة الثالثة لها مقالها المستقل في نفس المرحلة من خارطة الطريق، عن خدمتي imgproxy وThumbor، وهي تعتمد على نفس المحرك libvips الذي سوف نشرحه هنا. أما في هذا المقال فسوف نبني الطريقة الثانية، لأنها أوضح طريقة لفهم ما يحدث للصورة خطوة خطوة، ولأنها ما يحتاجه السوق في قصتنا: صور ترفع مرة واحدة وتعرض آلاف المرات بمقاسات ثابتة.
وفي كل الطرق الثلاث ستجد نفس الاسمين تقريباً في الأسفل: ImageMagick، وهو الأقدم والأشهر ويقرأ ويكتب أكثر من مئتي صيغة، وlibvips، وهو الأحدث في الانتشار والأسرع والأقل استهلاكاً للذاكرة. وهناك مكتبات أخرى مثل Pillow في Python، وGraphicsMagick الذي انفصل عن ImageMagick قديماً، لكن الفرق الجوهري الذي يجب أن تفهمه هو الفرق بين طريقتي عمل ImageMagick وlibvips، لأنه يفسر كل الأرقام التي سوف تراها في التجربة.
كيف يعمل ImageMagick من الداخل؟
بدأ ImageMagick كما يحكي مؤلفه John Cristy في صفحة تاريخ المشروع سنة 1987 في شركة DuPont، عندما طلب منه مديره أن يعرض صوراً ملونة بعمق 24 بت على شاشات لا تعرض إلا 256 لوناً في نفس الوقت، ثم نشره للعالم مجاناً سنة 1990، ومن يومها صار المكتبة التي يستدعيها نصف الإنترنت تقريباً لمعالجة الصور، ورخصته الحالية رخصة مفتوحة خاصة بالمشروع تشبه Apache 2.0 كما في صفحة الرخصة.
والفكرة الأساسية في ImageMagick بسيطة وواضحة: يقرأ الصورة كاملة إلى ما يسميه ذاكرة البكسلات Pixel Cache، ثم ينفذ عليها العمليات واحدة بعد الأخرى، ثم يكتبها. وتشرح صفحة المعمارية أن هذه الذاكرة تحفظ كل بكسل في الصورة بكل قنواته، وأن النسخة الشائعة Q16 تحفظ كل قناة في 16 بت، أي بايتين، حتى لو كانت الصورة الأصلية JPEG بعمق 8 بت لكل قناة. والسبب في ذلك دقة العمليات، فعندما تجمع وتطرح وتضرب قيم البكسلات في سلسلة طويلة من العمليات لا تريد أن يضيع جزء من الدقة في كل خطوة، لكن الثمن هو الذاكرة، والحساب سهل:
photo 12 MP 4032 x 3024 = 12.2 M pixels x 4 channels x 2 bytes = ~98 MB
photo 30 MP 4480 x 6720 = 30.1 M pixels x 4 channels x 2 bytes = ~241 MB
panorama 29163 x 6702 = 195.5 M pixels x 4 channels x 2 bytes = ~1.56 GBوهذا هو سبب انقطاع السيرفر في قصتنا: كل صورة مرفوعة تحتاج في ذاكرة ImageMagick أضعاف حجم ملفها، فملف JPEG حجمه 4 ميجابايت يصبح قرابة 100 ميجابايت في الذاكرة، وعندما يرفع أربعون بائعاً صورهم في نفس الدقيقة، ويعمل كل طلب رفع في عملية Process خاصة به، فإن الذاكرة تمتلئ قبل أن ينتهي نصفها. ولاحظ أن القناة الرابعة في الحساب هي قناة الشفافية Alpha، والصورة التي ليس فيها شفافية تحتاج ثلاث قنوات فقط، لكن الحساب بأربع أقرب لما رأيناه في التجربة كما سيأتي.
وماذا يحدث عندما لا تكفي الذاكرة؟ هنا يأتي دور حدود الموارد Resource Limits التي تشرحها صفحة الموارد: لكل عملية ImageMagick حد للذاكرة Memory، وحد للذاكرة المربوطة بملف Map، وحد للقرص Disk، وحد لمساحة الصورة Area بالبكسلات، وحد للعرض والارتفاع، فإذا تجاوزت الصورة حد الذاكرة نقل ImageMagick ذاكرة البكسلات إلى ملف على القرص، وهذا يحمي السيرفر من الانقطاع لكنه أبطأ بكثير كما سوف ترى. وهذه الحدود يمكن أن تضعها من سطر الأوامر بالخيار -limit، أو في ملف السياسة policy.xml الذي يطبق على كل استدعاء على السيرفر.
وملف السياسة Security Policy هو أهم ملف في أي سيرفر عليه ImageMagick، وتشرحه صفحة السياسة الأمنية، فهو يحدد الموارد كما ذكرنا، ويحدد أيضاً الصيغ Coders التي يسمح بقراءتها وكتابتها، والبرامج الخارجية Delegates التي يسمح باستدعائها، مثل Ghostscript لقراءة PDF وPostScript، والمسارات التي يسمح بالقراءة منها. والسبب أن ImageMagick مصمم ليقرأ كل شيء تقريباً، وهذا رائع على حاسبك الشخصي، لكنه على السيرفر يعني أن الملف الذي يرفعه شخص لا تعرفه قد يجعل ImageMagick يقرأ ملفات من السيرفر أو يتصل بالإنترنت أو يشغل برنامجاً آخر، وسوف نرى في قسم الأمان ماذا حدث عندما استغل المخترقون ذلك.
كيف يعمل libvips من الداخل؟
أما libvips فيصف نفسه بأنه مكتبة معالجة صور تعمل حسب الطلب Demand-driven وتوزع العمل على الأنوية أفقياً Horizontally Threaded، ورخصته LGPL-2.1 أو أحدث كما في ملف الرخصة، وهي رخصة تسمح باستخدامه من تطبيقك التجاري دون أن تفتح كودك ما دمت تربطه كمكتبة. والفرق بينه وبين ImageMagick ليس في سرعة خوارزمية معينة، وإنما في طريقة التفكير نفسها.
تشرح صفحة كيف يعمل libvips أن المكتبة تحتفظ في الذاكرة فقط بالبكسلات التي تعالجها الآن، وأن الصورة فيها ليست بالضرورة مصفوفة بكسلات، فقد تكون ما تسميه صورة جزئية Partial Image، أي دالة تستطيع أن تحسب أي مستطيل من البكسلات عندما يطلبه أحد. فعندما تكتب الأمر «اقرأ الصورة، ثم صغرها، ثم حولها إلى WebP واكتبها» لا يحدث شيء في البداية، وإنما يبني libvips سلسلة من العمليات Pipeline، ثم يبدأ الكاتب في النهاية بطلب أول شريط من الصورة الناتجة، فتطلب عملية التصغير ما تحتاجه من الشريط المقابل في الصورة الأصلية، ويقرأ القارئ هذا الجزء فقط من الملف، ثم ينتقل الكاتب إلى الشريط التالي، وفي نفس الوقت تعمل عدة خيوط Threads على أشرطة مختلفة. وبهذا الشكل لا توجد الصورة كاملة في الذاكرة في أي لحظة، وتصبح الذاكرة المطلوبة مرتبطة بعرض الصورة وعدد الخيوط وليس بمساحتها الكاملة.
والتشبيه الأقرب لذلك هو المطبخ: ImageMagick يشبه طباخاً يخرج كل المكونات من الثلاجة ويضعها على الطاولة قبل أن يبدأ، فيحتاج طاولة بحجم الوجبة كاملة، بينما يشبه libvips خط إنتاج يأخذ من الثلاجة ما يحتاجه الصحن الحالي فقط، فيكفيه سطح صغير مهما كان عدد الصحون. والصورة التالية تبين الطريقتين بالأرقام التي قسناها في التجربة على صورة بانورامية مقاسها 195 ميجابكسل:

وهذه الطريقة لها ثمن يجب أن تعرفه: العمليات التي تحتاج الصورة كلها في نفس الوقت، مثل التدوير بزاوية أو بعض المرشحات التي تقرأ من كل مكان في الصورة، تجبر libvips على أن يحفظ نسخة كاملة أو يقرأ الملف عدة مرات، وصيغ الصور التي لا تسمح بالقراءة من أي موضع في الملف تحتاج حيلاً إضافية، لذلك لا تتوقع نفس الفرق في كل عملية، وإنما في العمليات الشائعة في مواقع الويب: التصغير والقص والتحويل بين الصيغ. وتجد على صفحة السرعة والذاكرة في المشروع مقارنة يحدثها فريق libvips بين عدة مكتبات على نفس العملية، ونحن سوف نقيس بأنفسنا في التجربة بدل أن نعتمد على أرقام غيرنا.
التصغير أثناء القراءة Shrink-on-load: لماذا لا تفك الصورة كاملة؟
هناك حيلة ثانية تجعل تصغير صور JPEG أسرع بكثير، وهي موجودة في libvips وفي ImageMagick أيضاً لكن بشروط مختلفة. فكما شرحنا في قسم JPEG فإن JPEG يقسم الصورة إلى مربعات 8 في 8 بكسلات، ويحفظ كل مربع كمجموعة معاملات تحويل جيب التمام المتقطع DCT، وأول معامل في كل مربع هو متوسط لونه. وهذا يعني أن مكتبة فك الترميز libjpeg تستطيع أن تخرج الصورة مباشرة بنصف المقاس أو ربعه أو ثمنه، بحساب أقل من فك الصورة كاملة، لأنها تستخدم جزءاً من المعاملات فقط. فإذا أردت صورة بعرض 800 بكسل من صورة عرضها 4480، فإن libvips يطلب من libjpeg أن يفك الصورة بربع مقاسها، أي 1120 في 1680، ثم يصغر هذه النسخة إلى 800 بدقة عالية، فيعمل على 1.9 ميجابكسل بدل 30 ميجابكسل، والصورة التالية تبين ذلك:

والدالة thumbnail في libvips، والأداة vipsthumbnail المبنية عليها، تفعل ذلك تلقائياً لأنها تعرف المقاس النهائي قبل أن تفتح الملف، وهذا سبب أن توثيق libvips ينصح باستخدامها بدل أن تفتح الصورة ثم تصغرها بخطوتين. أما ImageMagick فلا يعرف المقاس النهائي عندما يقرأ الملف، لأنه يقرأ أولاً ثم ينفذ العمليات، لذلك يجب أن تخبره أنت قبل اسم الملف بالخيار -define jpeg:size=، فيختار أصغر نسبة تصغير تعطي صورة أكبر من أو تساوي المقاس الذي ذكرته، وسوف ترى في التجربة أن هذا الخيار وحده خفض ذاكرته من 225 ميجابايت إلى 76.
تجربة عملية: نفس الصور في libvips وImageMagick وPillow
الآن سوف نقيس كل ما سبق على صور حقيقية بدل أن نصدق أي مقارنة جاهزة، والهدف أن تعرف كيف تقيس على سيرفرك أنت وبصورك أنت، لأن الأرقام تختلف مع المعالج والصيغة والمقاس.
تجهيز الأدوات والصور
نحتاج Image واحدة فيها libvips وImageMagick وPillow وExifTool وأداة time التي تقيس الزمن وأقصى استهلاك للذاكرة، وكلها من حزم Debian 13 دون أي بناء من المصدر، والسبب أن هذا ما سوف تثبته غالباً على سيرفرك:
FROM debian:trixie-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
libvips-tools imagemagick libimage-exiftool-perl time python3 python3-pil procps \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /workثم نبني ال Image ونطبع إصدار كل أداة:
docker build -t imgproc-lab:1 .
docker run --rm imgproc-lab:1 sh -c 'vips --version; magick -version | head -1; exiftool -ver;
python3 -c "import PIL; print(PIL.__version__)"; grep -m1 "model name" /proc/cpuinfo; nproc'والمخرج سوف يكون كما يلي:
vips-8.16.1
Version: ImageMagick 7.1.1-43 Q16 x86_64 22550 https://imagemagick.org
13.25
11.1.0
model name : Intel(R) Core(TM) i7-7700K CPU @ 4.20GHz
8لاحظ كلمة Q16 في إصدار ImageMagick، فهي تعني بايتين لكل قناة كما شرحنا، وأن المعالج فيه 4 أنوية و8 خيوط، وهذا مهم عندما نقرأ أرقام المعالجة المتوازية لاحقاً. أما الصور فاخترنا صورتين من مكتبة NASA، وصور NASA في الغالب غير محمية بحقوق النشر كما في إرشادات استخدام الوسائط:
- شروق الشمس في مركز Kennedy: صورة من كاميرا Canon EOS 5D Mark IV مقاسها 4480 في 6720، أي 30.1 ميجابكسل، وحجم ملفها 17.8 ميجابايت، وفيها ملف ألوان Adobe RGB، وهي تمثل صورة كاميرا احترافية كبيرة.
- بانوراما المريخ من هضبة Naukluft: صورة من المسبار Curiosity مقاسها 29163 في 6702، أي 195.5 ميجابكسل، وحجم ملفها 110.6 ميجابايت، وهي تمثل أسوأ ما قد يرفعه مستخدم إلى موقعك.
وقبل أن تقيس انتبه لمشكلة وقعنا فيها في أول تشغيل: وضعنا الصور في مجلد على القرص ثم ربطناه بال Container، فأخذ vipsthumbnail قرابة 48 ثانية لصورة واحدة واستخدم 5% فقط من المعالج، والسبب أن libvips يقرأ الملف على دفعات كثيرة صغيرة، ومجلد مربوط من نظام ملفات بطيء، مثل مجلد Windows داخل Docker Desktop أو قرص شبكي NFS، يجعل كل قراءة تنتظر. فلما نسخنا الصور إلى داخل ال Container قبل القياس نزل الزمن إلى أقل من ثانيتين. والدرس العملي أن مكان الملف المؤقت جزء من أداء المعالجة، فضع ملفات ال Worker المؤقتة على قرص محلي سريع، وليس على مجلد مشترك.
صورة واحدة 30 ميجابكسل بخمس طرق
نصغر صورة الشروق إلى عرض 800 بكسل بجودة 82، وهو مقاس صورة صفحة الإعلان في قصتنا، بخمس طرق، ونقيس لكل طريقة الزمن الفعلي وأقصى ذاكرة استخدمتها العملية Maximum Resident Set Size ونسبة استخدام المعالج:
T() { /usr/bin/time -f "%e s %M KB cpu %P" "$@" 2>&1 | tail -1; }
T vipsthumbnail ksc.jpg --size 800x -o /tmp/v.jpg[Q=82]
T magick ksc.jpg -resize 800x -quality 82 /tmp/m.jpg
T magick -define jpeg:size=1600x2400 ksc.jpg -thumbnail 800x -quality 82 /tmp/m2.jpg
T python3 -c "
from PIL import Image
im=Image.open('ksc.jpg'); im.draft('RGB',(800,1200)); im.thumbnail((800,10000)); im.save('/tmp/p.jpg',quality=82)"
T python3 -c "
from PIL import Image
im=Image.open('ksc.jpg'); im=im.resize((800,1200), Image.LANCZOS); im.save('/tmp/p2.jpg',quality=82)"والمخرج سوف يكون كما يلي:
1.55 s 88008 KB cpu 100%
2.79 s 224776 KB cpu 272%
1.70 s 76312 KB cpu 177%
1.25 s 36328 KB cpu 99%
3.55 s 163028 KB cpu 99%في المخرج أعلاه لاحظ التالي:
- السطر الثاني هو ما كتبه المطور في قصتنا:
magick -resizeدون أي تلميح، وقد استخدم 225 ميجابايت لصورة واحدة، وهذا قريب جداً من 241 ميجابايت التي حسبناها لذاكرة البكسلات في الأعلى، أي أن ImageMagick فك الصورة كاملة بدقة 16 بت. ولاحظ أن المعالج كان 272%، أي أن ImageMagick وزع العمل على قرابة ثلاثة خيوط بواسطة OpenMP. - السطر الثالث هو نفس الأداة مع
-define jpeg:size=1600x2400، وقد نزلت الذاكرة إلى 76 ميجابايت والزمن إلى 1.7 ثانية، والسبب أن libjpeg فك الصورة بنصف مقاسها، وهذا تغيير في سطر واحد كان يكفي لتخفيف أغلب المشكلة في قصتنا. - السطر الأول هو
vipsthumbnail، وقد استخدم 88 ميجابايت في 1.55 ثانية دون أي خيار إضافي، لأنه يعرف المقاس النهائي ويطبق التصغير أثناء القراءة وحده. - السطر الرابع هو Pillow مع
draft، وهي نفس فكرة التصغير أثناء القراءة في Pillow، وقد كان الأقل ذاكرة والأسرع هنا. أما السطر الأخير فهو Pillow دونdraft، فقد فك الصورة كاملة واستخدم 163 ميجابايت، والسبب أن Pillow يحفظ البكسل الواحد في 3 بايتات وليس 8 كما في ImageMagick Q16.
وقد يتساءل البعض: إذا كان Pillow هو الأسرع هنا، فلماذا لا نستخدمه ونرتاح؟ والإجابة في تفصيلين: أن Pillow حفظ الصورة دون ملف الألوان Adobe RGB، وسوف نرى في قسم الألوان ماذا يعني ذلك، وأن زمن الصورة الواحدة الصغيرة ليس المقياس الذي يهمك، وإنما ما يحدث مع الصور الكبيرة جداً ومع عشرات الصور في نفس الوقت، وهذا ما سوف نقيسه الآن. ولاحظ أيضاً أن جزءاً من زمن الأدوات هو زمن تشغيلها، فالأمر vips --version وحده أخذ 0.32 ثانية لأن libvips يحمل وحداته عند البدء، وهذا الزمن يختفي إذا استخدمت المكتبة من داخل Worker يعمل باستمرار بدل تشغيل أداة لكل صورة.
صورة بانورامية 195 ميجابكسل
الآن نكرر التجربة على بانوراما المريخ، ونصغرها إلى عرض 1600 بكسل:
vipsheader PIA20332.jpg
T vipsthumbnail PIA20332.jpg --size 1600x -o /tmp/pv.jpg[Q=82]
T magick PIA20332.jpg -resize 1600x -quality 82 /tmp/pm.jpg
T magick -define jpeg:size=3200x800 PIA20332.jpg -thumbnail 1600x -quality 82 /tmp/pm2.jpg
python3 -c "from PIL import Image; Image.open('PIA20332.jpg')" 2>&1 | tail -1والمخرج سوف يكون كما يلي:
PIA20332.jpg: 29163x6702 uchar, 3 bands, srgb, jpegload
5.13 s 175564 KB cpu 102%
43.22 s 1262728 KB cpu 152%
3.76 s 134616 KB cpu 109%
PIL.Image.DecompressionBombError: Image size (195450426 pixels) exceeds limit of 178956970 pixels, could be decompression bomb DOS attack.في المخرج أعلاه لاحظ التالي:
- الأمر
vipsheaderقرأ أبعاد الصورة من ترويسة الملف دون أن يفك البكسلات، وهذا ما يجب أن يفعله خط المعالجة قبل أي شيء آخر كما سيأتي. - ImageMagick دون تلميح أخذ 43 ثانية و1.26 جيجابايت، أي أكثر من ثمانية أضعاف زمن libvips وسبعة أضعاف ذاكرته، والسبب أن ذاكرة البكسلات المحسوبة 1.56 جيجابايت تجاوزت حد الذاكرة 1 جيجابايت في ملف السياسة الافتراضي في Debian، فنقل ImageMagick جزءاً منها إلى ملف مربوط بالذاكرة Memory-mapped، وهذا سبب البطء.
- مع التلميح
jpeg:sizeصار ImageMagick أسرع من libvips قليلاً، لأن libjpeg فك الصورة بربع مقاسها، أي أن الفرق الكبير في السطر السابق لم يكن من ImageMagick نفسه، وإنما من طريقة استدعائه. - Pillow رفض فتح الصورة أصلاً، والسبب أن له حداً افتراضياً اسمه
Image.MAX_IMAGE_PIXELS، وإذا تجاوزت الصورة ضعفه رمى خطأDecompressionBombErrorكما في توثيق Pillow، وهذا سلوك حماية جيد سوف نعود إليه.
ماذا يحدث إذا كان ال Worker محدوداً بذاكرة 512 ميجابايت؟
في الإنتاج لا يعمل ال Worker بذاكرة السيرفر كلها، وإنما بحد تضعه له حتى لا يأخذ ذاكرة قاعدة البيانات والتطبيق، وفي Docker يكون ذلك بالخيار --memory. فماذا يحدث إذا وصلت البانوراما إلى Worker حده 512 ميجابايت؟ نجرب العطل بأمرين:
for cmd in "vipsthumbnail /data/PIA20332.jpg --size 1600x -o /tmp/x.jpg" \
"magick /data/PIA20332.jpg -resize 1600x /tmp/x.jpg"; do
docker run --rm --memory 512m --memory-swap 512m -v "$PWD/media:/media:ro" --tmpfs /data:size=200m imgproc-lab:1 \
bash -c "cp /media/PIA20332.jpg /data/; /usr/bin/time -f '%e s %M KB' $cmd 2>&1 | tail -3; echo exit=\${PIPESTATUS[0]}"
doneوالمخرج سوف يكون كما يلي:
3.31 s 171804 KB
exit=0
Command terminated by signal 9
6.15 s 463676 KB
exit=137
نجح libvips في 3.3 ثانية بذاكرة 172 ميجابايت، أما ImageMagick فقد قتله نظام التشغيل بالإشارة 9 بعد 6 ثوان عندما وصل إلى 464 ميجابايت، ورمز الخروج 137 يعني 128 زائد 9، أي أن ال OOM Killer في Linux هو من أوقفه وليس ImageMagick نفسه. والسبب أن حد الذاكرة في ملف السياسة 1 جيجابايت، وهو أكبر من حد ال Container، فظن ImageMagick أن الذاكرة متاحة وظل يطلبها. وهذا خطأ يتكرر كثيراً: تضع حداً لل Container ولا تضع حداً مطابقاً لل ImageMagick داخله، فتتحول الصورة الكبيرة من بطء إلى انقطاع.
والحل أن تجعل حدود ImageMagick أقل من حد ال Container، وأن تعطيه مجلداً مؤقتاً على قرص حقيقي يضع فيه ذاكرة البكسلات عندما لا تكفي الذاكرة. وفي محاولتنا الأولى وضعنا الحدود وتركنا المجلد المؤقت على /tmp، فقتل مرة أخرى، والسبب أن /tmp في التجربة كان tmpfs، أي في الذاكرة نفسها، وملفات tmpfs تحسب على حد ذاكرة ال Container. ثم غيرنا المجلد المؤقت إلى قرص ال Container بالمتغير MAGICK_TEMPORARY_PATH:
docker run --rm --memory 512m --memory-swap 512m -v "$PWD/media:/media:ro" --tmpfs /data:size=200m imgproc-lab:1 \
bash -c "cp /media/PIA20332.jpg /data/; MAGICK_TEMPORARY_PATH=/var/tmp /usr/bin/time -f '%e s %M KB' \
magick -limit memory 256MiB -limit map 512MiB /data/PIA20332.jpg -resize 1600x /var/tmp/x.jpg 2>&1 | tail -3; echo exit=\${PIPESTATUS[0]}"35.50 s 117592 KB
exit=0نجح الأمر بذاكرة 118 ميجابايت فقط، لكنه أخذ 35.5 ثانية بدل 3.3، لأن ذاكرة البكسلات صارت ملفاً على القرص. وهذا هو الاختيار الحقيقي مع ImageMagick: إما ذاكرة كبيرة، وإما قرص بطيء، وإما أن ترفض الصورة الكبيرة قبل أن تبدأ، أما libvips فيتجنب الاختيار كله في هذه العملية.
24 رفعاً في نفس اللحظة: ما الذي يحدد الزمن والذاكرة؟
الآن نعيد مشهد الحملة الإعلانية على نطاق صغير: 24 صورة بمقاس صورة هاتف، أي 3024 في 4032 بكسلاً، قصصناها من صورة الشروق وحفظناها بجودة 90، ونريد لكل صورة ثلاثة مقاسات WebP بعرض 1600 و800 و320، ونقيس الزمن الكلي وأقصى ذاكرة إضافية استخدمها ال Container، بتشغيل صورة واحدة في كل مرة ثم 4 ثم 8 في نفس الوقت. والسكربت التالي يعرف مهمة لكل أداة، ثم يشغلها على الصور بالأمر xargs -P، ويقرأ استهلاك الذاكرة من cgroup كل 50 مللي ثانية:
vipsjob() { b=${1%.jpg}; vipsthumbnail $1 --size 1600x -o /tmp/$b-1600.v
for s in 1600 800 320; do vipsthumbnail /tmp/$b-1600.v --size ${s}x -o /tmp/$b-$s.webp[Q=80,keep=none]; done
rm /tmp/$b-1600.v; }
magjob() { b=${1%.jpg}; magick $1 -auto-orient -strip -resize 1600x -quality 80 -write /tmp/$b-1600.webp \
\( +clone -resize 800x -write /tmp/$b-800.webp +delete \) -resize 320x /tmp/$b-320.webp; }
export -f vipsjob magjob
M=/sys/fs/cgroup/memory/memory.usage_in_bytes
base=$(cat $M); ( mx=0; while :; do u=$(cat $M); [ $u -gt $mx ] && mx=$u && echo $mx > /tmp/peak; sleep 0.05; done ) & S=$!
start=$(date +%s%N)
ls in-*.jpg | xargs -P $2 -I{} bash -c "${1}job {}"
end=$(date +%s%N); kill $S
echo "$1 P=$2 files=$(ls /tmp/*.webp | wc -l) wall=$(( (end-start)/1000000 )) ms peak_above_base=$(( ($(cat /tmp/peak) - base) / 1048576 )) MiB"في السكربت أعلاه لاحظ التالي:
- مهمة libvips تصغر الصورة مرة واحدة إلى 1600 بصيغة libvips الداخلية
.v، وهي صيغة بلا ضغط تقرأ بسرعة، ثم تصنع المقاسات الثلاثة منها، بدل أن تفك الأصل ثلاث مرات. - مهمة ImageMagick تصغر إلى 1600 ثم تأخذ نسخة
+cloneمن الناتج لتصنع 800، ثم تصغر الناتج نفسه إلى 320، فتقرأ الأصل مرة واحدة أيضاً. - الخيار
keep=noneفي libvips و-stripفي ImageMagick يحذفان البيانات الوصفية، و-auto-orientيطبق اتجاه الكاميرا، أماvipsthumbnailفيطبقه وحده، وسوف نشرح السبب في القسم التالي.
شغلنا كل حالة في Container جديد بحد 8 معالجات، والنتائج كما يلي:
vips P=1 files=72 wall=70974 ms peak_above_base=73 MiB
vips P=4 files=72 wall=23682 ms peak_above_base=267 MiB
vips P=8 files=72 wall=15471 ms peak_above_base=477 MiB
mag P=1 files=72 wall=71575 ms peak_above_base=140 MiB
mag P=4 files=72 wall=28047 ms peak_above_base=500 MiB
mag P=8 files=72 wall=18319 ms peak_above_base=950 MiBوالصورة التالية تضع الأرقام في رسم واحد:

في النتائج أعلاه لاحظ التالي:
- الزمن متقارب بين الأداتين عندما رتبنا الخطوات بشكل صحيح، فالفرق في صور الهاتف العادية ليس عشرة أضعاف كما قد تقرأ في بعض المقارنات، وإنما بين التساوي و20% تقريباً. أما الذاكرة فضعفان تقريباً مع ImageMagick في كل الحالات، وهذا هو الفرق الذي يقرر عدد الصور التي تعالجها في نفس الوقت على سيرفر بذاكرة محددة.
- قسمة الزمن على عدد الصور تعطيك تكلفة الصورة الواحدة: قرابة 3 ثوان من نواة واحدة لكل صورة بمقاساتها الثلاثة، وأغلبها في ترميز WebP وليس في التصغير، فقد قسنا أن ترميز صورة 1600 في 2133 إلى WebP يأخذ قرابة 0.8 ثانية وحده.
- من 4 إلى 8 مهام متوازية تحسن الزمن قليلاً فقط، والسبب أن المعالج فيه 4 أنوية حقيقية، والخيوط الثمانية تتقاسمها، بينما زادت الذاكرة إلى الضعف. والقاعدة العملية أن عدد المهام المتوازية في ال Worker يساوي عدد الأنوية الحقيقية تقريباً، وأن تضرب ذاكرة المهمة الواحدة في هذا العدد لتعرف حد ال Container.
وقبل هذه النتائج كانت لدينا محاولة أولى في ImageMagick أخذت فيها كل نسخة من الصورة الأصلية بدل الصورة المصغرة، أي \( +clone -resize 1600x … \) \( +clone -resize 800x … \) من الأصل، فأخذت 24 صورة 217 ثانية بذاكرة 393 ميجابايت مع صورة واحدة في كل مرة، أي ثلاثة أضعاف الزمن بعد الترتيب. وهذا أهم درس في هذا القسم: ترتيب الخطوات يؤثر في الأداء أكثر من اختيار المكتبة، فصغر مرة من الأصل، ثم اصنع المقاسات الأصغر من الناتج.
الصورة المقلوبة: اتجاه EXIF Orientation، والموقع المسرب GPS
الآن نعود إلى رسالة البائع الذي ظهرت صورته مقلوبة على جنبها. والسبب أن حساس كاميرا الهاتف له اتجاه واحد ثابت، فعندما تمسك الهاتف عمودياً لا يدير الهاتف البكسلات، وإنما يحفظها كما خرجت من الحساس، أي بالعرض، ويكتب في البيانات الوصفية EXIF وسماً اسمه Orientation يقول للبرامج «أدر هذه الصورة 90 درجة عند عرضها»، ويشرح جدول وسوم EXIF في ExifTool القيم الثمانية لهذا الوسم. والمتصفحات الحديثة تحترم هذا الوسم عند العرض كما يشرح توثيق image-orientation، لذلك تظهر الصورة صحيحة ما دام الوسم موجوداً. والمشكلة تبدأ عندما يحذف خط المعالجة البيانات الوصفية لأسباب الخصوصية، دون أن يدير البكسلات أولاً، فيذهب الوسم وتبقى البكسلات بالعرض.
ولنرى ذلك صنعنا صورة كما يحفظها الهاتف: صغرنا صورة الشروق إلى 1200 بكسل، وأدرنا بكسلاتها 270 درجة، ثم كتبنا فيها وسم الاتجاه 6، أي «أدر 90 درجة مع عقارب الساعة»، وأضفنا إحداثيات GPS تجريبية في وسط الصحراء:
vipsthumbnail ksc.jpg --size 1200x --export-profile srgb -o p.jpg[Q=90,keep=none]
vips rot p.jpg phone.jpg[Q=90] d270
exiftool -q -overwrite_original -Orientation#=6 -GPSLatitude=25.0001 -GPSLatitudeRef=N \
-GPSLongitude=45.0001 -GPSLongitudeRef=E phone.jpg
exiftool -s -ImageSize -Orientation -GPSPosition phone.jpgImageSize : 1800x1200
Orientation : Rotate 90 CW
GPSPosition : 25 deg 0' 0.36" N, 45 deg 0' 0.36" Eالبكسلات بالعرض 1800 في 1200، والوسم يقول أدرها، تماماً كما يرفعها الهاتف. الآن نعالجها بأربع طرق، ثم نقرأ الأبعاد والاتجاه والموقع في كل ناتج:
magick phone.jpg -strip -resize 400x400 sideways.jpg
magick phone.jpg -auto-orient -strip -resize 400x400 magick-ok.jpg
vipsthumbnail phone.jpg --size 400x400 -o upright.jpg[keep=none]
vipsthumbnail phone.jpg --size 400x400 -o keepall.jpg
for f in sideways magick-ok upright keepall; do echo "== $f"; exiftool -s -ImageSize -Orientation -GPSPosition $f.jpg; doneوالمخرج سوف يكون كما يلي:
== sideways
ImageSize : 400x267
== magick-ok
ImageSize : 267x400
== upright
ImageSize : 267x400
== keepall
ImageSize : 267x400
Orientation : Horizontal (normal)
GPSPosition : 25 deg 0' 0.36" N, 45 deg 0' 0.36" Eفي المخرج أعلاه لاحظ التالي:
- الناتج الأول هو خطأ قصتنا بالضبط:
-stripحذف الوسم، والبكسلات بقيت بالعرض 400 في 267، فظهرت الصورة الطولية على جنبها في كل متصفح. - الناتج الثاني صحيح لأن
-auto-orientأدار البكسلات قبل الحذف، ولاحظ أن ترتيب الخيارين في الأمر مهم، فالتدوير يجب أن يسبق الحذف. - الدالة
thumbnailفي libvips تدير الصورة بحسب الوسم تلقائياً، لذلك جاء الناتجان الثالث والرابع بالطول الصحيح دون أي خيار. - الناتج الرابع هو الخطأ الثاني في قصتنا: libvips يحتفظ بالبيانات الوصفية افتراضياً، فبقي الموقع GPS في الصورة المصغرة التي سوف يحملها كل مشتر، مع أن الاتجاه صار عادياً. والحل هو
keep=noneكما في الناتج الثالث، أو-stripفي ImageMagick.
الألوان الباهتة: ملف الألوان ICC Profile
وهذه مشكلة لم تكن في قصة السوق، ووقعنا فيها نحن أثناء التجربة. فصورة الشروق من كاميرا احترافية محفوظة بفضاء ألوان Adobe RGB، وهو فضاء أوسع من sRGB الذي تفترضه الشاشات، أي أن نفس الأرقام في البكسل تعني ألواناً مختلفة في الفضاءين، والملف يحمل ملف ألوان ICC Profile يقول للبرنامج كيف يفسر الأرقام، وقد شرحنا فضاءات الألوان في قسم البكسلات والألوان. وعندما أضفنا keep=none لحذف البيانات الوصفية بدا لنا أن الصور الناتجة باهتة مقارنة بالأصل، فقسنا متوسط التشبع Saturation في كل ناتج:
sat() { magick "$1" -colorspace HSL -channel G -separate +channel -format "%[fx:mean*100]" info:; }
vipsthumbnail ksc.jpg --size 600x -o /tmp/a.jpg[Q=85,keep=none]
vipsthumbnail ksc.jpg --size 600x --export-profile srgb -o /tmp/s.jpg[Q=85,keep=none]
vipsthumbnail ksc.jpg --size 600x -o /tmp/k.jpg[Q=85]
for f in a s k; do echo "$f sat=$(sat /tmp/$f.jpg) icc=$(exiftool -s3 -ProfileDescription /tmp/$f.jpg)"; donea sat=24.082 icc=
s sat=30.5009 icc=
k sat=24.082 icc=Adobe RGB (1998)والتفسير أن keep=none حذف ملف الألوان دون أن يحول الأرقام، فصار المتصفح يقرأ أرقام Adobe RGB على أنها sRGB، فخرجت الألوان أقل تشبعاً، 24.1 بدل 30.5. أما --export-profile srgb فحول الأرقام فعلاً إلى sRGB قبل الحذف، فبقيت الألوان كما في الأصل. والناتج الثالث صحيح أيضاً في المتصفح، لأنه احتفظ بملف Adobe RGB ويحوله المتصفح عند العرض، لكنه يحمل معه البيانات الوصفية كلها. وفي نفس القياس احتفظ ImageMagick بملف الألوان، بينما حفظ Pillow الصورة دون ملف ألوان ودون تحويل، فخرجت باهتة بنفس الدرجة، لأن دالة save في Pillow لا تكتب ملف ICC إلا إذا مررته لها بنفسك. والقاعدة: حول إلى sRGB أولاً، ثم احذف ما تريد، والصورة التالية تبين الخطأين من نواتج التجربة الحقيقية:

نجرب العطل: قنبلة فك الضغط Decompression Bomb
بقي الخطر الأخير في الصور المرفوعة، وهو أن الملف الصغير لا يعني صورة صغيرة. فصيغة PNG تضغط المساحات المتشابهة بشكل ممتاز، لذلك تستطيع أن تصنع ملفاً حجمه ميجابايت ونصف يحمل صورة رمادية مقاسها 40000 في 40000 بكسل، أي 1.6 مليار بكسل، فإذا فتحها خط المعالجة كما هي فقد يحتاج جيجابايتات من الذاكرة ودقائق من المعالج، وهذا ما يسمى قنبلة فك الضغط Decompression Bomb. والأوامر التالية تصنع القنبلة ثم تعطيها لكل أداة:
vips black /tmp/b.v 40000 40000 && vips pngsave /tmp/b.v bomb.png --compression 9 && rm /tmp/b.v
ls -l bomb.png; vipsheader bomb.png
T vipsthumbnail bomb.png --size 800x -o /tmp/b1.jpg
magick bomb.png -resize 800x /tmp/b2.jpg 2>&1 | head -2
python3 -c "from PIL import Image; Image.open('bomb.png')" 2>&1 | tail -1والمخرج سوف يكون كما يلي:
-rw-r--r-- 1 root root 1557712 Oct 28 08:50 bomb.png
bomb.png: 40000x40000 uchar, 1 band, b-w, pngload
8.77 s 247032 KB cpu 102%
magick: Image width exceeds user limit in IHDR `bomb.png' @ warning/png.c/MagickPNGWarningHandler/1527.
magick: Image height exceeds user limit in IHDR `bomb.png' @ warning/png.c/MagickPNGWarningHandler/1527.
PIL.Image.DecompressionBombError: Image size (1600000000 pixels) exceeds limit of 178956970 pixels, could be decompression bomb DOS attack.في المخرج أعلاه لاحظ التالي:
- ملف حجمه 1.56 ميجابايت، أي أقل من صورة هاتف عادية، يحمل 1.6 مليار بكسل، وأي حد لحجم الملف المرفوع لن يكتشفه.
- ImageMagick رفضه فوراً، والسبب حد العرض والارتفاع 32 ألف بكسل في ملف السياسة الذي تضعه Debian، وPillow رفضه بحده الافتراضي كما رأينا مع البانوراما.
- libvips عالجه فعلاً في 8.8 ثانية بذاكرة 247 ميجابايت فقط بفضل المعالجة حسب الطلب، وهذا يبدو جيداً، لكنه يعني أن مخترقاً يرفع مئة ملف من هذا النوع يستطيع أن يشغل كل أنوية ال Worker دقائق بثمن رخيص جداً، لأن libvips لا يضع حداً للبكسلات بنفسه.
والدفاع الصحيح لا يعتمد على حد داخل المكتبة، وإنما على خطوة صريحة في بداية خط المعالجة: اقرأ الأبعاد من الترويسة كما فعل vipsheader دون أن تفك البكسلات، وارفض أي صورة تتجاوز عدداً من البكسلات تختاره بحسب منتجك، مثل 50 ميجابكسل لسوق صور الهواتف، ثم ضع حداً للزمن لكل مهمة، فإذا لم تنته في 30 ثانية مثلاً أوقفها واعتبرها فاشلة.
حذف التجربة
docker image rm imgproc-lab:1
rm -rf media outتصميم خط المعالجة Pipeline في الإنتاج
بعد التجربة صار واضحاً ما يجب أن يتغير في السوق: المعالجة تخرج من طلب الرفع إلى Worker منفصل، والصورة تمر بخطوات مرتبة لا يجوز تبديلها، والصورة التالية تبين الخط كاملاً:

وفي الشكل أعلاه لاحظ القرارات التالية، وكل واحد منها يحل مشكلة رأيناها:
- الرفع لا يمر بالتطبيق: المتصفح يرفع الملف إلى تخزين الكائنات برابط موقع Presigned URL، كما شرحنا في S3 من داخل تطبيقك، ثم يخبر التطبيق أن الرفع اكتمل، فيضع التطبيق مهمة في الطابور ويرد فوراً. وبهذا الشكل لا ينتظر البائع المعالجة، ولا تحجز المعالجة ذاكرة التطبيق.
- الطابور يمتص الذروة: إذا رفع أربعون بائعاً 400 صورة في دقيقة، فإنها تنتظر في الطابور، ويعالجها ال Worker أربعاً أربعاً بحسب أنويته، فتتأخر آخر صورة دقائق بدل أن يتوقف السيرفر، وقد شرحنا هذا النمط في مفاهيم الطوابير وطوابير المهام فوق Redis وValkey.
- الأصل يبقى محفوظاً: لا تحذف الصورة الأصلية بعد صناعة المقاسات، والسبب أن تصميم الواجهة سوف يتغير يوماً ويحتاج مقاساً جديداً، أو تقرر الانتقال إلى AVIF، ولن تستطيع ذلك من صورة مصغرة مضغوطة. لكن احفظ الأصل في مكان خاص لا يصل إليه أحد غير ال Worker، لأنه ما زال يحمل البيانات الوصفية.
- المهمة آمنة عند التكرار Idempotent: الطابور قد يسلم المهمة مرتين، لذلك اجعل أسماء المقاسات مشتقة من معرف الصورة أو هاش محتواها، مثل
variants/<id>/800.webp، فإذا تكررت المهمة كتبت نفس الملفات مرة أخرى دون أي ضرر. - الحالة في قاعدة البيانات: الصورة تبدأ بحالة «قيد المعالجة»، والإعلان لا يظهر للمشترين إلا بعد أن تصبح كل صوره جاهزة، والصورة التي فشلت معالجتها ثلاث مرات تذهب إلى طابور المهام الفاشلة Dead Letter Queue ويظهر للبائع أن يرفعها من جديد.
- المقاسات ثابتة لا تتغير: اجعل صلاحيتها في ال CDN طويلة جداً، فإذا أعدت معالجة صورة فاكتبها باسم جديد بدل أن تغير الملف القديم، وقد شرحنا ذلك في شبكة توصيل المحتوى CDN.
وقد يتساءل البعض: لماذا ثلاثة مقاسات ثابتة، ولماذا WebP بالذات؟ والإجابة أن هذا قرار منتج قبل أن يكون قراراً تقنياً، فاسأل مصمم الواجهة عن أكبر عرض تظهر به الصورة على كل شاشة، واضربه في كثافة البكسلات للشاشات الحديثة، ثم اختر أقل عدد من المقاسات يغطيها، وقد شرحنا السمة srcset التي تجعل المتصفح يختار المقاس المناسب في الصور المتجاوبة. أما الصيغة فقد قارنا الصيغ بالأحجام والجودة في كيف تختار الصيغة، وWebP اليوم خيار آمن تدعمه كل المتصفحات الحديثة، وAVIF أصغر لكن ترميزه أبطأ، فإذا اخترته فأعد حساب السعة بنفس طريقة التجربة.
الأمان: من ImageTragick إلى عزل ال Worker
في 3 مايو 2016 نشر باحثون أمنيون تفاصيل مجموعة ثغرات في ImageMagick سموها ImageTragick، وأخطرها CVE-2016-3714 التي تسمح بتنفيذ أوامر على السيرفر Remote Code Execution عند معالجة صورة يرفعها المستخدم، وذكر موقع الثغرة أنها كانت تستغل فعلاً وقت النشر، وأن مكتبات شائعة مثل imagick في PHP وrmagick وpaperclip في Ruby كانت تمررها إلى ImageMagick. والسبب أن ImageMagick كان يحدد نوع الملف من محتواه وليس من امتداده، ويقبل صيغاً نصية مثل MVG وMSL تستطيع أن تطلب من ImageMagick قراءة رابط أو ملف، وبعض هذه الطلبات كانت تمر إلى برنامج خارجي Delegate في سطر أوامر دون تنظيف كاف، فيستطيع ملف يسمي نفسه photo.jpg أن يجعل السيرفر ينفذ أمراً. وكانت توصيتا الموقع: أن تتحقق من البايتات الأولى في الملف Magic Bytes قبل أن تعطيه لل ImageMagick، وأن تعطل الصيغ الخطرة في policy.xml.
والدرس الذي بقي بعد سنوات من هذه الحادثة أن مكتبة الصور على السيرفر هي أكثر كود في تطبيقك يقرأ مدخلات معقدة من أشخاص لا تعرفهم، فكل صيغة صور هي في الحقيقة لغة صغيرة لها مفسرها Parser، وكل مفسر فيه أخطاء لم تكتشف بعد، وتجد قائمة الثغرات الحديثة في صفحة التنبيهات الأمنية للمشروع. لذلك لا تعتمد على طبقة واحدة، وإنما على طبقات كما في الصورة التالية:

- تحقق قبل المعالجة: اقرأ البايتات الأولى في الملف وتأكد أنها توقيع صيغة تقبلها، مثل
FF D8 FFل JPEG، ثم اقرأ الأبعاد من الترويسة وارفض ما يتجاوز حدك، كما في تجربة القنبلة، ويشرح دليل OWASP لرفع الملفات بقية الضوابط. - قائمة صيغ مسموحة Allowlist: في ImageMagick عطل كل الصيغ في
policy.xmlثم اسمح بما تحتاجه فقط، كما يفعل الملف في Debian الذي رأينا فيه سطرpattern="{GIF,JPEG,PNG,WEBP}"، ولا تسمح ب PDF وPostScript وSVG وMVG إلا إذا كان منتجك يحتاجها فعلاً. وفي libvips هناك آلية لمنع المحملات غير الموثوقة Untrusted Loaders، أي التي لم تختبر بالبيانات العشوائية Fuzzing، بالدالةblock_untrusted_setأو بالمتغيرVIPS_BLOCK_UNTRUSTED، ويذكر دليل Active Storage في Rails أنه يفعل هذا المنع افتراضياً، وأن محمل ImageMagick داخل libvips من المحملات الممنوعة. - حدود الموارد: حد للبكسلات، وحد للذاكرة أقل من حد ال Container، ومجلد مؤقت على قرص حقيقي، وحد للزمن لكل مهمة، كما رأينا في تجربة 512 ميجابايت.
- عزل ال Worker: ال Container الذي يعالج الصور لا يحتاج شبكة إلا إلى التخزين والطابور، ولا يحتاج الكتابة إلا في مجلد مؤقت، ولا يحتاج صلاحيات root، فإذا نجحت ثغرة يوماً فإنها تجد نفسها في صندوق فارغ، وقد شرحنا هذه الإعدادات في تقوية السيرفر.
- التحديث: أغلب ثغرات مكتبات الصور تصلح في إصدارات صغيرة، فافحص ال Image بأداة مثل Trivy كما في فحص الحاويات، وأعد بناءها كل أسبوع على الأقل. وهذا كان سبب الرسالة الثالثة في قصتنا.
من داخل الكود: sharp وpyvips وPillow وImagick
في التجربة استخدمنا أدوات سطر الأوامر لأنها أوضح في القياس، لكن في التطبيق ستستخدم غالباً مكتبة من لغتك، والسؤال الذي يهمك أي محرك تحت المكتبة:
| اللغة | المكتبة | المحرك تحتها | ملاحظة |
|---|---|---|---|
| Node.js | sharp | libvips | الأشهر في Node.js، وتثبت معها نسخة libvips جاهزة فلا تحتاج حزم النظام |
| Python | pyvips | libvips | نفس عمليات vips من Python، ودالة thumbnail بنفس السلوك |
| Python | Pillow | محرك خاص بها | بسيطة ومنتشرة، واستخدم draft مع JPEG، ومرر icc_profile أو حول الألوان بنفسك |
| PHP | Imagick | ImageMagick | كل ما قلناه عن ملف السياسة وحدود الموارد ينطبق عليها |
| Ruby | image_processing مع ruby-vips أو MiniMagick | libvips أو ImageMagick | Active Storage في Rails يدعم الاثنين، ويذكر دليله أن libvips قد يكون أسرع حتى 10 مرات بعشر الذاكرة |
ولاحظ أن رقم «عشر مرات» في دليل Rails رأيناه في التجربة مع البانوراما فقط، أي عندما فك ImageMagick الصورة كاملة دون تلميح، أما مع صور الهاتف وترتيب جيد للخطوات فكان الفرق في الذاكرة ضعفين وفي الزمن قليلاً، لذلك قس على صورك قبل أن تبني قرارك على أي رقم منشور، بما في ذلك أرقامنا.
وهناك فرق مهم بين أن تستخدم المكتبة داخل عملية التطبيق نفسها، وأن تستخدمها في Worker منفصل حتى لو كان بنفس اللغة، فالمكتبة التي تتعطل بسبب صورة خبيثة أو تستهلك الذاكرة كلها توقف كل ما في نفس العملية، وإذا كان ذلك تطبيق الويب فقد توقف الموقع كله بسبب صورة واحدة، لذلك حتى مع sharp السريعة اجعل المعالجة في Worker خلف طابور، واترك التطبيق يستقبل الرفع ويرد فقط.
كم Worker تحتاج؟ حساب السعة والتكلفة
من التجربة نعرف أن صورة هاتف واحدة بمقاساتها الثلاثة تحتاج قرابة 3 ثوان من نواة واحدة على معالج التجربة، وأن 4 صور متوازية تحتاج قرابة 267 ميجابايت مع libvips و500 مع ImageMagick. والآن نحسب للسوق بافتراضات نكتبها صراحة، فغيرها بأرقام منصتك:
uploads per day 2,000 listings x 8 photos = 16,000 photos per day
peak hour 30% of the day in one evening hour = 4,800 photos in the peak hour
CPU per photo ~3 core-seconds (lab: 71 s / 24 photos, 3 WebP variants)
peak CPU need 4,800 x 3 s / 3600 s = 4 cores busy for the whole hour
memory per worker 4 parallel jobs: ~270 MB (libvips) or ~500 MB (ImageMagick)
storage per photo lab variants: 184 KB + 44 KB + 12 KB = ~240 KB, plus the original 3-5 MB
storage per year 16,000 x 365 x (0.24 + 4) MB = ~25 TB, of which ~1.4 TB are variantsوالنتيجة الأولى أن سيرفراً واحداً بأربع أنوية يكفي حتى في ساعة الذروة، بشرط أن يمر كل شيء عبر الطابور، فإذا تأخرت آخر صورة في الذروة دقيقتين فهذا مقبول، لأن البائع يرى «قيد المعالجة» ولا يرى خطأ. والنتيجة الثانية التي تفاجئ الناس أن تكلفة التخزين للأصول أكبر بكثير من المقاسات، لأن الأصل وحده 4 ميجابايت تقريباً، لذلك ضعه في فئة تخزين أرخص، أو صغره إلى حد أقصى معقول مثل 4000 بكسل للضلع الأطول بجودة عالية، إذا كان منتجك لا يحتاج الملف الأصلي كما هو. وقد شرحنا تكاليف التخزين والنقل في التكاليف الخفية في السحابة.
أخطاء شائعة وكيف تكتشفها
- المعالجة داخل طلب الرفع: تكتشفه عندما يتوقف السيرفر في يوم ذروة، أو يزيد زمن رد الرفع مع عدد الصور، والحل Worker خلف طابور.
magick -resizeعلى صور JPEG كبيرة دون-define jpeg:size: تكتشفه بقياس أقصى ذاكرة لعملية واحدة كما في التجربة، فإذا كانت قريبة من العرض في الارتفاع في 8 بايتات فالصورة تفك كاملة.- صناعة كل مقاس من الأصل: تكتشفه بزمن المهمة الذي يتضاعف مع عدد المقاسات، والحل أن تصغر مرة ثم تشتق الباقي.
- حذف البيانات الوصفية قبل التدوير: صور طولية تظهر على جنبها، والحل
-auto-orientقبل-strip، أوthumbnailفي libvips. - الاحتفاظ بالبيانات الوصفية في المقاسات العامة: تكتشفه بأمر
exiftoolعلى أي صورة من موقعك، فإذا ظهرGPSPositionفأنت تسرب مواقع المستخدمين. - حذف ملف الألوان دون تحويل: ألوان باهتة في صور الكاميرات الاحترافية فقط، لذلك قد لا تلاحظه مع صور الهواتف، والحل
--export-profile srgbأو ما يقابله في مكتبتك. - حد لل Container دون حد لل ImageMagick داخله: عمليات تقتل برمز 137 مع الصور الكبيرة، والحل حدود في
policy.xmlأقل من حد ال Container ومجلد مؤقت على قرص حقيقي. - الاعتماد على حجم الملف لرفض الصور الكبيرة: قنبلة PNG بحجم 1.5 ميجابايت تمر، والحل حد للبكسلات من الترويسة.
- ملف سياسة يسمح بكل الصيغ، أو Image لم تحدث منذ سنة: يكتشفه فحص Trivy، والحل قائمة صيغ مسموحة وإعادة بناء دورية.
قائمة تحقق قبل أن تستقبل صور المستخدمين
| البند | السؤال | القيمة التي بدأنا بها في السوق |
|---|---|---|
| مكان المعالجة | هل تعمل المعالجة خارج طلب الرفع وخارج عملية التطبيق؟ | Worker منفصل خلف طابور |
| التحقق | هل تفحص البايتات الأولى والأبعاد قبل فك البكسلات؟ | JPEG وPNG وWebP وHEIC فقط، وحد 50 ميجابكسل |
| الاتجاه | هل تدير الصورة بحسب EXIF قبل حذف البيانات؟ | thumbnail في libvips |
| الألوان | هل تحول إلى sRGB قبل حذف ملف الألوان؟ | --export-profile srgb |
| الخصوصية | هل تخلو المقاسات العامة من GPS والبيانات الوصفية؟ | keep=none، والأصل في مكان خاص |
| المقاسات | هل تشتق المقاسات من نسخة مصغرة واحدة؟ | 1600 ثم 800 ثم 320 بصيغة WebP |
| الموارد | هل حد المكتبة أقل من حد ال Container، ومع حد للزمن؟ | 4 مهام متوازية، 1 جيجابايت لل Container، 30 ثانية للمهمة |
| العزل | هل يعمل ال Worker بلا root وبلا شبكة عامة وبنظام ملفات للقراءة فقط؟ | نعم، مع مجلد مؤقت واحد قابل للكتابة |
| التحديث | هل تفحص ال Image وتعيد بناءها دورياً؟ | Trivy في كل بناء، وإعادة بناء أسبوعية |
| القياس | هل تعرف زمن الصورة وذاكرتها على سيرفرك أنت؟ | السكربت في تجربة 24 رفعاً على عينة من صور المستخدمين |
الخلاصة
- معالجة الصور على السيرفر خط ثابت لكل صورة: تحقق، ثم تدوير وتحويل ألوان، ثم تنظيف، ثم مقاسات، ثم حفظ، والترتيب جزء من الصحة وليس تفصيلاً.
- ImageMagick يفك الصورة كاملة إلى ذاكرة البكسلات بدقة 16 بت للقناة، لذلك تحتاج الصورة الكبيرة أضعاف حجم ملفها في الذاكرة، وlibvips يمرر الصورة أشرطة عبر خط عمليات حسب الطلب، فتبقى ذاكرته صغيرة مهما كبرت الصورة.
- في التجربة: البانوراما 195 ميجابكسل أخذت 43 ثانية و1.26 جيجابايت في ImageMagick دون تلميح، و5 ثوان و175 ميجابايت في libvips، ومع
jpeg:sizeصار ImageMagick بنفس سرعة libvips، ومع صور الهاتف كان الزمن متقارباً والذاكرة ضعفين. - ترتيب الخطوات يؤثر أكثر من المكتبة: صناعة المقاسات من الأصل في كل مرة ضاعفت الزمن ثلاث مرات.
- حد ال Container دون حد مطابق داخل ImageMagick يحول الصورة الكبيرة إلى عملية مقتولة، والقنبلة الصغيرة تمر من أي حد لحجم الملف، لذلك ضع حداً للبكسلات من الترويسة وحداً للزمن.
- مكتبة الصور أخطر كود يقرأ مدخلات المستخدمين في تطبيقك، كما أثبتت ImageTragick، فاجعل حولها طبقات: تحقق، وقائمة صيغ، وحدود، وعزل، وتحديث.
وفي المقال التالي من نفس المرحلة نأخذ نفس المحرك libvips إلى خدمة تصغير وتحويل عند الطلب مع imgproxy وThumbor، حيث يطلب المتصفح المقاس الذي يريده في الرابط نفسه، وهذه مقالات قريبة من الموضوع:
دليلصيغ الصور: كيف تختار الصيغة المناسبة لموقعككيف تحفظ كل صيغة صور البكسلات والألوان، ولماذا يصغر الملف نفسه إلى الثلث أو أقل عندما تختار الصيغة والمقاس المناسبين.
دليللماذا يفشل رفع الملفات الكبيرة؟ وأين تعدل حد الحجميمر الملف المرفوع بعدة طبقات لكل منها حد للحجم والزمن، فكيف تعرف الطبقة التي رفضته من شكل الخطأ وأين تعدل حدها.
دليلS3 من داخل تطبيقك: الصلاحيات والروابط الموقعة والتكلفةكيف يرفع تطبيقك الملفات إلى S3 بمفتاح محدود وروابط موقعة، ويحمي الملفات من الكتابة فوقها، ويتجنب البنود التي تضخم الفاتورة.
دليلطوابير المهام فوق Redis وValkey: متى تضيع المهمة؟كيف تبني طابور مهام فوق Redis أو Valkey بالقوائم والتدفقات، وأين تضيع المهام عند توقف العامل أو امتلاء الذاكرة.
دليلكيف تعمل شبكة توصيل المحتوى CDN وما الذي تحفظهلماذا يبطؤ موقعك على الزائر البعيد، وكيف تقرب شبكة CDN ملفاتك منه، وكيف تمنعها من حفظ الصفحات الخاصة.
دليلفحص ال Docker Images وملفات البنية ككود IaCماذا يرى فاحص الثغرات داخل ال Docker Image وما الذي يفوته، وكيف تختار Base Image أصغر وتجعل الفحص بوابة في خط CI.سجل التحديثات
- أكتوبر 2026: كتابة المقال، وتنفيذ التجربة على libvips 8.16.1 وImageMagick 7.1.1-43 Q16 وPillow 11.1.0 وExifTool 13.25 من حزم Debian 13 في Docker Engine 29.8.0.