Trying out the built-in container support for the .NET SDK
Just show me the code!
As always, if you don’t care about the post I have uploaded the source code on my Github.
A few months ago, the built-in container support for the .NET SDK was announced. This feature allows us to containerize our application using the dotnet publish
command, with no need of having to write a Dockerfile
.
To make it work we just have to add a series of properties in the application project file (.csproj), install the Microsoft.NET.Build.Containers
package and run the dotnet publish
command, the output artifact will be a container image of our app.
I’ve been wanting to try this new feature for quite a while, but I don’t want to use it with a simple “Hello World” .NET app, because I know that it will work well with it.
Instead, I decided that I’ll try to migrate an app that has a rather “complex” Dockerfile
to a new version that has no Dockerfile
and uses the built-in container support feature.
Application & Dockerfile
The application is a BookStore API built using .NET 7. It allows us to do the following actions:
- Get, add, update and delete book categories.
- Get, add, update and delete books.
- Get, add, update and delete inventory.
- Get, add and delete orders.
But the application per se doesn’t matter at all, the important part of the app is the Dockerfile
.
Remember that the purpose of this post is to evaluate if we can move from an app that uses a complex
Dockerfile
to a “dockerfile-less” app that uses the container support for .NET SDK.
The first step is to know what the BookStore API Dockerfile
is doing, so we can replicate those same features with the container support for .NET.
The BookStore API Dockerfile
contains the following features:
- It uses a multi-stage build.
- It uses 2 private platform images as base images. Those images are hosted on a private
AWS ECR
repository. - The API references a few private NuGet packages hosted on my private
Azure Artifacts NuGet feed
, which means that theDockerfile
has to make use of the Azure Artifacts Credential Provider when trying to run thedotnet restore
command. - The application is published using the
PublishTrimmed
feature. This feature removes unnecessary framework components from the resulting artifact. - The app is published using the
self-contained
mode. - The container uses a
non-root user
to run the API (This is done in the base image, not in the application Dockerfile. More info in the next section).
The next code snippet shows how the Dockerfile
looks like.
FROM 823934831816.dkr.ecr.eu-west-1.amazonaws.com/sdk:7.0-bullseye-slim-1.0.0 AS build-env
WORKDIR /app
# Get Azure DevOps PAT from args
ARG AZDO_PAT
# Setup cred-provider
ENV VSS_NUGET_EXTERNAL_FEED_ENDPOINTS {\"endpointCredentials\": [{\"endpoint\":\"https://pkgs.dev.azure.com/cponsn/_packaging/my-very-private-feed/nuget/v3/index.json\", \"username\":\"build\", \"password\":\"$AZDO_PAT\"}]}
# Copy everything and restore packages
COPY . ./
RUN dotnet restore -s "https://pkgs.dev.azure.com/cponsn/_packaging/my-very-private-feed/nuget/v3/index.json" \
-s "https://api.nuget.org/v3/index.json" \
--runtime linux-x64
# Build project
RUN dotnet build "./src/BookStore.WebApi/BookStore.WebApi.csproj" \
-c Release \
--runtime linux-x64 \
--self-contained true \
--no-restore
# Publish app
RUN dotnet publish "./src/BookStore.WebApi/BookStore.WebApi.csproj" \
-c Release \
-o /app/publish \
--no-restore \
--no-build \
--self-contained true \
--runtime linux-x64 \
/p:PublishTrimmed=true
# Build runtime image
FROM 823934831816.dkr.ecr.eu-west-1.amazonaws.com/runtime-deps:7.0-bullseye-slim-1.0.0
# Expose port
EXPOSE 8080
# Copy artifact
WORKDIR /app
COPY --from=build-env /app/publish .
# Set entrypoint
ENTRYPOINT ["./BookStore.WebApi"]
Platform images
As I stated in the previous section, the BookStore API Dockerfile
uses a multi-stage build with a pair of private platform images as base images.
But, what`s a platform image?
When talking about containers security, one of the best practices is to use your own platform images, those platform images are the base for your company applications.
The point of having a platform image instead of directly using a base image is to ensure that the resulting containers are hardened according to any corporate policies before being deployed to production.
The BookStore API Dockerfile
uses a multi-stage build to create the container image, but the container support feature works a little bit different, when you execute the dotnet publish
command instead of using a multi-stage approach it creates the application artifact locally (on your machine, outside of Docker) and afterwards the artifact is copied inside an image.
The BookStore API Dockerfile
uses those 2 platform images:
sdk:7.0-bullseye-slim-1.0.0
runtime-deps:7.0-bullseye-slim-1.0.0
But the container support for .NET SDK only needs a single base image because the build step is done outside of Docker, which means that the only image we need is the one that contains the runtime dependencies: runtime-deps:7.0-bullseye-slim-1.0.0
Let’s take a look at what the runtime-deps:7.0-bullseye-slim-1.0.0
image contains:
- A user named
devsecops
is created with theadduser
command (Thegecos
argument prevents the system from asking for additional details). - The
chown
command is used to set thedevsecops
user as owner of the/app
directory. - Non-root users are not allowed to allocate ports below
1024
, with theASPNETCORE_URLS
environment we’re settings the port8080
as the running port.
FROM mcr.microsoft.com/dotnet/runtime-deps:7.0-bullseye-slim
# Set internal version
ENV INTERNAL_VERSION=11202022.1
# Set non-root user
RUN adduser --disabled-password \
--home /app \
--gecos '' devsecops && chown -R devsecops /app
# Set the default user.
USER devsecops
# Non-user root cannot start on port 80
ENV ASPNETCORE_URLS=http://+:8080
Implementation
Now that we know how the Dockerfile
on the BookStore API looks like, it’s time to delete it from the solution and start using the container support for .NET.
1. Install the Microsoft.NET.Build.Containers
NuGet package.
This NuGet is reponsible to enable the container support features.
Right now (11/29/2022) the latest version of this package is:
- 0.3.0-alpha.16
This version is not available on nuget.org, you can get it from its GitHub page.
2.Configure the csproj file
The way to configure the container support for .NET SDK is through a set of MSBuild properties that can be placed into the application project (.csproj) file.
The container support properties we’re going to need are the following ones:
ContainerBaseImage
- Specifies the base image used for your resulting image.
- The value is going to be:
823934831816.dkr.ecr.eu-west-1.amazonaws.com/runtime-deps:7.0-bullseye-slim-1.0.0
ContainerImageName
- This property controls the name of the image itself.
- If a container image is not specified, it will use the
AssemblyName
of the project. In our case we’re going to use:bookstore-webapi
ContainerImageTags
- This property controls the tags that are generated for the image.
- If a container tag is not specified, it will use the
Version
of the project. In our case we’re going to use:latest
ContainerPort
- This attribute adds TCP or UDP ports to the list of known ports for the container, it is the equivalent of the
EXPOSE
command on theDockerfile
. - This attribute does absolutely nothing, it is an informative only attribute.
- The value is going to be:
<ContainerPort Include="8080" Type="tcp" />
- This attribute adds TCP or UDP ports to the list of known ports for the container, it is the equivalent of the
PublishProfile
- For containerizing a webapp the property
PublishProfile=DefaultContainer
is required.
- For containerizing a webapp the property
The Bookstore API Dockerfile
is also using the PublishTrimmed
attribute, to reduce the size of the resulting artifact, and the self-contained
attribute to publish the app as an executable, to mimick the same behaviour we’re going to add them on the project (csproj) file:
SelfContained
PublishTrimmed
Also the RuntimeIdentifier
attribute is required. This attribute is used to identify the target platforms where the application needs to run, in this case the value will be linux-x64
because we’re building a Linux container.
Instead of putting the SelfContained
, RuntimeIdentifier
and PublishTrimmed
properties on the project file, we can aso specify them when executing the dotnet publish
command, like this: dotnet publish --self-contained true --runtime linux-x64 /p:PublishTrimmed=true
If you decide to put those properties on the project file (.csproj) you must know that you won’t be able to build the app with Visual Studio on a Windows machine, mainly because you’re targeting Linux. Furthermore, the build and debug process on Visual Studio it’s going to take longer than usual because it needs to trim and self-contain the application artifact. If any of this bothers you, remove those properties from the csproj and specify them when executing the dotnet publish
command.
Here’s how the project file will end up looking like:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<PropertyGroup>
<RuntimeIdentifier>linux-x64</RuntimeIdentifier>
<PublishProfile>DefaultContainer</PublishProfile>
<SelfContained>true</SelfContained>
<PublishTrimmed>true</PublishTrimmed>
<ContainerImageName>bookstore-webapi</ContainerImageName>
<ContainerBaseImage>823934831816.dkr.ecr.eu-west-1.amazonaws.com/runtime-deps:7.0-bullseye-slim-1.0.0</ContainerBaseImage>
<ContainerImageTags>latest</ContainerImageTags>
</PropertyGroup>
<ItemGroup>
<ContainerPort Include="8080" Type="tcp" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Build.Containers" Version="0.3.0-alpha.16" />
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0" />
<PackageReference Include="MyOwn.EmailService" Version="1.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\BookStore.Domain\BookStore.Domain.csproj" />
<ProjectReference Include="..\BookStore.Infrastructure\BookStore.Infrastructure.csproj" />
</ItemGroup>
</Project>
3.Create the container
Now it’s time to run the dotnet publish
command that creates the container image, but this application is using a few private resources, to be more precise:
- The app has a few references to private packages hosted on my private
Azure DevOps
feed. - The app uses the
runtime-deps:7.0-bullseye-slim-1.0.0
as base image. This image is hosted on my privateAWS ECR
repository.
So, before running the dotnet publish
command I have to setup the AWS
and Azure DevOps
credentials on my local machine to allow the .NET CLI to retrieve them.
- To allow the
dotnet publish
command to pull the private image fromECR
, I’m going to make adocker login
command and use a Named Profile alongside theAWS_PROFILE
environment variable. - To allow the .NET CLI to restore the private packages from my private
Azure DevOps
feed, I’m going to place aNuGet.Config
with the proper credentials on my computer.
After settings the credentials properly, let’s run the dotnet publish
command and the BookStore API container image is created successfully.
If you take a look at the picture above, you’ll see that the image created with the dotnet publish
command (bookstore-webapi) is exactly the same size as the one created using the Dockerfile
(bookstore.api).
Building a container image using Azure Pipelines
We know from the previous section that moving from the BookStore API Dockerfile
to a “dockerfile-less” app that uses the built-in container support for .NET is doable on my local machine, but is it feasible on a CI/CD environment?
For this test I’m going to use Azure Pipelines
, if you want to use any other CI/CD runner (GitHub Actions, Bitbucket, etc.) it should be relatively easy to switch, because the idea behind it is the same for any runner.
The pipeline needs to do the same steps we have done on our local machine:
- Restore the application NuGet packages.
- The app contains some private reference, and those packages are hosted on a private
Azure DevOps
feed, which means that the pipeline task has to use thevstsFeed
attribute to specify which feed will be used when restoring the packages.
- The app contains some private reference, and those packages are hosted on a private
- Login into the docker registry
- Execute the
dotnet publish
command. - And finally, let’s try to push the image into an
AWS ECR
repository, why not?
The container support for .NET has the ContainerRegistry
attribute, which allow us to push the resulting image into a registry, if this value is not present on the csproj then the image gets stored in the local Docker daemon.
Right now the ContainerRegistry
attribute doesn’t work with AWS ECR
. If you need to push the resulting image into an AWS ECR
repository you have to store it in you local Docker machine and then push it onto the repository using the docker push
command.
Here’s how the resulting Azure Pipeline
looks like:
trigger: none
pool:
vmImage: ubuntu-latest
variables:
awsAccount: '823934831816'
region: 'eu-west-1'
steps:
- task: UseDotNet@2
displayName: 'Install .NET Core sdk 7.x'
inputs:
packageType: 'sdk'
version: '7.x'
- task: DotNetCoreCLI@2
displayName: 'Restore packages'
inputs:
command: 'restore'
projects: '**/*.csproj'
feedsToUse: 'select'
vstsFeed: 'a4c874c8-2ab9-4a3a-aa79-6f007f55ee6f'
- task: AWSShellScript@1
displayName: 'Build container and publish to AWS ECR'
inputs:
awsCredentials: 'aws-dev'
regionName: '$(region)'
scriptType: 'inline'
inlineScript: |
aws ecr get-login-password --region $(region) | docker login --username AWS --password-stdin $(awsAccount).dkr.ecr.$(region).amazonaws.com
dotnet publish $(Build.SourcesDirectory)/src/BookStore.WebApi/BookStore.WebApi.csproj --no-restore
docker tag bookstore-webapi:latest $(awsAccount).dkr.ecr.$(region).amazonaws.com/bookstore.webapi:latest
docker push $(awsAccount).dkr.ecr.$(region).amazonaws.com/bookstore.webapi:latest
Testing the resulting container image
We have successfully created the BookStore API container image using the container support for .NET SDK, but does it work?
Let’s run it using the docker run -d -p 5001:8080 bookstore-webapi
command and then let’s send a request to https:\\localhost:8080\api\orders
using cURL, and it doesn’t work…
The container appears to be running properly, but what it seems weird is that Kestrel is listening on port 80
instead of port 8080
that it was the one we defined on our base image using the ASPNETCORE_URLS
environment variable.
If we open a Shell session inside the container using the docker exec -it <container-id> sh
command, it seems that the root
user owns the /app
folder, but have created a non-root user called devsecops
who should own this directory.
It seems that the container support for .NET takes a few concessions when creating an image, there are certain values that grt overriden like the running port or the users and permissions, which represents a problem if you’re running an app that listens on a non-80 port or uses a non-root user.
In fact, if we try to run the BookStore API on port 80 using the docker run -d -p 5001:80 bookstore-webapi
command, it app works perfectly.
- Taking a look at GitHub, it seems that there is an ongoing issue with users and permissions.
Closing thoughts
The purpose of this post was to evaluate if we could move from an app that was using a Dockerfile
to a “dockerfile-less” app that uses the container support for .NET.
My overall impressions is that the container support for .NET works pretty well.
Restoring packages from a private feed on a .NET app is always a pain in the ass when building an image using a multi-stage approach, but the fact that the container support builds the artifact outside of docker makes the process more palatable.
Concerning the ability to push and pull images to and from a private registry, it is very good news that the container support is able to authenticate to a container registry, which allows us to use our own private base images and not have to rely on public images from Microsoft.
The fact that the image cannot be pushed to AWS ECR
is a shame, but is not a deal breaker at all, you can always store it on your local docker daemon and push it afterwards using the docker push
command.
Right now, the most well-known constraint when using the container support for .NET SDK is the fact that it can’t execute RUN
commands, for this post this was not important because the BookStore API Dockerfile
didn’t need to use it, but that’s something to keep in mind.
A possible solution if you’re using a RUN
command on your Dockerfile
and you want to start using the container support is to move the command into a base image.
Being able to use the container support for .NET SDK on a CI/CD enviroment is feasible and quite easy to do it, and in this post we have demonstrated that it is possible to do it.
So far, everything related to the container support seems positive, but the fact that you cannot run a container with a non-root user is a deal breaker from me, I cannot fathom any excuse for why you’d only allow to create container that can be run by a root user.
So, if you’re in a big enterprise we’re security is paramount and everything gets scanned and deeply reviewed, maybe it’s not yet the time to move away from the Dockerfile
.