Здравствуйте, коллеги. Некоторое время назад нашей команде поставили задачу разработки службы, для работы с периферийным оборудованием, которая будет работать на рабочих машинах ОС Linux в виде сервиса.

До этого момента все десктоп приложения в нашем подразделении, разрабатывались только под ОС Windows. Текущая задача стала определенным вызовом для команды и способом освоить что - то новое. Критерием готовности моей команды, в этом проекте, стало написание самой программы и публикация ее в виде RPM пакета для дальнейшего распространения. Вот, что именно надо сделать, чтобы получить готовый пакет, как все настроить в песочнице (на своем ПК) и какие сложности удалось преодолеть и будет рассказано.

Технологический стэк:

  • Бэк NET Core 8

  • ОС Alt Linux рабочая станция К 10.4.

  • GitLab

  • Docker

Декомпозиция задачи

Гораздо проще выполнить задачу, если разделить ее на составные части и решить каждую из них по отдельности. Я выделил следующие шаги:

  • Разработать простейшую консольную программу на .NET Core 8.

  • Вручную собрать RPM пакет.

  • Настроить CI/CD.

  • Сформировать работающий пайплайн для GitLab и получить на выходе RPM пакет.

Разработка простейшей программы

Если сразу пытаться упаковывать готовую программу с множеством проектов и зависимостей, то можно столкнуться с неожиданными проблемами, которые будут отвлекать от цели.

На мой взгляд гораздо проще упаковать простенькую программу. На ней пройти все шаги. Решить все проблемы, которые связаны именно с упаковкой. Когда придет время упаковывать целевую программу - можно будет легко расширить уже созданный пайплайн и все ошибки будут связаны только с вопросами компиляции бинарных файлов.

Сказано - сделано. Была разработана простейшая консольная программа - счетчик, которая раз в секунду выводило число с инкрементом +1

// See https://aka.ms/new-console-template for more information
Console.WriteLine("Start counting");
await Counter();

Task Counter()
{
    int i = 0;
    while (true)
    {
        Console.WriteLine(i);
        i++;
        Thread.Sleep(1000);
    }
}

Результат работы

Следовательно, если после формирования пакета, установки его на свежей ОС и запуска из командой строки мы получим такой - же результат, значит все получилось. Если это будет сделано автоматически через пайплайн - вообще замечательно.

Сборка RPM пакета

При сборке пакета я опирался на данную инструкцию (ссылка). Рекомендую инструкцию к ознакомлению. Хотя - бы первую треть. Далее я буду описывать шаги, считая, что вы ознакомились с терминологией и основными принципами, что такое spec файл и как он устроен.

Вот еще неплохой ресурс (ссылка). Относится к ресурсам Fedora, но нюансы создания Live CD для Fedora и Alt наводят на мысль, что Alt опирался именно на этот дистрибутив. Это мое личное наблюдение, не более.

Чтобы собрать RPM пакет, надо последовательно выполнить следующие шаги:

  • Установить утилиту rpm-build

  • Конфигурация каталогов для сборки.

  • Сформирование spec файла

  • Подготовить файлы и переместить в нужные директории.

  • Вызвать команду rpmbuild -ba <Имя spec файла>

Установка утилиты rpm-build

sudo apt-get update && apt-get install -y rpm-build

Конфигурация каталогов для сборки

Можно сконфигурировать автоматически (ссылка). На данном этапе это удобно, но неудобно в пайплайне.

rpmdev-setuptree

Обратите внимание: В подавляющем большинстве статей, да и любая нейросеть вам подскажет, что создается каталог /home/user/.rpmbuild. Похоже, что именно в Alt Linux настроено иначе и создается каталог /home/user/RPM. Это не критично и я не проверял - сработает ли в пайплайне, если сделать все через папку .rpmbuild. Решил. что сделаем так, как есть.

Кроме этого в корне домашней директории сформируется файл .rpmmacros, который надо еще руками подправить - удалить лишний текст. В ссылке выше это можно прочесть.

Вывод: В пайплайне сделаем структуру каталогов вручную и поместим заранее сформированный файл .rpmmacros, который будет лежать в корне проекта.

Формирование spec файла

Самый важный шаг для сборки пакета - создание корректного spec файла.

Сам spec файл по духу очень похож на файл с описанием пайплайнов. Это манифест и набор инструкций, которые надо выполнить скрипту, чтобы получить скомпилированную программу и куда и как ее записать при установке. В статье, на которую выше давал ссылку достаточно хорошо описана структура, что такое макросы и последовательность их выполнения.

Обратите внимание: Потратил некоторое время на довольно глупую ошибку. SPEC файл изначально был сформирован в Windows и перевод строки был помечен \r\n. Линукс, на котором я делал сборку, это не принимал и выплевывал ошибку "Неверный код возврата". Пытался возвращать правильный код возврата через exit 0 и т.д. Потратил время. Удалил \r в файле - все заработало.

Интересное наблюдение, что если коммитить файлы в Git - таких проблем быть не должно. Он, вроде бы, сразу меняет и предупреждает об этом.

Пример spec файла

При формировании spec файла я предположил, что программа должна находиться в каталоге /opt/{name} а в /usr/bin/{name} должна быть ссылка на исполняемый файл, чтобы можно было запускать ее без указания пути.

Name:           utestrpm
Version:        0.0.0 #В пайплайне заменится на правильную версию
Release:        1%{?dist}
Summary:        Учимся собирать RPM пакеты

License:        MIT
Group:			Other
URL:            https://your-domain.com
Source0:        source_file.tar.gz #Не будем привязываться к версии. Будем всегда делать архив с одним именем

BuildArch:      x86_64 #Если будет noarch - не соберется. Т.к. будут присутствовать бинарники
BuildRequires:  dotnet-8.0 #Подтянет зависимости, если нет. 

%description
Первое приложение на Linux, собранное в пакет RPM

%prep
%setup -q

%build
# Сборка .NET приложения. Отключили проверку сертификатов
export DOTNET_NUGET_SIGNATURE_VERIFICATION=false 

# Публикуем приложение как standalone
dotnet publish -c Release -r linux-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true

%install
rm -rf %{buildroot}
mkdir -p %{buildroot}/opt/%{name}
mkdir -p %{buildroot}%{_bindir}

install -m 755 utestrpm/bin/Release/net8.0/linux-x64/publish/utestrpm %{buildroot}/opt/%{name}/%{name}

# Создаем символическую ссылку в /usr/bin
ln -sf /opt/%{name}/%{name} %{buildroot}%{_bindir}/%{name}

%post

%preun

%files
# Основной бинарный файл в /opt
/opt/%{name}/%{name}
# Символическая ссылка в /usr/bin
%{_bindir}/%{name}

# Дополнительные файлы если есть
# /opt/%{name}/config/appsettings.json

%changelog
- Ура! Работает.

Листинг прост и нагляден. Интуитивно понятно, что делается на каждой строке. Хочется заострить внимание на паре моментов.

%{buildroot} - это самый важный макрос в файле. При сборке пакета формируется виртуальная корневая система и этот макрос указывает на нее. При установке пакета это будет ссылка на корневую систему. Обычно это "/".

Так - же важно максимально использовать макросы. Например %{_bindir} в Alt Linux это "/usr/bin". В другом репозитории, возможно, это будет просто "/bin", или что - то еще. Поэтому, чем больше макросов, тем лучше. Но, какой макрос указывает на /opt я не нашел.

При сборке решения для .NET Core из ОС Linux я столкнулся с проблемой валидации сертификатов при обновлении NuGet пакетов. В интернете множество советов, как это решить. Мне помог способ отключения валидации. Считаю, что это допустимо и удобно. Помним - наша цель запустить все из пайплайна. Поэтому сложные решения могут не подойти.

export DOTNET_NUGET_SIGNATURE_VERIFICATION=false 

Так - же хочу отметить нюансы сборки .NET приложения. Я принял решение упаковать все решение в один исполняемый файл. Это значительно упростило создание spec файла. Возможно потом это может привести к проблемам - тогда будем собирать по старинке и копировать все содержимое, вместо одного файла.

dotnet publish -c Release -r linux-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true

Ну, и самое последнее, что хочу указать по поводу spec файла. Если внимательно посмотреть, то закомментирована строка 48, которая записывает конфигурационный файл. Это задел на развитие, когда я буду усложнять программу, чтобы отладить все нюансы работы под Linux.

Подготовка файлов

Важно! Обратите внимание: Самый важный нюанс на мой взгляд. Прочтите статью сначала! Все ту - же, если не читали еще. Иначе может быть непонятно.

Итак, у нас есть обязательное поле Version и есть поле Source. В примерах Source описывается обычно всегда так:

Source: %name-%version.tar

Это легко поменять, что и будет потом сделано в пайплайне, на статичное имя, что гораздо удобнее при формировании артефакта между этапами пайплайна.

Но! Вот тут и нюанс! Source файл может быть назван как угодно, но его структура жестко привязана к макросам %name и %version.

Скрипт rpmbuild распаковывает архив и делает команду

cd ~/RPM/BUILD/%name-%version

Естественно, подставляя нужные значения из spec файла. Если этого каталога не найдется - скрипт завершается с ошибкой.

Вот пример tar.gz архива, который должен быть у нашей программы, если ее имя будет utestrpm и версия 1.0.2, к примеру:

Значит, просто взять и упаковать в архив программу недостаточно. Надо создать каталог с именем %name-%version. В него поместить исходные файлы и именно его упаковать в архив.

Практически уверен, что это поведение можно настроить. Но зачем? Гораздо проще учесть эту особенность в пайплайне.

Вызов команды

Это самая простая часть. Вызываем команду и ждем успешного выполнения.

rpmbuild -ba utestrpm.spec

Обратите внимание: Важно, что подобная команда, по умолчанию, не может быть вызвана из под привилегированного пользователя (root или sudo). В интернете находил статью, почему это правильно. Не читал. Поверил на слово.

Вывод: В пайплайне придется заводить непривилегированного пользователя и выполнять команду из под него.

Настройка CI/CD

В принципе этот этап можно пропустить. Если уже настроен GitLab и настроены Runner`ы. Но, для того, чтобы все заработало так, как я хочу, мне понадобилось ровно 80 коммитов. И еще примерно столько - же понадобится, пока я закончу все эксперименты и буду готов применить все наработки в целевом проекте. Я не хочу засорять свой основной репозиторий этим мусором.

Решено было настроить GitLab и один Runner с помощью Docker на своем рабочем компьютере.

Установка GitLab

docker run --detach \
  --hostname 172.17.0.2 \
  --publish 443:443 --publish 80:80 --publish 22:22 \
  --name gitlab \
  --restart always \
  gitlab/gitlab-ce:latest

Обратите внимание на hostname. Вам, возможно, придется использовать другое значение. Какое и почему я опишу ниже.

После установки GitLab (запускается несколько минут) вы сможете зайти по пути http://localhost

Логин для входа root. Пароль можно узнать, если выполнить команду

 docker exec -it gitlab cat /etc/gitlab/initial_root_password

Обратите внимание: Важно выполнить команду достаточно оперативно. Т.к. файл будет через некоторое время удален.

Установим GitlLab. Создадим группу и проект. Все должно выглядеть примерно вот так.

Создание Runner в GitLab

Тут важно отметить, что может возникнуть путаница. В самом GitLab необходимо завести некую сущность, которая называется Runner. Эта сущность будет иметь принадлежность (раннеры бывают локальными, групповыми и т.д.). Она будет иметь настройки - когда запускаться, на каких проектах и т.д.

В текущей статье я создал один групповой раннер. Чтобы его создать надо зайти в раздел группы. В моем примере она называется lnx и вызвать Build/Runners

Вы увидите раннеры. У меня один - этого достаточно. А так - же кнопку - создать новый.

Создадим новый раннер, который будет работать при всех коммитах

Обратите внимание, на какой адрес он меня перекинул, после нажатия кнопки "Create runner". Это важно! Ниже объясню почему.

Этот IP появился, потому - что я назначил такой hostname. Достаточно просто заменить в браузере на localhost и мы увидим то, что нам надо. Это временное неудобство, которое, в идеале, нам придется потерпеть только один раз, но оно сэкономит много наших сил далее. Очень скоро объясню почему.

Поменяли URL и видим следующую картину

Обратите внимание: Обязательно скопируйте текст, который выделен восклицательным знаком. Повторно к нему не удастся обратиться. Он нужен для окончательной настройки.

Если вернуться обратно на страницу со списком раннеров мы увидим зарегистрированный раннер со статусом "never contacted"

Создание и регистрация контейнера gitlab-runner

Как писал ранее - раннер это и сущность в гитлабе и специальная программа, которая ставится на какую - то рабочую машину. Именно эта программа будет выполнять команды пайплайна. Чтобы понять, какую именно машину GitLab будет вызывать надо связать раннер гитлаба с этой машиной.

Обычно, для создания раннеров прекрасно подходят виртуальные машины. Их можно сделать много, им будут выделены требуемые ресурсы и т.д. Для того, чтобы отработать работу пайплайна достаточно докер образа, который будет иметь внутри себя установленную программу раннер. Чтобы его установить надо выполнить команду.

docker run -d --name gitlab-runner --restart always \
  -v /srv/gitlab-runner/config:/etc/gitlab-runner \
  -v /var/run/docker.sock:/var/run/docker.sock \
  gitlab/gitlab-runner:latest

В итоге у нас есть два докер образа

Последним шагом идет регистрация раннера. Надо вызвать команду

docker exec -it gitlab-runner /bin/bash

Мы подключимся к консоли докер контейнера. В ней надо вставить тот скопированный текст. В каждом случае токен будет отличаться.

gitlab-runner register  --url http://172.17.0.2  --token glrt-570XNtiakleL5Dn_tzWUrmc6MwpvOjEKdDoyCnU6MQ8.01.171afrob3

Далее нас попросят указать адрес - оставляем по умолчанию. Дать имя раннеру. Указать способ запуска раннера - пишем docker. И указать образ по умолчанию. Я указывал alt:p10.

Раннер в GItlab должен засветиться зеленым. Иметь статус "online". Ура - у нас все почти готово. Дело за малым - написать пайплайн.

Почему так много внимание уделено адресу 172.17.0.2

Прежде, чем идти дальше стоит, наконец - то объяснить, откуда взялся hostname 172.17.0.2. Я опишу способ, как легко и быстро, но немного неправильно, настроить корректную работу раннера без лишних трудозатрат. Подходит только для проверки гипотез и временной меры. Не более.

Дело в том, что докер контейнеры общаются между собой через свою сеть, за организацию которой отвечает Docker. Можно в докере добавить свою сеть, назначить диапазон IP адресов, связать каждый образ с определенным IP адресом в этой сети. Это правильно. Но мы делаем MVP решение, которое нам нужно только для одной цели. Тратить время на идеальную настройку докера мне кажется лишним.

Решение - посмотреть сеть по умолчанию и узнать, какой IP адрес назначен нашему контейнеру gitlab. Выполним команду

docker network ls

В моем случае я получил следующий результат

Обратите внимание на сеть под именем bridge. Если вы ее не удаляли, то она есть всегда. Любой контейнер, который будет создан связывается с этой сетью.

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

docker network inspect bridge

Вот и этот IP адрес.

Обратите внимание: Так - как мы не закрепляли за контейнером gitlab этот адрес, то, после перезагрузки компьютера первым, скорее всего запустится контейнер gitlab-runner. Тогда он займет этот IP адрес. Это легко вылечить остановив все контейнеры и первым запустив gitlab, а потом gitlab-runner.

Столько текста, а все еще нет понимания, зачем это так важно.

Важно это потому, что внутри контейнера gitlab-runner тоже есть Docker. И наш пайплайн будет запускаться во внутреннем Docker. Скачивать там образ alt:p10. Подгружать туда исходные файлы на каждом шагу и так далее.

Это просто классический пример подхода Docker in Docker.

Указав наш хостнейм мы сконфигурировали гитлаб так, что раннер будет обращаться к нему не по доменному имени, а по IP. И это будет работать.Примерно так http://172.17.0.2/groups/lnx/utestrpm.

Если бы наш gitlab был настроен "как положено" и ему бы было назначено корректное доменное имя, которое доступно в вашей сети, то нет проблем. Можно бы было успешно обратиться по корректному hostname. Он был бы, к примеру, gitlab.tools.mycompany.ru.

В нашем случае, на локальной машине, мы бы столкнулись с трудностями. Пришлось бы указывать hostname в hosts, перезагружаться. Все равно следить, чтобы нужный IP адрес был назначен для контейнера, или делать свою сеть в докере и т.д. и т.п. Как по мне - проще указать в виде hostname IP адрес и все будет работать.

Создание пайплайна

Что такое пайплайн и для чего он нужен вы и так знаете. Если не знаете - любая нейросеть вам об этом охотно расскажет. Приведем пример работающего пайплайна для GitLab, который соберет RPM пакет и рассмотрим важные моменты.

stages:
  - setup
  - build

variables:
  RPM_USER: "pipe_builder"
  RPM_DIR: "/home/$RPM_USER/RPM"

prepare_tar:
  stage: setup
  script:       
    - apt-get update && apt-get install -y jq 
    
    # Получаем версию и сразу создаем архив
    - VERSION=$(jq -r '.service' utestrpm/env.json)
    - echo "VERSION=$VERSION" > version.env
    - FOLDER="utestrpm-$VERSION"
    
    # Создаем архив в одну команду
    #--transform "s,^,$FOLDER/," - магия переименования! 
    #   --transform - флаг для преобразования путей в архиве
    #   s,^,$FOLDER/, - sed-подобное выражение замены
    #       s - команда substitute (замена)
    #       ^ - регулярное выражение, означающее "начало строки"
    #       $FOLDER/ - что подставляем вместо начала строки
    #       Запятые - разделители (можно использовать /, но запятые удобнее)
    #           Если $FOLDER="utestrpm-1.0.0", то:
    #           Файл utestrpm.sln → попадет в архив как utestrpm-1.0.0/utestrpm.sln
    #           Файл utestrpm/main.cs → utestrpm-1.0.0/utestrpm/main.cs
    - tar -czf source_file.tar.gz --transform "s,^,$FOLDER/," utestrpm.sln utestrpm/
    
    # Генерируем spec файл
    - sed "s/^Version:.*/Version:$VERSION/" utestrpm_template.spec > utestrpm.spec
    
  artifacts:
    paths:
      - source_file.tar.gz          
      - utestrpm.spec
      - version.env
    expire_in: 1 hour

build_rpm:
  stage: build
  script:
    # Загружаем переменные
    - source version.env
    - echo "VERSION=$VERSION"
    
    # Установка только необходимых пакетов
    - apt-get update && apt-get install -y su dotnet-sdk-8.0 rpm-build
    
    # Создаем пользователя и структуру каталогов
    - useradd -m -s /bin/bash $RPM_USER
    - mkdir -p $RPM_DIR/{SOURCES,SPECS,RPMS,SRPMS}
    
    # Копируем файлы
    - cp source_file.tar.gz $RPM_DIR/SOURCES/
    - cp utestrpm.spec $RPM_DIR/SPECS/
    - cp .rpmmacros /home/$RPM_USER
    
    # Установим права
    - chown -R pipe_builder:pipe_builder /home/pipe_builder/
    
    # Выполняем сборку от имени пользователя
    - su - $RPM_USER -c "cd $RPM_DIR/SPECS && rpmbuild -ba utestrpm.spec"
    
    # Копируем и показываем результат
    - cp $RPM_DIR/RPMS/x86_64/*.rpm ./
    - ls -al *.rpm
        
  artifacts:
    paths:
      - ./*.rpm
    expire_in: 2 weeks
        
  needs:
    - prepare_tar

Подготовка архива (prepare_tar)

Этот этап предназначен для формирования артефактов, необходимых для успешной отработки скрипта rpmbuild.

Первым шагом определим версию приложения. При реальной разработке инкремент версии крайне важен. Значение версии было помещено в файл env.json, который лежит в проекте.

{
  "service": "1.0.2"
}

Была установлена утилита jq и с помощью нее найдена версия. Она помещена в переменную $VERSION. Так - же, для удобства сразу определили имя корневой папки будущего архива

# Получаем версию и сразу создаем архив
    - VERSION=$(jq -r '.service' utestrpm/env.json)
    - echo "VERSION=$VERSION" > version.env
    - FOLDER="utestrpm-$VERSION"

Следующий этап - генерация архива. Команда tar очень многогранна и сложна, поэтому не поскупился на описание, как это работает в комментариях.

# Создаем архив в одну команду
    #--transform "s,^,$FOLDER/," - магия переименования! 
    #   --transform - флаг для преобразования путей в архиве
    #   s,^,$FOLDER/, - sed-подобное выражение замены
    #       s - команда substitute (замена)
    #       ^ - регулярное выражение, означающее "начало строки"
    #       $FOLDER/ - что подставляем вместо начала строки
    #       Запятые - разделители (можно использовать /, но запятые удобнее)
    #           Если $FOLDER="utestrpm-1.0.0", то:
    #           Файл utestrpm.sln → попадет в архив как utestrpm-1.0.0/utestrpm.sln
    #           Файл utestrpm/main.cs → utestrpm-1.0.0/utestrpm/main.cs
    - tar -czf source_file.tar.gz --transform "s,^,$FOLDER/," utestrpm.sln utestrpm/

Следующим шагом я формирую правильный для текущей версии spec файл. Проще всего просто заменить в базовом файле - шаблоне версию и переименовать его, как надо.

# Генерируем spec файл
    - sed "s/^Version:.*/Version:$VERSION/" utestrpm_template.spec > utestrpm.spec

Все, что нам нужно отправляем на следующий шаг. Это правильный архив и правильный spec файл. Ну, и версию заодно. Файл .rpmmacros и так уже лежит в проекте. Он менять не будет никогда. На этапе сборки его скопируем куда надо.

Публикация RPM (build_rpm)

Завершающий штрих. Первым делом установим требуемые зависимости. Это сам пакет rpm-build, среда dotnet-sdk и утилита su, которая нам пригодится для того, чтобы скрипт не выпал с ошибкой (под root нельзя).

Следующим шагом создадим пользователя, его домашнюю папку и в ней корректную структуру каталогов.

# Создаем пользователя и структуру каталогов
    - useradd -m -s /bin/bash $RPM_USER
    - mkdir -p $RPM_DIR/{SOURCES,SPECS,RPMS,SRPMS}

Артефакты необходимо скопировать и сменить хозяина. Сейчас root, а должен быть наш служебный пользователь.

TODO: Путь захардкожен - надо поправить :)

 # Копируем файлы
    - cp source_file.tar.gz $RPM_DIR/SOURCES/
    - cp utestrpm.spec $RPM_DIR/SPECS/
    - cp .rpmmacros /home/$RPM_USER
    
    # Установим права
    - chown -R pipe_builder:pipe_builder /home/pipe_builder/
    

Ну, и самое приятное - запускаем скрипт

# Выполняем сборку от имени пользователя
    - su - $RPM_USER -c "cd $RPM_DIR/SPECS && rpmbuild -ba utestrpm.spec"
    
    # Копируем и показываем результат
    - cp $RPM_DIR/RPMS/x86_64/*.rpm ./
    - ls -al *.rpm

Обратите внимание: Если просто выполнить su, то результат будет отрицательным. Т.к. в текущем "потоке" мы все равно останемся под root. Можете вставить команду whoami и убедиться в этом.

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

Итог

Не знаю, как у вас, а у меня все получилось. RPM пакет создан. Версия та, которая нужна. Пакет доступен для скачивания. В реальном проекте добавятся тесты, проверки/перепроверки, публикация и разворачивание в песочнице, сохранение пакета в nexus, но это уже другая история. Скорее всего наращивать функционал будут уже девопсы.

Сейчас этап исследований не завершен. Но результатов хватило на эту статью. Необходимо доработать следующие шаги, чтобы переключиться на перенос наработок в реальный проект:

  • Сделать программу службой, настроить автозапуск (systemctl enable), поправить spec файл так, чтобы это настраивалось при установке пакета.

  • Корректно ввести логи, чтобы их можно было прочитать через journalctl

  • Добавить файлы конфигурации типа appsettings.json. Убедиться, что они устанавливаются и применяются.

Возможно напишу еще одну статью. Но, она будет короче это точно.

Спасибо за внимание. Надеюсь кому - то это поможет.

Комментарии (1)


  1. HomeMan
    02.10.2025 12:16

    В строке:

    install -m 755 utestrpm/bin/Release/net8.0/linux-x64/publish/utestrpm %{buildroot}/opt/%{name}/%{name}

    поменяйте utestrpm на %{name}