В предыдущей статье рассмотрены основы сборки RPM пакета и автоматизации процесса.

Данная публикация завершает цикл. Продемонстрировав практическую реализацию готового решения, включая:

  • Разработку Web сервиса.

  • Регистрацию сервиса в качестве системной службы с автозагрузкой (systemd) при установке пакета.

  • Внедрение централизованного логирования через journald.

В результате создана полнофункциональная заготовка для быстрого развертывания сервисов с последующей публикацией их в RPM пакет.

Разработка Web сервиса

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

  • Являться вэб приложением. Запускаться занимая порт 5432.

  • При запуске в фоне запускать счетчик

  • Реализовать эндпоинт GetValue, при обращении к которому, в качестве ответа получить текущее значение счетчика

  • Обеспечивать логирование основных операций:

    • Старт вэб сервиса

    • Старт счетчика

    • Фиксация факта обращения к эндпоинту

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

dotnet new webapp -n <Название проекта>

Можно изменить исходное приложение, внеся следующие изменения в csproj файл

<Project Sdk="Microsoft.NET.Sdk.Web">

    <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net8.0</TargetFramework>
        <ImplicitUsings>enable</ImplicitUsings>
        <Nullable>enable</Nullable>
    </PropertyGroup>

    <ItemGroup>

        <PackageReference Include="Microsoft.AspNetCore" Version="2.3.0" />
        <PackageReference Include="Microsoft.AspNetCore.Hosting" Version="2.3.0" />
        <PackageReference Include="NLog" Version="6.0.5" />
        <PackageReference Include="NLog.Web.AspNetCore" Version="6.0.5" />
    </ItemGroup>

</Project>

Ключевое изменение - поменять тип Sdk (строка 1)

Так - же обратите внимание на требуемые NuGet пакеты. В примере их всего четыре. Microsoft.AspNetCore и Microsoft.AspNetCore.Hosting нужны для работы Web службы. Остальные два - это пакеты для обеспечения логирования, через библиотеку NLog.

Настройка логирования

Для логирования в моем примере выбрана библиотека Nlog по следующим причинам:

  • В командах компании это стандартное решение для обеспечения логирования при разработке большинства приложений.

  • Удобное конфигурирование и значительная гибкость настроек.

  • Развитое комьюнити. Будут проблемы - решение наверняка найдется.

Для работы библиотеки необходимо сформировать конфигурационный файл nlog.config

<?xml version="1.0" encoding="utf-8" ?>
<nlog xmlns="http://www.nlog-project.org/schemas/NLog.xsd"
      xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      autoReload="true"
      internalLogLevel="Info"
      internalLogFile="/var/log/utestrpm/nlog-internal.log">

    <targets>
        <!-- Логирование в systemd journal -->
        <target name="journal" xsi:type="Console"
                layout="${longdate} ${level:uppercase=true} ${logger} - ${message} ${exception:format=tostring}"
                error="false" />

        <!-- Дополнительно: файловое логирование для отладки -->
        <target name="file" xsi:type="File"
                fileName="/var/log/utestrpm/utestrpm.log"
                layout="${longdate} ${level:uppercase=true} ${logger} - ${message} ${exception:format=tostring}"
                archiveEvery="Day"
                maxArchiveFiles="7" />
    </targets>

    <rules>
        <!-- Microsoft и System логи только от Warning и выше -->
        <logger name="Microsoft.*" minlevel="Warn" writeTo="journal" final="true" />
        <logger name="System.*" minlevel="Warn" writeTo="journal" final="true" />

        <!-- Все остальные логи от Info и выше -->
        <logger name="*" minlevel="Debug" writeTo="journal,file" />
    </rules>
</nlog>

Все просто и практически не нуждается в комментариях. Есть две точки, в которую будет вестись логирование. В файл и в консоль. Консоль будет направлена в journald. Правила написаны для иллюстрации возможностей, не более. Nlog может и не такое, но статья не о нем.

В реальном приложении все будет писаться в журнал. Командная строка Linux предоставляет значительные возможности по поиску и работе с логами. Например:

journalctl -u <ваш модуль> | grep "что ищем" 

Код программы

Чтобы программа корректно работала, как daemon необходимо выполнить следующие условия:

  • Приложение не должно захватывать консоль. (Не использовать Console.WriteLine и т.д.)

  • Приложение не должно блокировать основной поток. (WebApplication.RunAsync вместо WebApplication.Run)

  • Корректно обрабатывать сигнал завершения.

using NLog;
using NLog.Web;
public class Program
{
    private static Logger _logger;
    private static int _currentValue = 0;
    private static readonly object _lockObject = new object();
    private static CancellationTokenSource _cancellationTokenSource = new CancellationTokenSource();
    private static WebApplication? _app;
    
    
    public static async Task Main(string[] args)
    {
        _logger = LogManager.GetCurrentClassLogger();
       
       try{ 
        _logger.Info("=== Запуск приложения utestrpm ===");

        // Обработка сигналов завершения
        AppDomain.CurrentDomain.ProcessExit += (s, e) => StopApplication();
        Console.CancelKeyPress += (s, e) => 
        {
            e.Cancel = true;
            StopApplication();
        };

        
            // Запускаем фоновый счетчик
            _ = Task.Run(() => Counter(_cancellationTokenSource.Token));

            var builder = WebApplication.CreateBuilder(args);
            
            // Конфигурация для работы как демона
            builder.Logging.ClearProviders();
            builder.Host.UseNLog();
            builder.WebHost.UseUrls("http://localhost:5432");
            _app = builder.Build();

           //Эндпоинт для получения текущего значения
            _app.MapGet("/GetValue", () => 
            {
                lock (_lockObject)
                {
                    _logger.Debug("Запрос /GetValue, текущее значение: {value}", _currentValue);
                    return _currentValue;
                }
            });
            _logger.Info("Приложение запускается на порту 5432");
            // Запускаем приложение
            await _app.RunAsync(_cancellationTokenSource.Token);
        }
        catch (Exception ex)
        {
            _logger.Error(ex, "Критическая ошибка при запуске приложения");
            throw;
        }
        finally
        {
            // Корректное завершение NLog
            LogManager.Shutdown();
        }
        
    }

    static async Task Counter(CancellationToken cancellationToken)
    {
        _logger.Info("Запуск фонового счетчика");
        
        int i = 0;
        while (!cancellationToken.IsCancellationRequested)
        {
           
            lock (_lockObject)
            {
                _currentValue = i;
            }
            
            i++;
            await Task.Delay(1000, cancellationToken);
        }
    }

    private static void StopApplication()
    {
        _cancellationTokenSource.Cancel();
        
        // Даем время на корректное завершение
        Thread.Sleep(5000);
    }
}

Программа определенно стала сложнее. Добавилось создание самого вэб приложения, эндпоинта для получения значения, инициализации логирования и корректного завершения процесса.

Отдельно обращать внимание в листинге и не на что. Разве, что без строк 34 и 35 можно обойтись. Пусть будут. Программа все еще крайне проста и охватывается одним взглядом. При этом это полноценная заготовка. Если заработает она - заработает и более сложное решение.

Модификация SPEC файла

Самое важное и интересное - как изменить SPEC файл так, чтобы программа была службой? Прежде, чем привести пример измененного файла отметим, ключевые изменения:

  • Корректно указаны зависимости.

  • Помимо бинарного файла программы копируется и устанавливается nlog.config. Вроде мелочь, а хорошая иллюстрация, что делать, если файлов будет больше одного (а так будет всегда)

  • Формируется service файл и устанавливается по нужному пути.

  • Добавлены скрипты, отвечающие за первичную установку и удаление сервиса, которые зарегистрируют нашу службу, или наоборот - удалят.

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
BuildRequires:  dotnet-sdk-8.0
Requires:       systemd dotnet-8.0

%description
Web сервис на 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}
mkdir -p %{buildroot}%{_unitdir}

install -m 755 %{name}/bin/Release/net8.0/linux-x64/publish/%{name} %{buildroot}/opt/%{name}/%{name}
install -m 644 %{name}/nlog.config %{buildroot}/opt/%{name}/nlog.config
# Создаем символическую ссылку в /usr/bin
ln -sf /opt/%{name}/%{name} %{buildroot}%{_bindir}/%{name}

# Устанавливаем systemd unit файл
cat > %{buildroot}%{_unitdir}/%{name}.service << EOF
[Unit]
Description=%{name} Service
After=network.target

[Service]
Type=exec
ExecStart=/opt/%{name}/%{name}
WorkingDirectory=/opt/%{name}
User=root
Restart=always
RestartSec=5
# Важные настройки для .NET приложения
KillSignal=SIGTERM
TimeoutStopSec=30
SyslogIdentifier=%{name}
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target
EOF

%post
# После установки пакета
if [ $1 -eq 1 ]; then
    # Первая установка
    systemctl daemon-reload >/dev/null 2>&1 || :
    systemctl enable %{name}.service >/dev/null 2>&1 || :
    systemctl start %{name}.service >/dev/null 2>&1 || :
fi

%preun
# Перед удалением пакета
if [ $1 -eq 0 ]; then
    # Полное удаление
    systemctl stop %{name}.service >/dev/null 2>&1 || :
    systemctl disable %{name}.service >/dev/null 2>&1 || :
fi

%postun
# После удаления пакета
if [ $1 -ge 1 ]; then
    # Обновление пакета
    systemctl daemon-reload >/dev/null 2>&1 || :
    systemctl try-restart %{name}.service >/dev/null 2>&1 || :
fi

%files
# Основной бинарный файл в /opt
/opt/%{name}/%{name}

# Символическая ссылка в /usr/bin
%{_bindir}/%{name}

# Systemd unit файл
%{_unitdir}/%{name}.service

# Настройки логирования
/opt/%{name}/nlog.config

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

Обратите внимание! Через некоторое время SPEC файл из первой части перестал собираться. Скрипт rpm-build начал выдавать ошибку за ошибкой и ссылаться на некорректный синтаксис. Основных типов ошибок было две. Первая - комментарии в строках. Например в source0. Скрипт воспринимал их за новый источник и не находил его. Завершал работу. Вторая - некорректный синтаксис changelog.

Необычно то, а почему раньше - то работало? Все замечания скрипта по делу. Ошибки допущены по незнанию. Предполагаю, что обновились версии пакетов и у разработчиков Alt Linux дошли руки навести порядок. Но это не точно.

Указание зависимостей

BuildRequires:  dotnet-sdk-8.0
Requires:       systemd dotnet-8.0

Доводя прототип до функциональной заготовки были корректно настроены зависимости. Для работы приложения sdk не нужен. Но он нужен для сборки. Поэтому, если в докер образе не будет установлен sdk появится более удобная для анализа ошибка.

Вторая зависимость намного интереснее. Большинство современных дистрибутивов используют систему инициализации systemd. Но некоторые дистрибутивы используют традиционный SysV Init. Между системами есть различия в том числе и в синтаксисе команд. Текущий spec файл настроен исключительно на работу с systemd, что и указано в зависимостях.

Обратите внимание! Если хочется разобраться (а без этого никак) в вопросе, как работает регистрация служб, их автозапуск, порядок запуска и т.д. я рекомендую книгу Брайан Уорд "Внутреннее устройство Linux". Найти ее в сети очень просто в любом формате. В ней дается достаточно исчерпывающая информация, как по этому вопросу, так и по множеству других. Если не читали - обязательно прочтите. Станет намного проще и понятнее.

Копирование дополнительных файлов программы

В предыдущей статье не было акцента на то, как сборщик и, в дальнейшем, установщик понимают какие файлы куда копировать.

Это осуществляется в два этапа. Первый этап в разделе install, ответственен за создание нужных директорий и копирования туда папок в рамках виртуальной корневой системы.

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

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

В листинге выше видно, что создается три каталога и копируется два файла.

Второй этап в разделе files указывает, какие файлы по итогу имеют ценность для нашей программы. В процессе установки и настройки могут появиться файлы, которые не нужны.

%files
# Основной бинарный файл в /opt
/opt/%{name}/%{name}

# Символическая ссылка в /usr/bin
%{_bindir}/%{name}

# Systemd unit файл
%{_unitdir}/%{name}.service

# Настройки логирования
/opt/%{name}/nlog.config

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

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

Файл можно сделать отдельно, но куда удобнее его сгенерировать в процессе выполнения сборки. Это позволит использовать макросы типа %{name} и т.д. Эта часть файла отвечает за генерацию service файла:

cat > %{buildroot}%{_unitdir}/%{name}.service << EOF
[Unit]
Description=%{name} Service
After=network.target

[Service]
Type=exec
ExecStart=/opt/%{name}/%{name}
WorkingDirectory=/opt/%{name}
User=root
Restart=always
RestartSec=5
# Важные настройки для .NET приложения
KillSignal=SIGTERM
TimeoutStopSec=30
SyslogIdentifier=%{name}
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target
EOF

Сам бы до такого, наверное, додумался не сразу. Где - то подсмотрел. Решение красивое. Мы читаем в файл поток, который тут - же и сформировали. Как писалось выше в многих строках используется макрос %{name}, что не приведет к ошибкам и трате времени, когда будет изменено название программы.

Сам листинг прост, если ранее была усвоена информация по принципу работы systemd. Пробежимся бегло для понимания.

  • Сервис будет запущен только тогда, когда поднимется сеть в ОС.

  • Запускаться будет бинарный файл по пути /opt/%{name}/%{name} в соответствующей рабочей директории

  • Под пользователем root

  • Если что - то произойдет перезапускать через 5 секунд

  • Указан идентификатор для журналирования.

Скрипты первичной установки и удаления службы

Очень хочется, чтобы при установке пакета служба запускалась автоматически и не требовала дополнительных действий. Для этой цели существуют разделы %post %preun %postun

Для того, чтобы определить первая это установка (не было пакета ранее), ее удаление, или обновление используется условие $1 -eq <число на что проверяем>

В скрипте rpm-build $1 вернет значение количества этого пакета в системе на момент установки. Отсюда выходит:

  • 0 - первая установка. Пакетов не было

  • 1 и 2 - пакет установлен уже. При удалении или обновлении выполнятся разные разделы.

В текущем примере пакет не проверяется на предмет обновления. Это сделано для упрощения.

post
# После установки пакета
if [ $1 -eq 1 ]; then
    # Первая установка
    systemctl daemon-reload >/dev/null 2>&1 || :
    systemctl enable %{name}.service >/dev/null 2>&1 || :
    systemctl start %{name}.service >/dev/null 2>&1 || :
fi

%preun
# Перед удалением пакета
if [ $1 -eq 0 ]; then
    # Полное удаление
    systemctl stop %{name}.service >/dev/null 2>&1 || :
    systemctl disable %{name}.service >/dev/null 2>&1 || :
fi

%postun
# После удаления пакета
if [ $1 -ge 1 ]; then
    # Обновление пакета
    systemctl daemon-reload >/dev/null 2>&1 || :
    systemctl try-restart %{name}.service >/dev/null 2>&1 || :
fi

Уверен, что теперь, если было непонятно, как это работает - стало значительно ясней.

При первой установке регистрируется служба. При удалении она останавливается и удаляется.

Эпилог

Я хотел приложить исходные коды программы. Не нашел такой возможности в редакторе. Ну и ладно. Она слишком простая, чтобы ее выкладывать на GitHub, например. Все, что надо - есть двух статьях. Буду рад, если они окажутся кому - то полезными. Спасибо за внимание.

P.S. Как минимум одному человеку они оказались полезными. Лично мне. Рассказал другому - сам до конца разобрался. Теперь я уверен, что масштабирование подхода на реальную разработку будет 100% успешно, хоть и не без дополнительных проблем.

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


  1. TrueRomanus
    12.10.2025 18:51

    Почему не использовать публикацию в NativeAot? Тогда зависимость от рантайма не понадобиться.


    1. uppitss Автор
      12.10.2025 18:51

      Верно. Тогда не будет одной зависимости. Хорошее замечание.

      Но, при установке, она подтянется сама, если ее нет. Вопрос только дискового пространства.

      В реальной разработке в командах Aot использовался только в рамках эксперимента. Полноценного перехода нет и не планируется. Потому в статье в эту сторону не думал.


      1. TrueRomanus
        12.10.2025 18:51

        Но, при установке, она подтянется сама, если ее нет. Вопрос только дискового пространства.

        Кто подтянется сам? При публикации NativeAot Вы получите собранный платформо-специфичный бинарь внутри которого будет затримленный рантайм. Для запуска такого приложения не требуется ничего подтягивать, для него будут нужны только системные зависимости но рантайм уже не потребуется.


        1. uppitss Автор
          12.10.2025 18:51

          Кто подтянется сам? 

          Если в ОС не будет dotnet-8.0, то он подтянется при установке пакета.

          Стоит ли в реальной разработке собирать приложение под NativeAot? Вопрос, не имеющий для меня однозначного ответа:

          • Будет ли работать быстрее? Да будет. Но не каждое приложение необходимо оптимизировать. То, под которое проводилось исследование - точно нет. Это время команды и деньги бизнеса.

          • Будут ли непредвиденные проблемы, которые могут сдвинуть сроки, или потратить ресурсы? Не уверен, но возможно.

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

          Но, повторюсь. Ваш комментарий хорош. Так тоже можно. Для туториала интереснее вариант с зависимостью - предлагаю так и оставить.