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 https://+: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 -it means keep the container running in interactive mode
    --rm means remove the container when complete. This keeps your docker ps -a results 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 https://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 https://+: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.

Comments

  • Anonymous
    September 29, 2016
    Nice post! :)
  • Anonymous
    October 05, 2016
    my be I need to dig a bit further, but I don't get it, where the magic happens, like pre-jitting?!
  • Anonymous
    October 05, 2016
    very nice! my be I need to dig a bit further, but I don't get it, where the magic happens, like pre-jitting?!
    • Anonymous
      October 05, 2016
      The asp.net core nugets are pre-jitted in the aspnetcore and aspnetcore-build images. This reduces startup time by 30%. We haven't yet provided a model for pre-jitting your code, however the size of the code that's likely placed in a container is relatively small comparatively. We plan to provide pre-jitting build solutions in the future. Steve
  • Anonymous
    October 10, 2016
    Hi there, nice article!I would have a little question for you...When I clone the repo and run the following command: docker run -it --rm -v "$pwd:/sln" microsoft/aspnetcore-build:1.0.1, there is no error but when I cd in the sln directory, I only get some empty folders, hence I can't go ahead and proceed with "dotnet restore", would you have an idea why?Thanks in advance!
  • Anonymous
    October 14, 2016
    Nice post! What would be fantastic is to bundle all of this with dotnet core cli, and maybe cakebuild so that we could just run dotnet publish to create our optimized image!
    • Anonymous
      October 16, 2016
      Thanks Laurent,GlennC is working on just that approach. He's looking into something like dotnet build docker... We should have more info coming as this develops further.
  • Anonymous
    October 15, 2016
    The comment has been removed
    • Anonymous
      October 16, 2016
      Those examples run in linux containers. Your error message suggests that your Docker instance is set to serve Windows containers. Try right-clicking on the Docker for Windows icon in the tray and choose "Switch to Linux containers..." option.
      • Anonymous
        October 17, 2016
        I've found this advice elsewhere; and I can't follow it because I don't have that icon on my system tray. I'm using Server 2016, not Windows 10, if that makes any difference - I believe it does, as Docker for Windows won't install on Windows Server, only Win10. How can you tell this is a linux container? I don't believe I can run Linux containers as I'm using an Azure VM and from what I can tell you can't run Hyper-V in Azure - so no Linux host available.
        • Anonymous
          October 17, 2016
          Hi Tom,I checked with Michael Friism @ docker, and I hadn't realized that Docker for Windows doesn't currently support Windows Server. It's something they plan for, but not yet available. We're also still in the early stages for nested virtualization in Azure. This is one of the more difficult things about using some of these new stacks, particularly those that use virtualization, as they're not easily used in a cloud environment, yet. My best, but acknowledged difficult suggestion is to get a Win 10 machine to get this going. I don't have a date on when nested virt or D4W will be supported in an Azure Server. Sorry I couldn't be more help,Steve
          • Anonymous
            October 19, 2016
            The comment has been removed
    • Anonymous
      October 16, 2016
      Hi Tom,As jsoltysiak suggests, this could be the result of switching to Windows Container support. The Bind mount spec error typically is the result of a failed volume mounting. Check the response above for troubleshooting volume mounting.
  • Anonymous
    October 17, 2016
    Nice post! I think file name supposed to be "build.sh" at "Create a build.sh script" section. Neither batch.sh nor bash.sh.
  • Anonymous
    October 31, 2016
    This solution work with AppVeyor build server ?
  • Anonymous
    November 18, 2016
    The comment has been removed
    • Anonymous
      December 04, 2016
      Axel, Can you confirm your username doesn't have spaces in it? We recently discovered this bug.Steve
    • Anonymous
      December 04, 2016
      Neoncyber,The approach is a general image building approach that would apply to any build system.Steve
  • Anonymous
    December 03, 2016
    Steve,Running the command docker run -it --rm -v "$pwd:/sln" --workdir /sln microsoft/aspnetcore-build:1.0.1 sh ./build.sh gives always the following errorsh: 0: Can't open ./build.shI tried .\build.sh got the same error.I ran the same command without the ./build.sh, once I was inside the container, ran SH build.sh, all steps were executed succesfuly (resore, test and publish).Any idea? Thanks
    • Anonymous
      December 03, 2016
      Please ignore this comment. The problem was that you indicated to save as bash.sh whereas in the command docker run -it –rm -v “$pwd:/sln” –workdir /sln microsoft/aspnetcore-build:1.0.1 sh ./build.sh it is build.sh and not bash.sh
  • Anonymous
    December 27, 2016
    When running the container from aspnetcore-build I get:C:\Program Files\Docker\Docker\Resources\bin\docker.exe: Error response from daemon: mkdir /C: file exists.
    • Anonymous
      April 05, 2017
      Hi Mikaka,That sounds like you're trying to run this on Windows. We haven't yet released the Windows Server Nano tooling, but that's coming soon. I'm preparing my DockerCon and Build demos and testing the latest .NET Core Nano tooling now.
  • Anonymous
    March 02, 2017
    Great post!How can I copy Dockerfile to publish folder using the new csproj format?I tried: But it doesn't work.
  • Anonymous
    April 05, 2017
    The bottom of your article shows timings for 'Container Start' and 'Responds to Requests'. What methodology / tools are you using to collect those timings?
  • Anonymous
    April 18, 2017
    Loved the article Steve, thanks. Looking forward to finding out more about Windows Nano Server soon! BTW there's a small typo in one of your command lines: "dotnet publish src/Web/project.json -c releaes -o $(pwd)/publish/web" should be "-c release"
  • Anonymous
    April 18, 2017
    Docker just announced a multi-stage builds. Using this post you can see the basics for how to build an optimized image with this new feature. I'll get an updated post/sample out soon.