Skip to content
← All posts

Monitoring PHP-FPM in Production: A Complete Stack

How Cbox FPM Exporter and Cbox Init work together to give you full observability over PHP-FPM pools, OPcache, and Laravel queues. From FastCGI status pages to Prometheus dashboards.

· 11 min read · Sylvester Damgaard
Monitoring PHP-FPM in Production: A Complete Stack

PHP-FPM is the invisible workhorse behind most PHP deployments. It manages a pool of worker processes, handles connection queuing, and quietly recycles processes when they leak memory. But most teams have no visibility into what it's doing. They find out pm.max_children was too low when requests start timing out, or that OPcache is full when response times double overnight.

I built two tools to solve this: FPM Exporter for metrics collection, and Init for process management inside containers. Together, they give you a complete production monitoring stack for PHP-FPM.

FPM Exporter: FastCGI to Prometheus

FPM Exporter is a Go binary that speaks the FastCGI protocol directly to PHP-FPM sockets. No nginx status module, no PHP scripts, no HTTP requests routed through your application. It connects to the unix socket (or TCP port), requests the FPM status page, and exposes the data as Prometheus metrics on port 9114.

Automatic Pool Discovery

By default, FPM Exporter discovers pools automatically:

bash
fpm-exporter serve

Discovery works by scanning for php-fpm: master process in the process list, then parsing the config with php-fpm -tt to extract socket paths and status page locations. No manual configuration needed for standard setups.

For environments where autodiscovery doesn't work (chroot, custom paths), you can configure pools explicitly:

yaml
phpfpm:
  autodiscover: false
  pools:
    - socket: "unix:///var/run/php-fpm.sock"
      status_path: /status
    - socket: "tcp://127.0.0.1:9000"
      status_path: /status

The Metrics

FPM Exporter exports three categories of metrics. Here are the ones that matter most in production:

Pool health, the metrics you alert on:

Metric

What It Tells You

phpfpm_active_processes

How many workers are currently handling requests

phpfpm_idle_processes

How many workers are waiting for work

phpfpm_listen_queue

Requests waiting for a free worker (should be 0)

phpfpm_max_children_reached

Counter: times all workers were busy (capacity ceiling hit)

phpfpm_pm_max_children_config

Your configured pool size

The single most important PromQL query for FPM health is pool utilization:

promql
phpfpm_active_processes / phpfpm_pm_max_children_config * 100

When this approaches 90%, you need more workers. When phpfpm_listen_queue is non-zero, requests are already queuing and users are waiting.

OPcache metrics, the ones nobody checks until something breaks:

Metric

What It Tells You

phpfpm_opcache_hit_rate

Percentage of scripts served from cache

phpfpm_opcache_used_memory_bytes

How much OPcache memory is consumed

phpfpm_opcache_wasted_memory_bytes

Memory wasted by invalidated scripts

phpfpm_opcache_oom_restarts_total

Times OPcache ran out of memory and restarted

A healthy OPcache has a hit rate above 99% and zero OOM restarts. If phpfpm_opcache_wasted_memory_percent exceeds 5%, your opcache.max_wasted_percentage is probably too low or you have frequent deployments without a preload/warmup step.

promql
# Alert when OPcache is running out of memory
phpfpm_opcache_wasted_memory_percent > 5

# Memory utilization
phpfpm_opcache_used_memory_bytes /
(phpfpm_opcache_used_memory_bytes + phpfpm_opcache_free_memory_bytes)

Laravel queue sizes, because your queue workers are probably backed by the same PHP-FPM you are monitoring:

promql
laravel_queue_size{site="Production", connection="redis", queue="default"}

FPM Exporter runs php artisan to collect queue depths, application info, cache status, and driver configurations for each Laravel site.

Alerting Rules

Here is a production-ready set of Prometheus alerting rules based on the actual metrics the exporter provides:

yaml
groups:
  - name: phpfpm
    rules:
      - alert: PHPFPMDown
        expr: phpfpm_up == 0
        for: 1m
        labels:
          severity: critical

      - alert: PHPFPMHighUtilization
        expr: phpfpm_active_processes / phpfpm_pm_max_children_config > 0.9
        for: 5m
        labels:
          severity: warning

      - alert: PHPFPMMaxChildrenReached
        expr: increase(phpfpm_max_children_reached[5m]) > 0
        labels:
          severity: warning

      - alert: PHPFPMQueueBacklog
        expr: phpfpm_listen_queue > 10
        for: 2m
        labels:
          severity: warning

Init: PID 1 for PHP Containers

Running PHP-FPM inside a Docker container introduces a problem: containers expect a single PID 1 process, but a PHP application usually needs FPM, nginx, queue workers, and a scheduler. Most teams use Supervisor, but Supervisor wasn't designed for containers. It doesn't handle signals properly, doesn't reap zombie processes, and doesn't expose metrics.

Cbox Init is a Go binary purpose-built for PID 1 in Docker. It manages multiple processes with proper signal handling, health checks, and a built-in cron scheduler.

A typical Laravel container configuration:

yaml
version: "1.0"

global:
  shutdown_timeout: 30
  log_level: info
  metrics_enabled: true

processes:
  php-fpm:
    enabled: true
    command: ["php-fpm", "-F", "-R"]
    restart: always
    health_check:
      type: tcp
      address: "127.0.0.1:9000"

  nginx:
    enabled: true
    command: ["nginx", "-g", "daemon off;"]
    depends_on: [php-fpm]
    restart: always

  queue-worker:
    enabled: true
    command: ["php", "artisan", "queue:work", "--sleep=3", "--tries=3"]
    depends_on: [php-fpm]
    scale: 3
    restart: always

Init uses DAG-based dependency resolution (depends_on: [php-fpm]), so nginx only starts after PHP-FPM's TCP health check passes. Queue workers scale to 3 instances. All processes get graceful shutdown with SIGTERM, a configurable timeout, and SIGKILL as a last resort.

The Dockerfile is straightforward:

dockerfile
FROM php:8.3-fpm-alpine

COPY --from=builder /app/cbox-init /usr/local/bin/cbox-init
COPY cbox-init.yaml /etc/cbox-init/cbox-init.yaml

ENTRYPOINT ["/usr/local/bin/cbox-init"]

Putting It Together: The Sidecar Pattern

In Kubernetes, the recommended deployment runs FPM Exporter as a sidecar container alongside your PHP-FPM container, sharing a unix socket volume:

yaml
apiVersion: apps/v1
kind: Deployment
spec:
  template:
    metadata:
      annotations:
        prometheus.io/scrape: "true"
        prometheus.io/port: "9114"
    spec:
      containers:
        - name: php-fpm
          image: php:8.3-fpm
          volumeMounts:
            - name: fpm-socket
              mountPath: /var/run

        - name: fpm-exporter
          image: cboxdk/fpm-exporter:latest
          args: ["serve", "--laravel", "name=App,path=/var/www/html"]
          ports:
            - containerPort: 9114
              name: metrics
          volumeMounts:
            - name: fpm-socket
              mountPath: /var/run
          resources:
            requests:
              cpu: 10m
              memory: 32Mi
            limits:
              cpu: 100m
              memory: 64Mi

      volumes:
        - name: fpm-socket
          emptyDir: {}

The exporter needs almost no resources. 10m CPU and 32Mi memory is sufficient for most workloads. Prometheus scrapes the /metrics endpoint on port 9114 via annotations.

For the Prometheus Operator, add a ServiceMonitor:

yaml
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: fpm-exporter
spec:
  selector:
    matchLabels:
      app: php-app
  endpoints:
    - port: metrics
      interval: 30s

Grafana Dashboard Queries

FPM Exporter ships with a Grafana dashboard JSON you can import directly. The key panels:

FPM process utilization gauge (the single number to watch):

promql
phpfpm_active_processes{pool="$pool"} / phpfpm_pm_max_children_config{pool="$pool"} * 100

Active vs. idle workers over time (stacked area chart):

promql
phpfpm_active_processes{pool="$pool"}
phpfpm_idle_processes{pool="$pool"}

Queue growth rate for Laravel queues:

promql
rate(laravel_queue_size{site="$site"}[5m])

OPcache hit rate (should be >99%):

promql
phpfpm_opcache_hit_rate{pool="$pool"}

The Full Stack

Here is how it all fits together in production:

  1. Cbox Init runs as PID 1 in your Docker container, managing PHP-FPM, nginx, and queue workers with health checks and graceful shutdown

  2. FPM Exporter runs as a sidecar (or in the same container), collecting metrics via FastCGI from the FPM socket

  3. Prometheus scrapes the exporter's /metrics endpoint every 30 seconds

  4. Grafana visualizes pool utilization, OPcache health, and queue depths

  5. Alertmanager fires alerts when utilization exceeds thresholds

No PHP extensions to install. No agent running inside your FPM processes. No application code changes. Just a Go binary talking FastCGI to a unix socket.

Full setup guides: FPM Exporter docs and Init docs.


// Sylvester Damgaard