Jekyll ist ein Generator für statische Webseiten. Als Inputs werden u.a. Markdown-Dateien verwendet, die durch Jekyll in HTML übersetzt werden. Jekyll selbst kümmert sich allerdings nicht um das Deployment der erstellten Webseiten, sondern überlässt diese Arbeit anderen Tools. In diesem Artikel werden wir mit folgenden Technologien ein Continuous Deployment von Jekyll-Blogs aufbauen:

Jekyll

Um mittels Jekyll eine neue Webseite zu erstellen, können folgende Befehle benutzt werden:

$ jekyll new my-new-website
$ cd my-new-website
$ jekyll build

Anschließend sollte im _site Verzeichnis die generierte Webseite zu finden sein. Um dieses Verzeichnis zu deployen und unseren Besuchern zu ermöglichen, die Seite zu sehen, müssen wir die generierte Seite mit einem Webserver (öffentlich) hosten. Falls bereits irgendwo ein Webserver läuft, können wir die Webseite z. B. per ssh/scp auf den Server kopieren und die Webseite veröffentlichen. Stattdessen werden wir ein Docker Image erstellen, dass nginx installiert hat und nur noch unsere generierte Webseite benötigt.

Docker-Image

Um ein Docker-Image zu erstellen, muss ein sog. Dockerfile erstellt werden. Der Inhalt ist wenig überraschend:

FROM bitnami/nginx

COPY _site /app

HEALTHCHECK --start-period=5s --interval=15s --timeout=2s --retries=3 \
  CMD curl --silent --fail http://localhost:8080 || exit 1

Wir starten hier von bitnami/nginx (unser Webserver), fügen anschließend die generierte Webseite hinzu (COPY _site) und als zusätzliche Beigabe definieren wir noch einen sog. Health-Check, um sicherzustellen, dass der Webserver auch wirklich hoch fährt und wir per HTTP eine Antwort bekommen können. Um ein Docker-Image in Kubernetes mit seiner eigenen URL laufen zu lassen, fehlen allerdings noch Deployment-, Service- und Ingress-Konfiguration.

Um das Docker-Image zu erstellen führen wir folgenden Befehl aus:

$ docker build . --tag my-registry/new-website:latest

Helm-Template

Um die fehlende Konfiguration zu erstellen, empfiehlt es sich, helm bzw. helm template zu benutzen. Eine minimale Konfiguration sieht wie folgt aus (vgl. Helm-Entwicklerleitfaden):

  1. Eine Datei namens Chart.yaml, um Metadaten abzulegen
  2. Eine Datei namens values.yaml, um vordefinierte Werte für Parameter zu definieren
  3. Ein Verzeichnis namens templates, indem die Deployment-, Service- und Ingress-Konfiguration zu finden ist

Die Metadaten können z. B. wie folgt aussehen:

apiVersion: v1
name: my-new-website
version: 1.0.0
description: Helm chart for my-new-website
home: https://url.for.my.new.website/

Sofern unsere Templates keine Parameter verwenden, muss keine values.yaml-Datei erstellt werden. In unserem Fall werden wir den Namen, Namespace, Release und öffentliche URL unserer Templates parametrisieren, daher sieht unsere values.yaml-Datei wie folgt aus:

namespace: my-favorite-namespace
name: my-new-website
url: url.for.my.new.website
release: production

Im templates-Verzeichnis wiederum benötigen wir insgesamt 3 Dateien für Deployment, Service und Ingress. Zunächst das Deployment:


apiVersion: apps/v1
kind: Deployment
metadata:
  name: "{{ .Values.name }}"
  namespace: "{{ .Values.namespace }}"
  labels:
    app.kubernetes.io/name: "{{ .Values.name }}"
    app.kubernetes.io/instance: "{{ .Values.release }}"
spec:
  selector:
    matchLabels:
      app.kubernetes.io/name: "{{ .Values.name }}"
      app.kubernetes.io/instance: "{{ .Values.release }}"
  template:
    metadata:
      labels:
        app.kubernetes.io/name: "{{ .Values.name }}"
        app.kubernetes.io/instance: "{{ .Values.release }}"
    spec:
      containers:
      - image: "my/new-website:{{ .Values.tag }}"
        name: "{{ .Values.name }}"

Wie wir sehen können, werden Parameter aus values.yaml im Template mittels {{ .Values.NAME_OF_KEY }} referenziert. Neben Name und Namespace verwenden wir im Deployment noch zusätzlich Release und Tag, die im späteren Verlauf gesetzt werden. Das Deployment beschreibt für Kubernetes, welches Image mit welchen Parametern ausgeführt werden soll. Allein durch das Starten des Images, ist dieses aber nicht öffentlich erreichbar. Dafür brauchen wir als nächstes einen Service:


apiVersion: v1
kind: Service
metadata:
  name: "{{ .Values.name }}"
  namespace: "{{ .Values.namespace }}"
  labels:
    app.kubernetes.io/name: "{{ .Values.name }}"
    app.kubernetes.io/instance: "{{ .Values.release }}"
spec:
  ports:
  - port: 8080
  selector:
    app.kubernetes.io/name: "{{ .Values.name }}"
    app.kubernetes.io/instance: "{{ .Values.release }}"

Ein Kubernetes-Service ist ein persistenter Pointer auf ein Deployment, welches über seine Labels selektiert wird. Da Deployments sich ständig ändern können, z. B. um hoch oder runter zu skalieren, benutzen wir den Service als statische Referenz auf das jeweils aktuelle Deployment. Um diesen Service öffentlich zu erreichen, benötigen wir zum Schluss noch einen sog. Ingress:


apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: "{{ .Values.name }}"
  namespace: "{{ .Values.namespace }}"
  labels:
    app.kubernetes.io/name: "{{ .Values.name }}"
    app.kubernetes.io/instance: "{{ .Values.release }}"
spec:
  rules:
  - host: "{{ .Values.url }}"
    http:
      paths:
      - backend:
          serviceName: "{{ .Values.name }}"
          servicePort: 8080

Der Ingress wählt per serviceName den obigen Service aus und veröffentlicht diesen unter der angegebenen öffentlichen URL. Zusätzliche Konfiguration für SSL/TLS ist hier ebenfalls möglich aber nicht Teil dieses Artikels.

Um das Helm-Chart in etwas zu übersetzen, was von Kubernetes verstanden wird, muss helm template ausgeführt werden. Wie im folgenden Befehl zu sehen ist, können wir die values.yaml per --values an Helm übergeben. Möglich ist außerdem, dass einzelne Werte mittels --set KEY=VALUE überschrieben werden.

$ helm template --values values.yaml > k8s-descriptors.yaml

Kubectl

Die erstelle Kubernetes-Konfiguration kann leicht mit kubectl eingespielt werden:

$ kubectl apply --filename k8s-descriptors.yaml

Kubernetes sorgt dafür, dass alle definierten Ressourcen erstellt werden und die Webseite veröffentlicht wird.

Jenkins

Anstatt die oben aufgeführten Befehle immer wieder auszuführen, sobald es Änderung an der Webseite gibt, sollte der gesamte Prozess automatisiert werden. Jenkins ist eine von vielen Möglichkeiten, um eine vordefinierte Liste an Befehlen auszuführen. In diesem Artikel werden wir deklarative multibranch Pipeline verwenden, um für jeden Branch in unserem Git-Repository für unsere Webseite eine Pipeline zu bekommen, die für alle Branches fast das selbe macht. Die Pipeline sieht wie folgt aus:

pipeline {
  environment {
    GIT_HASH = sh(returnStdout: true, script: 'git rev-parse HEAD').trim()
    BASE_URL = 'url.for.my.new.website'
    BRANCH = BRANCH_NAME.replace('/', '-')
    JEKYLL_COMMAND = "${BRANCH == "master" ? "build" : "build --drafts"}"
    PUBLIC_URL = "${BRANCH == "master" ? BASE_URL : BRANCH + "." + BASE_URL}"
    NAME = "${BRANCH == "master" ? "my-new-website" : "my-new-website-" + BRANCH}"
    RELEASE = "${BRANCH == "master" ? "production" : "preview"}"
  }
  stages {
    stage('Build Jekyll Site') {
      steps {
        sh "jekyll ${JEKYLL_COMMAND}"
      }
    }
    stage('Create & Upload Docker Image') {
      steps {
        sh "docker build . --tag my-registry/new-website:${GIT_HASH}"
      }
    }
    stage('Create Deployment Descriptor') {
      steps {
        gitlabCommitStatus('descriptor') {
          sh """
            helm template . \
              --set name=${NAME} \
              --set url=${PUBLIC_URL} \
              --set tag=${GIT_HASH} \
              --set release=${RELEASE} \
              --values values.yaml
              > k8s-descriptors.yaml
          """
        }
      }
    }
    stage('Deploy to Cluster') {
      steps {
        gitlabCommitStatus('deploy') {
          sh "kubectl apply --filename k8s-descriptors.yaml"
          echo """
            *******************************************************************
              visit the site at https://${PUBLIC_URL}/
            *******************************************************************
          """
        }
      }
    }
  }
}

Die Pipeline führt je nachdem ob wir uns auf dem master-Branch oder nicht befinden eine leicht unterschiedliche Konfiguration aus, um jedem Branch eine eigene URL und einen eigenen Namen zu geben. Der master-Branch wird weiterhin nach https://url.for.my.new.website/ ausgeliefert, während alle Feature-Branches nach https://BRANCH_NAME.url.for.my.new.website/ ausgeliefert werden.