Containerize and host .NET 8 application in Kubernetes

Containerize and host .NET 8 application in Kubernetes

This article is a part of the series "Use Kubernetes to host .NET 8 applications":

  • Containerize and Host .NET 8 applications in Kubernetes (this article)

  • Scaling .NET 8 Applications in Kubernetes

  • Hosting .NET 8 Applications in Azure Kubernetes Service (AKS)

Have you ever felt the struggle of managing the ever-growing complexity of your applications? As our projects become increasingly interconnected, deploying and orchestrating them becomes a serious task.

That's where Kubernetes steps in. Think of it as your ultimate helper in the world of container orchestration. It takes care of deploying, scaling, and managing your applications.

As a .NET developer, I want to dive into using Kubernetes with .NET 8 in this series of articles, but in reality, it can be used with everything.

Test application

Let's get started with our test application (the source code). I want to make it simple but at the same time, it should be a good starting point to learn how to work with Kubernetes. For that purpose, I have created a console application that represents a simple background process. The source code of it below.

Console.WriteLine("Hello, Kube!");
Console.WriteLine("Starting background process");
Console.WriteLine("Looking for a configuration in local file");

var stepToRun = 0;

const string configFileName = "step.config";

if(File.Exists(configFileName))
{
    stepToRun = int.Parse(File.ReadAllText(configFileName));
    Console.WriteLine($"{stepToRun} steps were completed. Continue process.");
    stepToRun++;
}
else
{
    Console.WriteLine("Configuration does not exists. Starting from 0 step");
}

while(true)
{
    Console.WriteLine($"Processing step: {stepToRun}");

    // Do some work
    Thread.Sleep(5000);
    File.WriteAllText(configFileName, stepToRun.ToString());

    stepToRun++;
}

In a nutshell, this background process simulates processing and saves the progress in the local configuration file. If you run it for the first time, it starts processing from step 0.

Hello, Kube!
Starting background process
Looking for a configuration in local file
Configuration does not exists. Starting from 0 step
Processing step: 0
Processing step: 1
Processing step: 2
Processing step: 3

Rerunning the application will give a slightly different result.

Hello, Kube!
Starting background process
Looking for a configuration in local file
4 steps were completed. Continue process.
Processing step: 5
Processing step: 6
Processing step: 7

I know this is a somewhat silly application, but it will help us learn some important concepts in the future.

Containerization of test application

We need to create a container image for our application. Docker is the de facto standard for working with containers, so I will use it for this purpose.

Please find below the content of the Dockerfile for the sample application.

FROM mcr.microsoft.com/dotnet/runtime:8.0 AS base
WORKDIR /app

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY *.csproj ./
RUN dotnet restore
COPY . .

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

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

This Dockerfile builds a multi-stage Docker image for a .NET 8 application, optimizing for a smaller final image size by separating the build and runtime dependencies.

It starts with the official runtime image for .NET 8.0 mcr.microsoft.com/dotnet/runtime:8.0.

We use the SDK image mcr.microsoft.com/dotnet/sdk:8.0 to restore Nuget packages and build and publish the application in the release configuration.

In the final stage, we only need to copy the published files of the application from the previous stage and set the entry point.

To build the image from our Dockerfile we need to use the following command.

docker build -t hello-kube:1.0 .

After completing the Docker build command, you should have the image hello-kube on your machine. It's time to move on to the Kubernetes part.

Kubernetes in a few words

Before installing Kubernetes let's stop for a minute and talk about how it works with containers.

Kubernetes contains many different components, each with its own responsibility. For running our background process, we need to use the following:

  • Pod

  • Nodes (Worker and Control Plane)

  • Cluster

In reality, there are many more of them, but this article does not intend to replace the official documentation or grow to the size of a book. Thus, I will briefly cover only the most important parts necessary for understanding the content of the article.

A Pod is a place where your container or containers are running. You can think of it as an abstraction that helps Kubernetes to work with different types of applications. A Pod can run not only containers, but also virtual machines, WASM applications, and serverless functions. So, using such an abstraction allows Kubernetes not to care about the type of application it runs because it does not matter.

A worker node is a place where pods are run. It has a special runtime for that purpose. Besides that, the worker node load balances tasks and monitors pod status.

The Control Plane (in some resources you can see the name Master node as well) is a set of different services that rule worker nodes, and manage scaling, and deployment.

A cluster is a collection of nodes (machines) that work together to run containerized applications. It needs at least one control plane node for proper functioning.

This simple diagram shows how these components are tied together. As you can see, our cluster has 3 nodes: the control plane, worker node 1, and worker node 2. At the same time, worker nodes have running pods.

Create a cluster in the local environment

In this article, I'm going to use locally installed Kubernetes. Since I don't want to deal with a complex installation process, I will use Docker Desktop which is already installed on my machine.

  • Download and install the latest version of Docker Desktop (if you don't have it).

  • After installing Docker Desktop, open it.

  • Navigate to "Settings" by clicking on the Docker icon in the system tray (taskbar on Windows, menu bar on macOS).

  • Go to the "Kubernetes" tab and check the box next to "Enable Kubernetes."

  • Click the "Apply & Restart" button to save the changes and restart the Docker Desktop.

  • After Docker Desktop is restarted, open a terminal or a command prompt and enter the following command to check if Kubernetes is running.

kubectl version

You should have a response similar to this

Client Version: v1.29.1
Kustomize Version: v5.0.4-0.20230601165947-6ce0bf390ce3
Server Version: v1.28.2

By default, Docker Desktop creates a single node cluster with the name docker-desktop. It should be enough for our local development. Run the following command to verify that your cluster is running.

kubectl get nodes
NAME             STATUS   ROLES           AGE   VERSION
docker-desktop   Ready    control-plane   28d   v1.28.2

As you can see, I have a control-plane node with the name docker-desktop and it has the ready status, which means that we can start to work with our local cluster.

Running application container in Kubernetes

As we already know, we need the pod to run our container. We can create it in different ways: through CLI or using YAML configuration files. It is beneficial to use the second approach due to its readability, and you can store such files in the repository with the application code.

Create a configuration file with the name app-pod.yml to create a pod. The content of the file is below.

apiVersion: v1
kind: Pod
metadata:
  name: app-pod
spec:
  containers:
  - name: hello-kube
    image: hello-kube:1.0

Let's go through it.

apiVersion is the version of the Kubernetes API we would like to use.

kind indicates the type of Kubernetes resource we would like to provision.

metadata.name sets the name for our pod.

spec.containers describes which container to use.

To run a pod, we need to execute the following command.

kubectl apply -f app-pod.yml

The message pod/app-pod created should appear if the operation is successful.

Before going further, let's check which status our nodes have.

kubectl get pods

You can see the pod with the name app-pod in the list.

NAME      READY   STATUS    RESTARTS         AGE
app-pod   1/1     Running   10 (2m35s ago)   8d

It is time to check if our background process works. To do so, run the following command.

kubectl logs app-pod

On my machine, I have the following response.

Hello, Kube!
Starting background process
Looking for a configuration in local file
Configuration does not exists. Starting from 0 step
Processing step: 0
Processing step: 1
Processing step: 2
Processing step: 3
Processing step: 4
Processing step: 5

If you have the same, then congratulations, you have run your .NET 8 application in Kubernetes!

Let's pretend that we need to fix some important bug in our application or add a new functionality. We change the codebase and move the new version of the application into production.

I changed the first line in our application to the following to simulate some changes.

Console.WriteLine("Hello, Kube! v.1.1");

We need to build a new version.

docker build -t hello-kube:1.1 .

Don't forget to change the version of the image in app-pod.yml file from hello-kube:1.0 to hello-kube:1.1.

We need to redeploy our pod.

kubectl apply -f app-pod.yml

Checking the logs, we see.

Hello, Kube! v.1.1
Starting background process
Looking for a configuration in local file
Configuration does not exists. Starting from 0 step
Processing step: 0
Processing step: 1
Processing step: 2

The new version of our application runs (you can see it in the first line), but it looks like we started the process from the beginning. It does not sound correct because as you remember we save the progress in the configuration file.

We have such behavior because a pod is an unchanged object in its nature. So, when we update the pod, we don't perform an update but delete the current pod and create a new one. So, when we released the new version of our application, the old configuration file with the progress was deleted with the old pod.

Working with storages

Right now with our background process, we have an issue that the progress of work is not saved when our application is updated. So we need to find out the most appropriate way to save the progress. Due to we run it on one host, we can use hostPath volume for our purpose.

In simple words, hostPath mounts a file or a directory from the host filesystem to the pod. So that when we use this directory or file from our pod it is persisted on our host.

To add the support of a volume, we need to make changes to our app-pod.yml. Please review the new lines added.

apiVersion: v1
kind: Pod
metadata:
  name: app-pod
spec:
  containers:
  - name: hello-kube
    image: hello-kube:1.2
    volumeMounts:
    - mountPath: /configuration
      name: configuration-volume
  volumes:
  - name: configuration-volume
    hostPath:
      path: /run/desktop/mnt/host/hello-kube-vol
      type: DirectoryOrCreate

We start reviewing the changes in the file from the bottom. The new element spec.volumes describes which volumes can be used in the pod.

Our hostPath volume has the name configuration-volume with the path /run/desktop/mnt/host/hello-kube-vol.

After defining the volume, we need to mount this volume into the container. For that purpose, we use the element spec.containers.volumeMounts and as parameters, we specify the name of the volume (should match with the name in volumes.name) and the path within the container where the volume should be mounted. In this case, it's /configuration.

It is time to change our application code to store the configuration file in the folder /configuration.

Console.WriteLine("Hello, Kube! v.1.2");

const string configFileName = "/configuration/step.config";

// Others lines are unchanged

Don't forget to build a new container and deploy a new pod file.

Hello, Kube! v.1.2
Starting background process
Looking for a configuration in local file
Configuration does not exists. Starting from 0 step
Processing step: 0
Processing step: 1
Processing step: 2

As expected the application started with a 0 step.

I won't release the new version of the application to check if our configuration file is stored in a volume. For that purpose, I delete the pod and recreate it again.

kubectl delete pod app-pod
kubectl apply -f app-pod.yml
kubectl logs app-pod

The response that I have is the following

Hello, Kube! v.1.2
Starting background process
Looking for a configuration in local file
12 steps were completed. Continue process.
Processing step: 13

Our background process finally saves the progress. So, we can not be afraid to restart or recreate our pod.

Kubernetes has a big list of supported storages. I don't think you will need all of them for your applications, but checking which support is useful.

Summary

In this article, we explored using Kubernetes to host .NET 8 applications. We containerized a simple console application, introduced key Kubernetes components, and set up a local cluster using Docker Desktop. By deploying our application in a Kubernetes pod and addressing state persistence challenges with volumes, we gained practical insights into Kubernetes usage.

In the next article, we are going to learn how to deploy and scale applications.

The source code

Pods | Kubernetes

Volumes | Kubernetes

Docker Desktop

Image credits: Blue White Orange and Brown Container Van by Pixabay