Docker — writing a smaller image with multi stage builds for. NET core.

Written by kritner | Published 2018/10/23
Tech Story Tags:

TLDRvia the TL;DR App

I’ve been using docker for playing around with my dinky website, but the DockerFile/image has always been a bit brute forcey. It’s time to explore a somewhat more effective DockerFile!

Docker Overview

Docker is a method of building applications/infrastructure/code within a container; a container being a self contained piece of software with all dependencies needed to run an application.

Though not directly related to a build server, they do have some overlap in some of the problems they try to solve. When utilizing either docker or a build server, your build process and its dependencies need to be codified… in code. The idea is that you’re writing “docker code” in order to describe the steps to build and deploy your app. This is very similar to using a build server in that you can be sure that any developer or server will be able to build or run your application code, without the hassle of installing all of your applications dependencies, as those dependencies are referenced within the docker “code” itself. (Note, you still need to have docker installed, and there are likely a few other caveats, especially when it comes to injecting variables into your docker containers.)

Current Image

The current image I’m using is quite small (code length wise), and due to that fact builds take longer than they should. This is due simply to the fact there are no real “checkpoints” in my build process. I’ll try to explain more about that while walking through my base image:

dnc2.1.401-v1-base

FROM microsoft/dotnet:2.1.401-sdk-stretchWORKDIR /app

# Perform updates, install gnupg and sudoRUN apt-get update \&& apt-get -y upgrade \&& apt-get -y dist-upgrade \&& apt-get install -y gnupg \&& apt-get install -y sudo

dnc2.1.401-v1-node

FROM kritner/builddotnetcore:dnc2.1.401-v1-baseWORKDIR /app

# Install nodeRUN curl -sL deb.nodesource.com/setup_10.x | sudo -E bash - \&& apt-get install -y nodejs

KritnerWebsite.DockerFile

FROM kritner/builddotnetcore:dnc2.1.401-v1-nodeWORKDIR /app

# Copy everything to prep for buildCOPY . ./

WORKDIR /app/src/KritnerWebsite.Web

# Publish codeRUN dotnet publish -c Release -o outCMD dotnet out/KritnerWebsite.Web.dll

Issues with Current Image

  • Not really using “multi stage builds” (multiple “from” statements). I’m using a few different images, but it’s all being rolled up into the final image. This means I’m running a final image with a whole lot more “stuff” than what should be needed.
  • Due to the way I’m building my final KritnerWebsite.Dockerfile based off of my other images, it’s not very flexible when it comes to upgrading which sdk I’m using. I currently need to update dnc2.1.401-v1-base, rebuild dnc2.1.401-v1-node, then rebuild my actual website image.
  • Though related to the previous two points, I thought it deserved its own: I’m currently installing a LOT on top of the dnc image — things like sudo, node, performing OS level updates. Working with “separate” images for building dotnet core code, and running dotnet core, would help avoid some of this.

Refactoring my DockerFile

A Better Image Template (Thanks GaProgMan)

GaProgMan has worked a bit with docker, and had a few tips for me with a multi-step build process he gave me a few months ago for reference (yes, I’m just getting to this now):

## Assuming we are building for 2.1.300## Change this value to match the version of the SDK## found in the global.jsonFROM microsoft/dotnet:2.1.300-sdk-alpine AS build

## Set the default build configuration to be Development## Override this by adding a --build-arg switch to the call## to docker build. i.e:## docker build . --file UI.dockerfile --tag projname-ui --build-arg target_configuration=Release## Will build this project in Release rather than DevelopmentARG target_configuration=Development

WORKDIR /build

# Copy all the sln and csproj files, then run a restore. The .NET Core SDK# doesn't need access to the source files (other than these) in order to# restore packages.# By doing this first, docker can cache the result of the restore. This is# great for build times, because restore actions can take a long time.

COPY ./src/proj.name/proj.name.csproj proj.name.csproj

# Do the above for all of your csprojs

RUN dotnet restore

# This copy relies on the .dockerignore file listing bin and obj directories.# If these aren't listed, then the generated project.assets.json files will# be overwritten in this copy action - this will lead to us needing to run# another restore.# This, and all other copy commands, will follow any guidance supplied in# our .dockerignore file. This file ensures that we only copy files from given# directories or of given file types - it is similar in structure and usage to the# .gitignore fileCOPY ./src/proj.name .COPY ./global.json ./global.jsonRUN dotnet build --configuration ${target_configuration} --no-restore

# FROM build AS publishRUN dotnet publish --configuration ${target_configuration} --output "../dist" --no-restore --no-build

# Install all of the npm packages as a cache-able layer. Similar to when we did# a dotnet restore, it will be skipped if npm packages never change.# The install step is performed in the internal-npm-image container, the steps# from which are run just-in-time in our down stream container (i.e this one)

WORKDIR /build

FROM internal-npm-image as webpack

COPY --from=build ./build/ClientApp ./ClientApp/COPY --from=build ./build/webpack-config ./webpack-config/COPY --from=build ./build/tsconfig.json ./build/tsconfig.aot.json ./build/package.json ./build/webpack.config.js ./

RUN npm run webpack-production

FROM microsoft/dotnet:2.1.0-aspnetcore-runtime-alpine as App

## Set the default runtime environment to be development.## This can be overridden by providing a value via the --build-arg switch.## For example:## docker build . --file UI.dockerfile --tag projname-ui --build-arg target_configuration=Release --build-arg target_env=Staging## Will build as release, but with the Staging environmentARG target_env=Development## We have to "recreate" it here, because an ARG only exists within the## context of a base image.## So the version of target_env at the top of this dockerfile only exists## within the "build" image and this one (which exists only wihtin the## "App" image), is completely different to the earlier one.

WORKDIR /App

COPY --from=build ./dist ./COPY --from=webpack ./tmp/wwwroot/ ./wwwroot/

ENV ASPNETCORE_URLS http://+:5001ENV ASPNETCORE_ENVIRONMENT="${target_env}"EXPOSE 5001

ENTRYPOINT ["dotnet", "projname-ui.dll"]

Adapting the template to my build

I don’t want to copy *exactly* off of GaProgMan’s sample, luckily he commented it very well, so I’d know what’s happening. The most important thing I’m shooting for is creating more layers. These layers are important for ensuring more things will be cached; so not rebuilt (necessarily) with every build of the DockerFile.

First things first — I know I can cut down on my image size by utilizing two separate base images throughout the docker file:

  • SDK — for building
  • Runtime — for running

Previously, I was using only the SDK, which blows up my final image size by quite a bit — my images’ current size is 2.23 GB as per docker images (yeesh!).

So for the two images — sdk and runtime:

FROM microsoft/dotnet:2.2-aspnetcore-runtime AS baseRUN apt-get update \&& apt-get -y upgrade \&& apt-get -y dist-upgrade \&& apt-get install -y gnupg \&& apt-get install -y sudo \&& curl -sL deb.nodesource.com/setup_10.x | sudo -E bash - \&& apt-get install -y nodejs

FROM microsoft/dotnet:2.2-sdk AS buildRUN apt-get update \&& apt-get -y upgrade \&& apt-get -y dist-upgrade \&& apt-get install -y gnupg \&& apt-get install -y sudo \&& curl -sL deb.nodesource.com/setup_10.x | sudo -E bash - \&& apt-get install -y nodejs

In the above we’re running a few commands on the base images for the purpose of installing nodejs — which we’ll need both for building and running the angular app; at least I’m pretty sure it’s needed for both right?

WORKDIR /src

COPY ["./src/KritnerWebsite.Web/KritnerWebsite.Web.csproj", "src/KritnerWebsite.Web/KritnerWebsite.Web.csproj"]

RUN dotnet restore "src/KritnerWebsite.Web/KritnerWebsite.Web.csproj"

Next, we’ll do the dotnet restore on the single copied project file — the reasoning behind this was pretty well explained in the above example, but I didn’t really realize it worked this way until seeing it in GaProMan’s comments. Basically, this restored “layer” can be cached, and never “rebuilt” unless something in the dependencies changes, saving on time when rebuilding our docker image!

COPY ["./src/KritnerWebsite.Web/ClientApp/package.json", "src/KritnerWebsite.Web/ClientApp/package.json"]

RUN cd src/KritnerWebsite.Web/ClientApp \&& npm install

Same idea in the above, but for npm packages instead of .net dependencies.

COPY ["src/KritnerWebsite.Web/", "src/KritnerWebsite.Web"]

WORKDIR /src/src/KritnerWebsite.Web

RUN dotnet build -c Release -o /app --no-restore

In the above, I’m copying the entirety of the buildable source directory, and performing a build with the .net CLI. Special note that the --no-restore option is being used as a restore operation was performed previously.

FROM build AS publish

RUN dotnet publish -c Release -o /app --no-restore --no-build

Here, in a similar idea to the build layer, we’re performing a publish; making sure not to restore or build as both have already been completed.

Finally:

FROM base AS finalWORKDIR /appCOPY --from=publish /app .ENTRYPOINT ["dotnet", "KritnerWebsite.Web.dll"]

In the above we’re copying our built application from the publish image, into a new “final” image that was based off of “base” (the run time).

The new DockerFile

The new DockerFile looks like this in its entirety:

# docker build -t kritner/kritnerwebsite .# docker run -d -p 5000:5000 kritner/kritnerwebsite# docker push kritner/kritnerwebsite

# Runner image - Runtime + node for ng serveFROM microsoft/dotnet:2.2-aspnetcore-runtime AS baseRUN apt-get update \&& apt-get -y upgrade \&& apt-get -y dist-upgrade \&& apt-get install -y gnupg \&& apt-get install -y sudo \&& curl -sL deb.nodesource.com/setup_10.x | sudo -E bash - \&& apt-get install -y nodejs

# Builder image - SDK + node for angular buildingFROM microsoft/dotnet:2.2-sdk AS buildRUN apt-get update \&& apt-get -y upgrade \&& apt-get -y dist-upgrade \&& apt-get install -y gnupg \&& apt-get install -y sudo \&& curl -sL deb.nodesource.com/setup_10.x | sudo -E bash - \&& apt-get install -y nodejs

WORKDIR /src

# Copy only the csproj file(s), as the restore operation can be cached,# only "doing the restore again" if dependencies change.COPY ["./src/KritnerWebsite.Web/KritnerWebsite.Web.csproj", "src/KritnerWebsite.Web/KritnerWebsite.Web.csproj"]

# Run the restore on the main csproj fileRUN dotnet restore "src/KritnerWebsite.Web/KritnerWebsite.Web.csproj"

# Contains the angular related dependencies, similar to csproj above result is cachable.COPY ["./src/KritnerWebsite.Web/ClientApp/package.json", "src/KritnerWebsite.Web/ClientApp/package.json"]

# Install the NPM packagesRUN cd src/KritnerWebsite.Web/ClientApp \&& npm install

# Copy the actual files that will need buildingCOPY ["src/KritnerWebsite.Web/", "src/KritnerWebsite.Web"]

WORKDIR /src/src/KritnerWebsite.Web

# Build the .net source, don't restore (as that is its own cachable layer)RUN dotnet build -c Release -o /app --no-restore

FROM build AS publish

# Perform a publish on the build code without rebuilding/restoring. Put it in /appRUN dotnet publish -c Release -o /app --no-restore --no-build

# The runnable image/codeFROM base AS finalWORKDIR /appCOPY --from=publish /app .ENTRYPOINT ["dotnet", "KritnerWebsite.Web.dll"]

Now that the image is built, I can run it like normal to test it out:

docker run -d -p 5000:5000 kritner/kritnerwebsite

Huh, it actually seems to have worked! :D

Now I can push the image up to dockerhub, and pull it down on my server.

docker push kritner/kritnerwebsite

Now, to see the difference in size between the previous image and the current, I run docker images and am presented with:

So we went from a chonky 2.23GB to a cool 417MB, nice!

Wrap Up

Thanks to GaProgMan for pointing me in the right direction for making my docker image more useful. Code for this post can be found:

Reworks `DockerFile` for better multi stage support by Kritner · Pull Request #27 ·…_Drops final image size from 2.23GB to 417MB :O Closes #9_github.com

Related:

How to setup your website for that sweet, sweet HTTPS with Docker, Nginx, and letsencrypt_I’ve used letsencrypt in the past for free certs. I have not successfully utilized it since moving over to…_medium.freecodecamp.org

And it’s like… what’s the deal with build servers?_The simplest way I can think of to explain a build server is to imagine hiring a brand new developer for each code…_medium.com

Reworks `DockerFile` for better multi stage support by Kritner · Pull Request #27 ·…_Drops final image size from 2.23GB to 417MB :O Closes #9_github.com

Docker_Learn more about the only enterprise-ready container platform that enables IT leaders to cost-effectively build and…_www.docker.com

Docker - Hacker Noon_Read writing about Docker in Hacker Noon. how hackers start their afternoons._hackernoon.com


Published by HackerNoon on 2018/10/23