Vite, OOM и сервер на 4 ГБ: как мы снизили память сборки на 52%
Мы разрабатываем новую версию приложения на Vue. Локально всё собирается, на сервере обновление начинает занимать десятки минут. Один из запусков растянулся примерно на сорок. Затем ловим уже вполне определённую проблему:
Out of memory: Killed process 2454183 (node)
anon-rss: 1584424 kB
Ядро убило Node во время сборки Vite. Новая версия приложения оказалась недоступна.
На первый взгляд всё понятно: маленький сервер, большое приложение, нужно больше памяти. Но прежде чем увеличивать сервер или переносить сборку в CI, мы решили посмотреть, что можно сделать с самой сборкой. Ограничивали память Node, меняли импорты, убирали минификацию, пробовали другой движок. Некоторые изменения выглядели убедительно, пока мы не сравнили цифры.
В итоге на одном и том же приложении получили 802 MiB вместо 1681 MiB пикового RSS и 11,7 секунды вместо 41 секунды. Для этого добавили Rsbuild. Ниже — как к этому пришли и почему нескольких настроек Vite оказалось недостаточно.
На что не хватало четырёх гигабайт
У сервера четыре CPU, около 3,8 GiB физической памяти, доступной системе, и 2 GiB swap. На нём продолжают работать Docker, MySQL и остальные сервисы. Сборка запускается рядом с ними.
Новая версия — приложение на Vue 3 и TypeScript с UI-библиотеками, графиками и редакторами. В наших экспериментах Vite обрабатывал примерно 3700–3900 модулей. Использовали Vite 7.3.6 и Node.js 22.23.3. Кроме новой версии, при обновлении собирается легаси на Nuxt и упаковывается Docker-образ.
Поэтому первым делом пришлось разобраться с теми самыми сорока минутами. У обновления несколько этапов, и по общей длительности нельзя понять, какой из них тормозит.
Начали с обычных инструментов:
free -m
vmstat 1
ps -eo pid,ppid,pcpu,rss,comm --sort=-rss
В последующих наблюдениях установка зависимостей занимала примерно 5–9 секунд. Сборка и упаковка легаси могли работать несколько минут. Vite создавал отдельный пик памяти.
Полной разбивки сорокаминутного запуска у нас нет. Следующие измеренные обновления уже не занимали столько времени, поэтому приписывать все сорок минут Vite мы не можем. Зато один из отказов поймали непосредственно во время его работы: RSS процесса вырос примерно до 1,51 GiB, доступная RAM опустилась до 116 MiB, swap заполнился, затем сработал OOM killer.
При этом ещё до сборки dockerd мог занимать около 900 MiB. База данных и остальные процессы тоже никуда не исчезали. Дополнительные полтора гигабайта для сборщика оказывались существенной нагрузкой.
Заполненный swap сам по себе ещё не показывает, что сервер прямо сейчас тратит время на обмен страницами с диском. Поэтому смотрели и si/so в vmstat. В тяжёлых фазах такой обмен действительно происходил.
Эта картина определила задачу: уменьшить пик сборки и оставить больше памяти работающим сервисам.
Сначала убрали лишнюю нагрузку
До изменений Vite запускался после Docker-сборки легаси. К этому моменту dockerd сохранял высокий RSS, и поверх него появлялся ещё один тяжёлый процесс.
Поменяли порядок: сначала собираем новую версию, затем легаси. Это уменьшает наложение нагрузки, но память самого Vite от перестановки не ограничивается. Один из OOM мы зафиксировали уже после этого изменения.
В легаси обнаружилась другая особенность: Nuxt запускал несколько Terser workers, каждый из которых занимал сотни мегабайт. Ограничили параллелизм через terser.parallel: 1. На небольшом сервере одновременная работа нескольких таких процессов обходится дорого.
Здесь нужно учитывать, какой минификатор используется. Настройку Terser мы меняли в легаси. В нашей конфигурации Vite 7 минификацией занимался esbuild — у него другие параметры. Это разделение есть в документации Vite 7.
В самом Vite отключили расчёт gzip-размеров для итогового отчёта:
build: {
reportCompressedSize: false,
},
Минификация от этого продолжает работать. Убирается дополнительный расчёт сжатых размеров, который на больших файлах тоже требует времени. Описание опции.
Заодно проверили установку зависимостей. Она выглядела вероятным виновником долгого обновления, но в измеренных запусках завершалась за те самые 5–9 секунд, ещё до старта Vite. Оптимизация этого этапа не могла убрать пик следующего.
Все эти изменения имели понятный смысл. Но изолированного замера выигрыша каждой настройки у нас нет, а основной пик Vite остался. Следующим шагом ограничили память Node.
Меньше heap — не всегда лучше
Запустили сборку с лимитом:
node --max-old-space-size=1280 \
./node_modules/vite/bin/vite.js build
В одной из локальных серий пик снизился примерно с 1,89 до 1,46 GiB. Время выросло примерно с 12 до 19 секунд. Такой обмен нас устраивал: сборка стала медленнее, зато требовала заметно меньше памяти.
Дальше возникла естественная идея — уменьшить лимит ещё. Проверили её в отдельной локальной серии:
Лимит old space | Результат | Время | Наблюдаемый пик RSS |
|---|---|---|---|
1280 MiB | Успешно | 16,4 с | ≈1482 MiB |
1248 MiB | Успешно | 50,5 с | ≈1461 MiB |
1216 MiB |
| Падение примерно через 70 с | ≈1415 MiB |
Ради экономии примерно 21 MiB пришлось ждать втрое дольше. Ещё один шаг вниз — и сборка перестала завершаться. Оставили 1280 MiB.
У этой настройки есть важная граница применимости. Она ограничивает старое поколение кучи V8. В RSS процесса дополнительно входят другие области памяти, нативные структуры, код и буферы. Дочерний esbuild работает отдельным процессом. Поэтому лимит 1280 MiB вполне совместим с суммарным RSS выше полутора гигабайт. Подробнее устройство памяти описано в документации Node.js.
У нас успели случиться два разных отказа. В первом случае память закончилась у системы, и Linux убил Node. Во втором мы сами слишком сильно ограничили кучу, и V8 завершился с JavaScript heap out of memory. Исправление одного ограничения легко упиралось в другое.
Попробовали уменьшить граф импортов
Лимит позволил снизить расход памяти, но хотелось уменьшить и объём работы сборщика. В Vite 7 за production-сборку отвечает Rollup. Он обрабатывает граф модулей приложения, преобразует код и формирует чанки. Большая библиотека может означать сотни дополнительных модулей, даже если в нашем коде ей соответствует одна строка импорта.
Начали с PrimeVue. Заменили корневые импорты:
import { Button, Select } from 'primevue'
на прямые:
import Button from 'primevue/button'
import Select from 'primevue/select'
Количество обрабатываемых модулей уменьшилось с 3896 до 3730. Такой подход соответствует рекомендации Vite избегать лишних barrel-файлов — промежуточных модулей с множеством реэкспортов.
Однако нужного запаса памяти это не дало. Позже изменение откатили; в финальном сравнении оба сборщика работали на одинаковых исходниках без этой переделки.
Попробовали пройти дальше и заменить больше промежуточных импортов. Такая переделка затрагивала бы около четырёхсот файлов. Граф при этом уменьшился с 3730 до 3723 модулей, а пик RSS — примерно на 3,6 MiB. Ради такого результата большой рефакторинг не имел смысла. Его тоже вернули назад.
С Element Plus первоначальная гипотеза оказалась ещё проще для проверки. Хотелось подключать только нужный DatePicker. Посмотрели импорты — компоненты уже подключались напрямую. Остальное приходило через их внутренние зависимости: календарь, выбор времени, локализацию и вспомогательный код. У одного компонента тоже может быть немаленькая стоимость.
Продолжили локальные эксперименты. В этой таблице — отдельная исследовательская серия, её числа не нужно смешивать с финальными серверными замерами:
Вариант | Обрабатываемые модули | Пиковый RSS, примерно |
|---|---|---|
Базовый Vite 7 в этой серии | 3730 | 1,44 GiB |
Дополнительная переделка barrel-файлов | 3723 | 1,44 GiB |
Монолитный импорт ECharts | 3134 | 1,48 GiB |
Эксперимент с Vite 8 / Rolldown | — | 1,62 GiB |
Временное исключение ECharts и Element Plus | 2230 | 1,31 GiB |
Самый полезный результат здесь — строка с ECharts. Модулей стало меньше примерно на шестьсот, а потребление памяти немного выросло. Значит, по одному числу модулей мы не могли предсказать выигрыш. Модули отличаются размером и содержимым, а результат зависит и от работы сборщика с ними.
Исключение ECharts и Element Plus снизило пик. Но вместе с ними исчезали нужные функции приложения. Это была проверка гипотезы о стоимости частей графа; для поставки такой вариант не подходил. Экспериментальные изменения из таблицы откатили.
Попробовали и отключить минификацию. В одном из других локальных запусков RSS остался около 1,9 GiB. Размер результата увеличивался, а необходимой экономии памяти мы не получали.
Разбиение крупных зависимостей на чанки и ленивая загрузка частей приложения тоже не дали нужного снижения пика. Эти приёмы полезны для загрузки приложения в браузере. Во время production-сборки весь входящий в приложение граф всё равно нужно обработать.
К этому моменту у нас было несколько проверенных ограничений. Дальше уменьшать heap невыгодно. Большая переделка импортов почти ничего не даёт. Удалять нужные библиотеки нельзя. Проба Vite 8 с Rolldown в этой серии тоже потребовала больше памяти. Мы решили проверить другую систему сборки на том же приложении.
Добавили Rsbuild и перепроверили первый результат
Добавили Rsbuild 2.2.13, который использует Rspack, и плагины для Vue и Sass. Vite сохранили для разработки и как доступный вариант production-сборки.
Для сравнения оставили все графики, редакторы и компоненты. В обоих вариантах сохранили минификацию, отключили production sourcemaps и согласовали целевые браузеры. Проверили результат в браузере: проверенные сценарии работали с обоими сборщиками.
Первое наблюдение на сервере показало около 575 MiB RSS у Rsbuild. Выглядело очень хорошо, но состояние процессов мы снимали примерно раз в шесть секунд. Для короткой сборки это слишком редкий опрос: заметный пик мог целиком пройти между снимками.
Повторили замер с интервалом 100 мс. Получили 802 MiB. Разница достаточно большая, чтобы пересмотреть способ наблюдения, прежде чем делать выводы о сборщике.
При каждом опросе складывали RSS основного процесса и всех его потомков, затем брали максимум этой суммы. Такой подход учитывает и отдельный процесс esbuild рядом с Vite. В финальном замере на пике получилось:
Node 1434 MiB
esbuild 247 MiB
------------------
Всего 1681 MiB
Если смотреть только на Node, часть потребления сборки потеряется. Одновременно с RSS мы фиксировали доступную память системы, занятый swap и изменение общесистемных счётчиков обмена со swap.
Сумма RSS тоже имеет ограничения: общие страницы могут учитываться несколько раз, поэтому это не PSS и не точный объём уникальных физических страниц. Очень короткий пик между опросами можно пропустить даже при интервале 100 мс. Для сравнения обоих сборщиков использовали один метод.
Что получилось на сервере
Финальные прогоны сделали на одном сервере и одном коммите приложения. У обоих сборщиков был лимит old space 1280 MiB. Дерево процессов опрашивали раз в 100 мс.
Здесь измерено время самой сборки. Установка зависимостей, тесты и Docker-сборка легаси в него не входят. MiB равен 1024 × 1024 байтам.
Показатель | Vite | Rsbuild |
|---|---|---|
Время сборки | 41,0 с | 11,7 с |
Пиковый суммарный RSS | 1681 MiB | 802 MiB |
Минимальная доступная память системы | 201 MiB | 938 MiB |
Занятый swap перед сборкой | 1892 MiB | 1868 MiB |
Запись системы в swap за интервал сборки | ≈169 MiB | ≈26 MiB |
В этой паре запусков Rsbuild потребовал примерно на 52% меньше RSS и завершился в 3,5 раза быстрее.
Для нашего сервера особенно полезна третья строка. При Vite системе в худший момент оставался примерно 201 MiB доступной памяти. При Rsbuild — 938 MiB. У работающих рядом сервисов появлялся больший запас.
Здесь доступная память — это MemAvailable. Linux оценивает, сколько памяти можно предоставить новым приложениям без дополнительного обмена со swap. Оценка учитывает и часть освобождаемого кеша, поэтому её нельзя приравнивать к полностью свободной RAM. Определение в документации Linux.
Условия запусков стоит учитывать вместе с результатом. Сервер продолжал обслуживать остальные процессы. Состояние памяти и кешей было близким, но не идентичным: например, занятый swap до запусков отличался примерно на 24 MiB. Vite наблюдали при обычном обновлении, Rsbuild — в отдельном повторном запуске. Запись в swap посчитана для всей системы, включая активность остальных сервисов.
Это одна пара серверных прогонов. Десятков повторений с медианой и доверительными интервалами у нас нет. Ранее локально мы тоже сравнивали оба сборщика одинаковым методом: получили примерно 1588 MiB и 35,7 секунды у Vite против 1016 MiB и 15,5 секунды у Rsbuild. Направление совпало, но объединять локальные и серверные числа в одну выборку нельзя.
В финальных серверных запусках обе сборки завершились, новых сообщений OOM мы не обнаружили. К этому моменту уже действовали меры по снижению нагрузки. Аварийный запуск в начале расследования происходил в более раннем состоянии системы.
Что в итоге изменилось
Из настроек Node ощутимо помог лимит old space 1280 MiB. Он уменьшил пик ценой времени сборки. Дальнейшее уменьшение лимита давало слишком маленькую экономию и в конце концов приводило к падению.
Порядок этапов, ограничение параллелизма в легаси и отключение расчёта gzip-размеров оставили как способы убрать лишнюю нагрузку. Они не устранили основной пик Vite; отдельного численного выигрыша каждой из этих мер мы не измерили.
Наибольший выигрыш в финальном сравнении дал Rsbuild: 802 MiB вместо 1681 MiB при сохранении нужной функциональности. Для этого приложения на этом сервере он оказался более экономным вариантом. Доступную сборку Vite сохранили.
Общее обновление при этом не стало быстрее в 3,5 раза. После новой версии всё ещё собиралось легаси. В одном из наблюдаемых запусков этот этап занимал несколько минут, RSS dockerd доходил примерно до 1,45 GiB, а swap снова заполнялся почти полностью. У следующего этапа оставалось собственное узкое место.
Если бы я начинал это расследование заново, раньше перешёл бы к трём измерениям: времени конкретного этапа, пику RSS всего дерева процессов и минимальному MemAvailable. С ними стало видно, какие изменения действительно оставляют серверу больше памяти и за какую цену. Например, двадцать мегабайт, сэкономленные ценой трёхкратного замедления, перестали выглядеть полезной оптимизацией.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.