Skip to content

PHP-FPM Auto-Tuning

PHP-FPM Auto-Tuning Guide

Cbox Init includes intelligent PHP-FPM worker auto-tuning based on container resource limits. This feature eliminates manual calculations and prevents memory over-provisioning that can cause OOM kills.

Table of Contents

Overview

PHP-FPM worker configuration is critical for Laravel application performance and stability. Too few workers cause request queuing; too many cause memory exhaustion and container crashes.

Auto-tuning automatically:

  • Detects container memory and CPU limits (cgroup v1/v2)
  • Calculates optimal worker count based on your application profile
  • Sets all PM (Process Manager) parameters correctly
  • Reserves memory for Nginx, Redis clients, system overhead
  • Validates calculations to prevent over-provisioning

Runtime Auto-Tuning

Everything above is boot-time sizing: a one-shot calculation that runs before php-fpm starts and templates pm.max_children into the pool config from the container's memory limit and a workload profile. It is a good starting value, but it is a guess made before a single request has been served, from an assumed per-worker memory figure.

Cbox Init can also refine that value at runtime. The global.fpm_tune block embeds the fpm-tune engine as a built-in background loop: it measures live per-worker memory (PSS, which does not double-count shared OPcache), and when the right size has moved it rewrites a pool drop-in and reloads php-fpm with SIGUSR2 (a graceful reload, not a restart). Each change is validated against a throwaway copy, written atomically, and rolled back if the master does not come back.

The two compose. The boot calculator seeds pm.max_children so php-fpm starts sane; the runtime loop owns the number from there, following real memory use and damping reloads with hysteresis so it does not react to every small drift. Enable it alongside a php-fpm process:

global:
  fpm_tune:
    enabled: true
    mode: apply          # write drop-ins and reload; use "advisory" to only recommend
    interval: 30s
    metrics_addr: ":9110" # exposes fpm_tune_* metrics; empty disables it

A full example lives in configs/examples/php-fpm-autotune.yaml. The embedded engine is the same tool that runs standalone, so do not point a separate fpm-tune daemon at the same pools: the loop takes a lock on its state file, and a second copy on the same state refuses to start.

How It Works

Detection Phase

  1. Container Resource Discovery

    • Reads cgroup v2: /sys/fs/cgroup/memory.max, /sys/fs/cgroup/cpu.max
    • Fallback cgroup v1: /sys/fs/cgroup/memory/memory.limit_in_bytes, /sys/fs/cgroup/cpu/cpu.cfs_quota_us
    • Falls back to host resources if not containerized
  2. Profile Selection

    • User selects profile via --php-fpm-profile flag
    • Profile defines: avg memory per worker, PM mode, spare server ratios

Calculation Phase

  1. Worker Count Calculation

    Available Memory = (Total Memory × MaxMemoryUsage%) - Reserved Memory
    Max Workers = Available Memory ÷ Avg Memory Per Worker
    
  2. CPU-Based Limiting

    CPU Limit = CPU Cores × 4  (industry standard)
    Max Workers = min(Memory-Based Workers, CPU Limit)
    
  3. PM Parameter Calculation (for dynamic mode)

    pm.start_servers = max_children × StartServersRatio
    pm.min_spare_servers = max_children × SpareMinRatio
    pm.max_spare_servers = max_children × SpareMaxRatio
    
  4. Validation

    • Total memory usage < container limit
    • PM relationships: min_spare ≤ start_servers ≤ max_spare ≤ max_children
    • Minimum workers from profile enforced

Environment Variable Export

  1. PHP-FPM Integration
    • Sets environment variables: PHP_FPM_PM, PHP_FPM_MAX_CHILDREN, etc.
    • PHP-FPM pool config references these via ${VARIABLE} syntax

Application Profiles

Dev Profile

Profile: dev
Use Case: Local development
Workers: 2 (static)
Memory/Worker: ~32MB (runtime + request overhead)
OPcache (shared): 64MB (compiled app code shared by all workers)
Reserved Memory: 64MB
Max Memory Usage: 50%
PM Mode: static
Total Memory: 2 × 32MB + 64MB + 64MB = 192MB

When to use:

  • Local development with Docker Desktop
  • Fast startup, minimal footprint
  • Debugging-friendly (predictable worker count)

How OPcache reduces memory: App code is compiled once and stored in shared OPcache (64MB), not loaded in each worker. Workers only need runtime + request memory (~32MB each).

Example: 512MB container → 2 workers (uses ~192MB total)


Light Profile

Profile: light
Use Case: Small apps, low traffic (1-10 req/s)
Workers: Auto-calculated
Memory/Worker: ~36MB (runtime + request overhead)
OPcache (shared): 96MB (compiled app code shared by all workers)
Reserved Memory: 128MB
Max Memory Usage: 70%
PM Mode: dynamic
Spare Min/Max: 25% / 50%

When to use:

  • Small Laravel apps, internal tools
  • Cost-optimized cloud deployments
  • Background job processors

How OPcache reduces memory: Small Laravel app with some packages compiles to ~96MB opcodes in shared OPcache. Each worker only needs ~36MB for runtime + request handling.

Example: 1GB container → ~14 workers (700MB available - 224MB reserved = 476MB ÷ 36MB)


Medium Profile

Profile: medium (RECOMMENDED)
Use Case: Standard production (10-50 req/s)
Workers: Auto-calculated
Memory/Worker: ~42MB (runtime + request overhead)
OPcache (shared): 128MB (compiled app code shared by all workers)
Reserved Memory: 192MB
Max Memory Usage: 75%
PM Mode: dynamic
Spare Min/Max: 25% / 50%

When to use:

  • Most Laravel production applications
  • Balanced performance and resource efficiency
  • APIs with moderate traffic

How OPcache reduces memory: Standard Laravel with packages compiles to ~128MB opcodes in shared OPcache. Each worker only needs ~42MB for runtime + request handling, allowing 2-3x more workers!

Example: 2GB container → ~16 workers (CPU limited at 4 cores × 4 = 16)

  • Without OPcache: ~10 workers at 80MB each
  • With OPcache: 16 workers at 42MB each + 128MB shared

Heavy Profile

Profile: heavy
Use Case: High traffic (50-200 req/s)
Workers: Auto-calculated (minimum 8)
Memory/Worker: ~52MB (runtime + request overhead)
OPcache (shared): 256MB (compiled app code shared by all workers)
Reserved Memory: 384MB
Max Memory Usage: 80%
PM Mode: dynamic
Spare Min/Max: 20% / 40%

When to use:

  • High-traffic Laravel applications
  • Large apps with many packages and dependencies
  • Performance-critical APIs

How OPcache reduces memory: Large Laravel app with many packages compiles to ~256MB opcodes in shared OPcache. Workers need more overhead for connections/caching (~52MB) but still much less than without OPcache!

Example: 8GB container → ~32 workers (CPU limited at 8 cores × 4 = 32)

  • Without OPcache: ~12 workers at 128MB each
  • With OPcache: 32 workers at 52MB each + 256MB shared = 2.5x more workers!

Bursty Profile

Profile: bursty
Use Case: Variable traffic with spikes
Workers: Auto-calculated (minimum 4)
Memory/Worker: ~44MB (runtime + request overhead)
OPcache (shared): 128MB (compiled app code shared by all workers)
Reserved Memory: 192MB
Max Memory Usage: 75%
PM Mode: dynamic
Spare Min/Max: 40% / 70%
Start Servers: 50% of max

When to use:

  • E-commerce sites (flash sales)
  • Event-driven traffic patterns
  • Applications with unpredictable load

How OPcache reduces memory: Similar to medium profile (~44MB per worker) but with aggressive spare settings to handle traffic spikes quickly. More workers = better spike handling!

Example: 4GB container → ~30 workers (12 min spare, 21 max spare, 15 start)

  • Without OPcache: ~12 workers at 96MB each
  • With OPcache: 30 workers at 44MB each + 128MB shared = 2.5x more capacity for spikes!

Safety Features

Memory Protection

  • Never exceeds container limit: Total usage = workers + reserved < limit
  • Reserved memory: Nginx (varies), Redis/MySQL clients, system overhead
  • Safety margin: Max 50-80% memory usage (profile-dependent)

CPU Protection

  • Max 4 workers per CPU core: Industry-standard ratio
  • Prevents context switching: Too many workers on limited CPUs degrade performance

Validation Gates

  • One shared memory model: the reported minimum is the smallest limit that actually boots the profile, so the pre-check and the sizing never disagree
  • Post-calculation validation: PM relationships (min_spare <= start_servers <= max_spare <= max_children)
  • Warning system: Logs adjustments (CPU limiting, profile minimums, clamping)

Profile Minimums

  • Each profile has a preferred minimum worker count
  • Dev: 2 workers, Light: 2, Medium: 4, Heavy: 8, Bursty: 4
  • The minimum is a preference, not a hard floor that can crash a small container (see below)

When the container is too small

By default the calculator never kills PID 1. If the memory limit cannot fit the profile's preferred worker count, it clamps to the largest count that fits (down to one worker), logs a loud warning naming the profile and the smallest limit that would run it, and boots. The container comes up degraded but working, and the embedded runtime autotuner (fpm_tune) refines the number from there.

This matters because images often bake a default profile (for example PHP_FPM_AUTOTUNE_PROFILE=medium): a small sidecar should boot, not crash-loop.

Set global.autotune_strict: true (or PHP_FPM_AUTOTUNE_STRICT=1) to keep the fail-hard behavior instead: a profile that does not fit exits at startup with a clear error naming the profile and the smallest limit that runs it. Use it when you want a hard guarantee that the box is sized for the profile you chose.

global:
  autotune_strict: false   # default: clamp and boot. true: fail hard on a misfit.

Usage

Basic Usage

# Via CLI flag
./build/cbox-init --php-fpm-profile=medium

# Via environment variable (recommended for containers)
PHP_FPM_AUTOTUNE_PROFILE=medium ./build/cbox-init

# Docker
docker run -e PHP_FPM_AUTOTUNE_PROFILE=medium myapp:latest

# Priority: CLI flag > ENV var
PHP_FPM_AUTOTUNE_PROFILE=light ./build/cbox-init --php-fpm-profile=heavy
# Result: Uses 'heavy' (CLI overrides ENV)

With Config File

# Combine with specific config
./build/cbox-init \
  --php-fpm-profile=medium \
  --config /etc/cbox-init/production.yaml

Docker Integration

Dockerfile:

FROM php:8.3-fpm-alpine

# Install cbox-init
COPY build/cbox-init /usr/local/bin/cbox-init

# PHP-FPM pool config with environment variable placeholders
COPY www.conf /usr/local/etc/php-fpm.d/www.conf

# Default autotune profile (can be overridden at runtime)
ENV PHP_FPM_AUTOTUNE_PROFILE=medium

# Start with auto-tuning enabled via ENV
CMD ["cbox-init", "--config", "/etc/cbox-init/cbox-init.yaml"]

Docker Compose

services:
  app:
    image: myapp:latest
    environment:
      # Auto-tune PHP-FPM based on container limits
      - PHP_FPM_AUTOTUNE_PROFILE=medium
    deploy:
      resources:
        limits:
          memory: 2G      # Auto-tuner uses this
          cpus: '2'       # Auto-tuner uses this
    # No need to specify --php-fpm-profile in command
    # ENV var activates it automatically

  app-heavy:
    image: myapp:latest
    environment:
      # Different profile for high-traffic instance
      - PHP_FPM_AUTOTUNE_PROFILE=heavy
    deploy:
      resources:
        limits:
          memory: 8G
          cpus: '8'

Kubernetes Deployment:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: laravel-app
spec:
  replicas: 3
  template:
    spec:
      containers:
      - name: app
        image: myapp:latest
        env:
        - name: PHP_FPM_AUTOTUNE_PROFILE
          value: "medium"
        # Or from ConfigMap:
        # - name: PHP_FPM_AUTOTUNE_PROFILE
        #   valueFrom:
        #     configMapKeyRef:
        #       name: app-config
        #       key: php_fpm_profile
        resources:
          limits:
            memory: "2Gi"
            cpu: "2"

Validation

# Test autotune without starting processes
./build/cbox-init --php-fpm-profile=medium --dry-run

# Output:
# 🎯 PHP-FPM auto-tuned (medium profile):
#    pm = dynamic
#    pm.max_children = 6
#    pm.start_servers = 2
#    pm.min_spare_servers = 1
#    pm.max_spare_servers = 3
#    pm.max_requests = 1000
#    Memory: 1536MB allocated / 2048MB total

Integration with PHP-FPM

Pool Configuration

Cbox Init exports environment variables that PHP-FPM can reference in www.conf:

[www]
; Use auto-tuned values via environment variables
pm = ${PHP_FPM_PM}
pm.max_children = ${PHP_FPM_MAX_CHILDREN}
pm.start_servers = ${PHP_FPM_START_SERVERS}
pm.min_spare_servers = ${PHP_FPM_MIN_SPARE}
pm.max_spare_servers = ${PHP_FPM_MAX_SPARE}
pm.max_requests = ${PHP_FPM_MAX_REQUESTS}

; Standard pool settings
pm.process_idle_timeout = 10s
pm.max_requests = ${PHP_FPM_MAX_REQUESTS}

Environment Variables

Variable Description Example
PHP_FPM_PM Process manager mode dynamic
PHP_FPM_MAX_CHILDREN Maximum workers 10
PHP_FPM_START_SERVERS Workers to start 3
PHP_FPM_MIN_SPARE Minimum idle workers 2
PHP_FPM_MAX_SPARE Maximum idle workers 5
PHP_FPM_MAX_REQUESTS Requests before restart 1000

Manual Override

You can still manually override if needed:

# Override auto-tuned values
export PHP_FPM_MAX_CHILDREN=20
./build/cbox-init --php-fpm-profile=medium

Calculation Algorithm

Detailed Example: Medium Profile, 2GB Container, 4 CPUs

1. Container Limits:
   - Memory: 2048MB
   - CPUs: 4

2. Profile Configuration (medium):
   - Avg Memory/Worker: 256MB
   - Reserved Memory: 512MB
   - Max Memory Usage: 75%
   - Spare Min Ratio: 0.25
   - Spare Max Ratio: 0.5
   - Start Servers Ratio: 0.33

3. Memory-Based Calculation:
   Available = 2048MB × 0.75 = 1536MB
   Worker Memory = 1536MB - 512MB reserved = 1024MB
   Workers = 1024MB ÷ 256MB = 4 workers

4. CPU-Based Limit:
   CPU Limit = 4 cores × 4 = 16 workers
   Final = min(4, 16) = 4 workers  (memory-limited)

5. PM Parameters (dynamic):
   pm.max_children = 4
   pm.start_servers = ceil(4 × 0.33) = 2
   pm.min_spare_servers = ceil(4 × 0.25) = 1
   pm.max_spare_servers = ceil(4 × 0.5) = 2
   pm.max_requests = 1000

6. Validation:
   Total Memory = (4 × 256MB) + 512MB = 1536MB ✓
   1536MB < 2048MB limit ✓
   PM: 1 ≤ 2 ≤ 2 ≤ 4 ✓

Troubleshooting

Error: "insufficient memory: 0MB"

Cause: Container limits not detected (not running in container or cgroup not accessible)

Solution:

  • Run in actual Docker container with memory limit
  • Check cgroup mount: ls /sys/fs/cgroup/
  • Manual fallback: Don't use --php-fpm-profile, configure PHP-FPM manually

Error: "insufficient memory for workers"

Cause: Container too small for selected profile

Solutions:

  1. Increase container memory: docker run -m 2G ...
  2. Use lighter profile: --php-fpm-profile=light or dev
  3. Reduce reserved memory (not recommended)

Warning: "Memory allows X workers, but limiting to Y based on CPUs"

Meaning: You have more memory than CPUs can handle efficiently

Action: This is safe - CPU limit prevents context switching overhead. Consider:

  • Increasing CPU allocation if latency-sensitive
  • Accepting the limit if throughput is adequate

Warning: "Calculated X workers, but profile limits to Y"

Meaning: Profile enforces maximum (e.g., dev profile = 2 workers max)

Action: Use correct profile for your environment:

  • Dev: Local development only
  • Light/Medium/Heavy: Production profiles

Workers dying with OOM

Diagnosis:

# Check actual memory usage
docker stats

# Check PHP-FPM memory_limit
php -i | grep memory_limit

Solutions:

  1. Reduce memory_limit in php.ini (e.g., 256M128M)
  2. Use heavier profile with more memory/worker: --php-fpm-profile=heavy
  3. Increase container memory limit
  4. Profile your app to find memory leaks

Too few workers (requests queuing)

Diagnosis:

# Check PHP-FPM status
docker exec app kill -USR2 1  # Reload PHP-FPM
curl http://localhost/status?full

Solutions:

  1. Increase container memory to get more workers
  2. Switch to lighter profile: --php-fpm-profile=light (more workers, less memory each)
  3. Optimize app memory usage (caching, DB queries)
  4. Add horizontal scaling (more containers)

Best Practices

Profile Selection

  • Start with medium for most Laravel apps
  • Use dev only for local development
  • Upgrade to heavy if >50 req/s and you have large memory limits
  • Use bursty for e-commerce, events, unpredictable traffic

Container Sizing

  • Minimum recommendations by profile:
    • Dev: 384MB
    • Light: 768MB
    • Medium: 2GB
    • Heavy: 4GB+
    • Bursty: 4GB+

Monitoring

  • Track pm.status_path metrics (active workers, queue length)
  • Alert on memory usage >90%
  • Monitor worker churn (restarts from pm.max_requests)

Testing

  • Always --dry-run before production deployment
  • Load test with realistic traffic patterns
  • Verify no OOM kills under peak load
  • Check PM status during traffic spikes

PHP Configuration

  • Set memory_limit conservatively (profile avg memory - 20%)
  • Enable OPcache with sufficient memory
  • Configure max_execution_time appropriately
  • Use pm.status_path for monitoring

Advanced: Custom Profiles

While not exposed via CLI, you can create custom profiles by modifying internal/autotune/profiles.go:

ProfileCustom: {
    Name:                "Custom Production",
    Description:         "Tailored for our specific app",
    ProcessManagerType:  "dynamic",
    AvgMemoryPerWorker:  384,  // Measured from app profiling
    MinWorkers:          6,
    MaxWorkers:          0,     // Auto-calculate
    SpareMinRatio:       0.3,
    SpareMaxRatio:       0.6,
    StartServersRatio:   0.4,
    MaxRequestsPerChild: 1500,
    MaxMemoryUsage:      0.75,
    ReservedMemoryMB:    768,   // Nginx + Redis + MySQL clients
},

Then rebuild and use: --php-fpm-profile=custom


Need help? Open an issue at https://github.com/cboxdk/init/issues