Cómo mejorar la experiencia de desarrollo usando Docker

¿Cuándo fue la última vez que cambiaste de trabajo o de portátil y tuviste que perder horas de tu tiempo instalando todo otra vez? Seguro que no hace mucho. Hoy me gustaría hablar de como usar Docker para que este proceso no sea necesario y de paso lograr que la experiencia de desarrollo sea mucho mejor.

Introducción

Es muy habitual (y no lo digo como una crítica, ya que yo he estado ahí muchas veces) que dentro de un equipo no se dedique mucho tiempo a preparar un entorno donde ejecutar la aplicación de forma rápida y sencilla. Normalmente nos dejamos devorar por el día a día y una vez que tenemos todo configurado nos da mucha pereza hacer algo al respecto, total…la próxima persona que entre al equipo que lo haga.

Al final unos por otros y la casa sin barrer.

Mi experiencia me dice que cuando llegas nuevo a un equipo y te toca configurar tu ordenador, lo habitual es que haya muchos detalles no escritos en ningún sitio que te impiden empezar a trabajar y aportar de forma rápida.

Pero mi experiencia me dice también que dedicando un poco de esfuerzo, como veremos el esfuerzo es muy pequeño, podemos lograr que la puesta en marcha de nuestro proyecto sea prácticamente instantánea y sin ningún tipo de fricción. Para lograrlo vamos a hacer uso de Docker.

¿Qué es Docker y por qué debería usarlo?

Docker es una herramienta que nos permite replicar ciertos entornos como si fueran reales.

Esto que a priori puede no parecer una gran ventaja nos va a permitir definir de forma precisa donde queremos que se ejecute nuestro código y que esto se mantenga de forma consistente sin importar que Docker se ejecute en una máquina de alguien del equipo o en nuestro entorno de producción.

Es bastante habitual trabajar en varios proyectos con diferentes versiones como puede ser el caso de Python. Pese a que existen alternativas que te permiten cambiar fácilmente entre versiones (como puede ser el caso de nvm, sdkman ó pyenv) vamos a ver cómo podemos ir un paso más allá y que la versión concreta de cada proyecto sea transparente para nosotros.

Dockerizar una app en Python

Este post se basa principalmente en este repo donde podrás encontrar mas información si quieres saber más.

Para este post y como actualmente estoy trabajando con Python vamos a ver como podemos dockerizar una API hecha en Python. Bastaría con crear un fichero llamado Dockerfile con el siguiente contenido:

# Imagen donde se va a ejecutar nuestro código
FROM python:3.10 

# Directorio de vamos a almacenar nuestra app
WORKDIR /code

# Copiamos el fichero con las dependencias
COPY pyproject.toml /code

# Instalamos Poetry como gestor de dependencias
RUN pip install poetry 

# Instalamos las dependencias
RUN poetry install

# Copiamos nuestro código dentro de la imagen de Python
COPY . /code

# Definimos el comando por defecto cuando arranque nuestra app
CMD ["poetry", "run", "uvicorn", "weather_app.main:app", "--host", "0.0.0.0", "--port", "8080"]

El propio fichero es realmente sencillo y fácil de entender, la única peculiaridad es que en mi caso uso Poetry con Python que sería el equivalente a npm o gradle en otros lenguajes como JavaScript o Java.

Una vez que tenemos creado nuestro Dockerfile podemos crear una imagen de nuestra app con el comando:

docker build -t weather .

Donde -t nos permite asignarle una tag a nuestra app para poder ejecutarla posteriormente y donde el . le indica a Docker que busque el fichero Dockerfile.

Con la imagen ya creada podremos realizar entre otras cosas:

  • Abrir una terminal: docker run weather /bin/sh
  • Arrancar la aplicación: docker run -p 8080:8080 weather

Usemos Docker Compose

Como primera aproximación y para la mayoría de los casos ya es suficiente con lo que hemos hecho.

En nuestro caso vamos a ir un paso más allá y dockerizar el resto de nuestra app, ya que esta tiene otro elemento como es una base de datos Mongo.

Para este tipo de escenarios es muy útil hacer uso de Docker Compose el cual nos va a permitir definir los diferentes componentes de nuestro sistema y cómo se relacionan entre ellos.

Al igual que en el paso anterior debemos crear un fichero, en este caso llamado docker-compose.yml, con el siguiente contenido:

versión: "3.9"
services:
  # Definimos nuestra app
  weather:
    # Nuevamente le decimos que use el Dockerfile
    build: .
    # Mapeamos nuestro código dentro de la imagen
    volumes:
      - .:/code
    # Exponemos el puerto al exterior
    ports:
      - "80:8080"
    # Definimos esta dependencia para que se cree primero la BBDD
    depends_on:
      - "mongo"
  # Definimos nuestra base de datos
  mongo:
    # En este caso no hay Dockerfile, ya que usamos una imagen preconfigurada
    image: mongo
    restart: always
    # Exponemos el puerto al exterior
    ports:
      - "27017:27017"

Con este fichero estamos definiendo nuestra app de la siguiente forma:

  • Existen dos componentes:
    • weather:
      • Usa como imagen la generada previamente con el Dockerfile.
      • Definimos el puerto en el que va a escuchar nuestra API (8080) dentro de Docker Compose.
      • Definimos el puerto en el que va a escuchar Docker Compose (80) cuando se hagan llamadas desde fuera de Docker.
      • Definimos una dependencia hacia la base de datos para que este tenga preferencia en el arranque.
    • mongo
      • Usa como imagen la oficial de Mongo
      • Definimos el puerto en el que va a escuchar nuestra base de datos (27017) tanto dentro como fuera Docker Compose.
        • Desde nuestra app podremos acceder a mongo:27017.

A continuación podemos ver de una forma más visual como sería el diagrama de nuestro sistema:

Docker Compose

Una vez definido nuestro sistema podemos arrancarlo con el siguiente comando:

docker-compose up --build weather

El cual no creará la imagen de nuestra API antes de arrancarla junto con la base de datos de Mongo.

Simplifiquemos el ciclo de trabajo con Make

Si has llegado a este punto tengo que admitir que has tenido que leer mucha información, por suerte todo lo anteriormente comentado no es necesario saberlo de memoria (especialmente los comandos).

Por suerte para nosotros existe el concepto de Makefiles el cual nos va a permitir definir una serie de acciones de forma supersencilla.

Para ello necesitaremos crear un fichero llamado Makefile con el siguiente contenido:


.DEFAULT_GOAL := help 

.PHONY: help
help:  ## Show this help.
	@grep -E '^\S+:.*?## .*$$' $(firstword $(MAKEFILE_LIST)) | \
		awk 'BEGIN {FS = ":.*?## "}; {printf "%-30s %s\n", $$1, $$2}'

.PHONY: local-setup
local-setup: ## Set up the local environment (e.g. install git hooks)
	scripts/local-setup.sh

.PHONY: build
build: ## Set up the local environment (e.g. install git hooks)
	docker build .

.PHONY: install
install: ## Install all dependencies
	poetry install

.PHONY: up
up:    ## Run the app
	docker-compose up --build weather

.PHONY: test
test: ## Run tests
	docker-compose run --rm --no-deps weather poetry run pytest -n auto -ra

.PHONY: pre-commit
pre-commit: check-format check-typing check-style test

.PHONY: check-typing
check-typing:  ## Run a static analyzer over the code to find issues
	poetry run mypy .

.PHONY: check-format
check-format:
	poetry run yapf --diff --recursive weather_app/**/*.py

.PHONY: check-style
check-style:
	poetry run flake8 weather_app/
	poetry run pylint weather_app/**

.PHONY: reformat
reformat:  ## Format python code
	poetry run yapf --parallel --recursive -ir weather_app/
	poetry run py upgrade --py310-plus **/*.py

En este sencillo ejemplo hemos definido comandos para poder ejecutar nuestros tests, levantar nuestra app con docker-compose, formatear nuestro código, hacer una build,… como puedes ver las opciones son infinitas.

Todos estos comandos se ejecutan de la misma forma make XXX siendo XXX el nombre del target (reformat, test build,…).

El punto más importante aquí es que con la unión de Docker + Docker Compose + Makefile cualquier nueva integrante que llegue a equipo podrá ejecutar los tests en cuestión de segundos ejecutando make test.

Sin tener que instalar nada más que Docker, sin pasos ocultos sin documentar, sin problemas de versiones, sin llenar el ordenador de software que no necesitas.

Además esta misma imagen que hemos creado para nuestra API weather puede ser utilizada en la pipeline de nuestro CI/CD e incluso en nuestro cluster de Kubernetes.

Conclusiones

La idea de esta post era mostrar cómo es posible, y de una forma muy sencilla, ya que hecho una vez se puede re-aprovechar un montón para futuras ocasiones, automatizar y encapsular nuestra app de tal forma que la puesta en marcha sea instantánea y sin apenas fricción.

!Un saludo!