Swimburger

How to run a .NET Core console app as a service using Systemd on Linux (RHEL)

Niels Swimberghe

Niels Swimberghe - - .NET

Follow me on Twitter, buy me a coffee

.NET Core logo + Dotnet bot wearing Red Hat

This article has been updated for .NET 6 and RHEL 8 on 03/20/2022.

This article walks us through running a .NET Core console application on systemd. After running a simple console app as a service, we'll upgrade to the worker template which is designed for long running services/daemons. Lastly, we'll add the systemd package for increased integration with systemd.

To learn how to run ASP.NET Core services (web stuff) on Linux, check out "How to run ASP.NET Core as a service on Linux without reverse proxy, no NGINX or Apache".

Prerequisites:

This walkthrough should work for most .NET Core supported Linux distributions, not just RHEL.

This walkthrough should work for older versions of .NET too, but you'll need to make small modifications.

.NET Core console application #

Let's start by making a new console application using the dotnet CLI:

mkdir ~/HelloWorld
cd ~/HelloWorld
dotnet new console

Verify that the console app works by running dotnet run. The output should be "Hello World!".
If the application works, we can publish it somewhere logical such as /srv/HelloWorld:

sudo mkdir /srv/HelloWorld               # Create directory /srv/HelloWorld
sudo chown yourusername /srv/HelloWorld  # Assign ownership to yourself of the directory
dotnet publish -c Release -o /srv/HelloWorld

The published result contains an executable called 'HelloWorld' which will run the application. Let's verify we can also run the published application:

/srv/HelloWorld/HelloWorld
#output will be 'Hello World!'

To run services on Linux, Systemd uses 'service unit configuration' files to configure services.
Let's create the file 'HelloWorld.service' inside our project so we can store it in source control along with our code. Add the following content to the file:

[Unit]
Description=Hello World console application

[Service]
# systemd will run this executable to start the service
# if /usr/bin/dotnet doesn't work, use `which dotnet` to find correct dotnet executable path
ExecStart=/usr/bin/dotnet /srv/HelloWorld/HelloWorld.dll
# to query logs using journalctl, set a logical name here
SyslogIdentifier=HelloWorld

# Use your username to keep things simple.
# If you pick a different user, make sure dotnet and all permissions are set correctly to run the app
# To update permissions, use 'chown yourusername -R /srv/HelloWorld' to take ownership of the folder and files,
#       Use 'chmod +x /srv/HelloWorld/HelloWorld' to allow execution of the executable file
User=yourusername

# This environment variable is necessary when dotnet isn't loaded for the specified user.
# To figure out this value, run 'env | grep DOTNET_ROOT' when dotnet has been loaded into your shell.
Environment=DOTNET_ROOT=/usr/lib64/dotnet

[Install]
WantedBy=multi-user.target

Make sure to update the 'User' to your username. Refer to the comments in the file for a basic explanation. For more in-depth information, read the freedesktop manual page or the Red Hat documentation.

Depending on how you installed .NET, the path to the dotnet executable (/usr/bin/dotnet) may be different. You can use the command `which dotnet` to find the correct path.
Alternatively, you can use `dotnet --info` which will list information about the .NET installation, and dig through the files to find the correct `dotnet` executable path.

Systemd expects all configuration files to be put under '/etc/systemd/system/'. Copy the service configuration file to '/etc/systemd/system/HelloWorld.service'. Then tell systemd to reload the configuration files, and start the service.

sudo cp HelloWorld.service /etc/systemd/system/HelloWorld.service
sudo systemctl daemon-reload
sudo systemctl start HelloWorld

Using the `systemctl status` command we can view the status of the service:

sudo systemctl status HelloWorld
# Output should be similar to below:
#    ● HelloWorld.service - Hello World console application
#    Loaded: loaded (/etc/systemd/system/HelloWorld.service; disabled; vendor preset: disabled)
#    Active: inactive (dead)
#
#    Jan 27 19:15:47 rhtest systemd[1]: Started Hello World console application.
#    Jan 27 19:15:47 rhtest HelloWorld[4074]: Hello World!

In addition to the status command, we can use the 'journalctl' command to read everything our service is printing to the console. Using the unit-flag (-u), we can filter down to our HelloWorld service.

sudo journalctl -u HelloWorld
# Output should be similar to below:
#   Jan 27 19:15:47 rhtest systemd[1]: Started Hello World console application.
#   Jan 27 19:15:47 rhtest HelloWorld[4074]: Hello World! 

The console app only logs "Hello world!" to the console and then exits. When querying the status, systemd reports the service is inactive (dead). That's because the console app starts, runs, and immediately exits. That's not very useful, so let's add some code that will let the app run until told to stop. Update Program.cs with the following content:

while(true){
	Console.WriteLine("Hello World!");
	await Task.Delay(500);
}

Let's publish the app again:

sudo systemctl stop HelloWorld # stop the HelloWorld service to remove any file-locks
dotnet publish -c Release -o /srv/HelloWorld
sudo systemctl start HelloWorld

Now we have a minimal application that is continuously running until told to stop.
If the application stops due to a crash, systemd will not automatically restart the service unless we configure that. Add the 'Restart' & 'RestartSec' options to HelloWorld.service:

[Unit]
Description=Hello World console application

[Service]
# systemd will run this executable to start the service
# if /usr/bin/dotnet doesn't work, use `which dotnet` to find correct dotnet executable path
ExecStart=/usr/bin/dotnet /srv/HelloWorld/HelloWorld.dll
# to query logs using journalctl, set a logical name here
SyslogIdentifier=HelloWorld

# Use your username to keep things simple.
# If you pick a different user, make sure dotnet and all permissions are set correctly to run the app
# To update permissions, use 'chown yourusername -R /srv/HelloWorld' to take ownership of the folder and files,
#       Use 'chmod +x /srv/HelloWorld/HelloWorld' to allow execution of the executable file
User=yourusername

# ensure the service restarts after crashing
Restart=always
# amount of time to wait before restarting the service                        
RestartSec=5   

# This environment variable is necessary when dotnet isn't loaded for the specified user.
# To figure out this value, run 'env | grep DOTNET_ROOT' when dotnet has been loaded into your shell.
Environment=DOTNET_ROOT=/usr/lib64/dotnet

[Install]
WantedBy=multi-user.target

Copy the service file, reload, and restart the service:

sudo cp HelloWorld.service /etc/systemd/system/HelloWorld.service
sudo systemctl daemon-reload
sudo systemctl restart HelloWorld

Now the service will automatically restart in case of a crash. But when the OS reboots, the application will not automatically start. To enable automatic startup of the service on boot, run the following command:

sudo systemctl enable HelloWorld

This console app works fine, but Microsoft has provided the worker template which is a more robust solution for long running services/daemons. Let's upgrade to using the worker template next.

.NET Core worker template #

Let's create a new empty directory and create the worker using the dotnet CLI:

mkdir ~/WorkerApp
cd ~/WorkerApp
dotnet new worker

Verify the worker is functional using the command dotnet run.
If the application works, publish it somewhere logical such as /srv/WorkerApp:

sudo mkdir /srv/WorkerApp                  # Create directory /srv/WorkerApp
sudo chown yourusername /srv/WorkerApp     # Assign ownership to yourself of the directory
dotnet publish -c Release -o /srv/WorkerApp

Let's verify we can also run the published application:

/srv/WorkerApp/WorkerApp
# Output should look like this:
#   info: WorkerApp.WorkerApp[0]
#         WorkerApp running at: 01/28/2020 15:08:56 +00:00
#   info: Microsoft.Hosting.Lifetime[0]
#         Application started. Press Ctrl+C to shut down.
#   info: Microsoft.Hosting.Lifetime[0]
#         Hosting environment: Production
#   info: Microsoft.Hosting.Lifetime[0]
#         Content root path: /home/yourusername/WorkerApp
#   info: WorkerApp.WorkerApp[0]
#         WorkerApp running at: 01/28/2020 15:08:57 +00:00
#   info: WorkerApp.WorkerApp[0]
#         WorkerApp running at: 01/28/2020 15:08:58 +00:00

Create a service unit configuration file called "WorkerApp.service" inside our project:

[Unit]
Description=Long running service/daemon created from .NET worker template

[Service]
# will set the Current Working Directory (CWD). Worker service will have issues without this setting
WorkingDirectory=/srv/WorkerApp
# systemd will run this executable to start the service
# if /usr/bin/dotnet doesn't work, use `which dotnet` to find correct dotnet executable path
ExecStart=/usr/bin/dotnet /srv/WorkerApp/WorkerApp.dll
# to query logs using journalctl, set a logical name here
SyslogIdentifier=WorkerApp

# Use your username to keep things simple.
# If you pick a different user, make sure dotnet and all permissions are set correctly to run the app
# To update permissions, use 'chown yourusername -R /srv/WorkerApp' to take ownership of the folder and files,
#       Use 'chmod +x /srv/WorkerApp/WorkerApp' to allow execution of the executable file
User=yourusername

# ensure the service restarts after crashing
Restart=always
# amount of time to wait before restarting the service                  
RestartSec=5

# This environment variable is necessary when dotnet isn't loaded for the specified user.
# To figure out this value, run 'env | grep DOTNET_ROOT' when dotnet has been loaded into your shell.
Environment=DOTNET_ROOT=/usr/lib64/dotnet  

[Install]
WantedBy=multi-user.target

Copy the service configuration file to /etc/systemd/system/WorkerApp.service and tell systemd to reload the configuration files:

sudo cp WorkerApp.service /etc/systemd/system/WorkerApp.service
sudo systemctl daemon-reload
sudo systemctl start WorkerApp

Using 'journalctl', we can verify that the application is contentiously running successfully. The following 'journalctl' command will follow the output of the application. Use Ctrl-C to exit the command.

sudo journalctl -u WorkerApp -f

The .NET Core worker now runs as a systemd service, but the integration between .NET Core and systemd can bi improved on by installing the systemd-integration.

Adding .NET Core Systemd integration #

Microsoft recently added a package to better integrate with systemd. The .NET Core application will notify systemd when it's ready and when it's stopping. Additionally, systemd will now understand the different log levels when the .NET Core application logs to output.

Using the dotnet CLI, add the 'Microsoft.Extensions.Hosting.Systemd' (nuget) package:

dotnet add package Microsoft.Extensions.Hosting.Systemd

Next, we'll need to add one line to the 'Program.cs', `.UseSystemd()`:

using WorkerApp;
using Microsoft.Extensions.Hosting;

IHost host = Host.CreateDefaultBuilder(args)
	.UseSystemd()
	.ConfigureServices(services =>
	{
		services.AddHostedService<Worker>();
	})
	.Build();

Lastly, we need to update our service unit configuration file to specify 'type=Notify':

[Unit]
Description=Long running service/daemon created from .NET worker template

[Service]
Type=notify
# will set the Current Working Directory (CWD). Worker service will have issues without this setting
WorkingDirectory=/srv/WorkerApp
# systemd will run this executable to start the service
# if /usr/bin/dotnet doesn't work, use `which dotnet` to find correct dotnet executable path
ExecStart=/usr/bin/dotnet /srv/WorkerApp/WorkerApp.dll
# to query logs using journalctl, set a logical name here
SyslogIdentifier=WorkerApp

# Use your username to keep things simple.
# If you pick a different user, make sure dotnet and all permissions are set correctly to run the app
# To update permissions, use 'chown yourusername -R /srv/WorkerApp' to take ownership of the folder and files,
#       Use 'chmod +x /srv/WorkerApp/WorkerApp' to allow execution of the executable file
User=yourusername

# ensure the service restarts after crashing
Restart=always
# amount of time to wait before restarting the service                  
RestartSec=5

# This environment variable is necessary when dotnet isn't loaded for the specified user.
# To figure out this value, run 'env | grep DOTNET_ROOT' when dotnet has been loaded into your shell.
Environment=DOTNET_ROOT=/usr/lib64/dotnet  

[Install]
WantedBy=multi-user.target

Let's publish, reload, and restart the service:

sudo systemctl stop WorkerApp # stop service to release any file locks which could conflict with dotnet publish
dotnet publish -c Release -o /srv/WorkerApp
sudo cp WorkerApp.service /etc/systemd/system/WorkerApp.service
sudo systemctl daemon-reload
sudo systemctl start WorkerApp  

With the Systemd integration in place, we can now use the priority-flag (-p) on 'journalctl' to filter the output according to the log levels below:

LogLevel Syslog level systemd name
Trace/Debug 7 debug
Information 6 info
Warning 4 warning
Error 3 err
Critical 2 crit

For example, the following command will only print output with log level 4 and below:

sudo journalctl -u WorkerApp -f -p 4

We won't see much because there's nothing being logged as a warning, error, or critical.
Update the 'WorkerApp.cs' file to include 'LogWarning', 'LogError', 'LogCritical' and republish:

namespace WorkerApp;

public class Worker : BackgroundService
{
	private readonly ILogger<Worker> _logger;

	public Worker(ILogger<Worker> logger)
	{
		_logger = logger;
	}

	protected override async Task ExecuteAsync(CancellationToken stoppingToken)
	{
		while (!stoppingToken.IsCancellationRequested)
		{

			_logger.LogInformation("Information - Worker running at: {time}", DateTimeOffset.Now);
			_logger.LogWarning("Warning - Worker running at: {time}", DateTimeOffset.Now);
			_logger.LogError("Error - Worker running at: {time}", DateTimeOffset.Now);
			_logger.LogCritical("Critical - Worker running at: {time}", DateTimeOffset.Now);
			await Task.Delay(1000, stoppingToken);
		}
	}
}

Republish and restart the service:

sudo systemctl stop WorkerApp # stop service to release any file locks which could conflict with dotnet publish
dotnet publish -c Release -o /srv/WorkerApp
sudo systemctl start WorkerApp

When we run the same 'journalctl' command, we can now see the warning output as bold white text, the error and critical output as red bold text.

sudo journalctl -u WorkerApp -f -p 4
# Output should look like this:
#   Jan 28 18:17:02 rhtest WorkerApp[33537]: WorkerApp.WorkerApp[0] Warning - WorkerApp running at: 01/28/2020 18:17:02 +00:00
#   Jan 28 18:17:02 rhtest WorkerApp[33537]: WorkerApp.WorkerApp[0] Error - WorkerApp running at: 01/28/2020 18:17:02 +00:00
#   Jan 28 18:17:02 rhtest WorkerApp[33537]: WorkerApp.WorkerApp[0] Critical - WorkerApp running at: 01/28/2020 18:17:02 +00:00
#   Jan 28 18:17:03 rhtest WorkerApp[33537]: WorkerApp.WorkerApp[0] Warning - WorkerApp running at: 01/28/2020 18:17:03 +00:00
#   Jan 28 18:17:03 rhtest WorkerApp[33537]: WorkerApp.WorkerApp[0] Error - WorkerApp running at: 01/28/2020 18:17:03 +00:00
#   Jan 28 18:17:03 rhtest WorkerApp[33537]: WorkerApp.WorkerApp[0] Critical - WorkerApp running at: 01/28/2020 18:17:03 +00:00

The 'UseSystemd' function will not do anything when run outside of a systemd service.
The implementation checks if the OS is a Unix system and whether the parent process is systemd.
If not, the systemd integration is skipped.

Summary #

.NET Core has good support for running services on Linux. Using the worker template, we can create a long running service/daemon that integrates well with systemd. Using the systemd hosting integration, systemd is notified when the .NET Core application is ready & also understand the different log levels in .NET.

The setup for ASP.NET Core apps is very similar, though it'll take some extra steps to ensure the web application is accessible to other machines in your network or the internet.

Related Posts

Related Posts