Кеш зависимостей был отравлен, и три недели никто не замечал
Самое неприятное в этом инциденте — не сам сбой, а то, сколько он прожил незамеченным. Три недели каждая сборка на CI брала из локального кеша один и тот же повреждённый файл зависимости, собирала его в прод-образ, и ни разу — ни разу! — сборка не упала и не написала в лог ни одной ошибки. Разбираем по шагам, что случилось, какие версии причины мы отбросили и почему в итоге виноват оказался не код, а один интерпретированный «успех» там, где на самом деле была тихая порча данных.
Содержание
- Что сломалось: тикеты про кривые файлы, а в мониторинге — тишина
- Что мы увидели в логах и метриках, когда начали копать целенаправленно
- Гипотезы, которые мы отбросили
- Как нашли настоящую причину
- Механизм: как обычный сетевой сбой превратился в трёхнедельную порчу кеша
- Что мы изменили в пайплайне после инцидента
Что сломалось: тикеты про кривые файлы, а в мониторинге — тишина
История началась не с алерта, а с двух тикетов в саппорт с разницей в четыре дня. Оба — про один и тот же модуль: экспорт документов в PDF у части пользователей выходил битым. Не «сервис недоступен», не «500 ошибка» — просто открывался файл, а внутри вместо штрихкода и части макета — пустое место или обрезанный рендер. У части клиентов всё было нормально, у части — нет, и закономерность на первый взгляд не читалась.
Первая реакция дежурного была стандартной: посмотреть Sentry. Ошибок не было. Совсем. Функция генерации PDF не бросала исключение — она просто отдавала документ, в котором один из встраиваемых ресурсов оказывался пустым или обрезанным байткодом, а остальной рендер библиотека собирала как ни в чём не бывало, потому что код был написан защитно: если ресурс не загрузился, вставляется заглушка, а не падение всего процесса. Разумное решение для устойчивости — и оно же убийственно для обнаружения проблемы, потому что весь класс ошибок оказался невидим для алертинга «по количеству 5xx и исключений».
Метрика, которая теоретически могла бы подсветить проблему раньше — доля успешных экспортов без визуальных дефектов, — просто не собиралась отдельно. Был общий счётчик «экспорт запущен / экспорт завершён», и оба числа сходились: экспорт завершался, просто с дефектом внутри файла. С точки зрения инфраструктуры всё выглядело зелёным три недели подряд.
Когда тикетов стало пять, а не два, стало понятно, что это не единичный случай у конкретного клиента, а системная проблема, которая копится постепенно — и её нужно было раскапывать вручную, без подсказок от мониторинга.
Что мы увидели в логах и метриках, когда начали копать целенаправленно
Первым делом подняли историю деплоев за последний месяц. Модуль экспорта PDF не менялся почти два месяца — ни одного коммита в соответствующей директории. Это сразу отсекло самую очевидную версию «сломали код в последнем релизе».
Дальше посмотрели инфраструктурные метрики раннера, на котором собираются образы: загрузка CPU, память, место на диске — всё в норме, никаких аномалий вроде OOM-киллера или заполнения диска в момент сборки. Логи самого CI тоже не давали зацепок: во всех сборках за три недели — зелёный статус, все шаги «passed», включая юнит-тесты на модуль экспорта. Тесты гоняли рендер на тестовых данных и проверяли, что файл создаётся и не пустой — но не сверяли его содержимое побайтово с эталоном, так что повреждённый, но непустой файл спокойно проходил проверку.
Отдельно подняли логи сетевого оборудования и провайдера — за интересующий период у хостинг-провайдера действительно было плановое окно обслуживания с кратковременными потерями пакетов на аплинке одной из стоек, примерно за три недели до первого тикета. Это совпадение стало первой зацепкой, но само по себе оно ничего не доказывало — потери пакетов бывают регулярно и обычно ни на что не влияют, потому что TCP и retry-логика должны это компенсировать.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверГипотезы, которые мы отбросили
Прежде чем упереться в реальную причину, мы проверили и закрыли несколько версий — каждая казалась правдоподобной, и на каждую ушло от получаса до пары часов:
- Регрессия в коде. Отброшена сразу — модуль не менялся два месяца, а проблема появилась резко.
- Проблема на стороне клиента (браузер, PDF-ридер). Проверили — файл был повреждён уже на сервере, ещё до отдачи пользователю, значит дело не в просмотрщике.
- Деградация внешнего API рендеринга. Часть пайплайна дергает внешнюю библиотеку рендеринга штрихкодов. Прогнали её изолированно с тем же входом — работает штатно, значит внешний сервис ни при чём.
- Проблема с локалью или таймзоной сервера. Версия возникла из-за того, что баг проявлялся не у всех клиентов одинаково — заподозрили региональные различия в форматировании. Проверка показала, что распределение поломанных документов не коррелирует ни с локалью, ни с часовым поясом клиента, а коррелирует с тем, на каком экземпляре приложения был собран образ, обслуживший запрос.
- Проблема с диском или файловой системой контейнера в проде. Проверили размеры и контрольные суммы файлов ресурса внутри разных запущенных контейнеров — они были одинаковыми во всех репликах одного и того же образа, но отличались между образами, собранными в разное время. Это направило поиск в сторону самой сборки, а не рантайма.
Последний пункт стал поворотным: если дефект «размножается» вместе с образом, а не возникает случайно во время исполнения, значит проблема — на этапе сборки, а конкретнее — в том, что попадает внутрь образа при npm ci.
Как нашли настоящую причину
Дальше методика была скучной, но надёжной: взяли два образа, собранных в разное время — один из «плохого» периода, один свежий (после того как в лок-файл случайно попало обновление одной из транзитивных зависимостей, что автоматически инвалидировало кеш) — и сравнили содержимое node_modules для подозрительного пакета побайтово.
docker run --rm -it broken-image:old sh -c "sha256sum /app/node_modules/pdf-render-lib/dist/renderer.min.js"
docker run --rm -it broken-image:new sh -c "sha256sum /app/node_modules/pdf-render-lib/dist/renderer.min.js"
Хеши отличались. Дальше сверили оба варианта с тем, что реально лежит в реестре пакетов для этой версии:
npm view pdf-render-lib@2.4.1 dist.shasum
npm pack pdf-render-lib@2.4.1 --dry-run
Файл из «плохого» образа не совпадал ни с одним из этих значений. Сам файл при этом открывался, синтаксически был валидным JS — просто обрезанным примерно на середине, будто скачивание прервалось на середине потока, а хвост файла оказался утерян. Это уже прямо указывало на повреждение при скачивании пакета, а не на код приложения.
Оставалось понять, откуда именно в сборку попал обрезанный файл, если package-lock.json явно фиксирует версию и её контрольную сумму (integrity в формате SRI), а npm ci по спецификации обязан её проверять и падать при несовпадении.
Ответ нашёлся в конфигурации самого раннера. Сборочный скрипт был написан так:
npm ci --prefer-offline --no-audit || npm ci --prefer-offline --no-audit
echo "install step finished"
То есть при сбое первая попытка молча повторялась, а код возврата всей строки после || считался успешным независимо от того, что произошло на второй попытке — привычный, но опасный паттерн «на всякий случай повторим», добавленный когда-то для борьбы с флаки-сетью. Именно во время сетевого окна с потерями пакетов у провайдера первая попытка npm ci прервалась на скачивании тарболла пакета, и оборванные байты успели попасть в локальный кеш npm на раннере — GitLab Runner использовал персистентный volume /srv/gitlab-runner/cache/npm, общий для всех job с одинаковым ключом кеша по хешу лок-файла. Вторая попытка npm ci с флагом --prefer-offline увидела в локальном кеше «уже скачанный» тарболл этой версии и не стала обращаться к реестру повторно — а проверка целостности в момент установки из локального кеша была де-факто отключена, потому что при первой (прерванной) записи в кеш npm не успел дописать метаданные с контрольной суммой, и последующая установка трактовала это как устаревший, но валидный локальный артефакт.
С этого момента каждая следующая сборка, использующая тот же ключ кеша (то есть с тем же package-lock.json), брала повреждённый тарболл из локального кеша без повторного обращения в сеть — и без единой ошибки. Ключ кеша не менялся три недели, потому что именно столько времени лок-файл оставался без изменений — до случайного апдейта транзитивной зависимости, который и «починил» ситуацию побочно, инвалидировав ключ кеша и заставив npm скачать пакет заново, уже корректно.
Механизм: как обычный сетевой сбой превратился в трёхнедельную порчу кеша
Стоит разложить это на шаги отдельно, потому что цепочка неочевидная и легко повторяется в любой похожей конфигурации:
- Кратковременная потеря пакетов на аплинке совпала по времени со скачиванием зависимостей в CI-job.
npm ciначал писать тарболл пакета в локальный кеш потоково, и запись прервалась на середине из-за обрыва соединения.- Обработка ошибки в сборочном скрипте была построена через
||с повторным вызовом той же команды, из-за чего код возврата всей строки не отражал факт первого сбоя. - Повторный запуск с флагом
--prefer-offlineнашёл в локальном кеше частично записанный тарболл и посчитал его валидным для оффлайн-установки, не сверяя заново сintegrityиз лок-файла на этом этапе. - Раннер использует персистентный volume для кеша между job — испорченный артефакт остался лежать физически на диске раннера, а не исчез вместе с завершением job.
- Ключ кеша в GitLab CI был построен по хешу
package-lock.json, а не, например, по дате или номеру job — поэтому все последующие сборки с тем же лок-файлом получали тот же испорченный кеш, пока лок-файл не изменился. - Ни один из существующих тестов не проверял бинарное содержимое собранного бандла, поэтому дефект уходил в прод-образ незамеченным на каждом этапе конвейера.
Каждый шаг сам по себе — разумная оптимизация или разумная защита от флакинес. Проблема возникла именно на стыке: повтор без честного кода ошибки плюс оффлайн-режим плюс общий персистентный кеш плюс отсутствие верификации содержимого артефакта после сборки. Уберите любое одно звено — и три недели тихой порчи не случились бы.
Что мы изменили в пайплайне после инцидента
Правки внесли в порядке приоритета — от того, что закрывает конкретно этот сценарий, до того, что защищает от похожего класса проблем в целом.
Сначала убрали маскирование ошибок в скрипте установки:
set -euo pipefail
npm ci --no-audit
Без повторов «на всякий случай» и без || true. Если сеть действительно нестабильна — пусть падает job целиком, это дешевле, чем протухший кеш. Retry на нестабильную сеть перенесли на уровень самого раннера (несколько попыток запуска всей job), а не внутрь установки зависимостей.
Дальше отказались от офлайн-режима для сборок, которые собирают прод-образ:
npm ci --no-audit
Без --prefer-offline каждая релизная сборка обращается в реестр и заново проверяет integrity каждого пакета против лок-файла — это чуть медленнее, но исключает саму возможность подсунуть локально повреждённый артефакт вместо реального обращения в сеть.
Персистентный кеш зависимостей на раннере не убрали полностью — экономия по времени сборки реальная, — но добавили принудительную ротацию:
# cron на раннере, раз в неделю
find /srv/gitlab-runner/cache/npm -mtime +7 -delete
Плюс добавили отдельный шаг верификации сразу после установки, перед сборкой образа:
npm ls --all >/dev/null
npm audit signatures
Это не защищает от всех классов проблем, но ловит явное расхождение между тем, что реально установлено, и тем, что заявлено в дереве зависимостей.
Отдельно — то, что должно было поймать проблему сразу, а не через три недели: смоук-тест после сборки, который рендерит эталонный документ и сверяет контрольную сумму или минимальный размер файла с ожидаемым, а не просто проверяет факт создания файла:
node scripts/smoke-render.js --input fixtures/sample.json --out /tmp/out.pdf
test $(stat -c%s /tmp/out.pdf) -gt 50000
И последнее — завели отдельную бизнес-метрику «доля успешных экспортов без визуальных дефектов» вместо общего счётчика «запущено/завершено», с алертом на её падение. Технически ошибок не было и не будет в подобном сценарии по определению — значит нужна метрика, которая не зависит от того, бросает код исключение или нет.
Если вы держите свой раннер на арендованном сервере, стоит заранее продумать, где физически лежит кеш зависимостей и кто может пересечься с ним на одном диске — про это подробнее в статье про настройку GitLab CI/CD на VPS и в разборе частых проблем самого раннера в статье про частые ошибки GitLab CI Runner. Если зависимостей много и хочется вынести их скачивание и верификацию на отдельный сервис вместо локального кеша на раннере, посмотрите на связку с прокси-репозиторием — например, Nexus Repository в Docker Compose: он сам проверяет и хранит уже скачанные пакеты, и порча одного локального кеша на одном раннере перестаёт быть проблемой для всех остальных.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Почему npm ci не поймал повреждение через integrity из лок-файла?
Проверка целостности честно работает при скачивании из реестра. В нашем случае вторая попытка установки шла в офлайн-режиме из локального кеша, где npm посчитал файл уже проверенным по факту его наличия на диске, а не пересчитал контрольную сумму заново перед использованием.
Разве повтор команды через || — это не нормальная практика для нестабильной сети?
Сам повтор — нормальная идея. Проблема в том, что итоговый код возврата всей строки не отражал, что первая попытка реально провалилась на середине скачивания, и в кеше остался мусор от неудачной попытки, который затем использовался как валидный.
Можно ли было увидеть проблему быстрее без смоук-теста на контрольную сумму?
Да, если бы бизнес-метрика по качеству результата (а не по факту завершения процесса) собиралась отдельно и имела порог алертинга. Общий счётчик «запущено/завершено» в таких сценариях бесполезен, потому что процесс формально завершается успешно.
Стоит ли вообще держать персистентный кеш зависимостей между сборками?
Стоит, если это оправдано временем сборки, но кеш нужно рассматривать как недоверенный источник: периодически ротировать, не смешивать с офлайн-режимом для релизных сборок и по возможности выносить в отдельный прокси-репозиторий с собственной верификацией, а не хранить сырые тарболлы прямо на диске раннера.
Как быстро отличить порчу кеша от обычной регрессии в коде, если такое повторится?
Смотрите, коррелирует ли дефект с конкретным собранным образом (одинаковый на всех репликах одной сборки, разный между сборками) или с конкретным запросом/пользователем независимо от реплики. Первое почти всегда указывает на этап сборки, второе — на рантайм или код.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →