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):
- Eine Datei namens
Chart.yaml
, um Metadaten abzulegen - Eine Datei namens
values.yaml
, um vordefinierte Werte für Parameter zu definieren - 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.