Nginx en EGPHP

La puerta de entrada, bien construida.

Nginx decide qué llega a su sitio: qué servidor y qué pool de PHP responden, qué sale de la caché y qué se rechaza en la puerta. Así lo monta nuestro equipo, con la configuración a la vista.

Funciona con y creado con
  • NGINX
  • PHP
  • Redis

Una petición, de la puerta a su código

La mayoría de las peticiones no necesitan PHP en absoluto. Cada parada de abajo puede responder por sí sola, y solo lo que queda sigue hasta la siguiente.

  1. La conexión

    Cifrada con TLS 1.3, y una sola conexión transporta todos los archivos de la página por HTTP/2 o HTTP/3.

  2. Límites

    Aquí se responde a demasiadas peticiones desde una misma dirección, a demasiadas conexiones abiertas o a un cuerpo mayor del que acepta el sitio.

    429 o 413: se detiene aquí
  3. Rutas prohibidas

    Los archivos ocultos, las copias de seguridad y cualquier script dentro de una carpeta de subidas se rechazan antes de que PHP se entere.

    403 o 404: se detiene aquí
  4. Archivos estáticos

    Imágenes, estilos y scripts salen directamente del disco, con cachés de larga duración.

    Servido: se detiene aquí
  5. Caché de páginas

    Un visitante sin sesión iniciada recibe una página terminada desde la memoria.

    Acierto de caché: se detiene aquí
  6. El balanceador

    Elige el servidor y el pool de PHP que tienen hueco para la petición.

  7. El pool propio de la cuenta

    PHP se ejecuta como el usuario propio de la cuenta, dentro de sus propias carpetas y con su propio php.ini.

Balanceo de carga entre servidores

Cuando un servidor no basta, Nginx se coloca delante de varios y reparte el tráfico entre ellos. El método decide quién recibe la siguiente petición.

Un ejemplo: las direcciones y los valores son ilustrativos

El método por defecto. Cada servidor recibe la siguiente petición por turnos: lo adecuado cuando los servidores son parecidos y las peticiones cuestan más o menos lo mismo.

La siguiente petición va al servidor con menos conexiones abiertas: lo adecuado cuando algunas peticiones son largas, como informes, exportaciones y subidas.

La dirección del visitante elige el servidor, así que el mismo visitante siempre cae en el mismo. Sirve para aplicaciones que guardan las sesiones en el disco local; mejor aún, llevamos las sesiones a Redis y la necesidad desaparece.

conf.d/app.conf
upstream app_servers {
    # round robin: the default, no line needed    least_conn;    ip_hash;
    server 10.0.0.11:8080 max_fails=3 fail_timeout=30s;
    server 10.0.0.12:8080 max_fails=3 fail_timeout=30s;
    server 10.0.0.13:8080 max_fails=3 fail_timeout=30s;
    server 10.0.0.14:8080 backup;    # no backup server with ip_hash
    keepalive 32;
}

server {
    server_name example.com;
    location / {
        proxy_pass http://app_servers;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_set_header Host $host;
        proxy_next_upstream error timeout http_502 http_503;
    }
}
max_fails · fail_timeout
Comprobaciones de salud con tráfico real: tras tres intentos fallidos en treinta segundos, el servidor queda fuera durante treinta segundos y luego se vuelve a probar.
backup
Un servidor de reserva que solo recibe tráfico cuando ningún otro servidor está disponible.
proxy_next_upstream
Una petición que se encuentra con un error o un tiempo de espera agotado se reintenta en el siguiente servidor. Una petición que modifica datos no se envía dos veces, así que un pedido nunca se hace dos veces.
keepalive
Las conexiones con los servidores se mantienen abiertas entre peticiones en lugar de abrirse de nuevo para cada una.
weight
A un servidor más grande se le puede asignar una parte mayor de las peticiones.
down
Un servidor se retira para mantenimiento con una recarga, mientras los demás llevan el tráfico.

Balanceo de carga entre pools de PHP

La misma idea dentro de un solo servidor. Un sitio puede tener más de un pool de PHP-FPM detrás, de modo que un pool puede reiniciarse mientras el otro responde, y el trabajo lento nunca se queda con los workers que necesita la tienda.

Nginx ~ \.php$^~ /admin/
shop-a.sock Tienda, pool A Respondiendo Respondiendo y asumiendo las peticiones de B
shop-b.sock Tienda, pool B Respondiendo Reiniciándose con un ajuste nuevo: ningún visitante lo espera
shop-admin.sock Administración e informes Sus propios workers, aparte de la tienda
sites/example.com.confUn ejemplo
upstream php_shop {
    server unix:/run/php/shop-a.sock max_fails=2 fail_timeout=10s;
    server unix:/run/php/shop-b.sock max_fails=2 fail_timeout=10s;
    keepalive 8;
}

# server {}
location ~ \.php$ {
    try_files $uri =404;
    include fastcgi.conf;
    fastcgi_pass php_shop;
    fastcgi_keep_conn on;
    fastcgi_next_upstream error timeout;
}

location ^~ /admin/ {
    location ~ \.php$ {
        try_files $uri =404;
        include fastcgi.conf;
        fastcgi_pass unix:/run/php/shop-admin.sock;
    }
}
  • Dos pools, un sitio

    Los dos pools ejecutan el mismo código con la misma cuenta. Un ajuste nuevo de PHP llega a un pool y luego al otro, y los visitantes siguen recibiendo respuesta en todo momento.

  • Conmutación por error

    fastcgi_next_upstream pasa una petición al otro pool cuando su socket la rechaza o no responde a tiempo. Una petición que modifica datos no se envía dos veces.

  • Un carril para el trabajo lento

    El área de administración, las exportaciones y los informes tienen su propio pool, así que un informe de una hora no puede quedarse con los workers que un cliente necesita para pagar.

Las reglas de la puerta

La protección empieza antes de que PHP ejecute una sola línea. Estas reglas van en cada bloque server que escribimos, adaptadas al sitio.

sites/example.com.confUn ejemplo: los valores se fijan para cada sitio
# http {}
limit_req_zone  $binary_remote_addr zone=perip:10m rate=10r/s;
limit_req_zone  $binary_remote_addr zone=login:10m rate=5r/m;
limit_conn_zone $binary_remote_addr zone=conns:10m;server {
    listen 443 ssl;
    http2 on;
    server_name example.com;
    limit_req  zone=perip burst=40 nodelay;
    limit_conn conns 30;
    limit_req_status 429;
    location = /wp-login.php {
        limit_req zone=login burst=5 nodelay;
        include fastcgi.conf;
        fastcgi_pass php_shop;
    }    client_max_body_size 64m;
    client_header_timeout 15s;
    client_body_timeout 15s;    location ~ /\.(?!well-known/) { deny all; }
    location ~* \.(?:bak|old|orig|sql|swp)$ { deny all; }    location ~* ^/(?:wp-content/uploads|uploads|files)/.*\.php$ {
        deny all;
    }    location ~ \.php$ {
        try_files $uri =404;
        include fastcgi.conf;
        fastcgi_pass php_shop;
    }    ssl_protocols TLSv1.2 TLSv1.3;
    server_tokens off;
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;}
  1. Límites de peticiones y de conexiones

    Cada dirección tiene un ritmo constante con margen para una ráfaga corta; si lo supera, Nginx responde 429 y PHP ni se despierta. La página de inicio de sesión tiene su propio límite, mucho más estricto, que deja sin fuelle los intentos de adivinar contraseñas.

  2. Tamaño del cuerpo y tiempos de espera

    Una subida mayor de la que acepta el sitio se rechaza al instante, y un cliente que envía su petición muy despacio queda desconectado en lugar de mantener una conexión abierta.

  3. Archivos ocultos y restos

    Nunca se sirve nada cuyo nombre empiece por un punto, como .git o .env, ni las copias de seguridad de los archivos. La única excepción es la carpeta que usan las comprobaciones de certificados.

  4. Las subidas nunca ejecutan PHP

    Una carpeta que acepta archivos de los visitantes los sirve como archivos. Un script colocado ahí se rechaza, se llame como se llame.

  5. Solo un archivo PHP que exista

    try_files $uri =404 envía una petición a PHP solo si ese archivo exacto está en el disco. Así se cierra la trampa clásica que se dibuja abajo.

  6. Cabeceras y TLS

    HSTS mantiene a los navegadores en HTTPS, las demás cabeceras impiden que se husmee el tipo de una página o que otro sitio la meta en un marco, y el servidor no anuncia su versión. Solo TLS 1.2 y 1.3.

    Cómo protegemos el propio servidor

La trampa que abre una sola línea que falta

/uploads/avatar.jpgUn archivo real: una imagen con código PHP escondido dentro /shell.phpNo existe
Sin la regla

En una configuración antigua o descuidada, PHP recorre la ruta hacia atrás, encuentra la imagen y ejecuta el código que lleva dentro.

Con ella

Un 404, y no se ejecuta nada. cgi.fix_pathinfo=0 en PHP y security.limit_extensions en el pool son la segunda y la tercera cerradura de la misma puerta.

Cada cuenta es una habitación amurallada

En un servidor compartido por varios clientes, un sitio nunca debe poder leer a otro. Levantamos ese muro por capas, para que ningún ajuste lo sostenga por sí solo.

shopCuenta
Usuario de Linux
shop
Pool de PHP
/run/php/shop.sock
Carpetas permitidas
/home/shop
Ajustes
pool.d/shop.conf
clinicCuenta
Usuario de Linux
clinic
Pool de PHP
/run/php/clinic.sock
Carpetas permitidas
/home/clinic
Ajustes
pool.d/clinic.conf
blogCuenta
Usuario de Linux
blog
Pool de PHP
/run/php/blog.sock
Carpetas permitidas
/home/blog
Ajustes
pool.d/blog.conf
El código de la tienda intentando alcanzar los archivos de la clínica file_get_contents('/home/clinic/public_html/config.php') Permission deniedopen_basedir restriction in effect
  1. Su propio usuario de Linux

    Cada sitio se ejecuta con su propio usuario, y cada carpeta personal está cerrada a las demás cuentas. Este es el muro que aguanta incluso si PHP queda fuera de la ecuación.

  2. Su propio pool de PHP-FPM

    Un pool por cuenta, accesible solo a través de su propio socket. Un sitio saturado o averiado agota sus propios workers, no los de sus vecinos.

  3. open_basedir y include_path

    PHP solo puede abrir archivos dentro de las carpetas de la propia cuenta, y solo busca ahí los archivos incluidos. Un script que pide una ruta fuera de ellas se rechaza antes de tocar el disco.

  4. Sus propios archivos temporales y sesiones

    Las subidas en curso y los archivos de sesión se quedan dentro de la cuenta, no en un /tmp compartido donde cualquier sitio podría leer las sesiones iniciadas de otro.

  5. Ninguna función que el sitio no necesite

    open_basedir no es un entorno aislado: no detiene un programa lanzado desde PHP. Por eso las funciones que ejecutan comandos de shell se desactivan en cada cuenta, salvo que el sitio de verdad necesite alguna.

php/8.3/fpm/pool.d/shop.confUn ejemplo
[shop]
user  = shop
group = shop
listen = /run/php/shop.sock
listen.owner = www-data
listen.group = www-data
listen.mode  = 0660

pm = ondemand
pm.max_children = 12
pm.process_idle_timeout = 10s

php_admin_value[open_basedir] = /home/shop/public_html:/home/shop/lib:/home/shop/tmp
php_admin_value[include_path] = .:/home/shop/lib
php_admin_value[upload_tmp_dir] = /home/shop/tmp
php_admin_value[sys_temp_dir] = /home/shop/tmp
php_admin_value[session.save_path] = /home/shop/tmp/sessions
php_admin_value[disable_functions] = exec,passthru,shell_exec,system,proc_open,popen
php_admin_flag[allow_url_include] = off

php.ini por cuenta

Cada cuenta tiene sus propios ajustes, escritos en su propio pool. Algunos los fijamos nosotros, otros puede cambiarlos el sitio, y la diferencia es una sola palabra.

php_admin_value: fijo

Fijado para la cuenta. Ni ini_set() ni un archivo .user.ini pueden cambiarlo.

memory_limit = 256M
max_execution_time = 60
open_basedir = /home/shop/…
disable_functions = exec,…

php_value: un valor inicial

Nuestro valor por defecto para la cuenta. El sitio puede subirlo o bajarlo donde PHP lo permita.

upload_max_filesize = 32M
post_max_size = 32M
max_input_vars = 3000

.user.ini: el del propio sitio

Un archivo en la carpeta del sitio para los ajustes que este puede cambiar. PHP lo vuelve a leer cada cinco minutos por defecto.

upload_max_filesize = 64M
post_max_size = 64M
display_errors = Off

Un ejemplo: los valores se fijan para cada sitio

Límites que tienen que coincidir

Tamaño de subida
upload_max_filesizePHP≤ post_max_sizePHP≤ client_max_body_sizeNginx

Si Nginx permite menos, la subida se rechaza con un 413 antes de que PHP la vea; si PHP permite menos, el archivo llega y se descarta.

Tiempo
max_execution_timePHP< request_terminate_timeoutPHP-FPM≤ fastcgi_read_timeoutNginx

PHP debería detenerse primero, con un error que pueda registrar; el pool detiene lo que PHP no pudo detener; y Nginx espera lo suficiente para no mostrar un 504 mientras PHP sigue trabajando.

Caché por capas

Cada capa guarda algo distinto, y una petición solo baja hasta donde lo necesita. Cambie entre los dos visitantes para ver dónde se detiene cada uno.

  1. NavegadorEstilos, scripts e imágenes con una versión en el nombre, guardados durante un año.
    Ya tiene los archivosYa tiene los archivos
  2. Caché de páginas de NginxPáginas terminadas, guardadas desde unos segundos hasta unos minutos.
    Acierto: la página se responde aquíOmitida: una cookie de sesión o de carrito indica que la página es personal
  3. OPcachePHP compilado una vez y guardado en memoria.
    No se llega hasta aquíEl código se ejecuta sin volver a leerse ni compilarse
  4. RedisRespuestas repetidas de la base de datos, y sesiones.
    No se llega hasta aquíLa mayoría de las respuestas salen de la memoria
  5. Base de datosLa fuente de la verdad.
    No se llega hasta aquíSolo se le pregunta lo que Redis no tenía
conf.d/cache.confUn ejemplo: los valores se fijan para cada sitio
# http {}
fastcgi_cache_path /var/cache/nginx/shop levels=1:2
                   keys_zone=shop:50m inactive=60m max_size=2g;

map $http_cookie $skip_cache {
    default                     0;
    ~wordpress_logged_in_       1;
    ~woocommerce_items_in_cart  1;
}

# location ~ \.php$ {}
fastcgi_cache shop;
fastcgi_cache_key "$scheme$request_method$host$request_uri";
fastcgi_cache_valid 200 301 10s;
fastcgi_cache_bypass $skip_cache;
fastcgi_no_cache     $skip_cache;
fastcgi_cache_use_stale updating error timeout http_500 http_503;
fastcgi_cache_background_update on;
fastcgi_cache_lock on;
La clave
Esquema, método, host y la dirección completa, para que dos páginas distintas nunca compartan una entrada.
Saltarse la caché
Una sesión iniciada o un carrito se saltan la caché en los dos sentidos: la página ni se sirve desde ella ni se guarda en ella. Los formularios enviados con POST nunca se guardan en caché.
Copia antigua mientras se actualiza
Cuando una entrada caduca, una sola petición la renueva en segundo plano mientras todos los demás siguen recibiendo la copia anterior. Si PHP falla, se sirve la última página buena.
Una regla que se suele pasar por alto
Una directiva add_header dentro de un location sustituye todas las cabeceras definidas por encima, así que mantenemos las cabeceras de seguridad en un solo fragmento y lo incluimos allí donde se añaden cabeceras.

Velocidad, y el resto de una buena configuración

Los ajustes que hacen más rápido cada sitio del servidor, y los hábitos que mantienen seguro un cambio.

Un cambio aplicado sin cortar ninguna conexión
$ sudo nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
$ sudo systemctl reload nginx

La nueva configuración se prueba primero; si la prueba falla, no cambia nada. Al recargar, los workers nuevos arrancan con ella mientras los antiguos terminan las peticiones que tienen en curso, así que no se corta a ningún visitante.

log_format timed '$remote_addr "$request" $status $body_bytes_sent '
                 'rt=$request_time urt=$upstream_response_time '
                 'cache=$upstream_cache_status';

Cada línea del registro anota el tiempo total, el tiempo que tardó PHP y lo que hizo la caché, así que una página lenta se rastrea hasta Nginx, PHP o la red en lugar de adivinarse.

http2 on; listen 443 quic;
Una sola conexión transporta toda la página; HTTP/3 se añade donde la compilación y la red lo permiten.
gzip · brotli
El texto se comprime antes de salir, con Brotli cuando el módulo está compilado. Las imágenes y el vídeo no se comprimen dos veces.
sendfile · tcp_nopush
El núcleo envía los archivos del disco a la red por sí mismo, en paquetes completos.
open_file_cache
Nginx recuerda qué archivos existen y dónde están, en lugar de preguntar al disco en cada petición.
worker_processes auto
Un worker por núcleo de procesador, cada uno con muchas conexiones en un solo bucle de eventos.
Cache-Control: immutable
Los archivos con una versión en el nombre se guardan en caché un año y nunca se vuelven a comprobar.

Qué hay detrás de la puerta de entrada

Agrupado según el trabajo de cada pieza, todo decidido en la configuración de Nginx.

Conexiones

  • Conexiones cifradasTLS 1.3 cifra la conexión y HTTP/2 lleva todos los archivos de una página por una sola conexión.
  • Límites de frecuencia y tamañoDemasiadas peticiones, demasiadas conexiones abiertas o un cuerpo demasiado grande se responden en la puerta.

Entrega

  • Archivos estáticosImágenes, estilos y scripts salen directo del disco, con largos tiempos de caché.
  • Caché de páginasUn visitante sin sesión iniciada recibe una página terminada desde la memoria.
  • Balanceo de cargaLas peticiones van al servidor y al pool de PHP que tienen sitio para ellas.

Protección

  • Rutas prohibidasLos archivos ocultos, las copias y los scripts en carpetas de subida se rechazan antes de que PHP sepa de ellos.
  • Cabeceras de seguridadSe guardan en un solo fragmento y se incluyen donde se añadan cabeceras.
  • Cuentas amuralladasCada cuenta ejecuta PHP como su propio usuario, dentro de sus propias carpetas.

Cambiar la configuración sin soltar a ningún visitante

Nginx aplica una configuración nueva sobre la marcha. Así describe su documentación el orden de los hechos.

  1. Probar el archivo

    El comando nginx -t lee toda la configuración y se detiene en el primer error, antes de que nada funcione con ella.

  2. Enviar la señal de recarga

    El proceso maestro recibe la señal y comprueba la nueva configuración. Si falla, revierte y la configuración anterior sigue sirviendo.

  3. Arrancan workers nuevos

    Si la comprobación pasa, el maestro arranca nuevos procesos worker que usan la nueva configuración.

  4. Los workers viejos terminan

    Los workers viejos dejan de aceptar conexiones nuevas y terminan las peticiones que ya tienen.

  5. Los workers viejos salen

    Cuando se sirve al último visitante se apagan y solo quedan workers con la nueva configuración.

Documentación de Nginx: controlar nginx

Tráiganos el sitio. Nosotros construiremos su puerta de entrada.

Cuéntenos dónde funciona hoy y qué necesita. Primero revisamos la configuración actual, luego proponemos la configuración de Nginx y PHP, y presupuestamos el trabajo después de esa conversación.

MickeyResponde en segundos