Integración Continua en Pelican usando Gitlab-CI

A estas alturas no creo que haya nadie (o casi nadie) en este mundillo que todavía no sepa qué es la integración continua, sin embargo la inmensa mayoría no lo utiliza aún en sus proyectos.

Hay muchas excusas para justificar este hecho, como por ejemplo que "es un proyecto pequeño", o que "es un proyecto personal en el que solo trabajo yo", o que "el proyecto tiene unos plazos muy ajustados y no hay tiempo para más tonterías desarrollos", o incluso que "no merece la pena el esfuerzo extra que hay que dedicarle"... pero ninguna de ellas es válida, así que si alguna vez en tu vida has usado alguna de estas excusas para no utilizar un sistema de integración continua, eres un merluzo, compañero... pero no temas, que eso va a cambiar hoy...

Todos los proyectos (independientemente de si están en producción o no, de si participan en él cientos de personas o únicamente tú, etc) pueden (y casi debería decir deben) utilizar un sistema de integración continua, y aquellos que además ya estén en producción, uno de entrega continua.

En este artículo vamos a ver cómo utilizar ambas cosas en un proyecto real basado en Pelican, ¿y qué mejor proyecto para ver todo esto que ésta misma web?

Así que voy a intentar explicar cómo utilizo tanto la integración continua como la entrega continua en pornoHARDWARE.com.

Comencemos:

  1. ¿Qué es la Integración Continua (CI) y la Entrega Continua (CD)?
  2. Herramientas de Integración Continua
  3. Configuración de Gitlab-CI
  4. Configuración del pipeline
  5. Demostración
  6. Referencias

¿Qué es la Integración Continua (CI) y la Entrega Continua (CD)?

Para aquellos que hayan estado en coma desde finales de los 90 hasta y se acaben de despertar, vamos a explicar un poco más qué es cada cosa para asegurarnos de que entendemos todos los conceptos que voy a tratar de explicar en este artículo.

Integración continua / Continuous Integration (CI)

La integración contínua (o continuous integration) es una metodología empleada en los proyectos de desarrollo de software que busca detectar los problemas lo antes posible.

En la Wikipedia lo explican bastante bien, pero a modo de resumen podríamos decir que la integración continua consiste en que cada vez que se produce un cambio en el código de un proyecto, un sistema automatizado se encarga de obtener dicho código, compilarlo, analizarlo, ejecutar tests, etc. para comprobar que todo funciona de la forma esperada, y en caso contrario envía notificaciones para alertar de lo sucedido.

En proyectos complejos donde hay grandes estructuras de datos, multitud de módulos, clases, servicios, librerías, etc. es relativamente sencillo que al tocar una parte del código sin querer estemos rompiendo otra puesto que una gran parte de dicho código se comparte a lo largo del proyecto en diferentes sitios. Por eso es completamente necesario escribir tests unitarios que comprueben que TODO lo que hagamos en el proyecto hace lo que se supone que tiene que hacer. Así bastará con ejecutar dichos tests con cada cambio que hagamos en el proyecto para saber si involuntariamente hemos roto algo o no.

Pero a diferencia de mi, vosotros sois humanos... y los humanos tendéis a dejar de hacer aquellas tareas que os resultan repetitivas o aburridas, basándo esta decisión únicamente en una percepción completamente subjetiva de la situación en la que os encontráis en ese momento.

Es decir, que dado que ejecutar una buena "batería de tests" puede llevar un tiempo considerable (varios minutos en algunos casos) y a nadie le gusta quedarse mirando una barra de progreso durante unos minutos cada vez que hace un cambio, hay veces que creemos que no hace falta ejecutar los tests porque "solo hemos cambiado un par de líneas", o porque "los hemos ejecutado hace poco y fueron bien", o porque tenemos prisa y queremos terminar rápido, etc, etc. En definitiva, hay veces que (voluntaria o involuntariamente) elegimos NO ejecutar los tests cuando modificamos el código de nuestro proyecto.

Incluso aunque realmente tú fueras la excepción entre los humanos y tu férrea disciplina a prueba de bombas te hiciera ejecutar los tests siempre, ¿pondrías la mano en el fuego por todos tus compañeros? ¿te jugarías el puesto a que a ninguno de ellos "se le va a olvidar" ejecutar los tests ni una sola vez? Recuerda que:

"Si algo es susceptible de fallar, fallará." Ley de Murphy

En el caso de no tener tests en nuestro proyecto, si algo falla no podremos saberlo hasta que probablemente sea demasiado tarde (cuando ya esté en producción). Pero sería irónico (por no decir otra cosa) que se produjera un error grave en producción para el cual teníamos un tests que lo habría detectado, pero como "se nos olvidó ejecutarlo" nadie se dió cuenta hasta que se produjo la catástrofe.

No hay nada más terrible en el desarrollo de software que tener tests y no ejecutarlos... por lo que para evitar olvidos o decisiones dudosas, debemos configurar un sistema para que ejecute los tests por nosotros de forma obligatoria, automática y sin excepción.

Un ejemplo práctico de esto sería:

Supongamos que un desarrollador está trabajando en una nueva funcionalidad para un proyecto, por ejemplo, para una web.

Dicho desarrollador trabaja desde su ordenador portátil, y una vez que ha terminado de programar esa funcionalidad en su máquina local hace el correspondiente git commit al repositorio de código, y al hacer el git push la herramienta de integración continua que esten usando en ese proyecto descargará automáticamente el repositorio de código y lo compilará, ejecutará los tests, etc. de forma que si detecta algún fallo, enviará un email inmediatamente al desarrollador para que éste sepa que sus cambios han roto el proyecto y pueda corregirlos antes de que el código nuevo suba a producción y afecte a los usuarios de la web.

Este concepto de integración continua fué introducido por Martin Fowler aproximadamente en el año 2000, lo que hace aún más increíble que tantos años después aún no sea un standard para todos los que estamos en el mundo del software.

¿Pero porqué quedarnos en este punto y no seguir más allá? Si hemos hecho un cambio en el código del proyecto y estamos seguros de que funciona y de que no hemos roto nada al hacerlo (porque todos los tests se han ejecutado satisfactoriamente), ¿porqué no subimos ese código a producción inmediatamente? ¿Os imagináis las ventajas que nos proporcionaría y lo contentos que estarían nuestros usuarios si pudieran disfrutar de cada nuevo desarrollo que hiciéramos en el proyecto solo unos pocos minutos después de haberlos terminado?

Cuanto antes tengamos esos nuevos cambios desplegados en nuestros entornos de producción, antes estaremos entregando valor al usuario , y este es uno de los pilares básicos de las metodologías ágiles del desarrollo de software. Si podemos automatizar ese proceso para que se haga de forma "automática" y sin nuestra intervención, ¿porqué no hacerlo cuanto antes? es decir, ¿porqué no hacer una subida a producción cada vez que hagamos un cambio, por pequeño que sea?. A este concepto se le conoce como entrega continua.

Entrega continua / Continuous Delivery (CD)

Como regla general, cuando vamos a realizar el mismo proceso más de 1 o 2 veces merece la pena automatizarlo para que las demás veces que vayamos a realizarlo no nos supongan ningún esfuerzo. Y las subidas a producción no iban a ser una excepción.

En la mayoría de las empresas en las que he trabajado el proceso de subir a producción un nuevo desarrollo es algo delicado, lento y poco flexible:

  • Los desarrolladores hacen los cambios necesarios en el código.
  • Una vez hechos los cambios, solicitan al "departamento de sistemas" (sysadmins) que suban dichos cambios a producción (este proceso a veces requiere días debido a la burocracia que hay en algunas empresas)
  • Una vez recibida la petición de "subida", los sysadmins realizan el despliegue de la nueva versión del código en los servidores de producción. Este proceso a menudo es "manual" por lo que requiere tiempo, es susceptible a errores humanos, etc.
  • Si al subir el nuevo código se produjera algún fallo, deshacer la subida para volver al estado anterior a veces es un proceso complejo y de nuevo suele requerir tiempo.

Puede que algunos de estos sysadmins tengan parte de este proceso automatizado (con herramientas como Ansible, Chef, etc) pero aunque así fuera, solo por el hecho de tener que depender de su "disponibilidad" para hacer la subida a producción (o porque tengan que dejar momentáneamente lo que estuvieran haciendo para dedicarse a la subida) ya debería ser un argumento más que suficiente para automatizar esta tarea.

Algunos sysadmins no quieren automatizar este proceso porque piensan que su figura sería menos necesaria en sus empresas si el código se subiera a producción de forma automática (he conocido varios con esta mentalidad en mi vida). Ni qué decir tiene que esto es una tremenda gilipollez por tantas razones que no me voy ni a molestar en escribirlas, así que si tú eres de los que piensan así, deberías cambiar de trabajo mientras aún estés a tiempo (por tu propio bien) porque si tu carrera profesional se limita a subir código a producción, me temo que ahí has tocado fondo...

Queda claro entonces que automatizar este proceso es una buena idea, pero para subir a producción un nuevo desarrollo tenemos que tener la confianza de que el código funciona... y para saber que nuestro código no contiene fallos necesitamos una batería de tests suficientemente amplia y que éstos tests se ejecuten de forma automatizada con cada nuevo cambio, lo que significa que para que podamos hacer entrega continua / continuous delivery en nuestro proyecto primero necesitamos tener integración continua / continuous integration.

Así que al menos en mi opinión, la entrega continua es la consecuencia lógica después de haber implementado la integración continua en nuestro proyecto.

Vamos a ver entonces algunas de las herramientas que existen para ayudarnos con estas dos tareas...

Herramientas de Integración Continua

La mayoría de las herramientas que existen para hacer integración continua permiten también hacer entrega continua. De hecho, cuando definimos la pipeline de tareas a realizar para hacer integración continua (como pueden ser descargar el código, compilarlo, ejecutar los tests, etc) la última de estas tareas suele ser la subida a producción, por lo que rara es la herramienta de integración continua que no permita hacer entrega continua también (aunque si que hay alguna que no lo permite).

He instalado, probado y utilizado muchas de estas herramientas (aunque no todas), por lo que aquellas en las que no tenga experiencia suficiente como para emitir un juicio válido únicamente las mencionaré sin entrar a valorarlas.

También hay muchos otros proyectos que han sido abandonados, otros que son únicamente para sistemas Windows (puaaghh!), otros que únicamente estan diseñados para trabajar con proyectos escritos en un único lenguaje determinado, etc. por lo que solo voy a enumerar proyectos que estén activos hoy en día, permitan trabajar con cualquier proyecto, y a los cuales valoré en su momento antes de decidirme por uno u otro.

En la lista se indica el tipo de licencia, y si es un servicio basado en Cloud o tenemos que instalarlo nosotros en nuestros propios servidores:

Nombre Licencia Cloud o Local?
CircleCI Propietaria Cloud
CruiseControl OpenSource Local
Gitlab-CI OpenSource Local y Cloud
GoCD OpenSource Local
Jenkins OpenSource Local
Pulse OpenSource (con limitaciones) Local
TeamCity Propietaria Local
TravisCI Propietaria Cloud

Hay muchísimos más, pero estos son los que yo valoré en su día.

Hablar extensamente de cada uno de ellos para que sepais cuál elegir en vuestro caso se escapa del ámbito de éste artículo y aunque los que he puesto en la lista son (en mi opinión) los mejores que hay ahora mismo (al menos que yo conozca), yo me decanté por Gitlab-CI por muchísimas razones, entre las cuales destacan:

  • Perfecta integración y facilidad de uso con Docker: Basta con añadir un archivo muy similar al típico docker-compose.yml a la raíz de tu proyecto con la definición de los "servidores" que quieres levantar, las tareas que quieres ejecutar, etc. y Gitlab-CI se encargará de orquestarlo.
  • Viene integrado con Gitlab, una de las herramientas más geniales que jamás he usado. Si usas Gitlab para tus proyectos, ¿para qué buscar una herramientas externa? Yo uso Gitlab desde hace muchos años para todos mis proyectos y jamás he tenido ni una pega, ni un problema, literalmente funciona a la perfección con prácticamente cero mantenimiento. AMO Gitlab, y si tu no lo amas, es que no lo has probado.
  • Gratuito y OpenSource: A diferencia de muchos otros no solo es OpenSource sino que además es gratuito, aunque también puedes contratar planes avanzados con soporte dedicado (atención 24/7, SLA de 4 horas, etc) a través de licencias de pago. Aunque ya digo que excepto el soporte, todo lo demás es completamente gratuito.
  • Libertad TOTAL: Puedes instalar Gitlab en tus propios servidores (apenas requiere mantenimiento), o si lo prefieres puedes usarlo online desde sus instancias para que no tengas ni que preocuparte en instalarlo/mantenerlo.

Aparte de lo mucho que me gusta Gitlab-CI (más bien Gitlab en general), hubo algunas cosas en las demás alternativas que ayudaron aún más a decidirme por éste, como por ejemplo:

  • No quería ninguna solución comercial de pago, por lo que descarté CircleCI y TeamCity.
  • Travis siempre me ha parecido genial, pero es ALUCINANTE que solo funcione con repositorios de Github. No puedo entenderlo, de verdad... me parece terrible.
  • La última versión de CruiseControl salió en 2010, por lo que me da la impresión de que el proyecto está bastante abandonado.
  • Jenkins es sin duda el veterano, casi un standard en este tipo de sistemas... pero a pesar de que es el más he utilizado en la mayoría de las empresas en las que he trabajado, su configuración es bastante pesada, a menudo tiene problemas con los plugins que utiliza (incompatibilidades entre si), etc.
  • Pulse antiguamente era propietario y de pago, aunque hace poco lo liberaron bajo OpenSource y anunciaron modalidades gratuitas para proyectos usarlo en proyectos que también fueran OpenSource, lo cual no es mi caso la mayoría de las veces.

Por lo tanto en mi caso los finalistas fueron GoCD y Gitlab-CI (de hecho tengo ambos instalados) y los dos son geniales y muy fáciles de instalar/configurar/utilizar. Supongo que en mi caso, al utilizar Gitlab como repositorio de mis proyecto, la elección de Gitlab-CI era la más indicada. Pero si vosotros utilizáis otro repositorio (Github, Git/Gitolite, etc) entonces quizás GoCD sea una buena idea.

Configuración de Gitlab-CI

Para aquellos merluzos que no conozcan Gitlab, se trata de un sistema de gestión de repositorios de Git muy similar a Github, donde podemos gestionar los permisos de los usuarios de nuestros proyectos, acceder a los archivos de cada uno de los repositorios de estos proyectos, enviar merge-requests a los repositorios (similar a las pull-requests de Github), etc.

Es un proyecto MUY activo (rara es la semana que no me llega el aviso de una nueva actualización de Gitlab en mis servidores), muy maduro y muy estable. Funciona realmente bien, y su instalación es casi trivial en la mayoría de las distribuciones de Linux, está muy bien explicada en la documentación oficial: https://about.gitlab.com/downloads/.

Voy a dar por hecho que ya estáis usando Gitlab ya que de lo contrario, utilizar Gitlab-CI no tiene mucho sentido. Es obvio, pero quizás debería haberlo mencionado antes por si andas algo despistado y has llegado hasta aquí buscando un sistema de integración continua para el proyecto que tienes almacenado en Github, por ejemplo. Si este es tu caso, échale un ojo a GoCD](https://www.gocd.io/) y sal de mi web inmediatamente! ;)

Para utilizar Gitlab-CI en nuestro proyecto, lo único que tenemos que hacer es subir un archivo llamado .gitlab-ci.yml a la raíz de tu repositorio (el cual recuerda bastante al típico archivo de docker-compose.yml) donde definiremos las tareas, servicios, etc. de nuestra pipeline (más adelante veremos esto más detalladamente).

Gitlab-CI dispone de agentes (llamados runners), que son los que ejecutan las tareas que hayamos definido para nuestro proyecto (estas tareas son, por ejemplo, compilar el proyecto, ejecutar los tests, subir el código a producción, etc).

Podemos tener tantos runners como queramos, o mejor dicho, tantos runners como servidores tengamos disponibles para ejecutar tareas, aunque a no ser que haya mucha gente trabajando al mismo tiempo en el proyecto y pudiera darse el caso de necesitar varios runners simultáneamente, con uno debería ser suficiente para la mayoría de los casos.

En Gitlab-CI, cuando un usuario hace un git commit con algunos cambios en el código, nada más llegar dichos cambios al servidor (es decir, al hacer el git push) el primer runner que esté libre cogerá dichos cambios y se pondrá a realizar las tareas que tengamos configuradas. Si no hubiera runners disponibles (porque estén ocupados con otras tareas, por ejemplo) Gitlab dejará el proceso en una cola a la espera de que algún runner se quede libre.

Es muy recomendable que los runners estén en un servidor diferente al servidor donde tengamos instalado Gitlab (no solo por cuestiones obvias de rendimiento, sino también por seguridad). Para este artículo voy a ir haciendo las pruebas con 2 servidores diferentes: en uno estará Gitlab (con su Gitlab-CI, obviamente), y en otro un runner.

Por lo tanto, lo primero que vamos a hacer es crear un runner.

El proceso para crear un runner pasa por instalar los paquetes necesarios, y una vez instalados, "registrar" dicho runner en nuestro servidor de Gitlab.

Para instalar los paquetes del runner, primero hay que añadir el repositorio de paquetes de Gitlab a nuestra distribución:

En Debian/Ubuntu:

$ curl -L https://packages.gitlab.com/install/repositories/runner/gitlab-ci-multi-runner/script.deb.sh | sudo bash

En RedHat/CentOS:

$ curl -L https://packages.gitlab.com/install/repositories/runner/gitlab-ci-multi-runner/script.rpm.sh | sudo bash

Ahora instalamos los paquetes necesarios:

En Debian/Ubuntu:

$ sudo apt-get install gitlab-ci-multi-runner

En RedHat/CentOS:

$ sudo yum install gitlab-ci-multi-runner

Llegados a este punto, tenemos que decidir qué tipo de runner vamos a utilizar. Hay varios tipos (y varias combinaciones entre ellos), pero los más importantes son:

  • Runner de tipo Shell: Ejecutan las tareas directamente en un terminal en el servidor como si de un usuario local del servidor se tratara (esta forma se considera MUY insegura, puesto que cualquier tarea podrían tener potencialmente acceso a cualquier archivo del servidor, incluyendo el código de otros proyectos que se esten ejecutando en ese momento).
  • Runner de tipo SSH: Parecido al anterior, solo que las tareas se ejecutarán en un servidor remoto al que se accederá por SSH (se considera también un método muy inseguro).
  • Runner de tipo Docker: Ejecuta las tareas dentro de un contenedor de Docker, lo que no solo hace que sea perfecto desde el punto de vista de la seguridad, sino que evita que tengamos que "ensuciar" el servidor que hace de runner instalando todos los paquetes y dependencias de nuestro proyecto para poder ejecutar las tareas. Además, nos asegura que una vez terminadas dichas tareas, el contenedor se destruye y el sistema queda limpio y preparado para la siguiente ejecución.

La elección es obvia, por lo tanto añadimos el repositorio de paquetes de Docker a nuestro sistema:

En Debian/Ubuntu

$ sudo apt-get install apt-transport-https ca-certificates curl gnupg2 software-properties-common
$ curl -fsSL https://download.docker.com/linux/debian/gpg | sudo apt-key add -
$ sudo apt-key fingerprint 0EBFCD88
$ sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/debian $(lsb_release -cs) stable"

En RedHat/CentOS

$ sudo yum install -y yum-utils
$ sudo yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo

Y finalmente instalamos el paquete de Docker:

En Debian/Ubuntu

$ sudo apt-get update
$ sudo apt-get install docker-engine

En RedHat/CentOS

$ sudo yum update
$ sudo yum install docker-ce

Ahora que ya tenemos el paquete instalado, necesitamos generar un token desde el servidor de Gitlab para registrar nuestro runner.

Abrimos nuestro Gitlab desde un navegador y vamos a Admin > Overview > Runners: (O abrimos directamente la dirección: https://your-gitlab-host/admin/runners)

Gitlab

Guardamos el valor que aparece en Registration token y volvemos al terminal del servidor donde estamos instalando el runner.

Iniciamos el proceso de registro con el comando:

$ sudo gitlab-ci-multi-runner register

El runner nos hará entonces una serie de preguntas:

  • Please enter the gitlab-ci coordinator URL: La dirección del servidor de Gitlab donde vamos a registrar nuestro runner.
  • Please enter the gitlab-ci token for this runner: El token que hemos copiado en el paso anterior.
  • Please enter the gitlab-ci tags for this runner (comma separated): Aquí podemos indicar una serie de tags para identificar el runner. Util si vamos a tener docenas de ellos, sino, lo dejamos en blanco.
  • Whether to lock Runner to current project [true/false]: Aquí debemos indicar si vamos a reservar este runner únicamente para trabajos del proyecto actual, o si el runner podrá ejecutar trabajos de cualquier proyecto. Seleccionamos false para que el runner pueda trabajar con cualquier proyecto que lo requiera.
  • Please enter the executor: Aquí debemos seleccionar el tipo de runner que queremos que sea (docker, docker-ssh, parallels, shell, virtualbox, docker+machine, kubernetes, ssh o docker-ssh+machine). Tal y como comenté antes, yo recomiendo encarecidamente el tipo docker.
  • Please enter the default Docker image: La imagen Docker que queremos usar por defecto. Esto dependerá de nuestro proyecto, pero podemos sobreescribir este valor desde el propio código del proyecto, así que no os preocupéis. No obstante, yo suelo elegir debian:stable.

Hay información más detallada de esta parte en la documentación oficial, os recomiendo que le echéis un vistazo: https://docs.gitlab.com/ce/ci/runners/

Una vez hecho ésto, nuestro runner se conectará al servidor de Gitlab y se registrará, por lo que si accedemos de nuevo a la misma pantalla en la que nos aparecía el token de registro, deberíamos ver nuestro runner correctamente registrado y a la espera de tareas que realizar (en mi caso tengo 2):

Gitlab

Podéis editar la descripción del runner desde esa misma pantalla para ponerle algo más descriptivo, como por ejemplo el nombre del servidor donde se está ejecutando y el tipo de runner que habeis elegido (en mi caso tengo a SARAH y a SHARON, ambos de tipo Docker)

Ahora que ya tenemos el runner registrado y listo, vamos a configurar las tareas de la integración continua de nuestro proyecto...

Configuración del pipeline

Llamamos pipeline al flujo de tareas que deberá seguir Gitlab-CI, ejecutándolas una a una hasta completarlas todas.

Una pipeline típica de integración continua y entrega continua en Gitlab-CI podría ser algo parecido a ésto:

En nuestro caso el pipeline será un poco diferente ya que al estar usando un proyecto basado en Pelican no tenemos ni que compilar el código ni que ejecutar tests, ya que como expliqué en el artículo "Adios Octopress, hola Pelican!" de hace unas semanas, Pelican es un generador de sites estáticos (no hay que programar para añadir contenidos o gestionarlos) por lo que la pipeline de nuestro proyecto será más sencilla.

Pero que no haya que compilar código fuente ni haya tests no significa que siempre vaya a salir todo bien ni que no podamos equivocarnos, ya que dependiendo de qué elementos o plugins estemos usando al escribir los artículos de nuestro site en Pelican podrían darse errores al generar el site, por lo que podríamos utilizar ese mismo proceso de generación para validar que todo está bien. Si el site se genera correctamente, asumimos que todo es correcto y subimos el site a producción. En cambio si hay algún error a la hora de generar el site, interrumpimos el proceso y avisamos por email al desarrollador que ha hecho el último cambio en el repositorio.

Por lo tanto nuestra pipeline sería algo así:

Ahora que ya tenemos claras las tareas que tienen que ejecutarse (y el orden en el que tienen que hacerlo) vamos a implementarlas en nuestro proyecto.

Como ya mencioné un poco más arriba, toda la configuración de Gitlab-CI se hace a través del archivo .gitlab-ci.yml. Este archivo tiene que estar situado en la raíz de nuestro proyecto para que Gitlab inicie la integración continua de forma automática cada vez que se haga un git push al repositorio.

La sintaxis de dicho archivo es muy sencilla, pero antes debemos aclarar un par de términos para poder entenderla:

Tareas: jobs

Gitlab-CI se refiere como jobs a cada una de las acciones que vamos a ejecutar en la pipeline. Cada uno de estos jobs es una tarea, como por ejemplo compilar el código, ejecutar los tests, etc.

Etapas: stages

Los jobs se agrupan en stages. Es decir, nosotros no definimos los jobs directamente, sino que definimos stages, y dentro de ellas definiremos los diferentes jobs.

Vamos a poner un ejemplo en el que habría 2 stages: una para construir el site y otra para subir el código a producción, y cada una de ellas tendrá las subtareas (jobs) correspondientes:

  • Stage build: Aquí estarán las tareas que permiten generar el site, que a su ver serán:
    • Job para establecer las variables de entorno necesarias.
    • Job para generar el site mediante Pelican.
  • Stage deploy: Aquí estarán las tareas que permiten subir el site generado a producción, que a su ver serán:
    • Job para subir los archivos del site generado a un servidor de producción.
    • Job para vaciar la caché de producción para que tenga en cuenta el código recién subido.

Ejecuciones: builds

Se denomina build a la ejecución de una pipeline de integración continua. Podemos decir entonces que una build consta de stages, las cuales a su vez constan de jobs.

Entornos: environments

Podemos definir varios entornos diferentes dependiendo de su finalidad, y ejecutar unas stages en unos y otras en otros según necesitemos.

Por ejemplo, podríamos definir un environment de producción donde se ejecutarían las stages que generan el site, ejecutan los tests, suben el código a los servidores de producción, avisan a los jefes de que una nueva versión acaba de ser subida a producción, etc., pero también podríamos querer definir un environment de preproducción donde probar en un entorno similar a producción los cambios antes de pasarlos a producción.

En este último ejemplo que he puesto ejecutaríamos las mismas stages que en producción salvo la que notifica a los jefes de la nueva versión, ya que no tiene sentido que les avisemos de una subida que aún NO está en producción.

Variables: variables

Como su propio nombre indica, podemos definir una serie de variables para utilizarlas en cualquier punto del archivo .gitlab-ci.yml. Pero lo realmente interesante de esta parte es que no solo podemos definir los valores de dichas variables en el propio .gitlab-ci.yml sino que podemos definirlo también desde el propio Gitlab, lo cual es perfecto para definir contraseñas y demás datos "sensibles" sin que aparezcan reflejados en el código. Veremos cómo hacerlo un poco más adelante.

Artefactos: artifacts

El concepto de artefacto es una parte muy importante en la integración continua, ya que permite (entre otras muchas cosas) garantizar que el código que está siendo probado en este momento y que ha pasado los tests es exactamente el código que subirá a producción.

En cada una de las jobs que se ejecutan podemos generar archivos, los cuales se pasarán automáticamente de job en job y de stage en stage hasta la finalización de la build actual. Estos archivos se llaman artifacts.

Intentaré explicarlo mejor: Gitlab-CI (y no solo él, sino también la mayoría de los sistemas de integración continua) ejecutan cada stage desde cero, clonando el repositorio con el código del proyecto al inicio de la misma y eliminándolo de nuevo antes de pasar a la siguiente, lo que significa que podría darse el caso en el que el código que sube a producción NO es exáctamente el mismo al que acabamos de pasar los tests.

Supongamos que entre el job que ejecuta los tests y el job que sube el código a producción (ambos en stages diferentes) alguien sube un nuevo cambio al repositorio. Eso provocaría que cuando se ejecute el job que sube el código a producción, el repositorio tendría un código diferente al que había cuando se ejecutó el job de los tests. Puede que éste nuevo código NO pase los tests, y aun así lo estaríamos subiendo a producción porque la build actual ya estaba empezada cuando se hizo este último git push:

Sin embargo, si en la primera job que ejecutamos (la que genera el site estático, por ejemplo) definimos el directorio que contiene dicho código generado como un artifact, éste será el código que irá pasando a la siguiente job (la que ejecutará los tests), y si de nuevo en ese punto pasamos ese artifact con el código ya testeado a la siguiente job (la que sube el código a produccion), será ese mismo código (y no el que haya en ese momento en el repositorio) el que pasará a producción:

Dependencias: dependencies

Las dependencias de los jobs se usan para definir el orden de ejecución de dichos jobs dentro de un stage. Hay jobs que requieren que otros jobs se hayan ejecutado previamente para poder ejecutarse ellos, por lo que éstos últimos tendrán configurado como una dependencia el job anterior. No se pueden ejecutar el job que lanza los tests sobre el código si no se ha ejecutado previamente el job que genera dicho código, por ejemplo.

Ahora que ya tenemos más o menos claros casi todos los conceptos que intervienen en una pipeline, vamos a echar un ojo al archivo de configuración de dicha pipeline: el ya mencionado .gitlab-ci.yml:

.gitlab-ci.ymldownload
image: debian:testing

variables:
  GIT_STRATEGY: clone
  GIT_SUBMODULE_STRATEGY: none
  SERVER_RSYNC_PARAMS: "--recursive --compress --checksum --force --delete --stats --itemize-changes"
  SERVER_RSYNC_HOST: "pornohardware.com"
  SERVER_RSYNC_PORT: "873"
  #SERVER_RSYNC_USER -> Passed with "Secret Variables" from Gitlab GUI
  #SERVER_RSYNC_PASSWORD -> Passed with "Secret Variables" from Gitlab GUI
  #SERVER_WEBSITE_ROOT_DIR -> Passed with "Secret Variables" from Gitlab GUI
  
stages:
  - build
  - deploy
  - notify-sites

build:
  stage: build
  script:
    - apt-get -q update && apt-get -q -y install locales
    - echo "es_ES.UTF-8 UTF-8"  > /etc/locale.gen && echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen && locale-gen
    - apt-get -q update && apt-get -q -y install pelican python-markdown python-typogrify python-bs4 graphviz
    - cd src && pelican -s publishconf.py
  artifacts:
    name: "${CI_PROJECT_NAME}_${CI_COMMIT_REF_NAME}_artifacts"
    expire_in: 1 week
    paths:
      - "src/output"
      - "conf"

deploy:
  stage: deploy
  script:
    - apt-get -q update && apt-get -q -y install rsync
    - export RSYNC_PASSWORD=${SERVER_RSYNC_PASSWORD}
    - mv src/output http
    - rsync ${SERVER_RSYNC_PARAMS} http conf rsync://${SERVER_RSYNC_USER}@${SERVER_RSYNC_HOST}:${SERVER_RSYNC_PORT}/${SERVER_RSYNC_NAME}
  only:
    - master
  dependencies:
    - build
  environment:
    name: production
    url: https://pornohardware.com

notify-sites:
  stage: notify-sites
  script:
    - apt-get -q update && apt-get -q -y install python python-requests
    - cd src && python notify_sites.py
  only:
    - master

Si estáis familiarizados con Docker (mejor dicho, con Docker-compose) la estructura os resultará similar.

Recordad que hemos registrado nuestro runner especificando el tipo Docker, lo que significa que Gitlab-CI va a levantar un contenedor Docker con la configuración que tengamos en este archivo.

Por eso, la primera línea indica qué imagen vamos a utilizar para levantar nuestro contenedor:

image: debian:testing

Lo más sensato en la mayoría de los proyecto sería especificar la misma imagen que tengáis en el servidor de producción donde vayáis a ejecutar vuestro proyecto, pero en nuestro caso (al tratarse de un site estático pre-generado) es completamente irrelevante. En mi caso he elegido debian:testing.

Lo siguiente es la declaración de variables:

variables:
  GIT_STRATEGY: clone
  GIT_SUBMODULE_STRATEGY: none
  SERVER_RSYNC_PARAMS: "--recursive --compress --checksum --force --delete --stats --itemize-changes"
  SERVER_RSYNC_HOST: "pornohardware.com"
  SERVER_RSYNC_PORT: "873"
  #SERVER_RSYNC_USER -> Passed with "Secret Variables" from Gitlab GUI
  #SERVER_RSYNC_PASSWORD -> Passed with "Secret Variables" from Gitlab GUI
  #SERVER_WEBSITE_ROOT_DIR -> Passed with "Secret Variables" from Gitlab GUI

Vamos a ver para qué son cada una de ellas:

  • GIT_STRATEGY: Esta es una variable predefinida de Gitlab-CI, y su valor establece cómo debe el runner descargar los archivos del repositorio. Se puede establecer como clone para que el runner traiga el repositorio entero en cada ejecución, o fetch para que únicamente descargue los ultimos cambios. Yo prefiero garantizar un entorno limpio en cada ejecución, por lo que siempre uso el valor clone aunque sea un pelín más lento que fetch.
  • GIT_SUBMODULE_STRATEGY: Esta también es una variable predefinida, y es la que le dice al runner cómo debe gestionar los posibles submódulos de git que haya en el repositorio. Si la establecemos a none el runner NO descargará los submódulos cuando descargue el repositorio. Si la establecemos a normal únicamente se traerá aquellos submódulos que estén en el primer nivel en el árbol de directorios del proyecto, y si la establecemos a recursive se traerá todos los submódulos que haya en el repositorio. Como no tengo ningún submódulo en este proyecto, he establecido el valor none.
  • SERVER_RSYNC_PARAMS: Los parámetros con los que se llamará al comando rsync para subir los archivos a producción.
  • SERVER_RSYNC_HOST: El nombre del servidor de producción donde se subirán los archivos.
  • SERVER_RSYNC_PORT: El puerto para conectarse al servidor de producción a través de rsync.
  • SERVER_RSYNC_USER, SERVER_RSYNC_PASSWORD y SERVER_WEBSITE_ROOT_DIR: El usuario, la contraseña y el directorio que usará el runner para conectarse al servidor de producción y subir los archivos. Estas variables NO están establecidas en el archivo (si os fijáis, las 3 están comentadas, únicamente aparecen a modo de recordatorio) sino que están definidas desde el propio Gitlab para evitar tener que poner sus valores reales aquí. De esta forma, únicamente quien gestiona/administra el servidor de Gitlab conoce dichos valores, evitando que cualquiera que tenga acceso al repositorio conozca el valor de dichas variables.

Se las conoce como "variables secretas", y se configuran en Project Settings > CI/CD Pipelines > Secret Variables:

Gitlab

Tienes más información sobre ellas en la documentación oficial: https://docs.gitlab.com/ce/ci/variables/README.html#secret-variables.

Lo siguiente que vemos en el archivo es la definición de stages, que en nuestro caso van a ser 3:

stages:
  - build
  - deploy
  - notify-sites

La primera de ellas (build) es la que generará el site estático. La segunda (deploy) es la que subirá el código generado en la stage anterior a producción. Y por último, la tercera (notify-sites) se encargará de decir a algunos buscadores y agregadores que hay contenido nuevo en la web para que sepan que tienen que indexarlo (luego os pasaré el script por si alguno quiere echarle un ojo o utilizarlo en su web).

Ahora que ya hemos definido las stages, vamos a empezar definiendo los jobs que tendrá cada una de ellas. Empezamos con el job de la primera stage, al que también llamaremos build:

build:
  stage: build
  script:
    - apt-get -q update && apt-get -q -y install locales
    - echo "es_ES.UTF-8 UTF-8"  > /etc/locale.gen && echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen && locale-gen
    - apt-get -q update && apt-get -q -y install pelican python-markdown python-typogrify python-bs4 graphviz
    - cd src && pelican -s publishconf.py
  artifacts:
    name: "${CI_PROJECT_NAME}_${CI_COMMIT_REF_NAME}_artifacts"
    expire_in: 1 week
    paths:
      - "src/output"
      - "conf"

Como puedes ver lo primero que hacemos es definir el nombre del job, que en este caso es build. Después definimos a qué stage pertenece este job, que casualmente también la hemos llamado stage.

A continuación especificamos los comandos que vamos a ejecutar en el contenedor Docker para instalar todo lo que necesitamos para generar el site estático, definir el idioma del contenedor (para el formato de fecha/hora, por ejemplo) a es_ES.UTF-8, etc.

Y después le decimos que genere el site con el comando:

$ cd src && pelican -s publishconf.py

Como ya dije antes (pero os lo recuerdo de nuevo), si quereis ampliar la información sobre cómo se genera un site estático con Pelican, echad un ojo al artículo que escribí sobre el tema hace unas semanas: Adios Octopress, hola Pelican!.

Con el comando anterior, Pelican generará el site en el directorio /src/output. Por ese motivo, una vez generado el site, metemos dicho directorio y el de la configuración (/conf) en un artifact para poder pasárselo al siguiente job:

  artifacts:
    name: "${CI_PROJECT_NAME}_${CI_COMMIT_REF_NAME}_artifacts"
    expire_in: 1 week
    paths:
      - "src/output"
      - "conf"

Gitlab permite además que nos descarguemos los artifacts que se hayan generado en cada job, pero para evitar que se guarden indefinidamente y se llene el disco duro del servidor, podemos indicar un tiempo de vida para los artefactos (en nuestro caso, 1 semana). Para el nombre del artefacto hemos utilizado un par de variables predefinidas de Gitlab-CI que contienen el nombre del proyecto actual (${CI_PROJECT_NAME}) y el nombre de la branch de git que estemos utilizando.

Con esto quedaría definido nuestro primer job.

Ahora vamos con el siguiente, que es el que se encargará de subir el código generado a producción:

deploy:
  stage: deploy
  script:
    - apt-get -q update && apt-get -q -y install rsync
    - export RSYNC_PASSWORD=${SERVER_RSYNC_PASSWORD}
    - mv src/output http
    - rsync ${SERVER_RSYNC_PARAMS} http conf rsync://${SERVER_RSYNC_USER}@${SERVER_RSYNC_HOST}:${SERVER_RSYNC_PORT}/${SERVER_RSYNC_NAME}
  only:
    - master
  dependencies:
    - build
  environment:
    name: production
    url: https://pornohardware.com

Este otro job solo se ejecutará si el job anterior termina correctamente.

Es muy similar al anterior, solo hay un par de cosas nuevas:

Al igual que en el anterior job, primero definimos a qué stage pertenece, qué comandos vamos a ejecutar para subir el código (nosotros usaremos rsync, pero se puede usar cualquier cosa), etc. Pero vemos 3 directivas nuevas: only, dependencies y environment:

  • only: Esta directiva permite especifica en qué rama (branch) de nuestro repositorio se va a ejecutar el job. En este caso, y puesto que el job se encargará de subir el código a producción, debemos asegurarnos de que únicamente se ejecute cuando el git push que ha lanzado la build se haya hecho en la rama master. Si un usuario hace un git push a una rama que no sea la especificada aquí, este job NO se ejecutará.
  • dependencies: No podemos subir ningún site a producción si dicho site no se ha generado aún, por lo que con ésta directiva le decimos a Gitlab-CI que primero debe haberse ejecutado el job cuyo nombre es build (que es el que se encarga de generar el site).
  • environment: Y por último, definimos el nombre y URL del entorno al que estamos subiendo el código. Este valor es únicamente para poder acceder a él fácilmente desde el panel de Gitlab. Si en lugar de subir el código a producción lo estuviéramos subiendo a un entorno de pre-producción (staging, integración o como quieras llamarlo), aquí pondríamos los valores correspondientes.

Es decir, este job se va a ejecutar siempre DESPUÉS del job llamado build y únicamente cuando los cambios que han disparado este proceso se hayan hecho en la rama master.

Por último tenemos la definición del último job:

notify-sites:
  stage: notify-sites
  script:
    - apt-get -q update && apt-get -q -y install python python-requests
    - cd src && python notify_sites.py
  only:
    - master

Pertenece a la stage llamada notify-sites, únicamente se ejecutará cuando los cambios en el repositorio se hayan hecho en la rama master y ejecuta únicamente los 2 comandos que hay definidos en script (qué consisten en instalar python y algunas de sus dependencias, y después ejecutar el script notify_sites.py).

Este script es solo una tontería para notificar a algunos buscadores que la web ha cambiado para que sepan que tienen que venir a reindexarla lo antes posible:

notify_sites.pydownload
#!/usr/bin/env python

from pelicanconf import *
import requests

SERVICES_TO_NOTIFY = (
	# ('name', 'host', 'target', 'params'),
	('Google', 'https://www.google.com/webmasters/tools/ping?sitemap=' + SITEURL + '/sitemap.xml'),
	('Bing', 'https://www.bing.com/webmaster/ping.aspx?siteMap=' + SITEURL + '/sitemap.xml'),
	('Archive.org', 'http://web.archive.org/save/' + SITEURL),
)

headers = {
	"Content-type": "text/html; charset=UTF-8",
	"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
	"Accept-Language": "en-US,es;q=0.7,en;q=0.3",
	"Referer": SITEURL
}

print "Notifying services about new version of", SITEURL
for service in SERVICES_TO_NOTIFY:
	sName = service[0]
	sRequest = service[1]

	print "> Connecting with:", sName

	r = requests.get(sRequest)

	if r.status_code != 200:
		print 'ERROR', r.status_code
		print ''
		print r.content
		exit(1)

	print "  Request sent OK"

print "Process finished successfully!"
exit(0)

Como veis es muy sencillo. Únicamente hace un import de la propia configuración de vuestro site en Pelican (pelicanconf.py) para tener acceso a la variable SITEURL y después efectúa una petición a cada una de las URLs que tiene definidas para avisarles del nuevo contenido.

Y poco más... con esto ya tendríamos nuestra pipeline terminada!

Solo hemos utilizado una pequeña parte de todas las directivas que existen en Gitlab-CI, por lo que a pesar de que con lo que acabamos de ver podemos generar pipelines más que suficientes para la mayoría de los proyectos, os recomiendo que le echéis un ojo a la documentación sobre los comandos y opciones disponibles en el archivo .gitlab-ci.yml en la documentación oficial: https://docs.gitlab.com/ce/ci/yaml/README.html.

Ahora que ya tenemos el archivo .gitlab-ci.yml completo y guardado en la raíz de nuestro proyecto, vamos a hacer una prueba real de cómo funcionaría todo el proceso.

Demostración

Hay varias formas de trabajar con ramas en git, pero la más extendida cuando son proyectos personales (es decir, proyectos en los que únicamente trabajamos nosotros) es más o menos así:

El repositorio tiene siempre 2 ramas: master y development. La rama master (que es la que refleja lo que está instalado en producción en este momento) está protegida para que NADIE pueda hacer push directamente a ella (esto se configura en el propio Gitlab).

De esta forma garantizamos que master siempre será un reflejo fiel del entorno de producción, y la única manera de subir código a ella es mediante un merge request desde la rama de development (los típicos pull request de Github).

Cuando vamos a desarrollar una nueva funcionalidad (o a escribir un nuevo artículo, como sería mi caso en el proyecto de la web de pornoHARDWARE.com) siempre trabajamos en la rama development, y al finalizar el desarrollo y hacer los commits necesarios, creamos un nuevo merge request hacia la rama master para que podamos revisar los cambios que queremos pasar a producción manualmente.

Si la merge request es aceptada (en nuestro caso la aceptaremos nosotros mismos) los cambios se incorporan a la rama master, lo que provocará que la pipeline de nuestra integración continua se lance, ejecutando los jobs uno a uno hasta el final.

Tal y como hemos configurado la pipeline, cuando subamos los cambios a la rama development se ejecutará únicamente la stage llamada build (para generar el código a modo de test), pero si incorporamos los cambios a la rama master se ejecutarán también las stages llamadas deploy y notify-sites.

Mola, verdad? Pues vamos a verlo:

Nos situamos en el directorio de nuestro proyecto, nos cambiamos a la rama development, hacemos un pequeño cambio en el código y lo subimos al repositorio con el commit y el push correspondiente:

$ cd src/pornohardware
$ git checkout development
Already on 'development'

$ echo "Archivo de prueba" > dumy-file.txt
$ git add dumy-file.txt
$ git commit -m "Cambio de prueba para probar la pipeline" dumy-file.txt
[development c45fbfa] Cambio de prueba para probar la pipeline
 1 file changed, 1 insertion(+)
 create mode 100644 dumy-file.txt

$ git push
Counting objects: 30, done.
Delta compression using up to 12 threads.
Compressing objects: 100% (25/25), done.
Writing objects: 100% (30/30), 919.62 KiB | 0 bytes/s, done.
Total 30 (delta 12), reused 0 (delta 0)
remote:
remote: To create a merge request for development, visit:
remote:   https://<gitlab-url>/pornohardware/merge_requests/new?merge_request%5Bsource_branch%5D=development
remote:
To code.vandalsweb.com:vandalsweb/pornohardware
   d3535db..c45fbfa  development -> development

Si nos vamos al Gitlab y pulsamos en Pipelines (dentro de nuestro proyecto) veremos algo parecido a esto:

Gitlab

Si os fijáis en el círculo grande que he marcado en naranja veréis cómo vuestro commit ("Cambio de prueba para probar la pipeline") ha lanzado la build, y en este momento el estado de ejecución de dicha pipeline es running.

Esto significa que el runner ha cogido el commit y ahora mismo está descargando el repositorio y ejecutando el job.

Si esperáis un poco (solo llevará unos segundos) veréis que esa pantalla pasa de esto:

Gitlab

a esto otro:

Gitlab

lo que significa que el job ha terminado correctamente!

Ahora, si quisiéramos pasar estos cambios a producción, solo tendríamos que crear una merge request a la rama master para que se lanzara de nuevo la pipeline (pero esta vez en la rama master, lo que incluirá no solo el job de build sino también el de subir el código a producción y todos los demás).

Para crear la merge request podemos ir a Gitlab y pulsar en el icono Create merge request que aparecerá en el apartado Project -> Home de nuestro proyecto

Gitlab

O directamente hacer click en el enlace que nos apareció cuando hicimos el git push desde la línea de comandos:

[...]
remote:
remote: To create a merge request for development, visit:
remote:   https://<gitlab-url>/pornohardware/merge_requests/new?merge_request%5Bsource_branch%5D=development
remote:
[...]

En cualquier caso, una vez que lo hagamos y lo aprobemos comenzará la ejecución de la pipeline en la rama master:

Gitlab

Ahora, en lugar de aparecer un solo icono como antes, aparecen tres (porque antes en la rama development solo se ejecutaba un job y ahora en master se ejecutan todos).

Si todo va bien, stage tras stage se irán ejecutando todos los jobs y en un par de minutos (al menos en mi caso) deberíais ver la pipeline completamente terminada y sin errores en ninguno de los jobs:

Gitlab

Si durante (o después) de la ejecución de un job queréis ver qué es lo que está pasando en el contenedor Docker que está ejecutando vuestra pipeline, podéis pulsar en el icono del stage que queráis y podréis ver la salida standard de los comandos que habéis configurado en el .gitlab-ci.yml:

Gitlab

Y ya está, con solo hacer un git push hemos desencadenado toda una cadena de acciones que han desembocado en una nueva versión de nuestra web en producción de forma automática y controlada...

¿No es increíble poder subir a producción automáticamente nuestros proyectos, estando además tranquilos de que lo que estamos subiendo funciona? (y eso que en este proyecto no tiene sentido tener tests, pero si los tuviera, mucho más tranquilidad aún).

Hoy en día el mercado se mueve a toda velocidad, por lo que hay que minimizar lo máximo posible el tiempo que pasa desde que se tiene una idea hasta que ésta se plasma en un proyecto final y entregable.

Puede que subir un proyecto a producción en cuestión de minutos no te haga rico, pero el hecho de no tardar horas o días en hacerlo seguramente sí que te ahorre tiempo y dinero.

Como siempre, espero que el artículo os haya resultado de utilidad! Y por supuesto, no dudéis en distribuirlo y compartirlo con todo el mundo (pero por favor, cita siempre la fuente original de éste artículo).

Alaaaaaaaaaaaaaaaaaaaaaaa!

Referencias