.NET Deployment Models and Features With Examples
If you've ever wanted a better understanding of: Self-contained, trimming, single file, Native AOT, and ReadyToRun for .NET deployments, this post will take you through with worked examples.
The .NET documentation is great, however sometimes it's easier to grasp a situation with examples you can see. In this post we'll be looking at the .NET Application Publishing documentation in the context of a .NET console tool I wrote: GameboyColour-Decolouriser - it takes in one Gameboy Color image and turns it into the original 4-green-tone Gameboy palette.
Everything in this post is be annotated with direct links to the said documentation. Note: that this post will also skew toward optimising for file size, but I'll give you enough links and ideas to optimise for speed.
Key Terms
First let's get some vocabulary so we can fully understand what's coming up.
.NET CLI
It took me awhile to get used to it, but I love the new(ish) .NET CLI. And for this post we'll be specifically calling publish
a lot:
dotnet publish -c Release
With the deployment examples coming up, they will include both this CLI version and the .csproj
version. Either one will work.
Project file .csproj
The classic .csproj
project file. Depending on your Visual Studio version, you can right click the project and go Edit or open the .csproj
with another file editor. For this we'll just be looking at adding our flags to a PropertyGroup and in GameboyColor-Decolouriser, I'll just be adding our flags into the existing PropertyGroup, which for the project (by default) looks like this:
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
With the deployment examples coming up, they will include both this .csproj
version and the CLI version. Still, either one will work.
Framework dependent executable
Framework dependent executables gives us an executable that uses the .NET installed on the client's machine.
RID
Runtime Identifier, used to identify the target platform where the application will run when we do the publish
command. We'll be using these when we specifically want to do builds for Windows, OSX, or Linux.
For these examples, we will be mostly going with win-x64
for a 64bit Windows system.
GameboyColour-Decolouriser
A simple tool that converts Gameboy Color images to old green-y Gameboy images. You can find my repo here: GameboyColour-Decolouriser, specifically at this check in as a base. Adding some colour, check out below the four stages it does to decolourise:
Why I'll be using this as an example:
- It targets .NET 7 and I will be using Preview 5
- Some of the items below will only work with console projects
- It uses SkiaSharp (an external library) to do some image processing
- Running
dotnet publish -c Release
produces the following output:
We'll be using this as our baseline. So let's get into the real content and look at deployments!
Self-Contained
You want self-contained when you want full control of what .NET version you use with your application and what better way than to bring all of .NET with you. Of course this comes with downsides such as:
- Your application needs to be correctly built for your client's system
- It doesn't come with security patches
- BIG as it contains what it needs to run .NET itself
Command
If you prefer CLI over .csproj
:
dotnet publish -c Release -r win-x64 --self-contained true
Technically if we put forward a runtime, -r
, the .NET CLI with automatically create the application as self-contained, but it's nice for the clarity (and I swear I read somewhere that it's better to be explicit in case of breaking changes in the future, but I can't find that documentation for the life of me).
.csproj
If you prefer .csproj
over CLI, add these to your <PropertyGroup>
:
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>
Then, to publish:
dotnet publish -c Release
Result
40.8MB -> 78.5MB
We end up with a big publish folder as expected because we're dragging along all the .NET we need to run this on a 64 bit Windows machine.
What if we could reduce the size we have to deploy? Trimming is our friend.
Trimming
Only available for self-contained apps.
Trimming is wonderful. Not using that function? Throw it away at compile time. Trimming is all about removing excess code in order to produce a smaller file. Sure, there are a few things trimming can't handle like COM marshalling and dynamic assembly loading but outside of that, it puts in work. You might also have to take into account and think about the libraries and assemblies you depend on as they might not be trim friendly.
You can somewhat influence the trimming with some given options such as removing unused members or being more cautious and removing only completely unused assemblies.
Command
dotnet publish -c Release -r win-x64 --self-contained true -p:PublishTrimmed=true
The command looks a little different with a -p:
as that indicates an MS Build property.
.csproj
Note that since self-contained is a requirement for trimming, those elements are included as well.
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>
<PublishTrimmed>true</PublishTrimmed>
Then, to publish:
dotnet publish -c Release
Result
78.5MB -> 29.5MB
Considerably smaller!
Single File
I feel turning your application into a single file .exe
is when it starts to come together. If you want to read more about the fascinating design choices from the .NET team, check out this design doc.
For single file applications we can choose either:
- The framework-dependent deployment model (user needs .NET installed on their machine)
- Self-contained applications (.NET is baked into your
.exe
) + since it's self-contained, you can trim!
And we'll look at these in turn with some fun extra flags to see what they do:
IncludeNativeLibrariesForSelfExtract
Includes native libraries in the single file bundle. If required, these files are extracted to a directory in the client machine when the single file application is run. We'll be using in these examples.IncludeAllContentForSelfExtract
Extracts all files, including the managed assemblies, before running the executable. Makes the single file work like it originally did in .NET Core 3.1 - i.e. "This option provides backward compatibility with the .NET Core 3.x version of single-file apps.". We won't be using this one, but it's good to know as you might see it around.DebugType
We'll use this flag to determine what happens to our.pdb
file. We have some options like:none
,portable
, orembedded
.EnableCompressionInSingleFile
The single file that's produced will have all of the embedded assemblies compressed, which can significantly reduce the size of the executable but on application start, the assemblies take extra time to be decompressed into memory.
Framework-dependent deployment
Remember, they'll need .NET installed on their machine.
For the .NET CLI:
dotnet publish -c Release -r win-x64 --self-contained false -p:PublishSingleFile=true
Or for .csproj
:
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>false</SelfContained>
<PublishSingleFile>true</PublishSingleFile>
Note this is our first usage of self-contained
= false
.
29.5MB -> 10.7MB
Hold on, this is more than a single file. What's going on?
- The
.pdb
file contains useful debugging information like mapping calls to source code. You may want it to aid debugging of the release code. Or maybe you're doing a little side project and don't want them at all. For the sake of our example, I'm going to have them asembedded
. - The
libSkiaSharp.dll
is native code, which by default isn't bundled, meaning if we do want to bring it in, we need to useIncludeNativeLibrariesForSelfExtract
.
Putting both together, we get this command:
dotnet publish -c Release -r win-x64 --self-contained false -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:DebugType=embedded
Or:
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>false</SelfContained>
<PublishSingleFile>true</PublishSingleFile>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<DebugType>embedded</DebugType>
29.5MB -> 10.7MB
Woohoo! Truly single file.
Self-contained
Let's do another version and bring .NET with us.
dotnet publish -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true
Or
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>
<PublishSingleFile>true</PublishSingleFile>
Pretty much as we'd expect. A big .exe
and the same two files as previously.
29.5MB -> 73.6MB
Let's use the same flags we did before to create a true single file.
dotnet publish -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:DebugType=embedded
Or
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>
<PublishSingleFile>true</PublishSingleFile>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<DebugType>embedded</DebugType>
29.5MB -> 73.6MB
And there we go, our self-contained version is a single file now too!
Trimming self-contained single file apps
Hinted at before, we can use trimming with our self-contained single file apps. Let's see what the difference is.
dotnet publish -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:DebugType=embedded -p:PublishTrimmed=true
Or:
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>
<PublishSingleFile>true</PublishSingleFile>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<DebugType>embedded</DebugType>
<PublishTrimmed>true</PublishTrimmed>
76.3MB -> 24.4MB
Compressing self-contained single file apps
Mentioned earlier, we can compress our single file apps at the cost of a slower start up while decompressing occurs.
dotnet publish -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:DebugType=embedded -p:PublishTrimmed=true -p:EnableCompressionInSingleFile=true
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>
<PublishSingleFile>true</PublishSingleFile>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<DebugType>embedded</DebugType>
<PublishTrimmed>true</PublishTrimmed>
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
24.4MB -> 15.3MB
Native AOT
While AOT has been around in .NET for awhile, it's been more of a niche topic or an experiment. However, .NET 7 Native AOT is hitting mainstream (at the time of writing this .NET 7 Preview 5 is out) and here's how to use it. Do note that at the time of writing this, it's still new and there's all sorts of libraries that aren't fully compatible with it yet.
But first, what does AOT do for us?
Native AOT apps start up very quickly and use less memory. Users of the application can run it on a machine that doesn't have the .NET runtime installed.
Just looking at vanilla AOT, without anything from earlier in this post:
dotnet publish -c Release -r win-x64 -p:PublishAot=true
Or
<PublishAot>true</PublishAot>
40.8MB -> 92.6MB
Sweet, we now have the benefits of AOT working for us like
- Faster startup times
- Less memory usage
- We can use this in non JIT environments
But what if we started mixing in some of the work we've seen previously?
We can make it better
So we have the speed and efficiency, but with a big file size. Time to optimise. We'll look at what goes into it, then talk about the documentation after.
dotnet publish -c Release -r win-x64 -p:PublishAot=true -p:TrimmerDefaultAction=link -p:InvariantGlobalization=true -p:IlcGenerateStackTraceData=false -p:IlcOptimizationPreference=Size -p:DebugType=none -p:IlcFoldIdenticalMethodBodies=true -p:IlcTrimMetadata=true
Or:
<!-- Normal .NET 7 -->
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<PublishAot>true</PublishAot>
<TrimmerDefaultAction>link</TrimmerDefaultAction>
<InvariantGlobalization>true</InvariantGlobalization>
<IlcGenerateStackTraceData>false</IlcGenerateStackTraceData>
<IlcOptimizationPreference>Size</IlcOptimizationPreference>
<DebugType>none</DebugType>
<!-- Still works from the experimental AOT version -->
<IlcFoldIdenticalMethodBodies>true</IlcFoldIdenticalMethodBodies>
<IlcTrimMetadata>true</IlcTrimMetadata>
<!-- Still works from the experimental AOT version, but high risk -->
<!-- <IlcDisableReflection>true</IlcDisableReflection> -->
92.6MB -> 14.5MB
Much smaller!
Now, for that documentation:
- The normal docs for optimising
- The experimental AOT docs for optimisations which seem to still work and have even more interesting flags such as the one used above,
IlcFoldIdenticalMethodBodies
. - The hammer, the docs for killing off reflection. While this can create dramatic size cutting, be careful on this as .NET relies a lot on reflection, and while other options for AOT might make things weird, this option might just kill things entirely.
You might notice SkiaSharp.dll
is still there. It can't even be bundled with IncludeNativeLibrariesForSelfExtract
set to true
. Why? My knowledge here gets a bit murky but it's to do with static native libraries (SkiaSharp is a wrapper around Skia). Here's some evidence on static libraries not bundling with NativeAOT:
- [NativeAot] Static libraries #798
- Comment on
IncludeNativeLibrariesInSingleFile
and NativeAOT - [Closed] Proposal: Add Static Library Support #68761
- Static library improvements
When we build the application, we can see all the different runtimes SkiaSharp handles:
With each folder containing the binary for that RID. For example, the osx
version:
So what if I used something else that didn't have static libraries? If I convert the code to use ImageSharp instead of SkiaSharp and run the same fancy AOT arguments, the output ends up being the single file .exe
we expect:
Neat stuff right?
.json
files, you can use-p:GenerateRuntimeConfigurationFiles=false
or <GenerateRuntimeConfigurationFiles>false</GenerateRuntimeConfigurationFiles>
ReadyToRun
As a bookend, we'll look at ReadyToRun. An almost-AOT deployment model as it combines AOT and JIT scenarios.
We'll only be quickly looking at this and I'll be using PublishReadyToRunComposite
rather than the default PublishReadyToRun
because more efficient is more fun. I'll also be using the ImageSharp version we just looked at for NativeAOT. AND going straight to the small file size version.
dotnet publish -c Release -r win-x64 -p:PublishReadyToRunComposite=true -p:SelfContained=true -p:PublishSingleFile=true -p:PublishTrimmed=true -p:DebugType=none -p:EnableCompressionInSingleFile=true -p:InvariantGlobalization=true
Or:
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<PublishReadyToRunComposite>true</PublishReadyToRunComposite>
<SelfContained>true</SelfContained>
<PublishSingleFile>true</PublishSingleFile>
<PublishTrimmed>true</PublishTrimmed>
<DebugType>none</DebugType>
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
<InvariantGlobalization>true</InvariantGlobalization>
40.8MB -> 12.7MB
To Conclude
I really like the direction that .NET is taking with deployment models. Like with a lot of things in .NET there are many ways to do a similar task. Having these deployment options (with so many arguments!) is fascinating as each one can dramatically impact the final publishing result and perhaps, creating a better publish for your needs.
I also super love the direction NativeAOT is going too! ⭐