Hugo in Kubernetes

Overview

This blog post will cover how I wanted to deploy Hugo to host my blog-page.

Preparations

To achieve what I wanted, deploy an highly available Hugo hosted blog page, I decided to run Hugo in Kubernetes. For that I needed

  • Kubernetes cluster, obviously, consisting of several workers for the the "hugo" pods to run on (already covered here.
  • Persistent storage (NFS in my case, already covered here)
  • An Ingress controller (already covered here)
  • A docker image with Hugo, nginx and go (will be covered here)
  • Docker installed so you can build the image
  • A place to host the docker image (Docker hub or Harbor registry will be covered here)

Create the Docker image

Before I can deploy Hugo I need to create an Docker image that contains the necessary bits. I have already created the Dockerfile here:

 1#Install the container's OS.
 2FROM ubuntu:latest as HUGOINSTALL
 3
 4# Install Hugo.
 5RUN apt-get update -y
 6RUN apt-get install wget git ca-certificates golang -y
 7RUN wget https://github.com/gohugoio/hugo/releases/download/v0.104.3/hugo_extended_0.104.3_Linux-64bit.tar.gz && \
 8    tar -xvzf hugo_extended_0.104.3_Linux-64bit.tar.gz  && \
 9    chmod +x hugo && \
10    mv hugo /usr/local/bin/hugo && \
11    rm -rf hugo_extended_0.104.3_Linux-64bit.tar.gz
12# Copy the contents of the current working directory to the hugo-site
13# directory. The directory will be created if it doesn't exist.
14COPY . /hugo-site
15
16# Use Hugo to build the static site files.
17RUN hugo -v --source=/hugo-site --destination=/hugo-site/public
18
19# Install NGINX and deactivate NGINX's default index.html file.
20# Move the static site files to NGINX's html directory.
21# This directory is where the static site files will be served from by NGINX.
22FROM nginx:stable-alpine
23RUN mv /usr/share/nginx/html/index.html /usr/share/nginx/html/old-index.html
24COPY --from=HUGOINSTALL /hugo-site/public/ /usr/share/nginx/html/
25
26# The container will listen on port 80 using the TCP protocol.
27EXPOSE 80

Credits for the Dockerfile as it was initially taken from here. I have updated it, and done some modifications to it.

Before building the image with docker, install docker by following this guide.

Build the docker image

I need to place myself in the same directory as my Dockerfile and execute the following command (Replace "name-you-want-to-give-the-image:<tag>" with something like "hugo-image:v1"):

1docker build -t name-you-want-to-give-the-image:<tag> .  #Note the "."  important

Now the image will be built and hosted locally on my "build machine".

If anything goes well it should be listed here:

1$ docker images
2REPOSITORY                            TAG             IMAGE ID       CREATED        SIZE
3hugo-image                            v1              d43ee98c766a   10 secs ago    70MB
4nginx                                 stable-alpine   5685937b6bc1   7 days ago     23.5MB
5ubuntu                                latest          216c552ea5ba   9 days ago     77.8MB

Place the image somewhere easily accessible

Now that I have my image I need to make sure it is easily accessible for my Kubernetes workers so they can download the image and deploy it. For that I can use the local docker registry pr control node and worker node. Meaning I need to load the image into all workers and control plane nodes. Not so smooth way to to do it. This is the approach for such a method:

1docker save -o <path for generated tar file> <image name> #needs to be done on the machine you built the image. Example: docker save -o /home/username/hugo-image.v1.tar hugo-image:v1

This will "download" the image from the local docker repository and create tar file. This tar file needs to be copied to all my workers and additional control plane nodes with scp or other methods I find suitable. When that is done I need to upload the tar to each of their local docker repository with the following command:

1docker -i load /home/username/hugo-image.v1.tar

It is ok to know about this process if you are in non-internet environments etc, but even in non-internet environment we can do this with a private registry. And thats where Harbor can come to the rescue link.

With Harbor I can have all my images hosted centrally but dont need access to the internet as it is hosted in my own environment.
I could also use Docker hub. Create an account there, and use it as my repository. I prefer the Harbor registry, as it provides many features. The continuation of this post will use Harbor, the procedure to upload/download images is the same process as with Docker hub but you log in to your own Harbor registry instead of Docker hub.

Uploading my newly created image is done like this:

1docker login registry.example.com #FQDN to my selfhosted Harbor registry, and the credentials for an account I have created there. 
2docker tag hugo-image:v1 https://registry.example.com/hugo/hugo-image:v1 #"/hugo/" name of project in Harbor
3docker push registry.example.com/hugo/hugo-image:v1 #upload it

Thats it. Now I can go ahead and create my deployment.yaml definition file in my Kubernetes cluster, point it to my image hosted at my local Harbor registry (e.g registry.example.com/hugo/hugo-image:v1). But let me go through how I created my Hugo deployment in Kubernetes, as I am so close to see my newly image in action 😄 (Will it even work).

Deploy Hugo in Kubernetes

To run my Hugo image in Kubernetes the way I wanted I need to define a Deployment (remember I wanted a highly available Hugo deployment, meaning more than one pod and the ability to scale up/down). The first section of my hugo-deployment.yaml definition file looks like this:

 1apiVersion: apps/v1
 2kind: Deployment
 3metadata:
 4  name: hugo-site
 5  namespace: hugo-site
 6spec:
 7  replicas: 3
 8  selector:
 9    matchLabels:
10      app: hugo-site
11      tier: web
12  template:
13    metadata:
14      labels:
15        app: hugo-site
16        tier: web
17    spec:
18      containers:
19      - image: registry.example.com/hugo/hugo-image:v1
20        name: hugo-site
21        imagePullPolicy: Always
22        ports:
23        - containerPort: 80
24        name: hugo-site
25        volumeMounts:
26        - name: persistent-storage
27          mountPath: /usr/share/nginx/html/
28      volumes:
29      - name: persistent-storage
30        persistentVolumeClaim:
31          claimName: hugo-pv-claim

In the above I define name of deployment, specify number of pods with the replica specification, labels, point to my image hosted in Harbor and then what the container mountPath and the peristent volume claim. mountPath is inside the container, and the files/folders mounted is read from the content it sees in the persistent volume claim "hugo-pv-claim". Thats where Hugo will find the content of the Public folder (after the content has been generated).

I also needed to define a Service so I can reach/expose the containers contents (webpage) on port 80. This is done with this specification:

 1apiVersion: v1
 2kind: Service
 3metadata:
 4  name: hugo-service
 5  namespace: hugo-site
 6  labels:
 7    svc: hugo-service
 8spec:
 9  selector:
10    app: hugo-site
11    tier: web
12  ports:
13    - port: 80

Can be saved as a separate "service.yaml" file or pasted into one yaml file. But instead of pointing to my workers IP addresses to read the content each time I wanted to expose it with an Ingress by using AKO and Avi LoadBalancer. This is how I done that:

 1apiVersion: networking.k8s.io/v1
 2kind: Ingress
 3metadata:
 4  name: hugo-ingress
 5  namespace: hugo-site
 6  labels:
 7    app: hugo-ingress
 8  annotations:
 9    ako.vmware.com/enable-tls: "true"
10spec:
11  ingressClassName: avi-lb
12  rules:
13    - host: yikes.guzware.net
14      http:
15        paths:
16        - pathType: Prefix
17          path: /
18          backend:
19            service:
20              name: hugo-service
21              port:
22                number: 80

I define my ingressClassName, the hostname for my Ingress controller to listen for requests on and the Service the Ingress should route all the request to yikes.guzware.net to, which is my hugo-service defined earlier. Could also be saved as a separe yaml file. I have chosen to put all three "kinds" in one yaml file. Which then looks like this:

 1apiVersion: apps/v1
 2kind: Deployment
 3metadata:
 4  name: hugo-site
 5  namespace: hugo-site
 6spec:
 7  replicas: 3
 8  selector:
 9    matchLabels:
10      app: hugo-site
11      tier: web
12  template:
13    metadata:
14      labels:
15        app: hugo-site
16        tier: web
17    spec:
18      containers:
19      - image: registry.example.com/hugo/hugo-image:v1
20        name: hugo-site
21        imagePullPolicy: Always
22        ports:
23        - containerPort: 80
24        name: hugo-site
25        volumeMounts:
26        - name: persistent-storage
27          mountPath: /usr/share/nginx/html/
28      volumes:
29      - name: persistent-storage
30        persistentVolumeClaim:
31          claimName: hugo-pv-claim
32---
33apiVersion: v1
34kind: Service
35metadata:
36  name: hugo-service
37  namespace: hugo-site
38  labels:
39    svc: hugo-service
40spec:
41  selector:
42    app: hugo-site
43    tier: web
44  ports:
45    - port: 80
46---
47apiVersion: networking.k8s.io/v1
48kind: Ingress
49metadata:
50  name: hugo-ingress
51  namespace: hugo-site
52  labels:
53    app: hugo-ingress
54  annotations:
55    ako.vmware.com/enable-tls: "true"
56spec:
57  ingressClassName: avi-lb
58  rules:
59    - host: yikes.guzware.net
60      http:
61        paths:
62        - pathType: Prefix
63          path: /
64          backend:
65            service:
66              name: hugo-service
67              port:
68                number: 80

Now before my Deployment is ready to be applied I need to create the namespace I have defined in the yaml file above: kubectl create ns hugo-site.

Now when that is done its time to apply my hugo deployment. kubectl apply -f hugo-deployment.yaml

I want to check the state of the pods:

1$ kubectl get pod -n hugo-site 
2NAME                         READY   STATUS    RESTARTS   AGE
3hugo-site-7f95b4644c-5gtld   1/1     Running   0          10s
4hugo-site-7f95b4644c-fnrh5   1/1     Running   0          10s
5hugo-site-7f95b4644c-hc4gw   1/1     Running   0          10s

Ok, so far so good. What about my deployment:

1$ kubectl get deployments.apps -n hugo-site 
2NAME        READY   UP-TO-DATE   AVAILABLE   AGE
3hugo-site   3/3     3            3           35s

Great news. Lets check the Service, Ingress and persistent volume claim.

Service:

1$ kubectl get service -n hugo-site 
2NAME           TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)   AGE
3hugo-service   ClusterIP   10.99.25.113   <none>        80/TCP    46s

Ingress:

1$ kubectl get ingress -n hugo-site 
2NAME           CLASS    HOSTS               ADDRESS         PORTS   AGE
3hugo-ingress   avi-lb   yikes.guzware.net   x.x.x.x         80      54s

PVC:

1NAME            STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS   AGE
2hugo-pv-claim   Bound    pvc-b2395264-4500-4d74-8a5c-8d79f9df8d63   10Gi       RWO            nfs-client     59s

Well that looks promising. Will I be able to access my hugo page on yikes.guzware.net ... well yes, otherwise you wouldnt read this.. 🤣

Creating and updating content

A blog page without content is not so interesting. So just some quick comments on how I create content, and update them.

I use Typora creating and editing my *.md files. While working with the post (such as now) I run hugo in "server-mode" whith this command: hugo server. If I run this command on of my linux virtual machines through SSH I want to reach the server from my laptop so I add the parameter --bind=ip-of-linux-vm and I can access the page from my laptop on the ip of the linux VM and port 1313. When I am done with the article/post for the day I generated the web-page with the command hugo -D -v. The updated content of my public folder after I have generated the page is mirrored to the NFS path that is used in my PVC shown above and my containers picks up the updated content instantly. Thats how I do, it works and I find it easy to maintain and operate. And, if one of my workers fails, I have more pods still available on the remaining workers. If a pod fails Kubernetes will just take care of that for me as I have declared a set of pods(replicas) that should run. If I run my Kubernetes environment in Tanzu and one of my workers fails, that will also be automatically taken care of.