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


Nachdem wir in den letzten Teilen dafür gesorgt haben, dass wir brauchbare Dockerimages zur Verfügung haben, müssen wir diese nun gescheit „verdrahten“.

Zunächst benötigen wir ein Paar Volumes

---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: wordpress-pvc
spec:
  accessModes:
    - ReadWriteMany
  resources:
    requests:
      storage: 5Gi
  storageClassName: ceph-storageclass

---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: wordpress-waf-pvc
spec:
  accessModes:
    - ReadWriteMany
  resources:
    requests:
      storage: 64Mi
  storageClassName: ceph-storageclass

Das wordpress-waf-pvc ist für die Daten von Wordfence und muss nicht sonderlich groß ausfallen. Wenn ihr keinen Ceph-Cluster als Storage-Backend benutzt, muss der storageClassName natürlich entsprechend anders ausfallen. Aber bei Azure haben wir eigentlich keine wirkliche Alternative!

WordPress Config…

Der erste Gedanke bzgl. der wp-config.php mag sein, diese auch in eine ConfigMap zu packen. Denn als Datei nutzt sie uns ja reichlich wenig.
Aber bei nochmaligem Hinsehen fällt auf, dass Passwörter, Salts und dergleichen nicht in eine ConfigMap gehören, sondern in ein Secret!
Und da wir die wp-config.php nicht aufteilen können, packen wir die komplett in ein solches:

apiVersion: v1
kind: Secret
type: Opaque
metadata:
  labels:
    app: wordpress-cloud
  name: wordpress-secret
stringData:
  wp-config.php: |
    <?php
        define('DB_NAME', 'wordpress');
        define('DB_USER', '[email protected]');
        define('DB_PASSWORD', '#{wordpress-db-password}#');
        define('DB_HOST', 'wordpress-database.mysql.database.azure.com');
        define('DB_CHARSET', 'utf8mb4');
        define('DB_COLLATE', '');
        define('MYSQL_CLIENT_FLAGS', MYSQLI_CLIENT_SSL);

        /**#@+
         * Authentication Unique Keys and Salts.
         *
         * Change these to different unique phrases!
         * You can generate these using the {@link https://api.wordpress.org/secret-key/1.1/salt/ WordPress.org secret-key service}
         * You can change these at any point in time to invalidate all existing cookies. This will force all users to have to log in again.
         *
         * @since 2.6.0
         */
         define('AUTH_KEY',         'vQ+;~n?o0UY`Z>vrZ][email protected]^{*J+qG!Ef/u7}B5ZK-y5EX+|z.&Pmr]*Ko|V|-|');
         define('SECURE_AUTH_KEY',  'tE)|4d/P+P+H:mfO0wlC:.X7),1gsVDow[b)j5omUSe#.{DBgX-~l_vR9J8IAM4B');
         define('LOGGED_IN_KEY',    'qc+90v|>9/(z O~Edn7=>x8+jNvvJE[*d*2TI[#*@Wu1ba`W7Pe+9.3PMuSk~+#J');
         define('NONCE_KEY',        '9Gle5MV B6U,7M;NsL.!7/E0%NzA7lKl`~)6}?q8no):i}v>{Nz1.mz^r-Qy;9+p');
         define('AUTH_SALT',        ')gVcZ!34]/-6wF7??%XS;7ZHZx`^eWinEk,~MYkW&P#^[email protected];4t~A+f?lQQ4UH,');
         define('SECURE_AUTH_SALT', '|K8VKy||hgf^>2xdI!_k1ENq+An)TcRByxw1^0R+CTn#YnHT*[email protected]}R5:Ms');
         define('LOGGED_IN_SALT',   'xd^+cIhyudVE82^,,I|UzY.Cmg;{N&kRr?[sH[=50X)E.M?Nm4q}{4ZM7v5;^Dg+');
         define('NONCE_SALT',       'x=e^x8Jh Ucj$P1+EIXv4G403t^E$G+7q<-SdWJ~+kU6 lP&&w=!fP.|)8<C-i+|');

        $table_prefix = 'wp_';

        define('WP_DEBUG', false);

        if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
          $_SERVER['HTTPS'] = 'on';
        }

        // WORDPRESS_CONFIG_EXTRA
        define('AUTOSAVE_INTERVAL', 86400);
        define('WP_POST_REVISIONS', false);
        define( 'DISALLOW_FILE_MODS', true );
        define( 'FORCE_SSL_ADMIN', true );
        define( 'WPLANG', 'de_DE' );
        define('DISABLE_WP_CRON', true);

        if ( ! defined( 'ABSPATH' ) ) {
                define( 'ABSPATH', __DIR__ . '/' );
        }

        require_once(ABSPATH . 'wp-settings.php');

Wenn ihr „Azure Database for MySQL“ benutzt, dann denkt daran, dass der Username immer komplett sein muss – also im Schema <DB-USER>@<MY-DB-NAME>! Und wenn das der Fall ist, dann benutzt ihr sehr wahrscheinlich auch „Azure DevOps“. Da ihr das Passwort in dem Fall ja löblicher Weise in einen KeyVault gepackt habt, könnt ihr darauf in eurer Release-Pipeline zugreifen und mittels Replace Tokens Task im secret.yaml ersetzen, bevor ihr es auf AKS deployed.
Denn Passwörter in einem git-Repo müssen nun wirklich nicht sein!

Aber kommen wir zu den restlichen Config-Angaben.
Wie schon in einem der vorherigen Teile angesprochen: sollte eure Datenbank SSL verlangen (und das ist grundsätzlich empfehlenswert) dann wird die Angabe bzgl. MYSQLI_CLIENT_SSL zwingend benötigt! Bei „Azure Database for MySQL“ ist SSL standardmäßig aktiviert.

Wie die Salts und Keys generiert werden können, steht ja schon im Inline-Kommentar. Das lässt sich natürlich automatisieren – doch dazu mehr in einem späteren Beitrag.

Wichtig sind jetzt vor allem noch die Angaben bzgl. DISALLOW_FILE_MODS und DISABLE_WP_CRON. Ersteres verhindert, dass Benutzer Plugins und Themes installieren oder deinstallieren können (aktivieren/deaktivieren geht weiterhin)! Das ist essentiell, weil diese ja Teil des Dockerimage sind und mit skalieren müssen!
Letzteres ist wichtig, weil sonst jede Instanz für sich regelmäßig Cronjobs laufen lassen würde. Das ist im besten Falle unnötige Resourcenverschwendung und im schlechtesten Fall kommen die sich gegenseitig in die Quere. Da Kubernetes eingebauten Support für Cronjobs hat, werden wir das darüber abhandeln.

…da war doch noch was mit Wordfence

Ja, so langsam werden die Scherereien mit Wordfence zum Running-Gag.
Aber hilft ja nix. Das Plugin möchte halt noch eine Datei im Hauptverzeichnis sowie Anpassungen an der .htaccess – also konfigurieren wir die auch noch:

apiVersion: v1
kind: ConfigMap
metadata:
  labels:
    app: wordpress-cloud
  name: wordpress-cm
data:
  htaccess: |
    # BEGIN WordPress
    <IfModule mod_rewrite.c>
    RewriteEngine On
    RewriteBase /
    RewriteRule ^index\.php$ - [L]
    RewriteCond %{REQUEST_FILENAME} !-f
    RewriteCond %{REQUEST_FILENAME} !-d
    RewriteRule . /index.php [L]
    </IfModule>
    # END WordPress

    # Wordfence WAF
    <IfModule mod_php5.c>
            php_value auto_prepend_file '/var/www/html/wordfence-waf.php'
    </IfModule>
    <IfModule mod_php7.c>
            php_value auto_prepend_file '/var/www/html/wordfence-waf.php'
    </IfModule>
    <Files ".user.ini">
    <IfModule mod_authz_core.c>
            Require all denied
    </IfModule>
    <IfModule !mod_authz_core.c>
            Order deny,allow
            Deny from all
    </IfModule>
    </Files>
    # END Wordfence WAF

  wordfence-waf.php: |
    <?php
    // Before removing this file, please verify the PHP ini setting `auto_prepend_file` does not point to this.

    if (file_exists('/var/www/html/wp-content/plugins/wordfence/waf/bootstrap.php')) {
            define("WFWAF_LOG_PATH", '/var/www/html/wp-content/wflogs/');
            include_once '/var/www/html/wp-content/plugins/wordfence/waf/bootstrap.php';
    }
    ?>

Als nächstes konfigurieren wir endlich das Herzstück.

Deployment

apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    app: wordpress-cloud
  name: wordpress-deploy
spec:
  replicas: 1
  selector:
    matchLabels:
      app: wordpress-cloud
      instance: webapp
  template:
    metadata:
      labels:
        app: wordpress-cloud
        instance: webapp
    spec:
      initContainers:
        - image: yourcompany.azurecr.io/wordpress-waf-init:latest
          name: wordpress-waf-init-img
          imagePullPolicy: Always
          volumeMounts:
            - mountPath: /var/www/html/wp-content/uploads
              name: user-upload-storage
            - mountPath: /var/www/html/wp-content/wflogs
              name: waf-storage
      containers:
        - image: yourcompany.azurecr.io/wordpress-img:latest
          imagePullPolicy: Always
          name: wordpress-img
          ports:
            - containerPort: 80
          resources:
            limits:
              cpu: 500m
              memory: 512Mi
            requests:
              cpu: 350m
              memory: 256Mi
          volumeMounts:
            - mountPath: /var/www/html/wp-config.php
              name: config-volume
              subPath: wp-config.php
            - mountPath: /var/www/html/wp-content/uploads
              name: user-upload-storage
            - mountPath: /var/www/html/wp-content/wflogs
              name: waf-storage
            - mountPath: /var/www/html/.htaccess
              name: additional-config-volume
              subPath: htaccess
            - mountPath: /var/www/html/wordfence-waf.php
              name: additional-config-volume
              subPath: wordfence-waf.php
      volumes:
        - name: config-volume
          secret:
            secretName: wordpress-secret
        - name: additional-config-volume
          configMap:
            name: wordpress-cm
        - name: user-upload-storage
          persistentVolumeClaim:
            claimName: wordpress-pvc
        - name: waf-storage
          persistentVolumeClaim:
            claimName: wordpress-waf-pvc

Wichtig und in einem früheren Beitrag ebenfalls bereits angesprochen, ist initContainers. Dadurch ist sicher gestellt, dass die Wordfence-Files initial einmal platziert werden und ein „chown“ auf die Storage-Volumes gemacht wird, damit mounts nicht „root“ gehören – sonst hat es das WordPress schwer dort Datei abzulegen 😉

Storage-Volumes ist auch ein gutes Stichwort, denn die binden wir hier alle an der richtigen Stelle ein, zusammen mit den Einträgen aus dem Secret und der ConfigMap.

Die Resource-Limits setzen wir recht konservativ, denn wir wollen ja im Idle-Betrieb möglichst wenig verbrauchen und wenn nötig lieber die PODs hoch skalieren. Beim Speicher müssen wir im Limit aber etwas großzügiger sein, da der RAM-Verbrauch meist deutlich schneller nach oben geht, als CPU benötigt wird.

Cronjobs

Weiter oben haben wir ja in der Config verhindert, dass WordPress von sich aus Cronjobs startet.
Entsprechend müssen wir das jetzt manuell einrichten:

apiVersion: batch/v1beta1
kind: CronJob
metadata:
  labels:
    app: wordpress-cloud
  name: wordpress-cronjob
spec:
  schedule: "0 * * * *"
  concurrencyPolicy: Forbid
  jobTemplate:
    spec:
      template:
        spec:
          containers:
            - name: wordpress-img
              image: yourcompany.azurecr.io/wordpress-img:latest
              imagePullPolicy: Always
              command: ["/usr/local/bin/php"]
              args:
                - /usr/src/wordpress/wp-cron.php
              volumeMounts:
                - mountPath: /usr/src/wordpress/wp-config.php
                  name: config-volume
                  subPath: wp-config.php
                - mountPath: /usr/src/wordpress/wp-content/wflogs
                  name: waf-storage
          restartPolicy: OnFailure
          volumes:
            - name: config-volume
              secret:
                secretName: wordpress-secret
            - name: waf-storage
              persistentVolumeClaim:
                claimName: wordpress-waf-pvc

Wir können die Cron-Implementierung von WordPress direkt aus dessen Sourcen aufrufen – die Pfade für die Mounts müssen aber entsprechend angepasst werden.

Ein Nachteil von Kubernetes ist, dass „schedule“ sich nur exakt konfigurieren lässt. Wenn also mehrere verschiedene WordPress-Installationen im Einsatz sind, die natürlich alle ihre eigenen Cronjobs haben, dann muss „schedule“ für alle manuell anders eingestellt sein, oder alle laufen zur gleichen Zeit los.
Es gibt einen offenen Bug-Report/Feature-Request, um das flexibler zu gestalten (wie bspw. in Jenkins): https://github.com/kubernetes/kubernetes/issues/91652

Und der Rest…

Was verbleibt, ist eigentlich recht straight-forward:

ein Service

apiVersion: v1
kind: Service
metadata:
  labels:
    app: wordpress-cloud
  name: wordpress-service
spec:
  type: ClusterIP
  ports:
    - port: 80
      name: wordpress-port
  sessionAffinity: ClientIP
  selector:
    app: wordpress-cloud
    instance: webapp

ein HPA

apiVersion: autoscaling/v1
kind: HorizontalPodAutoscaler
metadata:
  labels:
    app: wordpress-cloud
  name: wordpress-autoscaler
spec:
  maxReplicas: 3
  minReplicas: 1
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: wordpress-deploy
  targetCPUUtilizationPercentage: 100 # as percentage of _requested_ CPU - not limit!

Der ist natürlich recht rudimentär, und kann bei Bedarf noch erweitert werden.

ein Ingress

apiVersion: networking.k8s.io/v1beta1
kind: Ingress
metadata:
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
    kubernetes.io/ingress.class: nginx
    nginx.ingress.kubernetes.io/affinity: cookie
    nginx.ingress.kubernetes.io/affinity-mode: persistent
    nginx.ingress.kubernetes.io/session-cookie-hash: sha1
    nginx.ingress.kubernetes.io/session-cookie-name: wordpress-cookie
    nginx.ingress.kubernetes.io/proxy-body-size: 42m
  name: wordpress-ingress
spec:
  rules:
    - host: wordpress.your-domain.tld
      http:
        paths:
          - backend:
              serviceName: wordpress-service
              servicePort: 80
  tls:
    - hosts:
        - wordpress.your-domain.tld
      secretName: wordpress-ingress-cert
status:
  loadBalancer:
    ingress:
      - {}

Die Sessionaffinity sollte beim Hantieren im Admin-Interface helfen, wenn mehrere PODs oben sind.
Und nicht vergessen die proxy-body-size anzupassen, falls das erforderlich sein sollte.


Aber ansonsten ist die Kubernetes-Config damit erst einmal vollständig und das WordPress in Kubernetes grundsätzlich lauffähig!

Wenn es allerdings darum geht, mehr als nur ein WordPress in K8s zu betreiben, dann ist es sowohl lästig als auch fehleranfällig, die ganzen Schritte immer manuell zu wiederholen.
Einen Ansatz, um da Abhilfe zu schaffen, schauen wir uns im nächsten Beitrag an.


Weiter zu Teil 5: Installation automatisieren