«У меня работает»: десять способов узнать, что нет

от автора

У меня был «полностью готовый» проект. Демон виртуального последовательного порта vseriald собирался у меня на машине четырьмя пресетами, проходил все тесты, был чист под valgrind, генерировал документацию без единого предупреждения. Я решил, что пора выпускать версию 1.0, поставил тег — и впервые по-настоящему запустил CI целиком.

Дальше был парад граблей. Не один-два «упс», а десяток подряд: пайплайн то не компилировался вовсе, то падал на сборке, то на скачивании артефактов, то на упаковке. И почти каждая проблема была невидима на машине разработчика по совершенно банальной причине: у меня другая версия компилятора, другая версия CMake, другой Doxygen и другая сеть, чем на чистом раннере.

Это и есть мораль, которую вынесу вперед: CI ловит ровно то, что не видно локально — версии инструментов, окружение и сеть. В этой статье — конкретные грабли по порядку, с настоящими сообщениями об ошибках, причинами и тем, как я их чинил. А в конце — как из всего этого выросли два полезных приема: воспроизведение окружения у себя и ускорение пайплайна в три с лишним раза.

Проект, о котором речь, открыт (читать, форкать, заводить задачи): https://gitlab.com/trgv/vspd. Это вторая из двух статей. О внутренностях самого демона (псевдотерминал, цикл событий, эмуляция RS485) — в первой, «COM-порт из ничего: PTY, epoll и немного RS485-боли»; здесь только про дорогу к релизу.

Вводные данные

CI у меня на GitLab. Пайплайн по стадиям: проверка стиля → сборка (матрица: Ubuntu 22.04 и 24.04 × GCC и Clang, плюс параллельная сборка Bazel) → тесты → анализ безопасности → санитайзеры (ASan + UBSan) → valgrind → документация (Doxygen) → упаковка (.deb и архив исходников) → публикация релиза.

Важная деталь: матрица намеренно гоняет сборку и на 22.04, и на 24.04. Это разные компиляторы (GCC 11 против GCC 13), разные версии CMake (3.22 против 3.28) и разные версии вспомогательных утилит. Как выяснилось — именно эти различия и ловили почти все.

И еще одна вводная: бесплатные общие раннеры GitLab у меня к тому моменту исчерпали лимит минут, поэтому пайплайн поехал на собственном раннере (Docker executor), поднятом на отдельном сервере. Часть «сетевых» грабель вылезла именно из-за этого — раннер живет в другой сети, чем инфраструктура GitLab.

Грабли по порядку

1. Пайплайн не компилируется вообще

Самый первый запуск не дошел даже до сборки: GitLab отказался разбирать конфигурацию.

jobs:...:cache:key:files config has too many items (maximum is 2)

Оказывается, в cache:key:files можно указать максимум два файла. У меня их было три. Чинится в одну строку — оставить два самых важных. Урок скучный, но честный: серверный линтер конфигурации (glab ci config compile или вкладка CI Lint) надо прогонять до пуша, а не после.

2. Версия пресетов против CMake на Ubuntu 22.04

Дальше посыпались задачи сборки на 22.04:

CMake Error: Could not read presets ... Unrecognized "version" field

В CMakePresets.json стояла "version": 6. А шестая версия схемы пресетов требует CMake 3.25+. На Ubuntu 22.04 системный CMake — 3.22, и он эту версию не понимает. При этом я в требованиях сам же написал «CMake >= 3.22» — то есть нарушил собственный минимум.

Фикс: понизил версию схемы до 3 (я реально использовал только возможности, доступные с 3.21 — пресеты сборки, тестов, наследование). После этого пресеты читаются и на 3.22, и на новых. Урок: заявленный минимальный CMake надо проверять на этом самом минимальном, а не на том, что стоит у тебя.

3. FetchContent и ключи, которых нет в старом CMake

Пресеты читаются, но сборка на 22.04 падает на этапе генерации: не найдены цели зависимостей.

Target "vseriald_unit_tests" links to target "spdlog::spdlog" but thetarget was not found.

Я подтягиваю зависимости (spdlog, yaml-cpp, googletest) через FetchContent, и в объявлении писал так:

FetchContent_Declare(spdlog ... EXCLUDE_FROM_ALL SYSTEM)

Ключевые слова SYSTEM (пометить заголовки как системные) и EXCLUDE_FROM_ALL появились у FetchContent_Declare только в CMake 3.25 и 3.28 соответственно. На 3.22 они ломают подтягивание — и цели зависимостей просто не создаются.

Убрать их нельзя бездумно: пометка SYSTEM мне нужна, чтобы строгий -Werror не валился на предупреждениях из чужих заголовков (того же spdlog). Поэтому я убрал несовместимые ключи, а «системность» включаемых каталогов вернул через свойство цели, которое работает и на старом CMake:

# Заголовки зависимостей — как системные, чтобы -Werror не падал на чужом# коде. Ключ SYSTEM у FetchContent_Declare требует CMake 3.25; это свойство# делает то же самое на базовой версии 3.22.function(_vseriald_mark_includes_system tgt)  if(TARGET ${tgt})    get_target_property(_inc ${tgt} INTERFACE_INCLUDE_DIRECTORIES)    if(_inc)      set_target_properties(${tgt} PROPERTIES                            INTERFACE_SYSTEM_INCLUDE_DIRECTORIES "${_inc}")    endif()  endif()endfunction()

4. GCC 11 строже нового GCC

Сборка 22.04 под GCC доехала до компиляции и упала:

error: useless cast to type 'mode_t' {aka 'unsigned int'} [-Werror=useless-cast]

В коде был static_cast<mode_t>(mode), где mode уже имеет тип mode_t. Новый GCC у меня на машине такой каст лишним не считает, а GCC 11 на 22.04 — считает, и с -Werror это ошибка. Фикс тривиальный — убрать лишний каст:

// было:  ::chmod(path.c_str(), static_cast<mode_t>(mode));::chmod(path.c_str(), mode);

Урок прямой: матрица компиляторов нужна не для галочки; разные версии — разный набор предупреждений.

5. Артефакты между задачами и таинственный 403

Сборка прошла, но тестовые задачи, которым нужен результат сборки, не смогли его скачать:

Downloading artifacts for build:cmake:gcc:debug:24.04 ...ERROR: Downloading artifacts from coordinator... 403 Forbidden

Раннер пытался забрать артефакт напрямую с CDN GitLab (cdn.artifacts.gitlab-static.net), получал редирект и упирался в 403. На общих раннерах GitLab это работает, на моем — нет. Лечится флагом раннера FF_USE_DIRECT_DOWNLOAD=false: тогда артефакты качаются через координатор, а не напрямую с CDN. После перезапуска раннера тестовые задачи позеленели. Урок: на собственном раннере «прямое скачивание» артефактов может не пускать — это известная особенность.

6. ASan-бинарь под valgrind

Стадия санитайзеров упала странно:

==NNNN== Memcheck, a memory error detectorASan runtime does not come first in initial library list ...

Видно, что ASan-собранный бинарь зачем-то запускается под valgrind. А ASan и valgrind несовместимы в принципе. Причина: вспомогательная функция в CMake регистрировала «valgrind-обертки» для тестов всегда, когда в системе есть valgrind — не глядя на то, что текущая сборка уже инструментирована санитайзером. В санитайзер-окружении valgrind тоже стоял, и обертки регистрировались поверх ASan-бинарей.

Фикс: в санитайзер-сборке не регистрировать ни valgrind-, ни callgrind-тесты вообще. Я это и в документации писал — «санитайзеры несовместимы с valgrind», — но в самой конфигурации сборки это правило раньше нигде не было прописано. Урок: правило, записанное только в документации, рано или поздно нарушится в коде; его надо выражать в самой системе сборки.

7. Тайминговые тесты не дружат с инструментацией

Следом в той же стадии иногда падал тест «медленного потребителя» — тот, что проверяет: если получатель не успевает, демон притормаживает источник и ничего не теряет. Под замедлением ASan тайминги «плыли», и тест становился нестабильным (в одном прогоне зеленый, в другом красный).

Эти тесты завязаны на заполнение буферов ядра и EAGAIN, и под санитайзером или valgrind, которые все заметно замедляют, это ненадежно. Я исключил их из санитайзер-прогона — ровно так же, как они уже были исключены из прогона под valgrind. Их память и так покрыта модульными тестами, а полную «тяжелую» проверку делают обычные, неинструментированные тесты. Урок: тайминг-зависимые тесты и санитайзеры/valgrind — плохие соседи; разделяйте.

8. Doxygen на раннере не такой, как у вас

Стадия документации с WARN_AS_ERROR упала на десятке предупреждений вида:

warning: unable to resolve reference to 'kBlock' for \ref command

На 22.04 Doxygen — версии 1.9.1, и он не умеет разрешать ссылку на элемент перечисления (@ref kBlock, где kBlock — значение enum). Мой локальный Doxygen новее и такие ссылки разрешает, поэтому локально все было чисто. Фикс: для ссылок, которые старый Doxygen не тянет, использовать обычный код в кавычках вместо @ref. Полный список предупреждений Doxygen выдает за один прогон, так что чинится разом. Урок: версия генератора документации на раннере — тоже часть окружения.

9. Упаковка тянет неочевидное

Дошли до сборки .deb:

CPackDeb: file utility is not available ...CPack Error: Error when generating package

Оказалось, cpack -G DEB для вычисления зависимостей пакета зовет утилиту file, а ее в минимальном образе не было. Одна строка в установку зависимостей — и пакет собирается. Урок: упаковка тянет вспомогательные утилиты, которых нет в чистом образе; их легко забыть, потому что локально они обычно уже установлены по умолчанию.

10. Реестр контейнеров недоступен из сети раннера

Самая поучительная сетевая “заковыка”. Задачи анализа безопасности (и позже — публикация релиза через служебный образ) не смогли скачать свои образы:

dial tcp 35.227.35.254:443: i/o timeout

При этом gitlab.com (git), Docker Hub и другие адреса с того же сервера были доступны — а вот registry.gitlab.com упорно отваливался по таймауту. Локальный фаервол открыт, трассировка маршрута доходит до сети Google — значит, блокировка где-то выше, у провайдера, и именно до конкретного адреса реестра. Это оказалось проблемой сети сервера, а не конфигурации. Об этой “заковыке” — отдельный разговор ниже, потому что у нее обнаружилась вторая голова.

Метод: воспроизводим окружение локально

После третьей-четвертой «версионной» грабли я перестал чинить их по одной через очередь CI (каждый прогон — это минуты ожидания) и сделал очевидное: воспроизвел проблемное окружение у себя.

  • Поставил себе ровно CMake 3.22.6 в отдельное изолированное окружение и пересобрал проект под ним с -Werror. Это сразу подтвердило, что фикс с пресетами и INTERFACE_SYSTEM_INCLUDE_DIRECTORIES действительно совместим с 3.22 — без гадания.

  • Поставил g++-11 и собрал весь проект им. Один прогон показал все места, которые не нравятся именно одиннадцатому GCC, а не по одному через CI.

Это банально, но это и есть главный вывод про «версионные» грабли: не угадывай, что не так на старом тулчейне, — поставь этот старый тулчейн рядом и собери. Полчаса на установку экономят часы хождения по кругу с пайплайном.

Свой раннер: когда кончилась квота

Параллельно со всем этим закончился лимит минут общих раннеров, и пайплайн встал в очередь «ожидания». Поэтому я поднял собственный раннер на отдельном сервере (Docker executor) и подключил его к проекту как проектный раннер, который берет задачи без тегов.

Сам по себе раннер ставится за пять минут, но именно он обнажил сетевые грабли из пунктов 5 и 10 (другая сеть) и потребовал двух настроек, которые на общих раннерах сделаны за тебя:

  • FF_USE_DIRECT_DOWNLOAD=false — чтобы артефакты качались через координатор (см. грабли 5).

  • Монтирование docker-сокета в задачи — это понадобилось чуть позже, чтобы собирать собственный образ прямо на хосте раннера (об этом — следующий раздел).

Вывод: «поднять раннер» — это не только gitlab-runner register. Это еще и понять, что сеть и окружение раннера теперь твои, а не GitLab.

Релиз руками, когда раннеров нет

Пока квота общих раннеров была исчерпана, а свой еще не стоял, релиз 1.0 надо было как-то выпустить. Оказалось, для этого CI не обязателен: все, что делает стадия публикации, несложно повторить вручную.

  • Архив исходников — это git archive из тега: git archive --format=tar.gz --prefix=vseriald-1.0.0/ v1.0.0 -o vseriald-1.0.0.tar.gz.

  • Пакет .deb — это локальная сборка release-пресета и cpack -G DEB.

  • Сам релиз с прикрепленными файлами — одна команда glab release create с этими артефактами.

Так я выпустил 1.0.0 руками. А когда поднял свой раннер и наладил доступ к реестру, следующий релиз (1.0.1) проехал по полному пайплайну сам: собрал .deb для обеих Ubuntu, архив исходников и создал релиз без единого ручного шага. Полезно знать оба пути: автоматический — норма, ручной — страховка на случай, когда инфраструктура недоступна.

Ускорение: предсобранный образ

Когда пайплайн наконец поехал целиком, всплыла новая боль: каждая задача начинала с установки всего тулчейна — компиляторов, CMake, valgrind, Doxygen, утилит. На матрице из десятка задач это съедало кучу времени: полный прогон занимал около 53 минут.

Решение классическое — предсобранный образ. Я вынес весь тулчейн в собственный образ (по одному на каждую базовую Ubuntu):

ARG BASE=ubuntu:24.04FROM ${BASE}RUN apt-get update -qq && apt-get install -y --no-install-recommends \      build-essential cmake ninja-build git clang clang-format clang-tidy \      valgrind pkg-config file libsystemd-dev doxygen graphviz shellcheck ... \ && rm -rf /var/lib/apt/lists/*# + python-инструменты и bazelisk

Задачи стали стартовать уже готовыми, без установки пакетов на каждый запуск. Полный прогон ужался примерно до 15 минут — в три с лишним раза быстрее.

До и после

До и после

Дальше нужно было решить, кто и когда пересобирает этот образ. Я сделал отдельную задачу build:image, которая пересобирает и публикует образ автоматически, когда меняется его описание (ci/Dockerfile), и доступна вручную в остальных случаях. Тонкость, из-за которой я выбрал сборку на хостовом docker-демоне раннера (а не отдельным инструментом): раннер использует образ по политике «нет локально — скачай». Если собрать образ другим инструментом и просто запушить в реестр, локальная копия на раннере с тем же тегом останется старой, и задачи будут брать ее, а не свежую. Сборка через хостовый docker одновременно обновляет и реестр, и локальный кэш раннера — устаревания не возникает.

Вывод: установка тулчейна в каждой задаче — это самый дешевый способ потерять время; предсобранный образ окупается с первого же полного прогона.

Реестр: две головы одного дракона

Вернусь к граблям 10. Доступ к registry.gitlab.com со стороны сети я в итоге наладил (это решалось на сетевом уровне). Но образы все равно не тянулись — и сообщение об ошибке указало на причину:

failed to pull ...: 403 Forbidden... from GET https://cdn.registry.gitlab-static.net/.../blobs/...

Оказалось, что манифест образа и его слои отдаются с разных хостов: API-хост registry.gitlab.com отвечает за «где лежит образ», а собственно слои — за подписанными ссылками на cdn.registry.gitlab-static.net. Я открыл доступ к первому, а второй остался недоступен — и docker pull доходил до скачивания слоев и падал.

Вывод, который я унес: «реестр недоступен» — это часто не один адрес. Когда docker pull валится на скачивании слоев, первым делом смотри, с какого именно хоста идет запрос за слоями, а не только пингуй основной адрес реестра.

Усиление защиты systemd: двигай метрику, а не ощущение

Пока чинил релиз, заодно довел до ума юнит systemd. Удобно, что у systemd есть встроенная оценка «насколько служба опасна» — systemd-analyze security, шкала от 0 (паранойя) до 10 (все открыто). Стартовое значение моего юнита было 8.3 — «EXPOSED». После осмысленного закручивания гаек вышло 1.9 — «OK».

Что закрутил

Зачем

CapabilityBoundingSet= (пусто)

по умолчанию демону не нужна ни одна привилегия-capability

SystemCallFilter=@system-service

разрешить только «нормальный» для службы набор системных вызовов

ProtectSystem=strict, ProtectProc=invisible

файловая система только для чтения, чужие процессы не видны

RestrictAddressFamilies, RestrictNamespaces

только нужные виды сокетов, запрет создания пространств имен

MemoryDenyWriteExecute, LockPersonality, NoNewPrivileges

стандартная «гигиена» против целого класса трюков

Что закрыть нельзя — и почему: PrivateNetwork (демону нужна сеть для TCP-транспорта), PrivateDevices (символьная ссылка должна жить в настоящем /dev), запуск от root (нужен для создания узла в /dev и для самого сброса привилегий). Я не убирал то, что демону реально нужно, а остальное закрыл — и проверил живьем, что служба при этом нормально создает порт и работает.

Мораль та же, что и со всем релизом: если у проблемы есть числовая метрика — двигай метрику, а не «ощущение безопасности».

Каждый баг — отдельный тест

Отдельная практика, которая хорошо легла на всю эту историю: каждый пойманный баг закрывается регрессионным тестом, который воспроизводит именно ту поломку и покраснел бы, если фикс откатить. Тот самый псевдотерминал из первой статьи, который после закрытия порта грузил ядро на 100%, — это bug_0001 с тестом, который я специально проверил «на красноту», временно убрав фикс. Грабли из CI — это не только «починил и забыл», это еще и повод дописать проверку, чтобы то же самое не вернулось тихо.

Что бы я сделал иначе

  • Включал бы CI с первого дня, а не перед релизом. Тогда грабли приходили бы по одной и в контексте свежих изменений, а не все сразу в день выпуска.

  • Гонял бы матрицу версий с самого начала. Минимальный CMake, старый GCC, старый Doxygen — это не формальность из README, а реальная среда, в которой код должен собираться.

  • Воспроизводил бы окружение локально сразу. Поставить себе ровно тот CMake 3.22 и тот GCC 11, что на раннере, — самый быстрый способ чинить «версионные» грабли пачкой.

  • Помнил бы, что сеть раннера — часть окружения. Доступность реестров, CDN, артефактов зависит от того, откуда раннер ходит в интернет, а слои образа могут жить не там, где манифест.

Сами по себе эти пункты несложные. Сложно другое — то, что все это невидимо, пока пайплайн не отработает целиком хотя бы один раз. Так что если у вас «все готово и локально зелено», но CI ни разу не проходил от начала до конца, — у меня для вас плохие новости и десяток пунктов выше.

Если вы попали сюда мимо первой статьи, а интересно, что у этого демона внутри (псевдотерминал, который грузил ядро на 100%, однопоточный epoll-цикл и эмуляция RS485), — это в «COM-порт из ничего: PTY, epoll и немного RS485-боли».

Исходники открыты, можно форкать и заводить задачи: https://gitlab.com/trgv/vspd. Вопросы и истории своих «локально-зеленых» грабель — в комментарии.

ссылка на оригинал статьи https://habr.com/ru/articles/1044402/