How I have moved my Ghost blog to Azure Kubernetes

How I have moved my Ghost blog to Azure Kubernetes

I have written already in this blog How I have moved my blog to Ghost.
That solution worked well until the beginning of this year, when there has been a change in Azure that prevented to write the SQLite database hosted in an Azure Storage Blob because it was made now readonly. This is documented in Serve content from Azure Storage in App Service on Linux - Limitations of Azure Storage with App Service. This was already a workaround, because we have already found issues hosting the database in Azure Files, like all the rest of the content.

Currently there is no way to mount a file in Azure File specifying the Unix lock mask and the only available solution is to serve the Ghost docker image from a Kubernetes cluster.
As I am not skilled on this argument, my three wonderful colleagues Gareth Emslie, Andre Bossard and Raghuram Tandra have helped me in this migration.

I have used as much as possible the Azure Cloud Shell, that you can access from the top right corner via the icon representing a prompt.

Creation of the Kubernetes cluster

As I have recreated all my resources from scratch, I have started with a new Resource Group called RG_CuriaKube hosted in West Europe. Feel free to adapt the scripts below to adjust to your needs.

az group create --name RG_CuriaKube --location westeurope

Now let's create the Kubernetes cluster called CuriaKube:

az aks create -g RG_CuriaKube -n CuriaKube --location westeurope --node-vm-size Standard_DS2_v2 --node-count 1 --enable-addons monitoring --generate-ssh-keys

Creation of the Azure Container Registry

I already had an Azure Container Registry from my previous installation, but I have created a new one to have everything in this new Resource Group. I have named it CuriaKubeACR.

az acr create --resource-group RG_CuriaKube --name CuriaKubeACR --sku Basic --admin-enabled true

From the previous episode: I have created a custom Docker file, fork of Gareth's, to add Application Insights logging and monitoring.
Now I create the task to pull images from my custom docker file (remember: you get the Git access token from github.com, your profile, Settings, Developer Settings, Personal Access Tokens, create a token with repo rights):

  az acr task create \
   --registry CuriaKubeACR \
   --name docker-ghost-ai \
   --image ghost:3-alpine-ai \
   --context https://github.com/curia-damiano/docker-ghost-ai.git \
   --file 3/alpine/ai/DOCKERFILE \
   --git-access-token 93************************************f3

Run the task:

az acr task run --registry CuriaKubeACR --name docker-ghost-ai

Verify that the task has completed:

az acr task list-runs --registry CuriaKubeACR --output table

Now we connect the Azure Container Registry to the Kubernetes cluster:

AKS_SP_ID=$(az aks show --resource-group RG_CuriaKube --name CuriaKube --query "servicePrincipalProfile.clientId" -o tsv)
ACR_RESOURCE_ID=$(az acr show --resource-group RG_CuriaKube --name CuriaKubeACR --query "id" -o tsv)
az role assignment create --assignee $AKS_SP_ID --scope $ACR_RESOURCE_ID --role contributor

And now we connect to the Kubernetes cluster:

az aks get-credentials --resource-group RG_CuriaKube --name CuriaKube

Creation of the Storage account

We create the storage account with name curiakube to save our files and database:

az storage account create -n curiakube -g RG_CuriaKube -l westeurope --sku Standard_RAGRS

From the portal, under Azure File Shares, we create a new share called contentfiles.
Now, if we have existing files, we copy them in this share, for example using Azure Storage Explorer.

Now we need to connect the Storage account to the Kubernetes cluster.
From the portal, get the access key of the Storage account, then execute:

kubectl create secret generic secret-pv-curiakube --from-literal=azurestorageaccountname=curiakube --from-literal=azurestorageaccountkey=+ir*********************************************************************************7g==

Creation of the ingress controller

Following the NGINX Installation Guide, we need to run the following scripts to install the ingress controller:

kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/nginx-0.30.0/deploy/static/mandatory.yaml
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/nginx-0.30.0/deploy/static/provider/cloud-generic.yaml

Verify it with:

kubectl describe ingress

Installing cert-manager

To install the Cert manager, we need to run the following scripts:

kubectl apply --validate=false -f https://github.com/jetstack/cert-manager/releases/download/v0.14.1/cert-manager.yaml

Label the ingress-basic namespace to disable resource validation:

kubectl label namespace default certmanager.k8s.io/disable-validation=true

Creation of the Application Insights resource

Create the Application Insights resource named CuriaKube:

az extension add -n application-insights
az monitor app-insights component create --app CuriaKube --location westeurope --kind web -g RG_CuriaKube --application-type web

The last command will give in its output the instrumentation key. Take note of it, because we will use it later when connecting it to our application.

Creation of the CA cluster issuer

Save locally the file named cluster-issuer.yaml, replacing the email with yours:

apiVersion: cert-manager.io/v1alpha2
kind: ClusterIssuer
metadata:
  name: letsencrypt
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: damiano.curia@gmail.com
    privateKeySecretRef:
      name: letsencrypt
    solvers:
    - http01:
        ingress:
          class: nginx

You can create it running from the Azure Console:

code cluster-issuer.yaml

pasting the file content above, save and close the code editor.

Then apply it with:

kubectl apply -f cluster-issuer.yaml --namespace default

You should see it here:

kubectl get clusterissuers

Creation of the pod for your application

You can copy and save the file curiakube.yaml copying the following content and:

  • replace the domain name (2 times);
  • add the Application Insights instrumentation key you have got before.

Note also from the file below that:

  • the persistent volume pv-curiakube, that is used to map the contentfiles folder of the storage account, is not marked in read-only;
  • the deployment curiakube has the readOnlyRootFilesystem property set to true;
  • sec-ctx-vol, on which the folder /tmp is mapped, is a non persistent volume, so that it is not in read-only (this is required because when uploading a picture, Ghost uses this folder to create temporary files before writing to the content storage).
apiVersion: v1
kind: PersistentVolume
metadata:
  name: pv-curiakube
spec:
  capacity:
    storage: 5Ti
  accessModes:
    - ReadWriteOnce
  storageClassName: azurefile
  azureFile:
    secretName: secret-pv-curiakube
    shareName: contentfiles
    readOnly: false
  mountOptions:
  - dir_mode=0777
  - file_mode=0777
  - uid=1000
  - gid=1000
  - mfsymlinks
  - nobrl
  - cache=none
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: pvc-curiakube
spec:
  accessModes:
    - ReadWriteOnce
  storageClassName: azurefile
  resources:
    requests:
      storage: 5Ti
---
apiVersion: apps/v1beta1
kind: Deployment
metadata:
  name: curiakube
spec:
  replicas: 1
  strategy:
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 1
  minReadySeconds: 5
  template:
    metadata:
      labels:
        app: curiakube
    spec:
      nodeSelector:
        "beta.kubernetes.io/os": linux
      securityContext:
        runAsUser: 1000
        runAsGroup: 1000
        fsGroup: 2000
      containers:
      - name: curiame
        image: curiakubeacr.azurecr.io/ghost:3-alpine-ai
        imagePullPolicy: Always
        ports:
        - containerPort: 2368
        resources:
          requests:
            cpu: 100m
            memory: 128Mi
          limits:
            cpu: 250m
            memory: 256Mi
        volumeMounts:
          - name: content
            mountPath: /var/lib/ghost/content/
          - name: sec-ctx-vol
            mountPath: /tmp
        env:
        - name: privacy__useUpdateCheck
          value: "false"
        - name: APPINSIGHTS_INSTRUMENTATIONKEY
          value: ""
        - name: NODE_ENV
          value: "production"
        - name: url
          value: "https://curia.me"
        securityContext:
          readOnlyRootFilesystem: true
          runAsUser: 1000
          runAsGroup: 1000
      volumes:
      - name: content
        persistentVolumeClaim:
          claimName: pvc-curiakube
      - name: sec-ctx-vol
        emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
  name: curiakube
spec:
  type: ClusterIP
  ports:
  - port: 80
    targetPort: 2368
  selector:
    app: curiakube
---
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: curiakube-ingress
  annotations:
    kubernetes.io/ingress.class: nginx
spec:
  rules:
    - host: curia.me
      http:
        paths:
          - backend:
              serviceName: curiakube
              servicePort: 80
            path: /

Apply it with:

kubectl apply -f curiakube.yaml
kubectl get service
kubectl get all

Creation of the ingress route

Edit the following file curiakube_ingress.yaml, where again you replace the domain name (2 times):

apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: curiakube-ingress
  annotations:
    kubernetes.io/ingress.class: nginx
    cert-manager.io/cluster-issuer: letsencrypt
spec:
  tls:
  - hosts:
    - curia.me
    secretName: tls-secret
  rules:
  - host: curia.me
    http:
      paths:
      - backend:
          serviceName: curiakube
          servicePort: 80
        path: /

Apply it with:

kubectl apply -f curiakube_ingress.yaml --namespace default

Determine your public IP, and update your DNS accordingly:

kubectl get ingress

Verify that a secret has been produced:

kubectl describe certificates

Wait for the certificate to be produced, when the output of the next command will give Ready=True (it took me 6 minutes to see that it has been done).

kubectl get certificate --namespace default

Now, if you flush your local DNS cache and open the browser pointing to your domain, you should see everything running, in https.

Shut down the site

Kubernetes is not like an App Service, that you can shut it down when you don't need it.
Note that if you need to take a backup of your site, you don't need to do it!
But, in case you want for other reasons, you need to undeploy the pod of your application:

kubectl delete deployment curiakube

When you want to put it back, I have verified that you have to reapply both the application and the ingress controller (otherwise you don't get the SSL certificate):

kubectl apply -f curiakube.yaml --namespace default
kubectl apply -f curiakube_ingress.yaml --namespace default

Other troubleshooting

During my experiments, I have done multiple troubleshooting. I have taken many commands from the Kubernetes cheat-sheet.

Here I list the main one:

kybectl get all
kubectl get certificate --namespace default
kubectl describe certificates
kubectl get ingress
kubectl describe ingress
kubectl get service
kubectl get pods
kubectl get clusterissuers

In particular to access logs and log in into the pod:

kubectl get all
kubectl logs pod/curiakube-**************vh
kubectl exec -it pod/curiakube-**************vh -- /bin/bash

Conclusions

First of all, I need again to thank my colleagues for their invaluable support. Without the help of Gareth in doing the initial setup in http, and the help of Andre in configuring Let's Encrypt and removing helm, I would have not been absolutely able to do it. Finally we needed a support engineer, Raghuram, to fix the permissions on the tmp folder.

The site works very well, I am already editing it now, draft are saved after every few seconds, and I verify that the SQLite database is correctly updated in real time! Also uploading of pictures works well.

But that is the point: I have to do this very complex migration just because in Azure I can't use a storage blob read-write, and with Azure Files I can't specify an access mask.

So all of this is a huge workaround because of technical limitations in the access to storage. We have built a incredible complex system, full of scripts and yaml files, that I don't know for how long they will continue to work or be reproducible in the (near ?) future.

This is my last worry: all the Kubernetes world is complex and moving so fast, week after week, that I don't see it sustainable even in the near future. I hope that it will be replaced by something simpler, easier and more stable. Or better, that Azure App Services could mount blobs and/or files specifying the access mask.