Building Efficient .NET Docker Images

If you have ever tried creating Docker image for your .NET app, you probably came across an official article from Microsoft on how Dockerize your .NET apps. In the article you can find the recommend Dockerfile which looks like this:

FROM mcr.microsoft.com/dotnet/core/sdk:3.0 AS build
WORKDIR /app

# copy csproj and restore as distinct layers
COPY *.sln .
COPY aspnetapp/*.csproj ./aspnetapp/
RUN dotnet restore

# copy everything else and build app
COPY aspnetapp/. ./aspnetapp/
WORKDIR /app/aspnetapp
RUN dotnet publish -c Release -o out


FROM mcr.microsoft.com/dotnet/core/aspnet:3.0 AS runtime
WORKDIR /app
COPY --from=build /app/aspnetapp/out ./
ENTRYPOINT ["dotnet", "aspnetapp.dll"]

This uses the official base .NET Core SDK image to compile and build your app from source code, and then copy the output into a fresh image based on runtime. Since each line in Dockerfile generates a distinct layer in your docker image, this allows excluding all the intermediate layers that went into building your source code. The base run image provides .NET Core Runtime + ASPNET core libraries needed to run your ASP.NET Core app. This means your final Docker image will be nice and slim when you push it up to your docker registry. Now this works great if your app ONLY uses the dependencies found in your base image. However, real-world applications rarely depend on only libraries found in ASP.NET Core BCL. You will often have an assortment of NuGet packages to enhance your codebase, and sometimes the newer version of BCL packages that are not included in the core runtime we’re targeting. These dependencies will be copied with your application output as part of the publish command. With many dependencies, your output folder may be upwards of 30MB+. The problem with this approach is that changing a single line of code in your project will only modify your project assembly, but since it’s lumped together with all the dependencies and copied as a single operation, it will cause another 30MB+ layer to be created when you rebuild your image, even though the actual file “delta” maybe less than 1MB.

Docker has a very efficient caching mechanism. Each layer has a checksum of all the files in it, and if try to build a new layer, Docker will calculate the checksum to see if there’s an existing layer that can be reused. This lets your new image to essentially reuse existing layers instead of building up new ones.

Since we know our application is probably going to change a lot more often, it only makes sense to isolate any dependencies that change rarely into their own layer to gain cache reuse. For that, we want to copy files in batches from our publish folder to generate distinct layers. But how can we efficiently isolate dependencies from our app code inside the publish folder? This is why I’ve created project NMica.

NMica installs a new set of MSBuild targets into your solution, allowing you to execute a PublishLayer target, which will split up output of the publish folder into 4 distinct subfolders:

  • package – any NuGet package references your app depends upon
  • earlypackage – NuGet packages that are considered pre-release. As these are more likely to be updated, this helps us isolate them into a separate layer
  • project – any projects that your main entry point project depends on
  • app – your main code and anything that doesn’t fit in the above category

After adding NMica as NuGet dependency to your projects, you can generate the above publish output with this command:
dotnet msbuild /t:PublishLayer /p:DockerLayer=All MyProject.csproj

Now we change our Dockerfile to look like this:

FROM mcr.microsoft.com/dotnet/core/sdk:3.1 AS build
WORKDIR src
COPY MultiProjectWebApp.sln .
COPY Backend/Backend.csproj Backend/Backend.csproj
COPY Common/Common.csproj Common/Common.csproj
RUN dotnet restore
COPY . .
RUN dotnet msbuild /p:RestorePackages=false /t:PublishLayer /p:PublishDir=/layer/ /p:DockerLayer=All Backend/Backend.csproj
FROM mcr.microsoft.com/dotnet/core/aspnet:3.1
WORKDIR /app
COPY --from=build /layer/package ./
COPY --from=build /layer/earlypackage ./
COPY --from=build /layer/project ./
COPY --from=build /layer/app ./
ENTRYPOINT ["dotnet", "Backend.dll"]

This will cause the publish output to be created in layers and we’ll get cache hits when rebuilding our docker image. Let’s look at a sample project. Suppose we have a project file like this:

<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>netcoreapp3.1</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Nmica" Version="1.0.0-local" />
    <PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="3.1.1" />
    <PackageReference Include="Serilog" Version="2.9.1-dev-01154" />
    <PackageReference Include="Steeltoe.CloudFoundry.Connector.EFCore" Version="2.4.2" />
    <PackageReference Include="Steeltoe.CloudFoundry.ConnectorCore" Version="2.4.1" />
    <PackageReference Include="Steeltoe.Extensions.Configuration.ConfigServerCore" Version="2.4.2" />
    <PackageReference Include="Steeltoe.Extensions.Logging.SerilogDynamicLogger" Version="2.4.2" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\Common\Common.csproj" />
  </ItemGroup>
</Project>

After editing a single line of code, I rebuilt the docker image. Notice how only last layer gets built – we got cache hits on package, earlypackage and project layers.

Step 11/18 : RUN dotnet msbuild /p:RestorePackages=false /t:PublishLayer /p:PublishDir=/layer/ /p:DockerLayer=All Backend/Backend.
 ---> Running in 5f53a5d16427                                                                                                     
Microsoft (R) Build Engine version 16.4.0+e901037fe for .NET Core                                                                 
Copyright (C) Microsoft Corporation. All rights reserved.                                                                         
                                                                                                                                  
  Common -> /src/Common/bin/Debug/netstandard2.0/Common.dll                                                                       
  Backend -> /src/Backend/bin/Debug/netcoreapp3.1/Backend.dll                                                                     
  Backend -> /layer/                                                                                                              
  /layer/project/Common.dll                                                                                                       
  /layer/app/appsettings.json                                                                                                     
  /layer/app/Backend                                                                                                              
  /layer/app/Backend.dll                                                                                                          
  /layer/app/appsettings.Development.json                                                                                         
  /layer/app/Backend.deps.json                                                                                                    
  /layer/app/Backend.runtimeconfig.json                                                                                           
  /layer/app/Common.pdb                                                                                                           
  /layer/app/web.config                                                                                                           
  /layer/app/Backend.pdb                                                                                                          
  /layer/earlypackage/Serilog.dll                                                                                                 
  /layer/package/Steeltoe.Extensions.Logging.DynamicLogger.dll                                                                    
  /layer/package/Steeltoe.Extensions.Configuration.CloudFoundryCore.dll                                                           
  /layer/package/Steeltoe.Extensions.Logging.SerilogDynamicLogger.dll                                                             
  /layer/package/Steeltoe.Extensions.Configuration.CloudFoundryBase.dll                                                           
  /layer/package/Steeltoe.Extensions.Configuration.ConfigServerCore.dll                                                           
  /layer/package/Pomelo.EntityFrameworkCore.MySql.dll                                                                             
  /layer/package/Microsoft.DotNet.PlatformAbstractions.dll                                                                        
  /layer/package/Steeltoe.Common.dll                                                                                              
  /layer/package/Microsoft.Extensions.DependencyModel.dll                                                                         
  /layer/package/Pomelo.JsonObject.dll                                                                                            
  /layer/package/Microsoft.EntityFrameworkCore.dll                                                                                
  /layer/package/Steeltoe.CloudFoundry.Connector.EFCore.dll                                                                       
  /layer/package/Steeltoe.CloudFoundry.ConnectorBase.dll                                                                          
  /layer/package/Steeltoe.CloudFoundry.ConnectorCore.dll                                                                          
  /layer/package/Newtonsoft.Json.dll                                                                                              
  /layer/package/Steeltoe.Extensions.Configuration.ConfigServerBase.dll                                                           
  /layer/package/Microsoft.EntityFrameworkCore.Abstractions.dll                                                                   
  /layer/package/Microsoft.EntityFrameworkCore.Relational.dll                                                                     
  /layer/package/Serilog.AspNetCore.dll                                                                                           
  /layer/package/Serilog.Extensions.Logging.dll                                                                                   
  /layer/package/Microsoft.Bcl.HashCode.dll                                                                                       
  /layer/package/MySqlConnector.dll                                                                                               
  /layer/package/Microsoft.Bcl.AsyncInterfaces.dll                                                                                
  /layer/package/Steeltoe.Extensions.Configuration.PlaceholderBase.dll                                                            
  /layer/package/Steeltoe.Common.Http.dll                                                                                         
  /layer/package/Superpower.dll                                                                                                   
  /layer/package/Serilog.Sinks.Console.dll                                                                                        
  /layer/package/Serilog.Filters.Expressions.dll                                                                                  
  /layer/package/Serilog.Settings.Configuration.dll                                                                               
Removing intermediate container 5f53a5d16427                                                                                      
 ---> b543f072562b                                                                                                                
Step 12/18 : FROM mcr.microsoft.com/dotnet/core/aspnet:3.1                                                                        
 ---> e28362768eed                                                                                                                
Step 13/18 : WORKDIR /app                                                                                                         
 ---> Using cache                                                                                                                 
 ---> 1ff03439049a                                                                                                                
Step 14/18 : COPY --from=build /layer/package ./                                                                                  
 ---> Using cache                                                                                                                 
 ---> 64632d7ac36b                                                                                                                
Step 15/18 : COPY --from=build /layer/earlypackage ./                                                                             
 ---> Using cache                                                                                                                 
 ---> f9259363c80a                                                                                                                
Step 16/18 : COPY --from=build /layer/project ./                                                                                  
 ---> Using cache                                                                                                                 
 ---> 8a4f37b3ef4b                                                                                                                
Step 17/18 : COPY --from=build /layer/app ./                                                                                      
 ---> 3653732acce6                                                                                                                
 Step 18/18 : ENTRYPOINT ["dotnet", "Backend.dll"]
 ---> Running in 333ebca8c68b
Removing intermediate container 333ebca8c68b
 ---> 05be7c3328a8
Successfully built 05be7c3328a8

This is awesome you say, but wait… there’s more!

If you’ve instrumented NMica in your projects, building at the solution level will generate Dockerfile. The generated Dockerfile automatically picks the appropriate base images and versions based on the intended target and SDK declared in your project file.

NMica on GitHub

Leave a comment

Your email address will not be published. Required fields are marked *