Kubernetes : Ghost blog

Bonjour à tous,

Aujourd'hui, un billet qui porte sur la mise en place de Ghost blog sur un cluster K8S avec un volume persistant sur NFS.

Contexte : à l'origine, le blog était sur une VM FreeBSD ce qui impliquait de maintenir l'OS, la version de node, les montées de version de ghost, etc.
Avec le passage sur un cluster K8S, la maintenance est facilitée dans ce cas et la DB étant sur un serveur dédié, le backup l'est aussi.

L'article qui m'a servi de support :
https://blog.groupe-creative.fr/comment-deployer-un-blog-ghost-avec-kubernetes/

Merci Maël!

J'ai rajouté quelques modifications notamment sur la partie volume/storage.

NFS

Vu qu'il s'agit d'un cluster et qu'il faut du stockage persistent, le mieux dans mon cas est de faire du NFS.

Pour cela, il faut un provider NFS qui sera utilisé ensuite lors des claim.
Depuis le master, j'ai utilisé le chart helm recommandé dans la documentation Kubernetes avec cette configuration :

helm repo add nfs-subdir-external-provisioner https://kubernetes-sigs.github.io/nfs-subdir-external-provisioner/
helm install nfs-subdir-external-provisioner nfs-subdir-external-provisioner/nfs-subdir-external-provisioner \
    --set nfs.server=nfs.adm.securmail.fr \
    --set nfs.path=/srv/k8s \
    --set storageClass.onDelete=retain \
    --set storageClass.pathPattern='/${.PVC.namespace}-${.PVC.name}'

Ces paramètres sont bien entendu en adéquation avec la configuration faite côté serveur NFS :

root@nfs:~# showmount -e localhost
Export list for localhost:
/srv/k8s 2a0e:f41:0:3::/64,192.168.0.0/24

On vérifie ensuite que la storage class est bien présente :

kubectl get storageclasses
NAME               PROVISIONER                                     RECLAIMPOLICY   VOLUMEBINDINGMODE      ALLOWVOLUMEEXPANSION   AGE
nfs-client         cluster.local/nfs-subdir-external-provisioner   Delete          Immediate              true                   9m37s

Blog

Namespace

apiVersion: v1
kind: Namespace
metadata:
  name: ghost

Et on apply :

kubectl apply -f namespace.yml

Vérification que le namespace est présent :

kubectl get ns
NAME                   STATUS   AGE
argocd                 Active   90d
default                Active   162d
ghost                  Active   22h
ingress-nginx          Active   162d
kube-flannel           Active   162d
kube-node-lease        Active   162d
kube-public            Active   162d
kube-system            Active   162d
kubernetes-dashboard   Active   162d
nfs-provisioner        Active   5h13m
puppet                 Active   138d

Volum claim

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: ghost-pvc
  namespace: ghost
spec:
  accessModes:
    - ReadWriteMany
  storageClassName: nfs-client
  resources:
    requests:
      storage: 1Gi

Et on apply :

kubectl apply -f volum_claim.yml

Vérification :

kubectl get pv
NAME                                       CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS   CLAIM             STORAGECLASS   REASON   AGE
pvc-d0542f12-a5b8-47ee-88a2-5d7ff0d015d0   1Gi        RWX            Delete           Bound    ghost/ghost-pvc   nfs-client              5h5m

Service

apiVersion: v1
kind: Service
metadata:
  name: ghost-service
  namespace: ghost
spec:
  ports:
    - name: ghost-http
      port: 2368
  selector:
    app.kubernetes.io/name: ghost

On apply :

kubectl apply -f service.yml

Vérification :

kubectl get service -n ghost
NAME            TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)    AGE
ghost-service   ClusterIP   10.110.208.97   <none>        2368/TCP   5h1m

Ingress

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: ghost-ingress
  namespace: ghost
  annotations:
    nginx.ingress.kubernetes.io/proxy-body-size: "10m"
spec:
  ingressClassName: nginx
  rules:
  - host: www.boris-tassou.fr
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: ghost-service
            port:
              number: 2368

Avec une option pour le body size, sans cette option, l'upload d'un thème par exemple peut ne pas fonctionner.

On apply :

kubectl apply -f ingress.yml

Vérification :

kubectl get ingress -n ghost
NAME            CLASS   HOSTS                 ADDRESS        PORTS   AGE
ghost-ingress   nginx   www.boris-tassou.fr   192.168.0.25   80      5h2m

Config map

apiVersion: v1
kind: ConfigMap
metadata:
  name: ghost-config
  namespace: ghost
data:
  config.k8s.json: |
    {
        "url": "http://www.boris-tassou.fr",
        "server": {
          "port": 2368,
          "host": "0.0.0.0"
        },
        "database": {
          "client": "mysql",
            "connection": {
              "host": "mysql01r",
              "port": 3306,
              "user": "ghost",
              "password": "BLABLABLA",
              "database": "ghost"
            }
        },
        "logging": {
          "transports": [
            "file",
            "stdout"
          ]
        },
        "mail": {
          "from": "blog@boris-tassou.fr",
          "transport": "SMTP",
          "options": {
              "host": "SMTP",
              "port": 25
          }
        },
        "process": "systemd",
        "paths": {
          "contentPath": "/var/lib/ghost/content"
        }
    }

On apply :

kubectl apply -f config-map.yml

Vérification :

kubectl get cm -n ghost
NAME               DATA   AGE
ghost-config       1      5h45m
kube-root-ca.crt   1      22h

Deployment

apiVersion: apps/v1
kind: Deployment
metadata:
  name: ghost
  namespace: ghost
  labels:
    app.kubernetes.io/name: ghost
spec:
  replicas: 1
  selector:
    matchLabels:
      app.kubernetes.io/name: ghost
  template:
    metadata:
      labels:
        app.kubernetes.io/name: ghost
    spec:
      volumes:
        - name: ghost-pv-claim
          persistentVolumeClaim:
            claimName: ghost-pvc
        - name: ghost-config
          configMap:
            name: ghost-config
            items:
              - key: config.k8s.json
                path: config.k8s.json
      containers:
        - name: ghost
          image: ghost:5.79.1-alpine
          imagePullPolicy: Always
          ports:
            - name: http
              containerPort: 2368
          volumeMounts:
            - mountPath: "/var/lib/ghost/content"
              name: ghost-pv-claim
              subPath: ghost
            - name: ghost-config
              mountPath: "/var/lib/ghost/config.production.json"
              subPath: config.k8s.json

On apply :

kubectl apply -f deployment.yml

Vérification :

kubectl get deployment -n ghost
NAME    READY   UP-TO-DATE   AVAILABLE   AGE
ghost   1/1     1            1           5h45m

On peut vérifier que le contenu est bien sur le NFS :

root@nfs:/srv/k8s# ls
ghost-ghost-pvc
root@nfs:/srv/k8s# ls -Al
total 4
drwxrwxrwx 3 root root 4096 Feb 14 10:05 ghost-ghost-pvc
root@nfs:/srv/k8s# ls -Al ghost-ghost-pvc/
total 4
drwxrwxrwx 11 gizmo root 4096 Feb 14 10:05 ghost
root@nfs:/srv/k8s# ls -Al ghost-ghost-pvc/ghost/
total 36
drwxr-xr-x 2 gizmo gizmo 4096 Feb 12 22:54 apps
drwxr-xr-x 2 gizmo gizmo 4096 Feb 14 11:16 data
drwxr-xr-x 2 gizmo gizmo 4096 Feb 12 22:54 files
drwxr-xr-x 2 gizmo gizmo 4096 Feb 12 22:54 images
drwxr-xr-x 2 gizmo gizmo 4096 Feb 14 10:05 logs
drwxr-xr-x 2 gizmo gizmo 4096 Feb 12 22:54 media
drwxr-xr-x 4 gizmo gizmo 4096 Feb 14 11:16 public
drwxr-xr-x 2 gizmo gizmo 4096 Feb 14 10:10 settings
drwxr-xr-x 4 gizmo gizmo 4096 Feb 14 10:31 themes

Et que côté pod on est bon :

kubectl get pods -n ghost
NAME                     READY   STATUS    RESTARTS   AGE
ghost-5bf66f6f59-c27qx   1/1     Running   0          61m
kubectl logs ghost-5bf66f6f59-c27qx -n ghost
[2024-02-14 10:16:02] INFO Ghost is running in production...
[2024-02-14 10:16:02] INFO Your site is now available on http://www.boris-tassou.fr/
[2024-02-14 10:16:02] INFO Ctrl+C to shut down
[2024-02-14 10:16:02] INFO Ghost server started in 1.018s
[2024-02-14 10:16:03] WARN Database state requires migration.
[2024-02-14 10:16:04] INFO Creating database backup
[2024-02-14 10:16:04] INFO Database backup written to /var/lib/ghost/content/data/boris-tassou-fr.ghost.2024-02-14-10-16-04.json
[2024-02-14 10:16:04] INFO Running migrations.
[2024-02-14 10:16:04] INFO Adding tokens.updated_at column
[2024-02-14 10:16:05] INFO Adding tokens.first_used_at column
[2024-02-14 10:16:05] INFO Adding tokens.used_count column
[2024-02-14 10:16:05] INFO Dropping table: suppressions
[2024-02-14 10:16:05] INFO Adding table: suppressions

J'ai un haproxy en amont avec cette configuration pour le backend :

backend bk_k8s_http
    mode http
    http-request set-header Host %[req.hdr(Host)]
    server k8s k8s-cluster.adm.securmail.fr:80 check inter 1m

Le blog est maintenant accessible!
Comme amélioration possible, on pourrait rajouter un certificat interne et modifier l'ingress pour avoir du HTTPS entre haproxy et l'ingress.

Have fun.