In this series of articles, I will provide a detailed step-by-step guide to enable you to containerize your application and deploy it on-premise or on any cloud platforms that support containers.
I will be using an example project to help me illustrate my points and help you understand the process better.
Prerequisites:
- Some basic knowledge of docker fundamentals.
- A machine running docker
if you don't have docker installed on your machine, you can follow the steps mentioned here to get docker up and running on your machine.
You can also download the source code I will be working with from this repository so you can follow along with the tutorial.
Why would you want to containerize your application?
Let’s start first by explaining why you would want to containerize your application.
Of course, there are many benefits to containerizing your application but in my opinion, the following are the most common reasons:
- The first and most obvious benefit of containers is probably Portability. Being able to deploy your container image on any cloud platform or machine is super useful. Containers allow you to package your application binaries, dependencies, and configurations into one unit.
- Scalability is another big benefit of containers. As long as your container image is designed correctly, you should be able to scale your system and spin up new containers very easily.
- Being able to run your application or services inside a container provides a layer of Isolation that helps with decoupling services and isolates security vulnerabilities.
With that being said, this guide should help you containerize your application regardless of the goals you’re trying to achieve.
Let's start working!
In order to containerize an application, you will need to create a Dockerfile that contains the instructions that will allow Docker to build an image of your application, I will go step by step and explain the instructions used to build a container image.
In this example, I’m writing a dockerfile for a .NET application. However, the steps will be similar for other platforms and languages as well.
And so naturally the first step for us is to create a Dockerfile in our project. The full recommended dockerfile for an ASP .NET application (using .NET 8) which can be generated using Visual Studio or JetBrains Rider, looks something like this:
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
EXPOSE 8080
EXPOSE 8081
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["HrApp.csproj", "./"]
RUN dotnet restore "HrApp.csproj"
COPY . .
RUN dotnet build "HrApp.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "HrApp.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
RUN useradd -ms /bin/bash appuser
USER appuser
ENTRYPOINT ["dotnet", "HrApp.dll"]
I know this might look intimidating at first if you're not used to Dockerfiles, but don't worry we will go over the file line by line and explain what is it doing.
This particular dockerfile uses a multi-stage build, which will allow us to optimize the size of the final image by including only the necessary components in the final image.
So what do these lines mean?
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
The first line of the docker file references the base image that we will also be using as the final image for our application. This image contains the runtime binaries required for our ASP application to run.
This image is created and maintained by Microsoft, although you can technically create your own image from scratch and then download the .NET binaries into it, it's much easier to use the official image and not have to worry about setting up the runtime yourself.
EXPOSE 8080
EXPOSE 8081
These two lines state that the image will be exposing two ports 8080 and 8081. While the expose keyword doesn’t publish the port itself, it however serves as more of a documentation for the ports that the image users could connect to.
These ports of course could be different depending on your application framework so you can modify them to fit your use case.
In this specific example, the application will be using port 8080 for HTTP traffic and port 8081 for HTTPS traffic.
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
At this point, we define the second stage of our image which is the build stage. we will be using the SDK base image from Microsoft which contains the necessary binaries required to build a dotnet core app.
Also, it's important to note that at this line, our image becomes a multistage image, I will be using a directory structure going forward to explain what these two images look like and how are our command going to be affecting the images.
ARG BUILD_CONFIGURATION=Release
The ARG keyword allows us to define arguments that could later on be passed to the image during the image build process, this allows us to change the behavior of the image being built based on these arguments. In this particular case, we are defining an argument for the build configuration that will be used by dotnet and we are giving it a default value of Release, so in case we want to build a version of the image with Debugging enabled, then we can pass the argument to the docker build command with a Debug value instead of Release.
This feature could be used to pass any argument you want to the image instead of hardcoding it and having to create a whole new image with a different value.
WORKDIR /src
Since the latest stage we have defined is the build stage, this command will be executed on it, and what it does is that it informs Docker that all the subsequent commands will be executed within the /src directory of the image. you can think of it as creating a directory in the image and then executing "cd" into that directory.
COPY ["HrApp.csproj", "./"]
This will copy the ".csporj" file of the project to the build image. now you might be wondering why are we only copying the ".csproj" file and not the whole project. Well, the reason is that in the next step, we will be using this file to execute "dotnet restore" command which will download all the dependencies packages defined in our ".csproj" file, the main reason for doing this step separately before building the project is to take advantage of the Docker caching capabilities, to put simply, Docker will perform a "dotnet restore" and if the next time we build the image, if it doesn't detect any changes to the ".csproj" file, then it will just reuse the cached layer from the previous build and will skip downloading the packages again. this technique can be used with most other frameworks/languages that have package systems and it results in a big time savings when building the image again in the future.
RUN dotnet restore "HrApp.csproj"
This will execute the restore command on our project file and dotnet will take care of downloading all the packages referenced in the project file.
COPY . .
Now that we have downloaded all the packages, we will go ahead with building the application, of course, to do so, we need to copy the rest of the source code files and that's essentially what this command is doing, it's copying all the file from the current host directory (our project directory) into the build image
RUN dotnet build "HrApp.csproj" -c $BUILD_CONFIGURATION -o /app/build
This command will execute the dotnet build process on our project file, you can also notice that we're passing the "$BUILD_CONFIGURATION" argument here to the process and we are specifying the output directory of the build process to be "/app/build"
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "HrApp.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
Here we're defining yet another stage for publishing the application, the main reason that the build and publish phases are separated into two stages is to further take advantage of the Docker caching capabilities. Also in case we decide to target a different architecture or whether we want to have a self-contained deployment or a framework-dependent executable (UseAppHost=true)
A couple of things to note here, first we are specifying the output directory to be "/app/publish" and second is that we are specifying "UseAppHost=false" Since we are using an image that already contains the .NET runtime, we don't want our app to contain the runtime in its deployment as well which would be redundant and will take more space.
FROM base AS final
Now that the application is built and published, we can finally copy the application files from our build image into our base image.
This line will switch the context from the previous "build" image to the "base" image and so all the subsequent commands will be executed on the base image that we are now naming as final for better readability.
WORKDIR /app
COPY --from=publish /app/publish .
First, we are creating "/app" directory and setting it as the current working directory. Then after that, we copy the application files into the final image.
You can notice that here we are using a special "--from" parameter with the COPY command, allowing us to copy files between images/stages.
RUN useradd -ms /bin/bash appuser
USER appuser
Here we are creating and specifying the user that will be used to execute the subsequent RUN, ENTRYPOINT, and CMD commands in the dockerfile. This will help make sure that we are not running the application with a privileged user or root that could cause security vulnerabilities down the line.
ENTRYPOINT ["dotnet", "HrApp.dll"]
Finally, now that we have everything ready we can specify the entry point of our image, which will be executed whenever you run a new container using this image.
Since this example is using a dotnet application, we will be calling the dotnet CLI with the DLL of the application as an argument and that will take care of stating our application.
And that's it for creating the Dockerfile!
Let's test it out!
Now that we have the image ready, we can try building it and running a container to test it out.
To build the image, we need to execute the following command
docker build -t hrapp -f Dockerfile .
This will execute the docker build process and the "-t" flag will tag the resulting image with the tag "hrapp" that we can use to identify our image, also we are specifying the location of the docker file with the "-f" flag and lastly the "." specifies the build context which in our case is the project directory, and assuming you are executing the command from the project directory, our context should be the current directory with is "."
After the build is done, we should be able to spin up a new container using our newly created image
docker run -p 8080:8080 hrapp
This command should create a new container using our "hrapp" image and it will also bind the 8080 port of the container to our host machine
After the container starts running we can test that our application is running by calling
curl http://localhost:8080
That's it! you have successfully containerized your .NET app
What's next?
Well, the next step would be to deploy your containerized app on a hosting platform. In my future articles, I will be demonstrating how you can deploy your containers on cloud platforms such as Azure and AWS so make sure you follow me to get to the next step of the deployment process.
Top comments (1)
Thanks for this article Aghyad, it could be considered as a good reference