Hugo in Kubernetes
This blog post will cover how I wanted to deploy Hugo to host my blog-page.
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
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
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.
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
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
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.