Вход на сайт

Просмотр новости

Найдите то, что Вас интересует

Приложение течёт, а утечки нет: как оптимизатор картинок Next.js съел 4-гигабайтный VPS

Дата публикации: 13-08-2026 18:11:30

История про то, как я три дня искал утечку памяти в Node-приложении, не нашёл — и был прав, что не нашёл. А ещё про то, что у каждого починенного пункта тут оказывался свой счёт: фикс памяти через месяц вернулся жалобой на скорость, а разбор скорости вскрыл третью проблему, о которой я не думал вовсе. Читать далее

Основное содержимое страницы с новостью.

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

Симптом

Небольшой VPS: 4 ГБ RAM, около шести Next-приложений и Postgres на одной машине. Одно из приложений — интернет-магазин на Next.js 15 (App Router) — ведёт себя не как соседи:

iwant-next.service    RSS 1,38 ГБ   (пик по systemd 3,0 ГБ)соседние приложения   RSS 200–250 МБ в простое

соседние приложения RSS 200–250 МБ в простое

За трое суток аптайма RSS полз 225 → 440 → 848 → 1 380 МБ. Свободной памяти на машине оставалось около 550 МБ, и сервер начинал уходить в свап — то есть тормозило уже всё, а не только магазин.

Выглядит как классическая утечка: монотонный рост, рестарт помогает, через день всё повторяется.

Первая развилка: а где, собственно, течёт

Прежде чем читать свой код, я снял heap-снимки V8.

RSS:        225 МБ → 440 МБ → 848 МБV8 heap:    116 МБ → 116 МБ → 116 МБ

V8 heap: 116 МБ → 116 МБ → 116 МБ

Куча стабильна. Растёт то, что находится вне кучи V8, — нативная память.

Это сразу отменяет весь список привычных подозреваемых: замыкания, глобальные кэши, подписки без отписки, залипшие таймеры. Ничего из этого не могло бы вырасти в гигабайт, не отразившись на heap-снимке. Значит виноват нативный аддон — а их в типичном Next-приложении по сути один.

sharp. Обработчик /_next/image.

Кто его дёргает

Дальше — журнал nginx за сутки. Не «посмотреть глазами», а посчитать: кто ходит и куда.

meta-externalagent          42% всех запросов/_next/image                49% всех запросов  (13 894 за сутки)

/_next/image 49% всех запросов (13 894 за сутки)

Краулер Meta обходил магазин и на каждой карточке добросовестно тянул все изображения через оптимизатор. Не атака, не бот-сеть — обычный обход, который по договорённостям всех устраивает. Просто он попадал ровно в самое дорогое место приложения.

Механизм дальше такой. Каждый промах кэша оптимизатора — это новый вызов sharp, то есть libvips, то есть выделение и освобождение крупных буферов в нативной куче. glibc в многопоточном процессе раздаёт такие аллокации по нескольким аренам, и при постоянном потоке разноразмерных буферов арены фрагментируются: память освобождена с точки зрения аллокатора, но операционной системе не возвращена. RSS растёт, free внутри процесса ничего не показывает, и снаружи это неотличимо от утечки.

Тут стоит остановиться на выводе, ради которого я и пишу: утечки в коде приложения не было. Ни одной строки бизнес-логики править не пришлось. Течёт не программа, а стык «оптимизатор изображений + аллокатор + краулер».

Что помогло

Четыре меры, от самой дешёвой к самой скучной.

1. MALLOC_ARENA_MAX=2. Одна переменная окружения в юните systemd. Ограничивает число арен glibc и тем самым убирает мультиареновую фрагментацию — классическое лекарство именно для sharp. Это дало основной эффект.

2. VIPS_CONCURRENCY=1. Одна нить libvips на операцию. Пиковое потребление на кадр падает кратно. Запомните этот пункт — он вернётся во второй половине статьи и укусит.

3. Лимит памяти на юнит.

MemoryHigh=800MMemoryMax=1GRestart=always

MemoryMax=1G

Restart=always

Это не лечение, а предохранитель: даже если что-то опять поедет, оно не утянет за собой Postgres и соседние сайты. MemoryHigh начинает давить на процесс мягко, MemoryMax — жёсткая граница.

4. Ограничение частоты на /_next/image в nginx.

limit_req zone=nextimg burst=40 nodelay;

Зона на 10 запросов в секунду, но с большим всплеском. Здесь важна именно щедрость burst: на странице каталога тринадцать изображений, и реальный посетитель открывает их пачкой. Жёсткий лимит без всплеска сломал бы живым людям то, ради чего всё затевалось.

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

5. minimumCacheTTL: 2678400 (31 день) в next.config.ts. Самая недооценённая строка: раз уж кадр один раз пережат, пусть повторные обращения — хоть краулера, хоть человека — приходят в кэш и не будят sharp вовсе. Это ничего не меняет в том, что отдаётся; меняется только то, как долго переиспользуется уже готовое.

RSS после этого держится в районе 300–430 МБ под нагрузкой.

Счёт пришёл через месяц

Дальше начинается вторая часть, которую я люблю больше первой.

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

Гипотеза «на карточке 33 запроса в полный размер» — неверна: srcset на месте, полная лестница из 8–9 ширин, браузер тянет 750w, а w=3840 это src-фолбэк, до которого дело не доходит.

Гипотеза «кэш подрезается на каждом деплое» — механизм подтвердился, но интереснее оказался масштаб. Скрипт подрезки удалял файлы (find -type f), оставляя папки записей. На диске лежало 12 748 пустых записей кэша, все созданы тремя вчерашними деплоями. Пустая запись — это гарантированный промах: оптимизатор считает, что записи нет, и снова зовёт sharp.

А настоящая картина была такая:

выборка первого экрана

промахов кэша

время кадра

мужской каталог

6 из 7

0,68–1,46 с

женский / обувь / аксессуары

14 из 15

0,72–2,23 с

итого

20 из 22 (91%)

среднее ≈ 1,07 с

Тёплый повтор тех же кадров — 0,25–0,45 с, где 0,25 с это мой сетевой пол (столько же отдаётся сам HTML). Значит холодный кадр стоит плюс 0,7–1,2 секунды чистого пережатия.

И вот здесь возвращается пункт 2 из списка мер. VIPS_CONCURRENCY=1 означает, что кадры пережимаются по одному, последовательно. Тринадцать холодных кадров на первом экране выстраиваются в очередь. Ровно то, что чинило память, создало жалобу на скорость.

Это не значит, что решение было неверным. Это значит, что у него была цена, и я узнал её на месяц позже, чем стоило.

Третья находка: AVIF стоит вдвое дороже, чем кажется

Заодно померил то, о чём вообще не думал. В конфиге стояло:

formats: ["image/avif", "image/webp"]

Chrome присылает Accept: image/avif, то есть большинство живых пользователей получали именно AVIF. Один и тот же кадр, одна ширина, холодный, два разных Accept, пять товаров:

AVIF

WebP

среднее время

1,124 с

0,545 с

суммарный вес пяти кадров

193 КБ

226 КБ

AVIF вдвое дороже по времени и экономит 15% веса. На экране из тринадцати холодных кадров это примерно +7,5 секунды последовательного пережатия ради экономии 30 КБ, которые на мобильной сети стоят десятые доли секунды.

При этом вес и не был проблемой: мобильный каталог целиком — 13 картинок, 245 КБ. Мы платили секундами за килобайты, которых никто не просил.

Второй эффект тоньше: каждый формат — отдельная запись кэша. AVIF удваивал рабочий набор: 1 530 кадров × 9 ширин × 2 формата ≈ 27,5 тысяч записей вместо 14 тысяч. Кэш упирался в потолок и начинал подрезаться — то есть AVIF своими руками увеличивал долю промахов, из-за которых и был дорог.

Убрали AVIF — одна строка. Холодный кадр подешевел вдвое, рабочий набор кэша уменьшился вдвое.

Я не утверждаю, что AVIF плох. Я утверждаю, что на машине, где libvips намеренно зажат в одну нить, выбор формата — это не вопрос сжатия, а вопрос очереди. Дефолт, который прекрасно работает на четырёх ядрах без ограничений, здесь работал против нас.

Структурное решение

Всё перечисленное — смягчение. Пока sharp стоит в горячем пути запроса, первый посетитель всегда платит за пережатие.

Настоящее решение — вынести лестницу изображений из рантайма: сгенерировать варианты заранее и отдавать плоским srcset со статики, а оптимизатор из пути убрать совсем.

Масштаб: 18 473 оригинала → 112 952 файла, 5 251 МБ. Генерация шла на том же VPS, с MALLOC_ARENA_MAX=2, отключённым внутренним кэшем sharp и одной нитью libvips на операцию: RSS держался в 300–430 МБ, сайт всё время оставался живым. Это, кстати, лучшее доказательство, что диагноз был верен — та же библиотека, тот же объём работы, но с правильными настройками аллокатора память не растёт.

Методическая находка, стоившая двух проверок

Напоследок — про измерения, потому что здесь я обманул сам себя ровно так, как это обычно и происходит.

Проверяя полноту сгенерированной лестницы, я бил HTTP-запросами по CDN и получил 7 «дыр» на 80 файлах. Добавил ретраи — осталось 4 на 50. Начал разбираться с конкретными: каждый «отсутствующий» файл при одиночном запросе отдаётся с кодом 200 и правильным размером, и в листинге хранилища все они есть.

CDN просто режет очередь из сотен HEAD-запросов подряд, а мой аудит считал любой не-200 отсутствием файла.

Для боевой отдачи это не проблема — браузер запрашивает один-два варианта на картинку в обычном темпе, а не четыреста подряд. Но как метод проверки burst-HEAD не годится: верить надо листингу хранилища.

Там же выяснилось, что и листинг умеет врать по-своему. Обход всего префикса работал, пока объектов было около двадцати тысяч, а на ста восемнадцати тысячах перестал доходить до конца: девять попыток подряд обрывались на нулевом ключе, и возобновлять было не с чего. Листинг по папкам товаров отдал те же 114 644 ключа за минуты и ни разу не сбоил.

Что я забираю с собой

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

У Node-приложения с картинками ровно один такой аддон. Прежде чем читать бизнес-логику, посмотрите, кто и с какой частотой дёргает оптимизатор изображений. Ответ лежит в журнале nginx, а не в профайлере.

MALLOC_ARENA_MAX — первое, что стоит попробовать, а не последнее. Одна переменная окружения против трёх дней чтения кода.

Краулер — часть нагрузки, а не аномалия. 42% запросов от одного обходчика это не повод его блокировать, это повод узнать, что у вас самое дорогое место открыто наружу без ограничений.

У каждого фикса есть цена, и её надо искать заранее. VIPS_CONCURRENCY=1 починил память и через месяц вернулся жалобой на скорость. Если бы я записал «замерить время отдачи кадра» сразу после правки, месяца бы не потерял.

Дефолты фреймворка настроены не на вашу машину. AVIF в списке форматов — разумный дефолт для сервера с запасом ядер и полный проигрыш там, где кодировщик намеренно зажат.

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

Если у вас Next.js на скромном VPS и RSS ползёт вверх без объяснений — расскажите в комментариях, чем кончилось у вас. Мне интересно, насколько типична связка «оптимизатор изображений плюс обходчик».

Схожие новости

#Наименование новостиТональностьИнформативностьДата публикации
1Немного про «утечки» памяти: почему PM2 перезапускал Next.js-воркеры и при чём здесь gcTime TanStack Query013.8510-08-2026
2Вторая копия Vue: как лишняя строка в lockfile повесила Chromium06.7709-08-2026
3Почему Google не индексирует страницы, хотя технически всё в порядке0528-06-2026
4Ваш кэш в Redis неэффективен, что с этим делать?-17.9610-08-2026
5Антиспам для WordPress, часть 2: что успело поменяться09.5912-08-2026
6In-memory база врёт: 5 расхождений с продовой БД0707-07-2026
7Добавили потоков, стало медленнее: разбираемся с ложным разделением кеш‑линии09.2610-08-2026
8Восемь раз я думал, что сломал код. Это была платформа07.0113-08-2026
9[Перевод] Структуры данных на практике. Глава 16: Фильтры Блума и вероятностные структуры данных0728-06-2026
10Анти‑кейс. Как создать технически идеальный сайт на Next.js про ИИ и нейросети и остаться без поискового трафика0528-06-2026

Классификация: Мнения. Схожих патентов: 0. Схожих новостей: 10. Тональность: 0. Информативность: 6.97. Источник: habr.com.