Desplegando un servidor web con alta capacidad

Aquí estoy lamiéndome las heridas de esta noche pasada. Hace unas semanas un cliente me contactó para preparar para alta capacidad una página web que administra. La página web sirve para hacer las inscripciones a una carrera de motos y el día de las inscripciones aquello se pone a fuego.

Tras un presupuesto super ajustado, el cliente opta por la opción más económica. El resultado? pues tras varios “yaque” (instálame un servidor de correo “yaqueestás”), prisas y cambios a último momento, un aumento considerable del tráfico respeto el año pasado y vete a saber que más, el servidor ha estado super saturado y no ha podido servir todas las peticiones necesarias en tiempo y forma; al menos lo importante, que son las inscripciones, se hicieron y con un bajo porcentaje de fallo.

La aplicación está hecha con zendframework2, ergo php5.6, con configuraciones a fuego en el código de la configuración del servidor, correo, etc. Además la web se encontraba en un servidor CentOS 7.0 (del 2014), con unas configuraciones crípticas, no, lo siguiente. ¿Lo de respetar los directorios según LHFS?, ¡¿pa’qué?!.

Para no tener que tocar la configuración del servidor que estaba funcionando, decidí desplegar la página web en otro servidor en mi humilde infraestructura y realizar primero ahí las pruebas.

El entorno de pruebas
Se montó una maquina virtual lxc con debian 11, con 8Gb de RAM y 2 CPU Intel(R) Xeon(R) CPU E5-2670 0 @ 2.60GHz, en mi humilde infraestructura tengo un cuello de botella pendiente de solucionar que es el CEPH, que al tenerlo sobre ethernet a la que metes un poco de caña a la infraestructura los iouts de disco se ponen por las nubes.

La configuración
Inicialmente la aplicación ya estaba montada con nginx + apache. En la nueva configuración he añadido el servidor varnish cache.

nginx
Lo primero que hice fue simplificar la configuración y hacerla mucho más entendible para evitar bucles raros.

root@test:/etc/nginx/sites-enabled# vi web
server {
     server_name dominio.com
                 www.dominio.com;

     if ($host = dominio.com) {
        return 301 https://www.dominio.com$request_uri;
     }

     if ($host = www.dominio.com) {
        return 301 https://www.dominio.com$request_uri;
     }
}

server {
    server_name www.dominio.com;

    #include snippets/certbot.conf;

    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    ssl_ciphers EECDH+CHACHA20:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:!MD5;
    ssl_certificate /etc/letsencrypt/live/www.dominio.com/fullchain.pem; # managed by Certbot
    ssl_certificate_key /etc/letsencrypt/live/www.dominio.com/privkey.pem; # managed by Certbot

    error_log  /var/log/nginx/doimnio.com.error.log error;

    include snippets/proxy.conf;
}

El contenido del snippet:

root@test:/etc/nginx/snippets# vi proxy.conf
location / {
    proxy_pass http://127.0.0.1:6081;
    #proxy_pass http://127.0.0.1:8080;
    include proxy_params;

    location ~ \.php$ {
        proxy_pass http://127.0.0.1:6081;
        #proxy_pass http://127.0.0.1:8080;
        include proxy_params;
    }

    proxy_headers_hash_max_size 512;
    proxy_headers_hash_bucket_size 128;

    fastcgi_read_timeout 300;
    proxy_read_timeout 300;

    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Real-IP       $remote_addr;
    proxy_set_header  X-Forwarded-For $remote_addr;
    proxy_set_header  X-Forwarded-Host $remote_addr;

    add_header X-Frame-Options SAMEORIGIN;
}

El puerto 8080 es para conectar directamente con la aplicación servida con el apache, el 6081 para hacerlo a través de varnish.

Apache
La configuración de apache está hecha para funcionar sobre php-fpm en lugar de libapache2-mod-php. El motivo es porqué php-fpm es posible adaptarlo más a los recursos disponibles de la máquina. Aquí cogí el fichero de configuración y lo limpié un poco, además la aplicación si la movías de su sitio original, dejaba de funcionar, así que tuve preservar la ruta, además de un FCGIWrapper raro (estaba ahí el binario metido al directorio, lo moví e hice un symlink al del sistema). Aquí puedes ver los logs activados, pero al momento de poner en marcha el servidor de producción comenté estas líneas, al igual que el log del nginx, para evitar las escrituras a disco.

root@test:/etc/apache2/sites-enabled# vi web.conf
<VirtualHost *:8080>
    ServerName www.dominio.com
    ServerAlias dominio.com
    ServerAdmin info@dominio.com
    DocumentRoot /home/admin/web/dominio.com/public_html
    ScriptAlias /cgi-bin/ /home/admin/web/dominio.com/cgi-bin/
    Alias /vstats/ /home/admin/web/dominio.com/stats/
    Alias /error/ /home/admin/web/dominio.com/document_errors/
    CustomLog ${APACHE_LOG_DIR}/dominio.com.bytes bytes
    CustomLog ${APACHE_LOG_DIR}/dominio.com.log combined
    ErrorLog ${APACHE_LOG_DIR}/dominio.com.error.log

    <Directory /home/admin/web/dominio.com/public_html>
        AllowOverride All
        Options +Includes -Indexes +ExecCGI
        FCGIWrapper /home/admin/web/dominio.com/cgi-bin/fcgi-starter .php
    </Directory>
    <Directory /home/admin/web/dominio.com/stats>
        AllowOverride All
    </Directory>
    <FilesMatch \.php$>
        SetHandler "proxy:unix:/var/run/php/php5.6-fpm.sock|fcgi://localhost"
    </FilesMatch>

    IncludeOptional /home/admin/conf/web/httpd.dominio.com.conf*

</VirtualHost>

Para tener funcionando nginx y apache al mismo momento, le decimos a apache que solo trabaje con el puerto 8080, además sólo escuchará en local, ya que los usuarios no tienen que porqué conectarse por este puerto. El contenido del fichero ports.conf será de una sola línea, todo lo demás tiene que ir comentado.

root@test:/etc/apache2# vi ports.conf
Listen 127.0.0.1:8080

Además tenemos que indicarle que el directorio donde está la web es válido

root@test:/etc/apache2# vi apache2.conf
<Directory /var/www/>
        Options Indexes FollowSymLinks
        AllowOverride None
        Require all granted
</Directory>

<Directory /home/admin/web/dominio.com/>
        Options Indexes FollowSymLinks
        AllowOverride None
        Require all granted
</Directory>

Finalmente vamos a desactivar php y apache-prefork y activar apache-worker y fcgi:

root@test:/etc/apache2# a2dismod php5.6 php7.4 mpm_prefork
root@test:/etc/apache2# a2enmod proxy_fcgi mpm_worker

Para configurar mpm_worker, modificaremos el siguiente fichero, con la configuración:

root@test:/etc/apache2# vi mods-enabled/mpm_worker.conf
<IfModule mpm_worker_module>
        # Original config
        #StartServers                    2
        #MinSpareThreads                 25
        #MaxSpareThreads                 75
        #ThreadLimit                     64
        #ThreadsPerChild                 25
        #MaxRequestWorkers        150
        #MaxConnectionsPerChild   0
        # 4 CPU config
        ServerLimit              2800
        StartServers             4
        MinSpareThreads          25
        MaxSpareThreads          75
        ThreadLimit              64
        ThreadsPerChild          25
        MaxRequestWorkers        2800
        MaxConnectionsPerChild   1000
        # 16 CPU config
        #ServerLimit              11200
        #StartServers             16
        #MinSpareThreads          100
        #MaxSpareThreads          300
        #ThreadLimit              256
        #ThreadsPerChild          100
        #MaxRequestWorkers        11200
        #MaxConnectionsPerChild   4000
</IfModule>

MariaDB/MySQL
Otro de los servicios que consumen gran cantidad de recursos es la base de datos. En la página web que se está usando, se hacen varias peticiones a la bbdd, que algunas, según mi opinión se podrían descartar. Como el alcance del trabajo no consistía en modificar el código de la web (¡valgamedios!) lo que hice fue activar la cache de MySQL, de esta forma, consultas que ya se han realizado en anterioridad, están almacenadas en la RAM y no es necesario recurrir a la base de datos para mostrar la consulta. Añado también sql-mode = “” para que sea compatible con inserts con null mal formados (” en lugar de null). Dejo también el bind-address a localhost; nadie mas que la aplicación tiene que acceder a este servidor MySQL.

root@test:/etc/mysql/mariadb.conf.d# vi 50-server.cnf
[mysqld]
sql-mode                = ""
bind-address            = 127.0.0.1

query_cache_type=1
query_cache_size = 10M
query_cache_limit=256K

varnish
Y aquí tenemos a la estrella de la fiesta. Varnish hace que una página web dinámica se cargue como una estática, además de que activa por defecto la compresión gzip, cosa que hace que se optimice mucho más el ancho de banda y el resultado sea un tiempo de carga de la página mucho inferior.

La configuración de varnish se encuentra en /etc/default/varnish

root@test:/etc/default# vi varnish
# 4 CPU config
DAEMON_OPTS="-a :6081 \
             -T localhost:6082 \
             -f /etc/varnish/default.vcl \
             -S /etc/varnish/secret \
             -s malloc,3G"

# 16 CPU config
#DAEMON_OPTS="-a :6081 \
#             -T localhost:6082 \
#             -f /etc/varnish/default.vcl \
#             -S /etc/varnish/secret \
#             -p thread_pools=14 \
#             -p thread_pool_min=200  \
#             -p thread_pool_max=5000 \
#             -p listen_depth=128 \
#             -p thread_pool_add_delay=2\
#             -p lru_interval=20 \
#             -h classic,72227  \
#             -p session_linger=120 \
#             -p sess_workspace=32768 \
#             -p connect_timeout=600 \
#             -s malloc,15G"

En la máquina de pruebas destino 3Gb a varnish, en la de producción que tiene 64Gb de RAM, la dejo a 15G

Después está el fichero de cache, que lo dejo por defecto, ya que no da tiempo de implementar en el código un purge de la cache cuando haya cambios en la página web. En varnish la cache se vacía o usando PURGE o reiniciando varnish. Apuntamos varnish al puerto 8080 que es donde se encuentra apache.

root@test-cenics:/etc/varnish# vi default.vcl
#
# This is an example VCL file for Varnish.
#
# It does not do anything by default, delegating control to the
# builtin VCL. The builtin VCL is called when there is no explicit
# return statement.
#
# See the VCL chapters in the Users Guide for a comprehensive documentation
# at https://www.varnish-cache.org/docs/.

# Marker to tell the VCL compiler that this VCL has been written with the
# 4.0 or 4.1 syntax.
vcl 4.1;

# Default backend definition. Set this to point to your content server.
backend default {
    .host = "127.0.0.1";
    .port = "8080";
}

sub vcl_recv {
    # Happens before we check if we have this in cache already.
    #
    # Typically you clean up the request here, removing cookies you don't need,
    # rewriting the request, etc.
}

sub vcl_backend_response {
    # Happens after we have read the response headers from the backend.
    #
    # Here you clean the response headers, removing silly Set-Cookie headers
    # and other mistakes your backend does.
}

sub vcl_deliver {
    # Happens when we have all the pieces we need, and are about to send the
    # response to the client.
    #
    # You can do accounting or modifying the final object here.
}

Las pruebas de carga
Para realizar las pruebas de carga usé curl y ab de apache.

curl
Curl lo usé para mirar el tiempo de respuesta de la página, tanto sin carga, como con carga. Para ello desde una máquina distinta al servidor de pruebas y distinta a la que usé para hacer el ab de apache, crée este fichero con este contenido

root@fermat:~# vi curl-format.txt
time_namelookup: %{time_namelookup}\n
time_connect: %{time_connect}\n
time_appconnect: %{time_appconnect}\n
time_pretransfer: %{time_pretransfer}\n
time_redirect: %{time_redirect}\n
time_starttransfer: %{time_starttransfer}\n
———\n
time_total: %{time_total}\n\n

Y ejecuté curl de esta forma

root@fermat:~# curl -w "@curl-format.txt" -o /dev/null -s https://www.dominio.com/
time_namelookup: 0.004
time_connect: 0.030
time_appconnect: 0.000
time_pretransfer: 0.000
time_redirect: 0.000
time_starttransfer: 0.000
———
time_total: 0.057

Apache workbench (ab)
Para hacer las pruebas de carga, usé un servidor que tengo con 24Gb de RAM y 12 CPU Intel(R) Xeon(R) CPU D-1531 @ 2.20GHz. Apache workbench (ab), está dentro del paquete apache2-utils

root@electra:~# ab -n 1000 -c 50 https://www.dominio.com/

Con esto le estamos diciendo, haz 1.000 peticiones con una concurrencia de 50 (es decir, 50 de golpe)

Resultados del benchmarking
Hice las pruebas primero en el servidor de pruebas y después en el servidor de producción que finalmente fue una instancia de OVH con 4CPU Intel Core Processor (Haswell, no TSX) con 16Gb de RAM.

servidor de pruebas

Prueba sin carga

root@fermat:~# curl -w "@curl-format.txt" -o /dev/null -s https://dominio.com/
time_namelookup: 0.004
time_connect: 0.028
time_appconnect: 0.000
time_pretransfer: 0.000
time_redirect: 0.000
time_starttransfer: 0.000
———
time_total: 0.054



Prueba con carga

root@fermat:~# curl -w "@curl-format.txt" -o /dev/null -s https://dominio.com/
time_namelookup: 0.004
time_connect: 0.031
time_appconnect: 0.000
time_pretransfer: 0.000
time_redirect: 0.000
time_starttransfer: 0.000
———
time_total: 0.060

En esta la máquina se ponía a todo trapo y aprovechaba todos los recursos de la maquina (glances). El resultado mirando varnishstat era de: 28-35, 59-67, 85-90, 31-37, 9-16: 7, 8, 5, 6, 7 (peticiones por segundo)

El resultado del comando ab

root@electra:~# ab -n 1000 -c 50 https://dominio.com/
This is ApacheBench, Version 2.3 <$Revision: 1843412 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking dominio.com (be patient)
Completed 100 requests
Completed 200 requests
Completed 300 requests
Completed 400 requests
Completed 500 requests
Completed 600 requests
Completed 700 requests
Completed 800 requests
Completed 900 requests
Completed 1000 requests
Finished 1000 requests


Server Software:        nginx/1.18.0
Server Hostname:        dominio.com
Server Port:            443
SSL/TLS Protocol:       TLSv1.2,ECDHE-RSA-AES256-GCM-SHA384,4096,256
Server Temp Key:        X25519 253 bits
TLS Server Name:        dominio.com

Document Path:          /
Document Length:        61939 bytes

Concurrency Level:      50
Time taken for tests:   158.840 seconds
Complete requests:      1000
Failed requests:        0
Total transferred:      62426228 bytes
HTML transferred:       61939000 bytes
Requests per second:    6.30 [#/sec] (mean)
Time per request:       7942.002 [ms] (mean)
Time per request:       158.840 [ms] (mean, across all concurrent requests)
Transfer rate:          383.80 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:       75   90  52.0     79     482
Processing:   385 7658 946.4   7819    8449
Waiting:      354 7633 946.2   7794    8423
Total:        462 7749 927.2   7900    8612

Percentage of the requests served within a certain time (ms)
  50%   7900
  66%   7986
  75%   8052
  80%   8084
  90%   8172
  95%   8232
  98%   8304
  99%   8405
 100%   8612 (longest request)

servidor de producción 4CPU

Prueba sin carga

root@fermat:~# curl -w "@curl-format.txt" -o /dev/null -s https://dominio.com/
time_namelookup: 0.004
time_connect: 0.031
time_appconnect: 0.000
time_pretransfer: 0.000
time_redirect: 0.000
time_starttransfer: 0.000
———
time_total: 0.059


Prueba con carga

root@fermat:~# curl -w "@curl-format.txt" -o /dev/null -s https://dominio.com/
time_namelookup: 0.004
time_connect: 0.030
time_appconnect: 0.000
time_pretransfer: 0.000
time_redirect: 0.000
time_starttransfer: 0.000
———
time_total: 0.078

Peticiones por segundo: 967-301, 035-308, 145-473: 334, 273, 328

La salida de ab

root@electra:~# ab -n 10000 -c 50 https://dominio.com/
This is ApacheBench, Version 2.3 <$Revision: 1843412 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking dominio.com (be patient)
Completed 1000 requests
Completed 2000 requests
Completed 3000 requests
Completed 4000 requests
Completed 5000 requests
^C

Server Software:        nginx/1.18.0
Server Hostname:        dominio.com
Server Port:            443
SSL/TLS Protocol:       TLSv1.2,ECDHE-RSA-AES256-GCM-SHA384,4096,256
Server Temp Key:        X25519 253 bits
TLS Server Name:        dominio.com

Document Path:          /
Document Length:        62664 bytes

Concurrency Level:      50
Time taken for tests:   16.857 seconds
Complete requests:      5371
Failed requests:        0
Total transferred:      339059660 bytes
HTML transferred:       336794736 bytes
Requests per second:    318.63 [#/sec] (mean)
Time per request:       156.923 [ms] (mean)
Time per request:       3.138 [ms] (mean, across all concurrent requests)
Transfer rate:          19642.83 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:       32   69  12.3     67     198
Processing:    24   87  16.3     85     185
Waiting:       19   65  12.3     64     139
Total:         57  156  25.2    153     305

Percentage of the requests served within a certain time (ms)
  50%    153
  66%    162
  75%    169
  80%    172
  90%    185
  95%    203
  98%    225
  99%    238
 100%    305 (longest request)

Con esto la máquina daba señales que sería capaz de gestionar el numero de peticiones que pidió el cliente.

Conclusiones

Esto con la propuesta ampliación a 16 cores, nos daba indicios que el tunning era suficiente para satisfacer un numero similar a las visitas que ocurrieron el año anterior.

Subimos la máquina a 16 cores 3 horas antes del inicio de las inscripciones (estas cosas de ahorrar dinero y tal) y apliqué la configuración que has visto comentada en los ficheros de configuración y mi sorpresa fué que quien se saturaba ahora era el php-fpm y que sólo levantaba 4 threads. Esto hizo que la máquina en lugar de alcanzar el 100% de CPU, se quedase a 30-35% y de consumo de RAM 64Gb) no subiese del 25%.

Pero la puesta en marcha del servidor de producción tuvo un par de detallitos mas, además de la falta de tiempo y mi poca experiencia en entornos de alto estrés (avisado de previa mano a mi cliente), la necesidad de instalar un servidor de correo (novedad respeto el año pasado que estaba fuera)… venga, métele a un servidor con mucha carga, un modoboa completico con postfix, dovecot, dkim, amavis, etc., mandando 3 correos electrónicos por inscripción (el correo al usuario y al administrador de la web, la recolección del correo del administrador de la web y el reenvio al alias del administrador de la web).
Además, las pruebas que habíamos hecho, no contemplaban insercciones en la base de datos (que en el momento crítico este almenos parece que estaba alto pero no saturado, oscilaba entre el 2% y el 60% de CPU en un solo thread).
Y lo más crítico, casi el doble de peticiones del año anterior (¿se terminó la pandemia?, ¡todos a la carretera!)

El momento crítico ha sido des de la apertura de las inscripciones a las 23:45 hasta las 01:15 que es cuando empezaron a bajar las peticiones (que segun varnishstat han sido de entre 60 y 130 peticiones por segundo durante la hora y media que ha durado el chaparrón de usuarios). Durante el momento de mas carga, de la cual 1,5h ha sido crítica (23h a 2h) ha habido un total de 29.544 visitas (26% mas que el año pasado) y visitantes únicos 2.852 (70% mas que el año pasado) a la web de inscripciones.

Esto ha hecho que algunos pagos fallasen o incluso se duplicasen (aprox 40 de ~857), en total de registros (pagados o no) es de 2780.

Por desgracia no tomé capturas de pantalla del momento de carga real del servidor. Reinicié los servicios, de poco sirvió, reinicié la máquina bajo mi resistencia, también de poco sirvió. En aquel momento era cruzar los dedos y confiar que la máquina hiciese el trabajo lo mejor posible. En algún momento había errores 500 que directamente el nginx no soportaba mas peticiones y 503 del varnish donde era el apache que daba timeout. Los momentos que era capaz de servir la página tardaba entre 3 y 20 segundos. El curl seguía respondiendo bastante rápido con respuestas de 0.2s.

Durante el chaparrón aplicamos algunos métodos para descargar la máquina, como vaciar la cola del postfix que estaba llena de mails en espera a enviar al correo del administrador (el alias), donde microsoft rechazaba los correos por exceso de envios. Tras esto modificamos el código para que no mandase el mail al administrador tras cada inscripción. Estas dos acciones permitieron dar un poco mas de aire al servidor.

La cosa es que ahora el cliente está enfadado de que la web, durante una hora y media, no respondió según lo esperado; por parte de los técnicos, que llevamos horas comparando los datos con el año anterior, nos quedamos con que nos hemos salvado de un fail absoluto y que el trabajo técnico realizado ha sido bastante notable. A la hora de escribir este post, el cliente final se ha puesto en el bolsillo alrededor de 4 ceros (con un ocho delante). El miércoles reunión para ver cuanto de enfadado está el cliente finalmente, de momento está en modo que el año que viene que no quiere repetir. Yo personalmente, tal como se ha trabajado, e incluso poniéndome presión el último día de mis vacaciones y con unos tiempos y presupuesto tan ajustado, tampoco.

No sé cuál fue la configuración que nos jodió y no nos permitió darle mas tralla a la máquina, se aceptan propuestas con el caso y los datos planteados a nivel de sistemas (la aplicación era la aplicación que era, si la configuración del primer servidor era kaos, imaginate el código). Lo que si que tengo claro es lo que digo siempre, 1 servidor – 1 servicio, sea en una máquina virtual o en otro servidor. Siempre da mucho mas juego diseñar un sistema entero para un servicio que un sistema para varios servicios, además evitas que se pisen procesos con otros. Una cosa que me quedó en el tinterio era tunear las tramas TCP, tal como hice en éste post.

Muchas gracias y espero que el post te haya sido interesante.

Links:
NGINX Tuning For Best Performance
7 Tips for NGINX Performance Tuning
How To Optimize MySQL with Query Cache on Ubuntu 18.04

One Comment

  1. Caram, caram… interesante. Si la aplicación tuviera más componentes estáticos podría ahorrarse unas cuantas peticiones al fpm. El cache frontal no es eficaz con cargas de datos dinámicas, por lo que si se usara html en el frontal para para la portada y la página de inicio de sesión y el formulario de inscripción fuera una ruta pública no condicionada por script de verificación de sesión (solo el POST) hasta se podría ahorrar nginx y varnish. Pero claro, en estos casos el código viene ya hecho y mal optimizado, así que toca hacer estos apaños. Salut.

    Respon

Deixa un comentari

L'adreça electrònica no es publicarà. Els camps necessaris estan marcats amb *

Aquest lloc utilitza Akismet per reduir els comentaris brossa. Apreneu com es processen les dades dels comentaris.