Cuando se usa Docker ampliamente, la administración de varios contenedores diferentes rápidamente se vuelve complicada.

Docker Compose es una herramienta que nos ayuda a superar este problema y manejar fácilmente múltiples contenedores a la vez.

En este tutorial, echaremos un vistazo a sus principales características y poderosos mecanismos.

La configuración de YAML explicada

En resumen, Docker Compose funciona al aplicar muchas reglas declaradas dentro de un solo archivo de configuración docker-compose.yml.

Estas reglas de YAML, tanto legibles por el hombre como optimizadas por la máquina, nos proporcionan una forma efectiva de tomar una instantánea de todo el proyecto desde diez mil pies en unas pocas líneas.

Casi todas las reglas reemplazan un comando específico de Docker, de modo que al final solo necesitamos ejecutar:

docker-compose up

Podemos obtener docenas de configuraciones aplicadas por Compose debajo del capó. Esto nos ahorrará la molestia de escribirlos con Bash o algo más.

En este archivo, debemos especificar la versión del formato de archivo Compose, al menos un servicio y, opcionalmente, volúmenes y redes:

version: "3.7"
services:
  ...
volumes:
  ...
networks:
  ...

Servicios

En primer lugar, los servicios se refieren a la configuración de los contenedores.

Por ejemplo, tomemos una aplicación web acoplada que consta de un frontend, un backend y una base de datos: es probable que dividamos esos componentes en tres imágenes y los definamos como tres servicios diferentes en la configuración:

services:
  frontend:
    image: vuejs-app
    ...
  backend:
    image: springboot-app
    ...
  db:
    image: postgres
    ...

Existen múltiples configuraciones que podemos aplicar a los servicios, y las exploraremos en profundidad más adelante.

Redes y Volúmenes

Los volúmenes, por otro lado, son áreas físicas de espacio de disco compartidas entre el host y un contenedor, o incluso entre contenedores. En otras palabras, un volumen es un directorio compartido en el host, visible desde algunos o todos los contenedores.

De manera similar, las redes definen las reglas de comunicación entre contenedores y entre un contenedor y el host. Las zonas comunes de la red harán que los servicios de los contenedores sean detectables entre sí, mientras que las zonas privadas los segregarán en espacios aislados virtuales.

Una vez más, aprenderemos más sobre ellos en la siguiente sección.

Descargar una imagen

A veces, la imagen que necesitamos para nuestro servicio ya ha sido publicada (por nosotros o por otros) en Docker Hub u otro Registro de Docker.

Si ese es el caso, entonces lo referimos con el atributo de imagen, especificando el nombre de la imagen y la etiqueta:

services: 
  my-service:
    image: ubuntu:latest
    ...

Construyendo una imagen

En algún momento, podríamos necesitar construir una imagen a partir del código fuente leyendo su archivo Docker.

Esta vez, usaremos la palabra clave de compilación, pasando la ruta al archivo Docker como el valor:

services: 
  my-custom-app:
    build: /una/ruta/dockerfile/
    ...

También podemos usar una URL en lugar de una ruta:

services: 
  my-custom-app:
    build: https://github.com/RicardoGeek/mi-proyecto.git
    ...

Además, podemos especificar un nombre de imagen junto con el atributo de compilación, que dará nombre a la imagen una vez que se haya creado, y estará disponible para ser utilizada por otros servicios:

services: 
  una-app-personalizada:
    build: https://github.com/RicardoGeek/mi-proyecto.git
    image: nombre-de-imagen-del-proyecto
    ...

Configurando la red

Los contenedores Docker se comunican entre sí en redes creadas, implícitamente o mediante configuración, por Docker Compose. Un servicio puede comunicarse con otro servicio en la misma red simplemente haciendo referencia a él por el nombre del contenedor y el puerto (por ejemplo, servicop-de-red-ejemplo: 80), siempre que hayamos hecho que el puerto sea accesible a través de la palabra clave expose:

services:
  servicio-de-red-ejemplo:
    image: tutum/hello-world:latest
    expose:
      - "80"

En este caso, por cierto, también funcionaría sin exponerlo, porque la directiva expose ya está en la imagen Dockerfile.

Para llegar a un contenedor desde el host, los puertos deben exponerse de forma declarativa a través de la palabra clave ports, que también nos permite elegir si la exposición del puerto es diferente en el host:

services:
  servicio-de-red-ejemplo:
    image: tutum/hello-world:latest
    ports:
      - "80:80"
    ...
  mi-app:
    image: ricardogeek:latest
    ports:
      - "8080:3000"
    ...
  mi-app-replica:
    image: ricardogeek:latest
    ports:
      - "8081:3000"
    ...

El puerto 80 ahora será visible desde el host, mientras que el puerto 3000 de los otros dos contenedores estará disponible en los puertos 8080 y 8081 en el host. Este poderoso mecanismo nos permite ejecutar diferentes contenedores exponiendo los mismos puertos sin colisiones.

Finalmente, podemos definir redes virtuales adicionales para segregar nuestros contenedores:

services:
  servicio-de-red-ejemplo:
    image: tutum/hello-world:latest
    networks: 
      - mi-red-compartida
    ...
  otro-servicio-en-la-misma-red:
    image: alpine:latest
    networks: 
      - mi-red-compartida
    ...
   otro-servicio-en-su-propia-red:
    image: alpine:latest
    networks: 
      - mi-red-privada
    ...
networks:
  mi-red-compartida: {}
  mi-red-privada: {}

En este último ejemplo, podemos ver que otro servicio en la misma red podrá hacer ping y alcanzar el puerto 80 del servicio de ejemplo de red, mientras que otro servicio de red en su propia red no lo hará.

Configurando volúmenes

Hay tres tipos de volúmenes: anónimos, con nombre y de host.

Docker administra volúmenes anónimos y con nombre, montándolos automáticamente en directorios autogenerados en el host. Si bien los volúmenes anónimos fueron útiles con versiones anteriores de Docker (pre 1.9), los nombrados son la manera sugerida de ir hoy en día. Los volúmenes de host también nos permiten especificar una carpeta existente en el host.

Podemos configurar volúmenes de host en el nivel de servicio y volúmenes con nombre en el nivel externo de la configuración, para que estos últimos sean visibles para otros contenedores y no solo para el que pertenecen:

services:
  ejemplo-de-servicio-con-volumenes:
    image: alpine:latest
    volumes: 
      - un-volumen-global:/volumenes/un-volumen-global
      - /tmp:/volumenes/volumen-del-host
      - /home:/volumenes/volume-del-host-solo-lectura:ro
    ...
  servicio-de-ejemplo-otros-volumenes:
    image: alpine:latest
    volumes:
      - un-volumen-global:/otro/un-volumen-global
    ...
volumes:
   un-volumen-global: 

Aquí, ambos contenedores tendrán acceso de lectura / escritura a la carpeta compartida un-volumen-global, sin importar las diferentes rutas a las que la hayan asignado. Los dos volúmenes de host, en cambio, estarán disponibles solo para volúmenes-servicio-ejemplo.

La carpeta /tmp del sistema de archivos del host se asigna a la carpeta/volumenes/volumen-del-host del contenedor.
Esta parte del sistema de archivos se puede escribir, lo que significa que el contenedor no solo puede leer sino también escribir (y eliminar) archivos en la máquina host.

Podemos montar un volumen en modo de solo lectura agregando: :ro a la regla, como para la carpeta /home (no queremos que un contenedor de Docker borre a nuestros usuarios por error).

Declarando las Dependencias

A menudo, necesitamos crear una cadena de dependencia entre nuestros servicios, de modo que algunos servicios se carguen antes (y luego se descarguen) otros. Podemos lograr este resultado a través de la palabra clave depend_on:

services:
  kafka:
    image: wurstmeister/kafka:2.11-0.11.0.3
    depends_on:
      - zookeeper
    ...
  zookeeper:
    image: wurstmeister/zookeeper
    ...

Sin embargo, debemos tener en cuenta que Compose no esperará a que el servicio zookeeper termine de cargarse antes de iniciar el servicio kafka: simplemente esperará a que se inicie. Si necesitamos que un servicio esté completamente cargado antes de iniciar otro servicio, necesitamos obtener un mayor control del inicio y el orden de cierre en Compose, pero discutiremos eso en otro post ;).

Gestionando variables de entorno

Trabajar con variables de entorno es fácil en Compose. Podemos definir variables de entorno estáticas y también definir variables dinámicas con la notación $ {}:

services:
  database: 
    image: "postgres:${POSTGRES_VERSION}"
    environment:
      DB: mydb
      USER: "${USER}"

Existen diferentes métodos para proporcionar esos valores para componer.

Por ejemplo, podríamos configurarlas en un archivo .env en el mismo directorio, estructurado como un archivo .properties, clave = valor:

POSTGRES_VERSION=alpine
USER=foo

De lo contrario, podemos configurarlas en el sistema operativo antes de llamar al comando:

export POSTGRES_VERSION=alpine
export USER=foo
docker-compose up

Finalmente, podríamos encontrar útil usar una sola línea en el shell:

POSTGRES_VERSION=alpine USER=foo docker-compose up

Podemos mezclar los enfoques, pero tengamos en cuenta que Compose usa el siguiente orden de prioridad, sobrescribiendo los menos importantes con los más altos:

  1. Compose file
  2. Variables de entorno de la terminal
  3. Archivos con variables de entorno
  4. Dockerfile

Escalado y réplicas

En versiones anteriores de Compose, se nos permitió escalar las instancias de un contenedor a través del comando docker-compose scale. Las versiones más recientes lo reemplazaron con la opción –scale.

Por otro lado, podemos usar Docker Swarm, un grupo de Docker Engines, y escalar automáticamente nuestros contenedores de forma declarativa a través del atributo de réplicas de la sección de implementación:

services:
  worker:
    image: dockersamples/app_ejemplo
    networks:
      - frontend
      - backend
    deploy:
      mode: replicated
      replicas: 6
      resources:
        limits:
          cpus: '0.50'
          memory: 50M
        reservations:
          cpus: '0.25'
          memory: 20M
      ...

Bajo la implementación, también podemos especificar muchas otras opciones, como los umbrales de recursos. Sin embargo, Redactar considera toda la sección de despliegue cuando se implementa en Swarm, y lo ignora de otra manera.

Categorized in:

Tagged in:

,