HomeLab Kubernetes Deployment of .NET Core API

Minimum Automation of Kubernetes Deployment for .NET Core API using GitHub Actions

By Thomas

In previous posts, we explored setting up a HomeLab Kubernetes Cluster, connecting it to a Load Balancer, and deploying a simple Java Spring Boot API to the Cluster.

This time, we’ll delve into creating and automatically deploying a simple .NET Core API in the similar ways. We’ll also utilize GitHub Actions to automate the deployment process to Kubernetes.

The following diagram illustrates the connection between your home network and the outside world with a relatively secure approach:

  • By connecting your computers through an internet firewall instead of directly to your ISP’s modem (or router), you add a layer of security.

  • Each ISP’s router configuration and port forwarding settings are different, so you’ll need to access your router’s management UI and locate the Port Forwarding menu to configure it.

  • Following the firewall, the connection passes through a Proxy (in here Nginx Proxy Manager as covered previously) and then connects to the Load Balancer we set up in the previous article.

The Simplest gRPC API

Here’s the modified code from the default .NET Core gRPC Service project (with TLS certificate implementation, as gRPC requires HTTP2 by default):


  • Program.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
using System.Reflection;
using api.grpc.cavecafe.app;
using api.grpc.cavecafe.app.Services;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddGrpc( options =>
{
    options.EnableDetailedErrors = true;
});
builder.WebHost.ConfigureKestrel((context, options) =>
{
    var assembly = Assembly.GetExecutingAssembly();
    var assemblyDir = Path.GetDirectoryName(assembly.Location);
    Console.WriteLine($"Assembly Dir: {assemblyDir}");
    
    var kestrel = builder.Configuration.GetSection("Kestrel");
    ServiceHelper.ShowAppConfig(kestrel);
    options.Configure(kestrel);
});
var app = builder.Build();
if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error");
    app.UseHsts();
}
app.UseHttpsRedirection();
app.UseRouting();
app.MapGrpcService<GreeterService>();
app.MapGet("/",
    () =>
    "Communication with gRPC endpoints must be made through a gRPC client.\n" + 
    "To learn how to create a client, visit: https://go.microsoft.com/fwlink/?linkid=2086909"
);
app.Run();

  • ServiceHelper.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
using System.Collections;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;

namespace api.grpc.cavecafe.app;
public abstract class ServiceHelper
{
    private static X509Certificate2 CreateFromPublicPrivateKeyFiles(string fullchainPemFile, string privkeyPemFile)
    {
        byte[] publicPemBytes = File.ReadAllBytes(fullchainPemFile);
        using var publicX509 = new X509Certificate2(publicPemBytes);
        var privkeyPem = File.ReadAllText(privkeyPemFile);
        var privateKeyBlocks = privkeyPem.Split("-", StringSplitOptions.RemoveEmptyEntries);
        var privateKeyBytes = Convert.FromBase64String(privateKeyBlocks[1]);
        return GetRsaKeyPair(privateKeyBlocks, privateKeyBytes, publicX509);
    }
    private static X509Certificate2 GetRsaKeyPair(string[] privateKeyBlocks, byte[] privateKeyBytes,
        X509Certificate2 publicX509)
    {
        using RSA rsa = RSA.Create();
        if (privateKeyBlocks[0] == "BEGIN PRIVATE KEY")
        {
            rsa.ImportPkcs8PrivateKey(privateKeyBytes, out _);
        }
        else if (privateKeyBlocks[0] == "BEGIN RSA PRIVATE KEY")
        {
            rsa.ImportRSAPrivateKey(privateKeyBytes, out _);
        }
        else
        {
            throw new InvalidOperationException("Invalid private key format");
        }
        X509Certificate2 keyPair = publicX509.CopyWithPrivateKey(rsa);
        return keyPair;
    }
    private static X509Certificate2 CreateFromPublicPrivateKey(string fullchainBase64, string privkeyBase64)
    {
        byte[] publicPemBytes = Convert.FromBase64String(fullchainBase64);
        using var publicX509 = new X509Certificate2(publicPemBytes);
        var privkeyPem = Encoding.UTF8.GetString(Convert.FromBase64String(privkeyBase64));
        var privateKeyBlocks = privkeyPem.Split("-", StringSplitOptions.RemoveEmptyEntries);
        var privateKeyBytes = Convert.FromBase64String(privateKeyBlocks[1]);
        return GetRsaKeyPair(privateKeyBlocks, privateKeyBytes, publicX509);
    }
}

  • greet.proto
syntax = "proto3";

option csharp_namespace = "api.grpc.cavecafe.app";
package greet;
// The greeting service definition.
service Greeter {
  // Sends a greeting
  rpc SayHello (HelloRequest) returns (HelloReply);
}
// The request message containing the user's name.
message HelloRequest {
  string name = 1;
}
// The response message containing the greetings.
message HelloReply {
  string message = 1;
}

  • Dockerfile
# Get base image from MCR (Microsoft Container Registry)
FROM mcr.microsoft.com/dotnet/aspnet:8.0.4-jammy-amd64
ENV ASPNET_VERSION=8.0.4

# Received arguments when build the Docker image 
ARG PROJECT_OUTPUT
ARG PROJECT_CONFIG
ARG SERVICE_PORTS
# Set UID for the App
RUN echo "APP_UID: ${APP_UID}"
USER $APP_UID
# Set environment variables
ENV ASPNETCORE_ENVIRONMENT=${PROJECT_CONFIG}
ENV ASPNETCORE_HTTPS_PORTS=${SERVICE_PORTS}
EXPOSE ${SERVICE_PORTS}
COPY ${PROJECT_OUTPUT}/ app/
WORKDIR /app
ENTRYPOINT ["dotnet", "/app/dotnet-grpc.dll"]

  • manifest.yml (preparation for namespace, secrets, services, ingress before app build and deployment)
---
# namespace definition
apiVersion: v1
kind: Namespace
metadata:
  name: dotnet-grpc
---
# K8s secrets to be used for the container
apiVersion: v1
kind: Secret
metadata:
  name: dockerhub-credentials
  namespace: dotnet-grpc
type: kubernetes.io/dockerconfigjson
stringData:
  .dockerconfigjson: |-
    {
      "auths": {
        "https://index.docker.io": {
          "username": "***********",
          "password": "dckr_pat_************************",
          "email": "your_email@email.com"
        }
      }
    }
---
# K8s service definition (ports information to be set)
apiVersion: v1
kind: Service
metadata:
  name: dotnet-grpc-service
  namespace: dotnet-grpc
spec:
  selector:
    app: dotnet-grpc
  ports:
    - protocol: TCP
      port: 5500
      targetPort: 8080
      nodePort: 30093
  type: LoadBalancer
---
# Ingress 
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: dotnet-grpc-ingress
  namespace: dotnet-grpc
spec:
  ingressClassName: nginx
  rules:
    - host: 'dotnet-grpc.cavecafe.app'
      http:
        paths:
          - backend:
              service:
                name: dotnet-grpc-service-internal
                port:
                  number: 5500
            path: /
            pathType: Prefix

  • deployment.yml
# Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
  name: dotnet-grpc
  namespace: dotnet-grpc
  labels:
    app: dotnet-grpc
spec:
  replicas: 1
  selector:
    matchLabels:
      app: dotnet-grpc
  template:
    metadata:
      name: dotnet-grpc
      labels:
        app: dotnet-grpc
    spec:
      containers:
        - name: dotnet-grpc
          image: cavecafe/dotnet-grpc:latest
          imagePullPolicy: Always
          ports:
            - containerPort: 8080
          securityContext:
            runAsUser: 10002
            runAsGroup: 10001
          env:
            - name: ASPNETCORE_HTTPS_PORT
              value: "5500"
            - name: ASPNETCORE_HTTPS_PORTS
              value: "5500"
          resources:
            requests:
              memory: "32Mi"
              cpu: "60m"
      restartPolicy: Always

Finally, to start the GitHub Actions event, prepare the following GitHub Action YAML file.

  • .github/workflows/build-deploy.yml
name: Build and Deploy to K8s

on:
  workflow_dispatch:
  push:
    branches:
    # this action will be triggered when someone push changes into main branch 
      - main

env:
  REPO_PATH: $
  GIT_BRANCH: $

  PROJECT_NAME: dotnet-grpc
  PROJECT_ARCH: linux-x64
  PROJECT_FRAMEWORK: net8.0
  PROJECT_OUTPUT: publish
  PROJECT_CONFIG: DEV
  SERVICE_PORTS: "5500"

jobs:
  build-push-deploy:
    # choose the types of CI runner for this action
    runs-on: [self-hosted, linux]
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set environment variables
        run: |
          REPO_NAME=$(basename $)
          echo "REPO_NAME=$REPO_NAME" | tee -a $GITHUB_ENV
          GIT_COMMIT_ID=$(git rev-parse --short HEAD)
          echo "GIT_COMMIT_ID=$GIT_COMMIT_ID" | tee -a $GITHUB_ENV
          NAMESPACE=$(echo $ | sed 's/[ .]/-/g')
          echo "NAMESPACE=$NAMESPACE" | tee -a $GITHUB_ENV
          BUILD_VERSION=$-$(date +%Y%m%d)-$GIT_COMMIT_ID
          echo "BUILD_VERSION=$BUILD_VERSION" | tee -a $GITHUB_ENV

      - name: Verify environment variables
        run: |
          echo REPO_PATH="$"
          echo REPO_NAME="$"
          echo PROJECT_NAME="$"
          echo NAMESPACE="$"
          echo GIT_COMMIT_ID="$"
          echo BUILD_VERSION="$"
          echo PROJECT_OUTPUT="$"

      - name: Build Application
        run: |
          echo cd $
          cd $
          echo "dotnet restore $.csproj"
          dotnet restore "$.csproj"

          echo "dotnet publish ..."
          dotnet publish "$.csproj" \
               -c $ \
               -o $ \
               -r $ \
               -f $ \
               /p:UseAppHost=false

      - name: Login to DockerHub
        uses: docker/login-action@v3
        with:
          username: $
          password: $
          logout: false

      - name: Build and Push Docker Image to DockerHub
        run: |
          DOCKER_PATH=$/$
          echo DOCKER_PATH=$DOCKER_PATH
          echo "DOCKER_PATH=$DOCKER_PATH" >> $GITHUB_ENV

          echo start building image $DOCKER_PATH:$ ...
          echo cd $
          cd $
          
          docker build \
            --build-arg BUILD_VERSION=$ \
            --build-arg REPO_NAME=$ \
            --build-arg PROJECT_OUTPUT=$ \
            --build-arg NAMESPACE=$ \
            --build-arg GIT_COMMIT_ID=$ \
            --build-arg PROJECT_NAME=$ \
            --build-arg PROJECT_CONFIG=$ \
            --build-arg SERVICE_PORTS="$" \
            -t $DOCKER_PATH:$ .
          
          echo built image $DOCKER_PATH:$
          echo tagging image $DOCKER_PATH:$, $DOCKER_PATH:latest ...
          docker tag $DOCKER_PATH:$ $DOCKER_PATH:latest
          echo pushing image $DOCKER_PATH:latest, $DOCKER_PATH:$ ...
          docker push $DOCKER_PATH:latest
          docker push $DOCKER_PATH:$

      - name: Set KUBECONFIG
        uses: Azure/k8s-set-context@v4
        with:
          kubeconfig: $

      - name: Deploy to Kubernetes
        run: |
          echo cd $
          cd $
          kubectl apply -f deployment.yml -n $

      - name: Restart using new deployment
        run: |
          kubectl scale deployment $ --replicas=0 -n $
          kubectl scale deployment $ --replicas=1 -n $

      - name: Verify deployment
        run: |
          kubectl get deployments -o wide -n $
          kubectl get pods -o wide -n $
          kubectl get svc -o wide -n $
          kubectl get ingress -o wide -n $

Pushing the above files to the GitHub repository will automatically trigger the connected GitHub Action CI Runner, which will complete all the steps and deploy the application to the cluster.

GitHub Action

As defined in the gRPC proto file, you can verify that the response to the API request has come out as follows.

gRPC API

We have briefly reviewed the process of creating an ASP.NET Core application, building a Docker image according to the desired service specifications, and automatically deploying it to a Kubernetes cluster. We just covered very minimum implementation of CI/CD except for QA validation automation.

Conclusion

Through this series of posts, we’ve explored the potential of utilizing Kubernetes, a powerful container orchestration platform, even within the confines of personal computers and home internet connections. While often associated with large-scale deployments by enterprises, Kubernetes can be a valuable tool for individual developers and smaller teams seeking to experiment, learn, and build scalable applications.

Our exploration has revealed that Kubernetes can be readily implemented on personal hardware, offering a cost-effective and accessible learning environment. This empowers individuals to gain hands-on experience with containerization and orchestration, fostering valuable skills for modern software development.

Furthermore, the availability of Kubernetes plugins within various development tools enhances the development experience by enabling seamless monitoring and management of Kubernetes clusters directly within the IDE. This integration streamlines the workflow and provides developers with a comprehensive view of their applications and infrastructure.

In today’s cloud-centric landscape, Kubernetes stands as a versatile platform that transcends the boundaries of large corporations. Its adoption by cloud providers underscores its significance and paves the way for organizations to build robust and adaptable infrastructure independent of proprietary cloud services.

Using Kubernetes, even on a personal level, developers empower themselves to learn, experiment, and contribute to the growing ecosystem of containerized applications. This journey not only enhances our technical expertise but also positions us to leverage the power of Kubernetes in building the future of software development.

My HomeLab Cluster currently houses eight operational services and websites, a testament to the rapid progress of my learning and hobby project journey. With continued exploration, this number is poised for exponential growth.

Share: Twitter Facebook LinkedIn