I have successfully fallen victim to having more microservices than users by deploying a button on Kubernetes. Yes, you read that right. One singular button. Kubernetes. Why? Learning of course.
Motivation
Some of my experience with building web servers in Golang involves using HTML templates which allows me to generate web pages dynamically using data from the backend server. This is nice but the result is a monolithic application. A few weeks ago I woke up with the thought of frontend and backend containers, and how I could make them. I noted this down but it sat because I wanted something new to work on instead of using an existing app I wrote.
One day a few weeks ago a friend of mine had the off-the-wall request of:
"Make a button that I click and gives me a daily affirmation."
My first thought was how easy this could be. My second thought was how I could overengineer this into answering my real thought of how I could create frontend and backend containers.
Design
The requirements were pretty clear-cut. A button that generates an affirmation. However, there are a few ways I can go about accomplishing that. When it comes to creating affirmations, from the start I knew I would not be writing them from scratch. I'm already not the most affectionate person. So the options are to store a list of affirmations of the internet in a database and retrieve a random one when the button is clicked or...When it comes to text generation, there is nothing better than an LLM right now. Yup, I brought AI into this. A button, just a single button, running on Kubernetes and using AI, am I a founder yet?
Finding the front end to use took about 45 seconds...NGINX.
I prefer to write web servers in GO over Python, so another easy choice.
Lastly, for this to run on my server and be internet accessible it’ll have to run on Kubernetes, containerizing the frontend and backend is also the whole point of this, so I must consider containerizing this through automated builds at some point in this process.
Code Review
The front end is nothing, all of 19 lines of HTML. As I said it is just a button:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/x-icon" href="affirmationicon.jpg">
<link rel="stylesheet" href="styles.css">
<title>Affirmation Generator</title>
</head>
<body>
<button onclick="generateAffirmation()">
<span>Need an affirmation?</span>
</button>
<div class="spinner" id="spinner"></div>
<div class="response" id="response"></div>
<script src="script.js"></script>
</body>
</html>
I've written my own structs and API calls to the OpenAI API before, in this case, I just use a library:
func chat(c *gin.Context) {
affirmations := []string{
AFFIRMATION_QUERY,
AFFIRMATION_QUERY_1,
AFFIRMATION_QUERY_2,
AFFIRMATION_QUERY_3,
AFFIRMATION_QUERY_4,
AFFIRMATION_QUERY_5,
AFFIRMATION_QUERY_6,
}
api_key := os.Getenv("OPENAI_API_KEY")
w := openai.NewClient(api_key)
ctx := context.Background()
req := openai.ChatCompletionRequest{
Model: openai.GPT4o,
MaxTokens: 45,
Messages: []openai.ChatCompletionMessage{
{
Role: openai.ChatMessageRoleUser,
Content: affirmations[rand.Intn(len(affirmations))],
},
},
}
resp, err := w.CreateChatCompletion(ctx, req)
if err != nil {
log.Printf("Completion error: %v\n", err)
return
}
response := (resp.Choices[0].Message.Content)
c.JSON(http.StatusOK, gin.H{"message": response})
}
The code randomly selects one of the six affirmation queries and sends it in the post request to the OpenAI API to generate the affirmation.
I've used the Gin framework to create web servers before, and this time was no different:
func keyFunc(c *gin.Context) string {
return c.ClientIP()
}
func errorHandler(c *gin.Context, info ratelimit.Info) {
c.String(429, "Too many requests. Try again in "+time.Until(info.ResetTime).String())
}
func main() {
router := gin.Default()
router.Use(cors.Default())
// Each IP can only make 5 requests per hour.
store := ratelimit.InMemoryStore(&ratelimit.InMemoryOptions{
Rate: time.Hour,
Limit: 5,
})
mw := ratelimit.RateLimiter(store, &ratelimit.Options{
ErrorHandler: errorHandler,
KeyFunc: keyFunc,
})
router.POST("/chat", mw, chat)
err := router.Run("0.0.0.0:8080")
if err != nil {
log.Println("Error starting webserver", err)
}
}
I also used the gin-rate-limit library to rate limit clients to only 5 requests per hour. The hope was to not get killed in API usage for someone trying to generate as many affirmations as they can in the least amount of time.
The really fun part lies in the Nginx configuration. First, via Javascript, when the button is clicked a spinner is shown on the page, and a POST request is sent to the /api/chat
endpoint:
async function generateAffirmation() {
const spinner = document.getElementById('spinner');
const responseDiv = document.getElementById('response');
// Show spinner
spinner.style.display = 'block';
responseDiv.innerHTML = ''; //
try {
const response = await fetch('/api/chat', {
method: 'POST',
headers: {
'Content-Type': 'text/plain'
},
body: JSON.stringify({})
});
if (!response.ok) {
throw new Error(response.statusText);
}
const data = await response.json();
responseDiv.innerHTML = data.message || 'No message received';
} catch (error) {
responseDiv.innerHTML = 'Error: ' + error.message;
} finally {
// Hide spinner
spinner.style.display = 'none';
}
}
This endpoint is defined in the NGINX configuration, which can proxy the POST request to an endpoint defined inside of the configuration:
location /api/ {
proxy_pass http://affirmations-backend.self-hosted.svc.cluster.local:8080/; # Important: trailing slash here
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60;
proxy_read_timeout 120;
proxy_send_timeout 120;
}
The host http://affirmations-backend.self-hosted.svc.cluster.local:8080/
is the DNS name of the affirmations backend service in Kubernetes.
Containerization
So containerizing the application(s) is also pretty easy. A Dockerfile for frontend and backend:
FROM nginx
# Copy the custom NGINX configuration if needed
COPY nginx.conf /etc/nginx/
# Copy the HTML, CSS, JavaScript, and icon files to the NGINX directory
COPY index.html /usr/share/nginx/html/
COPY styles.css /usr/share/nginx/html/
COPY script.js /usr/share/nginx/html/
COPY affirmationicon.jpg /usr/share/nginx/html/
FROM golang:1.22-bullseye as builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY main.go .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /main .
FROM gcr.io/distroless/static-debian11
COPY --from=builder /main /main
CMD ["/main"]
The default GO Docker image can be pretty large and chock-full of vulnerabilities. Earlier in the year I started using the Dockerfile above for my applications which takes the image to megabytes.
Lastly is GitHub actions. In this case, Gitea Actions since the code is hosted in my homelab. The two are 1:1 so this would be the same if it were GitHub actions:
name: ci
on:
push:
jobs:
backend:
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
sparse-checkout: .
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
uses: docker/login-action@v3
with:
registry: registry.home.${{ secrets.INTERNAL_DOMAIN }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64
push: true
tags: registry.home.${{ secrets.INTERNAL_DOMAIN }}/affirmations/backend:latest
frontend:
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
sparse-checkout: static
sparse-checkout-cone-mode: false
- name: Move static files to root
run: |
ls -lah
mv static/* .
rm -rf static
ls -lah
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
uses: docker/login-action@v3
with:
registry: registry.home.${{ secrets.INTERNAL_DOMAIN }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64
push: true
tags: registry.home.${{ secrets.INTERNAL_DOMAIN }}/affirmations/frontend:latest
Lesson Learned: Do not use monorepos for microservices.
As seen in the second build I have to do some trickery with a sparse checkout and move the files in the `static/` directory to the root of the file system or else the frontend container will not be built.
Kubernetes
I use FluxCD in my cluster, so instead of deploying this through a traditional Kubernetes manifest, I used app-template to install the application via helm. This allows me to not only pull down my container image but also define the services and ingress inside of the helm chart.
values:
controllers:
backend:
containers:
app:
envFrom:
- secretRef:
name: affirmations
image:
repository: registry.home.${INTERNAL_DOMAIN}/affirmations/backend
tag: latest
ports:
- name: http
containerPort: 8080
protocol: TCP
resources:
requests:
cpu: 10m
memory: 64Mi
limits:
memory: 256Mi
frontend:
containers:
app:
image:
repository: registry.home.${INTERNAL_DOMAIN}/affirmations/frontend
tag: latest
ports:
- name: http
containerPort: 80
protocol: TCP
resources:
requests:
cpu: 10m
memory: 64Mi
limits:
memory: 256Mi
probes:
liveness:
enabled: true
readiness:
enabled: true
startup:
enabled: true
service:
backend:
controller: backend
ports:
http:
port: 8080
frontend:
controller: frontend
ports:
http:
port: 80
ingress:
main:
enabled: true
className: traefik
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: "websecure"
traefik.ingress.kubernetes.io/router.middlewares: networking-ipwhitelist@kubernetescrd
cert-manager.io/cluster-issuer: letsencrypt-production-external
hosts:
- host: affirmations.${EXTERNAL_DOMAIN}
paths:
- path: /
pathType: Prefix
service:
identifier: frontend
port: http
tls:
- hosts: ["affirmations.${EXTERNAL_DOMAIN}"]
secretName: affirmations-tls
Another small touch of overengineering is the network policy I have defined to prevent any calls to the backend pod from any other pod in the cluster except the front end. This is because the backend has no authorization, so any POST request to its service == paying big tech.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-affirmations-frontend-only
namespace: self-hosted
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: affirmations
app.kubernetes.io/component: backend
policyTypes:
- Ingress
- Egress
ingress:
- from:
- podSelector:
matchLabels:
app.kubernetes.io/name: affirmations
app.kubernetes.io/component: frontend
egress:
- to:
- podSelector:
matchLabels:
app.kubernetes.io/name: affirmations
app.kubernetes.io/component: frontend
End Result
Overengineered affirmations generation.
See the code on GitHub. Generate your affirmations here: Affirmation Generator.