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.