Ejecutar aplicaciones multi-contenedor con docker-compose

Tengo que decir que cuanto más me adentro en el mundo Docker más me está gustando la experiencia. Sin embargo, he de reconocer que a veces es difícil elegir por dónde continuar contándote. Seguro que cada uno eligiríamos un camino distinto, ya que son tantos conceptos y tantos puntos a tener en cuenta que a veces no es fácil priorizar. Lo que si que creo es que, cuando empiezas con cualquier tecnología, es importante de que te surja la necesidad de algo más, ya que así entiendes el por qué de otras características. Por ejemplo, todavía no hemos visto nada de volúmenes, clústers, redes, etcétera pero creo que es importante ir poco a poco, al menos para que vayas entendiendo el por qué de las cosas.

Para terminar la semana, hoy te quiero contar un tema super interesante y es la ejecución de aplicaciones multi-contenedor. Si has seguido los últimos artículos, empecé con una aplicación demasiado sencilla llamada nodejs-webapp, que básicamente era un servidor web que devolvía código HTML estático sin más.

var express = require('express');
var app = express();

app.get('/', function (req, res) {
  res.sendFile(__dirname + '/index.html');
});

app.listen(3000, function () {
  console.log('Example app listening on port 3000!');
});

Era más que suficiente para ver cómo es posible ejecutar una aplicación con Node.js en un contenedor, cómo usar Docker Hub o Azure Container Registry, o incluso cómo ejecutar imágenes en Azure App Service, pero lo cierto es que las aplicaciones no son así. Las aplicaciones suelen estar compuestas de diferentes servicios, como una base de datos, APIs, un servidor web, etcétera.

¿Y cómo encajamos todas estas piezas en contenedores? Lo primero que podríamos pensar es en meter todo dentro de un contenedor, lo cual queda totalmente descartado 🙂 En primer lugar, porque desde hace mucho mucho tiempo tenemos que pensar en aplicaciones distribuidas, en componentes independientes que trabajen en conjunto pero que puedan ser mantenidos, actualizados y escalados por separado. Obviamente, cada aplicación es un mundo pero lo que está claro es que cada componente dentro de mi aplicación debería de tener un número limitado de responsabilidades bien definidas. En este artículo voy a abandonar nuestro nodejs-webbapp y voy a usar algo un poco más real para crear una aplicación multi-contenedor.

Aplicación de ejemplo

Mi aplicación va a constar de tres partes o servicios:

  1. front end: será un servidor web en Node.js que servirá la web que ve el usuario final.
  2. back end: servidor web que contiene una API que me permite manipular un conjunto de topics.
  3. mongodb: una base de datos, de tipo mongodb, donde almacenaré los topics.

Como ves, ya no se trata sólo de un sitio web sino que tenemos tres componentes bien diferenciados, cada uno con sus propias responsabilidades.

Front end

No quiero complicarlo mucho, pero sí que quiero usar React.js para mostrar el contenido en la web. He creado una carpeta, multi-container-app, donde voy a añadir el contenido de dos de las tres imágenes que voy a crear, de frontend y backend. Dentro de este directorio ejecuta el siguiente comando para crear frontend.

npx create-react-app frontend

Lanzando este comando se generará el esqueleto completo de las aplicaciones que utilizan este framework, por lo que es muy sencillo empezar. Instala también el módulo semantic-ui-react, que te ayudará a que quede algo más bonito.

npm install semantic-ui-react --save

Una vez que estén todos los paquetes instalados, abre el archivo App.js y reemplázalo con lo siguiente:

import React, { Component } from 'react';
import { Container, Header, Message, Table } from 'semantic-ui-react';

export default class App extends Component {

  constructor(props) {
    super(props);
    this.state = {
      topics: null,
      isLoading: null
    };
  }

  componentWillMount() {
    this.getTopics();
  }

  async getTopics() {

    try {

      this.setState({ isLoading: true });
      let response = await fetch('http://localhost:8080/api/topics');
      let data = await response.json();

      this.setState({
        topics: data,
        isLoading: false
      });

    } catch (error) {
      this.setState({ isLoading: false });
      console.error(error);
    }

  }

  render() {
    return (
      <Container text style={{ marginTop: '7em' }}>
        <Header as="h1">Topics</Header>
        {this.state.isLoading && <Message info header="Loading topics..." />}
        {this.state.topics && (
          <div>
            <Table>
              <thead>
                <tr>
                  <th>Name</th>
                </tr>
              </thead>
              <tbody>
                {this.state.topics.map(topic => (
                  <tr id={topic._id} key={topic._id}>
                    <td>{topic.name}</td>
                  </tr>
                ))}
              </tbody>
            </Table>
          </div>
        )}
      </Container>
    );
  }
}

Si no conoces React.js es una tarea más que tienes pendiente 😉 En este componente lo único que hago es que cuando se carga (componentWillMount) utilizo fetch para hacer una llamada al servicio de backend, que todavía no hemos creado, para recuperar los topics.

Para que los estilos de semantic-ui se vean correctamente, añade dentro de la etiqueta head del archivo public/index.html el siguiente enlace a semantic.min.css.

<link rel="stylesheet" href="//cdn.jsdelivr.net/npm/[email protected]/dist/semantic.min.css" />

Al igual que en nuestra aplicación nodejs-webapp necesitamos crear dos archivos más: Dockerfile, donde incluiremos la receta necesaria para generar la imagen de este servicio:

FROM node:10.13-alpine

WORKDIR /app

COPY ["package.json", "package-lock.json*", "./"]

RUN npm install

COPY . .

EXPOSE 3000

CMD npm start

Y el archivo .dockerignore con aquellos archivos y directorios que no queremos que docker build tome en cuenta.

node_modules
npm-debug.log
Dockerfile*
docker-compose*
.dockerignore
.git
.gitignore
.env
*/bin
*/obj
README.md
LICENSE
.vscode

Como ya sabes, para generar una imagen de frontend ejecuta el siguiente comando dentro de su directorio:

docker build -t frontend .

En el primer artículo de esta serie te enseñe a utilizar el etiquetado para que pudieras poner la versión que quisieras a tus imágenes. Además utilizábamos –tag=frontend. En este quería enseñarte otra forma de hacer lo mismo. Sin embargo, al no proporcionar una etiqueta lo que ocurrirá es que utilizará la llamada latest que indica que es la última versión.

Back end

Ahora llega el momento del back end. Como ya has visto en el apartado anterior, lo único que voy a hacer es generar un par de métodos para listar y añadir topics. Para ello voy a usar Express.js y como sistema de almacenamiento utilizaré MongoDB, que será el último servicio de esta aplicación. Crea una nueva carpeta dentro de multi-container-app llamada backend y utiliza el siguiente comando para generar el archivo package.json.

npm init -y

Ahora crea un archivo llamado server.js y copia el siguiente código:

const express = require('express'),
    mongoose = require('mongoose'),
    bodyParser = require('body-parser'),
    cors = require('cors'),
    app = express();

//configure CORS
app.use(cors());

//configure app to use body-parser
app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json());

const port = process.env.PORT || 8080;

mongoose.connect('mongodb://mongodb:27017', err => {
    if (err)
        throw err;
    console.log('Connected to mongodb');
});

//mongoDB service
var Topic = require('./models/topics');

//Routes
var router = express.Router();

router.post('/topics', (req, res) => {
    console.log('[POST] Topics');
    
    var topic = new Topic();
    topic.name = req.body.name;

    topic.save(err => {
        if (err)
            res.send(err);

        res.json({ message: 'Topic created!' });
    });
});

router.get('/topics', (req, res) => {
    console.log('[GET] Topics');
    
    Topic.find((err, topics) => {
        if (err)
            res.send(err);

        res.json(topics);
    });
});

//all routes will be prefixed with /api
app.use('/api', router);

//start the server
app.listen(port, () => {
    console.log(`Server up and running on port ${port}`);
});

Para que esta API funcione necesitas instalar los módulos express, cors, mongoose y body-parser. Puedes hacerlo a través del siguiente comando:

npm install --save express cors mongoose body-parser

Además, como es la API de topics la que hace uso de un MongoDB, crea un archivo llamado /models/topics.js donde definirás el modelo de un topic.

//models/topics.js

const mongoose = require('mongoose'),
    Schema = mongoose.Schema;

var TopicSchema = new Schema({
    name: String
});

module.exports = mongoose.model('Topic', TopicSchema);

Al igual que el front end, el servicio de back end también necesita un archivo Dockerfile, e idealmente un .dockerignore. Este último podemos copiarlo del que hemos usado en el front end. En cuanto al archivo Dockerfile, cambia ligeramente:

FROM node:10.13-alpine

WORKDIR /app

COPY ["package.json", "package-lock.json*", "./"]

RUN npm install

COPY . .

EXPOSE 8080

CMD npm start

En este caso estamos exponiendo el puerto 8080. Ejecuta el siguiente comando, dentro del directorio backend para comprobar que la imagen se crea correctamente.

docker build -t backend .

Ya tenemos nuestros dos servicios, uno que servirá la página web, a la que accederá el usuario, y un back end que facilitará el acceso a un MongoDB a través de una API. Para finalizar necesitamos una nueva receta que indique cómo deben trabajar juntos dentro de una misma aplicación, o como lo llaman en la documentación de Docker, stack.

Utilizando docker-compose

Si con docker run ejecutábamos un contenedor, con docker-compose seremos capaces de ejecutar varios a la vez. Para poder ejecutar este comando, antes de nada necesitamos un archivo más, de esos que yo llamo receta. Puedes crearlo donde quieras, pero yo prefiero ponerlo en la raíz de multi-container-app.

version: '3.7'

services:
  frontend:    
    image: frontend
    ports:
      - 4000:3000
    depends_on:
      - backend
  backend:
    image: backend
    ports:
      - 8080:8080
    depends_on:
      - mongodb
  mongodb:
    image: mongo:latest

He intentado dejarlo lo más simple posible, pero sé que hay muchas cosas que se pueden o deben añadir a este archivo, pero esto es lo mínimo que necesitas. Como ves, en él aparecen los tres servicios de mi aplicación: frontend, backend y mongodb. En los tres se indica cuál es la imagen que se utilizará para crear el contenedor. Además, tanto en frontend como en backend aparece el mapeo de puertos con el mundo exterior, para que podamos acceder desde fuera. En el contenedor donde se ejecutará mongodb no es necesario, a no ser que también quieras acceder. La propiedad depends_on me ayudará a iniciar los servicios en orden, ya que el frontend no puede funcionar correctamente si el backend no se ha iniciado y el backend no funcionará si mongodb no está todavía listo.

Con esta configuración mínima, ya estás listo para ejecutar tu aplicación multi-contenedor. Para arrancar los tres componentes a la vez, ejecuta el siguiente comando, en la misma ubicación donde tienes el archivo docker-compose.yml:

docker-compose up

En unos instantes verás que en la consola aparecerá el output de los tres contenedores, lo cual facilita muchísimo la depuración.

docker-compose up
docker-compose up

Para crear algunos topics a través de la API puedes utilizar un cliente como Postman y hacer algunas peticiones POST a http://localhost:8080/api/topics pasando como parámetro name, que es el nombre del topic.

Llamando al servicio backend para crear topics
Llamando al servicio backend para crear topics

Para acceder al front end accede a http://localhost:4000, que es el puerto que has mapeado en el archivo docker-compose.yml.

Docker - Reactjs app - Topics
Docker – Reactjs app – Topics

Y lo mejor: cuando pulsas Control + C automáticamente apaga todos los contendores definidos en el archivo docker-compose.yml.

docker-compose – Control + C

Como ves, Docker no solo te permite crear un contenedor para tu aplicación, sino que además te permite crear aplicaciones distribuidas de una forma muy útil para el desarrollo y la automatización.

El código lo tienes en mi GitHub.

Imagen de portada por kyohei ito.

¡Saludos!