image

В этом материале будет рассмотрено, как запускать приложение .NET Core / .NET 5 в качестве сервиса под Linux. Мы воспользуемся Systemd, чтобы интегрировать наше приложение с операционной системой, научимся запускать и останавливать наш сервис, а также получать от него логи.

Чтобы организовать атаку на цепочку поставок при помощи .NET, мне потребовалось настроить DNS-сервер, который перехватывал бы те хост-имена, которые ко мне направляются. Давайте возьмём этот кейс для примера.

Создание .NET-приложения, которое будет работать как сервис


.NET-сервису потребуется использовать модель хостинга Microsoft.Extensions.Hosting, применяемую в Microsoft. Так мы обеспечим работоспособность любого приложения ASP.NET Core, а также проектов, выполняемых с применением шаблона Worker Service (dotnet new worker). Здесь будем работать с Rider.

image

Далее потребуется установить пакет NuGet: Microsoft.Extensions.Hosting.Systemd. Этот пакет предоставляет хостинговую инфраструктуру .NET для сервисов Systemd. Иными словами, в нём содержится инфраструктура, необходимая для работы с демоном Systemd под Linux.

Осталось сделать ещё одну вещь: зарегистрировать в нашем приложении хостинговые расширения Systemd. В Program.cs понадобится добавить .UseSystemd() к сборщику хоста:

public class Program
{
	// ...
 
	public static IHostBuilder CreateHostBuilder(string[] args) =>
    	Host.CreateDefaultBuilder(args)
        	.UseSystemd() // add this
        	.ConfigureServices((hostContext, services) =>
        	{
            	services.AddHostedService<Worker>();
        	});
}

Когда всё это настроено, наше приложение может работать как сервис Systemd под Linux! Ну, почти… Осталось сделать ещё две вещи:

  • Написать приложение
  • Зарегистрировать его для работы с Systemd

Начнём с первого пункта и реализуем DNS-сервер, который будем использовать в качестве сервиса.

Реализуем сервер .NET DNS в качестве сервиса


DNS-сервер, рассмотренный здесь, пишется на .NET с применением отличного DNS-пакета от Мирзы Капетановича, а далее при помощи Systemd размещается на Linux как сервис.

Давайте соберём простой DNS-сервис, возвращающий актуальное время как запись в формате TXT.

Примечание: можете пропустить этот раздел, если просто хотите изучить, как регистрировать приложения .NET в качестве сервисов Systemd.

Сперва установим пакет DNS. В нём содержится почти готовый DNS-сервер, так что нам понадобится всего лишь записать его код и реализовать серверную логику.

Класс Worker, созданный на основе шаблона worker service (сервис-исполнитель) – хорошая отправная точка. В нём можно применить инъекцию конструктора, чтобы получить доступ ко всем сервисам, имеющимся в вашем приложении. Мы воспользуемся интерфейсом ILogger (уже имеющимся в шаблоне) и IConfiguration, чтобы получить доступ к appsettings.json/переменным окружения/аргументам командной строки. Здесь мы наследуем BackgroundService, в котором есть метод ExecuteAsync, именно этот метод и отвечает за выполнение нашего сервиса. Вот так выглядит костяк Worker:

public class Worker : BackgroundService
{
	private readonly ILogger<Worker> _logger;
	private readonly IConfiguration _configuration;
 
	public Worker(ILogger<Worker> logger, IConfiguration configuration)
	{
    	_logger = logger;
    	_configuration = configuration;
	}
 
	protected override async Task ExecuteAsync(CancellationToken stoppingToken)
	{
    	// ... здесь идёт вся логика ...
	}
}

Теперь давайте напишем сам DNS-сервер. В методе ExecuteAsync добавим следующий код:

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
	var server = new DnsServer(new SampleRequestResolver());
	server.Listening += (_, _) => _logger.LogInformation("DNS server is listening...");
	server.Requested += (_, e) => _logger.LogInformation("Request: {Domain}", e.Request.Questions.First().Name);
	server.Errored += (_, e) =>
	{
    	_logger.LogError(e.Exception, "An error occurred");
    	if (e.Exception is ResponseException responseError)
    	{
        	_logger.LogError("Response: {Response}", responseError.Response);
    	}
	};
 
	await Task.WhenAny(new[]
	{
    	server.Listen(
        	port: int.TryParse(_configuration["Port"], out var port) ? port : 53531,
        	ip: IPAddress.Any),
 
    	Task.Delay(-1, stoppingToken)
	});
}

А теперь объясню развёрнуто:

  • Мы настраиваем DnsServer, который будет разрешать записи при помощи SampleRequestResolver (его нам ещё потребуется собрать).
  • Мы подписываемся на некоторые события, предоставляемые DnsServer, так что сможем просматривать логи входящих запросов и сразу замечать, не пошло ли что-нибудь не так.
  • Наконец, начинаем слушать входящие запросы.

Для ExecuteAsync мы передаём CancellationToken, который задействуется, когда Systemd прикажет нашему сервису завершиться. Поскольку сама библиотека DNS не поддерживает CancellationToken, я воспользуюсь здесь Task.WhenAny, чтобы запустить DNS-сервер. Так мы сможем завершить наш сервис, как только будет затребован его сброс.

Сам сервер будет слушать порт, указанный в конфигурации. Когда этот порт прямо не указан, по умолчанию задаётся 53531.

И ещё: тот самый класс SampleRequestResolver. Он должен реализовывать интерфейс IRequestResolver и собирать ответы на входящие DNS-запросы. Поскольку эта тема выходит за рамки данного поста, просто покажу быструю реализацию, которая возвращает текущие значения даты и времени для любой запрошенной TXT-записи. Для всех прочих запросов возвращается ошибка:

public class SampleRequestResolver : IRequestResolver
{
	public Task<IResponse> Resolve(IRequest request, CancellationToken cancellationToken = new CancellationToken())
	{
    	IResponse response = Response.FromRequest(request);
 
    	foreach (var question in response.Questions)
    	{
        	if (question.Type == RecordType.TXT)
        	{
            	response.AnswerRecords.Add(new TextResourceRecord(
                	question.Name, CharacterString.FromString(DateTime.UtcNow.ToString("O"))));
        	}
        	else
        	{
            	response.ResponseCode = ResponseCode.Refused;
        	}
    	}
 
    	return Task.FromResult(response);
	}
}

Попробуйте выполнить сервис на локальной машине и убедитесь – он работает! Его удобно протестировать при помощи таких инструментов как nslookup и dig.

image

Посмотрите README.md проекта DNS – там есть и другие примеры. Будьте осторожны, чтобы случайно не собрать открытый DNS-преобразователь, если исследуете, как обрабатывать вашу собственную DNS.

Пишем конфигурацию сервисного блока


Давайте развернём наш сервис на Linux! Systemd использует файлы конфигурации сервисного блока, в которых определяется, что делает сервис, должен ли он перезапускаться, и всё подобное.

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

[Unit]
Description=DNS Server
 
[Service]
Type=notify
ExecStart=/usr/sbin/DnsServer --port=53
 
[Install]
WantedBy=multi-user.target

В разделе [Unit] содержится самая общая информация о нашем приложении. Systemd может выполнять не только сервисы, и [Unit] – это общий раздел для всех типов приложений, которые можно так запускать. Я добавил только описание (Description), но здесь доступны и многие другие опции.

В разделе [Service] определяются детали, касающиеся нашего приложения. Для приложений .NET тип Type будет notify, так, что мы сможем уведомлять Systemd, когда хост начинает или завершает работу. За всё это отвечает пакет Microsoft.Extensions.Hosting.Systemd. В ExecStart определяется путь к двоичному файлу, используемому для запуска нашего сервиса. В показанном далее примере я воспользуюсь самодостаточным .NET-приложением и через аргумент командной строки сообщу ему, какой порт слушать.

Наконец, в разделе [Install] определяется, в каких операционных системах можно будет запускать наш сервис. В данном случае выставлено значение multi-user.target, при котором мы можем запускать сервис всякий раз, когда находимся в многопользовательской среде (то есть, почти всегда). Здесь также можно было бы задать graphical.target, и тогда для запуска этого сервиса у вас должна быть загружена графическая среда.

Создаём самодостаточное .NET-приложение


Сам наш сервис должен быть доступен на машине с Linux, выбранной в качестве платформы, и на ней мы определим, где Systemd может найти его (это делается при помощи ExecStart): /usr/sbin/DnsServer.

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

dotnet publish -c Release -r linux-x64 --self-contained=true -p:PublishSingleFile=true -p:GenerateRuntimeConfigurationFiles=true -o artifacts

Так создаётся исполняемый файл DnsServer размером примерно 62 МБ (внём содержится всё, что нам нужно от среды выполнения .NET). Скопируйте его в /usr/sbin/DnsServer на машине с Linux и убедитесь, что он поддаётся выполнению (sudo chmod 0755 /usr/sbin/DnsServer).

Установка и запуск сервиса под Linux


Созданный нами файл .service (я назвал его dnsserver.service) должен находиться в каталоге /etc/systemd/system/ на той машине Linux, на которой мы собираемся разворачивать сервис.

Далее выполните следующую команду, чтобы Systemd загрузил этот новый конфигурационный файл:

sudo systemctl daemon-reload

На данном этапе мы уже должны быть в состоянии посмотреть статус нашего DNS-сервера, работающего как сервис:

sudo systemctl status dnsserver.service

image

Упомяну и несколько других команд, которые могут вам пригодиться:

  • Для запуска и остановки сервиса:

sudo systemctl start dnsserver.service
sudo systemctl stop dnsserver.service
sudo systemctl restart dnsserver.service

  • Чтобы убедиться, что сервис запускается одновременно с той машиной, на которой стоит Linux:

sudo systemctl enable dnsserver.service

Поздравляю, теперь у нас есть приложение .NET, работающее как сервис на Linux!

Что происходит в моём сервисе?


Итак, сервис работает, но ведь ещё и интересно узнать, что он делает. Можно просмотреть самые свежие записи логов, которые выдаёт наше приложение. Для этого воспользуемся следующей командой:

sudo systemctl status dnsserver.service

Итак, мы видим, что сервис работает, можем узнать детали о процессе с заданным id, а также изучить последние записи логов.

image

Если логи вас очень интересуют, то при помощи journalctl можно вывести все логи для блока (-u) – это и есть наш DNS-сервер:

sudo journalctl -u dnsserver.service

В данном случае круто, что благодаря пакету Microsoft.Extensions.Hosting.Systemd мы также получаем здесь цветовые коды, а также данные об уровне серьёзности по каждой записи в логах:

image

Заключение


В этом посте было рассмотрено, как использовать приложение .NET в качестве сервиса Systemd под Linux. Для этого мы воспользовались пакетом Microsoft.Extensions.Hosting.Systemd. Правда, мы только минимально познакомились с темой. Есть ещё множество опций, которые можно сообщить Systemd, ещё можно фильтровать уровни логов при помощи journalctl, и делать многое другое. Рекомендую посмотреть статью Нильса Свимберге, если хотите подробнее изучить эту тему.

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


  1. mayorovp
    10.09.2023 08:31
    +4

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


    1. s207883
      10.09.2023 08:31

      Не приведет ли это к необходимости установки .net в систему?


      1. stantum
        10.09.2023 08:31

        Смотря как сбилдить


      1. mayorovp
        10.09.2023 08:31
        +4

        Нет, режим --self-containedозначает распространение рантайма вместе с приложением


        1. s207883
          10.09.2023 08:31

          Благодарю


    1. alan008
      10.09.2023 08:31

      Они обещали, что в .net 7 или 8 этот режим будет работать получше, как минимум отсекать неиспользуемую часть рантайма.


      1. mayorovp
        10.09.2023 08:31

        Это же независимая фича, её можно будет включить (или выключить) отдельно. Надеюсь.


        1. alan008
          10.09.2023 08:31
          +1

          Да, отдельная. <PublishTrimmed>true</PublishTrimmed>


          Про само self-contained:
          https://learn.microsoft.com/en-us/dotnet/core/deploying/#publish-self-contained


          Про trim self-contained:
          https://learn.microsoft.com/en-us/dotnet/core/deploying/trimming/trim-self-contained


  1. 13werwolf13
    10.09.2023 08:31

    скиньте эту статью кто нибудь разработчикам terraria, а то их dedicated server до сих пор не вкурсе что его надо запускать в виде демона в НЕинтерактивном режиме..


    1. mayorovp
      10.09.2023 08:31

      И у майнкрафта та же проблема.


      Я как-то делал костыль на Rust, чтобы запускать такие программы неинтерактивно, перенаправляя поток ввода в unix сокет.


      1. 13werwolf13
        10.09.2023 08:31
        +1

        сервер майнкрафта хотя бы работает в виде systemd службы без костылей, хотя и теряется возможность посылать ему команды. сервер террарии же просто валится, костыль для запуска его ввиде службы являет собой запуск внутри screen.

        P.S.: за ваш костыль спасибо, посмотрю, хотя бы из чистого интерес


  1. ArtLkv
    10.09.2023 08:31

    А можно ли на .NET и C# сделать службу не для systemd, а для OpenRC или runit? Или Microsoft поддерживает решение лишь для systemd? Просто из интереса.


    1. mayorovp
      10.09.2023 08:31

      А что мешает? Вся поддержка systemd сводится к работе с механизмами сокетов (LISTEN_FDS) и sd_notify (NOTIFY_SOCKET).


      У OpenRC и runit разве есть подобные особые механизмы? Насколько я знаю, их нет. Следовательно, для них и особой поддержки никакой не требуется.