Introduction
Faire tourner WordPress en production sur Kubernetes avec une image de 35 Mo au lieu de 700 Mo, sans shell, sans outils superflus, avec un healthcheck FastCGI en Go pur — c'est le pari de notre stack PHP-FPM hardened. Cet article détaille l'architecture complète déployée sur notre cluster K3s, en production et en intégration.
L'objectif est triple : réduire la surface d'attaque au strict minimum (FROM scratch = zéro binaire exploitable), garantir des performances optimales via OPcache et un tuning fin de PHP-FPM, et maintenir une observabilité complète grâce aux exporters Prometheus intégrés dans chaque pod.
Architecture du Pod WordPress
Chaque instance WordPress est déployée comme un pod Kubernetes contenant 4 containers runtime et 1 initContainer :
┌─────────────────────────────────────────────────────────────────┐
│ Pod WordPress │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌────────────────────────┐ │
│ │ initContainer│ │ │ │ │ │
│ │ wordpress- │──▶│ emptyDir │◀─│ nginx-waf-hardened │ │
│ │ files:6.8.3 │ │ /var/www/ │ │ (static assets + │ │
│ └─────────────┘ │ html │ │ ModSecurity WAF) │ │
│ │ │ └────────────────────────┘ │
│ └──────┬───────┘ │
│ │ │
│ ┌────────────────────────┼──────────────────────────────────┐ │
│ │ php-fpm-hardened:8.5.3 │ │
│ │ (PHP 8.5.7, FROM scratch, 35 Mo) │ │
│ │ UID 1999, Go init, FastCGI PING/PONG │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────┐ ┌──────────────────────────────┐ │
│ │ phpfpm-exporter │ │ nginx-exporter │ │
│ │ (metrics :9253) │ │ (metrics :9113) │ │
│ └──────────────────┘ └──────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
Rôle de chaque container
- initContainer wordpress-files:6.8.3 — Image Alpine légère contenant le core WordPress 6.8.3 pré-extrait. Copie les fichiers dans le volume emptyDir partagé avant le démarrage des containers principaux.
- nginx-waf-hardened — Sert les assets statiques via
try_files, proxy les requêtes PHP vers le socket FastCGI, applique les règles ModSecurity/OWASP CRS. - php-fpm-hardened:8.5.3 — Exécute le code PHP WordPress. Image FROM scratch de 35 Mo, UID 1999, Go init.
- phpfpm-exporter — Expose les métriques PHP-FPM au format Prometheus sur le port 9253.
- nginx-exporter — Expose les métriques nginx au format Prometheus sur le port 9113.
L'image FROM scratch : php-fpm-hardened:8.5.3
Philosophie de construction
L'image suit le pattern 4 stages utilisé pour toutes nos images hardened :
- Stage builder — Compile PHP 8.5.7 et toutes ses extensions depuis
php:8.5-fpm-alpineavec les flags de hardening GCC (-D_FORTIFY_SOURCE=2,-fstack-protector-strong,-Wformat-security). - Stage prep — Assemble les binaires, bibliothèques partagées et fichiers de configuration dans une arborescence propre.
- Stage init — Compile le binaire Go d'initialisation (healthcheck FastCGI + signal forwarding).
- Stage final (FROM scratch) — Copie uniquement les artefacts nécessaires. Résultat : zéro shell, zéro package manager, zéro outil exploitable.
Extensions compilées pour WordPress
Toutes les extensions nécessaires au fonctionnement optimal de WordPress sont compilées statiquement ou liées dynamiquement :
# Extensions PHP compilées dans l'image
gd # Manipulation d'images (thumbnails, crops)
imagick # ImageMagick pour le traitement avancé
mysqli # Connexion MySQL/MariaDB
zip # Gestion des archives (plugins, thèmes)
intl # Internationalisation (ICU)
redis # Cache objet Redis
opcache # Cache bytecode PHP
exif # Métadonnées images
fileinfo # Détection MIME
mbstring # Support multi-octets
openssl # Connexions TLS
curl # Requêtes HTTP
dom/xml # Parsing XML (flux RSS, sitemaps)
sodium # Cryptographie moderne
Le binaire Go init
L'absence de shell dans une image FROM scratch pose un défi pour les healthchecks. La solution : un binaire Go compilé statiquement qui implémente le protocole FastCGI en pur Go.
// Healthcheck FastCGI PING/PONG
// Implémente le protocole FastCGI (records type 1, 4, 5)
// Envoie une requête FCGI_GET_VALUES au pool php-fpm
// Vérifie la réponse PONG sur le socket Unix
func healthcheck(socketPath string) error {
conn, err := net.Dial("unix", socketPath)
if err != nil {
return fmt.Errorf("cannot connect to %s: %w", socketPath, err)
}
defer conn.Close()
// Envoi du PING FastCGI
req := buildFCGIRequest("GET", "/ping")
conn.Write(req)
// Lecture de la réponse PONG
resp, err := readFCGIResponse(conn)
if err != nil || !bytes.Contains(resp, []byte("pong")) {
return fmt.Errorf("healthcheck failed: %v", err)
}
return nil
}
Ce binaire Go remplace complètement l'outil cgi-fcgi habituellement utilisé pour les healthchecks. Avantage : aucune dépendance, aucun shell, compilation statique avec CGO_ENABLED=0.
tini PID 1 et gestion des signaux
tini-static est le PID 1 du container. Il assure :
- Le reaping des processus zombies (php-fpm fork des workers)
- La propagation correcte des signaux SIGTERM/SIGQUIT vers php-fpm
- Un arrêt gracieux des workers en cours de traitement
L'initContainer wordpress-files
Pourquoi un initContainer séparé ?
Le problème fondamental : notre image php-fpm-hardened est FROM scratch. Elle n'a aucun shell, aucune commande cp, aucun sh -c. Il est donc impossible d'exécuter un script de copie de fichiers au démarrage.
La solution Kubernetes : un initContainer basé sur Alpine qui contient le core WordPress pré-extrait et le copie dans un volume emptyDir partagé avec les autres containers du pod.
# Extrait du Deployment
initContainers:
- name: wordpress-files
image: jbsky/wordpress-files:6.8.3
command: ["sh", "-c"]
args:
- |
cp -a /usr/src/wordpress/. /var/www/html/
chown -R 1999:1999 /var/www/html
volumeMounts:
- name: wordpress-core
mountPath: /var/www/html
securityContext:
runAsUser: 0 # root pour le chown initial
Pourquoi emptyDir est obligatoire
Le container nginx-waf doit servir les fichiers statiques WordPress (CSS, JS, images du thème) via try_files. Ces fichiers doivent être accessibles depuis le filesystem du container nginx. Le volume emptyDir est monté aux mêmes chemins dans nginx-waf et php-fpm, permettant :
- nginx sert directement les fichiers statiques (pas de proxy vers PHP)
- php-fpm accède aux fichiers PHP pour l'exécution
- Le volume est éphémère : recréé à chaque redémarrage du pod = toujours la dernière version du core
Configuration de sécurité
SecurityContext Kubernetes
securityContext:
runAsUser: 1999
runAsGroup: 1999
fsGroup: 1999
runAsNonRoot: true
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
Le UID 1999 est un choix délibéré : suffisamment élevé pour ne pas entrer en conflit avec les utilisateurs système, identique dans les images nginx-waf et php-fpm pour le partage de fichiers via emptyDir.
Hardening WordPress applicatif
Les constantes WordPress sont définies dans wp-config.php via ConfigMap :
// Désactiver l'éditeur de fichiers intégré
define('DISALLOW_FILE_EDIT', true);
// Méthode d'écriture directe (pas de FTP)
define('FS_METHOD', 'direct');
// Désactiver les mises à jour automatiques
// (géré par rebuild d'image)
define('AUTOMATIC_UPDATER_DISABLED', true);
define('WP_AUTO_UPDATE_CORE', false);
// Limiter les révisions
define('WP_POST_REVISIONS', 5);
// Forcer SSL admin
define('FORCE_SSL_ADMIN', true);
La philosophie : les mises à jour WordPress passent par un rebuild de l'image wordpress-files, pas par le mécanisme interne de WP. Cela garantit la traçabilité (tag d'image = version exacte) et la reproductibilité (même image en integ et en prod).
OPcache : configuration pour la performance
[opcache]
opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.revalidate_freq=0
opcache.validate_timestamps=0
opcache.save_comments=1
opcache.fast_shutdown=1
opcache.enable_file_override=1
opcache.jit_buffer_size=128M
opcache.jit=1255
validate_timestamps=0 est crucial en production : PHP ne vérifie jamais si les fichiers ont changé sur disque. C'est sûr car les fichiers WordPress sont immutables (copiés par l'initContainer au boot). Un redéploiement du pod = nouveau volume = nouveau cache OPcache.
Le JIT (Just-In-Time compiler) de PHP 8.5 apporte un gain mesurable sur les opérations CPU-intensives de WordPress (rendu de templates, calcul de taxonomies).
Gestion des volumes
volumes:
# Core WordPress (éphémère, recréé à chaque démarrage)
- name: wordpress-core
emptyDir: {}
# Données persistantes (thèmes, plugins, uploads)
- name: wp-content
persistentVolumeClaim:
claimName: wordpress-content-pvc
# Configuration (wp-config.php, php.ini, www.conf)
- name: config
configMap:
name: wordpress-config
# Configuration nginx
- name: nginx-config
configMap:
name: wordpress-nginx-config
La séparation est claire :
- emptyDir — Core WordPress, immuable, recréé depuis l'image à chaque restart
- PVC —
wp-content/(uploads, plugins activés, thèmes personnalisés). Survit aux redéploiements. - ConfigMap — Configuration PHP et nginx. Modifiable via
kubectl apply, prise en compte au prochain rollout.
Configuration PHP-FPM (www.conf)
[www]
user = nobody
group = nobody
listen = /var/run/php-fpm/www.sock
listen.owner = nobody
listen.group = nobody
listen.mode = 0660
pm = dynamic
pm.max_children = 20
pm.start_servers = 5
pm.min_spare_servers = 3
pm.max_spare_servers = 10
pm.max_requests = 500
; Healthcheck endpoint
ping.path = /ping
ping.response = pong
; Status page pour l'exporter Prometheus
pm.status_path = /status
; Slow log pour le profiling
request_slowlog_timeout = 5s
slowhog = /proc/self/fd/2
Métriques et comparaison
Tailles d'images
| Image | Taille | Ratio |
|---|---|---|
| wordpress:latest (officielle) | ~700 Mo | baseline |
| wordpress:fpm-alpine | ~180 Mo | x3.9 plus petite |
| php-fpm-hardened:8.5.3 | ~35 Mo | x20 plus petite |
| wordpress-files:6.8.3 | ~85 Mo | initContainer, éphémère |
Surface d'attaque
| Critère | wordpress:latest | php-fpm-hardened |
|---|---|---|
| Shell disponible | bash, sh, dash | Aucun |
| Package manager | apt-get | Aucun |
| Binaires système | ~200+ | 3 (tini, init, php-fpm) |
| CVE potentielles (OS) | Variables | 0 (pas d'OS) |
| Utilisateur | www-data (33) | 1999 (non-root) |
| Capabilities | Default Docker set | ALL dropped |
Déploiement en production et intégration
Le même ensemble d'images est utilisé en intégration et en production, déployé dans des namespaces Kubernetes séparés. La seule différence réside dans les ConfigMaps (connexion base de données, URL du site) et les PVCs (données séparées).
Le workflow de mise à jour WordPress :
- Rebuild de
wordpress-files:6.8.Xavec le nouveau core - Push sur le registry
- Déploiement en intégration, validation
- Promotion en production via mise à jour du tag d'image dans le manifest
Ce processus garantit que la version de WordPress en production est exactement celle testée en intégration — pas de surprise liée à un wp core update automatique.
Conclusion
L'approche FROM scratch pour WordPress représente un investissement initial significatif en ingénierie (compilation des extensions, init Go, initContainer), mais les bénéfices sont considérables : surface d'attaque quasi nulle, images 20x plus petites, démarrages rapides, et un modèle de mise à jour prévisible basé sur les images. C'est la preuve qu'un CMS aussi permissif que WordPress peut tourner dans un environnement durci au maximum, sans compromis sur les fonctionnalités.
Liens
- Code source : php-fpm-hardened
- Image Docker : Docker Hub
Laisser un commentaire