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 likedocker 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 yourdocker 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 templateAdd 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 upValidate 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
- 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 16, 2016
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/dockertoolstroubleshootingThere'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
- Anonymous
- 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
- 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
- Anonymous
- Anonymous
- 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
- 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
- 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
- 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
- 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 05, 2017
Hi Jeffrey,The very rudimentary timer is here: https://github.com/SteveLasker/DockerStartTimerIt's fraught with threading bugs that i haven't gone back and fixed.
- Anonymous
- 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.