En el anterior post montamos un entorno con Apache y PHP sobre una distribución Alpine. Pero lo hicimos “a mano”, es decir:
- Creamos un contenedor a partir de la imagen de Alpine.
- 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).
- 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:
FROM alpine:3.3
: le estamos indicando que nuestra nueva imagen parte de la imagen alpine:3.3.MAINTAINER Moisés Vilar
: le indicamos quién es el responsable de mantener esta imagen.RUN apk update && \...
: le indicamos que ejecute los comandos indicados. ¡Fijaos que son los mismos que los utilizados en el anterior post!EXPOSE 80
: le indicamos que abra el puerto 80 del contenedor al resto del mundo.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:
- La referencia a Dockerfile donde encontraréis la definición exacta de estos comandos y muchos otros más, haciendo de este mecanismo algo super versátil.
- y la guía de buenas prácticas a la hora de implementar Dockerfiles, para cuando os enfrentéis a Dockerfiles más complicados que éste.
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:
- 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). - 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 comandodocker run -d -p 80:80 -v /c/Users/Moises/:/var/www/localhost/htdocs blablabla
- 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 comandodocker build -t moisesvilar/awesome_chuck:1.0
sobre dicho Dockerfile. - 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
- Una vez realizado el testing, creamos la imagen para preproducción… ¡Pero este paso es idéntico al tercero!
- Lanzamos el contenedor de preproducción… ¡Pero este paso es idéntico al cuarto!
- Creamos la imagen para producción… ¡Pero este paso es idéntico al tercero!
- 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
Si no tenemos todavía vinculada nuestra cuenta de Github (o Bitbucket) con Docker Hub, nos invitará a hacerlo:
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:
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.
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).
A continuación, si nos vamos a la pestaña Build Details veremos que Docker Hub está construyendo nuestra imagen:
Y en unos minutos, nuestra imagen estará lista:
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?
- 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.
- 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.
- 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!