PHP-FPM Monitoring
PHP-FPM Monitoring
This guide explains how to monitor PHP-FPM pools and OPcache directly using the FastCGI protocol, without exposing sensitive data through web server proxies.
Why Direct FastCGI Access?
Security Concerns with HTTP Proxy
The traditional approach to PHP-FPM monitoring involves configuring Nginx or Apache to proxy status endpoints:
# ⚠️ Security risk - exposes internal data via HTTP
location /fpm-status {
fastcgi_pass unix:/var/run/php-fpm.sock;
fastcgi_param SCRIPT_NAME /status;
include fastcgi_params;
allow 127.0.0.1;
deny all;
}
Problems with this approach:
| Issue | Risk |
|---|---|
| Exposed endpoint | Even with IP restrictions, the endpoint exists on the web server |
| Configuration drift | Status path might accidentally be exposed in production |
| Attack surface | Additional HTTP endpoint increases attack surface |
| ACL complexity | Managing allow/deny lists across environments |
Direct FastCGI Benefits
Using fcgx to connect directly to PHP-FPM:
- No HTTP exposure: Status data never touches the web server
- Process isolation: Monitoring runs as a separate service
- Simpler security: Only the monitoring service needs socket access
- No web server config: No Nginx/Apache configuration needed
PHP-FPM Status Endpoint
See PHP-FPM Configuration for complete setup including:
- Basic status endpoint configuration
- Admin socket (
pm.status_listen) for high-load monitoring - Multi-pool configuration
Querying Status
package main
import (
"context"
"fmt"
"time"
"github.com/cboxdk/fcgx"
)
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
client, err := fcgx.DialContext(ctx, "unix", "/var/run/php-fpm.sock")
if err != nil {
panic(err)
}
defer client.Close()
// Query status with JSON output and full process list
params := map[string]string{
"SCRIPT_NAME": "/status",
"SCRIPT_FILENAME": "/status",
"QUERY_STRING": "json&full",
"REQUEST_METHOD": "GET",
}
resp, err := client.Get(ctx, params)
if err != nil {
panic(err)
}
defer resp.Body.Close()
var status FPMStatus
if err := fcgx.ReadJSON(resp, &status); err != nil {
panic(err)
}
fmt.Printf("Pool: %s\n", status.Pool)
fmt.Printf("Active: %d / Total: %d\n", status.ActiveProcesses, status.TotalProcesses)
fmt.Printf("Accepted connections: %d\n", status.AcceptedConn)
}
type FPMStatus struct {
Pool string `json:"pool"`
ProcessManager string `json:"process manager"`
StartTime int64 `json:"start time"`
StartSince int64 `json:"start since"`
AcceptedConn int64 `json:"accepted conn"`
ListenQueue int64 `json:"listen queue"`
MaxListenQueue int64 `json:"max listen queue"`
IdleProcesses int64 `json:"idle processes"`
ActiveProcesses int64 `json:"active processes"`
TotalProcesses int64 `json:"total processes"`
MaxActiveProcesses int64 `json:"max active processes"`
MaxChildrenReached int64 `json:"max children reached"`
SlowRequests int64 `json:"slow requests"`
}
Status Query Parameters
| Parameter | Description |
|---|---|
json |
Return JSON formatted output |
full |
Include per-process details |
html |
Return HTML formatted output |
xml |
Return XML formatted output |
Combine parameters: ?json&full
OPcache Monitoring
The OPcache Scoping Problem
Critical concept: OPcache is scoped per PHP-FPM pool. Each pool maintains its own independent OPcache.
This means:
# ❌ This does NOT show your PHP-FPM pool's OPcache
php -r "print_r(opcache_get_status());"
# The CLI has its own OPcache (usually disabled)
# It cannot see what's cached in your www pool
Why?
- Each PHP-FPM pool runs as separate master + worker processes
- OPcache uses shared memory segments per pool
- CLI PHP is a completely separate process with its own memory
- There is no IPC mechanism to query another process's OPcache
Solution: Execute PHP via FastCGI
To query OPcache for a specific pool, you must execute PHP code within that pool:
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"time"
"github.com/cboxdk/fcgx"
)
func main() {
// Create a temporary PHP script to query OPcache
scriptPath := "/tmp/opcache-status.php"
scriptContent := `<?php
error_reporting(0);
ini_set('display_errors', 0);
header("Status: 200 OK");
header("Content-Type: application/json");
echo json_encode(opcache_get_status());
exit;`
if err := os.WriteFile(scriptPath, []byte(scriptContent), 0644); err != nil {
panic(err)
}
defer os.Remove(scriptPath)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
client, err := fcgx.DialContext(ctx, "unix", "/var/run/php-fpm.sock")
if err != nil {
panic(err)
}
defer client.Close()
params := map[string]string{
"SCRIPT_FILENAME": scriptPath,
"SCRIPT_NAME": "/opcache-status.php",
"REQUEST_METHOD": "GET",
"SERVER_SOFTWARE": "fcgx-monitor",
"REMOTE_ADDR": "127.0.0.1",
}
resp, err := client.Get(ctx, params)
if err != nil {
panic(err)
}
defer resp.Body.Close()
body, err := fcgx.ReadBody(resp)
if err != nil {
panic(err)
}
var status OpcacheStatus
if err := json.Unmarshal(body, &status); err != nil {
panic(err)
}
fmt.Printf("OPcache enabled: %v\n", status.Enabled)
fmt.Printf("Memory used: %d bytes\n", status.MemoryUsage.UsedMemory)
fmt.Printf("Hit rate: %.2f%%\n", status.Statistics.HitRate)
fmt.Printf("Cached scripts: %d\n", status.Statistics.NumCachedScripts)
}
type OpcacheStatus struct {
Enabled bool `json:"opcache_enabled"`
MemoryUsage MemoryUsage `json:"memory_usage"`
Statistics Statistics `json:"opcache_statistics"`
}
type MemoryUsage struct {
UsedMemory uint64 `json:"used_memory"`
FreeMemory uint64 `json:"free_memory"`
WastedMemory uint64 `json:"wasted_memory"`
CurrentWastedPct float64 `json:"current_wasted_percentage"`
}
type Statistics struct {
NumCachedScripts uint64 `json:"num_cached_scripts"`
Hits uint64 `json:"hits"`
Misses uint64 `json:"misses"`
HitRate float64 `json:"opcache_hit_rate"`
OomRestarts uint64 `json:"oom_restarts"`
HashRestarts uint64 `json:"hash_restarts"`
ManualRestarts uint64 `json:"manual_restarts"`
}
Important OPcache Metrics
| Metric | Description | Alert Threshold |
|---|---|---|
opcache_hit_rate |
Cache hit percentage | < 95% |
current_wasted_percentage |
Fragmented memory | > 5% |
oom_restarts |
Out of memory restarts | > 0 |
hash_restarts |
Hash table overflow restarts | > 0 |
num_cached_scripts |
Number of cached files | Monitor trend |
Security Considerations
When deploying the OPcache monitoring PHP script:
- File permissions: Ensure the script is readable by PHP-FPM user
- Script location: Use
/tmpor a dedicated monitoring directory - Cleanup: Remove temporary scripts when done
- open_basedir: Ensure the script path is allowed
// Check if script path is accessible
if _, err := os.Stat(scriptPath); os.IsNotExist(err) {
// Create the script
}
Multi-Pool Monitoring
When monitoring multiple PHP-FPM pools:
type PoolConfig struct {
Name string
Socket string
}
func MonitorPools(ctx context.Context, pools []PoolConfig) map[string]*FPMStatus {
results := make(map[string]*FPMStatus)
for _, pool := range pools {
status, err := getPoolStatus(ctx, pool.Socket)
if err != nil {
log.Printf("Failed to get status for %s: %v", pool.Name, err)
continue
}
results[pool.Name] = status
}
return results
}
func getPoolStatus(ctx context.Context, socket string) (*FPMStatus, error) {
client, err := fcgx.DialContext(ctx, "unix", socket)
if err != nil {
return nil, err
}
defer client.Close()
params := map[string]string{
"SCRIPT_NAME": "/status",
"SCRIPT_FILENAME": "/status",
"QUERY_STRING": "json&full",
}
resp, err := client.Get(ctx, params)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var status FPMStatus
if err := fcgx.ReadJSON(resp, &status); err != nil {
return nil, err
}
return &status, nil
}
Health Check Endpoints
Ping Endpoint
PHP-FPM provides a lightweight ping endpoint:
func PingPool(ctx context.Context, socket string) error {
client, err := fcgx.DialContext(ctx, "unix", socket)
if err != nil {
return fmt.Errorf("connection failed: %w", err)
}
defer client.Close()
params := map[string]string{
"SCRIPT_NAME": "/ping",
"SCRIPT_FILENAME": "/ping",
}
resp, err := client.Get(ctx, params)
if err != nil {
return fmt.Errorf("ping failed: %w", err)
}
defer resp.Body.Close()
body, _ := fcgx.ReadBody(resp)
if string(body) != "pong" {
return fmt.Errorf("unexpected response: %s", body)
}
return nil
}
Kubernetes Probes
// Liveness probe - is PHP-FPM responding?
func LivenessHandler(socket string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
if err := PingPool(ctx, socket); err != nil {
w.WriteHeader(http.StatusServiceUnavailable)
fmt.Fprintf(w, "unhealthy: %v", err)
return
}
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, "healthy")
}
}
// Readiness probe - can PHP-FPM handle requests?
func ReadinessHandler(socket string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
status, err := getPoolStatus(ctx, socket)
if err != nil {
w.WriteHeader(http.StatusServiceUnavailable)
fmt.Fprintf(w, "not ready: %v", err)
return
}
// Check if pool has capacity
if status.ListenQueue > 0 {
w.WriteHeader(http.StatusServiceUnavailable)
fmt.Fprintf(w, "queue backlog: %d", status.ListenQueue)
return
}
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, "ready")
}
}
Summary
| Approach | Use Case | Security |
|---|---|---|
| HTTP proxy | Legacy setups, simple deployments | ⚠️ Endpoint exposed |
| Direct FastCGI | Production monitoring, exporters | ✅ No HTTP exposure |
| CLI PHP | Development debugging only | ❌ Cannot see FPM OPcache |
For production PHP-FPM monitoring, direct FastCGI access via fcgx provides the most secure and reliable approach.
See Also
- PHP-FPM Configuration - Status endpoints and admin sockets
- Making Requests - Core request patterns
- Error Handling - Handle connection errors
- API Reference - Complete API documentation