nginx (I)

Imagen de vfmBOFH

Bien, niños y niñas. A modo de explicación-ampliación de éste post, también como evolución de éste otro, y finalmente para probar el nuevo marcador de código del Drupal (esto es una puya para ver si alguien escribe algo para antes de fin de año) vamos a echarle un ojo a la configuración actual que mueve este mismo blog, además de otros dos que yo me sé, a nivel de servicio web.

La idea es compartirlo para que se pueda estudiar y mejorar si es posible. Así que si hay sugerencias, para éso están los comentarios.

DISCLAIMER - OJOCUIDAO - AVISO - CUIDADÍN

Esto va a ser un post largo. Muy largo. Y en varias partes. Con un montón de archivos de configuración y explicaciones. Si no lo ves claro, huye.

DISCLAIMER - OJOCUIDAO - AVISO - CUIDADÍN

BOFHers se mueve con un stack LEMP, optimizado para correr un Drupal7. También se han tenido que ajustar un par de trócolas e instalar un bujecillo simple porque los otros sites que se hospedan son Wordpress, que es lo mismo pero no es igual, y se me quedaba sobreseído el eje trasero. El VPS es uno normalito, con 1GB de RAM, Debian Wheezy instalado y actualizado regularmente.

Bien, la parte de servicio web es nginx (actualmente 1.6.2, de los backports de Debian Wheezy), el php se ejecuta con php-fpm y la base de datos es MySQL. Estos dos últimos son los paquetes de release de Wheezy. Todo va interrelacionado, así que a lo largo del post, iremos saltando de un archivo de configuración a otro.

Voy a dar por sentado que todo el mundo sabe instalar un stack LEMP en su distro, así que no voy a empezar a listar ahora paquetes. No obstante, y como referencia, en el post de Blogdrake hay una lista de paquetes. Tampoco voy a extenderme con los .conf de seguridad y manejo de bots que uso en nginx, puesto que prácticamente no hay diferencias con los que describo en Blogdrake. Centrémonos en el LEMP a secas y cómo tratar de que vaya lo más rápido y suave posible.

Básicamente, el esquema que uso en el VPS es un nginx con un microcaché en RAM escuchando peticiones y, cuando éstas peticiones incluyen la ejecución de código php, las paso a php-fpm para que haga el trabajo. En php-fpm tengo tres pools definidas, una para cada site que el VPS sirve. (¿Por qué? Lo veremos en su momento). Finalmente, MySQL dándolo todo al fondo a la derecha.

Empecemos por lo último, para dar variedad al tema. La instalación por defecto de MySQL en Debian Wheezy es bastante moderada en cuanto a exigencia de recursos. No obstante, teniendo en cuenta que vamos a reservar cierta cantidad de RAM para el microcaché, que las pools de php-fpm tienen sus requisitos y que hay más cosas corriendo en ése VPS, vamos a intentar aligerarla un poco a nivel de consumo de RAM, buffers, etc. También hay un par de ajustes del motor innodb. Al final, conseguimos una respuesta más que aceptable, con una máxima memoria ocupada de unos 675 megas:

[client]
        port            = 3306
        socket          = /var/run/mysqld/mysqld.sock
[mysqld_safe]
        socket          = /var/run/mysqld/mysqld.sock
        nice            = 0
[mysqld]
        user            = mysql
        pid-file        = /var/run/mysqld/mysqld.pid
        socket          = /var/run/mysqld/mysqld.sock
        port            = 3306
        basedir         = /usr
        datadir         = /var/lib/mysql
        tmpdir          = /tmp
        lc-messages-dir = /usr/share/mysql
        skip-external-locking
        bind-address            = 127.0.0.1
        key_buffer              = 16M
        max_allowed_packet      = 16M
        thread_stack            = 192K
        thread_cache_size       = 4
        myisam-recover         = BACKUP
        max_connections        = 30
        table_cache            = 64    
        thread_concurrency     = 10
        wait_timeout           = 300
        interactive_timeout    = 300
        table_open_cache       = 128
        query_cache_limit       = 1M
        query_cache_size        = 32M
        general_log_file        = /var/log/mysql/mysql.log
        general_log             = 1
        expire_logs_days        = 10
        max_binlog_size         = 100M
        ##### Config para innodb
        innodb_file_per_table
        innodb_flush_method=O_DIRECT
        innodb_log_file_size=125M
        innodb_buffer_pool_size=512M
        ##############
        [mysqldump]
        quick
        quote-names
        max_allowed_packet      = 16M
        [isamchk]
        key_buffer              = 16M
        !includedir /etc/mysql/conf.d/

Una vez que reiniciemos MySQL, ya podemos entrar a la chicha en serio: nginx y php-fpm.

Nginx:
Esencialmente, me he basado en la archipopular configuración de perusio para Drupal y nginx, ajustándole valores y elmininando purrela. Voy a tratar de dejarlo todo bien comentadito para una mejor comprensión. La config ésta se basa en mogollón de archivos.conf que son llamados unos desde otros, así que es un poco lioso al principio. Pero nada que no se solucione con un poco de comprensión lectora y sentido común. La flexibilidad de nginx para aceptar todas esas llamadas entre archivos de configuración hará el resto.

nginx.conf
El archivo principal de configuración del servidor web. Muy grande no es tampoco.

user www-data; #Usuario que ejecuta nginx
worker_processes 1; #Sólo hay un core en el VPS así que...
pid /var/run/nginx.pid;
worker_rlimit_nofile 8192; #Máximo de archivos abiertos por worker
events {
    worker_connections 1024; #Hacer coincidir con la salida de ulimit -n
    multi_accept on; #Nuestro worker aceptará nuevas conexiones simultáneamente
}
 
http {
    include /etc/nginx/mime.types;
    default_type application/octet-stream;
    include /etc/nginx/fastcgi.conf; #cargamos la config para php
    access_log /var/log/nginx/access.log; # logs generales. Cada vhost tendrá uno
    error_log /var/log/nginx/error.log; # aquí veríamos sucesos de "sistema"
    sendfile on; #usemos sendfile() para transferir datos.
    set_real_ip_from 0.0.0.0/32; # todas las direcciones tienen una IP real.
    real_ip_header X-Forwarded-For; # Forward de IP, por si me da por montar un proxy :-)
    limit_conn_zone $binary_remote_addr zone=zconn:10m; #limitando conexiones por IP.
    client_body_timeout 60; #timeouts varios
    client_header_timeout 60;
    keepalive_timeout 10 10;
    send_timeout 60;
    reset_timedout_connection on;
    client_max_body_size 10m; #tamaño de respuesta máximo
    tcp_nodelay on; #Escupimos datos tan pronto los tenemos
    tcp_nopush on; #Y lo hacemos optimizando paquetes
    gzip on; #Y encima, comprimidos
    gzip_buffers 16 8k; #Buffers y blablabla
    gzip_comp_level 1;
    gzip_http_version 1.1;
    gzip_min_length 10;
    gzip_types text/plain text/css application/x-javascript text/xml application/xml\\
\\ application/xml+rss text/javascript image/x-icon application/vnd.ms-fontobject\\
\\ font/opentype application/x-font-ttf;
    gzip_vary on;
    gzip_proxied any; # Compression for all requests.
    gzip_disable "msie6";
    server_tokens off; #Ocultemos la versión del servidor. Por tocar las $BALLS
    ssl_session_cache shared:SSL:10m; #Ajustes SSL por si hacen falta
    ssl_session_timeout 10m;
    add_header X-Frame-Options DENY; #Cuidadín con los frames y el click hijacking
    add_header X-Content-Options nosniff;
    include upstream_phpcgi_unix.conf; #includes varios, cada uno para determinadas
    include map_block_http_methods.conf; # características de la config de nginx.
    include php_fpm_status_allowed_hosts.conf;
    include nginx_status_allowed_hosts.conf;
    include apps/drupal/cron_allowed_hosts.conf;
    include blacklist.conf;
    include map_cache.conf;
    include fastcgi_microcache_zone.conf;
    include /etc/nginx/sites-enabled/*;
}

Bien. Vemos que es un conf muy simple y espartano, con un montón de includes. De arriba a abajo, carga los mime-types, llama a la config general para fastcgi, y finalmente, las configuraciones de métodos HTTP permitidos (map_block_http_methods), qué hosts tienen acceso a las páginas de estado de php-fpm y nginx (php_fpm y nginx_status_allowed_hosts.conf), desde qué hosts se puede acceder a la url de ejecución manual del cron de Drupal (cron_allowed_hosts.conf), el archivo de listas negras para user-agents y referrers (blacklist.conf), control de cookies para caché (map_cache.conf), la configuración del microcaché (fastcgi_microcache_zone.conf), y por último los "vhosts" ([..]/sites enabled/*).

Huelga decir que ésta va a ser la configuración común para todos los sites que sirva nginx, y que las afinadas vendrán en los archivos .conf que almacenemos en sites-enabled.

fastcgi.conf:

include fastcgi_params; #cargamos los parámetros de fastcgi.
fastcgi_buffers 32 32k;
fastcgi_buffer_size 32k;
#Para sacar estos valores, usad lo siguiente:
#awk '($9 ~ /200/)' access.log | awk '{print $10}' | sort -nr | head -n 1
#Para el tamaño máximo de respuesta
#echo $(( `awk '($9 ~ /200/)' access.log | awk '{print $10}' | awk '{s+=$1} END\\
#\\{print s}'` / `awk '($9 ~ /200/)' access.log  | wc -l` ))
#Para el tamaño medio de respuesta.
#Se usan 32 buffers de 32K (la respuesta media ronda los 24k). y se limita a 1MB el tamaño
#total.
fastcgi_intercept_errors on;
#Le metemos un timeout monstruoso, porque será php-fpm quien lo gestione
fastcgi_read_timeout 14400;
#
fastcgi_index index.php;
## Ocultamos cabeceras de X-Drupal-Cache. Conflictos los justos.
fastcgi_hide_header 'X-Drupal-Cache'; 
## Más de lo mismo. No siempre genera Drupal. Hay un wp por ahí...
fastcgi_hide_header 'X-Generator';

fastcgi_params:

fastcgi_param QUERY_STRING $query_string;
fastcgi_param REQUEST_METHOD $request_method;
fastcgi_param CONTENT_TYPE $content_type;
fastcgi_param CONTENT_LENGTH $content_length;
fastcgi_param SCRIPT_NAME $fastcgi_script_name;
fastcgi_param REQUEST_URI $request_uri;
fastcgi_param DOCUMENT_URI $document_uri;
fastcgi_param DOCUMENT_ROOT $document_root;
fastcgi_param SERVER_PROTOCOL $server_protocol;
fastcgi_param GATEWAY_INTERFACE CGI/1.1;
fastcgi_param SERVER_SOFTWARE nginx/$nginx_version;
fastcgi_param REMOTE_ADDR $remote_addr;
fastcgi_param REMOTE_PORT $remote_port;
fastcgi_param SERVER_ADDR $server_addr;
fastcgi_param SERVER_PORT $server_port;
fastcgi_param SERVER_NAME $server_name;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param REDIRECT_STATUS 200;
fastcgi_param HTTPS $https if_not_empty;

Éste sí que no tiene misterio: Esencialmente usamos las variables que nginx almacena de cualquier petición ($variable) para alimentar las variables de fastcgi. Las únicas excepciones son GATEWAY_INTERFACE, para forzar el modo CGI sobre el inetd y REDIRECT_STATUS, donde fijamos un código http 200 para las redirecciones a fastcgi.

upstream_phpcgi_unix.conf:

upstream phpcgi {
    fair; #Balanceo de carga. fpm0 va a su aire para Drupal, pero fpm1 y fpm2, van
          #balanceados para servir los dos wordpress.
    server unix:/var/run/php-fpm0.sock;
    server unix:/var/run/php-fpm1.sock;
    server unix:/var/run/php-fpm2.sock;
    keepalive 5;
}

Definimos los servidores que van a estar escuchando peticiones de ejecución php. Uso sockets unix porque me dan más rendimiento crudo. Si queréis usar sockets tcp, pues vale. He definido tres (uno por cada site hospedado), aunque sólo dos están balanceados. El otro es exclusivo para drupal, con sus propios ajustes. Luego lo vemos.

map_block_http_methods.conf:

map $request_method $not_allowed_method {
    default 1; #Por defecto, el método http de la petición será bloqueado
    GET 0;
    HEAD 0;
    POST 0;
    # Menos éstos tres.
}

En román paladino, evaluamos el contenido de la variable $request_method y, en función del tipo que es, le damos el valor 0 o 1 a nuestra variable $not_allowed_method.

php_fpm_status_allowed_hosts.conf y nginx_status_allowed_hosts.conf

En estos dos .conf, usaremos la directiva geo para crear dos variables: $dont_show_fpm_status y $dont_show_nginx_status, con valores 0 (permitido) o 1 (no permitido) en función de si la ip de la petición pertenece una lista de direcciones IP que definamos.

Dejo como ejemplo la de nginx_status_allowed_hosts.conf:

geo $dont_show_nginx_status {
    default 1;
    127.0.0.1 0; # permitimos la interfaz loopback
    xxx.xxx.xxx.xxx 0; # Dirección IP arbitraria
}

cron_allowed_hosts.conf

Aquí definimos qué ip's pueden ejecutar los crons de Drupal (en modo http://site/cron.php). Tal y como está definido el archivo, sólo se puede hacer desde localhost. Esto también permite ejecutar el cron desde la interfaz administrativa de Drupal:

geo $not_allowed_cron {
    default 1;
    127.0.0.1 0; # allow the localhost
}

blacklist.conf:

Esto es una variación del antiguo antibot.conf del post de blogdrake:

# Extracto de los bots que tengo bloqueados en BOFHers (1) y
# los admitidos (0)
map $http_user_agent $bad_bot {
    default 0;
    ~*^Lynx 0; 
    libwww-perl 0;
    ~(?i)(httrack|htmlparser|bash) 1;
    ~*GetRight 1;
    ~*GetWeb! 1;
    ...
}
# También se pueden usar regex OR style, como aquí.
map $http_referer $bad_referer {
    default 0;
    ~(?i)(adult|babes|click|diamond|forsale|girl|jewelry|love|nudit|organic|poker|porn|\\
\\poweroversoftware|sex|teen|webcam|zippo|casino|replica) 1;
}
# Aquí hacemos que salte el control de referrer para localhost y
# la IP pública del VPS
geo $bad_referer {
    127.0.0.1 0;
    xxx.xxx.xxx.xxx 0;
}

map_cache.conf:

# Control de cookies para caché
# Comprobamos si hay cookie de sesión y si la hay, se 
# sirve procesando. Si no, desde caché.
map $http_cookie $no_cache {
    default 0;
    ~SESS 1; 
}
# Creamos la variable $cache_uid para sesiones abiertas
map $http_cookie $cache_uid {
    default nil;
    ~SESS[[:alnum:]]+=(?<session_id>[[:graph:]]+) $session_id;
}

fastcgi_microcache_zone.conf:

#Montamos el sistema de microcaché. Para referencia, ver el post de blogdrake
fastcgi_cache_path /mnt/ramdisk/ levels=1:2 keys_zone=microcache:5M max_size=256M \\
\\ inactive=2h loader_threshold=2592000000 loader_sleep=1 loader_files=100000;
# Atentos, el almacenamiento está en /mnt/ramdisk un disco en RAM para rendimiento
# no vaya a ser que al puto Wardog le dé por publicar y salgamos en MnM
# Tamaño del microcaché, 256M. Más que suficiente.

Con ésto, terminamos con la configuración común para nginx. Esto significa que, por defecto, todos los sites que tengamos definidos en /etc/nginx/sites-enabled usarán ésta configuración por defecto. En los bloques de cada vhost se afina aún más la configuración.