Russian Trusted Root CA в Node и Docker: как пережить смену гос-API без простоя
- Node.js
- Docker
- TLS
- сертификаты
- Russian Trusted CA
- Минцифры
- 152-ФЗ
- zero-downtime
- интеграции
- DevOps
Когда российская нац-платформа переезжает на новый API-домен с сертификатом доверенного УЦ Минцифры (Russian Trusted Root CA), интеграция на Node/Docker внезапно перестаёт ходить к этому домену — соединение рвётся на проверке TLS-цепочки. Причина не в коде, а в том, что этого корневого сертификата нет в дефолтном trust store рантайма. Решение — добавить корень через NODE_EXTRA_CA_CERTS (additive, не ломая остальной TLS), вынести адрес API в переменную окружения и провести переключение обратимо: серт заранее → smoke на новом endpoint → флип → быстрый откат. Ниже — конкретика на примере перехода мессенджера MAX на новый домен Bot API.
Контекст: что именно меняется
Платформа уведомляет о двух связанных изменениях:
- Меняется домен API — старый хост выводится из эксплуатации к фиксированному дедлайну, появляется новый.
- Новый хост обслуживается сертификатом Russian Trusted Root CA — корневым сертификатом национального УЦ Минцифры.
Второй пункт — главная ловушка. Russian Trusted Root CA не входит в набор доверенных корней, с которым поставляется Node.js (его CA-bundle построен на наборе Mozilla). Значит, как только клиент пойдёт на новый домен, проверка цепочки доверия не соберётся.
Симптом: код верный, рвётся TLS
При обращении к домену с неизвестным корнем Node обрывает соединение ещё до прикладного уровня. Типичные ошибки:
Error: unable to get local issuer certificate
code: 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY'
или SELF_SIGNED_CERT_IN_CHAIN / CERT_UNTRUSTED — в зависимости от того, какого звена цепочки не хватает. Важно понимать: приложение здесь ни при чём. HTTP-запрос корректен, токен валиден — отказывает проверка TLS-цепочки, потому что рантайму нечем подтвердить подпись серверного сертификата. Лечится это не правкой кода, а добавлением корня в доверенные.
Решение: NODE_EXTRA_CA_CERTS — additive, а не замена
В Node есть штатный механизм расширить набор доверенных корней — переменная окружения NODE_EXTRA_CA_CERTS:
NODE_EXTRA_CA_CERTS=/etc/ssl/certs/russian_trusted_ca.pem
Ключевое свойство — она additive. Node добавляет сертификаты из этого файла к встроенному bundle, а не подменяет его. Это критично: соединения с базой, очередями, прочими внешними API продолжают проверяться по международным корням как раньше. Вы расширяете доверие ровно на один известный корень, не ослабляя проверку для остального трафика. Это принципиально безопаснее, чем глобальные «костыли» вроде NODE_TLS_REJECT_UNAUTHORIZED=0, который выключает проверку целиком и открывает дорогу man-in-the-middle на любом соединении — так делать нельзя.
Грабли, которые стоит знать заранее
- PEM должен быть валидным и непустым. Если файл пуст, битый или путь указывает в никуда, Node при старте бросит ошибку парсинга CA — процесс не поднимется. Это, как ни странно, хорошо: «тихого» проигнорированного серта не будет.
- Root + Sub в одном файле. Цепочка доверия часто состоит из корневого и промежуточного (Sub) сертификатов. Кладите оба блока
BEGIN CERTIFICATEв один PEM — иначе промежуточного звена не хватит и проверка не соберётся. - Читается один раз при старте.
NODE_EXTRA_CA_CERTSподхватывается при инициализации процесса. Поменяли серт — пересоздайте контейнер, на лету подмена файла не подхватится. - Переводы строк. В Linux-образе PEM с CRLF может сломать разбор цепочки. Если репозиторий редактируется и из Windows — зафиксируйте перевод строк для
*.pemкак LF (.gitattributes:*.pem eol=lf), иначе серт «случайно» испортится при коммите.
Источник сертификата: только официальный
Корневой сертификат — это якорь доверия. Подменённый корень означает, что злоумышленник сможет выдать себя за любой сервис, которому вы доверяете. Поэтому Russian Trusted Root CA берётся только из официального источника регулятора — портал Госуслуг / gu-st.ru (russiantrustedca.pem), а не со случайных зеркал, gist’ов и форумов. Это не формальность, а часть модели безопасности: смысл проверки цепочки исчезает, если корень взят из недоверенного места.
Параметризация base-URL: домен в env, не в коде
Вторая половина миграции — смена адреса API. Здесь принцип простой: адрес API не зашивается в код, а живёт в переменной окружения.
В нашем случае хардкода старого домена в коде и не было — он находится внутри SDK мессенджера. Но SDK позволяет переопределить базовый URL через параметр клиента, и мы выносим его в конфигурацию:
// конфигурация: домен из env с дефолтом и валидацией
const DEFAULT_BASE_URL = 'https://platform-api2.max.ru'; // новый домен
const baseUrl = process.env.MAX_API_BASE_URL || DEFAULT_BASE_URL;
new URL(baseUrl); // ранняя валидация: битый URL → понятная ошибка на старте
// клиент: base-URL приходит из конфигурации, а не зашит в SDK
const bot = new Bot(token, { clientOptions: { baseUrl } });
Что это даёт:
- дефолт уже указывает на новый домен — после деплоя клиент сразу ходит на правильный хост;
- переопределение через env делает откат тривиальным — вернуть старый домен можно одной переменной, без правок кода и пересборки;
- ранняя валидация URL ловит опечатку в конфиге на старте, а не глубоко в рантайме при первом запросе.
Zero-downtime порядок: серт заранее → smoke → флип → откат
Главная идея — подготовить новый путь заранее и переключиться обратимо. Порядок шагов:
- Серт заранее. Кладём CA в образ и подключаем через
NODE_EXTRA_CA_CERTS. На старом домене это ничего не ломает (additive), но новый путь уже готов. - Smoke на новом endpoint. Проверяем, что с добавленным CA соединение к новому домену валидно: лёгкий «пустой» вызов — например, запрос метаданных бота (
getMyInfo) — должен вернуть HTTP 200. Это доказывает, что цепочка доверия собирается и токен принимается новым хостом, ещё до переключения боевого трафика. - Флип через env. Переключаем
MAX_API_BASE_URL(или оставляем дефолт, уже указывающий на новый домен). Код не трогаем. - Откат через env. Если на новом хосте что-то пойдёт не так — возвращаем старый домен переменной (старый endpoint обычно живёт ещё какое-то время до дедлайна) и предыдущий тег образа. Откат не требует пересборки — это просто смена значения переменной.
Эта схема — частный случай общего принципа «обновляйся обратимо»: и серт, и адрес спрятаны за переменными окружения, поэтому каждый шаг можно откатить за секунды.
Docker: куда класть серт
В multi-stage Dockerfile на node:22-alpine сертификат вкладывается в финальный образ, а путь совпадает с NODE_EXTRA_CA_CERTS:
# финальная (runtime) стадия
COPY certs/russian_trusted_ca.pem /etc/ssl/certs/russian_trusted_ca.pem
ENV NODE_EXTRA_CA_CERTS=/etc/ssl/certs/russian_trusted_ca.pem
Серт в образе (а не во внешнем volume) делает контейнер самодостаточным: он несёт свой якорь доверия с собой, и поведение воспроизводимо между окружениями. В compose-файле переменную дублируем явно (с дефолтом), чтобы её было видно в конфигурации деплоя:
environment:
NODE_EXTRA_CA_CERTS: "${NODE_EXTRA_CA_CERTS:-/etc/ssl/certs/russian_trusted_ca.pem}"
MAX_API_BASE_URL: "${MAX_API_BASE_URL:-https://platform-api2.max.ru}"
Кому это пригодится
Этот же сценарий вы встретите везде, где Node/Docker/CI-сервис ходит к российскому гос-API или нац-платформе, перешедшей на Russian Trusted Root CA: соединение валится на проверке цепочки, хотя «вчера работало» и код не менялся. Рецепт переносимый:
- добавить корневой сертификат (Root + Sub) из официального источника через
NODE_EXTRA_CA_CERTS(additive, неNODE_TLS_REJECT_UNAUTHORIZED=0); - вынести адрес API в переменную окружения с дефолтом и валидацией;
- переключаться обратимо: серт заранее → smoke (200) на новом endpoint → флип → откат через env.
Так смена гос-требований превращается из аварийного простоя в плановую правку конфигурации.
Ссылки
- Канал поддержки в MAX: AI-первая линия и передача оператору — сервис, который мы переводили на новый домен
- Обновить раньше — заплатить дешевле: безопасное обновление без простоя — тот же принцип обратимых изменений с откат-дриллом