Утром выходит новая коллекция: сорок карточек товаров, у каждой по пять фотографий. Рассылка ушла, первые посетители открывают каталог, и график CPU поднимается раньше, чем в аналитике заметен рост заказов. Страницы при этом лёгкие, база почти молчит, а процессор занят. Причина часто в том, что картинки в каталоге новые, и сервер впервые делает из каждой оригинальной фотографии те варианты, которые попросили браузеры. Эта статья о том, где проходит граница, после которой оптимизация изображений перестаёт быть приятной мелочью внутри приложения и становится отдельной серверной нагрузкой.
Один файл на входе, десятки файлов на выходе
Фотограф отдал вам один исходник: например, JPEG шириной 4000 пикселей. Для разработчика это одна картинка, для сервера это сырьё. Браузер на телефоне попросит узкую версию, ноутбук с плотным экраном попросит широкую, а один и тот же адрес может запрашиваться в разных форматах. Компонент Image в Next.js строит адреса вида «исходный URL плюс ширина плюс качество», и каждая такая комбинация является самостоятельным результатом. Исходник один, а вариантов по числу сочетаний.
Посчитаем на условном примере, числа выбраны для наглядности. В каталоге 200 товаров, у каждого по 4 фотографии, итого 800 исходников. Компонент отдаёт браузеру набор из 6 ширин, а клиенты запрашивают в основном 2 формата. Получается 800 × 6 × 2 = 9600 потенциальных результатов. Не все из них будут запрошены: телефонные посетители никогда не попросят самую широкую версию. Но верхняя граница работы определяется именно этим произведением, а не количеством файлов в папке с загрузками.
Отсюда первое практическое следствие: нагрузка зависит не от веса исходников на диске, а от числа различных запросов вида «этот файл, такая ширина, такое качество». Если вы добавили ещё одну ширину в конфигурацию, число вариантов выросло на одну шестую даже при том, что в каталоге не появилось ни одной новой фотографии.
Умение библиотеки писать WebP и AVIF тут тоже играет роль. Sharp, на которой обычно строится обработка, поддерживает вывод в WebP и AVIF наряду с другими форматами, а по результату операции можно узнать формат, размер, ширину и высоту (Sharp: Output options). Это хорошо для размера ответа, но каждый дополнительный формат означает ещё одну кодировку той же картинки. Какой из форматов тяжелее для процессора на ваших фотографиях, заранее сказать нельзя: это зависит от содержимого, размеров и настроек качества, поэтому такое сравнение стоит делать собственным замером, а не по общему впечатлению.
Холодный запрос и повторный: две разные профессии сервера
Возьмём одну карточку и один запрос к оптимизатору: исходник, ширина 640, качество по умолчанию. Посмотрим, что делает сервер.
Холодный запрос, когда такого результата ещё нет в кеше:
- обработчик принимает адрес и проверяет, разрешён ли источник;
- скачивает оригинал целиком, то есть выполняет собственный исходящий запрос;
- декодирует изображение в память в разжатом виде;
- уменьшает его до нужной ширины;
- кодирует в выбранный формат;
- записывает результат в кеш и отдаёт клиенту.
Повторный запрос к тому же варианту проходит короче: найти файл в кеше, проверить срок жизни, отдать. Никакого декодирования и кодирования, процессор почти не участвует, работа сводится к чтению и передаче байтов.
| Этап | Холодный запрос | Повторный запрос |
|---|---|---|
| Получение оригинала | да, сетевой запрос к источнику | нет |
| Декодирование в память | да, размер зависит от пикселей, а не от килобайт файла | нет |
| Изменение размера и кодирование | да, основная нагрузка на CPU | нет |
| Чтение с диска | запись результата | чтение готового файла |
| Что растёт у сервера | CPU, пиковая память, исходящий трафик к источнику | диск и сеть к клиенту |
Обратите внимание на строку про память. JPEG в 700 КБ после декодирования занимает в оперативной памяти место, определяемое числом пикселей: снимок 4000×3000 это 12 миллионов пикселей, и при четырёх байтах на пиксель получаем порядка 48 МБ только на несжатый буфер одного изображения. Это арифметика, а не измерение вашего сервера, реальный расход зависит от версии библиотек и режима работы, но порядок величины важен: десять параллельных обработок таких снимков дают сотни мегабайт временного расхода при том, что в кеше потом окажутся небольшие файлы.
Иными словами, холодный запрос работает как маленькая задача обработки, а повторный как обычная раздача статики. Держать их в голове нужно по отдельности, потому что на графиках они выглядят как две непохожие нагрузки.
Что происходит, когда десятки карточек открываются сразу
Теперь вернёмся к утру с новой коллекцией. Посетитель открывает страницу каталога, на которой 24 карточки. Если коллекция новая, ни одного готового варианта в кеше нет. Браузер раскладывает страницу и запрашивает картинки, которые попали в область видимости или близки к ней. Допустим, сразу идёт 12 запросов на оптимизацию. Один посетитель создаёт 12 холодных обработок, а если за короткое время пришло двадцать человек, то запросов может быть несколько сотен.
Важная деталь: разные посетители с разными экранами просят разные ширины, поэтому прогревают разные варианты. Первый человек с телефоном заполнил кеш узкими версиями, но следующий с ноутбуком по-прежнему попадает на холодные запросы. Кеш прогревается не по числу посетителей, а по числу различных сочетаний ширины и формата, которые встречаются в вашей аудитории.
Одновременность тут важнее среднего значения. На графике за сутки средняя загрузка CPU может выглядеть спокойно, а в первые минуты после публикации процессор упирается в потолок: ядер мало, обработок много, запросы выстраиваются в очередь. Страница начинает отдаваться медленнее, и вместе с картинками страдает всё остальное, если процесс оптимизации делит ядра с рендерингом страниц. Человек видит не «медленные картинки», а «медленный сайт».
Скорость диска тут не поможет. Запись результатов в кеш действительно идёт на накопитель, и NVMe справляется с ней легко, но узкое место холодной обработки не в записи: оно в декодировании и кодировании, то есть в вычислениях. Быстрый накопитель ускоряет чтение уже готовых файлов и скорость записи, а упор в процессор не снимает. Если график показывает загрузку всех ядер в моменты первых запросов, а ожидание диска небольшое, переходить на другой тип хранилища бессмысленно.
Как отделить картинки от рендеринга в измерениях
Чтобы понять, кто именно ест процессор, нужны замеры, которые различают два вида работы. Вот что полезно собрать на VPS, не меняя приложение.
- Загрузка по ядрам во времени. Утилиты вроде top или htop покажут, что процесс Node занял все ядра. Само по себе это не говорит, SSR это или картинки, поэтому нужен второй срез.
- Разбор журнала по адресу. Запросы к оптимизатору в Next.js идут по пути
/_next/imageс параметрами адреса, ширины и качества. Подсчитайте, сколько таких запросов пришло в минуту и сколько из них уникальных. Доля уникальных сочетаний и есть число холодных запросов. - Время ответа отдельно для картинок и страниц. Если страницы отдаются быстро, а картинки при первом обращении долго, нагрузка в обработке изображений. Если замедляются и те и другие, процессор делится между ними.
- Пиковая память процесса. Смотрите не среднее, а максимум за интервал публикации. Резкий подъём на десятки или сотни мегабайт, совпадающий с пачкой новых запросов к картинкам, указывает на декодирование.
- Исходящий трафик к источнику. Если оригиналы лежат на другом хосте, холодный запрос ещё и скачивает файл целиком. Рост входящего трафика сервера при прогреве показывает, сколько байтов оригиналов пришлось забрать.
Хороший эксперимент для разделения причин: временно запросить страницу каталога с уже прогретым кешем, затем со сброшенным и сравнить загрузку CPU. Рендеринг страницы одинаков, меняется только состояние кеша картинок. Разница между двумя графиками и есть цена оптимизации изображений на вашем сервере и на ваших фотографиях.
Получив эту разницу, вы можете оценить запас по формуле. Допустим, замер показал, что одно ядро обрабатывает в среднем N холодных запросов в секунду (N измеряется на ваших изображениях, универсального числа нет). Тогда для пачки из M запросов, пришедших за время T, нужная мощность примерно M / T / N ядер. Если результат больше числа ядер, которые можно отдать картинкам, очередь неизбежна, и вопрос только в том, насколько она будет заметна для посетителей.
Источник изображения и набор размеров: откуда берётся работа
Нагрузку создают не только посетители. Её определяют два списка в конфигурации: откуда разрешено брать оригиналы и какие размеры оптимизатор готов делать.
Внешние источники в Next.js ограничиваются параметром remotePatterns (Next.js: Image component). Он описывает, с каких хостов и по каким путям оптимизатор вообще согласен загружать оригиналы. Разберём это на двух запросах.
Допустим, в паттернах разрешён хост cdn.shop.example с путём /catalog/**. Запрос на оптимизацию картинки https://cdn.shop.example/catalog/dress-01.jpg подходит под правило: обработчик скачает файл, сделает вариант и закеширует. Это ожидаемая работа для каталога.
Теперь запрос на https://other.example/huge/photo.tiff. Хост не входит в разрешённые, обработчик откажет и не станет скачивать файл. Без такого ограничения любой желающий мог бы подставлять в адрес чужие тяжёлые файлы и заставлять ваш сервер скачивать и пережимать их, расходуя процессор и трафик за ваш счёт. Ограничение источников защищает не вкус, а бюджет ядер.
Чем шире шаблон, тем больше потенциальной работы. Разрешение вида «весь хост, любой путь» пускает в обработку и каталог, и служебные файлы, которые вы не собирались оптимизировать. Если оригиналы лежат в одном каталоге, шаблон лучше сужать до него.
Есть ещё одна особенность, о которой полезно знать, если оригиналы закрыты авторизацией: стандартный загрузчик Image Optimization не пересылает заголовки авторизации при получении оригинала. Приватную картинку через него взять не получится, и для таких случаев нужен другой путь доставки, например подписанные ссылки на стороне хранилища или собственный загрузчик. Заметьте: это уже меняет схему обработки, и поведение стандартного загрузчика на неё переносить нельзя.
Второй список это набор ширин. По умолчанию их несколько, и каждый браузер выбирает ближайшую подходящую. Каждая лишняя ширина умножает число вариантов, как в расчёте выше. Для фотографий товаров, которые на сайте показываются в двух-трёх реальных размерах, десять ширин в списке дают десять шансов на холодный запрос там, где хватило бы трёх. Сократив список до размеров, которые действительно встречаются в вёрстке, вы напрямую уменьшаете число обработок при прогреве.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.
Ключи кеша и срок жизни: что хранится и сколько
Результат оптимизации кешируется, и понимать, по чему его находят, важно по тем же причинам, по каким важно понимать ключ любого кеша. Различные сочетания адреса оригинала, ширины и качества дают разные записи, как мы уже видели. Если в приложении меняется адрес src, это новая запись, и кеш прогревается заново.
Срок жизни определяется так: берётся большее из значения minimumCacheTTL и значения max-age в заголовке Cache-Control, который прислал источник оригинала (Next.js: Image component). Практический смысл у этого правила двойной.
- Если источник отдаёт оригиналы с коротким
max-age, аminimumCacheTTLзадан большим, результат живёт дольше, чем хочет источник: это удобно для разгрузки CPU. - Если источник присылает длинный
max-age, он перебивает короткийminimumCacheTTL. Вы не сможете сократить срок меньше, чем указал источник, просто уменьшив настройку.
Что это значит для нагрузки: чем дольше живёт запись, тем реже повторяется дорогая холодная обработка. Но у длинного срока есть цена. Документация на дату проверки не предлагает встроенного механизма сбросить такой кеш по команде, и обновление картинки при неизменном адресе придёт к посетителям только после истечения срока. Рабочая стратегия обновления это изменение src, например добавление версии или хеша содержимого к имени файла. Тогда старая запись остаётся лежать до своего срока, а новый адрес порождает новую обработку.
У этой стратегии свой побочный эффект, и его надо учитывать в нагрузке. Каждая замена фотографий по новому адресу означает новую порцию холодных запросов. Если вы обновляете всю коллекцию, обновляете и все варианты. Поэтому имеет смысл менять адреса точечно, только у изменившихся изображений, а не перегенерировать имена у всего каталога заодно.
Вторая сторона вопроса: кеш занимает место на диске. Каждый вариант это файл, и при 9600 потенциальных результатах из нашего примера объём может оказаться заметным. Здесь пригодится быстрый накопитель, ведь именно он ускоряет чтение множества небольших файлов. Но объём нужно считать и следить за ним, а не надеяться, что он сам по себе не вырастет.
Три способа снизить нагрузку и чем они отличаются
Когда замеры показали, что картинки действительно создают пиковую нагрузку, есть три направления. Они решают разные проблемы, и выбирать между ними нужно по тому, что именно показали измерения.
Ограничить число вариантов
Самый дешёвый шаг: сократить список ширин до реально используемых, зафиксировать качество, не плодить значения параметров по случайным причинам, сузить remotePatterns. Всё это уменьшает число уникальных запросов и, соответственно, холодных обработок. Тут нет новых компонентов, и риск минимален. Подходит, если пики короткие и связаны именно с избыточным числом вариантов.
Ограничение работает и против дублей: если одну и ту же картинку в разных местах запрашивают с разным качеством, получается несколько записей вместо одной. Единые значения для одинаковых по смыслу блоков сокращают число различных результатов без потери в виде.
Подготовить варианты заранее
Если известно, какие картинки появятся утром, их можно прогреть до публикации. Простейший способ: скриптом обратиться к нужным адресам оптимизатора с ожидаемыми ширинами в спокойное время, пока посетителей нет. К моменту рассылки кеш уже заполнен, и первые посетители получают повторные запросы вместо холодных. Нагрузка не исчезает, а переезжает на удобное время.
Другой вариант подготовки: сделать нужные размеры на этапе загрузки изображения в каталог. Здесь пригодится Sharp напрямую: приложение само решает, какие форматы и размеры создать, а Sharp в результате операции сообщает формат, размер, ширину и высоту, что удобно для записи метаданных. При этом за каталоги и права записи отвечает приложение (Sharp: Output options). Готовые файлы затем раздаются как статика, и оптимизатор по запросу не участвует. Цена такого подхода: у вас появляется собственный конвейер, который надо поддерживать, и набор размеров приходится решать заранее, а не по требованию браузера.
Обратите внимание, что при отдаче готовых файлов через собственный загрузчик или с отключённой оптимизацией путь обработки меняется. Поведение стандартного загрузчика с его кешем и правилами срока жизни на этот путь переносить нельзя: нужно отдельно проверить, кто и как кеширует результат.
Вынести обработку в отдельный обработчик
Если пики повторяются, а объём картинок велик, обработку стоит отделить от приложения: отдельный процесс, а чаще отдельный VPS, который занимается только изображениями. Тогда всплеск холодных запросов упирается в ядра обработчика, а страницы продолжают рендериться на своих. Это отсекает главную неприятность: картинки больше не отнимают процессор у остального сайта.
Технически приложение указывает адрес обработчика через собственный загрузчик, а обработчик скачивает оригиналы, делает варианты и держит свой кеш. Перед этим нужно решить, кто отвечает за ограничение источников и размеров: такая защита, которую в стандартном варианте даёт remotePatterns, на отдельном сервисе должна быть настроена заново. Иначе вы создадите открытый сервис пережимания чужих файлов.
Отдельный сервер не уменьшает работу, он её переносит. Суммарный расход CPU на кодирование остаётся тем же, меняется только то, чьи ядра он занимает. Если оптимизация просто стала реже вызываться за счёт ограничения вариантов, второй сервер может оказаться лишним. Поэтому порядок такой: сначала сократить число вариантов, затем прогреть, и только если пики остаются, выделять отдельный обработчик.
Как выбирать ресурсы VPS под обработку картинок
Ресурсы выбираются по двум измеренным величинам: сколько ядер нужно в пик и сколько памяти занимают параллельные обработки. Дополнительный диск вторичен, хотя кеш должен где-то лежать.
Если оптимизатор живёт вместе с приложением, ему нужны ядра сверх тех, что съедает рендеринг. На тарифе с одним vCPU, как у Ba (1 vCPU, 2 ГБ RAM, 40 ГБ NVMe), любая пачка холодных запросов делит единственное ядро с самим приложением. Для небольшого каталога с редкими обновлениями этого может хватить, но проверять нужно замером на своих изображениях, а не по описанию тарифа. Uab (2 vCPU, 4 ГБ, 80 ГБ) и Heka (4 vCPU, 8 ГБ, 160 ГБ) дают больше параллелизма, и тогда один длинный пик не обязательно блокирует всё. Maat (8 vCPU, 16 ГБ, 240 ГБ) и Djed (16 vCPU, 32 ГБ, 320 ГБ) рассматриваются тогда, когда расчёт по формуле выше показал, что нужно много ядер, или когда отдельный сервер обработки должен переваривать большие пачки. Это ресурсы, а не обещание ёмкости: сколько запросов выдержит конкретный сервер, покажут ваши замеры.
Память стоит планировать по пику. Если десять одновременных обработок крупных фотографий требуют, по оценке выше, сотен мегабайт, то 2 ГБ на машине, где живут ещё и Node-процесс, и система, дают небольшой запас. Нехватка памяти при декодировании это не замедление, а риск аварийного завершения процесса, поэтому лимит параллельности и размер исходников важны не меньше числа ядер.
Диск на VPS MAATRIX это NVMe, и он полезен тем, что быстро отдаёт множество небольших файлов кеша. Но, как уже сказано, он не лечит упор в процессор. Отдельная ремарка: root-доступ позволяет следить за нагрузкой привычными утилитами и настроить лимиты процессов, а расположение серверов (в публичном предложении указаны UK, US, RU и FR) стоит выбирать ближе к посетителям и источнику оригиналов, потому что холодный запрос ещё и скачивает файл.
Если вы переезжаете с платформы, где оптимизация изображений была скрыта за счётом, и теперь смотрите, как перенести её на собственный сервер, подробнее про изображения, кеш и функции при таком переезде рассказано в материале Переезд с Vercel на VPS: Next.js, изображения, кеш и функции.
Рабочий порядок действий для каталога
Соберём всё в последовательность, которую можно пройти за несколько вечеров.
- Выпишите, какие ширины и форматы реально встречаются в вёрстке. Всё остальное уберите из конфигурации.
- Сузьте
remotePatternsдо хоста и каталога с оригиналами. - Проверьте заголовки Cache-Control у источника и подберите
minimumCacheTTL, помня, что действует большее из двух значений. - Решите, как будете обновлять картинки: если адрес остаётся прежним, обновление дойдёт только после срока жизни, поэтому при замене файла меняйте
src. - Снимите два графика CPU и памяти: с холодным и прогретым кешем на одном и том же каталоге.
- Если пик остаётся ощутимым, прогрейте кеш скриптом до публикации.
- Если и после этого пики мешают страницам, переносите обработку на отдельный сервер с собственной защитой источников.
Каждый шаг уменьшает число холодных запросов или меняет, кто их выполняет, и на каждом шаге есть что измерить. Если шаг не изменил график, значит, причина была в другом месте, и дорогое решение откладывается.
Теперь можно вернуться к утренней коллекции. Сорок карточек, пять фотографий, шесть ширин и два формата дают 2400 возможных вариантов, но реальная аудитория, скорее всего, использует малую часть. Сервер умеет создавать все, посетителям нужны единицы. Сколько из тех размеров, что вы разрешили оптимизатору, действительно увидел хотя бы один человек?