Дрейф версий Node.js: как остановить сбои из-за несоответствий при деплое
Дрейф версий Node.js ломает сборки, когда на локальной машине, в CI и в production используются чуть разные runtime или инструменты. Узнайте, как аккуратно закрепить версии.

Как выглядит дрейф версий
Дрейф версий Node.js обычно проявляется как проблема, которая сначала вообще не имеет смысла. Код час назад спокойно работал на вашем ноутбуке, а теперь CI падает с другой ошибкой, или production падает еще до запуска приложения. В самом функционале ничего не выглядит сломанным. Несоответствие прячется ниже приложения — в runtime или на этапе установки.
Один из самых частых сценариев очень простой: локальные тесты проходят, CI падает. Разработчик использует Node 20.11, CI-образ — 20.9, и один пакет собирается или разрешается немного иначе. Код приложения почти не менялся, а вот среда изменилась. Этого маленького разрыва достаточно, чтобы чистый прогон тестов превратился в красный пайплайн.
Бывает и наоборот. CI проходит, потому что пайплайн каждый раз использует один и тот же кешированный набор, а потом production запускается на другой версии Node и падает при старте. Вы можете увидеть синтаксическую ошибку, отсутствующий бинарник или пакет, который сообщает, что ему нужна другая версия engine. Это именно тот баг, который съедает полдня, потому что деплой выглядит нормальным, пока процесс не пытается стартовать.
Lockfile тоже может подсказать, где проблема. Вы запускаете свежую установку на новой машине, а lockfile меняется, хотя зависимости никто не трогал. Часто это означает, что отличается версия пакетного менеджера, либо установка прошла под другой версией Node и выбрала чуть другие метаданные, дополнительные пакеты или platform-specific артефакты. Если такой измененный lockfile попадет в коммит, следующий билд снова может вести себя иначе.
Нативные аддоны делают проблему еще заметнее. Пакеты вроде bcrypt, sharp или sqlite3 могут отлично работать на одной машине и ломаться на другой с сообщениями о несовместимых бинарниках, неудачной пересборке или отсутствующих системных библиотеках. MacBook, Linux-раннер в CI и тонкий production-контейнер — это три разных мира. Если хотя бы один из них собрал аддон под другой Node ABI или другой системный набор, приложение может успешно установиться в одном месте и упасть в другом.
Большинство сбоев из-за несовпадения при деплое сначала кажутся случайными. Но со временем становится видно закономерность: один и тот же код, разная среда, разный результат.
Откуда берутся несоответствия
Большинство сбоев при деплое, в которых обвиняют "Node", на самом деле не начинаются в production. Они начинаются тогда, когда каждая среда тихо делает свой выбор. На одном ноутбуке стоит Node 18, на другом — 20, CI все еще использует старый образ, а production каждый день тянет то, на что указывает свободный тег.
Так и появляется дрейф версий Node.js. Никто не меняет код приложения, но runtime под ним меняется. Пакет, который вчера работал, сегодня может сломаться, потому что одна машина чуть иначе разрешает зависимости, собирает бинарники или запускает скрипты.
Типичные точки поломки
Обычный пример довольно прост. Один разработчик использует nvm и остается на Node 18, потому что старому проекту нужна именно эта версия. Другой обновляется до Node 20 ради новой возможности. Оба могут установить зависимости и запустить тесты, но итог у них уже не будет полностью одинаковым.
CI часто делает ситуацию хуже. В пайплайне может по-прежнему использоваться старый Docker-образ, старое значение setup-node или кешированная среда, к которой никто не прикасался месяцами. Там сборка проходит, а потом после деплоя падает, потому что production работает на другой major- или minor-версии.
Production может дрейфовать еще быстрее, если вы используете свободные теги образов вроде node:20 или node:latest. Такие теги двигаются. Пересборка на следующей неделе может подтянуть более новую patch-версию, другую Linux-базу или другую настройку OpenSSL. Такие мелкие изменения вполне способны сломать запуск, тесты или install-скрипты.
Пакетные менеджеры добавляют еще один слой. Команда начинает с npm, потом один человек переходит на pnpm, а Yarn обновляется через Corepack без понятного плана. Lockfiles, правила hoisting и поведение установки меняются. На локальной машине приложение может выглядеть нормально, но CI и серверы теперь по-разному разрешают пакеты.
Нативные модули — самая хрупкая часть. Пакеты вроде sharp, bcrypt или драйверов баз данных часто зависят от бинарников, собранных под конкретные Node ABI, ОС и тип процессора. Модуль, собранный на macOS, мало поможет, если production на Linux нужен свой бинарник.
Если вы хотите меньше неожиданных деплоев, сначала проверьте именно эти места:
- точную версию Node на каждом ноутбуке
- версию, которую ставит CI, или образ, который он подтягивает
- базовый образ или runtime в production
- точный пакетный менеджер и используемый lockfile
- любые зависимости, которые компилируют нативный код
Маленькие несовпадения быстро накапливаются. Одного файла с версией недостаточно, если остальная цепочка его игнорирует.
Как закрепить версию Node.js
Выберите одну точную версию Node.js и используйте ее везде. Точная — это 20.11.1, а не 20, не 20.x и не >=20. Одно такое решение убирает большую часть проблем с дрейфом версий Node.js еще до того, как они попадут в CI или production.
Самая простая схема — намеренно скучная. Держите одну и ту же версию во всех местах, которые реально читают люди и машины:
- файл версии, например
.nvmrcили.node-version package.jsonвengines.node- конфигурация CI
- ваш Dockerfile или настройка runtime у хостинга
Если в одном месте написано 20.11.1, а в другом 20.12.0, вы уже заложили будущий сбой деплоя. Даже небольшого отличия достаточно, чтобы поменялись результаты установки, сломались build-скрипты или появилось странное поведение во время работы.
Простой базовый вариант выглядит так:
# .nvmrc
20.11.1
{
"engines": {
"node": "20.11.1"
}
}
Используйте ту же версию в CI вместо широких обозначений вроде node:20 или ubuntu-latest плюс то, что уже оказалось установлено в системе. То же самое делайте в Docker. Если ваш образ начинается с FROM node:20, у вас все еще остается место для дрейфа. Закрепляйте полную версию.
Небольшой пример из команды хорошо показывает, почему это важно. Разработчик собирает локально на 20.11.1, CI работает на 20.12.2, а production использует базовый образ, который за ночь переехал на более свежий patch-релиз. На ноутбуке все работает, в CI падает, а после новой пересборки вдруг снова работает. Такой баг съедает часы, потому что внешне ничего не выглядит сломанным.
Печатаете версию runtime каждый раз, когда код переходит между средами. Запускайте node -v в локальных проверках, в начале CI jobs и в логах сборки образа. Когда деплой ломается, одна эта строка часто рассказывает всю историю за несколько секунд.
Команды, которые работают с минималистичной инфраструктурой, как AI-first-настройки, которые Oleg Sotnikov делает для клиентов, обычно держат правило очень простым: одна точная версия, записанная один раз, повторенная везде и видимая в логах.
Как закрепить пакетный менеджер
В репозитории должен быть один пакетный менеджер, а не два и не три. Если один разработчик использует npm, другой pnpm, а CI — Yarn, вы получите разные деревья зависимостей, даже если код приложения не менялся. Этого уже достаточно, чтобы обычный деплой превратился в медленный и раздражающий сбой.
Выберите один инструмент и сделайте его частью правил репозитория. Затем оставьте только его lockfile. Если вы используете npm, храните package-lock.json. Если используете pnpm — pnpm-lock.yaml. Если используете Yarn — yarn.lock. Остальные удалите и не давайте им возвращаться в следующих pull request.
Версия тоже имеет значение. pnpm@8 и pnpm@9 могут разрешать пакеты по-разному. То же самое касается Yarn и npm. Закрепите эту версию в package.json, чтобы на каждой машине использовался один и тот же инструмент:
{
"packageManager": "[email protected]"
}
Corepack сильно упрощает жизнь. Он идет вместе с современными версиями Node.js и может установить точную версию пакетного менеджера, которую ожидает ваш репозиторий. В итоге ваш ноутбук, CI и production стартуют с одной и той же базы, а не с трех чуть разных.
Нужно также быстро падать, если кто-то использует не тот менеджер. Короткая проверка в CI работает хорошо, а preinstall-guard помогает на локальных машинах. Цель проста: остановить установку до того, как она создаст новый lockfile с другим инструментом.
Несколько правил репозитория экономят очень много времени:
- используйте один пакетный менеджер на репозиторий
- храните один lockfile и удалите остальные
- закрепите версию пакетного менеджера
- блокируйте установки через неправильный инструмент
- относитесь к изменениям lockfile как к изменениям кода
Последний пункт люди игнорируют постоянно. Переписанный lockfile — это не безобидное обновление форматирования. Он может менять версии пакетов, выбор peer dependencies, install-скрипты и platform-specific бинарники. Если pull request переписывает половину lockfile, считайте это настоящим изменением зависимостей и проверяйте его на чистой установке.
Команды, которые работают с минималистичной инфраструктурой, как AI-first-настройки, которые Oleg делает для startup-клиентов, обычно достаточно рано вводят жесткие правила. Маленькие несовпадения тратят больше времени, чем большие баги, потому что подрывают доверие к процессу релиза.
Нативные модули требуют особого внимания
Нативные модули ломаются чаще, чем обычные JavaScript-пакеты. В них есть скомпилированный код, поэтому им важно не только ваше приложение. Должны совпадать версия Node, операционная система и архитектура процессора.
Пакет может без проблем установиться на одной машине и сломаться на другой, хотя lockfile одинаковый. Обычно это происходит, когда в одной среде стоит Node 18 на macOS arm64, а в CI или production — Node 20 на Linux x64. Пакет не менялся, но нужный ему бинарник — да.
Частые примеры:
- sharp
- bcrypt
- better-sqlite3
- sqlite3
- canvas
Если в команде используются такие пакеты, относитесь к ним как к чувствительным зависимостям. Именно на них дрейф версий Node.js часто проявляется первым.
Когда вы меняете runtime Node, сразу пересобирайте нативные модули. Не рассчитывайте, что старая установка еще подойдет. Небольшой скачок версии может изменить native ABI, и тогда приложение падает на старте или ломается во время установки.
Не копируйте старый node_modules между машинами, Docker-образами или CI jobs, которые работают с разными runtime. Такая экономия выигрывает несколько минут, а потом превращается в часы очистки и повторной настройки. Лучше ставить зависимости заново в целевой среде или использовать кеш, привязанный к точной версии Node, ОС и архитектуре.
Одна ошибка всплывает постоянно: разработчик ставит зависимости на MacBook, коммитит lockfile, а CI собирает проект на Linux. Потом production использует другой Linux-образ, чем CI. Каждый шаг кажется достаточно близким, но нативные модули не знают значения слова «достаточно близко».
Если можно выбрать между двумя пакетами, безопаснее часто оказывается тот, у которого есть надежные prebuilt-бинарники для вашей версии Node и целевых платформ. Это не убирает риск полностью, но заметно сокращает количество сбоев при сборке и странных ошибок во время работы.
Для команд, которые держат lean CI и production, это особенно важно. Держите build-среду простой. Делайте ее похожей на production, пересобирайте после изменения runtime и не доверяйте старому нативному бинарнику.
Простой пример: от ноутбука до продакшена
Priya обновляет Node.js на своем ноутбуке с 18 до 20, потому что другому проекту нужна именно эта версия. Ее приложение по-прежнему запускается, тесты проходят, и команда идет дальше. На ее машине ничего не выглядит сломанным.
Именно так дрейф версий Node.js и прячется. Локальная установка часто выглядит нормально, потому что ноутбук собирает или скачивает пакеты под свою конфигурацию. Если приложение использует нативный модуль вроде sharp, пакет может скачать бинарник, подходящий для Node 20 на macOS. Там все работает, поэтому изменение кажется безобидным.
CI показывает другую картину. Пайплайн все еще использует старый setup image с Node 18 и сохраняет старую версию пакетного менеджера из предыдущего кеша. Lockfile остается тем же, так что разрыв никто не замечает. Сборка может даже пройти, если тестовый путь не затрагивает sharp.
Production делает несоответствие очевидным. В deploy-образе снова используется другой base image, и приложение пытается загрузить sharp, когда обрабатывает загрузку изображения. Теперь процесс падает, потому что sharp был собран под другую версию Node, другую ОС или другие системные библиотеки. Код приложения не менялся. Изменилась runtime-цепочка.
Команда решает проблему, закрепив всю toolchain, а не только зависимости приложения:
- они задают одну точную версию Node.js для ноутбуков, CI и production
- они закрепляют версию пакетного менеджера в проекте
- они используют один точный тег образа для build и runtime-образов
- они очищают старые кеши и пересобирают зависимости внутри этой закрепленной среды
На практике это может означать, что одну и ту же версию Node добавляют в .nvmrc, конфигурацию CI и тег Docker-образа, а затем задают packageManager в package.json. После этого удаляют кешированный node_modules, делают чистую установку и пересобирают sharp в том же образе, который использует production.
Следующий деплой проходит успешно, потому что каждый шаг теперь опирается на одни и те же предположения. Мелкие несоответствия перестают накапливаться. Обычно именно в этом и разница между релизом, который кажется случайным, и релизом, который одинаково ведет себя на ноутбуке, в CI и в production.
Ошибки, из-за которых деплой ломается снова и снова
Многие сбои при деплое начинаются с мелких решений, которые на этапе настройки выглядят безобидно. Дрейф версий Node.js обычно начинается задолго до production, а потом проявляется в день релиза, когда одна машина собирает другой результат, чем другая.
Одна из частых ошибок — использовать latest везде. Docker-образ вроде node:latest, шаг CI, который ставит самую новую версию Node, или пакетный менеджер, который обновляет себя при каждом запуске, — все это может менять поведение без предупреждения. Ваше приложение может работать во вторник и сломаться в пятницу без какого-либо изменения в коде, на которое можно было бы указать.
Еще одна проблема — частичное закрепление. Разработчик фиксирует Node локально через .nvmrc или Volta, но CI все еще использует плавающую версию. Этого уже достаточно, чтобы изменилась структура зависимостей, результат сборки или тесты. Если вы закрепляете Node, закрепляйте его везде, где запускается приложение: локальная настройка, CI, Docker и production.
Смешивание пакетных менеджеров в одном репозитории тоже тихо ломает процесс. Если один человек использует npm, а другой pnpm, появляются разные lockfiles, разная раскладка установки и путаные баг-репорты. Выберите один инструмент и уберите сигналы, которые зовут остальные, включая старые lockfiles, которым не место в репозитории.
Кеширование может сделать ситуацию еще хуже. Команды часто кешируют node_modules, чтобы сэкономить несколько минут, а потом переиспользуют этот кеш после смены версии Node. Это плохая сделка. Нативные зависимости могли быть собраны под другой runtime, поэтому установка выглядит быстрой, а приложение позже падает с ошибкой модуля.
Несколько привычек создают большую часть боли:
- Не используйте плавающие теги runtime в Docker или CI.
- Не закрепляйте локальные инструменты и не оставляйте CI на автoобновлении.
- Не храните одновременно
package-lock.jsonиpnpm-lock.yaml. - Не переиспользуйте кеши
node_modulesмежду версиями Node. - Не игнорируйте предупреждения
enginesво время установки.
Последний пункт важнее, чем многие думают. Когда пакет сообщает, что ожидает Node 20, а ваш пайплайн работает на Node 18, предупреждение часто оказывается единственным ранним сигналом. Относитесь к нему как к проваленной проверке, а не как к фоновому шуму. Пятиминутное исправление здесь может сэкономить часы откатов позже.
Быстрые проверки перед каждым релизом
Проверка версий за 30 секунд ловит большую часть плохих релизов. Дрейф версий Node.js обычно сначала выглядит мелочью, а потом съедает час, когда CI проходит, а production падает.
Запускайте одни и те же проверки каждый раз, даже если релиз совсем маленький. Люди пропускают их, потому что вчера приложение работало. Именно так в деплой и попадают мелкие несоответствия.
- Выводите
node -vв трех местах: на своем ноутбуке, в CI job и на работающем сервере или контейнере. Не соглашайтесь на «почти подходит».20.11и20.12могут вести себя по-разному, если зависимости или build-инструменты требовательны. - Держите один пакетный менеджер в репозитории и один lockfile. Если вы видите и
package-lock.json, иpnpm-lock.yaml, кто-то может установить зависимости не тем инструментом и получить другое дерево зависимостей. - Пересобирайте нативные модули на целевой платформе. Модуль, собранный на macOS, может не заработать внутри Linux-контейнера, даже если JavaScript-код не менялся.
- Заставьте Docker и CI использовать один и тот же закрепленный runtime. Если в Dockerfile один тег Node, а в GitLab CI job — другой, вы уже разделили сборку на две среды.
- Читайте в логах установки одну строку, которую многие команды игнорируют: версию пакетного менеджера. Если в CI указана одна версия
pnpm, а в локальной оболочке другая, поведение lockfile может дрейфовать.
Небольшой пример делает это очевидным. Разработчик тестирует на Node 20.11 с pnpm 9, CI работает на Node 20.12 с другим pnpm, а production собирает Docker-образ из старого base tag. Приложение может даже стартовать, но нативный пакет вроде sharp или bcrypt может сломаться уже после деплоя.
Oleg часто исправляет это, делая версии видимыми, а не предполагаемыми: выводит их в CI, закрепляет в Docker и пересобирает все нативное там, где оно действительно будет работать. Звучит просто, но это останавливает удивительно большое количество сбоев при релизе.
Если одно число версии или один lockfile выглядит подозрительно, остановите релиз. Сначала исправьте закрепление, потом деплойте.
Что делать дальше
Выберите одну версию Node.js и одну версию пакетного менеджера, а затем запишите их в репозитории там, где их увидят все. Положите их в файлы, которые уже читают ваша команда и ваши инструменты, а не в wiki-страницу, которая через месяц устареет.
Обычно это значит, что в source control нужно проверить версию runtime, версию пакетного менеджера и lockfile. Если у вас есть две или три разные точки, которые называют разные версии, оставьте одну source of truth и уберите остальные.
Уборка важна не меньше, чем закрепление версий. Старые файлы версий, старые lockfiles и размытые теги базовых образов снова и снова возвращают проекты в дрейф. Репозиторий, в котором одновременно остались куски настроек от npm, Yarn и pnpm, рано или поздно начнет ломаться странным образом.
Достаточно простого прохода по чистке:
- удалите файлы версий, которые больше не используете
- уберите лишние lockfiles и оставьте один
- замените плавающие теги контейнеров на точные версии
- обновите CI так, чтобы он каждый раз ставил один и тот же runtime и пакетный менеджер
- добавьте одну проверку, которая быстро падает, если версии не совпадают
Последний шаг экономит очень много времени. Небольшая проверка в CI может сравнить ожидаемую версию Node.js и версию пакетного менеджера с тем, что job реально использует. Если они отличаются, сборка должна остановиться до тестов, до компиляции нативных модулей и до того, как кто-то потратит час на чтение шумных логов.
Сделайте это сначала в одном репозитории. Выберите приложение, которое ломается чаще всего, или то, которое команда выкатывает каждую неделю. Стабилизируйте именно этот setup, запишите схему, а потом перенесите ее в следующий репозиторий. Команды обычно двигаются быстрее с одним чистым примером, чем с большим планом уборки сразу для десяти сервисов.
Если настройки размазаны по Dockerfile, CI jobs, конфигам хостинга, shell-скриптам и старым привычкам команды, внешний взгляд может сэкономить время. Oleg помогает командам распутывать runtime, инфраструктуру и AI-first workflows разработки, поэтому он быстро замечает конфликты и превращает их в одну схему, которой люди действительно пользуются.
Цель довольно скромная: один и тот же код должен одинаково работать на ноутбуке, в CI и в production. Когда это становится правдой, деплои перестают падать из-за крошечных несоответствий версий.