Using dotnet runtime package stores to optimize your docker images

.NET offers a somewhat unknown feature called “runtime package stores”, which can be used to optimize your Docker images.

According to the documentation it is “possible to package and deploy apps against a known set of packages that exist in the target environment”, which gives you the benefit of “deployments, lower disk space usage, and improved startup performance in some cases”. This sounds awesome! And when it comes to optimizing your Docker images, the ability to have “a known set of packages” already available from the base image, allows you to have more efficient deployments. Basically meaning that all you need to deploy is your actual app, and none of the dependencies.

What is a runtime package store?

I guess the first thing we need to do, is to sort out what a runtime package store really is. And to be honest, that is fairly simple…

A runtime package store is basically a directory on a machine, that contains a set of “pre-deployed” assemblies. A bit like a Global Assembly Cache (GAC), without the nastiness of the GAC.

This feature allows you to define a set of assemblies (NuGet packages in most cases) that you want to have pre-deployed to your server. These packages can then be loaded by your application, without it needing to bring all those packages to the server on it’s own.

Note: It also supports multiple versions of the packages. So, if you are deploying multiple applications to the same server, the benefit is even larger.

The directory structure of the runtime package store has to be structured in a certain way. Luckily, the dotnet CLI already has a command that allows us to easily build a runtime package store.

Creating a runtime package store

To create a package store, you first need a “manifest” that defines what packages you want to add to your store. And by “manifest”, we really mean a .csproj file.

Imagine that you have an application that depends on a few packages. For example, a project with a project file that looks like this

<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net6.0</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="BCrypt.Net-Core" Version="1.6.0" />
    <PackageReference Include="Newtonsoft.json" Version="13.0.1" />
    <PackageReference Include="SixLabors.ImageSharp" Version="1.0.4" />
  </ItemGroup>
</Project>

To support this application, we need to create a runtime package store that includes those 3 packages.

We could actually go ahead and create it based on this project file on its own. But let’s image that we want to create a more generic runtime package store that is to be shared by several applications.

To support this, we can go ahead and create a new .csproj file. I’ll call mine RuntimeStore.csproj… In this file, we define the SDK to use, the target framework, and the packages we want to include in our store. Like this

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net6.0</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="BCrypt.Net-Core" Version="1.6.0" />
    <PackageReference Include="Newtonsoft.json" Version="12.0.3" />
    <PackageReference Include="Newtonsoft.json" Version="13.0.1" />
    <PackageReference Include="SixLabors.ImageSharp" Version="1.0.4" />
    <PackageReference Include="Autofac" Version="6.4.0" />
  </ItemGroup>
</Project>

As you can see, it is very similar to the first one. But I have included an extra version of JSON.NET and Autofac.

The next step is to ask dotnet to create a runtime package store. This can be done by running

> dotnet store --manifest ./<PATH TO CSPROJ> --runtime <RUNTIME IDENTIFIER> --output ./<PATH TO STORE> --skip-optimization

Ok, that is pretty simple!

Note: Don’t ask me about the --skip-optimization. I’m not quite sure what it does, but if you leave it out, the command fails… So just leave it there and be happy!

Using a runtime package store

There are 2 more things left to do, to get our application to use our runtime package store. First of all, we need to tell the runtime that there is a package store on the machine. This is done by adding an environment variable called DOTNET_SHARED_STORE, with the value set to the path of the store. Like this in PowerShell

PS C:> $env:DOTNET_SHARED_STORE="<PATH TO STORE>"

Just remember to set the environment variable in a way that will be available to the entire machine. Or at least reachable from the session that will run your application…

The next step is to tell the compiler to not include the available packages when publishing the application. This is done by providing the compiler with a “manifest”. In this case, this manifest is an XML-file that defines what packages will be available in the runtime package store on the serve that is to host the application. Luckily, this XML-file is generated by the dotnet store command, and placed in a file called <RUNTIME STORE PATH>/<BITNESS>/<DOTNET VERSION>/artifact.xml. In the case of a 64-bit machine, with .NET 6.0, the path becomes <RUNTIME STORE PATH>/x64/net6.0/artifact.xml.

The simplest way to use this manifest file, is to copy the file from the runtime package store to your project. And then you tell the compiler that there is a manifest file, either by adding the --manifest parameter to the dotnet publish command, or by adding it to your project file like this

<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net6.0</TargetFramework>
    <TargetManifestFiles>./artifact.xml</TargetManifestFiles>
  </PropertyGroup>
  ...
</Project>

This will make sure that the compiler leaves out the pre-deployed packages from the deployment.

Using it with Docker images

But the title talked about optimizing our Docker images using this feature… Yes it did! And we will! But we must learn to walk before we can run…

They way that we can utilize this, is by adding a runtime package store to a base image. And then use that base image for our applications. It’s fairly simple.

Just create a folder that contains your runtime package store manifest and an empty Docker-file. Inside the Docker-file, you add something like this

FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build
COPY ./RuntimeStore.csproj /runtimestore/
RUN dotnet store --manifest ./runtimestore/RuntimeStore.csproj --runtime linux-x64 --output /runtimestore/store --skip-optimization

FROM mcr.microsoft.com/dotnet/aspnet:6.0
COPY --from=build /runtimestore/store /runtimestore
ENV DOTNET_SHARED_STORE "/runtimestore"

This will create a Docker image based on the mcr.microsoft.com/dotnet/aspnet:6.0 base-image, but with your own runtime package store added, as well as the required DOTNET_SHARED_STORE environment variable.

To create the image, just run

> docker build -t my-application-base-image .

in the folder with the Docker-file. This will create an image called my-application-base-image. I might suggest calling it something better, but that is up to you.

The next step is to get hold of the runtime package store manifest from the generated image. The easiest way to do this, is to run the following commands

> docker create --name temp my-application-base-image
> docker cp temp:/runtimestore/x64/net6.0/artifact.xml ./artifact.xml
> docker rm temp

This will create a new container based on the newly created image, copy out the artifact.xml file, and finally delete the container again.

Once you have the artifact.xml file, you just add it to the root of your application, and add the required <TargetManifestFiles> element to the project file. Like this

<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net6.0</TargetFramework>
    <TargetManifestFiles>./artifact.xml</TargetManifestFiles>
  </PropertyGroup>
  ...
</Project>

With that in place, you can create a Docker-file that looks something like this

FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build
COPY ./ ./app/
RUN dotnet publish -c Release -o ./publish ./app

FROM my-application-base-image
COPY --from=build /publish /app
WORKDIR /app
ENTRYPOINT dotnet ./MyApplication.dll

As you can see, it uses a multistage build to create the final image. First it uses the mcr.microsoft.com/dotnet/sdk:6.0 image to publish the application. And since you have the artifact.xml-file, and the reference to it in the project file, the application will be published without the packages defined in it.

Next, it copies the published application to an image based on your newly created base-image, setting the entry point to dotnet ./MyApplication.dll to make sure that the application starts up when the container starts.

As the base image already have the dependencies in the runtime package store, and the DOTNET_SHARED_STORE environment variable, the application should load without a problem. And the cool thing, is that that top layer with your application is now tiny, as it really only includes your code and files, not all the dependencies. This should save you a few megs every time you deploy a new image. And if you deploy often, that will add up. And obviously, the more dependencies you have, the bigger the benefit.

Conclusion

If you are really trying to get the absolute smallest deployments possible, this kind of pre-packaging of your assemblies can definitely help. Sure, in a small application with few dependencies, the benefit isn’t going to be huge. But if you have a lot of dependencies, and do many deployments, then it all adds up. And if you share the base-image across multiple applications running on the same machine, then the benefit is obviously even bigger.

It can also be used to dynamically load functionality to any ASP.NET Core application using that base-image if you wanted to. I demo this in my “ASP.NET Core Beyond the Basics”-talk. Unfortunately, I haven’t managed to write a blog post on how that works yet. But until I do, you can always have a look at my talk on YouTube.

Thoughts? Questions? Comments? Ping me at @ZeroKoll!

zerokoll

Chris

Developer-Badass-as-a-Service at your service