In this article, we will deploy a .NET app as a systemd service. Instead of installing the application directly on the system, we will take a different approach by using a container instead.
Why use a container?
When we deploy an application as a systemd service, we need to solve problems that go beyond starting the application. Containers solve problems such as: how the application gets distributed, how upgrades are managed, and how native dependencies are provided.
Container images are distributed through registries. Images are versioned using tags, allowing for easy upgrades and including all the dependencies. When deploying to Kubernetes, packing up your .NET application as a container for systemd gives it the benefits of being self-contained and universally deployable.
Creating an application
As an example for this article, we will use an application based on the Worker
template. The Worker
template is a console application based on Microsoft.Extension.Hosting
and provides the configuration and dependency injection services you know from ASP.NET Core. Worker
is meant for long-running applications that aren't web applications. You can learn more about the Worker
template in Worker Services. What we do in this article also applies to ASP.NET Core.
Create the application as follows:
$ dotnet new worker -o worker
$ cd worker
If you open up the application folder, you'll find the Worker.cs
file, which implements a simple long-running job that prints a message to the console once a second.
Execute dotnet run
to see the application in action. When you've seen a couple of messages, press Ctrl-C to terminate the application.
The .NET Core 3.0 added support for systemd services. Specifically, the application host (from Microsoft.Extension.Hosting
) can notify systemd upon startup. For a web application, notifying systems indicates that the application is ready to serve requests. In our example, the notification means that all the hosted services, such as the Worker
class, have started for our worker.
If you want a BackgroundService
such as Worker.cs
to do asynchronous initialization that affects readiness, you can allow time for that activity by overriding the base StartAsync
method, as shown in the following code:
public async override Task StartAsync(CancellationToken cancellationToken)
{
// Perform startup.
System.Console.WriteLine("Starting");
await Task.Delay(500, cancellationToken);
System.Console.WriteLine("Started");
// Call base method.
await base.StartAsync(cancellationToken);
}
In .NET 7, systemd support was extended to include .NET running in a container managed by systemd. If you are using an earlier version of .NET, systemd can run your container, but it won't know when your application is fully ready.
To enable systemd integration, add the Microsoft.Extensions.Hosting.Systemd
package:
$ dotnet add package Microsoft.Extensions.Hosting.Systemd
Next, call UseSystemd
on your application's IHostBuilder
:
IHost host = Host.CreateDefaultBuilder(args)
.ConfigureServices(services =>
{
services.AddHostedService<Worker>();
})
+ .UseSystemd()
.Build();
You can add this code to any .NET application to make it runnable with systemd. If the application doesn't execute from systemd, the UseSystemd
call is a no-op.
Creating a container image
Now we're going to create a container image. There are a number of ways you can do this. You can hand-craft a Containerfile
(Dockerfile) based on Microsoft's Containerize a .NET app tutorial. You can use the new SDK feature that builds an image for you: as described in Announcing built-in container support for the .NET SDK. And a third option is to use the dotnet build-image
global tool that I introduced in the article Containerize .NET applications without writing Dockerfiles.
Feel free to pick the option you prefer. I'm going to use the third option.
Install the global tool:
$ dotnet tool install -g dotnet-build-image
And now, create the image:
$ dotnet build-image -t awesome-worker-app:latest -b ubi
We're passing two arguments. awesome-worker-app:latest
is the name of the image. ubi
indicates that creation should be based on Red Hat's UBI images. You can pick another base, such as alpine
or jammy-chiseled
.
For this tutorial, we'll deploy the systemd unit on the local system. To deploy it on several systems, you can set the tag to match the image registry (for example: -t quay.io/tmds/awesome-worker-app:latest
), and add the --push
argument to push the image to the registry after it was built.
The following command runs the image locally:
$ podman run –rm awesome-worker-app:latest
As this executes, you'll see the messages from the containerized .NET application, similar to when executing dotnet run
. Terminate the container by pressing Ctrl+C.
Creating a systemd unit
We'll use Podman to generate a systemd unit file for us.
Start the container by running the following command:
$ podman run --name awesome-worker-app --rm --label "io.containers.autoupdate=local" localhost/awesome-worker-app:latest
This command is similar to the one we used earlier. We're using the fully qualified name of the image (by adding the localhost/
prefix), and we've added a label (io.containers.autoupdate
), which we'll discuss in a moment. If your image is deployed to a remote registry, set the label value to registry
instead of local
.
If your application uses .NET 7, which supports systemd containers, you can add --sdnotify=container
to use that support.
In another terminal, run:
$ podman generate systemd --new -n -f --start-timeout 600 awesome-worker-app
This will write out a file named container-awesome-worker-app.service
with the following content:
# container-awesome-worker-app.service
# autogenerated by Podman 4.2.0
[Unit]
Description=Podman container-awesome-worker-app.service
Documentation=man:podman-generate-systemd(1)
Wants=network-online.target
After=network-online.target
RequiresMountsFor=%t/containers
[Service]
Environment=PODMAN_SYSTEMD_UNIT=%n
Restart=on-failure
TimeoutStartSec=600
TimeoutStopSec=70
ExecStartPre=/bin/rm -f %t/%n.ctr-id
ExecStart=/usr/bin/podman run \
--cidfile=%t/%n.ctr-id \
--cgroups=no-conmon \
--rm \
--sdnotify=conmon \
-d \
--replace \
--name awesome-worker-app \
--label io.containers.autoupdate=local localhost/awesome-worker-app:latest
ExecStop=/usr/bin/podman stop --ignore --cidfile=%t/%n.ctr-id
ExecStopPost=/usr/bin/podman rm -f --ignore --cidfile=%t/%n.ctr-id
Type=notify
NotifyAccess=all
[Install]
WantedBy=default.target
This systemd file isn't specific to your system. It can be used to deploy the image anywhere.
We've provided a large --start-timeout
value of 10 minutes to allow the system time to pull the image (in case the image is hosted on a remote registry).
The label we added through the --label
argument shows up in the ExecStart
command. This label is detected by a podman auto-update command, which monitors all containers with this label. If a new image is available, the command pulls it and restarts the systemd unit.
Podman ships with a timer that triggers the update daily at midnight. If the new image fails to deploy, Podman even rolls back the container to the previous version. Failure detection works best if you use the --sdnotify=container
option (with .NET 7).
Now, if you host the image in an image registry solely using this unit file, you can deploy the .NET application to a large number of systems and even have it update automatically.
Deploying the service
You can deploy this unit with the system units at /usr/systemd/system
. We'll deploy it as part of our user's systemd services as follows:
$ mkdir -p ~/.config/systemd/user
$ cp container-awesome-worker-app.service ~/.config/systemd/user/awesome-worker-app.service
$ systemctl --user daemon-reload
$ systemctl --user start awesome-worker-app
That's it—your service is up and running.
You can get the logs of the service by running the following:
$ journalctl --user -f -u awesome-worker-app
If you'd like this service to start at boot, you have to enable lingering for your user and enable the service as follows:
$ sudo loginctl enable-linger $(id -u)
$ systemctl -- user start awesome-worker-app
Triggering an update
To finish the example, let's see auto-update in action.
First, change the message that is printed in Worker.cs
:
{
while (!stoppingToken.IsCancellationRequested)
{
- _logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now);
+ _logger.LogInformation("Updated worker running at: {time}", DateTimeOffset.Now);
await Task.Delay(1000, stoppingToken);
}
}
Then rebuild the image:
$ dotnet build-image -t awesome-worker-app:latest -b ubi
The new image is now available. A system that has the auto-update timer enabled will start using it at midnight. But you don't have to wait for that: to update the image the right way, just invoke podman auto-update:
$ podman auto-update
UNIT CONTAINER IMAGE POLICY UPDATED
awesome-worker-app.service 342942f00932 (awesome-worker-app) awesome-worker-app:latest local true
The command updates the service, and you can see the change in the journal:
$ journalctl --user -f -u awesome-worker-app
Containers and new .NET features simplify systemd
This article has covered how to deploy .NET applications as systemd services using containers. Containers offer a convenient way to distribute, deploy, and update your application. You've also learned that applications using .NET 7 support notifying systemd about their readiness when running in containers.
Last updated: September 19, 2023