Optimizar Pool PHP-FPM en VPS Linux: pm.max_children y el Fin del 502
Resuelve el 502 Bad Gateway calculando pm.max_children, pm.start_servers y pm.max_requests en el pool PHP-FPM de tu VPS Linux con Nginx.
Un pool PHP-FPM mal dimensionado es la causa número uno de 502 Bad Gateway en VPS con Nginx + PHP. El síntoma aparece bajo carga moderada — 50, 100 requests simultáneos — y desaparece cuando el tráfico baja, llevando el diagnóstico en direcciones equivocadas (Nginx, base de datos, red). En realidad, el pool está agotado: todos los workers PHP-FPM ocupados, Nginx intenta enviar el request vía FastCGI, recibe rechazo o timeout, y devuelve 502 al cliente.
Este tutorial es para ti, developer o sysadmin con VPS Linux corriendo
Nginx + PHP-FPM en cualquier aplicación PHP (Laravel, WordPress, Symfony,
Magento) que está viendo 502 esporádicos en producción o simplemente quiere
dimensionar el pool a partir de mediciones reales, no de valores copiados de
un blog. Vamos a cubrir cómo medir la memoria real de los workers, calcular
pm.max_children con la fórmula correcta, ajustar los parámetros auxiliares
(pm.start_servers, pm.min_spare_servers, pm.max_spare_servers,
pm.max_requests) y validar la configuración con Nginx sirviendo carga real.
Tiempo estimado de ejecución: 25 minutos, incluyendo una prueba de carga controlada para validar antes/después.
Requisitos previos
Necesitas una VPS Linux con privilegios root o sudo, PHP-FPM instalado y funcional, y Nginx ya enrutando requests al socket Unix de PHP-FPM. Los ejemplos usan Ubuntu 24.04 LTS con PHP 8.3, pero los principios se aplican a cualquier versión de PHP-FPM (7.4 en adelante) y a Debian, AlmaLinux o Rocky Linux con ajustes mínimos en las rutas de archivo.
Acceso SSH activo, sudo y la aplicación PHP corriendo en producción o staging con tráfico representativo. Vas a necesitar recolectar métricas con el pool bajo carga real — medir en idle da números equivocados.
Ubuntu 24.04 LTS 8.3 (php-fpm) /etc/php/8.3/fpm/pool.d/www.conf /run/php/php8.3-fpm.sock Medir la memoria real de los workers
Antes de tocar cualquier parámetro, necesitas saber cuánto consume cada worker PHP-FPM de memoria residente (RSS) con tu aplicación específica. Ese número varía drásticamente entre stacks — un worker Laravel con Eloquent pesado puede consumir 80–120 MB, un WordPress sencillo se queda en 40–60 MB, y una API Symfony optimizada puede correr con 30 MB. Adivinar 60 MB porque “es el valor por defecto” es la forma más común de infradimensionar el pool.
Genera carga representativa en la aplicación. En staging, usa una herramienta
como ab (ApacheBench) o wrk contra los endpoints más pesados:
ab -n 1000 -c 20 https://tu-dominio.com/endpoint-pesadoEn producción, puede ser el tráfico natural durante hora pico. El objetivo es forzar a los workers a procesar requests reales y quedar en estado post-ejecución con la aplicación cargada en memoria.
Con el pool bajo carga, lista los workers ordenados por RSS:
ps -ylC php-fpm8.3 --sort:rssLa salida tiene una columna RSS en KB. Ignora el proceso master (normalmente el primero, el más pequeño) y enfócate en los workers (estado S tras haber procesado requests). Esos son los que importan para el cálculo.
Calcula la media de RSS de los workers activos:
ps --no-headers -o rss -C php-fpm8.3 | awk '{sum+=$1; count++} END {print "Media:", sum/count/1024, "MB"}'Anota ese valor. En una aplicación Laravel típica, espera algo entre 70 y
110 MB. Ese es el número que va a la fórmula de pm.max_children.
Calcular pm.max_children
La fórmula es simple y existe para ser respetada: pm.max_children = (RAM disponible para PHP) ÷ (RSS medio por worker). El punto no obvio es el
“disponible para PHP” — no es la RAM total de la VPS. Tienes que restar lo
que MySQL, Redis, Nginx, kernel y buffers consumen en pico.
Mira la RAM total y el uso actual:
free -mEn una VPS de 4 GB ejecutando MySQL (~800 MB), Redis (~150 MB), Nginx (~50 MB) y el sistema base (~400 MB), quedan aproximadamente 2,5 GB para PHP. En una VPS de 2 GB con la misma stack, quedan unos 600 MB — mucho menos de lo que los blogs suelen asumir.
Aplica la fórmula. Con 2,5 GB para PHP y RSS medio de 80 MB por worker:
2560 MB ÷ 80 MB = 32 workersReserva un margen de seguridad del 15–20%. Usa 26 o 27 como
pm.max_children. Ese buffer evita que picos de RSS (workers procesando
endpoints atípicos) empujen al sistema al swap, que destruye el rendimiento
mucho más rápido que un 502.
Si agotas la RAM y el sistema cae en swap, la latencia sube a segundos y la VPS entera queda no responsiva. Un 502 ocasional es recuperable; el thrashing de swap lleva a un outage completo. Siempre infradimensiona antes que sobredimensionar.
Ajustar el pool en www.conf
Con pm.max_children calculado, abre /etc/php/8.3/fpm/pool.d/www.conf
y ajusta los parámetros relacionados. No son independientes — valores mal
alineados causan tanto churn que la ganancia del max_children correcto
desaparece en overhead de spawn.
Edita el pool:
sudo nano /etc/php/8.3/fpm/pool.d/www.confLocaliza y ajusta estas líneas. Los valores de abajo asumen el ejemplo de 26 workers calculado antes — adáptalos a tu cálculo:
pm = dynamic
pm.max_children = 26
pm.start_servers = 7
pm.min_spare_servers = 4
pm.max_spare_servers = 10
pm.max_requests = 500La regla práctica: start_servers ≈ 25% de max_children,
min_spare_servers ≈ 15%, max_spare_servers ≈ 40%. Eso mantiene
suficientes workers en idle para absorber picos sin desperdiciar RAM
cuando no hay tráfico.
Habilita la status page del pool — la vas a necesitar para monitorizar. Descomenta o añade:
pm.status_path = /fpm-statusGuarda y cierra el archivo.
Valida la sintaxis antes de aplicar:
sudo php-fpm8.3 -tLa salida debe ser configuration file /etc/php/8.3/fpm/php-fpm.conf test is successful. Si da error, el pool no va a arrancar y el sitio se cae —
no sigas con un error pendiente.
Recarga PHP-FPM sin matar requests en curso:
sudo systemctl reload php8.3-fpmReload es graceful: los workers actuales terminan lo que están procesando, el master spawnea nuevos con la config nueva, y la transición es transparente para el usuario. Restart solo si reload falla.
Exponer el status en Nginx para monitorizar
pm.status_path solo funciona si Nginx enruta el request hacia PHP-FPM.
Sin eso dimensionas a ciegas y descubres el problema solo cuando el 502
vuelve a aparecer.
Edita el vhost de Nginx y añade un location protegido:
location ~ ^/(fpm-status|fpm-ping)$ {
allow 127.0.0.1;
allow 10.0.0.0/8;
deny all;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $fastcgi_script_name;
}La restricción por IP es esencial — un status público expone carga y número de workers a cualquiera en internet, lo que es información útil para un atacante planeando un DoS.
Prueba la config y recarga:
sudo nginx -t && sudo systemctl reload nginxAccede a http://127.0.0.1/fpm-status vía SSH (curl localhost) y
confirma que la página devuelve métricas: accepted conn, listen queue,
active processes, max active processes.
Verificación
Genera carga de nuevo y observa dos métricas críticas en el status. La
primera es listen queue: si es mayor que cero de forma consistente, el
pool se está saturando y los requests se están encolando — necesitas
aumentar max_children o investigar lentitud en la aplicación. La segunda
es max children reached: si ese contador sube, has tocado techo al menos
una vez desde el último reload.
curl -s http://127.0.0.1/fpm-status
Salida saludable bajo carga:
pool: www
process manager: dynamic
accepted conn: 4823
listen queue: 0
max listen queue: 2
idle processes: 6
active processes: 11
total processes: 17
max active processes: 19
max children reached: 0
Si max children reached se quedó en 0 durante la prueba de carga, el
dimensionamiento es correcto. Si subió, ejecuta ps de nuevo bajo carga,
recalcula la RSS media (los workers pueden estar consumiendo más de lo
que mediste inicialmente) y ajusta.
Resolución de problemas
El 502 sigue apareciendo tras el ajuste
Revisa /var/log/php8.3-fpm.log buscando la cadena server reached max_children setting. Si aparece, tu cálculo subestimó la memoria o la
carga real es mayor que la prueba. Aumenta max_children en un 20% y
repite el ciclo de medición. Si el mensaje no aparece pero el 502 persiste,
el problema no es el pool — investiga fastcgi_read_timeout en Nginx (el
valor por defecto de 60s puede ser bajo para endpoints pesados) y queries
lentas en la base de datos.
Workers consumiendo mucha más RAM de la esperada
Los memory leaks en aplicaciones PHP se acumulan a lo largo de cientos de
requests. Reduce pm.max_requests de 500 a 200 — eso recicla workers
más agresivamente y mata el leak antes de que crezca. Si el leak es muy
rápido (el worker duplica su tamaño en 50 requests), el problema es el
código — profilea con xdebug.profiler_enable en staging para encontrar
la causa.
pm = static asigna todos los workers en el boot de PHP-FPM. Si
configuras pm.max_children = 100 en una VPS de 2 GB y usas static, el
boot va a consumir toda la RAM inmediatamente y el sistema entra en
thrashing antes de que llegue el primer request.
Próximos pasos
Con el pool dimensionado, algunos próximos pasos lógicos para fortalecer
la stack: configurar OPcache con un opcache.memory_consumption adecuado
para evitar recompilación en cada hit, ajustar fastcgi_read_timeout en
Nginx para endpoints conocidos como lentos, exportar métricas del pool a
un sistema de observabilidad (Grafana, Datadog), e implementar health
checks en Nginx vía fastcgi_next_upstream para failover elegante.
Si estás llevando esto a producción y quieres empezar con una base
estable, una VPS Hostini ya viene con PHP-FPM 8.3 preinstalado,
Nginx tuneado y SSD NVMe — lo que reduce drásticamente el tiempo de I/O
por request e impacta directamente el pm.max_children que tu aplicación
necesita.
Preguntas frecuentes
¿Cuál es el valor ideal de pm.max_children?
No existe un valor ideal universal — depende de la RSS media de cada worker y de la RAM disponible. La fórmula es (RAM libre para PHP) ÷ (RSS medio por proceso). En una VPS de 4 GB con 60 MB por worker y 2 GB reservados para PHP, son 33 workers. Mide antes de adivinar.
¿Por qué recibo 502 Bad Gateway solo bajo carga?
Casi siempre es el pool agotado: pm.max_children demasiado bajo o workers bloqueados en I/O. Nginx envía el request, PHP-FPM lo rechaza o tarda más que fastcgi_read_timeout, y Nginx devuelve 502. Revisa php-fpm.log buscando 'server reached max_children setting'.
¿Debo usar pm = dynamic, static u ondemand?
dynamic es el valor por defecto y funciona en el 95% de los casos. static solo tiene sentido en VPS dedicado a una única aplicación con tráfico predecible — elimina el churn de spawn. ondemand ahorra RAM en inactividad pero añade latencia en el primer request — bueno para sitios de bajo tráfico, malo para APIs.
¿pm.max_requests afecta al rendimiento?
Sí. Define cuántos requests procesa cada worker antes de ser reciclado. Un valor bajo (100) mata los leaks de memoria rápido pero aumenta el consumo de CPU en spawn. Un valor alto (1000) es eficiente pero deja crecer los leaks. 500 es un punto medio seguro para la mayoría de stacks (Laravel, WordPress, Symfony).
¿Cómo saber la RSS real de mis workers PHP-FPM?
Ejecuta `ps -ylC php-fpm8.3 --sort:rss` con el pool bajo carga real (no en idle). La columna RSS muestra la memoria residente en KB. Calcula la media de los workers en estado S (sleeping tras request) — ese es el consumo estable que importa para el cálculo de pm.max_children.
¿Necesito reiniciar PHP-FPM o basta con reload?
`systemctl reload php8.3-fpm` es suficiente para cambios en pm.* — relectura graceful sin matar requests en curso. Usa restart solo si cambias opciones globales (error_log, daemonize) o si el pool está bloqueado. Reload es seguro en producción.