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.
As defined in the gRPC proto file, you can verify that the response to the API request has come out as follows.
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.