ASP.NET Core has two different publishing modes, framework-dependent and self-contained. In this post I compare the impact of the publishing mode on Docker image size. I compare an image published using both approaches and take into account the size of cached layers to decide which approach is the best for Docker images.
I'm only comparing Docker image size in this post, as that's the main thing that changes between the modes. As self-contained mode uses app-trimming it's a little less "safe" as you may accidentally trim assemblies or methods you need. It also takes a little longer to build trimmed self-contained apps, as the SDK has to do the app trimming. For the purposes of this post I'm ignoring those differences.
ASP.NET Core apps can be published in one of two modes:
- Framework-dependent. In this mode, you need to have the .NET Core / .NET 5.0 runtime installed on the target machine.
- Self-contained. In this mode, your app is bundled with the .NET Core / .NET 5.0 runtime when it is published, so the target machine does not need the .NET Core runtime installed.
There are advantages and disadvantages to both approaches. For example
- Pro: you only need to distribute your app's dll files, as the runtime is already installed on the target machine.
- Pro: you can path the .NET runtime for all your apps, without having to re-compile.
- Con: you have less control at runtime - you don't know exactly which version of the .NET runtime will be used when your app runs (as it depends what's installed).
- Pro: You have complete control over the runtime used with your app, as you're distributing it with your app.
- Pro: No requirement for a shared runtime to be installed. This means you can, for example, deploy preview versions of a runtime to Azure App Service, before they're "officially" supported.
- Con: As you're distributing the runtime as well, the total size of your app will be much larger. This can be mitigated to some extent in .NET 5.0 by using trimming, but your app will still be much larger.
- Pro/Con: To patch the runtime, you need to recompile and redistribute your app. This is a con, in that it's an extra task to do, but it means that you have always tested your app with a known version of the runtime.
These are pretty standard trade-offs if you're deploying to a hosted service or to to a VM, but what about if you're building Docker images?
If you're building Docker images and publishing these as your "deployment artefact" then the pros and cons aren't as clear cut.
You only need to distribute your app's dll files, as the runtime is already installed on the target machineNo longer true, as with a Docker image you're essentially distributing the target machine as well! You don't know exactly which version of the .NET runtime will be used at runtimeNo longer true as you specify exactly which version of the runtime to use in your Docker image.
You have complete control over the runtime used with your app, as you're distributing it with your appTrue, but not really any different to framework-dependent now. No requirement for a shared runtime to be installedTrue, but again, also true when deploying as a framework dependent app in Docker. To patch the runtime, you need to recompile and redistribute your appTrue, but again, the same as framework-dependent apps now. As you're distributing the runtime as well, the total size of your app will be much larger.This is no longer true; in both cases we're distributing everything in the Docker image: the OS, the .NET runtime, and the app itself. However, with self-contained deployments we can trim the framework, so we'd expect the resulting Docker images to be smaller.
So, based on this, it seems like self-contained deployments for Docker images should be the best approach right? Both images should be functionally the same, and the self-contained deployment is smaller, which is desirable for Docker images, so that makes sense.
However, this ignores an important aspect of Docker images - layer caching. If two Docker images use the same "base" image, then Docker will naturally "de-dupe" the duplicate layers. These duplicate layers don't need to be transferred when pushing or pulling images from registries if the target already has the given layer.
If two framework dependent apps are deployed in Docker images, and both use the same framework version, then the effective image size is much smaller, due to the layer caching. In contrast, even though self-contained deployment images will be smaller over all, they'll never benefit from caching the .NET runtime in a layer, as it's part of your application deployment. That means you may well have to push more bytes around with self-contained deployments.
I was interested to put some numbers around this so I setup a little experiment.
I decided to do a quick test to put some numbers on the differences between the apps. I wanted to use a vaguely realistic application as a test, so I decided to use the IdentityServer 4 quick-start application as my test
I started by installing the IdentityServer templates using
dotnet new -i IdentityServer4.Templates
And then created my test app called
dotnet new is4ef -n IdentityServerTestApp -o .
The templates currently target .NET Core 3.1, so I quickly updated the target framework to .NET 5.0 for this test. Why not!
Finally I checked I could build using
dotnet build. Everything worked, so I moved on to the Docker side.
Given I want this to be a vaguely realistic test, I created two separate Dockerfiles that use multi-stage builds: one in which the app is deployed in self-contained mode, and one that uses framework-dependent mode.
You should always use multi-stage builds in production, to keep your final Docker images as small as possible. These let you use a different Docker image for the "build" part of your app than you use to deploy it.
The Dockerimage below shows the framework-dependent version. I've just done the whole restore/build/publish in one step here, as I'm only going to be building it once anyway. In practice, you should use an approach that caches intermediate build layers.
# The builder image FROM mcr.microsoft.com/dotnet/sdk:5.0.100-alpine3.12 AS builder WORKDIR /sln # Just copy everything COPY . . # Do the restore/publish/build in one step RUN dotnet publish -c Release -o /sln/artifacts # The deployment image FROM mcr.microsoft.com/dotnet/aspnet:5.0.100-alpine3.12 # Copy across the published app WORKDIR /app ENTRYPOINT ["dotnet", "IdentityServerTestApp.dll"] COPY ./sln/artifacts .
The self-contained deployment uses a very similar Dockerfile:
# The builder image FROM mcr.microsoft.com/dotnet/sdk:5.0.100-alpine3.12 AS builder WORKDIR /sln # Just copy everything COPY . . # Do the restore/publish/build in one step RUN dotnet publish -c Release -r linux-x64 -o /sln/artifacts -p:PublishTrimmed=True # The deployment image FROM mcr.microsoft.com/dotnet/runtime-deps:5.0.100-alpine3.12 # Copy across the published app WORKDIR /app ENTRYPOINT ["dotnet", "IdentityServerTestApp.dll"] COPY ./sln/artifacts .
There's only two differences here:
- We're publishing the app as self-contained by specifying a runtime identifier (
linux-x64). I've also set it to do conservative assembly-level trimming, new in .NET 5.0. You could update that to the more aggressive member-level trimming by adding
- Instead of using the
dotnet/aspnetimage, which has the ASP.NET Core runtime installed, we're using
dotnet/runtime-deps, which doesn't have a .NET runtime installed at all.
If we compare the base size of the runtime images, you can see that the
dotnet/aspnet image is about 10x larger, 103MB vs. 9.9MB:
> docker images | grep mcr.microsoft.com/dotnet mcr.microsoft.com/dotnet/sdk 5.0.100-alpine3.12 487MB mcr.microsoft.com/dotnet/aspnet 5.0.0-alpine3.12 103MB mcr.microsoft.com/dotnet/runtime-deps 5.0.0-alpine3.12 10MB
This is what we'd expect. The
dotnet/aspnet image includes everything in the
dotnet/runtime-deps, but then layers the .NET 5.0 runtime and ASP.NET Core framework libraries on top. That layer constitutes the additional 93MB of the image size.
Now lets see what the size difference is once we build and publish our apps.
We can build the images using the following two commands:
# Build the framework-dependent app docker build -f FrameworkDependent.Dockerfile -t identity-server-test-app:framework-dependent . # Build the self-contained app docker build -f SelfContained.Dockerfile -t identity-server-test-app:self-contained .
Let's compare the final Docker images after publishing our app:
> docker images | grep identity-server-test-app identity-server-test-app framework-dependent 131MB identity-server-test-app self-contained 65MB
The table below breaks these numbers down a bit further, to see where the size difference comes from.
I later run another test in self-contained mode with aggressive, member-level, trimming enabled. For the purposes of the table, I assumed that only members from framework dlls were trimmed. That's probably not entirely accurate, but I think is a reasonable assumption here.
|Base runtime-dependencies||Yes||9.9 MB||9.9MB||9.9MB|
|Shared framework layer||Yes||93 MB||-||-|
|Trimmed .NET runtime dlls||No||-||48MB||27 MB|
|Application dlls||No||28 MB||28 MB||28 MB|
|Total||131 MB||76 MB||65 MB|
|Total (excluding cached)||28 MB||66 MB||55 MB|
As you can see, the self-contained image is significantly smaller than the framework dependent image 76MB (or 65MB for member-trimming) vs. 131MB. However, if you just consider the "uncacheable" layers in the apps, and assume that the base dependencies and shared framework layers will already be present on target machines (a reasonable enough assumption in many cases), then the results swing the other way!
So which is the "right" option? From my point of view, if you're deploying Docker images to a Kubernetes cluster (for example), then the framework-dependent approach probably makes the most sense. You'll benefit from more layer-caching, so there should be fewer bytes to push to and from Docker registries. If you're unsure if the layers will be cached, if you're deploying to some sort of shared hosting for example, then the smaller self-contained deployments are probably the better option.
I'm only talking about the case where you're deploying in Docker here. If you're not, I think the self-contained deployment will often be the better approach for the added control and assurance it gives you about your runtime environment.
In this post I compared the size of an example application when deployed in a Docker image using two different modes. Using the framework-dependent publishing mode, the final runtime image was 131 MB, but 103 MB of that would likely already be cached on a target machine.
In contrast, the self-contained deployment model gave images that were 76 MB (or 65 MB with member-level trimming enabled)—significantly smaller. However, only 10MB of those images can be cached, giving a larger size to push and pull from repositories (66MB vs 28MB).
For that reason, I think if you're deploying Docker images to a Kubernetes cluster (for example), then the framework-dependent approach probably makes the most sense. You'll benefit from more layer-caching, so there should be fewer bytes to push to and from Docker registries. If you're unsure if the layers will be cached, if you're deploying to some sort of shared hosting for example, then the smaller self-contained deployments are probably the better option.