Teil 1: Anforderungen und Problem.. äh, Herausforderungen
Teil 2: Lösungsansätze – Dockerimages und Initialisierung
Teil 3: Lösungsansätze – Dockerimage mit WordPress
Teil 4: Lösungsansätze – Kubernetes
Teil 5: Installation automatisieren
Teil 6: Updates automatisieren
Teil 7: Feinschliff


Wir haben nun ein WordPress in Kubernetes am Laufen, aber es warten bereits die nächsten in der Pipeline.
Die brauchen alle eigene Dockerimages (weil anderes Theme und andere Plugins), eine eigene Datenbank, eine eigene k8s-config (weil die Namen sich ja unterscheiden müssen) usw…

Stichpunktartig gestalten sich die Anforderungen wie folgt:

  • eigene Dockerimages
  • eigene Datenbank
  • angepasste k8s-config
  • git-Repos wo das ganze Gedöns drin ist
  • einen KeyVault-Eintrag
  • Build-Pipelines um die Dockerimages automatisch zu bauen
  • und außerdem Release-Pipelines für:
    • Datenbank Setup
    • Datenbank Update
    • k8s-config deployen
    • rollout image

Die gute Nachricht ist: das meiste davon ist in 90% der Fälle nahezu identisch und muss nur minimal angepasst werden. Also ideal um das ganze in Templates zu gießen. Da das alles sowieso in entsprechenden git-Repos landen soll, sortieren wird das also schon mal in passenden Ordnern ein. Bspw.:

  • templates-img
  • templates-waf-init
  • templates-k8s
  • templates-pipelines

OK, der letzte wird nicht in einem git-Repo landen, aber zu den Besonderheiten von Pipelines – vor allem in Azure DevOps – kommen wir gleich noch.

Auf die Verzeichnisse verteilen wir jetzt die Files, die wir in den letzten Teilen erstellt haben.
Der Verzeichnisbaum könnte/sollte dann in etwa so aussehen:

.
├── setup.sh
├── templates-img/
│  ├── azure-pipelines.yml
│  ├── init/
│  │  ├── Dockerfile
│  │  ├── download-plugins.sh
│  │  ├── download-themes.sh
│  │  ├── plugin-list.json
│  │  ├── setup-or-update-wp.sh
│  │  └── theme-list.json
│  └── runtime/
│     └── Dockerfile
├── templates-k8s/
│  ├── autoscaler.yaml
│  ├── configmap.yaml
│  ├── cronjob.yaml
│  ├── deployment.yaml
│  ├── ingress.yaml
│  ├── pvc.yaml
│  ├── secret.yaml
│  ├── secret.yaml_part2
│  └── service.yaml
├── templates-pipelines/
│  ├── deploy-wp.json
│  ├── rollout-img.json
│  ├── setup-db.json
│  └── update-db.json
└── templates-waf-init/
   ├── azure-pipelines.yml
   ├── Dockerfile
   ├── init-wflogs.sh
   └── wflogs-tmpl/
      ├── attack-data.php
      ├── config-livewaf.php
      ├── config-synced.php
      ├── config-transient.php
      ├── config.php
      ├── GeoLite2-Country.mmdb
      ├── ips.php
      ├── rules.php
      └── template.php

Ein paar Dinge fallen in dem Listing auf, die wir bislang noch nicht behandelt haben: setup.sh, secret.yaml_part2, die azure-pipelines.yml sowie die JSON-Files in templates-pipelines.

Also gehen wir die der Reihe nach durch.

Setup-Script

#!/bin/bash

organization="https://dev.azure.com/yourorganization/"
project="TeamWordpress"
dbHost="team-database.mysql.database.azure.com"
dbUser="[email protected]"

sitename=$1
workdir=$2
generatorDir=$(dirname $0)

if [ -z ${sitename} ]; then
  echo "No site-name specified! Aborting...";
  exit 1;
fi

invalidSitename=$(echo "${sitename}" | grep "[^[:alnum:]\._-]" | wc -l)
if [ ${invalidSitename} -gt 0 ]; then
  echo "Invalid site-name! Only alphanumeric characters and '.', '-' or '_' are allowed!";
  exit 1;
fi

if [ -z "${workdir}" -o ! -d "${workdir}" ]; then
  echo "No workdir for repositories specified! Aborting...";
  exit 1;
fi

echo "Checking dependencies on installed tools...";
azureCliMissing=$(which az | grep "not found" | wc -l)
mysqlMissing=$(which mysql | grep "not found" | wc -l)

if [ ${azureCliMissing} -gt 0 ]; then
    echo "azure-cli is not installed! Aborting.";
    exit 1;
fi
if [ ${mysqlMissing} -gt 0 ]; then
    echo "mysql-client is not installed! Aborting.";
    exit 1;
fi

cd "${generatorDir}"
generatorDir=$(pwd) # determine absolute path

cd "${workdir}"

##
# create required git-repos
#
echo "Create required repositories...";
az repos create --org "${organization}" -p "${project}" --name "wp-${sitename}-img"
az repos create --org "${organization}" -p "${project}" --name "wp-${sitename}-k8s"
az repos create --org "${organization}" -p "${project}" --name "wp-${sitename}-waf-init"

echo "Checkout repositories to specified workdir (${workdir}) ...";
git clone [email protected]:v3/yourorganization/${project}/wp-${sitename}-img
git clone [email protected]:v3/yourorganization/${project}/wp-${sitename}-k8s
git clone [email protected]:v3/yourorganization/${project}/wp-${sitename}-waf-init

##
# copy templates
#
echo "Copy templates to repositories...";
cp -R "${generatorDir}/templates-img/." "${workdir}/wp-${sitename}-img"
cp -R "${generatorDir}/templates-k8s/." "${workdir}/wp-${sitename}-k8s"
cp -R "${generatorDir}/templates-waf-init/." "${workdir}/wp-${sitename}-waf-init"

##
# exchange placeholders
#
siteid=$(echo "${sitename}" | sed -e 's/[^a-zA-Z]//gi')
echo "Process templates using site-identifier: ${siteid}";
sed -i -e "s/SITENAME/${siteid}/g" "${workdir}/wp-${sitename}-img/"*.yml*
sed -i -e "s/SITENAME/${siteid}/g" "${workdir}/wp-${sitename}-img/init/Dockerfile"
sed -i -e "s/SITENAME/${siteid}/g" "${workdir}/wp-${sitename}-img/runtime/Dockerfile"
sed -i -e "s/SITENAME/${siteid}/g" "${workdir}/wp-${sitename}-k8s/"*.yaml
sed -i -e "s/SITENAME/${siteid}/g" "${workdir}/wp-${sitename}-k8s/ssh/"*.tmpl
sed -i -e "s/SITENAME/${siteid}/g" "${workdir}/wp-${sitename}-waf-init/"*.yml

curl -s "https://api.wordpress.org/secret-key/1.1/salt/" | sed -e 's/^/         /gi' >> "${workdir}/wp-${sitename}-k8s/secret.yaml"
cat "${workdir}/wp-${sitename}-k8s/secret.yaml_part2" >> "${workdir}/wp-${sitename}-k8s/secret.yaml"
rm "${workdir}/wp-${sitename}-k8s/secret.yaml_part2"

##
# commit & push
#
echo "Commit and push changes...";

cd "${workdir}/wp-${sitename}-img/"
git add --all
git commit -m "generated image repository"
git push

cd "${workdir}/wp-${sitename}-k8s/"
git add --all
git commit -m "generated k8s config repository"
git push

cd "${workdir}/wp-${sitename}-waf-init/"
git add --all
git commit -m "generated wordfence-data init repository"
git push


##
# create build-pipelines
#
echo "Create build-pipelines...";
az pipelines create --name "wp-${sitename}-img" --description "build-pipeline for wp-${sitename}-img" \
 --repository "https://[email protected]/yourorganization/${project}/_git/wp-${sitename}-img" \
 --org "${organization}" -p "${project}" --branch master --repository-type tfsgit --yml-path "azure-pipelines.yml" --folder-path "wp-cloud"

az pipelines create --name "wp-${sitename}-waf-init" --description "build-pipeline for wp-${sitename}-waf-init" \
 --repository "https://[email protected]/yourorganization/${project}/_git/wp-${sitename}-waf-init" \
 --org "${organization}" -p "${project}" --branch master --repository-type tfsgit --yml-path "azure-pipelines.yml" --folder-path "wp-cloud"


##
# create release-pipeline templates
#
echo "Generating JSON-Files for Release-Pipelines...";
repoId=$(az repos show -r "wp-${sitename}-k8s" --org "${organization}" -p "${project}" | jq -r '.id')
projectId=$(az repos show -r "wp-${sitename}-k8s" --org "${organization}" -p "${project}" | jq -r '.project.id')

sed -e "s/SITENAME/${sitename}/g" "${generatorDir}/templates-pipelines/deploy-wp.json" > "${workdir}/Deploy Wordpress (${sitename}).json"
sed -i -e "s/SITEID/${siteid}/g" "${workdir}/Deploy Wordpress (${sitename}).json"
sed -i -e "s/PROJECT_ID/${projectId}/g" "${workdir}/Deploy Wordpress (${sitename}).json"
sed -i -e "s/REPO_ID/${repoId}/g" "${workdir}/Deploy Wordpress (${sitename}).json"

sed -e "s/SITENAME/${sitename}/g" "${generatorDir}/templates-pipelines/setup-db.json" > "${workdir}/Setup Wordpress DB (${sitename}).json"
sed -i -e "s/SITEID/${siteid}/g" "${workdir}/Setup Wordpress DB (${sitename}).json"

sed -e "s/SITENAME/${sitename}/g" "${generatorDir}/templates-pipelines/update-db.json" > "${workdir}/Update Wordpress DB (${sitename}).json"
sed -i -e "s/SITEID/${siteid}/g" "${workdir}/Update Wordpress DB (${sitename}).json"

sed -e "s/SITENAME/${sitename}/g" "${generatorDir}/templates-pipelines/rollout-img.json" > "${workdir}/Rollout Wordpress Image (${sitename}).json"
sed -i -e "s/SITEID/${siteid}/g" "${workdir}/Rollout Wordpress Image (${sitename}).json"

echo "The JSON-Files must be imported via the Web-UI (+ New -> Import release pipeline)
Keep in mind, that you have to specify the Agent-Pool manually, as M$ Azure-DevOps does not support specifying it in the JSON-Template!
";

##
# generate db-password and store in keeper
#
echo "generate site-db password...";
rndPasswd=$(< /dev/urandom  tr -dc _A-Z-a-z-0-9 | head -c 32)
az keyvault secret set --vault-name wordpress-cloud-keyvault -n "wp-${siteid}-db-password" --value "${rndPasswd}"

##
# create mysql DB
#
current_date=$(date +"%Y-%m-%d")
username=$(whoami)
current_ip=$(curl -s "https://ipecho.net/plain")

cd "${generatorDir}"

echo "Create firewall-rule in MySQL for current client-IP...";
az mysql server firewall-rule create -g "myResourceGroup" -s "team-database" -n "current-client-ip-${username}_${current_date}" --start-ip-address "${current_ip}" --end-ip-address "${current_ip}"

wget https://www.digicert.com/CACerts/BaltimoreCyberTrustRoot.crt.pem
echo "CREATE DATABASE wp_${siteid};
CREATE USER 'wp_${siteid}'@'%' IDENTIFIED BY '${rndPasswd}';
GRANT ALL PRIVILEGES ON wp_${siteid}.* TO 'wp_${siteid}'@'%';
" > setup-db.sql

echo "Password for database-user '${dbUser}' required:";
mysql -h "${dbHost}" --ssl=TRUE --ssl-ca=BaltimoreCyberTrustRoot.crt.pem -p -u "${dbUser}" < setup-db.sql

Die ersten ca. 40 Zeilen sind relativ unspektakulär. Wir definieren ein paar Basiswerte, prüfen die Eingabeparameter (den Namen des zu erstellenden Blog sowie das Verzeichnis wo der generierte Kram hin gespeichert werden soll), sowie das Vorhandensein von diversen Tools die im Weiteren benötigt werden – namentlich Azure-CLI und MySQL-Client.

Mit Hilfe von Azure-CLI werden dann die drei git-Repos erstellt, die weiter oben schon Erwähnung fanden. Es wird dabei davon ausgegangen, dass bereits ein az login stattgefunden hat, und die nötigen Berechtigungen im konfigurierten Projekt der Organisation vorhanden sind.
Anschließend werden die Repos direkt ins spezifizierte Zielverzeichnis gecloned, denn wir wollen sie ja gleich mit „Leben“ befüllen – was wir auch direkt tun.

Um Probleme mit bestimmten Zeichen zu vermeiden, schmeißen wir diese aus dem Namen raus um eine Site-ID zu generieren, mit der wir in den kopierten Files die Platzhalter ersetzen. Zumindest in den Yaml und Dockerfiles.
Und dann kommen wir auch schon zum oben erwähnten secret.yaml_part2. Da wir es uns ja einfach gemacht haben, und einfach nur sed zum ersetzen der Platzhalter genommen haben (statt eine ausgewachsene Templateengine), bekommen wir Probleme mit Multiline-Werten. Wenn also ein Platzhalter auf einer Zeile mit einem mehrere Zeilen umfassenden Wert ersetzt werden soll.
Deswegen machen wir es uns auch an dieser Stelle wieder einfach, und splitten die Datei einfach auf. Die fertige Datei setzen wir dann aus den Einzelteilen zusammen. Nicht sonderlich elegant, aber effektiv. Wir wollen hier ja keinen Schönheitswettbewerb gewinnen sondern ein paar WordPress-Seiten mit möglichst wenig Aufwand ans Laufen bringen!

Der nächste Schritt ist selbsterklärend: commit und push auf die git-Repos.
Danach geht es den Pipelines an den Kragen. Die Build-Pipelines sind davon der leichte Teil. Via Azure-CLI lassen diese sich problemlos anlegen und die yaml-Definition ist übersichtlich strukturiert.
Schwieriger wird es bei den Release-Pipelines. Da unterscheidet Azure Devops nämlich. Und die Release-Pipelines lassen sich nicht automatisiert anlegen! Zwar verfügt Azure Devops für Release-Pipelines über eine Import- und Export-Funktion, aber selbst wenn man eine definitiv funktionierende Release-Pipeline exportiert und dann eins zu eins Re-importiert, muss man zwingend für jede Stage die Angaben über den Agent-Pool manuell vornehmen. Diese Information – obwohl zwingend erforderlich – ist in der JSON-Definition schlicht nicht vorgesehen, bzw. wird schlicht ignoriert sollte man sie dort explizit mit angeben! Da kommt doch Freude auf …nicht.
Das Script kann an der Stelle also nur unterstützen, aber die Einrichtung nicht automatisiert vornehmen. Wir nehmen also die Templates der JSON-Definitionen, ersetzen die erforderlichen Werte und schreiben sie, unter dem Namen den die Pipeline erhalten soll, ins Zielverzeichnis.
Importieren muss der User sie dann händisch. Aber das ist immer noch um Welten besser als die Dinger komplett manuell anzulegen und dabei irgendwas zu vergessen oder sich anderweitig Tippfehler einzuhandeln. Und auch beim kopieren von vorhandenen Pipelines vergisst man gerne einmal die Variablen anzupassen.

Danach können wir uns wieder eleganteren Dingen widmen. Denn die Datenbank muss ja noch vorbereitet werden. Dazu generieren wir erst einmal ein Passwort und speichern es im KeyVault unserer Wahl. Danach bestimmen wir ein paar Werte, von denen der wichtigste die aktuelle, externe IP ist. Die müssen wir nämlich in den Firewall-Rules hinterlegen, damit wir überhaupt zum DBMS verbinden können.
Dann laden wir noch das passende Parent-Cert runter – in unserem Fall, dass was für „Azure Database for MySQL“ passt. Das will MySQL nämlich unbedingt als Parameter mit angegeben haben, wenn die zu verbindende Datenbank SSL verlangt.

Themes, Plugins und sonstige Sonderwünsche müssen im Nachgang noch entsprechend angepasst bzw. erweitert werden.
Und damit ihr das ganze auch nachvollziehen könnt, fehlen natürlich noch die Pipeline-Definitionen. Also werfen wir einen Blick darauf.

Build-Pipelines

Fangen wir mit den einfachen Pipelines an. Vor allem, weil wir davon nur zwei Stück benötigen.

pool:
  vmImage: 'Ubuntu-20.04'

trigger:
  branches:
    include:
      - master

variables:
  initImageName: 'wp-SITENAME-init'
  imageName: 'wp-SITENAME-img'
  wpVersion: '5.6'

jobs:
  - job: buildInitImage
    steps:
      - task: [email protected]
        inputs:
          containerRegistry: 'youracr'
          repository: '$(initImageName)'
          command: 'build'
          Dockerfile: 'init/Dockerfile'
          arguments: '--progress=plain --build-arg VERSION="$(wpVersion)"'
          tags: |
            $(Build.BuildNumber)
            $(wpVersion)
            latest
    
      - task: [email protected]
        inputs:
          containerRegistry: 'youracr'
          repository: '$(initImageName)'
          command: 'push'
          tags: |
            $(Build.BuildNumber)
            $(wpVersion)
            latest

  - job: buildRuntimeImage
    dependsOn: buildInitImage
    condition: succeeded()
    steps:
      - task: [email protected]
        inputs:
          containerRegistry: 'youracr'
          repository: '$(imageName)'
          command: 'build'
          Dockerfile: 'runtime/Dockerfile'
          arguments: '--progress=plain --build-arg VERSION="$(wpVersion)"'
          tags: |
            $(Build.BuildNumber)
            $(wpVersion)
            latest
    
      - task: [email protected]
        inputs:
          containerRegistry: 'youracr'
          repository: '$(imageName)'
          command: 'push'
          tags: |
            $(Build.BuildNumber)
            $(wpVersion)
            latest
pool:
  vmImage: 'Ubuntu-18.04'

trigger:
  branches:
    include:
      - master

variables:
  imageName: 'wp-SITENAME-waf-init'

steps:
  - task: [email protected]
    inputs:
      containerRegistry: 'youracr'
      repository: '$(imageName)'
      command: 'build'
      Dockerfile: '**/Dockerfile'
      tags: |
        $(Build.BuildNumber)
        latest

  - task: [email protected]
    inputs:
      containerRegistry: 'youracr'
      repository: '$(imageName)'
      command: 'push'
      tags: |
        $(Build.BuildNumber)
        latest

Solltet ihr nicht noch zusätzliche Steps, z.B. für einen Dockerfile-Linter, benötigen werden hier nur nacheinander die Dockerimages gebaut und in die Company-Registry gepushed. Easy! (Schwieriger wird es erst wenn noch Resourcen aus anderen Repos mit rein gezogen werden sollen)

Release-Pipelines

Da die JSON-Files mit den Definitionen so groß sind, dass sie den Rahmen dieses Beitrags sprengen würden, verlinke ich hier einfach nur darauf und gehe im weiteren nur auf die interessantesten Auszüge ein.

deploy-wp.json – Zum Deployen der k8s-config im Cluster
rollout-img.json – Forcieren, dass die PODs restarted und das neue Image geladen wird
setup-db.json – Datenbank initial einrichten, ohne über die Weboberfläche eines laufenden WordPress zu gehen
update-db.json – Updaten der Datenbank bei Updates von WordPress oder den Plugins

In den verlinkten Files sind teilweise Werte drin, die mit REPLACEME_WITH_CORRECT_ beginnen.
Die müsst ihr mit Werten ersetzen, die für eure Azure-Umgebung passen. Auch bei den Variablen solltet ihr die Werte noch mit sinnvollen Defaults ersetzen!

Ein weiterer wichtiger Punkt ist bspw. dieser hier:

      "preDeployApprovals": {
        "approvals": [
          {
            "rank": 1,
            "isAutomated": true,
            "isNotificationOn": false,
            "id": 40
          }
        ],
        "approvalOptions": {
          "requiredApproverCount": null,
          "releaseCreatorCanBeApprover": false,
          "autoTriggeredAndPreviousEnvironmentApprovedCanBeSkipped": false,
          "enforceIdentityRevalidation": false,
          "timeoutInMinutes": 0,
          "executionOrder": 1
        }
      },

Man sollte meinen, wenn da eh nur sinngemäß „gibts nicht“, „brauchts nicht“ o.ä. drin steht, dann könnte man das auch weg lassen!?
Weit gefehlt! Wenn das Snippet fehlt (und für postDeployApprovals gilt das ganz analog!) dann weigert sich Azure die Pipeline zu speichern – und das lässt sich über die Weboberfläche auch nicht mehr beheben!

Auch kann es sein, dass bestimmte Tasks noch nicht in eurer Umgebung vorhanden sind.

    "taskId": "a8515ec8-7254-4ffd-912c-86772e2b5962",
    "version": "3.*",
    "name": "Replace tokens in **/secret.yaml",

Der Replace-Token Task muss bspw. aus dem Marketplace nach installiert werden.

Bei den Tasks, die ein Dockerimage laufen lassen, gibt es auch zwei Dinge zu beachten:

    "taskId": "e28912f1-0114-4464-802a-a3a35437fd16",
    "version": "1.*",
    "name": "Run an image",
    ...
    "inputs": {
        ...
        "runInBackground": "false",
        ...
    }

Zum einen geht „run image“ offenbar nur mit Version 1 des Task. Zum anderen ist die Angabe „runInBackground“ wichtig, damit er das auch wirklich laufen lässt und erst danach weiter macht.


Damit haben wir jetzt einen Stand erreicht, bei dem wir mit vertretbarem Aufwand neue WordPress-Portale hoch ziehen können.
Bei so vielen WordPress-Instanzen, stellt sich zwangsläufig die Frage: „Wie halten wir die denn mit ebenfalls vertretbarem Aufwand aktuell?“
Und genau dem Thema gehen wir im nächsten Beitrag auf den Grund!


Weiter zu Teil 6: Updates automatisieren