MAATRIX / Блог / Пакет удалили из реестра, и прод перестал собираться

Пакет удалили из реестра, и прод перестал собираться

MAATRIX

В четверг утром пайплайн, который месяцами зелёный, лёг на самом скучном шаге — установке зависимостей. Ни строчки нового кода в мердж-реквесте, ни изменений в инфраструктуре, а сборка падает с 404 Not Found. Разбираем по шагам, как мы искали причину, какие версии событий отбросили и почему в итоге виноват оказался не наш код, а чужой пакет, который просто исчез из реестра.

Что сломалось

Пайплайн собирал бэкенд на Node.js: docker build с многоступенчатым Dockerfile, на этапе зависимостей — npm ci по package-lock.json. Сборка запускалась на self-hosted GitLab-раннере, три параллельных джобы для разных сервисов монорепозитория.

В 9:14 по местному времени упала джоба одного из сервисов. В 9:16 — вторая, использующая тот же общий пакет из внутреннего workspace. Третий сервис, который его не тянул, собрался штатно. Дежурный решил, что это разовый сбой сети, перезапустил джобу вручную — упала снова, с той же ошибкой, слово в слово.

К 9:40 стало понятно, что это не флак: любая новая сборка с нуля падает стабильно, а старые образы в реестре контейнеров, собранные накануне, продолжают спокойно разворачиваться. То есть прод не упал — упала возможность выкатить что-либо новое. С учётом того, что на пятницу была запланирована горячая правка биллинга, это уже тянуло на инцидент, а не на «само пройдёт».

Что видели в логах и метриках

Лог джобы был лаконичен:

npm error code E404
npm error 404 Not Found - GET https://registry.npmjs.org/@acme%2fdate-fmt/-/date-fmt-2.4.1.tgz
npm error 404
npm error 404  '@acme/date-fmt@2.4.1' is not in this registry.
npm error 404
npm error 404 Note that you can also install from a
npm error 404 tarball, folder, http url, or git url.
npm error A complete log of this run can be found in: /root/.npm/_logs/2026-08-27T06_14_02_112Z-debug-0.log

Debug-лог (npm error A complete log...) подтвердил ровно то же самое одной строкой раньше: npm честно резолвил дерево зависимостей по лок-файлу, дошёл до конкретной версии пакета и получил 404 при попытке скачать тарбол. Никакой сетевой ошибки, никакого таймаута — обычный HTTP 404, как будто пакета с такой версией никогда не существовало.

Метрики раннера ничего интересного не показали: CPU и память в норме, свободное место на диске в пределах обычного, исходящий трафик тоже не аномальный — джоба падала за 20–30 секунд, толком не успев ничего скачать. В логах реверс-прокси перед раннером (мы держим локальный кэширующий прокси для внешних запросов) нашли те же самые 404 на registry.npmjs.org, то есть прокси честно транслировал ответ вышестоящего сервера, а не генерировал его сам.

Отдельно проверили package-lock.json в самом мердже: git log -p -- package-lock.json показал, что файл не менялся уже три недели. Значит, версия 2.4.1 была зафиксирована задолго до инцидента и раньше прекрасно устанавливалась.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Какие гипотезы мы отбросили

По порядку, как проверяли:

  • Сбой самого npm-реестра. Первая и самая удобная версия — «у них авария, не у нас». Открыли статус-страницу npm — всё зелёное. Попробовали установить другой, произвольный пакет той же командой npm ci в соседней временной директории — встал без проблем. Значит, реестр в целом жив, проблема точечная.
  • Сетевые проблемы раннера или файрвола. Проверили DNS-резолвинг (dig registry.npmjs.org с раннера), TLS-хендшейк (curl -v https://registry.npmjs.org/), правила исходящего трафика в security group — всё чисто, других жалоб на сеть с этого раннера не было. С похожими симптомами (сборка падает, а причина не в коде) мы уже разбирались в статье про раннер, который тихо ушёл на устаревший коммит — но там была совсем другая механика, поэтому версию с раннером проверили и отдельно отбросили.
  • Испорченный или неполный лок-файл. Сравнили хэш package-lock.json в репозитории с хэшем в последнем успешном билде — идентичны, ничего не корраптилось при коммите или мердже.
  • Кто-то из команды случайно опубликовал breaking-версию во внутренний scoped-пакет. Проверили список версий пакета в приватном скоупе организации на npm — версия 2.4.1 числилась опубликованной, unpublish со стороны команды никто не делал, в аудит-логе организации таких действий не было.
  • Локальный кэш npm на раннере устарел или был инвалидирован. Здесь версия оказалась близка к истине, но не в том смысле, в котором мы её проверяли: сам по себе кэш был исправен, просто в нём не было нужного тарбола — потому что накануне ночью на раннере отработала плановая очистка диска, которая среди прочего почистила ~/.npm/_cacache.

Последний пункт и стал зацепкой, которая привела к реальной причине.

В чём была настоящая причина

Пакет @acme/date-fmt@2.4.1 — не наш внутренний, а сторонний, из публичного npm-реестра, которым мы пользовались как транзитивной зависимостью одной из библиотек логирования. Автор пакета в ту же ночь выпустил 2.4.2 с исправлением уязвимости и следом отозвал (npm unpublish) версию 2.4.1 как «небезопасную» — в рамках 72-часового окна, в течение которого npm разрешает авторам отзывать недавно опубликованные версии. Формально это штатное поведение реестра, просто оно ударило по всем, кто жёстко пиннился на конкретную версию через лок-файл и не имел собственного зеркала.

До этой ночи нас спасал локальный кэш npm на раннере: сборки шли из кэша, тарбол физически лежал на диске раннера, и до registry.npmjs.org дело не доходило. Как только плановая очистка диска (docker system prune плюс скрипт, чистящий ~/.npm раз в месяц ради экономии места) снесла кэш, следующая же сборка пошла за пакетом «в живую» — и упёрлась в 404, потому что версии, на которую был закреплён лок-файл, больше физически не существовало в реестре.

Проверили гипотезу напрямую:

curl -s -o /dev/null -w "%{http_code}\n" \
  https://registry.npmjs.org/@acme%2fdate-fmt/-/date-fmt-2.4.1.tgz
# 404

npm view @acme/date-fmt versions --json | grep 2.4.1
# версии 2.4.1 в списке уже нет, только 2.4.0 и 2.4.2

Совпадение по времени было почти идеальным: очистка кэша по крону — в 03:00, отзыв версии автором — где-то между 02:00 и 04:00 (точнее по публичным данным реестра не восстановить), первая упавшая сборка — в 09:14. То есть окно, когда «повезло бы» и сборка снова прошла бы из кэша, мы упустили буквально на несколько часов раньше своей же чисткой диска.

Как потушили пожар

Раз версии 2.4.1 больше не существует физически, чинить нужно было не инфраструктуру, а зависимость:

  1. Подняли, какой диапазон версий date-fmt совместим по API — в CHANGELOG пакета 2.4.2 числился как патч-релиз без breaking changes.
  2. Обновили прямую зависимость библиотеки логирования, которая тянула date-fmt, до версии, зависящей уже от 2.4.2, а где это было невозможно быстро — явно прописали резолюцию версии в package.json:
{
  "overrides": {
    "@acme/date-fmt": "2.4.2"
  }
}
  1. Пересобрали лок-файл (npm install, затем зафиксировали новый package-lock.json), локально прогнали тесты сервисов, которые используют логирование, — регрессий не увидели.
  2. Смёрджили фикс отдельным экстренным MR в обход обычной очереди ревью (с последующим пост-фактум ревью), задеплоили.

От момента первой красной джобы до раскатанного фикса прошло около полутора часов — большая часть времени ушла не на сам фикс (он занял минут двадцать), а на то, чтобы отбросить более удобные, но неверные гипотезы про сеть и раннер.

Что изменили после инцидента

Разовый фикс версии не решает системную проблему: любой сторонний пакет в любой момент может быть отозван автором, снят с публикации из-за уязвимости или удалён по решению npm за нарушение правил. Полагаться на то, что реестр всегда отдаст именно то, что зафиксировано в лок-файле, нельзя — что мы, собственно, и обсуждали в статье про приватный реестр, который «протух» и не дал поднять поды: проблема там была зеркальная, но вывод тот же — внешний реестр не резервная копия ваших зависимостей.

Что сделали:

  • Подняли собственный прокси-реестр. Развернули Nexus Repository в режиме npm-proxy: раннеры теперь ходят не напрямую в registry.npmjs.org, а в локальный Nexus, который кэширует уже скачанные тарболы навсегда, а не до ближайшей чистки диска. Если пакет однажды был успешно скачан, он останется доступен, даже если автор его отзовёт в реестре. Разворачивали похожим образом, что описано в гайде по Nexus Repository в docker compose — только с включённым npm-хостингом группы npm-proxy вместо (или вместе с) docker-хостинга.
  • Исключили кэш npm из автоматической очистки диска. Скрипт уборки места на раннерах теперь трогает только Docker-слои и старые артефакты сборок, а ~/.npm/_cacache и локальный кэш Nexus — в списке исключений. Если место реально кончается, это отдельный сигнал «пора выделить раннеру диск побольше», а не повод стереть кэш пакетов молча.
  • Перешли на .npmrc с явным указанием реестра для CI. В корне репозитория и в образе раннера прописали:
registry=https://nexus.internal.example/repository/npm-proxy/
always-auth=false

Так все сборки детерминированно идут через прокси, и человеческий фактор (кто-то локально не так настроил npm) больше не влияет на CI.

  • Добавили еженедельную проверку целостности лок-файлов. Отдельная джоба раз в неделю прогоняет npm ci --dry-run против «холодного» окружения без локального кэша — то есть напрямую бьёт по прокси-реестру и репортит в чат, если какая-то версия из лок-файла недоступна. Это превращает «пакет исчез» из сюрприза в четверг утром в тикет, который можно закрыть спокойно, без пожара.
  • Договорились не пинить транзитивные зависимости на самый свежий патч-релиз без причины. Свежий патч чаще всего безопаснее, но он же самый вероятный кандидат на отзыв, если в нём находят проблему в первые дни после публикации. Для второстепенных транзитивных пакетов теперь по возможности ждём хотя бы несколько дней перед тем, как поднимать версию в лок-файле, если только патч не закрывает что-то критичное здесь и сейчас.

Отдельно проговорили с командой, что даже правильно настроенный self-hosted раннер — это инфраструктура, за которую отвечаем мы сами, включая цепочку «откуда раннер берёт зависимости». Если для CI/CD используется VPS, разумно сразу закладывать под него отдельный диск или том под кэш пакетов и Docker-слоёв, не разделяя его с системным разделом — тогда плановая чистка места не рискует случайно снести то, что нельзя терять.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Почему npm вообще разрешает автору удалять уже опубликованную версию пакета?

У npm есть политика «unpublish» — в первые 72 часа после публикации автор может отозвать версию, обычно из-за найденной уязвимости, случайной публикации приватного кода или грубой ошибки в пакете. Через 72 часа отозвать версию через обычный интерфейс уже нельзя, но пакет всё ещё может быть снят администрацией реестра целиком за нарушение правил — так что полной гарантии вечной доступности версии нет никогда.

Помог бы package-lock.json с полем integrity, если бы пакет остался в реестре, но с изменённым содержимым?

Да, именно для этого поле integrity и существует: если хэш скачанного тарбола не совпадает с зафиксированным, npm ci упадёт с ошибкой целостности, а не тихо подсунет изменённый код. В нашем случае пакета не стало вообще, поэтому сработал не механизм проверки целостности, а обычный 404 — но это разные классы защиты, и оба нужны одновременно.

Чем прокси-реестр лучше, чем просто зафиксировать зависимости в приватном npm-скоупе компании?

Форк зависимости в свой скоуп решает проблему, но требует ручной работы на каждый пакет и раздувает поддержку. Прокси-реестр (Nexus, Verdaccio, Artifactory) делает то же самое автоматически и прозрачно для разработчиков: команда как обычно пишет npm install, а кэширование и защита от «пакет пропал» происходят на уровне инфраструктуры, без изменений в коде и package.json.

Стоит ли то же самое делать для Python (pip) или это специфика npm?

Проблема ровно та же самая: PyPI тоже позволяет удалять релизы, и известны случаи, когда сборки массово падали из-за снятого с публикации пакета. Прокси-кэш через тот же Nexus (или devpi для чисто Python-стека) закрывает эту дыру точно так же, независимо от языка.

А если сеть до раннера вообще пропадает, а не отдельный пакет — с чего начинать диагностику?

Это уже отдельный класс проблем — стоит сразу проверить сам раннер и его сетевые настройки, а не гадать на конкретных пакетах; частые причины разобраны в статье про типовые ошибки GitLab CI раннера на сервере.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →