CategoríaTecnología e Informática

NGINX: Reducir tiempo de carga

En este artículo veremos como realizar dos optimizaciones básicas que podemos aplicar fácilmente a la instalación que por defecto se realiza de NGINX en un sistema operativo Linux. Concretamente veremos como:

  1. Utilizar compresión gzip para enviar los archivos
  2. Controlar la caché del navegador

Estas dos optimizaciones, bastante sencillas de realizar, pueden reducir considerablemente el tiempo de carga de nuestra web. Para los siguientes pasos se asume que tienes una instalación «por defecto» de nginx en un sistema Linux y que tienes acceso al shell con un usuario que puede ejecutar comandos mediante sudo.

nginx-tiempo-carga

Crear archivos de prueba

Antes de comenzar, crearemos algunos archivos de prueba en el sitio por defecto de nginx suficientemente grandes como para realizar nuestras pruebas. Los archivos de la web están ubicados en /var/www/html/, por tanto los crearemos allí, utilizando un tamaño mínimo de 1k, ya que en la configuración que vamos a realizar, nginx no comprimirá ningún archivo que sea muy pequeño.

Hay que tener en cuenta cómo nginx determina el tipo de archivo. En lugar de analizar todo el contenido del archivo, lo que podría llevar demasiado tiempo para una respuesta rápida por parte del servidor web, nginx simplemente utiliza la extensión del archivo para determinar el tipo MIME y averiguar el propósito del archivo. Esto nos permite utilizar un archivo con cualquier tipo de contenido para realizar nuestras pruebas sin necesidad de crear archivos con contenido real. Para ello utilizaremos el comando truncate que nos permite expandir o comprimir el tamaño de un archivo. Haremos esto para un archivo html, una hoja de estilos, un archivo javascript y una imagen:

$ sudo truncate -s 1k /var/www/html/test.html
$ sudo truncate -s 1k /var/www/html/test.jpg
$ sudo truncate -s 1k /var/www/html/test.css
$ sudo truncate -s 1k /var/www/html/test.js

Habilitar compresión gzip en nginx

El primer paso será comprobar cuál es el comportamiento inicial de nuestro servidor. Para ello usamos curl con la opción -I para recuperar la información de uno de los archivos de test

$ curl -H "Accept-Encoding: gzip" -I http://localhost/test.html

HTTP/1.1 200 OK
Server: nginx/1.10.0 (Ubuntu)
Date: Fri, 20 Jan 2017 18:58:30 GMT
Content-Type: text/html
Content-Length: 1024
Last-Modified: Fri, 20 Jan 2017 18:57:18 GMT
Connection: keep-alive
ETag: "5881cece-8b11"
Accept-Ranges: bytes
Content-Encoding: gzip

¡Bien! Nuestro servidor usa la compresión gzip por defecto para los archivos html. Pero… qué pasa con los css, js…

$ curl -H "Accept-Encoding: gzip" -I http://localhost/test.css

HTTP/1.1 200 OK
Server: nginx/1.10.0 (Ubuntu)
Date: Fri, 20 Jan 2017 18:59:42 GMT
Content-Type: text/css
Content-Length: 1024
Last-Modified: Fri, 20 Jan 2017 18:57:19 GMT
ETag: "5881cfaa-45a"
Connection: keep-alive
Accept-Ranges: bytes

¡Vaya! parece que este tipo de archivos se envía sin comprimir.

Afortunadamente, nginx ya tiene las líneas de configuración por defecto, pero están comentadas. Vamos a editar el archivo de configuración ubicado en /etc/nginx/nginx.conf para aplicar la configuración:

$ sudo vim /etc/nginx/nginx.conf

Buscamos la sección de configuración de gzip y quitamos la marca de comentario (#) que quedaría así:

[...]
##
# Gzip Settings
##

gzip on;
gzip_disable "msie6";

gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
[...]

Recargamos la configuración de nginx y volvemos a probar con el archivo css:

$ sudo systemctl reload nginx
$ curl -H "Accept-Encoding: gzip" -I http://localhost/test.css

HTTP/1.1 200 OK
Server: nginx/1.10.0 (Ubuntu)
Date: Fri, 20 Jan 2017 19:01:14 GMT
Content-Type: text/css
Content-Length: 1024
Last-Modified: Fri, 20 Jan 2017 18:57:19 GMT
Connection: keep-alive
ETag: "5881cfaa-45a"
Accept-Ranges: bytes
Content-Encoding: gzip

Ahora sí, nuestro servidor también envía comprimidos los archivos css. En realidad, hace lo mismo con todos los tipos de archivo definidos en la configuración gzip_types. Podemos notar que, en la lista de tipos de archivo, no se incluyen las imágenes. Esto es debido a que los archivos jpg o png que normalmente se utilizan en la web ya utilizan algún método de compresión, lo que hace innecesario volver a comprimir con gzip (usaríamos recursos del servidor para no obtener mejora visible en el tamaño de archivo).

Ya que hablamos de uso de recursos del servidor, vamos a limitar el tamaño de los archivos que serán comprimidos por nginx, ya que a los archivos muy pequeños no merece la pena aplicar la compresión, incluso el archivo resultante podría ser de un tamaño mayor. Para ello agregamos la configuración gzip_min_length con un valor de 256 bytes.

[...]
##
# Gzip Settings
##

gzip on;
gzip_disable "msie6";

gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
gzip_min_length 256;
[...]

Recargamos el servicio para aplicar la configuración:

$ sudo systemctl reload nginx

El siguiente paso corresponde a la caché del navegador.

Controlar la cache del navegador

De nuevo comenzaremos comprobando el comportamiento por defecto de nuestra instalación mínima de nginx:

$ curl -H "Accept-Encoding: gzip" -I http://localhost/test.css

HTTP/1.1 200 OK
Server: nginx/1.10.0 (Ubuntu)
Date: Fri, 20 Jan 2017 19:11:14 GMT
Content-Type: text/css
Content-Length: 1024
Last-Modified: Fri, 20 Jan 2017 18:57:19 GMT
Connection: keep-alive
ETag: "5881cfaa-45a"
Accept-Ranges: bytes
Content-Encoding: gzip

Si nos fijamos en el resultado, podemos ver la cabecera ETag que contiene un valor único para identificar cada archivo solicitado. Un navegador web puede almacenar este valor y solicitar información sobre los cambios del archivo relacionado. Como no podía ser de otra manera, podemos hacerlo también con curl:

$ curl -I -H 'If-None-Match: "5881cfaa-45a"' http://localhost/test.html

HTTP/1.0 304 Not Modified
Server: nginx/1.10.0 (Ubuntu)
Date: Fri, 20 Jan 2017 19:12:23 GMT
Last-Modified: Fri, 20 Jan 2017 08:51:54 GMT
ETag: "5881cfaa-45a"
Connection: keep-alive

En este caso podemos comprobar que el servidor nos informa de que el archivo no ha sido modificado y que podemos usar la versión guardada en la caché del navegador.

Esto supone una mejora con respecto a descargar cada vez el archivo, pero, aún así, supone una consulta al servidor web, proceso que requiere un tiempo que, aunque pueda parecer pequeño, es importante, sobre todo si hablamos de muchas consultas.

La petición puede ser evitada informando al navegador de que no consulte el archivo hasta un tiempo después de haberlo almacenado en su caché. Esto se realiza mediante las cabeceras Expires o Cache-control. nginx permite la configuración de estos valores a través del módulo ngx_http_headers_module que viene en la instalación por defecto.

Estos valores se configuran para cada sitio, según se muestra en el siguiente ejemplo:

# Mapa para el cache del navegador
map $sent_http_content_type $expires {
default off;
text/html epoch;
text/css max;
application/javascript max;
~image/ max;
}

# Default server configuration
#
server {
listen 80 default_server;
listen [::]:80 default_server;

expires $expires;

root /var/www/html;

# Add index.php to the list if you are using PHP
index index.html index.htm index.nginx-debian.html;

server_name _;

location / {
# First attempt to serve request as file, then
# as directory, then fall back to displaying a 404.
try_files $uri $uri/ =404;
}

}

Primero agregamos un mapa que permite especificar el tipo de archivos que queremos que el navegador cachee:

  • default off: para cualquier tipo de archivo que no especifiquemos ningún comportamiento, no se incluye ninguna cabecera.
  • text/html epoch: con el valor epoch le decimos al navegador que pregunte antes de descargar el archivo.
  • text/css max y application/javascript max: tanto para los archivos de estilo como los scripts, establecemos el valor máximo posible.
  • ~image/ max: también indicamos al navegador que utilice el tiempo máximo para todos los archivos que contengan image/ en el tipo MIME del archivo (por ejemplo, image/jpg).

Una vez definido el comportamiento hay que incluirlo en la configuración del sitio, esto se realiza con la línea expires $expires.

Para que la nueva configuración tenga efecto es necesario reiniciar el servicio nginx:

$ sudo service nginx restart

Si solicitamos de nuevo el archivo observamos dos nuevas cabeceras: Expires y Cache-control.

$ curl -I http://localhost/test.html

HTTP/1.1 200 OK
Server: nginx/1.10.0 (Ubuntu)
Date: Fri, 20 Jan 2017 19:32:49 GMT
Content-Type: text/css
Content-Length: 1024
Last-Modified: Fri, 20 Jan 2017 18:51:54 GMT
Connection: keep-alive
ETag: "5881cfaa-45a"
Expires: Thu, 31 Dec 2037 23:55:55 GMT
Cache-Control: max-age=315360000
Accept-Ranges: bytes

Conclusión

Hoy en día, vuelve a estar al alza la optimización de la velocidad de carga de un sitio web, aunque a través de conexiones WiFi de alta velocidad pueda no ser un problema, el mundo de la movilidad sigue teniendo en su mayor parte conexiones muy lentas. La mayor parte de abandonos de nuestros visitantes se deben, entre otros factores, a la velocidad de carga de nuestra web; según Google &laguo;casi el 50% de los visitantes abandona un sitio web para móviles si las páginas no se cargan en tres segundos».

Pero el tiempo de carga no sólo importa a nuestros visitantes, sino también a los buscadores, que penalizarán la posición en la que indexan nuestra web teniendo (también) en cuenta la velocidad de carga.

Con una configuración mínima y sencilla podemos reducir drásticamente el tiempo de carga de nuestra web y mejorar la velocidad para evitarlo. No son las únicas medidas que podemos tomar, pero eso es harina de otro artículo…

Generar un ‘slug’ para el blog

Aunque el slug es una unidad de masa en el sistema de unidades FPS, no nos referimos a este significado sino al término anglosajón usado en la edición de periódicos donde cada artículo se etiquetaba con un nombre corto y único que permitía al reportero seguir el artículo a través de toda la línea de edición del periódico. Algunas veces, este término contenía letras o palabras clave para dar instrucciones específicas al editor. Por ejemplo, las letras AM al principio del slug indicaba que era para la edición de la mañana y las letras CX que se trataba de una corrección.

Con la llegada de los blogs el término slug fue adoptado para representar un título único del archivo, normalmente en minúsculas y sin espacios, para ser incluido en la URL de acceso al artículo, de forma que sea amigable para los buscadores (SEO). Desde este punto de vista, es importante facilitar la labor de generar un slug en nuestro gestor de contenido para facilitar la tarea a nuestros usuarios a la hora de publicar nuevo contenido.

Buscando un poco por Google podemos encontrar múltiples fragmentos de código que nos ayudan a generar un slug, la mayoría basados en el uso de expresiones regulares, lo malo es que no todas tienen en cuenta nuestros caracteres especiales como la ñ o las letras con tilde, así que generamos una en JavaScript que podamos usar en nuestra aplicación MEAN para generar el slug a partir del título del artículo.

function generarSlug(titulo) {
    return titulo.toString().toLowerCase()
      // Cambia los espacios por guiones (-)
      .replace(/\s+/g, '-')
      // Cambiamos los acentos y caracteres no ASCII
      .replace(/[ÁÀÂÃÄ]/gi, 'a')
      .replace(/[ÉÈÊË]/gi, 'e')
      .replace(/[ÍÌÎÏ]/gi, 'i')
      .replace(/[ÓÒÔÕÖ]/gi, 'o')
      .replace(/[ÚÙÛÜ]/gi, 'u')
      .replace(/[Ñ]/gi, 'n')
      .replace(/[Ç]/gi, 'c')
      // Elimina cualquier carácter que no sea una letra o un número
      .replace(/[^a-z0-9-\/]+/g, '')
      // Elimina guiones repetidos
      .replace(/\-\-+/g, '-')
      // Cambia los guiones bajos y las barras por guiones
      .replace(/[_|\s]+/g, '-')
      // Eliminamos los guiones del principio y final
      .replace(/^-+|-+$/g, '')
  }

La siguiente pregunta es dónde la vamos a usar. Por el momento la usaremos en nuestro modelo de mongoose para que, en caso de no haber especificado un slug manualmente, se genere antes de guardar nuestro nuevo artículo del blog. En realidad, si tenemos marcado el slug como un campo obligatorio en nuestro modelo, debemos insertarla antes de la validación, si no, nunca se lanzará la función.

Para ello utilizamos capturamos el evento validate antes de que sea lanzado:

articuloSchema.pre('validate', function (next) {
    if (!this.slug || this.slug.length < 1) {
      if (this.title) {
        this.slug = generarSlug(this.title);
      }
    }
    next();
  });

De esta manera, si el usuario no ha establecido un slug manualmente nuestro modelo lo comprueba justo antes de lanzar la validación y crea uno automáticamente a partir del título. Cabe destacar que el slug no es único si permitimos que el título de un artículo se repita.

Las soluciones posibles para evitarlas son:

  1. Marcar el título como único
  2. Interponer el campo _id del documento en la ruta (podemos ver este patrón en Stackoverflow)
  3. Usar la fecha del artículo para crear la URL personalizada, por ejemplo, /año/mes/slug como hacen muchos de los más conocidos gestores de blogs.

Es una cuestión de elección.

Para finalizar, os dejo un ejemplo del modelo de mongoose que podríamos usar:

'use strict';

var mongoose  = require('mongoose');
var Schema    = mongoose.Schema;

var articuloSchema = new Schema({
  titulo: { type: String, required: true },
  cuerpo: { type: String, required: true },
  fechaPub: { type: Date, default: Date.now },
  publicado: { type: Boolean, default: false },
  etiquetas: [ String ],
  slug: { type: String, required: true }
}, {
  timestamps: true
});

/**
 * Antes de guardar, generamos el slug si no tiene valor
 */
articuloSchema.pre('validate', function (next) {
  if (!this.slug || this.slug.length < 1) {
    if (this.title) {
      this.slug = generarSlug(this.title);
    }
  }
  next();
});

/**
 * Función para generar un slug a partir del título
 */
function generarSlug(titulo) {
    return titulo.toString().toLowerCase()
      // Cambia los espacios por guiones (-)
      .replace(/\s+/g, '-')
      // Cambiamos los acentos y caracteres no ASCII
      .replace(/[ÁÀÂÃÄ]/gi, 'a')
      .replace(/[ÉÈÊË]/gi, 'e')
      .replace(/[ÍÌÎÏ]/gi, 'i')
      .replace(/[ÓÒÔÕÖ]/gi, 'o')
      .replace(/[ÚÙÛÜ]/gi, 'u')
      .replace(/[Ñ]/gi, 'n')
      .replace(/[Ç]/gi, 'c')
      // Elimina cualquier carácter que no sea una letra o un número
      .replace(/[^a-z0-9-\/]+/g, '')
      // Elimina guiones repetidos
      .replace(/\-\-+/g, '-')
      // Cambia los guiones bajos y las barras por guiones
      .replace(/[_|\s]+/g, '-')
      // Eliminamos los guiones del principio y final
      .replace(/^-+|-+$/g, '')
  }

exports.Articulo = mongoose.model('Articulo', articuloSchema);
A %d blogueros les gusta esto: