PHP-FPM Hardened : WordPress zero-shell FROM scratch

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 :

  1. Stage builder — Compile PHP 8.5.7 et toutes ses extensions depuis php:8.5-fpm-alpine avec les flags de hardening GCC (-D_FORTIFY_SOURCE=2, -fstack-protector-strong, -Wformat-security).
  2. Stage prep — Assemble les binaires, bibliothèques partagées et fichiers de configuration dans une arborescence propre.
  3. Stage init — Compile le binaire Go d'initialisation (healthcheck FastCGI + signal forwarding).
  4. 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
  • PVCwp-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 :

  1. Rebuild de wordpress-files:6.8.X avec le nouveau core
  2. Push sur le registry
  3. Déploiement en intégration, validation
  4. 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

Articles connexes


Commentaires

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur la façon dont les données de vos commentaires sont traitées.