Content

Before we start - prepare your system

For the purpose of this article I assume you followed Microsoft's tutorial on how to create a service from ASP.NET Core application on Linux. That mainly means you already have a service file called /etc/systemd/system/kestrel-helloapp.service created.

PID files are stored in the /var/run/ directory. ASP.NET Core applications should run under different user than root (e.g., under www-data) and that means they are not allowed to write to /var/run. We could manually create a subfolder and change it's owner but, on Ubuntu, it will disappear after next reboot. To solve this it is possible to modify the service definition file and add RuntimeDirectory=helloapp line. Thanks to this line systemd will create helloapp subdirectory in /var/run on every service start.

It is also worth it to add PIDFile=/var/run/helloapp/helloapp.pid line to the service definition file. This won't create a pid file for us unfortunately 😉 It tells systemd to cleanup PID file when service exits so it will double check for us that the file is gone when the app is stopped.

Your file, comparing to what's on Microsoft's site, should now looks like this:

[Unit]
Description=Example .NET Web API App running on Ubuntu

[Service]
WorkingDirectory=/var/www/helloapp
RuntimeDirectory=helloapp
ExecStart=/usr/bin/dotnet /var/www/helloapp/helloapp.dll
Restart=always
# Restart service after 10 seconds if the dotnet service crashes:
RestartSec=10
KillSignal=SIGINT
SyslogIdentifier=dotnet-example
User=www-data
PIDFile=/var/run/helloapp/helloapp.pid
Environment=ASPNETCORE_ENVIRONMENT=Production
Environment=DOTNET_PRINT_TELEMETRY_MESSAGE=false

[Install]
WantedBy=multi-user.target

First solution - IHostApplicationLifetime in Startup file

IHostApplicationLifetime allows to subscribe to application lifetime events like ApplicationStarted, ApplicationStopping or ApplicationStopped. It can be injected the same way as any other service. For us a good place is Configure method in Startup.cs.

public class Startup
{
    // [...]

    public void Configure(/* ... */ IHostApplicationLifetime appLifetime)
    {
        // [...]

        if (env.IsDevelopment())
        {
            // [...]
        }
        else
        {
            // [...]
            HandlePidFile(appLifetime);
        }
        // [...]
    }

    private void HandlePidFile(IHostApplicationLifetime appLifetime)
    {
        string pidFile = Configuration.GetValue<string>("PidFile");

        if (string.IsNullOrWhiteSpace(pidFile))
            return;

        WritePidFile(pidFile);
        appLifetime.ApplicationStopped.Register(() => RemovePidFile(pidFile));
    }

    private void WritePidFile(string pidFile)
    {
        var processId = Environment.ProcessId.ToString();
        File.WriteAllText(pidFile, processId);
    }

    private void RemovePidFile(string pidFile)
    {
        File.Delete(pidFile);
    }
}

The above code injects IHostApplicationLifetime into Configure method. Then, for any other than development environment, retrieves PID file path from appsettings.json file and creates that file by writing current process ID to it. It also registers ApplicationStopped event handler that removes the file when the application is exiting.

Second solution - IHostApplicationLifetime in dedicated IHostedService

I think this solution is more elegant one because it doesn't pollutes the Startup file. Instead we are going to use a dedicated IHostedService. Hosted services are services that are automatically started by ASP.NET on application start and are notified to stop on application shutdown. For more on this you can check this page. Implementation of such service may look as following:

public class PidFileHostedService : IHostedService
{
    private readonly ILogger<PidFileHostedService> logger;
    private readonly IConfiguration configuration;
    private readonly IWebHostEnvironment env;

    private bool isPidFileCreated = false;
	private string pidFile = string.Empty;

    public PidFileHostedService(ILogger<PidFileHostedService> logger,
        IConfiguration configuration,
        IWebHostEnvironment env)
    {
        this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
        this.configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
        this.env = env ?? throw new ArgumentNullException(nameof(env));
    }

    public async Task StartAsync(CancellationToken cancellationToken)
    {
        try
        {
            if (env.IsDevelopment())
                return;

            pidFile = configuration.GetValue<string>("PidFile");

            if (string.IsNullOrWhiteSpace(pidFile))
                return;

            await WritePidFile();
            
            isPidFileCreated  = true;
        }
        catch (Exception ex)
        {
            logger.LogError(ex, $"Unexpected error when starting {nameof(PidFileHostedService)}", null);
        }
    }

    private async Task WritePidFile()
    {
        var processId = Environment.ProcessId.ToString();
        await File.WriteAllTextAsync(pidFile, processId);
    }

    public Task StopAsync(CancellationToken cancellationToken)
    {
		try
        {
            if (isPidFileCreated)
                File.Delete(pidFile);
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "Unexpected error when deleting PID file", null);
        }
		
        return Task.CompletedTask;
    }
}

The logic here is exactly the same as in the previous solution. All necessary interfaces are registered in the DI by default in ASP.NET. The only thing needed to be done for this service to be fired is to register it in Startup.cs:

services.AddHostedService<PidFileHostedService>();