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
Comments by Martin Drößler