Diseñando una web ultra-rápida (V)

En el anterior post vimos cómo instalar y configurar nginx con caché y cómo comprobar el rendimiento. En este post me voy a centrar en cómo no servir contenido obsoleto.

Refrescando la caché

Lógicamente si la caché dura un día, los usuarios tardarán un día desde que envían un mensaje hasta que se pueda ver. Incluso ellos mismos no verían su mensaje, lo que es muy confuso.

Sin caché, estaríamos hablando entre 1000 y 2000 por segundo, que también es muy rápido. Como el backend sólo realiza una tarea muy básica, le permite entregar una cantidad de peticiones muy alta. Gracias a esto, podemos permitirnos que las cachés sean más pequeñas si queremos, ya que el tiempo de generar el contenido es realmente bajo.

Podemos establecer unos tiempos de caché distintos según el tipo de contenido que sea. Por ejemplo para los foros puede ser bastante largo. Para los hilos un tiempo intermedio y para los mensajes sería más corto.

Es más, como el backend puede devolver para cada petición unas cabeceras de control de caché distintas, podemos hacer que los hilos viejos y los mensajes viejos que tienen menos probabilidad de cambiar, que tengan una caché más grande. Especialmente las páginas de mensajes que tienen más de un par de días. Si tenemos bloqueadas las ediciones pasado un tiempo, ya tenemos garantizado que éstos no cambian.

El contenido que puede cambiar, la caché como mucho va a ser de escasos segundos. Y a veces incluso eso va a ser mucho en algunos casos ya que la aplicación podría pedir el post que se acaba de escribir y no lo vería.

Para solucionar eso lo ideal es que al realizar la petición PUT/POST desde javascript, el servidor devuelva el JSON entrada con él y lo integre. De ese modo una caché de unos pocos segundos no le afecta y ve el contenido instantáneamente. Los demás tardarán un poco, por ejemplo 5 segundos.

Pero esto quiere decir que si un hilo recibe mucha actividad, vamos a tener muchos usuarios leyendo la última página y refrescando. Cada poco, vamos a tener que generar una versión nueva. La ventaja es que la caché de 5 segundos va a enviar la misma copia a varias personas, por lo que la carga ya se va a reducir muchísimo.

Pero esto se puede mejorar aún más.

304 Revalidate con NGINX

En NGINX se puede habilitar una opción de caché llamada “revalidate“, que cuando la caché caduca, envía al servidor una petición con E-Tag y/o If-Modified-Since, y el servidor puede responder con HTTP 304 Not Modified. Si esto sucede, NGINX renueva la caché. Ésta respuesta “Not Modified” deberá tener también las cabeceras Cache-Control y en ellas podemos incluso definir nuevos parámetros de persistencia si queremos.

Supongo que sabéis cómo funciona: En cada petición en el backend generamos o una fecha de modificación, o un string (tipo una firma) llamado E-Tag. Cuando el cliente web nos envía una petición para la que ya tiene caché, incluye éstos parámetros y podemos comparar si su versión es aún válida o la hemos cambiado. Si ha cambiado o no provee éstas cabeceras, realizamos la petición normal y devolvemos HTTP 200. Si no ha cambiado, devolvemos HTTP 304, sin contenido.

Para hacer esto en Nginx es tan sencillo como activar cache_revalidate:

uwsgi_cache_revalidate on;

La ventaja de esto es que no tenemos que generar el contenido otra vez y no tenemos que enviarlo. El truco reside en encontrar la forma de saber si ha cambiado sin apenas usar recursos. Si en la base de datos tenemos una fecha de modificación, podemos usar esa. También se puede usar redis u otro sistema de caché en memoria para almacenar la versión y actualizarla cada vez que envíen contenido nuevo.

También está cache_lock que es interesante. Si se activa, cuando llegan dos peticiones para la misma página y no tiene caché, sólo lanza una petición al backend y la segunda petición se espera. Muy útil para evitar consumo de recursos extra a costa de que el cliente espere un poco. De todos modos es muy posible que la primera petición termine antes que la segunda, por lo que no debería haber ningún retraso percibido, con la ventaja de consumir menos:

uwsgi_cache_lock on;

Volvamos a revisar la gráfica que hice al principio:

grafica

En el extremo izquierdo hay 3000 peticiones por segundo para las consultas que no pesan casi nada, y en el derecho unas 300 por segundo para las que ya tienen un tamaño considerable. Esto quiere decir que para contenido grande, podemos hacer que NGINX lo mantenga en caché a un ritmo 10 veces más rápido que generarlo de nuevo! Es como una caché sin caché. O caché inteligente.

Incluso podría ser mejor. Puede que haya contenido que requiera mucha lógica para computarlo aunque no pese demasiado. Este contenido se beneficiaría aún más de ésta práctica.

Con éste sistema, la caché se puede bajar hasta 1 segundo y el ritmo de entrega sigue siendo muy bueno. Aún así, hay que seguir considerando el cachear más tiempo el contenido que no va a cambiar, porque aún es tres veces más rápido que el 304.

Hago la prueba con caché de 1 segundo, gestión del 304 y a medir con siege:

$ siege -f urls3.txt -c 8 -ib -t 60s
(...)
Lifting the server siege...
Transactions: 216952 hits
Availability: 100.00 %
Elapsed time: 59.61 secs
Data transferred: 242.55 MB
Response time: 0.00 secs
Transaction rate: 3639.52 trans/sec
Throughput: 4.07 MB/sec
Concurrency: 7.46
Successful transactions: 216952
Failed transactions: 0
Longest transaction: 0.27
Shortest transaction: 0.00

Como vemos, llego a las 3639 peticiones por segundo. Es decir, a pesar de servir un contenido que me daba 2500, se sirve a una velocidad altísima. Y como comentaba, si el tamaño de la respuesta aumenta, ésta velocidad no se reduce apenas porque estamos devolviendo sólo un 304. El truco es devolver el 304 rápidamente, en mi caso siempre lo hago sin ningún cálculo por lo que es de esperar que en una web real haya que calcular un poco.

Métodos de refresco forzado

Aún se puede mejorar más, pues NGINX tiene otras opciones de interés. Una es el stale-while-revalidate, que hace que entregue a los clientes una copia antigua enseguida mientras él la actualiza por debajo.

 proxy_cache_use_stale updating error timeout http_500 
       http_502 http_503 http_504;
 proxy_cache_background_update on;

Al hacer estos cambios y probar, ya veo un aumento en el rendimiento:

Transactions: 239757 hits
Availability: 100.00 %
Elapsed time: 59.72 secs
Data transferred: 267.79 MB
Response time: 0.00 secs
Transaction rate: 4014.69 trans/sec
Throughput: 4.48 MB/sec
Concurrency: 7.43
Successful transactions: 239758
Failed transactions: 0
Longest transaction: 0.23
Shortest transaction: 0.00

El problema de esto es que no podemos especificar cuan vieja puede llegar a ser la caché, por lo que aunque nuestra caché sea de 1 segundo, si durante una hora nadie la pide, el siguiente que la pida verá la de hace una hora y se actualizará en segundo plano.

Si queremos corregir esto, en lugar de usar estos comandos hay que usar las cabeceras http Cache-Control con stale-while-revalidate y stale-if-error, que permiten especificar el máximo de tiempo. Pero no estoy seguro de si Nginx sigue los tiempos también.

Otra de las opciones es purgar la caché a demanda. Se puede hacer que al recibir ciertas peticiones nginx vacíe la caché. Primero establecemos que cuando se reciba una petición tipo PURGE nos asigne una variable a 1:

    map $request_method $purge_method {
        PURGE 1;
        default 0;
    }

Luego asignamos este $purge_method como flag al comando cache_purge:

uwsgi_cache_purge $purge_method;

Esto resulta en que cuando se recibe una petición de tipo PURGE, la url especificada se elimina de la caché. También acepta patrones, por lo que se puede hacer:

$ curl -X PURGE -D – "https://www.example.com/api/forums/1/*"

Otra opción son URLs que no leen de caché, pero sí guardan. Lo normal es que si una URL guarda a la caché, también lea de la caché. Y que si no lee, tampoco guarde. Esto es así porque si una URL sirve contenido basado en cookies, podría estar cacheando contenido de un usuario autenticado, con información privada y luego servirla a anónimos.

Pero si hablamos de urls que siempre devuelven lo mismo, se puede pensar en una URL alternativa o una cabecera que hace que guarde en caché el contenido nuevo, sin leerlo nunca de la caché. Y sería muy útil para en lugar de purgar la caché, refrescarla con antelación cuando sabemos que ha cambiado.

En nginx esta función la realiza “cache_bypass” y se asigna a las variables que queramos. Si cualquiera de ellas devuelve algo distinto de vacío o cero, el contenido devuelto es nuevo. Si no se especifica “no_cache” la respuesta obtenida se guardará igualmente en la caché.

proxy_cache_bypass $bypass_request

Luego ya es lanzar un wget –mirror o similar con el método adecuado o cabeceras adecuadas, y nginx refrescará la caché.

Aunque igual es más sencillo purgarla por patrón y luego llenarla de nuevo consultando normalmente. Eso ya es cuestión de gustos y de las ventajas/inconvenientes para lo que queramos hacer exactamente.

Lo dejamos aquí por hoy. En el próximo post volvemos a la carga acelerando las páginas que requieren autenticación.

Nos vemos pronto!

Diseñando una web ultra-rápida (III)

En el anterior post vimos una visión general de la arquitectura necesaria. Si no lo has leído aún, haz clic aquí. Si ya lo has leído, es momento de pasar a la acción!

Instalación

Como ya sabéis algunos, todos mis servidores son Debian GNU/Linux. Si queréis seguir los pasos, serían para Debian 9 (Stretch). Para más detalle, recomiendo la guía de NixCraft (en inglés).

Primero, instalamos el paquete “nginx“:

$ sudo apt-get install nginx

Esto instalará el servidor y lo pondrá en marcha con una web por defecto.

Si navegamos a http://127.0.0.1/, deberíamos ver la página de bienvenida de Nginx:

welcome-screen-e1450116630667

Si no veis la página de bienvenida, posiblemente tengáis otro servidor web funcionando como Apache o Lighttpd en el puerto 80. Si es el caso, la instalación con apt-get seguramente habrá fallado al iniciar el servidor. Podéis probar a detener los otros servidores web o cambiar nginx de puerto. También es posible que tuvierais restos de una instalación de nginx anterior y haya cargado la configuración anterior. En este caso se puede probar a purgar el paquete e instalarlo de nuevo.

Nginx en Debian se puede parar e iniciar con systemctl:

$ sudo systemctl restart nginx

La configuración del servidor está en /etc/nginx/nginx.conf, pero normalmente allí sólo hay cosas de configuración general y no hace falta modificar este fichero para nada. En mis pruebas este fichero no lo he modificado ninguna vez.

Normalmente modificamos /etc/nginx/sites-enabled/default, que si os fijáis es un enlace simbólico a /etc/nginx/sites-available/default. Es lo mismo que se hace con Apache.

Por defecto sirve los ficheros en /var/www/html. En servidores web lo habitual es crear otros ficheros (uno por web) en lugar de cambiar default, porque esto permite servir múltiples dominios en un servidor web. Para pruebas locales usar default es lo más sencillo porque nos permite visitar localhost en lugar de tener que configurar un dominio.

En mi caso lo que hice es copiar “default” a mi repositorio git, y hacer un enlace simbólico a él. De este modo los cambios los tengo controlados por git.

Configurando un backend

Por defecto nginx sólo sirve estáticos. Para servir contenido dinámico hay básicamente tres formas:

  • Módulo proxy: Pasa todas las peticiones a otro servidor web. Por ejemplo si alzamos un servidor de desarrollo en el puerto 8080, le podemos decir que todas las peticiones que nos lleguen las reenvíe allí. FastCGI y uWSGI son mejores alternativas.
  • Módulo fastcgi: Pasa las peticiones a otro ejecutable de nuestra elección, usando el protocolo FastCGI. Ésta es la mejor opción para servir PHP. FastCGI puede comunicar con otros procesos a través de sockets TCP, por lo que incluso podemos tener Nginx en una máquina y PHP en otra.
  • Módulo uwsgi: Pasa las peticiones a otro ejecutable, usando el protocolo uWSGI. Esta es la mejor opción para servir Python. Al igual que FastCGI, se comunica con sockets por lo que Python puede ser ejecutado en otras máquinas.

uWSGI fue diseñado originalmente para Python, pero a día de hoy soporta muchos otros lenguajes como PHP o NodeJS. Además, de los tres es el más rápido.

Como ya imaginaréis, yo lo voy a instalar con uWSGI y olvidarme del resto. Si vais a usar cualquiera de los otros dos, la configuración es prácticamente idéntica, ya que los tres módulos tienen los mismos comandos y opciones, sólo hay que cambiar el prefijo uwsgi por proxy o fastcgi.

Primero agregaremos el upstream en la parte superior del fichero:

upstream backendpython {
 server unix:///tmp/backendpython.sock; # por fichero
 # server 127.0.0.1:8001; # por TCP
}

Esto crea una variable “backendpython” y le asocia un servidor. Se pueden poner varios a la vez y nginx distribuirá la carga. La conexión puede ser un socket unix (por fichero) o socket TCP. Los sockets unix sólo funcionan en Linux/MacOSX/BSD. Los TCP funcionan también en Windows. Los sockets unix son más rápidos y más seguros, pero no permiten conectar a otra máquina.

En mi caso lo he conectado al fichero /tmp/backendpython.sock. Este fichero lo tiene que crear la aplicación uWSGI. En mi caso es un programa bash muy sencillo:

#!/bin/bash
test -f config.sh && source config.sh
export SCRIPT_NAME=backend.py
uwsgi_python3 -p6 --uwsgi-socket /tmp/backendpython.sock \
 -C777 -i --manage-script-name \
 --mount /api=backendpython:app \
 --py-autoreload 1

Esto lanza 6 procesos (-p6), nos crea el fichero de socket unix y le da permiso a todos (-C777). Para local está bien, pero en servidores habría que restringir los permisos.

También se puede hacer por TCP, o incluso HTTP. Dadle un vistazo a la documentación de uwsgi_python3 para ver cómo hacerlo.

Ahora hay que decirle a Nginx dónde tiene que usar este nuevo upstream. En la sección “location /” sería:

 location / {
 include uwsgi_params;
 uwsgi_pass backendpython;
}

Con esto ya lo tendríamos configurado. Hay que tener en cuenta que con esta configuración dejamos de servir los ficheros en /var/www/html. Todas las peticiones pasan por el backend. Esto es lo que yo quiero en mi caso porque mi backend sólo responde JSON. Para servir el frontend en Angular 6 agrego esta porción:

location /angular {
 location ~* ^.+\.(css|js)$ {
  access_log off;
  expires max;
 }
 try_files $uri $uri/;
}

En mis pruebas locales no me hace falta porque Angular levanta un servidor en el puerto 4200. (podríamos usar proxy_pass para redirigir este tráfico y así tener el autoreloader funcionando en el puerto 80)

Además, si estáis usando PHP, lo normal es que queráis servir los ficheros *.php en lugar de enviarlo todo a uno solo. Esto tiene una configuración distinta. Aquí hay una guía: Instalar Nginx con Php-fpm

Os recomiendo leer Pitfalls and Common Mistakes – NGINX que explica muy bien qué errores se suelen cometer al configurar el servidor. Incluso como advierte, hay muchas guías que cometen errores bastante grandes. Es de lectura obligada aunque estéis siguiendo una guía paso a paso.

Con esto ya tendríamos nginx funcionando con nuestro backend, pero en este punto aún no hay caché. Lo configuraremos más adelante.

Comprobando la velocidad de nuestro backend

Con el framework Flask me hice un pequeño servidor web que se conecta a MySQL y convierte los datos a json.

Con http2 la cantidad de consultas no importa tanto y no es necesario empaquetar recursos. Así que mi diseño inicial de la API son muchas urls con poco contenido.

Conseguí unas 2650 peticiones por segundo. Como uWSGI está alzando seis procesos, usa toda la cpu disponible.

La velocidad es muy buena, pero las peticiones son de 1kbyte o menos. La pregunta recurrente es, ¿qué es mejor desde el punto de vista de la cpu usada en el servidor? ¿Pocas urls grandes o muchas pequeñas?

Hay que entender que lo que intentamos conocer aquí es cuan eficaz es nuestro servidor web, cuántos recursos usará y no estamos viendo qué tan rápida es la web desde el punto de vista del usuario. Esto es a propósito. El objetivo es no gastar dinero en los servidores y poder tener tantos usuarios como quiera. Y aprovecharnos de sus dispositivos moviendo allí la lógica de la app.

Suena un poco egoísta pero es lo que hay. Hay quien hace minar criptomonedas a sus usuarios, comparado con hacer que usen sus recursos para mover nuestra web, ¿no parece tan malo, verdad?

Volviendo al tema, ¿qué tamaño de petición es ideal para nuestro servidor? Pues lo suyo es probarlo!

Dependiendo que que backend sea, framework y servidor web el resultado será distinto, así que recomendaría que cada uno haga sus pruebas y saque sus conclusiones. Yo hice las mías y éstos son los resultados :

registros bytes trans/sec kB/sec MB/sec
1 285 3364.2 931.84 0.91
2 584 3151.65 1689.6 1.65
4 1131 2995.12 3307.52 3.23
8 2279 2653.26 5908.48 5.77
12 3471 2374.81 8048.64 7.86
16 4549 2171.1 9646.08 9.42
24 7003 1835.11 12554.24 12.26
32 9025 1600.84 14110.72 13.78
48 14048 1271.97 17448.96 17.04
64 18114 1039.71 18391.04 17.96
128 36978 603.89 21811.2 21.3
256 73938 346.12 24995.84 24.41
512 147917 178.72 25815.04 25.21
1024 293412 86.62 24821.76 24.24
2048 381540 69.36 25845.76 25.24

grafica

Como se puede ver la cantidad de kBytes/s crece constantemente hasta los 16Kb y después se estanca en los 25Mb/s. La cantidad de peticiones por segundo empieza estancada y luego empieza a bajar a buen ritmo a partir de los 16Kb. (La escala de la gráfica es logarítmica en ambos ejes para facilitar la visualización de los datos)

Parece bastante claro que el punto clave son los 16Kb, donde se maximiza la cantidad de mensajes y la tasa de transferencia a la vez.

Para medirlo yo uso Siege. Es bastante completo y la carga que genera se parece bastante a la carga real de usuarios. No es el más rápido (es de los más lentos), pero los demás montan escenarios bastante poco realistas o les faltan funciones. Un ejemplo de comando sería éste:

$ siege -f urls.txt -c 8 -ib -t 60s

Las opciones que uso son:

  • -f: (file) Ruta a un fichero con una lista de urls a solicitar. El fichero es de texto plano con una url cada línea.
  • -c 8: (connections) Número de conexiones simultáneas. Para pruebas en local normalmente con sobrepasar un poco los núcleos del ordenador es suficiente. Para pruebas contra un servidor remoto puede que haya que subir el número bastante.
  • -i: (internet) Realiza las llamadas en orden aleatorio. Por defecto sigue el orden del fichero.
  • -b: (benchmark) Elimina los tiempos de espera entre solicitudes. Ideal para realizar pruebas de rendimiento.
  • -t 60s: (time) Establece cuánto tiempo va a durar el test. Se puede parar antes con Control-C, pero para resultados consistentes entre llamadas lo mejor es establecer un tiempo definido.

Con esto ya tenemos todo lo básico configurado y hemos hecho algunas mediciones interesantes. En el próximo post veremos cómo configurar la caché en nginx y medir la diferencia de velocidad.

Os espero aquí para la siguiente entrega!