Si vous avez déjà vu un pic de CPU à 02:17, suivi d’un 502 Bad Gateway nginx, puis plus rien d’exploitable dans vos logs, vous avez déjà ressenti le manque de métriques “corrélables”. Le but ici n’est pas d’empiler des graphes, mais de pouvoir répondre en 2 minutes à : “qu’est-ce qui a cassé, quand, et pourquoi WordPress 6.9.4 s’est mis à ramer ?”

Le besoin / Le problème serveur

Sur un serveur WordPress (6.9.4 en avril 2026) vous avez trois couches qui se renvoient la balle quand ça va mal : le système (CPU/RAM/disk I/O), le web (nginx/Apache), et l’app (PHP-FPM + MySQL/MariaDB + WordPress). Les logs seuls ne suffisent pas : ils sont verbeux, parfois incomplets, et surtout non agrégés.

À la fin, vous saurez :

  • collecter des métriques système, nginx, PHP-FPM et MySQL via Prometheus,
  • exposer des métriques WordPress ciblées (cron, requêtes, erreurs, cache) via un MU-plugin,
  • déclencher des alertes automatiques (Alertmanager) avec anti-bruit (inhibition, fenêtres, seuils pertinents),
  • corréler un incident WordPress avec des graphiques Grafana utiles (pas décoratifs),
  • déployer en staging puis promouvoir en production sans “monitoring cassé”.

Résumé rapide

  • Prometheus scrape : node_exporter + nginx exporter + php-fpm exporter + mysqld exporter + endpoint WordPress dédié.
  • WordPress : MU-plugin qui expose /metrics (ou /?bpcab_metrics=1) avec auth (token + allowlist IP) et métriques “actionnables”.
  • Alerting : règles Prometheus + Alertmanager (mail/Slack) avec inhibition (éviter “tout est rouge”).
  • Grafana : dashboards orientés symptômes (latence, 5xx, saturation FPM, DB slow, WP-Cron en retard).
  • Sécurité : endpoints métriques derrière firewall / basic auth / tokens, pas de fuite d’infos (versions, chemins, usernames).

Avant de commencer (prérequis)

Accès requis :

  • SSH root ou sudo (installation services, systemd, firewall),
  • WP-CLI sur le serveur (ou via SSH) pour valider WordPress,
  • accès à la conf web (nginx/Apache) + PHP-FPM,
  • accès MySQL (socket local ou user dédié).

Sauvegarde obligatoire (ne testez pas l’alerting en prod sans filet) :

  • snapshot VM ou image disque, ou à minima backup /etc + /var/lib des services,
  • dump DB avant toute création d’utilisateur / changement de conf :
# Dump rapide (à adapter) — conservez-le hors du serveur si possible
mysqldump --single-transaction --routines --triggers 
  -u root -p --all-databases | gzip > /root/backup-all-db-$(date +%F).sql.gz

Versions cibles :

  • WordPress : 6.9.4 (stable actuelle),
  • PHP : 8.1+ (8.2/8.3 très courant en 2026, adaptez vos exporters),
  • MySQL 8.x ou MariaDB 10.6+ (les métriques diffèrent légèrement),
  • nginx (recommandé ici) ou Apache (possible, mais exemples nginx fournis).

Hypothèse d’architecture : un serveur “monolithique” (WP + DB + monitoring). En production sérieuse, je sépare souvent Prometheus/Grafana sur une VM dédiée. Les configs restent identiques, seuls les endpoints changent.

Étape 1 : Installer Prometheus + exporters (node, nginx, php-fpm, mysql)

Je pars sur Debian/Ubuntu avec systemd. Sur RHEL/Alma, le principe est identique (packages et paths changent). L’idée : un exporter = une surface de métriques.

1) Créer un utilisateur système et dossiers

# Utilisateurs dédiés (bonnes pratiques)
sudo useradd --system --no-create-home --shell /usr/sbin/nologin prometheus || true
sudo useradd --system --no-create-home --shell /usr/sbin/nologin node_exporter || true

# Dossiers
sudo mkdir -p /etc/prometheus /var/lib/prometheus
sudo chown -R prometheus:prometheus /etc/prometheus /var/lib/prometheus

2) Installer Prometheus (binaire officiel) + service systemd

Les dépôts distro sont souvent en retard. Pour du monitoring, je préfère les releases officielles.

# Vérifiez la dernière version sur GitHub (exemple : 2.x)
# https://github.com/prometheus/prometheus/releases
PROM_VERSION="2.55.1"
cd /tmp
curl -fsSLO "https://github.com/prometheus/prometheus/releases/download/v${PROM_VERSION}/prometheus-${PROM_VERSION}.linux-amd64.tar.gz"
tar -xzf "prometheus-${PROM_VERSION}.linux-amd64.tar.gz"
sudo cp "prometheus-${PROM_VERSION}.linux-amd64/prometheus" /usr/local/bin/
sudo cp "prometheus-${PROM_VERSION}.linux-amd64/promtool" /usr/local/bin/
sudo mkdir -p /etc/prometheus/consoles /etc/prometheus/console_libraries
sudo cp -r "prometheus-${PROM_VERSION}.linux-amd64/consoles/"* /etc/prometheus/consoles/
sudo cp -r "prometheus-${PROM_VERSION}.linux-amd64/console_libraries/"* /etc/prometheus/console_libraries/
sudo chown -R prometheus:prometheus /etc/prometheus
sudo chmod 0755 /usr/local/bin/prometheus /usr/local/bin/promtool

3) node_exporter (métriques OS)

# https://github.com/prometheus/node_exporter/releases
NODE_VERSION="1.9.0"
cd /tmp
curl -fsSLO "https://github.com/prometheus/node_exporter/releases/download/v${NODE_VERSION}/node_exporter-${NODE_VERSION}.linux-amd64.tar.gz"
tar -xzf "node_exporter-${NODE_VERSION}.linux-amd64.tar.gz"
sudo cp "node_exporter-${NODE_VERSION}.linux-amd64/node_exporter" /usr/local/bin/
sudo chown node_exporter:node_exporter /usr/local/bin/node_exporter
sudo chmod 0755 /usr/local/bin/node_exporter

4) nginx exporter (métriques HTTP côté nginx)

Le point clé : activer stub_status en local, puis laisser l’exporter le convertir en métriques Prometheus.

# https://github.com/nginxinc/nginx-prometheus-exporter/releases
NGINX_EXP_VERSION="1.3.0"
cd /tmp
curl -fsSLO "https://github.com/nginxinc/nginx-prometheus-exporter/releases/download/v${NGINX_EXP_VERSION}/nginx-prometheus-exporter_${NGINX_EXP_VERSION}_linux_amd64.tar.gz"
tar -xzf "nginx-prometheus-exporter_${NGINX_EXP_VERSION}_linux_amd64.tar.gz"
sudo cp nginx-prometheus-exporter /usr/local/bin/nginx-prometheus-exporter
sudo chmod 0755 /usr/local/bin/nginx-prometheus-exporter

5) PHP-FPM exporter (saturation workers, queue, latence)

J’ai souvent vu des sites “lents” alors que le CPU est OK : en réalité FPM est saturé (pm.max_children trop bas, ou requêtes bloquées). Il faut activer pm.status_path.

Exporters possibles : plusieurs existent. Je donne un exemple avec un exporter PHP-FPM compatible “status page”. Adaptez la release selon votre choix (l’idée reste identique : lire /status et exposer /metrics).

# Exemple avec hipages/php-fpm_exporter (vérifiez la release actuelle)
# https://github.com/hipages/php-fpm_exporter/releases
FPM_EXP_VERSION="2.3.0"
cd /tmp
curl -fsSLO "https://github.com/hipages/php-fpm_exporter/releases/download/v${FPM_EXP_VERSION}/php-fpm_exporter_${FPM_EXP_VERSION}_linux_amd64.tar.gz"
tar -xzf "php-fpm_exporter_${FPM_EXP_VERSION}_linux_amd64.tar.gz"
sudo cp php-fpm_exporter /usr/local/bin/php-fpm_exporter
sudo chmod 0755 /usr/local/bin/php-fpm_exporter

6) MySQL exporter (DB)

# https://github.com/prometheus/mysqld_exporter/releases
MYSQL_EXP_VERSION="0.16.0"
cd /tmp
curl -fsSLO "https://github.com/prometheus/mysqld_exporter/releases/download/v${MYSQL_EXP_VERSION}/mysqld_exporter-${MYSQL_EXP_VERSION}.linux-amd64.tar.gz"
tar -xzf "mysqld_exporter-${MYSQL_EXP_VERSION}.linux-amd64.tar.gz"
sudo cp "mysqld_exporter-${MYSQL_EXP_VERSION}.linux-amd64/mysqld_exporter" /usr/local/bin/
sudo chmod 0755 /usr/local/bin/mysqld_exporter

7) Utilisateur MySQL dédié (lecture métriques)

Ne mettez pas root dans un exporter. Le moindre leak de .cnf devient critique.

sudo mysql -uroot -p <<'SQL'
CREATE USER IF NOT EXISTS 'exporter'@'localhost' IDENTIFIED BY 'CHANGEZ_MOI_MOT_DE_PASSE_LONG';
GRANT PROCESS, REPLICATION CLIENT, SELECT ON *.* TO 'exporter'@'localhost';
FLUSH PRIVILEGES;
SQL

# Fichier de creds lisible uniquement par root (mysqld_exporter le lit)
sudo install -m 0600 -o root -g root /dev/null /etc/mysqld_exporter.cnf
sudo bash -c 'cat > /etc/mysqld_exporter.cnf' <<'EOF'
[client]
user=exporter
password=CHANGEZ_MOI_MOT_DE_PASSE_LONG
host=127.0.0.1
EOF

Étape 2 : Exposer des métriques WordPress (plugin MU + endpoint sécurisé)

Les métriques système ne suffisent pas. Vous voulez des signaux “WordPress” : erreurs PHP visibles, WP-Cron qui dérive, taux de réponses non cache, latence côté WP.

Je déconseille de rendre /metrics public. Même sans données perso, vous exposez des infos d’architecture (plugins, charge, timings). Je combine généralement :

  • allowlist IP (Prometheus uniquement),
  • token (header) pour éviter un scrape accidentel,
  • désactivation sur front si besoin (ne pas impacter cache/page builder).

1) MU-plugin : endpoint de métriques

Créez wp-content/mu-plugins/bpcab-prom-metrics.php. MU-plugin = chargé tôt et fiable (moins de risques de désactivation “par erreur”).

<?php
/**
 * Plugin Name: BPCAB Prometheus Metrics (MU)
 * Description: Expose un endpoint Prometheus pour WordPress 6.9.4+ (sécurisé par IP + token).
 * Author: BPCAB
 * Version: 1.0.0
 *
 * Sécurité : ce fichier doit être déployé par SSH/CI, pas collé dans un plugin de snippets.
 */

if (!defined('ABSPATH')) {
	exit;
}

/**
 * Configuration minimale via constantes (à définir dans wp-config.php).
 *
 * BPCAB_METRICS_TOKEN : token long (au moins 32+ chars) envoyé en header.
 * BPCAB_METRICS_ALLOW_IPS : liste CSV d'IPs autorisées (ex: "127.0.0.1,10.0.0.12").
 */
function bpcab_metrics_is_allowed(): bool {
	$allow_csv = defined('BPCAB_METRICS_ALLOW_IPS') ? (string) BPCAB_METRICS_ALLOW_IPS : '127.0.0.1';
	$allowed = array_filter(array_map('trim', explode(',', $allow_csv)));

	$remote_ip = $_SERVER['REMOTE_ADDR'] ?? '';
	if ($remote_ip === '' || !in_array($remote_ip, $allowed, true)) {
		return false;
	}

	$expected = defined('BPCAB_METRICS_TOKEN') ? (string) BPCAB_METRICS_TOKEN : '';
	if ($expected === '') {
		// Si vous oubliez le token, on refuse par défaut.
		return false;
	}

	// Header custom : X-Prometheus-Token
	$got = $_SERVER['HTTP_X_PROMETHEUS_TOKEN'] ?? '';
	return hash_equals($expected, (string) $got);
}

/**
 * Endpoint : /?bpcab_metrics=1
 * Avantage : ne dépend pas de rewrite, marche sur nginx/Apache.
 * Inconvénient : si vous avez des caches agressifs, forcez no-cache ci-dessous.
 */
function bpcab_metrics_maybe_output() : void {
	if (!isset($_GET['bpcab_metrics'])) {
		return;
	}
	if (!bpcab_metrics_is_allowed()) {
		status_header(403);
		header('Content-Type: text/plain; charset=utf-8');
		echo "forbiddenn";
		exit;
	}

	// On évite les effets de bord (caches, compression, etc.)
	if (!headers_sent()) {
		header('Content-Type: text/plain; version=0.0.4; charset=utf-8');
		header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
		header('Pragma: no-cache');
	}

	$start = microtime(true);

	// Métriques simples et actionnables (évitez d'exposer des URLs, usernames, etc.)
	$metrics = [];

	// 1) Version WP (label) — attention à ne pas multiplier les labels (cardinalité faible)
	$wp_version = get_bloginfo('version');
	$metrics[] = '# HELP wp_info Informations WordPress (labels: version).';
	$metrics[] = '# TYPE wp_info gauge';
	$metrics[] = 'wp_info{version="' . esc_attr($wp_version) . '"} 1';

	// 2) WP-Cron : timestamp du prochain événement (si DISABLE_WP_CRON, vous surveillerez plutôt le cron système)
	$next = wp_next_scheduled('wp_version_check');
	$metrics[] = '# HELP wp_next_core_check_timestamp Prochain check coeur WordPress (epoch).';
	$metrics[] = '# TYPE wp_next_core_check_timestamp gauge';
	$metrics[] = 'wp_next_core_check_timestamp ' . (is_int($next) ? $next : 0);

	// 3) Erreurs PHP visibles via error_get_last() (signal faible, mais utile)
	$last = error_get_last();
	$metrics[] = '# HELP wp_php_last_error_present 1 si une erreur PHP est présente dans error_get_last().';
	$metrics[] = '# TYPE wp_php_last_error_present gauge';
	$metrics[] = 'wp_php_last_error_present ' . (!empty($last) ? 1 : 0);

	// 4) Santé DB : latence d'un SELECT trivial (ms)
	global $wpdb;
	$t0 = microtime(true);
	$wpdb->get_var('SELECT 1');
	$db_ms = (microtime(true) - $t0) * 1000;
	$metrics[] = '# HELP wp_db_select1_ms Latence SELECT 1 (ms).';
	$metrics[] = '# TYPE wp_db_select1_ms gauge';
	$metrics[] = 'wp_db_select1_ms ' . round($db_ms, 3);

	// 5) Object cache (si présent)
	$has_oc = wp_using_ext_object_cache() ? 1 : 0;
	$metrics[] = '# HELP wp_ext_object_cache_enabled 1 si un object-cache externe est actif (Redis/Memcached).';
	$metrics[] = '# TYPE wp_ext_object_cache_enabled gauge';
	$metrics[] = 'wp_ext_object_cache_enabled ' . $has_oc;

	// 6) Temps total génération endpoint (ms) — utile pour détecter un effet de bord
	$total_ms = (microtime(true) - $start) * 1000;
	$metrics[] = '# HELP wp_metrics_endpoint_ms Temps de génération des métriques (ms).';
	$metrics[] = '# TYPE wp_metrics_endpoint_ms gauge';
	$metrics[] = 'wp_metrics_endpoint_ms ' . round($total_ms, 3);

	echo implode("n", $metrics) . "n";
	exit;
}
add_action('init', 'bpcab_metrics_maybe_output', 0);

2) Définir le token + allowlist dans wp-config.php

Faites-le côté serveur, pas en base. Un token exposé en DB fuite souvent via backups/restores.

<?php
// wp-config.php (extrait)

// Token long, unique, non réutilisé ailleurs.
// Ne le commitez pas en clair si votre repo est partagé.
define('BPCAB_METRICS_TOKEN', 'COLLEZ_UN_TOKEN_64CHARS_MINIMUM_GENERE_PAR_OPENSSL');

// IP(s) Prometheus (ou localhost si Prometheus est sur la même machine)
define('BPCAB_METRICS_ALLOW_IPS', '127.0.0.1,10.0.0.50');

Générer un token propre :

openssl rand -hex 32

Étape 3 : Configurer Prometheus (scrape, labels, règles)

Le piège classique : vous mettez tout dans un seul job, puis vous ne savez plus “quel site” ou “quelle couche” a déclenché l’alerte. Ajoutez des labels stables (instance, role, site) sans exploser la cardinalité.

1) Fichier prometheus.yml (scrape configs)

On va scraper :

  • node_exporter : :9100
  • nginx exporter : :9113 (souvent)
  • php-fpm exporter : :9253 (selon exporter)
  • mysqld exporter : :9104
  • WordPress endpoint : via nginx location interne (recommandé) ou direct

Je recommande de proxyfier l’endpoint WP via nginx en local, pour injecter le header token côté serveur (Prometheus n’a pas besoin de connaître le token). Ça évite aussi qu’un collègue copie-colle l’URL et se prenne un 403 incompréhensible.

2) Règles d’alerting (PromQL) orientées incidents WordPress

Des alertes utiles :

  • saturation mémoire / disque,
  • hausse 5xx nginx,
  • FPM “max children reached”,
  • DB trop lente,
  • endpoint WP metrics qui devient lent (souvent signe de DB ou autoload options).

Étape 4 : Alerting automatique avec Alertmanager (mail/Slack) + anti-bruit

J’ai souvent vu des setups où chaque symptôme envoie une alerte, ce qui finit en “mute permanent”. L’anti-bruit se fait à deux niveaux :

  • dans Prometheus : for: (éviter les spikes), seuils réalistes,
  • dans Alertmanager : grouping, inhibit rules (si le serveur est down, inutile d’alerter “nginx 5xx”).

Grouping et inhibition

Exemple : si InstanceDown est en firing, inhibez NginxHigh5xx, PhpFpmSaturated, etc.

Étape 5 : Grafana (datasource, dashboards, panels utiles WordPress)

Grafana sert à réduire le temps de diagnostic. Je vise des dashboards par “symptôme” :

  • Latence perçue : p95/p99, temps de réponse nginx,
  • Erreurs : 4xx/5xx, upstream errors,
  • Capacité : CPU steal (VM), RAM, disk IO wait,
  • PHP-FPM : active/idle, queue, max children reached,
  • MySQL : threads_running, slow queries, buffer pool,
  • WordPress : db_select1_ms, endpoint_ms, object cache actif, cron drift (selon métriques).

PromQL de base (exemples de panels)

# CPU (utilisation non idle)
100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)

# RAM dispo (%)
(node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100

# Nginx 5xx rate (selon métriques exporter/logs)
# Si vous utilisez nginx-prometheus-exporter basé sur stub_status, vous n'avez pas les codes HTTP.
# Pour les codes, il faut souvent un exporter basé sur logs (ex: mtail) ou NGINX Plus.
# Ici, on suppose des métriques http_requests_total{status="500"} (à adapter).
sum by (instance) (rate(http_requests_total{status=~"5.."}[5m]))

# PHP-FPM saturation (exemple générique, dépend de l'exporter)
phpfpm_active_processes / phpfpm_max_children * 100

# MySQL threads running
mysql_global_status_threads_running

# WordPress DB ping (ms)
wp_db_select1_ms

Note franche : nginx stub_status ne donne pas les codes HTTP. Si votre priorité est le 5xx rate, vous avez trois options réalistes :

  • NGINX Plus (métriques riches),
  • instrumenter via logs (mtail, vector + exporter, ou Loki + alerting Grafana),
  • exposer le 5xx côté app (WordPress/PHP) via logs structurés (plus complexe).

Dans la pratique, sur des stacks WordPress, je combine Prometheus pour infra + Loki pour logs nginx/PHP. Ici je reste volontairement sur Prometheus/Alertmanager, mais gardez cette limite en tête.

Étape 6 : Déploiement staging → production et rollback

Ne déployez pas le MU-plugin métriques directement en prod sans test. Le risque principal : vous déclenchez du code trop tôt (hook inadapté) et vous cassez une route, ou vous introduisez une latence DB supplémentaire.

Stratégie simple

  • staging : activer endpoint + scrape toutes les 30s,
  • vérifier cardinalité et perf,
  • prod : activer scrape toutes les 15s si nécessaire, mais évitez 1s (inutile et coûteux),
  • rollback : supprimer le MU-plugin (ou le renommer) + reload nginx + reload Prometheus.

WP-CLI pour valider côté WordPress

# Vérifier version et santé
wp core version
wp core verify-checksums

# Vérifier que MU-plugins est bien chargé
wp mu-plugin list

Fichiers de configuration complets

Cette section est volontairement “copier-coller”. Adaptez les ports si vous avez déjà des services.

1) nginx : stub_status + proxy endpoint WordPress metrics

Exemple de server block (extrait). Placez-le dans votre vhost (ou un include). Le point clé : stub_status en local et endpoint /metrics/wp qui ajoute le header token.

# /etc/nginx/snippets/monitoring.conf
# À inclure dans votre server { } WordPress

# 1) Nginx stub_status (local uniquement)
location = /nginx_status {
	stub_status;
	allow 127.0.0.1;
	deny all;
}

# 2) Proxy local vers WordPress metrics
# On évite d'exposer le token à Prometheus (il scrape localhost).
location = /metrics/wp {
	allow 127.0.0.1;
	deny all;

	# Votre site WordPress doit répondre en local (fastcgi/php).
	# Ici on passe par HTTP local (si vous avez un upstream local).
	# Si votre WordPress n'écoute pas en HTTP local, adaptez en proxy_pass vers 127.0.0.1:8080 par ex.
	proxy_set_header X-Prometheus-Token "COLLEZ_ICI_LE_MEME_TOKEN_QUE_WP-CONFIG";
	proxy_set_header Host $host;
	proxy_pass http://127.0.0.1/?bpcab_metrics=1;

	# Important : pas de cache
	add_header Cache-Control "no-store";
}

Activez l’include dans votre vhost :

# Exemple vhost : /etc/nginx/sites-available/example.conf
# server { ... include /etc/nginx/snippets/monitoring.conf; ... }

sudo nginx -t
sudo systemctl reload nginx

2) PHP-FPM : activer status path

Exemple pool www. Sur Debian, c’est souvent /etc/php/8.2/fpm/pool.d/www.conf. Ajustez la version.

# Éditez le pool FPM
sudo sed -i 's|^;pm.status_path =.*|pm.status_path = /fpm-status|g' /etc/php/8.2/fpm/pool.d/www.conf

# Optionnel : ping path pour vérifier que FPM répond
grep -q '^ping.path' /etc/php/8.2/fpm/pool.d/www.conf || echo 'ping.path = /fpm-ping' | sudo tee -a /etc/php/8.2/fpm/pool.d/www.conf

sudo systemctl restart php8.2-fpm

Exposez /fpm-status uniquement en local via nginx :

# /etc/nginx/snippets/php-fpm-status.conf
location ~ ^/(fpm-status|fpm-ping)$ {
	allow 127.0.0.1;
	deny all;

	include fastcgi_params;
	fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
	fastcgi_pass unix:/run/php/php8.2-fpm.sock;
}

3) systemd : services exporters + Prometheus

node_exporter.service

sudo bash -c 'cat > /etc/systemd/system/node_exporter.service' <<'EOF'
[Unit]
Description=Prometheus Node Exporter
Wants=network-online.target
After=network-online.target

[Service]
User=node_exporter
Group=node_exporter
Type=simple
ExecStart=/usr/local/bin/node_exporter 
  --web.listen-address=127.0.0.1:9100
Restart=on-failure
RestartSec=2s

# Sécurité systemd (durcissement basique)
NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectSystem=strict

[Install]
WantedBy=multi-user.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable --now node_exporter

nginx-prometheus-exporter.service

sudo bash -c 'cat > /etc/systemd/system/nginx-prometheus-exporter.service' <<'EOF'
[Unit]
Description=Nginx Prometheus Exporter
Wants=network-online.target
After=network-online.target nginx.service

[Service]
Type=simple
ExecStart=/usr/local/bin/nginx-prometheus-exporter 
  -nginx.scrape-uri http://127.0.0.1/nginx_status 
  -web.listen-address 127.0.0.1:9113
Restart=on-failure
RestartSec=2s

NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectSystem=strict

[Install]
WantedBy=multi-user.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable --now nginx-prometheus-exporter

php-fpm_exporter.service

sudo bash -c 'cat > /etc/systemd/system/php-fpm_exporter.service' <<'EOF'
[Unit]
Description=PHP-FPM Prometheus Exporter
Wants=network-online.target
After=network-online.target php8.2-fpm.service nginx.service

[Service]
Type=simple
# L'exporter lit la page status en local
ExecStart=/usr/local/bin/php-fpm_exporter 
  --phpfpm.scrape-uri "http://127.0.0.1/fpm-status?full" 
  --web.listen-address 127.0.0.1:9253
Restart=on-failure
RestartSec=2s

NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectSystem=strict

[Install]
WantedBy=multi-user.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable --now php-fpm_exporter

mysqld_exporter.service

sudo bash -c 'cat > /etc/systemd/system/mysqld_exporter.service' <<'EOF'
[Unit]
Description=Prometheus MySQL Exporter
Wants=network-online.target
After=network-online.target mysql.service

[Service]
Type=simple
Environment="DATA_SOURCE_NAME=exporter:CHANGEZ_MOI_MOT_DE_PASSE_LONG@(127.0.0.1:3306)/"
ExecStart=/usr/local/bin/mysqld_exporter 
  --web.listen-address=127.0.0.1:9104 
  --config.my-cnf=/etc/mysqld_exporter.cnf
Restart=on-failure
RestartSec=2s

NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectSystem=strict

[Install]
WantedBy=multi-user.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable --now mysqld_exporter

prometheus.service

sudo bash -c 'cat > /etc/systemd/system/prometheus.service' <<'EOF'
[Unit]
Description=Prometheus
Wants=network-online.target
After=network-online.target

[Service]
User=prometheus
Group=prometheus
Type=simple
ExecStart=/usr/local/bin/prometheus 
  --config.file=/etc/prometheus/prometheus.yml 
  --storage.tsdb.path=/var/lib/prometheus 
  --web.listen-address=127.0.0.1:9090 
  --web.enable-lifecycle
Restart=on-failure
RestartSec=2s

NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectSystem=strict
ReadWritePaths=/var/lib/prometheus

[Install]
WantedBy=multi-user.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable --now prometheus

4) Prometheus : config + règles

sudo bash -c 'cat > /etc/prometheus/prometheus.yml' <<'EOF'
global:
  scrape_interval: 15s
  evaluation_interval: 15s

rule_files:
  - /etc/prometheus/rules/wordpress.rules.yml

scrape_configs:
  - job_name: "node"
    static_configs:
      - targets: ["127.0.0.1:9100"]
        labels:
          role: "server"
          site: "example"

  - job_name: "nginx"
    static_configs:
      - targets: ["127.0.0.1:9113"]
        labels:
          role: "web"
          site: "example"

  - job_name: "php-fpm"
    static_configs:
      - targets: ["127.0.0.1:9253"]
        labels:
          role: "app"
          site: "example"

  - job_name: "mysql"
    static_configs:
      - targets: ["127.0.0.1:9104"]
        labels:
          role: "db"
          site: "example"

  - job_name: "wordpress"
    metrics_path: /metrics/wp
    static_configs:
      - targets: ["127.0.0.1"]
        labels:
          role: "wp"
          site: "example"
EOF

sudo mkdir -p /etc/prometheus/rules
sudo chown -R prometheus:prometheus /etc/prometheus
sudo promtool check config /etc/prometheus/prometheus.yml

Règles (exemple) :

sudo bash -c 'cat > /etc/prometheus/rules/wordpress.rules.yml' <<'EOF'
groups:
  - name: wordpress-server-alerts
    rules:
      - alert: InstanceDown
        expr: up == 0
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "Target Prometheus down ({{ $labels.job }})"
          description: "Le scrape échoue depuis 2 minutes sur {{ $labels.instance }} (job={{ $labels.job }}, site={{ $labels.site }})."

      - alert: LowDiskSpace
        expr: (node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"} / node_filesystem_size_bytes{fstype!~"tmpfs|overlay"}) < 0.10
        for: 10m
        labels:
          severity: warning
        annotations:
          summary: "Espace disque faible (<10%)"
          description: "Risque d'échec MySQL / logs. Instance={{ $labels.instance }} mount={{ $labels.mountpoint }}."

      - alert: HighLoad15m
        expr: node_load15 > (count by (instance) (node_cpu_seconds_total{mode="system"}) * 1.5)
        for: 15m
        labels:
          severity: warning
        annotations:
          summary: "Load élevé"
          description: "Load15 élevé vs CPU. Possible saturation PHP-FPM, backups, ou attaques."

  - name: wordpress-app-alerts
    rules:
      - alert: WordPressDbSelectSlow
        expr: wp_db_select1_ms > 50
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "DB lente (SELECT 1 > 50ms)"
          description: "Latence DB anormale sur {{ $labels.site }}. Vérifiez MySQL (IO, locks, slow queries)."

      - alert: WordPressMetricsEndpointSlow
        expr: wp_metrics_endpoint_ms > 200
        for: 10m
        labels:
          severity: warning
        annotations:
          summary: "Endpoint métriques WP lent (>200ms)"
          description: "Souvent corrélé à DB lente, autoload options volumineuses, ou saturation FPM."

      - alert: PhpFpmSaturated
        # Cette métrique dépend de l'exporter. Adaptez à vos noms réels.
        expr: (phpfpm_active_processes / phpfpm_max_children) > 0.90
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "PHP-FPM saturé (>90%)"
          description: "Augmentez pm.max_children, optimisez les requêtes, ou ajoutez du cache. Site={{ $labels.site }}."
EOF

sudo chown -R prometheus:prometheus /etc/prometheus/rules
sudo promtool check rules /etc/prometheus/rules/wordpress.rules.yml

# Reload Prometheus (web.enable-lifecycle activé)
curl -fsS -X POST http://127.0.0.1:9090/-/reload

5) php.ini (petit réglage utile pour incidents)

Vous voulez des erreurs exploitables. En prod, pas d’affichage, mais logs propres.

# Exemple : /etc/php/8.2/fpm/conf.d/99-monitoring.ini
sudo bash -c 'cat > /etc/php/8.2/fpm/conf.d/99-monitoring.ini' <<'EOF'
; Logs PHP en production (ne pas afficher aux visiteurs)
display_errors=0
log_errors=1
error_log=/var/log/php/php-fpm-error.log

; Limiter les surprises lors de pics
max_execution_time=60
memory_limit=256M
EOF

sudo mkdir -p /var/log/php
sudo chown -R www-data:www-data /var/log/php
sudo systemctl restart php8.2-fpm

Vérification

Vérifiez chaque brique en local. Tant que curl ne marche pas, Prometheus ne marchera pas.

1) Exporters

curl -fsS http://127.0.0.1:9100/metrics | head
curl -fsS http://127.0.0.1:9113/metrics | head
curl -fsS http://127.0.0.1:9253/metrics | head
curl -fsS http://127.0.0.1:9104/metrics | head

2) Endpoint WordPress metrics (via nginx proxy)

curl -fsS http://127.0.0.1/metrics/wp | sed -n '1,40p'

Vous devez voir des lignes type :

# HELP wp_info Informations WordPress (labels: version).
# TYPE wp_info gauge
wp_info{version="6.9.4"} 1
wp_db_select1_ms 1.234

3) Prometheus targets + query

# Targets (API)
curl -fsS "http://127.0.0.1:9090/api/v1/targets" | head -c 400; echo

# Query simple
curl -G -fsS "http://127.0.0.1:9090/api/v1/query" --data-urlencode 'query=up' | head -c 400; echo

4) WP-CLI : test basique app

wp eval 'echo "WP OKn";'
wp option get siteurl
wp cron event list --fields=hook,next_run_relative --format=table | head -n 20

Si ça ne marche pas

Diagnostic par étapes. Ne sautez pas directement à Grafana : le problème est souvent un endpoint local cassé.

Tableau de diagnostic

Symptôme Cause probable Vérification Solution
Prometheus target “DOWN” Service exporter non démarré / écoute sur mauvaise IP systemctl status node_exporter + ss -lntp | grep 9100 Corriger --web.listen-address, redémarrer service
WordPress metrics = 403 IP non allowlistée ou token manquant curl -i http://127.0.0.1/?bpcab_metrics=1 Vérifier BPCAB_METRICS_ALLOW_IPS et injection header nginx
Endpoint WP metrics très lent DB lente / locks / autoload options énormes mysqladmin processlist + métrique wp_db_select1_ms Analyser slow query log, optimiser options autoload, index
PHP-FPM exporter ne remonte rien pm.status_path non activé ou nginx bloque l’URL curl -i http://127.0.0.1/fpm-status Activer pm.status_path, ajouter location nginx allow localhost
MySQL exporter renvoie “access denied” user exporter mal créé / mauvais mot de passe mysql -uexporter -p -h127.0.0.1 -e "SELECT 1" Recréer user + corriger /etc/mysqld_exporter.cnf

Logs à consulter

# Prometheus
journalctl -u prometheus -n 200 --no-pager

# Exporters
journalctl -u node_exporter -n 100 --no-pager
journalctl -u nginx-prometheus-exporter -n 100 --no-pager
journalctl -u php-fpm_exporter -n 100 --no-pager
journalctl -u mysqld_exporter -n 100 --no-pager

# nginx / PHP
tail -n 200 /var/log/nginx/error.log
tail -n 200 /var/log/php/php-fpm-error.log

Erreurs réalistes que je vois souvent

  • Copier le MU-plugin au mauvais endroit : il doit être dans wp-content/mu-plugins/ (pas dans plugins/).
  • Oublier un point-virgule dans wp-config.php : vous cassez tout le site (white screen). Testez d’abord en staging.
  • Hook inadapté : si vous tentez d’exposer les métriques sur plugins_loaded et que vous faites du DB trop tôt, vous pouvez provoquer des effets de bord. Ici, init priorité 0 est un compromis.
  • Conflit cache : certains reverse proxies/caches peuvent mettre en cache /?bpcab_metrics=1. D’où les headers no-store et le proxy nginx local.

Pièges et erreurs courantes

Erreur Cause Solution
Vous scrapez /?bpcab_metrics=1 depuis l’extérieur Endpoint exposé publiquement (risque fuite d’infos) Allowlist IP + token + proxy local nginx + firewall
Vous mettez des labels dynamiques (URL, user, plugin) Explosion de cardinalité Prometheus Labels stables uniquement (site, role, instance). Jamais de valeurs uniques par requête.
Prometheus consomme trop de disque Rétention trop longue / scrape trop fréquent --storage.tsdb.retention.time=15d (ex) + scrape 15s/30s
Alertes “flapping” Seuils trop agressifs, pas de for: Ajoutez for: 5m / 10m, utilisez des moyennes (rate/avg)
Exporter inaccessible Écoute sur 0.0.0.0 bloqué par firewall ou au contraire exposé Écoutez sur 127.0.0.1 et scrapez localement
Snippet cassé via plugin de snippets Chargement ordre imprévisible / erreurs fatales MU-plugin versionné, déployé via Git/SSH, tests staging
PHP trop ancien Serveur legacy, exporters/WordPress non compatibles Montez à PHP 8.1+ (WordPress 6.9.4), sinon vous perdez du temps sur des bugs fantômes

Sécurité serveur

Le monitoring augmente la surface d’attaque. Traitez-le comme une API interne.

  • Écoute locale : exporters et Prometheus sur 127.0.0.1 quand tout est sur la même machine.
  • Firewall : si Prometheus est sur une VM dédiée, n’ouvrez que les ports nécessaires depuis l’IP Prometheus.
  • Tokens et allowlist pour l’endpoint WordPress.
  • Pas de secrets en clair dans un repo : token WP et creds MySQL exporter via secrets manager/Ansible vault si possible.
  • Durcissement systemd (déjà esquissé) : NoNewPrivileges, ProtectSystem, PrivateTmp.
  • HTTPS : si vous exposez Grafana/Prometheus, mettez-les derrière nginx avec TLS + auth (basic auth, SSO, ou mTLS).

Headers HTTP (si vous exposez Grafana via nginx) : je pose systématiquement au moins X-Frame-Options, X-Content-Type-Options, Referrer-Policy. Ce n’est pas spécifique WordPress, mais ça évite des surprises.

Ressources

FAQ

Est-ce que je peux faire ça sur un hébergement mutualisé ?

Pas proprement. Sans systemd/SSH root vous ne pouvez pas lancer exporters ni Prometheus. Dans ce cas, externalisez : un Prometheus ailleurs qui scrape un endpoint WordPress très verrouillé, ou passez par une solution SaaS.

Pourquoi un MU-plugin plutôt qu’un plugin classique ?

Parce que je vois régulièrement des plugins désactivés “pour tester”, ou cassés par une mise à jour. Le MU-plugin est chargé automatiquement et réduit le risque de monitoring silencieusement désactivé.

Pourquoi ne pas exposer /metrics en clair et mettre un simple mot de passe ?

Parce que ça finit souvent indexé, scanné, ou leaké. L’allowlist IP + token est basique mais efficace. Si Prometheus est sur une VM dédiée, ajoutez un firewall réseau et idéalement mTLS.

Le endpoint métriques peut-il casser Elementor/Divi/Avada ?

Non, si vous restez sur une query var dédiée et que vous exit immédiatement. Le code ci-dessus ne charge pas de templates et n’interagit pas avec les builders. Le vrai risque vient d’un hook trop tardif (qui laisse WordPress générer la page) ou d’un plugin de cache qui intercepte l’URL.

Comment monitorer le “vrai TTFB” côté visiteur ?

Prometheus ici mesure l’infra. Pour le ressenti utilisateur, ajoutez du RUM (Real User Monitoring) ou au minimum des sondes externes (blackbox exporter) qui mesurent un endpoint public.

Dois-je activer DISABLE_WP_CRON ?

Sur des sites à trafic irrégulier, oui : cron système toutes les minutes, plus fiable. Dans ce cas, surveillez plutôt la dérive via vos logs cron et des métriques système. Le MU-plugin peut aussi exposer un compteur d’exécutions si vous l’ajoutez.

Pourquoi mes métriques nginx n’ont pas de codes HTTP ?

Parce que stub_status ne fournit pas cette dimension. Pour les codes, vous devez passer par NGINX Plus, ou analyser les logs (mtail / vector / Loki). C’est une limite fréquente.

Quelle rétention Prometheus pour un WordPress “standard” ?

15 jours est un bon départ sur un petit serveur. Si vous voulez 90 jours, prévoyez du disque et surveillez la croissance TSDB. Ajustez surtout la fréquence de scrape et évitez les labels à haute cardinalité.

Comment tester une alerte sans casser la prod ?

Le plus simple : baisser temporairement un seuil (en staging), ou simuler un up == 0 en stoppant un exporter. N’allez pas “remplir le disque” en prod juste pour voir une alerte.

Que faire si l’endpoint WordPress metrics renvoie parfois 500 ?

Regardez d’abord /var/log/nginx/error.log et /var/log/php/php-fpm-error.log. J’ai souvent vu : DB down, memory_limit trop bas, ou un autoload options énorme qui rend init instable. Réduisez les appels DB dans l’endpoint et gardez-le minimal.