Sunday, April 06, 2008

Escalabilidad en el servidor

Al principio fue Apache, con un modo de funcionamiento extremadamente robusto. Cada petición se sirve en un proceso hijo distinto. Si algo va mal, el proceso hijo se colgaría, pero no afectaría en absoluto al resto de peticiones servidas por los otros procesos. Método lento, ya que crear un nuevo proceso es lento, pero seguro. Para mejorar el rendimiento se suele usar un "pool" (grupo) de procesos latentes, para no tener que crear un nuevo proceso con cada petición. Así nos ahorramos la creación de un nuevo proceso por petición.

Si queremos servir páginas web dinámicas y usamos Apache tenemos principalmente dos posibilidades, usar un "pool" de procesos PHP, qué comunicarán con Apache usando FastCGI, o usar ejecutar PHP directamente desde Apache con mod_php (en lugar de PHP/fastCGI podemos usar Python/SCGI o cualquier otro lenguaje). En el segundo caso, la comunicación entre Apache y el intérprete será más rápida ya que no necesita una comunicación entre procesos (Apache - PHP) por cada petición. Para servir una petición, Apache comunicará con uno de sus procesos libres del pool, y si es una petición dinámica y ese proceso aún no ha cargado mod_php, lo cargará y ejecutará el script correspondiente.

El numero máximo de recursos por segundo que podremos servir antes de agotar la memoría será:

nb_procesos_max = mem_total / mem_proc
recursos_seg = nb_procesos_max / seg_recurso

Donde mem_proc es la memoría que ocupa cada proceso Apache y seg_recurso es el tiempo medio que tardamos en servir un recurso.

Por ejemplo, si tenemos 2 GB de memoria, cada proceso consume 20 MB, y de media tardamos 0.1 segundos en servir una petición, podremos servir un máximo de 2000 / (20 * 0.1) = 1000 peticiones por segundo. Con este modelo, si queremos activar KeepAlives (ver el artículo sobre cómo acelerar la descarga de páginas web) durante 10 segundos, tendremos un límite de 2000 / (20 * 10) = 20 peticiones por segundo. ¡Hemos dividido por 100 nuestro máximo!

Para poder activar KeepAlives sin matar nuestro servidor, o simplemente si queremos consumir menos memoria, tenemos que cambiar de modelo. Sabemos que la mayoría de recursos son simples ficheros estáticos (js, css, png, jpg, etc.). No necesitamos un intérprete PHP para servirlos. De hecho, es tan sencillo que podemos hacerlo directamente desde el proceso principal del servidor web, sin necesidad de usar un proceso secundario ya que la posibilidad de que el proceso se cuelgue haciendo algo tan simple es prácticamente nula. Apache (<= 2.1) no puede funcionar en este modo, y tendremos que usar otro servidor Web, como lighttpd, nginx o cherokee.

Para los recursos dinámicos, podemos mantener un pool de procesos PHP y comunicar con ellos usando fastCGI, o simplemente seguir usando Apache y comunicar con Apache por HTTP (ideal para una migración rápida, ya que podemos seguir usando la configuración previa). La comunicacion entre servidor y PHP será algo más lenta, pero como ventaja desacoplamos el tiempo de vida de la conexión TCP con el del proceso PHP. Lo único que hay que hacer es pedirle a Apache que escuche en otro puerto (Listen 8080) y pedirle al servidor de recursos estáticos que actue de reverse proxy hacia localhost:8080 para todas las peticiones dinámicas.

La mayoría de servidores que permiten este modo de funcionamiento, permiten también usar un pool de procesos para servir los recursos estáticos, pero esta vez un pool pequeño y fijo (por ejemplo de 4 procesos). De este modo podemos aprovechar varios procesadores o un procesador multicore, y paralelizar las peticiones en caso de que alguna llamada al sistema bloquee (en teoría todas las llamadas que se usan son asíncronas, en la práctica a veces bloquean).

Consejos para acelar las páginas web - Parte II

Ya hemos hablado de la importancia de la latencia en el tiempo de descarga de las páginas web. Reducir el número de "three-way handshakes" activando KeepAlives, el número de ficheros a descargar, paralelizarlos sobre un máximo de 4 dominios, etc. son consejos importantes para reducir el impacto de la latencia, pero hay algo que sobrepasa a cualquier otra técnica para reducir el tiempo de espera cuando queremos usar un recurso (imágen, hoja de estilo, javascript, etc.), y es no mandarle nada al navegador, siempre que este tenga en caché una copia del recurso en cuestión.

El navegador divide los recursos que tiene en su caché en dos tipos, los "frescos" y los "dudosos". Los recursos frescos pueden usarse sin preguntarle nada al servidor. Los recursos dudosos se pueden usar, pero antes hay que preguntarle al servidor que nos lo envió si sigue siendo válido, o si hay que tirarlo a la basura y usar una nueva versión que el servidor le mandará al navegador.

¿Cómo controlar si un recurso entra en la caché, y si lo hace el tiempo qué permanece fresco? Con las cabeceras Cache-Control o Expires. La cabecera Expires contiene una fecha a partir de la cual el recurso debe ser considerado como dudoso. Si la fecha está en el pasado, o si no es una fecha (como "0"), se considerará inmediatamente al recurso como dudoso. La cabecera Cache-Control ofrece muchas más opciones, permitiendo por ejemplo especificar si un proxy que se encuentre entre el usuario y el servidor puede guardar el recurso en caché o si sólo el navegador puede hacerlo. Podéis leer la especificación para ver todas las subopciones de Cache-Control, siendo una de las más importantes max-age, que especifica durante cuantos segundos se tiene que considerar el recurso como fresco. Si se usa Cache-Control, toma prioridad sobre Expires.

Recordemos que si un recurso es fresco, el navegador lo usa directamente sin decirle nada al servidor. Más rápido imposible.

Si el recurso es dudoso, ¿cómo puede el navegador comprobar si ha cambiado en el servidor o no? La pregunta, en prácticamente todos los casos es completamente irrelevante. Es mejor que vuestros recursos siempre se mantengan frescos, poniendo un Expires o un Cache-Control max-age de muchos años. O están en la caché y pueden usarse directamente, o no están y hay que pedírselos al servidor.

¿Por qué muchas páginas los dejan pasar al estado "dudoso" (poniendo pequeños tiempos de expiración)? Porque quieren que si cambian el recurso, el navegador se baje la nueva versión. Esto es extremadamente problemático, porque en la práctica es imposible predecir cuando se va a cambiar un recurso, asi que no le podemos decir al navegador hasta cuando lo puede mantener fresco, o nos arriesgamos a que el navegador use una versión antigua de algunas de nuestras imágenes, páginas de estilo, javascript, etc.

Tenemos tres alternativas para atacar este problema:


  1. Ignorarlo. Es lo que hacen todos los que ponen un tiempo corto en Expires. Es incorrecto porque el cliente puede ver una nueva versión de nuestra página web con una mezcla de recursos antiguos y nuevos. Es ineficiente, porque hace que el cliente le haga una petición al servidor para comprobar la validez del recurso una vez caduca, requiriendo un mínimo de una ida y vuelta al servidor, y más típicamente dos idas y vueltas (ver el artículo anterior sobre la importancia de la latencia)

  2. Poner el Expires a 0. Es correcto, ya que el cliente siempre ve la última versión del recurso. Es ineficiente, ya que siempre es necesario comprobar la validez del recurso.

  3. Nunca cambiar un recurso. Más especificamente, cuando quieras crear una versión nueva de un recurso, no le des el mismo nombre que otro recurso ya existente en tu página web. Es correcto, ya que la versión en la caché y en el servidor de un recurso siempre es la misma. Es eficiente, ya que si se sigue usando ese recurso y está en la caché, el navegador nunca le pregunta al servidor.



La última opción es obviamente la ideal, y la que más trabajo necesita por parte del desarrollador, ergo es la opción menos usada. En realidad no es tan dificil de implementar. En lugar de poner en el HTML enlaces como "imagen.png", pondremos un enlace llamado "imagen.png?v=1". El servidor evidentemente ignorará el "?v=1", pero el navegador eso no lo sabe. Cuando actualicemos "imagen.png", cambiamos el número de versión, y en el HTML ponemos "imagen.png?v=2". Asi pues, en el mismo instante en que tengamos una versión nueva de "imagen.png" el navegador tendrá que pedírsela al servidor (él no tiene en su caché nada sobre "imagen.png?v=2"). Si queremos aumentar la legibilidad, o evitar que algún proxy no cachée nuestra imagen por usar un ? en la URL, podemos escribir imagen.v2.png, y eliminar el .v2 con mod_rewrite en Apache. Con un poco de esfuerzo, todo el proceso puede automatizarse. En Panoramio por ejemplo tenemos una tabla con el MD5 de todos los recursos estáticos qué usamos, y su número de versión (excepto para las imágenes que suben los usuarios). Cuándo subimos una nueva versión de Panoramio, un script comprueba el MD5 de todos los recursos, y si este cambia, aumentamos su número de versión. Todas las URLs a nuestros recursos las generamos a través de una función que le añade a la URL el número de versión que hemos calculado anteriormente. Todo esto se basa en que nuestro HTML no debe de estar cacheado, o los clientes puede recibir una versión antigua del HTML con una versión nueva de algunos recursos.

Si teneis algún recurso para el que este tipo de solución no es práctica, entonces tendreis que dejar que vuestros recursos pasen al estado dudoso (o no cachearlo en absoluto). Para comprobar si un recurso sigue siendo válido, el navegador le pregunta al servidor si ese recurso ha cambiado desde que se metió en la caché, usando la cabecera If-Modified-Since. Si no ha cambiado, el servidor responde con HTTP/304 Not Modified, y si ha cambiado responde enviando el nuevo contenido.

Si nuestro recurso no tiene una fecha de modificación clara (por ejemplo, si se ha generado dinámicamente), este método de verificación no nos servirá. Si ese es nuestro caso, al enviar el recurso la primera vez tendremos que añadir la cabecera ETag, que contendrá un identificador único que deberá cambiar cuando cambie el recurso. Los ETag que tanto Apache como IIS generan no dependen exclusivamente del contenido del fichero, si no que también dependen de datos especificos del servidor (por ejemplo, el inode donde se encuentra el fichero). Si usamos varios servidores, este ETag será inutilizable. En la práctica hay muy pocas razones para usar un ETag, siendo la validación por fecha suficiente en la mayoría de los casos.

En el anterior artículo de esta serie preguntasteis por herramientas para poder medir el tiempo de descarga de páginas web. Una formidable es Fiddler, desgraciadamente sólo disponible para Windows. Especificamente para Firefox tenemos firebug, sin duda el plugin de desarrollo más útil que existe para Firefox, y que incluye un panel donde podemos ver el tiempo que tarda cada petición al servidor, pero ¡cuidado al interpretar los resultados!, firebug muestra cuando las peticiones entran en la cola de peticiones de Firefox, no cuando se despacha la petición realmente al servidor.

El autor de Fiddler tiene un artículo donde habla también sobre el rendimiento de páginas web, y donde explica con algo más de detalle las diferentes cabeceras involucradas en todo el proceso de cacheo.

Queda bastante que hablar de este tema, sobre la compresión de los recursos en el servidor, y sobre todo de los distintos bugs en servidores y clientes que le ponen un poco de pimienta a la vida, pero ya es tarde. ¡Hasta la próxima!

Consejos para acelerar las páginas web - Parte I

La velocidad de carga de las páginas web viene determinada por muchos factores, pero los más importantes son la combinación de la latencia y el número de recursos a descargar, y por otro lado el ancho de banda y el tamaño de estos recursos.

Como diseñadores web, lo único que podemos hacer para disminuir la latencia entre nuestros servidores y nuestros usuarios es colocar los servidores lo más cerca posible de nuestros usuarios, o distribuirlos por todo el planeta si nuestra página web no tiene un uso mayoritario en un solo país. Pero están soluciones suelen ser muy caras y no están al alcance de todo el mundo.

En la práctica, la latencia y el ancho de banda del usuario lo tendremos que tomar como elementos incontrolables. Ya que el ancho de banda ha ido creciendo exponencialmente desde hace unos años, este elemento ya no tiene la importancia tan crítica que tenía hace tan solo unos años, y el tiempo de espera debido a la latencia y el número de recursos a descargar ha cobrado más importancia.

Veamos pues en detalle el proceso de descarga de una página web, y por qué afecta tanto la latencia al tiempo total de descarga. Cuando el navegador tiene que mostrar una página web nueva, digamos http://www.ejemplo.com/, primero tiene que convertir el nombre del dominio a una dirección IP con la que poder conectar, asi que contacta con los DNS de nuestro ISP para conseguir esta dirección IP.

Con la dirección IP en su mano, intenta establecer una conexión TCP con ese servidor, siguiendo el proceso conocido como “three-way handshake”. El navegador envía un paquete SYN y espera pacientemente la respuesta del servidor, que será un paquete SYN + ACK. Cuando el navegador reciba el paquete respuesta del servidor ya puede mandar un paquete ACK de respuesta e inmediatamente lanzar una petición HTTP GET del recurso que nos interesa (en nuestro ejemplo, “/”), a lo que el servidor responderá por ejemplo, mandando el contenido del fichero “index.html”.

Si el tiempo que tarda un paquete en llegar de nuestro navegador al servidor es de 100 ms, necesitaremos 400 ms debido a la latencia antes de que el navegador consiga el primer byte de contenido real, y ya podemos tener una flamante conexión a 20 Mb/s que no reducirá nada este tiempo de espera. A partir de aquí, y hasta que el navegador termina de recibir “index.html” pasará un tiempo dominado por el tamaño de este fichero y el ancho de banda de que dispongamos. Si “index.html” pesa 100 KB y tenemos una conexión de 4 Mb/s tardará unos (100 * 8) Kb / (4 * 1024) Kb/s * 1.2 = 235 ms. El factor “mágico” de 1.2 tiene cuenta de la sobrecarga típica de datos a enviar debido a la encapsulación de estos datos en paquetes.

Una vez con el contenido del HTML, el navegador procede a interpretarlo, y típicamente encontrará dentro del HTML referencias a otros recursos, como hojas de estilo CSS, Javascript, imágenes, etc. Para cada uno de estos recursos extra se repite todo el proceso anterior, asi pues si hacemos referencia a 10 recursos, un número muy conservador hoy en día, perderíamos 10 * 400 ms = 4 s extra debido a la latencia.

Afortunadamente el navegador paraleliza estas peticiones, pero para no sobrecargar al servidor no hace más de dos peticiones a la vez al mismo dominio. Mención especial merecen los ficheros Javascript, que no se descargan en paralelo. Si en nuestro HTML no usamos ningún fichero Javascript, los 10 recursos añadirán entonces 10 * 400 ms / 2 = 2 s extra.

¿Cómo podemos “engañar” al navegador para que descargue más ficheros en paralelo de nuestro servidor? Hay un truco muy sencillo, y es crear varios subdominios que apunten al mismo servidor, y dividir el enlace en el HTML a nuestros recursos entre estos subdominios. El navegador empezar a descargar en paralelo un máximo de 2 recursos por subdominio, sobre un máximo de 4 dominios simultáneamente. Haciendo esto el tiempo perdido estableciendo las conexiones TCP y empezando a descargar los recursos será de 0.8 s (400 ms para establecer las primeras 8 conexiones + 400 ms para las 2 últimas conexiones).

Hay una forma aún más sencilla de reducir este tiempo de espera. Si el servidor no cierra la conexión TCP con el cliente en cada petición HTTP, usando KeepAlives, dividimos por dos el efecto de la latencia en cada recurso que se descargue en una conexión ya abierta. El efecto de KeepAlives depende del número de subdominios que tengamos, si lo servimos todo desde el mismo dominio, en lugar de los 2 s anteriores tardaremos 400 ms para establecer las 2 primeras conexiones en paralelo + 200 ms por cada uno de los otros 8 recursos a bajar / 2 (número de recursos que se descargan en paralelo) = 400 + 200 * 4 = 1.2 s. Sobre 4 subdominios tendremos una espera usando KeepAlives de 0.6 s, ya que los 2 últimos recursos se descargan sin necesidad de establecer otra vez la conexión TCP.

Hay que tener en cuenta que en algunos servidores web la activación de KeepAlives tiene un impacto sobre el rendimiento muy significativo. Os recomiendo utilizar un servidor web alternativo, como lighttpd, nginx o cherokee al menos para vuestro contenido estático, con KeepAlives activado.

Un cambio que puede tener un efecto significativo en el tiempo de descarga es concatenar todos los ficheros CSS a descargar en un solo fichero (o 2, uno general al sitio y otro particular a la página), concatenar los ficheros Javascript, e incluso concatenar las imágenes, seleccionando la subimagen a mostrar usando CSS Sprites. Así podremos reducir significativamente el número de recursos a descargar.

Otro de los factores que sorprendentemente puede influir en el tiempo de descarga es el tamaño de las peticiones que hace el navegador. Cuando el navegador le pide al servidor una página, le envía también información extra, como el nombre del navegador, el idioma preferido por el usuario, las cookies, etc. Muchos de estos datos (como las cookies) no suelen influir en el resultado devuelto por el servidor para los ficheros CSS, JS o imágenes, y si nuestras cookies son cross-subdomain podemos evitar que el navegador tenga que mandarlas si colocamos los ficheros estáticos en otro dominio. Yahoo! por ejemplo usa el dominio yimg.com para servir este tipo de ficheros. A pesar de que las cookies son relativamente pequeñas, hemos de recordar que típicamente los usuarios navegan con conexiones asimétricas, donde la velocidad de subida puede ser 50 veces inferior a la velocidad de bajada de datos.

Una vez aplicados estos cambios en nuestras páginas, podremos concentrarnos en la segunda parte de mayor influencia en la velocidad, el tamaño de los recursos a descargar, y las distintas formas en que podemos influenciar la caché del navegador para evitar tener que descargar todo cada vez que un usuario llegue a nuestra página. Pero esa es otra historia de la que hablaremos otro día.