
У меня был «полностью готовый» проект. Демон виртуального последовательного порта 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».
|
Что закрутил |
Зачем |
|---|---|
|
|
по умолчанию демону не нужна ни одна привилегия-capability |
|
|
разрешить только «нормальный» для службы набор системных вызовов |
|
|
файловая система только для чтения, чужие процессы не видны |
|
|
только нужные виды сокетов, запрет создания пространств имен |
|
|
стандартная «гигиена» против целого класса трюков |
Что закрыть нельзя — и почему: 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/