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
    -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 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.

Comments (27)

  1. Cesar de la Torre says:

    Nice post! 🙂

  2. rs38 says:

    my be I need to dig a bit further, but I don't get it, where the magic happens, like pre-jitting?!

  3. rs38 says:

    very nice! my be I need to dig a bit further, but I don't get it, where the magic happens, like pre-jitting?!

    1. Steve Lasker says:

      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

  4. Os1r1s110 says:

    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!

    1. Steve Lasker says:

      Hi Os11r1s110,
      If you're getting empty folders, they Volume Mapping isn't configured properly. This is the most common problem with using Docker for Windows, and the VS Docker Tools do depend on it for fast iterative changes while developing and debugging.
      Take a look here: aka.ms/dockertoolstroubleshooting
      There's some additional info here: https://blogs.msdn.microsoft.com/stevelasker/2016/06/14/configuring-docker-for-windows-volumes/
      I apologize. We know the volume mapping is our Achilles heel. We're working with Docker to improve the experience.
      I'd also suggest using the Beta channel of Docker for Windows. https://docs.docker.com/docker-for-windows/
      Steve

  5. 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!

    1. Steve Lasker says:

      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.

  6. Tom Wolverson says:

    Running the command docker run -it --rm -v "$pwd\:/sln" microsoft/aspnetcore-build:1.0.1 in powershell I get:

    C:\Program Files\Docker\docker.exe: Error response from daemon: invalid bind mount spec

    I have copied and run the command exactly as it appears in your blog post. What am I doing wrong?

    1. jsoltysiak says:

      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.

      1. Tom Wolverson says:

        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.

        1. Steve Lasker says:

          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

          1. Tom Wolverson says:

            Hi Steve,

            Thanks very much for following this up, I was tearing my hair out trying to figure out why my particular setup wasn't working as described. I may have been a bit impolite with the initial query owing to my severe lack of patience after three days of banging my head against it! I'll just have to get hold of a physical Win10 box to proceed (my desktop has physical disk 'issues' on 10...)

    2. Steve Lasker says:

      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.

  7. neoncyber says:

    This solution work with AppVeyor build server ?

  8. Axel Aguilar says:

    I'm getting this error
    $ docker run -it --rm -v "$pwd\:/sln" microsoft/aspnetcore-build:1.0.1
    C:\Program Files\Docker Toolbox\docker.exe: Error response from daemon: create \: "\\" includes invalid characters for a local volume name, only "[a-zA-Z0-9][a-zA-Z0-9_.-]" are allowed.

    1. Steve Lasker says:

      Axel,
      Can you confirm your username doesn't have spaces in it? We recently discovered this bug.
      Steve

    2. Steve Lasker says:

      Neoncyber,
      The approach is a general image building approach that would apply to any build system.
      Steve

  9. Salam says:

    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 error
    sh: 0: Can't open ./build.sh
    I 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

    1. Salam says:

      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

  10. Mikaka says:

    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.

    1. Steve Lasker says:

      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.

  11. Radium says:

    Great post!
    How can I copy Dockerfile to publish folder using the new csproj format?
    I tried:

    But it doesn't work.

  12. Jeffrey LeCours says:

    The bottom of your article shows timings for 'Container Start' and 'Responds to Requests'. What methodology / tools are you using to collect those timings?

    1. Steve Lasker says:

      Hi Jeffrey,
      The very rudimentary timer is here: https://github.com/SteveLasker/DockerStartTimer
      It's fraught with threading bugs that i haven't gone back and fixed.

  13. Toby J says:

    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"

  14. Steve Lasker says:

    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.

Skip to main content