Building Optimized Docker Images with ASP.NET Core

If you're exploring docker, you'll often see dockerfiles that demonstrate the simplicity of building a docker image by copying your source into a container and voila, you have a docker image with the environment packaged with your app.

 FROM microsoft/dotnet
WORKDIR /app
ENV ASPNETCORE_URLS http://+:80
EXPOSE 80
COPY . .
RUN dotnet restore
ENTRYPOINT ["dotnet", "run"]

While very true, and very cool, there are big optimizations to be had.

Dynamic Compilation

.NET has a long history of productivity. When working with server based deployments, customers wanted fast ways to deploy updates. If all they changed was an image, .js, .html, .cshtml, .cs or web.config file, would you think about rebuilding the server? Re-publishing the entire app, bundled up? Or, would you run a routine that simply copied the delta, hopefully remembering to remove the older, no longer needed files?

.NET would handle dynamic compilation of the .cshtml and .cs files, and provided means to reset IIS for the web.config files. In a server environment, this was pretty cool. You're page might take a second or so to recompile the code, but that was more efficient than creating another site, copying the entire contents and switching dns over.

Containers are Immutable, Pre-Optimize

In the server/vm world, you might take a hit on the first page request, however the page and the code were compiled and cached within that request. Subsequent requests were fast, and if the server rebooted, you had pre-compiled pages waiting for fast responses.

In the container model, you're constantly starting containers. And we don't shut them down, per se, we kill them. The common model doesn't restart a sleeping container as they're disposable. The orchestrators simply instance new instances of a common image. What this means is we need to optimize, pre-compile the app when built. When the container is started, it's already to run.

We've been doing a lot of great work to make .NET Core and ASP.NET Core a container optimized framework. We've focused on startup performance and produced some optimized images:

In the last few weeks we've released two images to help in this journey, and docker tools to leverage them:

microsoft/aspnetcore-build used to compile and build asp.net core apps. The output would be placed in a microsoft/aspnetcore image which is an optimized runtime image.

The aspnetcore-build image contains everything you need to compile an ASP.NET Core app including:

  • .NET Core
  • ASP.NET SDK
  • NPM
  • Bower
  • Gulp

While we need these dependencies at build time, we don't want to carry these with our app at runtime as it would just make the image unnecessarily bigger.

Using the aspnetcore-build and aspnetcore images

Lets start with a basic ASP.NET Core Web application. To save some time, clone the sample at: https://github.com/SteveLasker/BuildASPDotNetInAContainer which contains a web and unit test project.

You should be able to F5 and run your app in the Visual Studio environment. You can even "Publish" the app using the context menu. However, if you try to call dotnet publish form the command line, you'll likely get an error that bower isn't installed. It turns out bower is installed, but privately to the Visual Studio environment.

This is a great example of having, or not having all the dependencies you need. While you could install bower, how do you know all the developers on the team, and the build server are building your app the same way? How do you know that someone doesn't have a slightly different version of one of the dependencies to build your app?

We're going to use our aspnetcore-build image to be the common build environment for everyone, including our build server.

  • Open a power shell window in the root of your solution.
    I happen to use power shell because the syntax is a bit easier for commands like docker rm -f $(docker ps -a -q)
    Tip: Right-click on your solution and select Open Folder in File Explorer.
    Copy the path
    In the powerhsell window, type cd "[paste]"
    Or, better yet, use Mad Mads Open Command Line extension
  •  Run the aspnetcore-build image
    docker run -it --rm -v "$pwd\:/sln" microsoft/aspnetcore-build:1.0.1 -itmeans keep the container running in interactive mode
    --rm means remove the container when complete. This keeps your docker ps -aresults clean.
    -v means volume mount the present working directory (the solution directory) to a root folder in the Linux image named sln

You're now running an instance of the aspnetcore-build environment, with your source code volume mounted, or you might think of it as network shared, into the container.

Lets build the contents interactively to see how this would work

  • Switch into the solution directory
    cd sln/
  • Restore the packages. Remember, we only have our source here. The packages are unique to this image.
    dotnet restore
  • You can also run any unit tests you might have in your project
    dotnet test test/WebTests/project.json
  • Publish the app into a publish folder in the root of the solution
    dotnet publish src/Web/project.json -c releaes -o $(pwd)/publish/web
  • Explore the directory on your dev machine. Notice we now have a publish/web folder in the root of our solution. This contains everything we need place into our optimized image

Create a build.sh script

Now that we've proven we can compile, test and publish our app, we'll automate this a bit with a build script.

  • In the root of the solution, create a new file named batch.sh. We don't yet have a template in Visual Studio for bash scripts, so we have to do a few tricks.
    On Solution Items, select Add --> New Item
    Choose any text file template and name the file bash.sh
    Delete any previous contents from the template

  • Add the following, which you could copy/paste the commands from your powershell window to make sure you've got all the casing and paths correct:

     #!bin/bash
    set -e
    dotnet restore
    dotnet test test/WebTests/project.json
    rm -rf $(pwd)/publish/web
    dotnet publish src/Web/project.json -c release -o $(pwd)/publish/web
    

    Notice we clear out the publish/web directory to make sure we have a clean state each time

  • Important: 
    By default, all files created in Windows uses CRLF, which aren't supported in Linux. . To fix this, we'll need to tell VS to save the files with just LF
    Select File --> Advanced Save Options
    Change Line Endings to Unix (LF)

Compile and Publish the project with the build script

  • From the root of your solution, open your power shell prompt
  • Run the following docker command
    docker run -it --rm -v "$pwd\:/sln" microsoft/aspnetcore-build:1.0.1 sh ./build.sh
  • You'll get an error:
    sh: 0: Can't open ./build.sh
    This is because the build.sh file is in the /sln directory. We can't just call /sln/build.sh as all our commands are assuming a working directory at the root of our solution. No problem, docker has a solution for this  as well
  • Run the modified docker command, setting the working directory:
    docker run -it --rm -v "$pwd\:/sln" --workdir /sln microsoft/aspnetcore-build:1.0.1 sh ./build.sh

Voila, we now have our app compiled, tested published, using a consistent environment across the entire team

Using Docker-Compose to encapsulate our docker run parameters

Entering the docker run parameters each time can be quite tedious. You could capture yet another script to call the commands that call the build script in the container. Or, we can leverage docker-compose to encapsulate our comamnds

  • In the root of the solution, add docker-compose-build.yml

  • Enter the following content:

     version: '2'
    services:
      tradapp-build:
        image: microsoft/aspnetcore-build:1.0.1
        volumes:
          - .:/sln
        working_dir: /sln
        entrypoint: ["sh", "./build.sh"]
    
  • You can now use the following command to simplify the entire build
    docker-compose -f docker-compose-build.yml up

Building the optimized image

Now that we have our published content, we can place it in an optimized image

  • In the Web app, add a dockerfile
    Note, you'll need to use a text file, rename it to dockerfile.
    If you have the Visual Studio Docker Tools installed you'll get some language service help, but you'll need to close and reopen the file to see it as VS still thinks it's a text file.

  • Add the following content to the dockerfile:

     FROM microsoft/aspnetcore:1.0.1
    WORKDIR /app
    COPY . .
    EXPOSE 80
    ENTRYPOINT ["dotnet", "Web.dll"]
    

    Note, if you have a different project name, Web.dll must match the folder name of your project. Just look in the publish/web folder to confirm the name of the dll

  • Add the dockerfile to the published output.
    Edit the project.json file in the Web project and add the dockerfile to the publishOptions section

     "publishOptions": {
       "include": [
         "dockerfile",
    
  • Run our build again, to validate the dockerfile gets pushed to the publish/web folder
    docker-compose -f docker-compose-build.yml up

  • Validate the dockerfile was placed in the publish/web folder

  • Build your optimized docker image
    docker build publish/web -t web:optimized

  • Run the image
    docker run -it --rm -P 8080:80 web:optimized

  • Browse to http://localhost:8080
    Note: although kestrel is listening to port 80, we've told docker to nat the containers port to 8080 on the host.

  • Press CTRL + C to stop the running container

Comparing Optimized Images

To compare the initial image that copies the source into a container and simply calls dotnet run isn't really a fair comparison, but we've seen this a lot as it just looks so easy and it's not immediately obvious why it matters.

First, lets build an image using the dockerfile at the beginning

  • Copy/Paste the dockerfile in the web project

  • Name it dockerfile.single

  • Replace the contents with:

     FROM microsoft/dotnet
    WORKDIR /app
    ENV ASPNETCORE_URLS http://+:80
    EXPOSE 80
    COPY . .
    RUN dotnet restore
    ENTRYPOINT ["dotnet", "run"]
    
  • Change to the root of the project directory, where the dockerfile.single is added

  • Build the image
    docker build . -f dockerfile.single -t web:single

  • Run the image
    docker run -d -p 8080:80 web:single

First, lets do the most obvious, check the image size:

docker images

 IMAGE ID            REPOSITORY                   TAG                 SIZE
0ec4274c5571        web                          optimized           276.2 MB
f9f196304c95        web                          single              583.8 MB
f450043e0a44        microsoft/aspnetcore         1.0.1               266.7 MB
706045865622        microsoft/aspnetcore-build   1.0.1               896.6 MB

Notice the web:optimized image is <10mb larger than our aspnetcore image, providing a small image to travel across the network and a fast, optimized image for serving requests

Now, you might argue, size doesn't matter. This is easier to build. We can talk about network-close, and the desire to have your images small and close to your deployments to reduce latency and ingress/egress costs.

But, lets talk about startup time. If we measure the amount of time docker run takes to return, then the amount of time the container takes to start serving requests, we can see some interesting numbers. And, the impact of dynamic compilation.

Image Size Container Start Responds to Requests
Restore/Run in a single container 583.8 0.530ms 14.600ms
Compile and Build in separate containers 276.2 0.540ms 3.768ms

While there are many ways to optimize the single build solution such as using dotnet publish, removing content, the reality is you're attempting to cleanup an image that was loaded with stuff we're trying to avoid. So, while it is a few extra steps, hopefully this article helps show that with a few scripts and docker-compose, we can automate this without having to cleanup a dirty image.

Thanks and please let us know what else you'd like to see in our tools, docs and runtime
Steve

The Microsoft Ignite 2016 talk is now available here.

And a special thanks to Glenn Condron for working through the various build options we considered along the way.