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-slim COPY node-v26.13.1-linux-x64/ /usr/local/ COPY . /app CMD ["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 ok rootfs-bookworm-slim v26.13.1 error while loading shared libraries: libatomic.so.1: cannot open shared object file: No such file or directory rootfs-trixie-slim v24.22.3 v24.22.3 ok rootfs-trixie-slim v26.13.1 error while loading shared libraries: libatomic.so.1: cannot open shared object file: No such file or directory rootfs-distroless-cc12 v24.22.3 v24.22.3 ok rootfs-distroless-cc12 v26.13.1 error while loading shared libraries: libatomic.so.1: cannot open shared object file: No such file or directory rootfs-distroless-cc13 v24.22.3 v24.22.3 ok rootfs-distroless-cc13 v26.13.1 error while loading shared libraries: libatomic.so.1: cannot open shared object file: No such file or directory rootfs-node26-slim v24.22.3 v24.22.3 ok rootfs-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 atomic libatomic.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.js var 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('ok\n'); });
На 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.<anonymous> (/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 using NODE_MODULE_VERSION 137. This version of Node.js requires NODE_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 10 v24.22.3 137 napi 10 v26.13.1 147 napi 10
Обратите внимание на последнее число: версия Node-API у всех трёх одинаковая. Это подсказка к исправлению.
Сценарий 5. module.register() пока работает, но шумит
Хуки загрузчика через register() часто встречаются в инструментах: APM-агенты, моки, загрузчики TypeScript.
// register.mjs import { 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 26 dir="${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" done echo "## 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" done true
Логика проверки аддонов такая: модуль на Node-API экспортирует символ napi_register_module_v1, и его ABI не зависит от версии Node. Если такого символа нет, аддон написан на старом API поверх node.h и V8, и его придётся пересобирать под каждую мажорную версию.
На тестовом приложении:
## Удалённые и устаревшие API app/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 без libatomic1 app/Dockerfile
Скрипт грубый: регулярка на register(' может зацепить чужую функцию с таким же именем, а проверка Dockerfile эвристическая. Но как первый проход по большому монорепозиторию он находит всё, что выше упало, и не требует ставить Node 26.
Второй дешёвый приём: прогнать тесты на текущей версии с --pending-deprecation --throw-deprecation. Тогда устаревшее, но ещё работающее начнёт падать заранее.
Исправление
libatomic. В Debian-образе ставим пакет:
FROM debian:trixie-slim RUN 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 . /app CMD ["node", "/app/server.js"]
В distroless пакетного менеджера нет, библиотеку приходится приносить из промежуточной стадии:
FROM debian:trixie-slim AS libs RUN apt-get update && apt-get install -y --no-install-recommends libatomic1 FROM gcr.io/distroless/cc-debian13 COPY --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.mjs import { 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.5 v22.23.5 ID;NAME v22.23.5 200 ok v22.23.5 isBrowser = false v22.23.5 main loaded hello from napi addon ######## v24.22.3 v24.22.3 ID;NAME v24.22.3 200 ok v24.22.3 isBrowser = false v24.22.3 main loaded hello from napi addon ######## v26.13.1 v26.13.1 ID;NAME v26.13.1 200 ok v26.13.1 isBrowser = false v26.13.1 main loaded hello from napi addon
Ни одного предупреждения, один и тот же код работает на 22, 24 и 26, так что откатиться можно в любой момент. Важно и то, что registerHooks() уже есть в 22.23.5 и 24.22.3, поэтому переписать хуки можно до обновления.
Образы с исправлением:
rootfs-trixie-fixed v26.13.1 v26.13.1 ok rootfs-distroless-fixed v26.13.1 v26.13.1 ok
Внутри них исправленное приложение тоже отрабатывает:
rootfs-trixie-fixed v26.13.1 stream ok hello from napi addon rootfs-distroless-fixed v26.13.1 stream ok hello from napi addon v26.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? И как вы обычно ловите такие вещи: тестами, линтером или по первому падению на стейдже?

