28 октября Node.js 26 получил статус LTS с кодовым именем Lithium, а в ноябре вышел первый security-релиз этой ветки, 26.13.1. Платформы уже начинают ставить 26 по умолчанию для новых проектов, так что самое время переводить на неё сервисы, которые сейчас живут на 22 или 24.
Обычно переход между LTS проходит тихо: поменял версию в .nvmrc и Dockerfile, прогнал тесты, поехали. Тут тоже в основном так, но в 26 окончательно удалили несколько старых API, поменяли ABI нативных модулей, а официальные бинарники для Linux начали требовать библиотеку, которой нет в «тонких» базовых образах. Ломается это не в вашем коде, а в зависимостях десятилетней давности и в Dockerfile, на которые никто не смотрит.
Ниже разбор по схеме «проблема → воспроизведение → исправление → проверка». Все выводы взяты из реального прогона на Node.js 22.23.5, 24.22.3 и 26.13.1 (официальные бинарники linux-x64).
Что изменилось
Из списка изменений 26.0.0 и примечаний к LTS-релизу для обычного бэкенда важны такие пункты:
-
http.ServerResponse.prototype.writeHeader()удалён окончательно, остался толькоwriteHead(); -
внутренние модули
_stream_readable,_stream_writable,_stream_duplex,_stream_transform,_stream_passthroughи_stream_wrapудалены; -
module.register()теперь выдаёт предупреждение при запуске, вместо него предлагаютmodule.registerHooks(); -
флаг
--experimental-transform-typesудалён; -
NODE_MODULE_VERSIONподнят до 147, поэтому нативные аддоны, собранные под 24, не загрузятся; -
начиная с Node.js 25 официальные бинарники для Linux слинкованы с
libatomic, и её может не быть в системе.
Проверим каждый пункт.
Воспроизведение
Собрал маленькое приложение, в котором есть всё, что обычно находится в проекте лет пяти от роду:
app/├── Dockerfile├── server.js # HTTP-сервер├── stream_test.js # использует старую библиотеку-стрим├── register.mjs # подключает хуки загрузчика├── hooks.mjs├── main.mjs└── node_modules/ ├── legacy-csv-stream/ # require('_stream_transform') └── fast-hash/ # нативный аддон, собран под Node 24
Сценарий 1. Контейнер не стартует: libatomic.so.1
Самый неприятный вариант, потому что падает ещё до первой строки вашего кода. Типичный Dockerfile, где официальный архив Node кладут в slim-образ:
FROM debian:trixie-slimCOPY node-v26.13.1-linux-x64/ /usr/local/COPY . /appCMD ["node", "/app/server.js"]
Docker на этой машине нет, поэтому я скачал слои нескольких популярных базовых образов из публичных реестров, распаковал их в каталоги и запускал в них официальные бинарники Node 24 и 26 через bwrap. Для динамического линковщика это то же самое, что запуск внутри контейнера:
bwrap --bind rootfs-trixie-slim / --ro-bind node-v26.13.1-linux-x64 /opt/node --dev /dev --proc /proc --tmpfs /tmp /opt/node/bin/node -e 'console.log(process.version, "ok")'
rootfs-bookworm-slim v24.22.3 v24.22.3 okrootfs-bookworm-slim v26.13.1 error while loading shared libraries: libatomic.so.1: cannot open shared object file: No such file or directoryrootfs-trixie-slim v24.22.3 v24.22.3 okrootfs-trixie-slim v26.13.1 error while loading shared libraries: libatomic.so.1: cannot open shared object file: No such file or directoryrootfs-distroless-cc12 v24.22.3 v24.22.3 okrootfs-distroless-cc12 v26.13.1 error while loading shared libraries: libatomic.so.1: cannot open shared object file: No such file or directoryrootfs-distroless-cc13 v24.22.3 v24.22.3 okrootfs-distroless-cc13 v26.13.1 error while loading shared libraries: libatomic.so.1: cannot open shared object file: No such file or directoryrootfs-node26-slim v24.22.3 v24.22.3 okrootfs-node26-slim v26.13.1 v26.13.1 ok
Node 24 запускается везде, Node 26 не запускается ни в debian:bookworm-slim, ни в debian:trixie-slim, ни в distroless/cc-debian12 и cc-debian13. Разница видна прямо в ldd:
$ ldd node-v26.13.1-linux-x64/bin/node | grep atomiclibatomic.so.1 => /lib/x86_64-linux-gnu/libatomic.so.1$ ldd node-v24.22.3-linux-x64/bin/node | grep atomic$
В официальном образе node:26-slim пакет libatomic1 уже установлен, поэтому, если вы берёте его как базовый, проблемы нет. Ломаются те, кто ставит Node сам: копирует архив в свой образ, собирает distroless-вариант или качает бинарник в CI-раннер с минимальной системой.
Сценарий 2. Старая библиотека требует _stream_transform
Так писали стримы лет десять назад, и такой код до сих пор лежит в транзитивных зависимостях:
// node_modules/legacy-csv-stream/index.jsvar Transform = require('_stream_transform');var util = require('util');function Upper() { Transform.call(this); }util.inherits(Upper, Transform);Upper.prototype._transform = function (chunk, enc, cb) { cb(null, String(chunk).toUpperCase()); };module.exports = Upper;
На 22 и 24 всё работает и печатает ID;NAME. На 26.13.1:
Error: Cannot find module '_stream_transform'Require stack:- /workspace/habr/node26-lts/app/node_modules/legacy-csv-stream/index.js- /workspace/habr/node26-lts/app/stream_test.js at Module._resolveFilename (node:internal/modules/cjs/loader:1603:15)
Хорошо, что падает при загрузке модуля, а не посреди ночи. Плохо, что падает в чужом коде, который вы не писали и, возможно, ни разу не открывали.
Сценарий 3. res.writeHeader is not a function
const http = require('node:http');const srv = http.createServer((req, res) => { res.writeHeader(200, { 'content-type': 'text/plain' }); res.end('okn');});
На 22 и 24 сервер отвечает 200 ok. На 26 сервер стартует нормально и падает только на первом запросе:
/workspace/habr/node26-lts/app/server.js:3 res.writeHeader(200, { 'content-type': 'text/plain' }); ^TypeError: res.writeHeader is not a function at Server. (/workspace/habr/node26-lts/app/server.js:3:7)
Это коварнее предыдущего случая: healthcheck, который просто проверяет, что порт открыт, пройдёт, а первый настоящий запрос уронит процесс. Если такой вызов сидит в редком обработчике, тесты без нужного маршрута его не заметят.
Сценарий 4. Нативный аддон, собранный под 24
node_modules собрали в CI на Node 24, закешировали и подложили в образ с Node 26:
Error: The module '/workspace/habr/node26-lts/app/node_modules/fast-hash/build/Release/fast_hash.node'was compiled against a different Node.js version usingNODE_MODULE_VERSION 137. This version of Node.js requiresNODE_MODULE_VERSION 147. Please try re-compiling or re-installing
Номера ABI у трёх версий такие:
$ node -p "process.version+' '+process.versions.modules+' napi '+process.versions.napi"v22.23.5 127 napi 10v24.22.3 137 napi 10v26.13.1 147 napi 10
Обратите внимание на последнее число: версия Node-API у всех трёх одинаковая. Это подсказка к исправлению.
Сценарий 5. module.register() пока работает, но шумит
Хуки загрузчика через register() часто встречаются в инструментах: APM-агенты, моки, загрузчики TypeScript.
// register.mjsimport { register } from 'node:module';register('./hooks.mjs', import.meta.url);
На 26 код продолжает работать, но в логе появляется:
(node:970200) [DEP0205] DeprecationWarning: `module.register()` is deprecated. Use `module.registerHooks()` instead.
А если у вас в тестах или в проде стоит --throw-deprecation, процесс падает:
DeprecationWarning: `module.register()` is deprecated. Use `module.registerHooks()` instead. at node:internal/util:128:11 at register (node:internal/modules/esm/loader:1014:3) at file:///workspace/habr/node26-lts/app/register.mjs:2:1
Попутно две мелочи. Флаг --experimental-transform-types, если он остался в скриптах package.json, теперь просто не даёт запуститься:
node: bad option: --experimental-transform-types
При этом обычный запуск .ts-файла с аннотациями типов без флагов на 26.13.1 работает. И ещё: проверка вида typeof localStorage !== 'undefined', которой некоторые библиотеки определяют «мы в браузере», на 26 по-прежнему даёт undefined, но печатает в лог ExperimentalWarning: localStorage is not available because --localstorage-file was not provided. Ничего не ломается, но лишний шум в логах.
Как найти всё это до обновления
Тесты ловят только то, что выполнилось, а сценарии 2–4 сидят в node_modules и Dockerfile. Поэтому я написал простой скрипт, который ищет их статически:
#!/bin/sh# check_node26.sh — ищет в проекте (включая node_modules) то, что ломается в Node.js 26dir="${1:-.}"echo "## Удалённые и устаревшие API"grep -rnE --include='*.js' --include='*.cjs' --include='*.mjs' --include='*.ts' "require(['"](node:)?_stream_(readable|writable|duplex|transform|passthrough|wrap)['"])|from ['"](node:)?_stream_|.writeHeader(|bregister(['"]|--experimental-transform-types" "$dir" | grep -v 'registerHooks'echo "## Нативные аддоны без Node-API (пересобирать под NODE_MODULE_VERSION 147)"find "$dir" -name '*.node' -type f | while read -r f; do nm -D "$f" 2>/dev/null | grep -q 'napi_register_module_v1' || echo "$f"doneecho "## Dockerfile: официальный бинарник Node без libatomic1"find "$dir" -maxdepth 3 -name 'Dockerfile*' | while read -r f; do grep -qiE 'node' "$f" && ! grep -q 'libatomic' "$f" && grep -qiE '^FROM .*(slim|distroless)' "$f" && echo "$f"donetrue
Логика проверки аддонов такая: модуль на Node-API экспортирует символ napi_register_module_v1, и его ABI не зависит от версии Node. Если такого символа нет, аддон написан на старом API поверх node.h и V8, и его придётся пересобирать под каждую мажорную версию.
На тестовом приложении:
## Удалённые и устаревшие APIapp/server.js:3: res.writeHeader(200, { 'content-type': 'text/plain' });app/node_modules/legacy-csv-stream/index.js:2:var Transform = require('_stream_transform');app/register.mjs:2:register('./hooks.mjs', import.meta.url);## Нативные аддоны без Node-API (пересобирать под NODE_MODULE_VERSION 147)app/node_modules/fast-hash/build/Release/fast_hash.node## Dockerfile: официальный бинарник Node без libatomic1app/Dockerfile
Скрипт грубый: регулярка на register(' может зацепить чужую функцию с таким же именем, а проверка Dockerfile эвристическая. Но как первый проход по большому монорепозиторию он находит всё, что выше упало, и не требует ставить Node 26.
Второй дешёвый приём: прогнать тесты на текущей версии с --pending-deprecation --throw-deprecation. Тогда устаревшее, но ещё работающее начнёт падать заранее.
Исправление
libatomic. В Debian-образе ставим пакет:
FROM debian:trixie-slimRUN apt-get update && apt-get install -y --no-install-recommends libatomic1 && rm -rf /var/lib/apt/lists/*COPY node-v26.13.1-linux-x64/ /usr/local/COPY . /appCMD ["node", "/app/server.js"]
В distroless пакетного менеджера нет, библиотеку приходится приносить из промежуточной стадии:
FROM debian:trixie-slim AS libsRUN apt-get update && apt-get install -y --no-install-recommends libatomic1FROM gcr.io/distroless/cc-debian13COPY --from=libs /usr/lib/x86_64-linux-gnu/libatomic.so.1* /usr/lib/x86_64-linux-gnu/COPY node-v26.13.1-linux-x64/ /usr/local/
Собирать сам образ мне было негде, поэтому я проверял по-другому: поставил libatomic1 в распакованный trixie-slim и скопировал те же два файла (libatomic.so.1 и libatomic.so.1.2.0) в распакованный cc-debian13. Ну и самый простой путь, если ничего особенного вам не нужно: брать за основу официальный node:26-slim.
_stream_*. Всё, что было во внутренних модулях, давно доступно из публичного node:stream:
// было: var Transform = require('_stream_transform');var Transform = require('node:stream').Transform;
Если это транзитивная зависимость, сначала поищите свежую версию пакета. Если пакет заброшен, правка в одну строку через patch-package или форк, а в долгую лучше заменить библиотеку.
writeHeader. Это был просто старый алиас:
res.writeHead(200, { 'content-type': 'text/plain' });
Нативные аддоны. Пересобрать под 26 (npm rebuild на той же версии Node, что и в рантайме) и, главное, не таскать node_modules с бинарниками между образами с разными версиями Node. В кеш CI стоит добавить версию Node в ключ. Для старого аддона я проверил пересборку руками: тот же исходник, скомпилированный с заголовками из архива 26.13.1, загружается на 26 и наоборот не загружается на 24.
Надёжнее перейти на Node-API: тот же «hello» на node_api.h собирается один раз и грузится на всех трёх версиях. Если аддон чужой, посмотрите, нет ли у него версии на Node-API или prebuild под 26.
module.register(). Переход на registerHooks(). Главное отличие: хуки синхронные и выполняются в том же потоке, а не в отдельном воркере, поэтому async и await внутри них не получится использовать:
// register.mjsimport { registerHooks } from 'node:module';// синхронные хуки в том же потоке; next() тоже синхронныйregisterHooks({ resolve(spec, ctx, next) { return next(spec, ctx); },});
Если ваши хуки делают что-то асинхронное (ходят в сеть, читают файлы через промисы), переписывать придётся больше одной строки. Это стоит оценить заранее. Если же хуки приходят из APM-агента или тестового фреймворка, проверьте, есть ли у него версия с поддержкой registerHooks.
Проверка браузера. Вместо localStorage проверяем то, чего в Node нет:
const isBrowser = typeof window !== 'undefined' && typeof window.document !== 'undefined';
Проверка
Исправленное приложение прогоняем на всех трёх версиях с --throw-deprecation --pending-deprecation, чтобы любое предупреждение стало ошибкой:
for v in v22.23.5 v24.22.3 v26.13.1; do N="../bin/node-$v-linux-x64/bin/node --throw-deprecation --pending-deprecation" echo "######## $v" $N stream_test.js $N server.js $N storage.js $N --import ./register.mjs main.mjs $N -e "console.log(require('../addon/napi_addon.node').hello())"done
######## v22.23.5v22.23.5 ID;NAMEv22.23.5 200 okv22.23.5 isBrowser = falsev22.23.5 main loadedhello from napi addon######## v24.22.3v24.22.3 ID;NAMEv24.22.3 200 okv24.22.3 isBrowser = falsev24.22.3 main loadedhello from napi addon######## v26.13.1v26.13.1 ID;NAMEv26.13.1 200 okv26.13.1 isBrowser = falsev26.13.1 main loadedhello from napi addon
Ни одного предупреждения, один и тот же код работает на 22, 24 и 26, так что откатиться можно в любой момент. Важно и то, что registerHooks() уже есть в 22.23.5 и 24.22.3, поэтому переписать хуки можно до обновления.
Образы с исправлением:
rootfs-trixie-fixed v26.13.1 v26.13.1 okrootfs-distroless-fixed v26.13.1 v26.13.1 ok
Внутри них исправленное приложение тоже отрабатывает:
rootfs-trixie-fixed v26.13.1 stream ok hello from napi addonrootfs-distroless-fixed v26.13.1 stream ok hello from napi addonv26.13.1 200 ok
Повторный прогон check_node26.sh по исправленному приложению выводит только заголовки разделов, находок нет.
Короткий чек-лист перед переходом на 26
-
Если Node ставится не из официального образа
node:26, добавьтеlibatomic1и проверьте, чтоnode -vвыполняется внутри итогового образа, а не только на этапе сборки. -
Пройдитесь по проекту вместе с
node_modulesпоиском_stream_,writeHeader(иregister(. -
Найдите
.node-файлы без Node-API и пересоберите их. Добавьте версию Node в ключ кешаnode_modules. -
Перепишите
module.register()наregisterHooks(), пока вы ещё на 24. -
Прогоните тесты с
--throw-deprecation --pending-deprecation. -
Сделайте healthcheck, который выполняет настоящий запрос, а не просто проверяет открытый порт.
А у вас уже был переезд на 26? Что сломалось первым: Dockerfile, нативный модуль или какая-нибудь библиотека из 2016 года в глубине node_modules? И как вы обычно ловите такие вещи: тестами, линтером или по первому падению на стейдже?
Источник: habr.com