Loading...
19-06-2021

Met de komst van DotNet Core 3.1 is er ook nieuwe leven ingeblazen in het kunnen maken van achtergrond services, a.k.a. Windows Services, maar niet gelimiteerd aan Windows! Sterker nog, Microsoft leverde meteen ook ondersteuning voor systemd (Linux) mee. Nou is er niks specifieks om launchd te ondersteunen, maar ook zonder is het simpel om een DotNet programma als achtergrond service te laten draaien.

Zo'n (achtergrond) service is niet meer dan een console applicatie. De integratie met systemd en windows services zorgt er voor dat de applicatie bepaalde features van deze systemen herkend. Maar dit is niet per definitie noodzakelijk. Launchd kan hierbij een console applicatie starten en in de gaten houden of deze nog actief is. Zo niet, dan herstart hij deze optioneel. De Launchd services worden beschreven in een plist xml. Dit is een simpel formaat in de vorm van een dictionary (key en values). Hieronder een voorbeeld:

<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
  <dict>
    <key>Label</key>
    <string>my.service.app</string>
    <key>WorkingDirectory</key>
    <string>/Users/gebruiker/Projects/my.service.app/</string>
    <key>ProgramArguments</key>
    <array>
      <string>/Users/gebruiker/Projects/my.service.app/my.service.app</string>
    </array>
    <key>RunAtLoad</key>
    <true />
    <key>KeepAlive</key>
    <true />
    <key>StandardOutPath</key>
    <string>/Users/gebruiker/Library/Logs/my.service.app.log</string>
    <key>StandardErrorPath</key>
    <string>/Users/gebruiker/Library/Logs/my.service.app.log</string>
  </dict>
</plist>

Plist xml's worden door MacOS (en IOS/IPadOS) voor diverse doeleinde gebruikt. Elke key waarde wordt opgevolgd door een waarde. In dit voorbeeld een "string", "array"of een "true" waarde. Welke key's er allemaal mogelijk zijn is afhankelijk van het doel waarbij deze ingezet wordt. Het is hierbij ook mogelijk om de applicaties op bepaalde tijdstippen te laten starten, zoals met cron onder linux ook kan.

Service console applicaties zijn dus niks meer dan console applicaties. Bij de ontwikkeling van DotNet Core, is ook de keuze gemaakt om (standaard) Dependency Injection te ondersteunen, net zoals in AspNet.Core. Uiteraard was dit niet zozeer iets nieuws, maar wel dat het out-of-the-box beschikbaar is. Er is in de applicatie een Main functie aanwezig (deze kan ook async gebruikt worden, waar gewenst).

using System;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

namespace my.service
{
    class Program
    {
        static void Main(string[] args)
        {
            CreateHostBuilder(args).Build().Run();
        }
        
        public static IHostBuilder CreateHostBuilder(string[] args) =>
            Host.CreateDefaultBuilder(args)
                // .UseSystemd() /* Te gebruiken voor linux services */
                .ConfigureServices((hostContext, services) =>
                {
                    services
                        .AddSingleton<SchedularContainer>()
                        .AddHostedService<SchedularWorker>()
                        .AddHostedService<InitWorker>();
                });
    }
}

Er wordt in ConfigureServices zowel de types geregistreerd, als ook de "HostedServices". Dit zijn objecten die overerven van "BackgroundService". Deze klasse verwacht dat er een ExecuteAsync(CancellationToken) wordt toegevoegd. Vervolgens bij het starten van de applicatie, worden alle ExecuteAsync methodes aangeroepen. Hierin kunnen dus specifieke onderdelen uitgevoerd worden.

using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

namespace my.service
{
    public class InitWorker : BackgroundService
    {
        public readonly ILogger Logger;
        
        public InitWorker(ILogger<InitWorker> logger)
        {
            this.Logger = logger;
        }

        protected override Task ExecuteAsync(CancellationToken stoppingToken)
        {
            return Task.Run(() => { /**/ });
        }
    }
}

.Net 3.1, en opvolgend .Net 5.0 kunnen een applicatie publiceren die uitgevoerd kan worden zonder dat dotnet sdk of runtime geïnstalleerd zijn. Dit heeft als voordeel dat er geen gedoe is, of de juiste versie wel geïnstalleerd is of niet. Het minpunt is dat de applicatie wat groter is. Dit laatste vind ik het wel waard. Als de service in Docker uitgevoerd zou worden, zou ik ervoor kiezen om dit niet te doen, en de juiste Docker image te gebruiken. Hieronder een voorbeeld van het publiceren en installeren van de app als launchd service, zoals deze in bash uitgevoerd kan worden:

# Publiceer de applicatie as self-contained, specifiek voor MacOS 64 bits
dotnet publish -c release -r osx-x64 --self-contained true

# Kopieer de output naar de "installatie" lokatie
cp -r ./bin/release/net5.0/osx-x64/publish/* /Users/gebruiker/Projects/my.service.app && \

# Symlink de plist 
ln -s /Users/gebruiker/Projects/my.service.app.plist ~/Library/LaunchAgents/my.service.app.plist

# Importeer de plist in launchd
launchctl load ~/Library/LaunchAgents/my.service.app.plist" ; \

# Start de service
launchctl start my.service.app

# Bekijk de output van de logfile
tail -F /Users/gebruiker/Library/Logs/my.service.app

Het maken van de symlink en Launchd load hoeft maar eenmalig uitgevoerd te worden bij de eerste installatie. Launchd kent zo een aantal commando's: start en stop, en load en unload. Via list wordt een lijst getoond van geregistreerde services.

Via tail -F wordt het laatste deel van de logfile getoond (en gevolgd) die afkomstig is van de applicatie. Launchd zal zelf zorgen voor het opruimen van de logsfiles.

Conclusie

Zonder ondersteuning van launchd in de DotNet applicatie, kan toch simpel een service gemaakt worden. Houdt er wel rekening mee, dat MacOS bedoeld is als Desktop omgeving. Services worden dan ook opgestart als een gebruiker ingelogd is, als deze in ~/Library/LaunchAgents is gedefinieerd. In mijn geval logt de "server" automatisch in als een specifieke gebruiker bij opstarten en dat zorgt ervoor dat alle services gestart worden. Via /Library/LaunchDaemons kan een service als root uitgevoerd worden, of een andere specifiek aangeven gebruiker, en gestart worden bij.

  • dotnet
  • Launchd
  • MacOS