El Entorno de Desarrollo Perfecto Con Docker
El Entorno de Desarrollo Perfecto Con Docker
El Entorno de Desarrollo Perfecto Con Docker
En esta entrada se explicará como usar Docker para conseguir un entorno de desarrollo
óptimo y cómodo para trabajar. Se describirán algunos trucos que mejoran notablemente
la rapidez con la que se opera con Docker y se darán algunos ejemplos prácticos sobre
como Dockerizar entornos completos para la combinación de los lenguajes de
programación y frameworks más comunes.
Nota: Esta entrada presupone que el lector ya cuenta con una instalación funcional
de Docker, así como la herramienta docker-compose.
Todos hemos tenido que trabajar en un proyecto hecho con PHP 5 y MySQL 5, luego en
otro hecho con PHP 7, luego en otro hecho con Python 2 y Postgres 9. Y para rematar,
unos cuantos más, hechos con Python 3, PostgreSQL 10, Node 10, MongoDB
2, NGINX, Apache, al menos 5 o 6 versiones distintas de la misma librería usada en
varios de los proyectos, etc.
Docker se presentó como una alternativa que solucionaba ambos problemas (a costa
de hacer sacrificios en otros aspectos, tema que da para otro post) e implementaba
una serie de características que terminarían por definirlo como la herramienta por
excelencia para manejar contenedores. Usaremos todas estas ventajas para convertir
Docker en una herramienta que nos permita tener todos nuestros entornos de
desarrollo encapsulados. Es decir, conseguiremos un entorno de desarrollo (por
proyecto) completamente funcional, autocontenido, de fácil manejo y replicación, con
certificados SSL para poder simular por completo la puesta en marcha en producción
del proyecto, etc.
Conceptos necesarios
Para poder entender de manera correcta el resto de la entrada es necesario que antes
definamos unos cuantos conceptos de Docker y de su funcionamiento. Esto nos
ayudará a razonar mejor y con más claridad sobre las explicaciones que ofreceré, y
sobretodo, nos permitirá a entender como crear nuestros propios entornos,
basándonos en los ejemplos que pondré a continuación.
Nota: Definiré de manera parcialmente incorrecta algunas cosas y haré analogías extrañas,
en pos de facilitar la lectura y la comprensión del funcionamiento de Docker.
Docker - Es una herramienta que se queda a medio camino entre un chroot y
una máquina virtual . Conseguimos una encapsulación mayor de procesos, de
memoria, de disco duro y, en general, del entorno donde operan nuestras
aplicaciones en comparación con chroot, pero no es una separación total, como lo
que obtendríamos con una máquina virtual.
Imagen (Image) - Una manera muy simple de entender lo que es una imagen es
pensar que representa un LiveCD . Es lo que se usa para iniciar un contenedor.
Volumen (Volume) - Esto es, a efectos prácticos, el disco duro del contenedor.
Debemos tener en cuenta que un volumen puede ser compartido entre nuestro
contenedor y nuestro sistema operativo (es decir, ambos verán los mismos archivos
en tiempo real) o privado, en cuyo caso únicamente el contenedor podrá ver el
contenido del volumen.
Red (Network) - Para que los contenedores tengan una utilidad, queremos que sea
posible acceder a las aplicaciones o servicios que se ejecutan en su interior. Lo más
común es que dicho acceso se haga mediante red (ya sea exponiendo un servidor
web, una API o cualquier otro servicio con capacidades de comunicación vía red). A
veces también necesitamos que unos contenedores sean capaces de comunicarse
vía red con otros contenedores (ejemplo: una aplicación accediendo a una instancia
de Redis). Cabe destacar que Docker, además de permitirnos exponer los
contenedores al contexto de nuestro sistema operativo, también es capaz de
exponerlos únicamente entre ellos, mediante redes privadas, pero ocultándolos al
contexto de nuestro sistema operativo.
Una imagen vale más que mil entornos
Lo primero que debemos hacer es construir nuestra imagen, "grabando" en su interior
todo lo que necesitamos para ejecutar nuestro proyecto. Aunque es posible construir
una única imagen con todas las dependencias del aplicativo que queremos encapsular
(el intérprete del lenguaje de programación que estemos usando, el servidor web, una
base de datos, un almacén de acceso rápido, etc.), es una buena práctica separar cada
uno de estos componentes en contenedores distintos o servicios.
Haciendo esta separación, en vez de hacer una imagen monolítica, podemos separar
nuestros proyectos en distintos componentes y con eso conseguimos varias ventajas:
Simplicidad
Mayor encapsulación
Reutilización de imágenes
Fácil manejo de los contenedores
Nota: Cabe mencionar que separando nuestros servicios en múltiples imágenes tendremos
que manejar múltiples contenedores. Mas adelante en esta entrada veremos que esto no
supone mayor esfuerzo ni mayor complejidad, sino justo lo contrario.
Nota: Empezaré con un ejemplo orientado a entornos de desarrollo con Python y Django,
pero al final de la entrada dejaré más ejemplos orientados a otros lenguajes de
programación y frameworks.
Dockerfile
FROM python:3.6.4-stretch
WORKDIR /app
A priori puede parecer que falta algo, ya que es sospechoso que tan pocas lineas de
código puedan crear un entorno completo de desarrollo. Sin embargo, no es el caso. El
truco está en la primera línea, donde usamos la palabra clave FROM . Esta palabra
indica a Docker que debe construir nuestra imagen usando como base la imagen
de python , en su versión 3.6.4 , basada en debian stretch . Es decir, en vez de
partir de un lienzo en blanco, partimos de un Debian Stretch sobre el cual ya se ha
instalado Python 3.6.4.
Docker cuenta con un repositorio público donde es posible subir imágenes.
Normalmente, ahí podemos encontrar imágenes oficiales de los stacks de desarrollo
más comunes, con un mantenimiento adecuado y un ritmo de actualizaciones muy
bueno. Aprovechándonos de este repositorio, podemos construir imágenes complejas
en pocas lineas de código.
Nota: Es importante mencionar que el comando COPY no copiará archivos o carpetas que
coincidan con alguno de los patrones definidos en el archivo .dockerignore . Esto es muy
importante, ya que nos permite excluir archivos innecesarios y obtener imágenes más
pequeñas.
Por último, copiamos un script que nos servirá de ENTRYPOINT (el comando por
defecto). Cuando iniciamos un contenedor, dicho contenedor debe ejecutar un
programa. Mientras dicho programa siga ejecutándose, el contenedor permanece
operativo. Si el programa termina por cualquier motivo, el contenedor se apaga. En
este caso nuestro programa es un script y lo especificamos mediante el uso
de ENTRYPOINT , mientras que nuestro comando o CMD (que actúa como parámetro
de nuestro ENTRYPOINT ) es bash .
Para que terminemos de entender el ENTRYPOINT y el CMD , debemos examinar
el entrypoint.sh .
entrypoint.sh
#!/bin/bash
set -e
function wait_service {
while ! nc -z $1 $2; do
sleep 1
done
}
case $1 in
run-migrations)
echo "--> Applying migrations"
wait_service $DATABASE_HOST $DATABASE_PORT
exec python manage.py migrate --noinput
;;
run-runserver)
echo "--> Starting Django's server"
wait_service $DATABASE_HOST $DATABASE_PORT
exec python manage.py runserver 0.0.0.0:8000
;;
run-tests)
echo "--> Starting Django's test framework"
wait_service $DATABASE_HOST $DATABASE_PORT
exec python manage.py test
;;
run-celery)
echo "--> Starting Celery queue"
wait_service $REDIS_HOST $REDIS_PORT
exec celery worker -A main -l info
;;
run-celery-flower)
echo "--> Starting Celery flower"
wait_service $REDIS_HOST $REDIS_PORT
exec celery flower -A main --port=5555
;;
run-celery-beat)
echo "--> Starting Celery beat"
wait_service $REDIS_HOST $REDIS_PORT
exec celery beat -A main -l info
;;
*)
exec "$@"
;;
esac
Como ya hemos dicho antes, nuestro contenedor ejecutará este script y se mantendrá
activo mientras el script no termine su ejecución. Por otro lado, el Dockerfile define
que el comando(parámetro en este caso, ya que hemos definido un ENTRYPOINT ) por
defecto es la cadena de texto bash . Si analizamos el script, podemos ver que tiene
definida una función, que ignoraremos por el momento, y un case , que evalúa el
primer parámetro ( $1 ) que ha recibido. Cuando el contenedor se ejecuta sin
parámetros, el parámetro por defecto que usa es bash , así que el flujo de ejecución
del script no entra en ninguno de los casos definidos, sino que ejecuta el caso por
defecto ( *) ).
versión: "3.2"
services:
nginx-proxy:
image: jwilder/nginx-proxy
container_name: proyecto-django-nginx-proxy
ports:
- "80:80"
- "443:443"
networks:
- proyecto-django-internal
volumes:
- /var/run/docker.sock:/tmp/docker.sock:ro
- ./certs:/etc/nginx/certs
depends_on:
- proyecto-django
Aquí definimos la sección de "servicios" y nuestro primer servicio: un servidor web
usando la imagen jwilder/nginx-proxy . A mayores, definimos el nombre del
contenedor que va a ejecutar esta imagen, los puertos que expone el contenedor a
nuestro sistema, la red privada de la que forma parte, los volúmenes (en este caso
compartidos) entre nuestro sistema y el contenedor y, por último, los contenedores de
los que depende.
Quizás lo más curioso de este servicio sea uno de los volúmenes que usa. Sin entrar en
demasiados detalles técnicos, exponer el /var/run/docker.sock al contenedor hará
que sea capaz de detectar las peticiones web lanzadas desde nuestro sistema e
interceptarlos.
proyecto-django:
build: .
container_name: proyecto-django
env_file:
- ./src/.env
command: ["run-runserver"]
stdin_open: true
tty: true
networks:
- proyecto-django-internal
ports:
- 8080:8000
environment:
- VIRTUAL_HOST=proyecto-django.dev
- VIRTUAL_PORT=8000
depends_on:
- redis
- postgres
volumes:
- ./src:/app/
- ./media:/data/media
Nota: Si no estás completamente seguro de por qué esto es útil, te invitamos a leer el punto
relacionado con la "Configuración" en nuestra entrada sobre las aplicaciones de 12 factores
.
Tercero, el uso de la instrucción command . El valor que definimos aquí se usará como
sustituto del comando CMD en la imagen del contenedor. En este caso, estamos
usando la imagen que hemos construido nosotros mismos usando
nuestro Dockerfile . Es decir, cuando este contenedor se inicie, el comando que se
ejecutará será ./entrypoint.sh run-runserver , en vez de ./entrypoint.sh bash . Si
volvemos a mirar el código del entrypoint.sh , veremos que existe
un case para run-runserver , que hará uso de la función wait_service para
asegurarse de que nuestro servicio de la base de datos está operativo y después
ejecutará el servidor de Django en el puerto 8000 .
Otra cosa importante que debemos mencionar es que, además de haber inyectado
variables de entorno usando el env_file , estamos inyectando 2 variables de entorno
a mayores. La primera es VIRTUAL_HOST y nos permite definir el dominio a usar para
acceder a nuestro contenedor desde nuestro sistema, todo esto pasando por el
servicio de NGINX. La segunda es VIRTUAL_PORT y le permite saber al servicio de
NGINX a que puerto del contenedor conectarse (nuestra aplicación de Django estará
escuchando en el puerto 8000 por defecto). Debemos aclarar que podríamos
conectarnos directamente a nuestro contenedor de Django
usando http://localhost:8000 en el navegador de nuestro sistema, pero pasar por
NGINX nos permitirá usar certificados de SSL, lo cual puede resultarnos muy útil a la
hora de depurar aplicaciones que dependen del uso obligatorio de HTTPS.
Por último, mencionar que este contenedor usará 2 volúmenes compartidos con el
sistema. El primero es la carpeta src , cuyo contenido será visible desde la
carpeta /app dentro de nuestro contenedor. Si volvemos a mirar
nuestro Dockerfile , nos daremos cuenta que el WORKDIR está puesto a /app , lo
cual significa que todos los comandos que ejecutemos dentro del contenedor (sin
habernos cambiado de directorio a propósito), serán ejecutados directamente sobre el
contenido de nuestra carpeta src . El segundo volumen es la carpeta media , que
será visible desde la carpeta /data/media dentro de nuestro contenedor. Este
segundo volumen se ha incluido únicamente como ejemplo para el uso de múltiples
volúmenes en caso de ser necesarios.
celery:
build: .
container_name: proyecto-django-celery
env_file:
- ./src/.env
command: ["run-celery"]
networks:
- proyecto-django-internal
depends_on:
- redis
- celery-beat
- celery-flower
volumes:
- ./src:/app/
- ./media:/data/media
celery-flower:
build: .
container_name: proyecto-django-celery-flower
env_file:
- ./src/.env
command: ["run-celery-flower"]
networks:
- proyecto-django-internal
ports:
- 5555:5555
depends_on:
- redis
volumes:
- ./src:/app/
- ./media:/data/media
celery-beat:
build: .
container_name: proyecto-django-celery-beat
env_file:
- ./src/.env
command: ["run-celery-beat"]
networks:
- proyecto-django-internal
depends_on:
- redis
volumes:
- ./src:/app/
- ./media:/data/media
Definimos los distintos componentes de Celery (Celery, Beat y Flower). Las definiciones
son prácticamente idénticas, siendo la única diferencia el parámetro que cada
contenedor usará con el entrypoint.sh .
postgres:
image: postgres:9.6.6-alpine
container_name: proyecto-django-postgres
env_file:
- ./src/.env
networks:
- proyecto-django-internal
volumes:
- ./data/postgres:/var/lib/postgresql/data
redis:
image: redis:3.2.8-alpine
container_name: proyecto-django-redis
volumes:
- ./data/redis:/data
networks:
- proyecto-django-internal
networks:
proyecto-django-internal: {}
Dado que hemos separado nuestros servicios en varios archivos, lo primero que
debemos tener en cuenta es la manera de "componer" o "unir" estos archivos.
Podemos usar el parámetro -f para especificar todos los archivos que queremos
que docker-compose una por nosotros. En caso de que algún servicio esté duplicado
en varios .yml , las instrucciones se mezclarán (en caso de almacenar múltiples
valores) o se sobrescribirán por la definición del último archivo (en caso de ser una
instrucción con un único valor posible).
Por ejemplo, para iniciar nuestro contenedor de NGNIX y todas sus dependencias,
podríamos usar el comando
Acordarse de incluir todos los archivos .yml , los nombres de los contenedores y los
varios comandos de docker-compose es una tarea pesada, por eso hemos incluido un
archivo que nos servirá de mucha ayuda: Makefile . En dicho archivo hemos definido
varios targets que podremos usar para hacer prácticamente todas las operaciones
que podríamos necesitar. Simplemente necesitamos ejecutar el comando make
(debemos tener instalada dicha herramienta en nuestro sistema) seguido del nombre
del target .
Targets definidos:
3, 2, 1... ¡Despegamos!
¡Basta de teoría! ¡Vamos a ejecutar esto de una vez!
3. Ejecutamos make certs para generar unos certificados que usaremos en nuestro
proyecto.
La función de wait_service
Hay una gran diferencia entre iniciar un contenedor e iniciar una aplicación dentro del
contenedor. Lo primero ocurre tan rápido como Docker sea capaz de iniciar un
contenedor, lo segundo depende de la aplicación en sí. Cuando configuramos los
distintos contenedores y sus dependencias, hay ciertos límites que Docker no puede
sobrepasar. Por ejemplo, puede iniciar un contenedor que esté marcado como
dependencia de otro contenedor, pero no puede hacer que el programa que se está
ejecutando en el primer contenedor se quede "esperando" a que el programa del
segundo contenedor esté disponible. Por esa razón nuestro entrypoint.sh tiene una
función llamada wait_service .
Dicha función no es más que un netcat dentro de un bucle, intentando conectarse a
un destino y un puerto. Cuando netcat consigue conectarse, la ejecución de
nuestro entrypoint.sh continúa.
function wait_service {
while ! nc -z $1 $2; do #<-----------------
sleep 1 # | |
done # | |
} # | |
# | |
# | |
wait_service $REDIS_HOST $REDIS_PORT # ---- |
wait_service $DATABASE_HOST $DATABASE_PORT # ---
Los valores
de $REDIS_HOST , $REDIS_PORT , $DATABASE_HOST y $DATABASE_PORT vienen de las
variables de entorno, las cuales a su vez vienen de nuestro archivo .env , que ha sido
inyectado en cada uno de nuestros contenedores por Docker.
DATABASE_HOST=postgres
REDIS_HOST=redis
Ambos tienen un command multilínea que invoca el intérprete bash pasándole una
cadena de ordenes. El contenedor de backup ejecuta el comando pg_dump y escribe
el resultado en el directorio /backup que está vinculado a data/backup en el
contexto de nuestro sistema. El contenedor restore ejecuta el comando psql ,
leyendo del mismo directorio.
En ambos casos, lo que ocurre en realidad es que estamos pasando toda la cadena de
texto que lleva el command como parámetro al contenedor. El contenedor le pasa esa
cadena de texto al entrypoint.sh , el cual a su vez ejecuta el caso por defecto, dando
lugar a exec "$@" . Es decir, estamos usando exec "$@" para poder ejecutar código
aleatorio en nuestra imagen, en vez de tener que definir casos adicionales por cada
función que queremos añadir a mayores.
Más ejemplos
Hemos cubierto muchos conceptos, pero sabemos que los ejemplos prácticos son los
que mayor ayuda ofrecen, por eso hemos preparado varios ejemplos más.
Notas finales
Quiero dar las gracias a Oscar M. Lage por haberme animado a escribir este post, por
haberme ayudado con las pruebas de los ejemplos y por haber realizado una revisión
de este post. ¡Muchas gracias!