Допустим, в каталоге тысяча товаров. Кажется, что нужно обработать тысячу фотографий. Но у одной карточки обычно не одно фото, а три-пять. Каждое нужно показать в миниатюре для листинга, в среднем размере для плиток, в крупном виде для карточки и, возможно, в увеличенной версии для зума. И каждый размер хочется отдать минимум в двух форматах: WebP и AVIF, а для старых клиентов ещё и JPEG. Число товаров ещё ничего не говорит о числе преобразований. Давайте посчитаем.

Арифметика одного снимка

Возьмём условный пример: в магазине 1000 товаров, у каждого в среднем 4 фото, нужны 4 размера и 3 формата. Получается 1000 × 4 × 4 × 3 = 48 000 производных файлов. Цифры выдуманные, ваши будут другими, но порядок понятен: из тысячи карточек вырастает десятки тысяч задач. Причём это только первая загрузка.

Дальше начинаются причины повторной обработки, и они-то и съедают время сервера:

  • Новый размер. Дизайнер решил, что плитка в каталоге должна быть шире, и нужен ещё один промежуточный размер для всех фото.
  • Новый формат. Вчера хватало JPEG и WebP, сегодня добавляется AVIF. Количество вариантов растёт на треть.
  • Смена параметров качества. Вы решили, что миниатюры слишком мылятся, и повышаете качество.
  • Замена оригинала. Поставщик прислал исправленный снимок, у которого был неудачный фон.
  • Сбой. Обработка упала на середине, часть файлов записана, часть нет.
  • Обновление библиотеки. Кодировщик изменился, и вы хотите пересобрать вариант с новыми настройками.

Если всё это делать прямо в запросе загрузки через админку, сайт будет ждать, пока кодировщик отработает все варианты. Менеджер нажал «Сохранить», а страница крутится минуту. Если загружает сразу пачка менеджеров или идёт импорт из прайса, веб-процессы начинают соревноваться с кодировщиками за процессор и память. Покупатели в этот момент ждут открытия каталога. Поэтому обработку разумно вынести в отдельный процесс с собственной очередью, а веб-часть оставить только принимать файл и отвечать быстро.

Оригинал и производные: два разных мира

Самое важное решение в такой схеме принимается до первой строчки кода: оригинал и производные файлы хранятся по-разному и называются по-разному.

Оригинал — это то, что прислал поставщик или загрузил менеджер. Он нужен как исходный материал. Его нельзя перезаписывать, пережимать «на месте» или удалять после обработки. Производные — это то, что можно в любой момент пересоздать из оригинала, поэтому к ним можно относиться как к кэшу, пусть и долгоживущему.

Практическое следствие: ключ хранения оригинала не должен зависеть от параметров обработки. А ключ производной версии, наоборот, должен их отражать. Пример схемы каталогов:

originals/ab/cd/abcd1234ef56.jpg
variants/abcd1234ef56/w400-q60-v2.avif
variants/abcd1234ef56/w400-q75-v2.webp
variants/abcd1234ef56/w1200-q75-v2.webp

Здесь оригинал назван по содержимому, например по хэшу файла. Это даёт две вещи: одинаковые фото от разных поставщиков не хранятся дважды, а замена файла превращается в новый ключ, а не в тихую подмену. Производные лежат в папке, привязанной к оригиналу, и в имени содержат ширину, качество и версию профиля обработки. Суффикс v2 здесь — номер вашего профиля, а не версия какой-либо библиотеки. Он понадобится, когда вы решите менять алгоритм.

В базе данных магазина удобно держать отдельную таблицу вариантов: идентификатор оригинала, профиль (размер, формат, качество, версия), путь к файлу, фактический размер в байтах, ширина и высота, статус и время создания. Библиотека Sharp умеет возвращать после операции формат, размер файла, ширину и высоту (описание выходных параметров Sharp), так что эти поля можно заполнять из результата конвертации, а не угадывать. Каталоги и права записи при этом остаются заботой приложения: Sharp сам их не создаёт и не настраивает, поэтому пользователь процесса-обработчика должен иметь доступ на запись в папку производных и только чтение для оригиналов.

Право только на чтение оригиналов — недорогая страховка. Если ошибка в коде обработчика когда-нибудь решит «почистить» не тот каталог, оригиналы окажутся защищены хотя бы на уровне прав файловой системы. Это не заменяет резервную копию оригиналов, но снижает вероятность того, что копия вообще понадобится.

Что отдавать сайту, пока производной нет

Пока вариант не готов, страница не должна показывать пустое место или битую ссылку. Нормальный порядок такой: если AVIF готов, отдаём его; если нет, но готов WebP, отдаём WebP; если нет ничего, показываем запасной вариант, например уже существующий JPEG нужного размера или аккуратно уменьшенный оригинал. Магазин не должен ждать идеального формата, чтобы показать товар. Статус в таблице вариантов как раз и позволяет шаблону выбирать лучший из готовых.

Очередь, которая не даёт серверу задохнуться

Теперь про сам процесс обработки. Важно сразу не приписывать лишнего: библиотека конвертации умеет преобразовывать изображения, но не является брокером задач и не хранит очередь. Sharp делает операцию над файлом, а то, откуда приходит задача, сколько их выполняется одновременно и что происходит при падении, решает ваше приложение или отдельная система очередей. В простом магазине это может быть таблица в базе данных со статусами или готовый менеджер очередей. Для начала хватает и таблицы: «ожидает», «в работе», «готово», «ошибка».

Главный параметр очереди — не скорость, а ограниченная параллельность. Одновременно должно выполняться столько задач, сколько выдержит память и процессор, а остальные ждут. Почему именно память? Потому что кодирование изображения требует развернуть его в несырой, несжатой форме. Файл JPEG размером 3 МБ может быть фотографией 6000 × 4000 пикселей. В распакованном виде это 24 миллиона пикселей, и при четырёх байтах на пиксель получается порядка 96 МБ только для самой картинки. Реальный пик выше: кодировщик держит промежуточные буферы, а при ресайзе и конвертации в несколько форматов может понадобиться ещё. Точную цифру для вашего случая назвать нельзя, её нужно измерить на собственных файлах, но порядок стоит прикинуть заранее.

Теперь представьте, что поставщик прислал не просто 6000 × 4000, а панорамный снимок или скан каталога, где 12000 × 9000 пикселей. Это 108 миллионов пикселей, и условный запас по памяти на один такой файл уже измеряется сотнями мегабайт. Если одновременно запущено четыре таких задачи на сервере с 4 ГБ ОЗУ, где живут ещё база данных и веб-сервер, то ядро может начать убивать процессы по нехватке памяти. Магазин упадёт не потому, что картинок много, а потому что несколько тяжёлых совпали по времени.

Из этого следуют три практических правила для очереди:

  1. Ограничьте число одновременных задач. Начните с малого, например с одной-двух, и поднимайте, глядя на реальное потребление памяти и загрузку процессора. Больше потоков не значит быстрее, если упираетесь в память или ядра.
  2. Проверяйте размеры до обработки. Прочитайте заголовок файла: ширину и высоту. Если число пикселей выходит за разумный для магазина предел, отправьте файл в отдельную медленную очередь или отклоните с понятным сообщением менеджеру, а не пытайтесь пережать на общих основаниях.
  3. Делайте один оригинал одной задачей, а не одним форматом. Если из одного оригинала нужно получить двенадцать вариантов, выгоднее прочитать и декодировать его один раз, а затем выпустить все размеры подряд, чем двенадцать раз открывать тот же файл в параллельных задачах.

Третий пункт полезно уточнить. Группировка по оригиналу экономит чтение с диска и повторное декодирование. Но она же означает, что задача длиннее и пик памяти держится дольше. Здесь нет универсального ответа: на мелких фотографиях выигрывает группировка, на очень тяжёлых иногда лучше разбить работу, чтобы после каждого крупного варианта память освобождалась. Решает замер на ваших изображениях.

Откуда брать ресурсы

Обработчик изображений — ровно тот случай, когда выделенный сервер оправдан. Конвертация нагружает процессор пачками, а это плохо уживается с витриной, которой нужен стабильный отклик. Если вынести обработчик на отдельный VPS MAATRIX, магазин на основном сервере не будет делить ядра с кодировщиком. На публичной странице тарифов указаны конфигурации от 1 vCPU и 2 ГБ памяти (Ba) до 16 vCPU и 32 ГБ (Djed), диски NVMe, процессоры AMD EPYC и root-доступ. Это даёт свободу поставить нужные библиотеки и настроить очередь по-своему. Но сами по себе цифры тарифа не говорят, сколько фотографий в минуту вы обработаете: это зависит от разрешения исходников, настроек качества и числа вариантов. Подбирайте размер, глядя на пик памяти самого тяжёлого оригинала и желаемую параллельность, а не на среднюю картинку.

Заодно оцените место на диске. Производные занимают заметный объём: даже если AVIF и WebP в среднем меньше JPEG, вы храните несколько размеров и форматов на каждое фото. Для ориентира сложите размеры оригиналов и умножьте на ожидаемый коэффициент для набора вариантов, который определите на пробной партии.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.

АРЕНДОВАТЬ СЕРВЕР

Когда файл оказался плохим

Рано или поздно в очередь попадёт файл, который не открывается: обрезанная загрузка, переименованный PDF, повреждённый заголовок, формат с экзотическим профилем. Обработчик не должен падать вместе с очередью. Правильное поведение такое: перехватить ошибку, записать в таблицу статус «ошибка» с коротким текстом причины и перейти к следующей задаче. Менеджер потом увидит в админке список проблемных фото и сможет запросить замену.

Различайте два типа сбоев, потому что обращаться с ними нужно по-разному:

  • Постоянная ошибка. Файл битый, формат не распознан, размеры нулевые. Повтор не поможет, нужно пометить задачу как проваленную и не крутить её по кругу.
  • Временная ошибка. Закончилась память на пике, не хватило места на диске, перезапустился процесс, хранилище ответило с задержкой. Здесь повтор имеет смысл, но с паузой и ограничением числа попыток.

Если не разделять их, очередь будет бесконечно пережёвывать одну и ту же поврежденную картинку, занимая слот параллельности, пока остальные ждут.

Прерванная задача и безопасный повтор

Отдельная история — обработчик, убитый посреди записи. Процесс перезапустили, и на диске остался наполовину записанный файл с правильным именем. Если сайт увидит запись «готово» или просто найдёт файл по пути, он отдаст покупателю обрезанную картинку.

Защита здесь проста и не зависит от конкретной библиотеки: записывайте результат во временное имя в той же файловой системе, и только после успешного завершения переименовывайте в окончательное. Переименование в пределах одного раздела выполняется как одна операция, поэтому читатель видит либо старый файл, либо целый новый, но не половину. Статус в таблице вариантов переключайте на «готово» только после этого переименования и только когда известны размер и размеры изображения.

Второй элемент — идемпотентность задачи. Повтор не должен создавать дубли и не должен ломать то, что уже есть. Если ключ варианта детерминирован (оригинал + профиль), то повторная обработка просто перезапишет тот же результат тем же самым способом. Пара «оригинал и профиль» играет роль естественного ключа: в таблице на неё стоит уникальное ограничение, и вторая такая же задача либо находит готовый результат, либо ждёт первую. Идентификатор самой попытки для этого не годится: если на каждую попытку создавать новый случайный номер, система не поймёт, что это та же работа. Уникальность должна определяться содержимым задачи, а не номером запуска.

Третий элемент — подбирать «зависшие» задачи. Если задача в статусе «в работе» уже давно без признаков жизни, скорее всего, обработчик умер. Фиксируйте время начала и обновляйте отметку во время работы; при перезапуске возвращайте старые задачи в ожидание. Срок ожидания выбирайте с запасом на самый тяжёлый оригинал, чтобы не перехватить задачу, которая просто долго работает.

Качество не гарантировано: сравнивайте на своих снимках

Теперь о том, ради чего всё затевалось. Sharp поддерживает вывод и в WebP, и в AVIF (параметры вывода). Но поддержка формата в библиотеке — это возможность, а не обещание. Нельзя заранее сказать, что AVIF для ваших фото окажется меньше WebP в определённое число раз, или что визуальное качество при одинаковом числовом параметре будет одинаковым. Параметры качества у форматов не равны друг другу: «качество 60» в одном кодеке и в другом даёт разный результат. Выигрыш зависит от содержания: у однотонных фонов товарной съёмки одна картина, у тканей с мелкой фактурой, меха, глянца или текста на упаковке — совсем другая. Время кодирования тоже различается, и AVIF не обязан быть быстрее.

Поэтому сравнение нужно проводить на собственной выборке. Рабочий способ:

  1. Возьмите 30–50 реальных оригиналов, которые отражают ваш ассортимент: светлый фон, тёмный, мелкая фактура, текст на упаковке, градиенты, тени.
  2. Выпустите каждый в нескольких значениях качества для каждого формата.
  3. Запишите размеры файлов и время обработки. Размер вам вернёт сам результат операции, время замерьте вокруг вызова.
  4. Посмотрите глазами на нужных размерах, особенно на проблемных участках: края товара, мелкий текст, плавные переходы цвета, где часто проявляются полосы.
  5. Выберите профиль для каждой категории товаров, а не один на весь магазин, если разница существенна.

Результат такого теста — таблица ваших собственных цифр. Её можно держать в таком виде:

Пример формы для замеров (значения заполняются по вашим файлам)
ОригиналФормат и качествоРазмер файлаВремя кодированияВизуальная оценка
Кроссовки, белый фонWebP, ваше значениеизмеритьизмеритьзаметить артефакты у шнурков
Плед, крупная вязкаAVIF, ваше значениеизмеритьизмеритьпроверить фактуру
Упаковка с мелким текстомAVIF и WebPизмеритьизмеритьчитаемость текста

Если на каком-то классе фото AVIF не даёт заметного выигрыша, а кодируется дольше, имеет смысл для этого класса оставить WebP. Это не провал идеи, а нормальный результат измерений.

Меняем алгоритм, пока сайт живёт на старом

Допустим, вы провели замеры и решили изменить профиль: повысить качество миниатюр и добавить новый размер. На сайте уже тысячи карточек, которые используют старые варианты. Пересобрать всё за одну ночь, перезаписав файлы под теми же именами, рискованно: вы потеряете возможность откатиться и создадите пиковую нагрузку.

Вот где окупается версия профиля в ключе. Новые варианты получают другой суффикс, например v3, и лежат рядом со старыми v2. Сайт продолжает отдавать v2, пока для нужной карточки не появился готовый v3. Порядок такой:

  1. Добавьте новый профиль в конфигурацию с новой версией. Старый не трогайте.
  2. Поставьте задачи в очередь с низким приоритетом. Новые загрузки обрабатываются в первую очередь, фоновая пересборка идёт в свободное время и в тех же пределах параллельности.
  3. Переключайте отдачу по готовности. Шаблон выбирает версию v3 там, где запись о ней в статусе «готово», иначе остаётся на v2.
  4. Начните с малого. Пересоберите самые посещаемые товары или одну категорию, проверьте результат глазами и по размерам.
  5. Удаляйте старую версию в последнюю очередь. Только когда все нужные страницы переведены, а кэши браузеров и CDN, если они есть, успели обновиться. Старые ссылки могут жить в чужих кэшах и в выдаче картинок, поэтому спешить с удалением не нужно.

Версионирование решает ещё одну проблему: кэширование. Если имя файла меняется вместе с профилем, браузеру и промежуточным кэшам не нужно ничего сбрасывать. Новый адрес — новый файл, старый спокойно доживает свой срок. Если же перезаписывать файл под тем же именем, часть посетителей ещё долго будет видеть прежнюю картинку, а часть — новую.

Если магазин построен на готовой CMS, например OpenCart, у неё есть собственная логика ресайза и кэша изображений, и перед внедрением внешнего обработчика нужно понять, где именно ваша платформа берёт картинки для страниц. Как поднимать сам магазин, разобрано в материале про установку и настройку OpenCart на VPS; подключение отдельного обработчика обычно сводится к тому, чтобы шаблоны брали адреса из вашей таблицы вариантов, а не из встроенного кэша.

Почему оригиналы решают всё

Вся эта конструкция держится на одном допущении: оригиналы целы. Именно они позволяют менять формат, качество и размеры без обращения к поставщику. Поэтому вложите усилия в их сохранность: отдельное хранилище, доступ только на чтение для обработчика, регулярное копирование за пределы сервера. Не рассчитывайте, что копирование включено по умолчанию на любом VPS; в публичных данных тарифов автоматические резервные копии как общая возможность не указаны, так что настройте их сами и проверьте восстановление на тестовом каталоге.

Производные, напротив, можно не копировать так тщательно: они пересоздаются из оригиналов. Достаточно сохранить таблицу профилей и список, из чего собирались варианты. Если сервер обработки когда-нибудь придётся развернуть заново, вы поставите библиотеку, подключите хранилище оригиналов, запустите очередь, и она неспешно восстановит всё остальное.

Через год появится новый формат или вы решите, что нынешние настройки качества слишком щедрые. Вам не придётся писать поставщикам с просьбой переслать фотографии, искать старые архивы по почте и сопоставлять названия файлов с артикулами. Достаточно добавить профиль, поставить задачи и дать очереди поработать в своём темпе. Смена формата превращается из проекта в настройку, потому что оригиналы остались там, где им и положено быть: нетронутыми.