Featured images for .NET topics.

In this article, we'll take a deep dive into the container tooling of the upcoming .NET 8 release.

.NET container tooling

The SDK container tooling provides an easy way to build container images directly from a .NET project. It was introduced last year via a NuGet package for .NET 7. For .NET 8, it comes included with the SDK.

During .NET 8's development, the container tooling has seen many improvements, including support for building rootless images, support for Podman, and better interoperability with container registries (like Docker Hub, quay.io, and the Amazon container registry).

.NET 8 will be available in November. If you want to try what is shown in this article, you can download a release candidate from Microsoft download page.

If you are on Fedora, you can obtain it from the Fedora .NET SIG copr repo:

dnf copr enable @dotnet-sig/dotnet
dnf install dotnet-sdk-8.0

Our first image

For our first container image, we'll containerize an ASP.NET Core application.

First, create an ASP.NET Core web project:

dotnet new web -o web
cd web

Now publish it while setting PublishProfile to DefaultContainer:

dotnet publish  /p:PublishProfile=DefaultContainer

That's it!

You can run the application image locally using Podman or Docker and visit the site on the exposed port.

podman run --rm -p 8080:8080 web

The publish command outputs something like this:

MSBuild version 17.8.0-preview-23468-06+1b84c9b5c for .NET
  Determining projects to restore...
  All projects are up-to-date for restore.
  web -> /tmp/web/bin/Release/net8.0/web.dll
  web -> /tmp/web/bin/Release/net8.0/publish/
  Building image 'web' with tags 'latest' on top of base image 'mcr.microsoft.com/dotnet/aspnet:8.0-rc.2'.
  Pushed image 'web:latest' to local registry via 'podman'.

If you look closer at the output, you can see the SDK used mcr.microsoft.com/dotnet/aspnet:8.0.0-rc.2 as the base image, and created an application image named web:latest. That's the name we used to run the container locally.

The SDK defaults to use Microsoft base images. It picks an image considering the properties that are set in the project. In this case, we're packing an ASP.NET application that targets .NET 8, so it picks the aspnet base image for that version. If the project changed to a self-contained application, the SDK would use the runtime-deps base image instead.

By default, the application image will be named the same as the output assembly of the .NET project. You can override the image name by setting the ContainerRepository property in the project file.

Selecting the base image

Microsoft's default images are Debian based. Microsoft publishes additional images based on other distros as you can see from the tag names on the Microsoft runtime repository. The SDK allows you to choose one of the other distros by setting the ContainerFamily property. This value will be added as a suffix to the base image tag. For example, to use Alpine images you can set:

<ContainerFamily>alpine</ContainerFamily>

The SDK isn't limited to using Microsoft images. You can choose a base image using the ContainerBaseImage property. The following example changes the TargetFramework to .NET 7 and uses the corresponding Red Hat UBI .NET image. We're using .NET 7 because UBI images for .NET 8 won't be available until .NET 8 GA (November 2023).

<TargetFramework>net7.0</TargetFramework>
<ContainerBaseImage>registry.access.redhat.com/ubi8/dotnet-70-runtime:latest</ContainerBaseImage>

If you run the publish command again, the application image is built on top of the Red Hat base image!

Pushing the image to a repository

To publish the image to a container repository, we need to provide our credentials. The SDK can find credentials entered via Docker/Podman login.

Alternatively, you can also set the credentials using the SDK_CONTAINER_REGISTRY_UNAME and SDK_CONTAINER_REGISTRY_PWORD environment variables.

To set the target repository, we need to specify the registry host (ContainerRegistry), the image repository (ContainerRepository), and the image tag (ContainerImageTag). We can add them to the project file, or set them on the command line. The following example is for pushing the application image to quay.io/tmds/web:latest.

dotnet publish /p:PublishProfile=DefaultContainer /p:ContainerRegistry=quay.io /p:ContainerRepository=tmds/web /p:ContainerImageTag=latest

The default tag for the application image is latest. To use another, you can either set ContainerImageTag or ContainerImageTags. The latter property can be set to a semi-colon separated list of tags. To pass it on the command-line with bash, some single and double quotes escaping is needed to make bash and MSBuild accept the property value.

dotnet publish … '/p:ContainerImageTags="tag1;tag2"'

Targeting another architecture

A container registry can support different architectures and operating systems. For example, Microsoft's .NET images (like mcr.microsoft.com/dotnet/runtime:8.0) support x64, and arm64 on Windows and Linux and Red Hat's .NET images support x64, arm64, ppc64le, and s390x on Linux.

When building an application, .NET supports controlling the target platform through the RuntimeIdentifier property. The same property is used when building container images.

For example, on a Linux/Windows x64 development machine, an arm64 Linux container can be built by setting:

<RuntimeIdentifier>linux-arm64</RuntimeIdentifier>

If you want to set the runtime identifier so it only affects the container image, you can set ContainerRuntimeIdentifier instead of setting the RuntimeIdentifier.

Similarly, you can target the architectures provided by Red Hat. By default, the SDK includes a native executable (the app host) for the target architecture to start the application. For Microsoft-provided architectures, the SDK will fetch this executable from nuget.org. It is not available for the Red Hat architectures, so we need to disable it. Note that disabling the app host has no effect on how the resulting image works.

<ContainerBaseImage>registry.access.redhat.com/ubi8/dotnet-70-runtime:latest</ContainerBaseImage>
<RuntimeIdentifier>linux-ppc64le</RuntimeIdentifier>
<UseAppHost>false</UseAppHost>

Alternatively, if we use ContainerRuntimeIdentifier we don't need to set UseAppHost to false. The application will be published with the app host of the build platform. That executable doesn't work on the target architecture, but that is not an issue because it's not used to start the application in the container.

<ContainerBaseImage>registry.access.redhat.com/ubi8/dotnet-70-runtime:latest</ContainerBaseImage>
<ContainerRuntimeIdentifier>linux-ppc64le</ContainerRuntimeIdentifier>

Publishing self-contained applications

When you build an image for a self-contained application, the published application will include binaries that need to be compatible with the base image.

It's best to explicitly set the runtime identifier to ensure no binaries are used that are meant for the build platform. This might for example happen when a Linux container gets built on a Windows system.

dotnet publish --sc -r linux-x64 /p:PublishProfile=DefaultContainer

To set these values in the .NET project file, you can use RuntimeIdentifier (as we've done in the previous section) to set the -r argument, and the --sc argument can be set by adding a property SelfContained and setting it to true.

When you run the command, you'll see the SDK has changed from using the aspnet base image to the runtime-deps image. That makes sense: because ASP.NET is now included with the self-contained application, the base image no longer needs to provide it.

If you want to target Alpine Linux, instead of using the runtime identifier linux-x64 we need to use linux-musl-x64. The binaries for linux-musl-x64 are compatible with Linux distributions that use the musl C library instead of the glibc C library. The SDK does not (yet) pick a proper base image tag for this rid so we also need to set ContainerFamily.

dotnet publish --sc -r linux-musl-x64 /p:ContainerFamily=alpine /p:PublishProfile=DefaultContainer

Containerizing console applications

.NET 8's built-in SDK container tooling does not support building container images for console applications. These are the .NET projects that use the Microsoft.NET.SDK as can be seen at the top of the project file: <Project Sdk="Microsoft.NET.Sdk">. To containerize a console application, you need to use the container support from the NuGet package, and invoke the PublishContainer target.

dotnet add package Microsoft.NET.Build.Containers
dotnet publish /t:PublishContainer

Customizing the application image

In the following sections we'll cover how you can customize the application image further. We'll look at adding container labels, setting environment variables, adding ports, controlling the working directory and user, and customizing the entrypoint and command used to start the application.

Labels

Container images can include metadata through labels. These labels can be added from the project file as shown in the following example which adds a build host label that gets set to the machine name.

<ItemGroup>
  <ContainerLabel Include="com.my-company.buildhost" Value="$([System.Environment]::MachineName)" />
</ItemGroup>

The SDK will add some well-known labels by default. This can be disabled by setting ContainerGenerateLabels to false. It can also disabled for each label separately.

The following table shows the labels added by the SDK, what properties determine their value, and the property that can be used to disable adding the label (by setting it to false). Some values are initialized from well-known properties, and can be overridden through container-specific properties.

label value disable through
org.opencontainers.image.created
org.opencontainers.artifact.created
Now ContainerGenerateLabelsImageCreated
org.opencontainers.artifact.description
org.opencontainers.image.description
ContainerDescription/Description ContainerGenerateLabelsImageDescription
org.opencontainers.image.authors ContainerAuthors/Authors ContainerGenerateLabelsImageAuthors
org.opencontainers.image.url ContainerInformationUrl/PackageProjectUrl ContainerGenerateLabelsImageUrl
org.opencontainers.image.documentation ContainerDocumentationUrl/PackageProjectUrl ContainerGenerateLabelsImageDocumentation
org.opencontainers.image.version ContainerVersion/PackageVersion ContainerGenerateLabelsImageVersion
org.opencontainers.image.vendor ContainerVendor ContainerGenerateLabelsImageVendor
org.opencontainers.image.licenses ContainerLicenseExpression/PackageLicenseExpression ContainerGenerateLabelsImageLicenses
org.opencontainers.image.title ContainerTitle/Title ContainerGenerateLabelsImageTitle
org.opencontainers.image.base.name ContainerBaseImage ContainerGenerateLabelsImageBaseName
org.opencontainers.image.source PrivateRepositoryUrl ContainerGenerateLabelsImageSource
org.opencontainers.image.revision SourceRevisionId ContainerGenerateLabelsImageRevision

The source repository and revision information are provided through source-link, which is included with the SDK (since .NET 8). To add these labels, PublishRepositoryUrl must be set to true.

Environment variables

Environment variables can be set in the container image through ContainerEnvironmentVariable as shown in the following example.

<ItemGroup>
  <ContainerEnvironmentVariable Include="ASPNETCORE_ENVIRONMENT" Value="Production" />
</ItemGroup>

Ports

Container images describe the ports they expose. Note that these ports are image metadata, and they do not limit external access to specific ports on a container image. Ports can be added through ContainerPort as shown in the following example:

<ItemGroup>
  <ContainerPort Include="5000" Type="tcp"/>
</ItemGroup>

The SDK will expose the ports from the base image, and automatically add ports based on the well-known ASP.NET Core environment variables ASPNETCORE_URLS, ASPNETCORE_HTTP_PORTS and ASPNETCORE_HTTPS_PORTS.

Microsoft images prior to .NET 8 set the environment variable to use port 80. Starting with .NET 8, the Microsoft images use the same port as Red Hat's images: 8080.

Working directory

When the container image runs, the working directory is /app for Linux containers, and c:\app for Windows containers. The default can be overwritten by setting the ContainerWorkingDirectory property.

The working directory is also where the .NET application gets published to. This fulfills ASP.NET Core's default behavior which is to use the working directory as the content root (which is used to look up AppSettings and Razor files).

Container user

Since .NET 8, Microsoft .NET Linux container images include a non-root user named app. Like with previous .NET versions, the base image runs as the root user. If you build an application image through a Dockerfile, you can choose to make it run under the app user by adding an USER $APP_UID instruction. The $APP_UID used here is an environment variable set by the base image. It contains the app user's user id (uid).

For all .NET versions, Red Hat .NET images contain a non-root user and the image runs as that non-root user by default.

When the SDK containerizes a .NET application, that application will run as the base image user. If the APP_UID environment variable is set, that user is used instead.

In practice this means that with Microsoft images for .NET 8 or Red Hat images for any .NET version, the application image runs as a non-root user by default. With Microsoft base images for earlier versions of .NET, the application image will run as the root user by default.

The .NET application that is added to the image is owned by the root user. Consequently, the non-root user that runs the application in the container has no permissions to change the application files. Note that this includes the appsettings.json file that comes with an ASP.NET Core application.

If you want to explicitly control the user for the application image, you can set it using the ContainerUser property.

When a .NET image runs on the Red Hat OpenShift container platform, OpenShift will run it under a random user ID and with a group ID (gid) of 0. The random uid is an additional security measure preventing the uid to map to another user on the host. Unlike the uid of 0 (which means root), a group id of 0 has no privileges attached to it. To accommodate working on OpenShift, the Red Hat base image set the HOME environment to the rootless user's home directory, and this directory is configured with write permissions for its group (gid 0).

Controlling the entrypoint and command

Container images have an entrypoint (ENTRYPOINT) and a command (CMD). The entrypoint controls the fixed command line that gets executed when you run a container, and the command controls the default arguments that are passed.

For example:

podman run myimage
podman run myimage arg1 arg2

For the first command line, the container will execute the image entrypoint and pass it the image command. For the second command line, the container will execute the image entrypoint and pass it the command line arguments instead of the image command.

The entrypoint can be overwritten as well (through the --entrypoint argument). Also, both values can be controlled on Kubernetes/OpenShift as part of the pod spec. The common practice is to not override these values. For configuration, instead of arguments, environment variables and ConfigMaps are used. This makes the distinction between using an entrypoint or a command unimportant to the Kubernetes consumer.

By default, the application image generated by the SDK contains an entrypoint that starts the application and an empty command.

If you tried to publish the application using the .NET 7 Red Hat images, you may have noticed this warning message:

warning CONTAINER2022: The base image has an entrypoint that will be overwritten to start the application. Set ContainerAppCommandInstruction to 'Entrypoint' if this is desired. To preserve the base image entrypoint, set ContainerAppCommandInstruction to 'DefaultArgs'.

This message indicates that the Red Hat images have an entrypoint. This entrypoint is a helper script that performs initialization before starting the command. If you have a base image with such a helper script, it's best to preserve that logic by setting ContainerAppCommandInstruction to DefaultArgs. This causes the .NET application to be started using an image command instead of the image entrypoint. If the base image has an entrypoint has an entrypoint which isn't a helper script, you probably want to override it by setting ContainerAppCommandInstruction to Entrypoint so the .NET application starts.

The SDK container tooling allows to fully customize both the entrypoint and the command through the ContainerEntrypoint and ContainerDefaultArgs item groups. If you want to take full control you can set ContainerAppCommandInstruction to None. This stops the SDK from adding an instruction to start the .NET application.

Conclusion

The .NET 8 built-in support for building container images provides a convenient way to containerize .NET applications without going through the hassle of writing Dockerfiles. In this article, we've covered the ins and outs of the tooling. It's a good time to adopt this feature thanks to all the improvements made as part of the .NET 8 release. To learn more, you can find the official documentation at learn.microsoft.com.

Last updated: January 17, 2024