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
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.