En el anterior post montamos un entorno con Apache y PHP sobre una distribución Alpine. Pero lo hicimos “a mano”, es decir:

  1. Creamos un contenedor a partir de la imagen de Alpine.
  2. Instalamos en el contenedor los binarios de Apache y PHP (o lo que es lo mismo, modificamos la capa de lectura/escritura del contenedor con dichos binarios).
  3. Creamos una imagen a partir del contenedor modificado (o lo que es lo mismo, “congelamos” el sistema de ficheros de dicho contenedor, incluyendo su capa de lectura/escritura).

En realidad es un proceso tedioso, que necesita de intervención humana. Y como sabéis, los humanos somos criaturas muy propensas a errores, sobre todo si tenemos que realizar tareas repetitivas. ¡En eso nos ganan las máquinas de largo!

Por lo tanto, y como no podía ser de otra manera, Docker nos ofrece un mecanismo para automatizar la creación de imágenes. Dicho mecanismo se basa en unos ficheros llamados Dockerfiles que ejecutan, a través de un lenguaje de scripting muy sencillo, las tareas necesarias para componer nuestras imágenes.

¡Veamos cómo funciona!

Dockerfile para nuestro entorno Alpine + Apache + PHP

FROM alpine:3.3
MAINTAINER Moisés Vilar

RUN apk update && \
    apk add apache2 php-apache2 && \
    mkdir /run/apache2

EXPOSE 80

CMD httpd -D FOREGROUND

Lo que tenéis arriba es la pinta de nuestro Dockerfile. Copiadlo en un fichero, nombrarlo Dockerfile (sin extensión) y guardadlo en vuestro equipo (si estáis utilizando la Docker machine, guardadlo dentro de vuestra carpeta personal).

Veamos punto por punto qué contiene este archivo:

  1. FROM alpine:3.3: le estamos indicando que nuestra nueva imagen parte de la imagen alpine:3.3.
  2. MAINTAINER Moisés Vilar: le indicamos quién es el responsable de mantener esta imagen.
  3. RUN apk update && \...: le indicamos que ejecute los comandos indicados. ¡Fijaos que son los mismos que los utilizados en el anterior post!
  4. EXPOSE 80: le indicamos que abra el puerto 80 del contenedor al resto del mundo.
  5. CMD httpd -D FOREGROUND: por último, le indicamos qué comando queremos que se ejecute cuando se inicie un contenedor a partir de esta imagen. En nuestro caso, que lance el servicio Apache.

Para construir una imagen de nombre moisesvilar/apache_php:alpine3.3 usando este Dockerfile, y suponiendo que dicho fichero se encuentra en el path /c/Users/Moises/, el comando sería el siguiente:

docker build -t moisesvilar/apache_php:alpine3.3 /c/Users/Moises/.

Veréis cómo va ejecutando todos los pasos indicados: descargará la imagen alpine:3.3, instalará Apache y PHP usando su gestor de paquetes etc.

Finalmente, tendremos creada nuestra imagen (podréis verla si hacéis un docker images).

Y para ejecutar un contenedor a partir de esta imagen, nuestro viejo conocido:

docker run -d -v /c/Users/Moises/workspace:/var/www/localhost/htdocs -p 80:80 moisesvilar/apache_php:alpine3.3

Fijaos que no le estoy indicando el comando a ejecutar httpd -D FOREGROUND. ¡Ya lo sabe a partir del Dockerfile que se ha usado para construir la imagen!

Un par de comentarios

Como seguramente ya os habréis dado cuenta, en el Dockerfile no le indico en ningún sitio que quiero crear un volumen montando el directorio /c/Users/Moises/workspace de mi equipo local en el directorio /var/www/localhost/htdocs del contenedor.

¡No podemos hacer esto a través de un Dockerfile!

Y tiene todo el sentido del mundo: los Dockerfiles se usan para automatizar la creación de imágenes independientemente de la máquina donde se ejecute. Yo puedo ejecutar mi comando docker build allá donde tenga Docker instalado y se construirá una imagen idéntica en todos los casos. Si nos permitiesen crear el volumen a partir de un directorio de mi equipo local, estaría obligando a que en todas las máquinas donde construyese esa imagen exista ese directorio. Y esto no tiene sentido ninguno.

Recordad que la creación de volúmenes de esta forma sólo es útil durante desarrollo. Cuando construyamos nuestro contenedor para pasarlo a producción, introduciremos el código final de nuestro proyecto de otra manera, como veremos en la siguiente sección.

Otro punto a tener presente es la forma de ejecutar el comando RUN dentro del Dockerfile. Fijaos que hemos utilizado los separadores && \, cuándo podríamos haber escrito cada comando en un RUN diferente:

RUN apk update
RUN apk add apache2 php-apache2
RUN mkdir /run/apache2

Pero ésta no es una práctica aconsejable por la siguiente razón: el comando docker build crea un contenedor temporal donde va a ejecutar cada uno de los comandos RUN. Tras la ejecución de un comando RUN, el sistema de ficheros del contenedor se “congela” y se crea una nueva imagen (tal y como hacíamos con el commit). Es decir, con cada ejecución del comando RUN, se crea una nueva capa en el UFS.

Si tenemos quince comandos RUN, ¡se habrán creado quince imágenes!, lo que convertiría al proceso en algo extremadamente lento.

De la manera que lo hemos hecho, sólo se crea una imagen (al final de todo) y el proceso es lo más rápido posible.

Os dejo por aquí dos enlaces de uso frecuente:

Dockerfile para nuestro proyecto en producción

Como dijimos antes, crear un volumen montando un directorio de nuestro equipo local sobre un directorio del contenedor nos ayuda durante la fase de desarrollo: podemos trabajar en el código en nuestro equipo local usando aquellas herramientas que más nos gusten (como por ejemplo, nuestro IDE favorito, nuestro gestor de dependencias favorito etc) sin preocuparnos del entorno de ejecución, que estará instalado en nuestro contenedor.

Pero también dijimos que los Dockerfiles no nos permiten especificar volúmenes creados de esta manera. Por lo que surge una pregunta: una vez terminada la fase de desarrollo ¿cómo “publico” el código final en una imagen?

Es decir, al final lo que queremos es una imagen que contenga no sólo nuestro entorno de ejecución (en nuestro caso, Alpine + Apache + PHP) sino también el código final de nuestro proyecto. Una vez tengamos nuestra imagen, podemos lanzar un contenedor a partir de ella y tendremos nuestro proyecto ejecutándose exactamente en el mismo entorno en el cual lo hemos desarrollado. Lo que comúnmente se llama paso a producción.

Una manera de hacer esto es a mano. Es decir, creamos un contenedor a partir de nuestra imagen Alpine + Apache + PHP y usando el comando docker cp copiamos en la capa de lectura/escritura de dicho contenedor los ficheros de nuestro proyecto en el directorio deseado. Finalmente, congelamos el sistema de ficheros del contenedor con docker commit en una nueva imagen y esa es la que usaríamos para desplegar nuestros contenedores en producción.

Podéis trastear en la referencia del comando docker cp, pero nosotros no vamos a hacerlo de esta manera, sino que aprovecharemos la potencia de los Dockerfiles para automatizar esta tarea.

Si le habéis echado un vistado a la referencia de Dockerfile, seguramente habéis visto el comando ADD. Dicho comando copia archivos, directorios o archivos remotos identificados por una URL al sistema de ficheros de una imagen, en el directorio que le indiquemos.

Por lo tanto, si el archivo index.php que utilizamos en el post anterior se encuentra en el mismo directorio que el siguiente Dockerfile:

FROM alpine:3.3
MAINTAINER Moisés Vilar

RUN apk update && \
    apk add apache2 php-apache2 && \
    mkdir /run/apache2 && \
    mkdir /var/www/localhost/htdocs/chuck

ADD index.php /var/www/localhost/htdocs/chuck
    
EXPOSE 80

CMD httpd -D FOREGROUND

si ejecutamos un docker build sobre este Dockerfile, tendremos la imagen para nuestros contenedores en producción.

¡Probadlo! Cread un Dockerfile como el anterior, guardadlo en la misma carpeta (por ejemplo, en /c/Users/Moises) que el archivo index.php (recordad que podéis descargarlo desde mi repositorio de Github) y ejecutad:

docker build -t moisesvilar/awesome_chuck:1.0 /c/Users/Moises/.

para construir la imagen y:

docker run -d -p 80:80 moisesvilar/awesome_chuck:1.0

para lanzar un contenedor a partir de ella, y por último abrid un navegador para navegar hasta la siguiente URL: http://localhost/chuck/. Voilá! ¡Ahí tenéis nuestro contenedor en producción!.

Más comentarios

Imaginad que tenemos todos nuestros entornos dockerizados: desarrollo, testing, preproducción y producción (por ejemplo). Voy a intentar representarlo en una imagen:

Entornos de desarrollo dockerizados

Entornos de desarrollo dockerizados

  1. El primer paso es crear nuestra imagen con el entorno de desarrollo, para ello utilizamos el comando docker build -t moisesvilar/apache_php:alpine3.3 sobre el primer Dockerfile que hemos visto en este post (el que está representado en azul en la figura).
  2. A continuación, lanzamos un contenedor para desarrollo a partir de la imagen moisesvilar/apache_php:alpine3.3, creando un volumen de datos donde se monte nuestro directorio de trabajo local en el directorio de despliegue de aplicaciones dentro del contenedor. Para ello, usamos el comando docker run -d -p 80:80 -v /c/Users/Moises/:/var/www/localhost/htdocs blablabla
  3. Una vez terminemos el desarrollo, creamos el Dockerfile para nuestro entorno de testing (el que está representado en verde en la figura). Este Dockerfile es el segundo que hemos visto en este post, el que incluía el comando ADD para añadir los ficheros de nuestro proyecto a la imagen. Para ello, volvemos a ejecutar un comando docker build -t moisesvilar/awesome_chuck:1.0 sobre dicho Dockerfile.
  4. Lanzamos nuestro contenedor, pero sin crear el volumen de datos (el código de nuestro proyecto ya se encuentra en la imagen). El comando es más sencillo: docker run -d -p 80:80 moisesvilar/awesome_chuck:1.0
  5. Una vez realizado el testing, creamos la imagen para preproducción… ¡Pero este paso es idéntico al tercero!
  6. Lanzamos el contenedor de preproducción… ¡Pero este paso es idéntico al cuarto!
  7. Creamos la imagen para producción… ¡Pero este paso es idéntico al tercero!
  8. Lanzamos el contenedor de producción… ¡Pero este paso es idéntico al cuarto!

¿Lo pilláis? ¡Da igual el número de entornos que tengamos! Al llegar al punto 4, todos ellos se montan de la misma manera.

Comparadlo con un despliegue tradicional: instalar Alpine en un equipo, instalar Apache, instalar PHP, subir el código, ejecutar. Así para cada entorno.

Pero es que además, Docker nos asegura que todos nuestros entornos son idénticos: misma versión de PHP, misma versión de Apache. Desde desarrollo a producción.

¿Qué pasa en el enfoque tradicional si nos despistamos y desarrollamos el proyecto para la versión 5.6 de PHP y resulta que nuestro entorno de producción tiene instalado PHP versión 7.1? Pues seguramente tengamos muchos problemas si hemos usado componentes deprecados definitivamente en la versión 7.1 pero que todavía eran válidos en la versión 5.6.

Y el problema no es muy grave si tenemos un entorno intermedio (testing o preproducción) y en dichos entornos ya nos obligan a usar PHP 7.1.

Pero, ¿y si no es así? ¿Y si en todos los entornos tenemos la versión 5.6 instalada excepto en producción? ¿Cuándo nos enteraremos de que algo va mal?

¡Exactísimo! ¡Cuando pasemos a producción! ¿El trailer lleno de Red Bull? ¡Por aquí, por favor! ^^

Podemos modificar el enfoque que vimos en la figura anterior intercambiando el papel que realiza los Dockerfiles por el Docker Hub.

Es decir, tras generar la imagen podemos subirla al Docker Hub con docker push y descargarla en nuestros entornos con docker pull.

Pero es mucho más molón si hacemos esto automáticamente en combinación con Github, tal y como veremos en la siguiente sección.

Enlazando Github y Docker Hub a través de un Dockerfile

Uno de los aspectos más usados en un pipeline de continuous delivery, desde el punto de vista de desarrollo, es que cuando el equipo realice un push a la rama master del proyecto (si estamos usando Git como servidor de control de versiones), éste se despliegue automáticamente, por lo menos, en el entorno de preproducción.

Pues bien, hacer esto con Docker Hub es extremadamente sencillo.

Supondré que tenemos un repositorio en Github con el código de nuestro proyecto, y con un Dockerfile en la raíz del repositorio cuyo contenido expongo a continuación. Tenéis el código de ejemplo en mi repositorio de Github

FROM moisesvilar/apache_php:alpine3.3
MAINTAINER Moisés Vilar

RUN mkdir /var/www/localhost/htdocs/chuck
ADD index.php /var/www/localhost/htdocs/chuck
    
EXPOSE 80

CMD httpd -D FOREGROUND

Fijaos que estoy partiendo de la imagen moisesvilar/apache_php:alpine3.3 (que la tengo subida al Docker Hub, por lo que Docker la descargará si no la encuentra en el equipo local). Inmediatamente, creo el directorio donde se alojará la aplicación, copio el código del proyecto (que también está en mi repositorio), expongo el puerto 80 y ejecuto el servidor Apache.

Si nos vamos a nuestra cuenta en hub.docker.com, veremos arriba a la derecha la opción Create > Create Automated Build

Opción Create Automated Build en Docker Hub

Opción Create Automated Build en Docker Hub

Si no tenemos todavía vinculada nuestra cuenta de Github (o Bitbucket) con Docker Hub, nos invitará a hacerlo:

Aviso de vinculación de Github con Docker Hub

Aviso de vinculación de Github con Docker Hub

Yo he escogido la opción para Github (no tengo cuenta en Bitbucket) pero vosotros podéis escoger la que queráis, el proceso es idéntico.

Quizás tendremos que acceder con nuestros credenciales a la cuenta de Github y a continuación tendremos que otorgar permisos a Docker Hub.

Una vez hecho esto, tendremos la opción disponible de nuevo en Create > Create Automated Build:

Vincular Github con Docker Hub

Vincular Github con Docker Hub

A continuación seleccionaremos si queremos escoger de entre todos nuestros repositorios de Github (públicos y privados) o sólo los públicos. Y en la siguiente pantalla escogemos de qué repositorio queremos que Docker Hub realice los build de nuestras imágenes: aquél que contiene el archivo Dockerfile del comienzo de esta sección, en mi caso, el repositorio docker-vi-ws.

Repositorio a vincular con Docker Hub

Repositorio a vincular con Docker Hub

En el siguiente paso, establecemos un nombre para la imagen, una pequeña descripción y la visibilidad de la imagen dentro de Docker Hub (recordad que si queréis que sea privada, tenéis que pasar por caja, que los chicos de Docker también tienen que comer).

Configurando la imagen

Configurando la imagen

A continuación, si nos vamos a la pestaña Build Details veremos que Docker Hub está construyendo nuestra imagen:

Imagen en construcción

Imagen en construcción

Y en unos minutos, nuestra imagen estará lista:

¡Imagen construida!

¡Imagen construida!

Y a partir de aquí podemos emplear el comando docker run contra esta nueva imagen para lanzar nuestros contenedores.

Pero lo chulo viene ahora.

Realizad cualquier modificación en el código (yo he añadido un chiste más de Chuck Norris), y actualizad la rama master de vuestro repositorio con un git push. Si os vais de nuevo a la pestaña Build Details de Docker Hub, ¡veréis que la imagen se está construyendo de nuevo!

¡Docker Hub comprueba automáticamente que hay una nueva versión del código y actualiza la imagen en consecuencia!

¡Continuous Delivery modo hardcore!

Conclusiones

¿Qué hemos aprendido hoy?

  1. Hemos visto como construir un Dockerfile para crear imágenes de nuestros entornos, evitando el tener que crearlas a mano (ejecutando un shell en el contenedor y picando los comandos a mano). Esto es útil para los chicos de desarrollo.
  2. Hemos visto como crear un Dockerfile para construir imágenes con el código ya insertado en el sistema de ficheros de la imagen, lista para ser utilizada por cualquier contenedor. Esto es útil para los chicos de sistemas, encargados de desplegar aplicaciones en distintos entornos.
  3. Hemos visto como crear un pipeline de continuous delivery entre Github y Docker Hub, de tal manera que cualquier actualización en la rama master de un repositorio de Github se transforma automáticamente en una imagen en el Docker Hub.

¡Nada más por el momento!

Dejadme vuestro feedback en los comentarios ¡no sé si esto está siendo interesante o me tengo que dedicar a calcetar!

En todo caso, para la semana volveremos con un poquito más de volúmenes, y aprovecharemos para montar un entorno dockerizado de una base de datos.

Au revoir!