Running ASP.NET Core in an Alpine Linux Docker Container – A True Micro Service (21MB)

Posted on: December 20th, 2017 by Dean North

What is Alpine Linux?

Alpine Linux is a security-oriented, lightweight Linux distribution based on musl libc and busybox.

This may sound familiar if you have been to the Alpine Linux Website, because it’s their tagline and it describes Alpine linux perfectly.

One of the other main attractions of Alpine is its size. The compressed Alpine container with all the dependencies to run a .NET Core application is only 6MB! That’s right SIX!

I’m sold, give me some Alpine

Last month the .NET Core team announced that the Alpine docker images are ready for testing. This does not mean that you should switch all of your containers over from Debian to Alpine and deploy to production right away. We are still a fair way away from that. But that’s where things are headed. One day (possibly next year) we will be able to deploy micro services as Alpine Docker containers to a Kubernetes cluster in Azure possibly even compiled as native applications using CoreRT.

Also last month, my favourite Scott wrote an article explaining how to get started with running a .NET Core console application on the Alpine docker images.

So this week I wanted to take that one step further and try running an ASP.NET Core application on Alpine and see how small the resulting container would be. As this requires using the nightly builds of .NET Core 2.1 there were a few hoops to jump through and a couple of snags that I ran into along the way. I decided to write this article to help others who want to get started with ASP.NET Core on Alpine today.

Preparing your Development Environment

The Alpine docker images use .NET Core 2.1, so we will need to install the nightly build of the runtime and SDK. If you haven’t used Docker before, you will need to install that too. Here is a small checklist of what you need to have installed…

  1. Docker for Windows
  2. .NET Core 2.1 SDK
  3. .NET Core 2.1 Runtime
  4. Visual Studio 2017 Preview 5 (Optional)

The SDK download page says that the SDK also includes the runtime, however I was getting errors trying to run the app until I installed the runtime separately, so I suggest you install both.

Creating a Hello World ASP.NET Core app

Start by creating a new ASP.NET Core Project.

New Project Dialog

Make sure you check the “Enable Docker Support” checkbox. This will create a dockerfile for you and will also create a docker-compose project in the solution.

Docker Support Option

Visual Studio will create you a project that looks something like this.

Solution Explorer

Let’s run the docker-compose project to make sure that everything is working normally before we switch over to Alpine and .NET Core 2.1. You should see something like this.

First Run

You will notice that the console reports that Kestrel is listening on port 80, yet chrome is browsing some other port (32790 in my case). This discrepancy is explained by how Docker works. When using Docker it’s best to imagine that your code is running in a virtual machine on its own isolated operating system (debian by default as we selected linux) inside that VM there is nothing but our application running, so port 80 is available for us to listen on. Because Docker knows what port our app is listening on, it is free to listen on another port that is available on our dev machine and then proxy requests from our dev machine on that port over to port 80 inside the Docker container.

How does Docker know that we will be listening on port 80? Take a look inside the docker-compose.override.xml file in the docker-compose project for the answer.

Docker Compose port

Switching to .NET Core 2.1

This is where things get interesting. We will be using the nightly build of .NET Core 2.1 but we won’t be using the nightly build of ASP.NET Core. I tried using the nightly for both and it led to all kinds of issues that prevented me getting anything running on Alpine. So for now, let’s use ASP.NET Core 2.0 on .NET Core 2.1.

First, you will need to edit your csproj file and set the target framework to 2.1 and add a RuntimeFrameworkVersion element with the nightly runtime version <RuntimeFrameworkVersion>2.1.0-preview1-25919-02</RuntimeFrameworkVersion>. While we are here, we will also add <OutputType>Exe</OutputType> as we will be building a self-contained app to reduce the docker image size as much as possible.

My complete project file looks like this…

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>netcoreapp2.1</TargetFramework>
    <RuntimeFrameworkVersion>2.1.0-preview1-25919-02</RuntimeFrameworkVersion>
    <DockerComposeProjectPath>..\docker-compose.dcproj</DockerComposeProjectPath>
  </PropertyGroup>

  <ItemGroup>
    <Folder Include="wwwroot\" />
  </ItemGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.All" Version="2.0.3" />
  </ItemGroup>

</Project>

Now we need to tell NuGet where it can find the nightly packages. We can do this globally in the settings in Visual Studio, but we can also set it per project by creating a NuGet.config file and putting it in the same folder as the project. Let’s do the latter and add this file to the project.

NuGet.config

<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <packageSources>
    <!--To inherit the global NuGet package sources remove the <clear/> line below -->
    <clear/>
    <add key="dotnet-core" value="https://dotnet.myget.org/F/dotnet-core/api/v3/index.json" />
    <add key="api.nuget.org" value="https://api.nuget.org/v3/index.json" />
  </packageSources>
</configuration>

Next, let’s try running the docker-compose project again. If you have edited your csproj file correctly, you should have broken the world.

Framework not found

The error here is that the debian docker image we are using doesn’t have the nightly version of the 2.1 runtime installed and can’t run the app. This is what we were expecting. If you set your startup project to AlpineHelloWorld and run again, then you won’t get this error and the app will start. This is because running this project will run it from your machine not inside a docker container, and you have the nightly runtime installed!

At this point, we have a hello world app running from our dev machine on ASP.NET Core 2.0 on .NET Core 2.1. Next step, getting it to run inside an Alpine docker container.

Switching to Alpine

The default dockerfile created from the ASP.NET Core template is pretty close to what we actually want. It uses one container to build the app, then a second container to run it. Here is the default file.

FROM microsoft/aspnetcore:2.0 AS base
WORKDIR /app
EXPOSE 80

FROM microsoft/aspnetcore-build:2.0 AS build
WORKDIR /src
COPY *.sln ./
COPY AlpineHelloWorld/AlpineHelloWorld.csproj AlpineHelloWorld/
RUN dotnet restore
COPY . .
WORKDIR /src/AlpineHelloWorld
RUN dotnet build -c Release -o /app

FROM build AS publish
RUN dotnet publish -c Release -o /app

FROM base AS final
WORKDIR /app
COPY --from=publish /app .
ENTRYPOINT ["dotnet", "AlpineHelloWorld.dll"]

We will need to make the following changes…

  1. Change the build image to microsoft/dotnet-nightly:2.1-sdk
  2. Change the base image to microsoft/dotnet-nightly:2.1-runtime-deps-alpine
  3. Copy over the NuGet.config file we created before we do dotnet restore COPY AlpineHelloWorld/NuGet.config AlpineHelloWorld/
  4. Tell dotnet build and publish that we are targeting alpine by adding -r alpine.3.6-x64
  5. Change the Entry Point to ENTRYPOINT [ "/app/AlpineHelloWorld" ] as we are compiling a self contained app
  6. You may not need to, but I had to move the first 3 lines to the bottom and replace FROM base AS final

This is my final dockerfile…

FROM microsoft/dotnet-nightly:2.1-sdk AS build
WORKDIR /src
COPY *.sln ./
COPY AlpineHelloWorld/AlpineHelloWorld.csproj AlpineHelloWorld/
COPY AlpineHelloWorld/NuGet.config AlpineHelloWorld/
RUN dotnet restore
COPY . .
WORKDIR /src/AlpineHelloWorld
RUN dotnet build -c Release -o /app -r alpine.3.6-x64

FROM build AS publish
RUN dotnet publish -c Release -o /app -r alpine.3.6-x64

FROM microsoft/dotnet-nightly:2.1-runtime-deps-alpine AS base
WORKDIR /app
COPY --from=publish /app .
ENTRYPOINT [ "/app/AlpineHelloWorld" ]

If you run the project inside docker now, you should get a successful build and chrome should start. This is where I got stuck for quite a while. You may get this error displayed in chrome…

ERR Empty Response

This may be a bug in .NET Core 2.1 in the nightly version I’m using, but if you do get this error, this change to the Program.cs fixed it for me.

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;

namespace AlpineHelloWorld
{
    public class Program
    {
        public static void Main(string[] args)
        {
            BuildWebHost(args).Run();
        }

        public static IWebHost BuildWebHost(string[] args) =>
            WebHost.CreateDefaultBuilder(args)
                .UseStartup<Startup>()
                .UseUrls("http://0.0.0.0:80") // <----- This will fix "Err Empty Response"
                .Build();
    }
}

If you got this far and followed along, you may think that you now have an ASP.NET Core website running inside an Alpine Linux docker container. But you would be wrong! When you run the docker-compose project, it appears as if it is just executing your dockerfile and then running the container it just built. But if you look at the dev image you will see that it’s almost 2GB in size. It would appear that docker-compose looks inside your dockerfile for the first image you are using, then uses that as a base image for it’s own build. This makes sense if you think about it, as it needs to mount the output folder from your machine inside the container and have a load more dependencies installed in order for debugging to work. The tiny 6MB alpine image wouldn’t have what was required to enable debugging on its own.

In order to get the container that is actually running the microsoft/dotnet-nightly:2.1-runtime-deps-alpine image, we need to switch to Release config and build the docker-compose project. This will build a second docker image and tag it as latest. If we open a powershell and run it, we can see the image and it’s size by running docker images.

REPOSITORY                   TAG                       IMAGE ID            CREATED             SIZE
alpinehelloworld             latest                    8590bc85144c        8 seconds ago       112MB
alpinehelloworld             dev                       fa2653ffc651        11 minutes ago      1.87GB

We are down to 112MB as an uncompressed image, not bad. Let’s try running the image to make sure it’s all working. To do this, type docker run alpinehelloworld:latest into powershell.

At the time of writing there is an issue with the alpine.3.6-x64 package. They are missing libuv.so. This means, at this point, you may get this error when you try to run your image.

crit: Microsoft.AspNetCore.Server.Kestrel[0]
      Unable to start Kestrel.
System.DllNotFoundException: Unable to load DLL 'libuv': The specified module or one of its dependencies could not be found.

Luckily for us, libuv.so is included in the linux-x64 package, so we can work around this for now by building the project for linux-x64 and copying the missing file over to our alpine.3.6-x64 build output directory.

With this work around in place, our dockerfile now looks like this…

FROM microsoft/dotnet-nightly:2.1-sdk AS build
WORKDIR /src
COPY *.sln ./
COPY AlpineHelloWorld/AlpineHelloWorld.csproj AlpineHelloWorld/
COPY AlpineHelloWorld/NuGet.config AlpineHelloWorld/
RUN dotnet restore
COPY . .
WORKDIR /src/AlpineHelloWorld
RUN dotnet build -c Release -o /app -r alpine.3.6-x64

FROM build AS publish
RUN dotnet publish -c  Release -o /app/linux -r linux-x64
RUN dotnet publish -c Release -o /app/alpine -r alpine.3.6-x64

FROM microsoft/dotnet-nightly:2.1-runtime-deps-alpine AS base
WORKDIR /app
COPY --from=publish /app/linux/libuv.so .
COPY --from=publish /app/alpine .
ENTRYPOINT [ "/app/AlpineHelloWorld" ]

Now, if we build our image again, then do a docker run, we should see this…

Hosting environment: Production
Content root path: /app
Now listening on: http://0.0.0.0:80
Application started. Press Ctrl+C to shut down.

Success! now we have a working ASP.NET Core site running inside our 112MB Alpine container.

There are 2 Alpine images listed in the announcement, which one do I want?

The 2.1-runtime-alpine image contains the .NET Core 2.1 runtime and is 82.5MB. The 2.1-runtime-deps-alpine is the one I said to use in this article because it contains just the dependencies required to run a .Net Core 2.1 application and is 6MB.

The reason I recommend using the deps one is because when we added -r alpine.3.6-x64 we told dotnet publish that we wanted to make a self-contained application. If you run the dotnet publish command yourself and look at what gets built, you will see this…

Output files

The contents of this folder excluding the AlpineHelloWorld bits is 94.9MB. I used the Microsoft.AspNetCore.All metapackage and at the time of writing, trimming wasn’t working, so this size can be reduced further by referencing just the AspNetCore packages you are actually using in your project.

How small can we get?

The first thing we can do to try and reduce the size of our container is to switch from the Microsoft.AspNetCore.All metapackage to using only the dependencies that we actually need. I have removed the metapackage and instead added Microsoft.AspNetCore. This takes our image down to 88MB.

REPOSITORY                   TAG                       IMAGE ID            CREATED             SIZE
alpinehelloworld             latest                    9ba1ce5e2424        5 seconds ago       88MB

The next thing we can do is to use the ILLinker tool to remove code that our application doesn’t use. To do this, all we need to do is add a package reference. Add this to your project file…

<ItemGroup>
    <PackageReference Include="ILLink.Tasks" Version="0.1.4-preview-906439" />
</ItemGroup>

This takes us down to 62.2MB

REPOSITORY                   TAG                       IMAGE ID            CREATED             SIZE
alpinehelloworld             latest                    9ec09d92b9aa        5 seconds ago       62.2MB

Surely we can’t get any smaller right? Well oddly enough, with this one easy trick a single mother near you discovered, you can cut your image size by over 6MB!

When you created your project using the visual studio wizard, the project file was correctly created as a “Web” project

<Project Sdk="Microsoft.NET.Sdk.Web">  <-------- .Web

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>netcoreapp2.1</TargetFramework>
    <RuntimeFrameworkVersion>2.1.0-preview1-25919-02</RuntimeFrameworkVersion>
    <DockerComposeProjectPath>..\docker-compose.dcproj</DockerComposeProjectPath>
  </PropertyGroup>

...

</Project>

If we remove this so that it’s no longer a “Web” project and is instead just a Microsoft.NET.Sdk project, then our image size is now just 56.8MB!

REPOSITORY                   TAG                       IMAGE ID            CREATED             SIZE
alpinehelloworld             latest                    46a37a8ea087        31 seconds ago      56.8MB

If you tried this last trick, you will have noticed that you can no longer build your docker-compose project. I haven’t figured out why yet, but in the mean-time, you can build the docker image using the command line. In powershell navigate to your solution folder, then run this…

docker build . -f alpinehelloworld/dockerfile -t alpinehelloworld:latest

This is as small as we are going to try and go in this article, so the next thing to do is to publish our new image. This is done with the docker push command. Make sure you have created an account on cloud.docker.com .

Once published, the image will show it’s compressed size. This is the size that is sent over the wire when pulling the image, so is really the figure we care about if we are trying to get the image as small as possible. In our case, the final image size is just 21MB.

You can see the image we created in this article here https://hub.docker.com/r/atlascode/alpinehelloworld/tags/

Feel free to pull the image and see it running yourselves.

Summary

In my opinion, the guys over at the .NET Core and ASP.NET Core teams are doing fantastic work and the future of .NET is heading in a great direction for micro services. With more time, I’m sure Alpine will eventually take over as the default image for ASP.NET Core linux containers.

Special thanks to Jan Vorlicek for all of his work on Alpine support and for keeping us updated over on the GitHub Issue.

What a time to be C# developer!

Dean North

Dean North

Dean founded Bespoke Software Development Company - Atlas Computer Systems Limited and is our technical consultant (aka technical wizard).

Want to stay up to date with the latest software news, advice and technical tips?

;