Aplicando arquitecturas de microservicios en sistemas empotrados

En este artículo vamos a continuar expandiendo las funcionalidades del servidor OPC-UA que programamos en el anterior artículo. En este caso demostraremos, con un caso muy sencillo desarrollado sobre lo que hemos hecho anteriormente, la posibilidad de emplear arquitecturas de microservicios en sistemas empotrados dotados de sistema operativo Linux, como la Beaglebone o Raspberry Pi.

La arquitectura de microservicios es un estilo o método de desarrollo software con gran auge en los últimos años. Su objetivo es el desarrollo de una aplicación como un conjunto de servicios independientes con funcionalidades definidas que interactúan entre sí para construir una funcionalidad más completa. Dichos servicios hacen uso de interfaces de comunicación simplificadas para realizar esta interacción y pueden desplegarse de manera independiente y desarrollarse en diversos lenguajes de programación [1]. Esto se diferencia de las aplicaciones monolíticas, donde toda la lógica se encuentra contenida en un único bloque que incorpora todas las funcionalidades necesarias.

Fuente: Edvantis blog

La utilización de una arquitectura de microservicios conlleva en sí mismo una serie de ventajas frente al desarrollo de una aplicación monolítica. La más clara es quizás la separación del ciclo de desarrollo de cada una de las funcionalidades, que puede ser implementada por equipos disjuntos y sufrir actualizaciones y modificaciones en su comportamiento sin depender del resto de funcionalidades del sistema. Por otro lado, también se mejora la reutilización de servicios ya desarrollados en diversos casos de uso en base a sus necesidades (p. ej. un servicio de envío de mails, un almacenamiento en bases de datos, etc.). En cuanto a escalabilidad, se pueden escalar simplemente aquellos servicios que lo necesiten en lugar de la aplicación completa y este modelo también permite controlar mejor los errores en el sistema ya que un fallo en un servicio, si se gestiona adecuadamente, no tiene por qué implicar una caída completa de la aplicación, como seguramente ocurriría en el caso monolítico.

No obstante, también se debe tener en cuenta que la aplicación de este paradigma tiene también varias desventajas que se deberán considerar antes de desarrollar una aplicación con una arquitectura de microservicios. En primer lugar, se ha de tener en cuenta que se está trabajando con un sistema distribuido en lugar de centralizado, lo que puede introducir complejidad a la hora de desarrollar, depurar y desplegar el servicio completo. Por otro lado, la separación en servicios y el uso de interfaces de comunicación entre los mismos introduce una sobrecarga que en el caso de las aplicaciones monolíticas no existía.

En el caso de los sistemas empotrados su aplicación puede ser muy ventajosa en los sistemas tipo Raspberry por muchos de los motivos ya expuestos, aunque deberemos analizar cuidadosamente su aplicación ya que puede ser contraproducente a nivel de rendimiento y espacio ocupado por los servicios dependiendo de la aplicación. A continuación presentamos la evolución del servidor OPC-UA, que se dividirá en tres servicios desacoplados (lectura de datos de sensor, servidor OPC-UA en sí y almacenamiento en base de datos local) desplegados sobre contenedores Docker [2].

ResinOS, sistema operativo para sistemas empotrados con tecnología de contenedores

Como en el artículo anterior, escogeremos como plataforma hardware una Beaglebone Black. Aunque en este caso para el despliegue de los servicios y como sistema operativo base emplearemos ResinOS [3]. ResinOS es un sistema operativo de código libre basado en Linux (más concretamente en Yocto [4]) creado específicamente para el despliegue de aplicaciones en forma de contenedores Docker. En la figura se puede ver la arquitectura básica de este sistema operativo, el cual tiene una base muy simple que parte del kernel de Linux y las adaptaciones adecuadas para la placa hardware en la que se despliega el sistema, además de una serie de utilidades básicas. Por encima de todo se encuentra Docker, para realizar el despliegue de los contenedores con las aplicaciones creadas por el usuario, que se cargan en la placa objetivo desde una máquina de desarrollo como un PC normal. Cabe destacar que el sistema de ficheros raíz de ResinOS es de sólo lectura, lo que facilita que no tenga estado y disminuye en gran medida las posibilidades de corrupción del mismo, algo especialmente interesante para el despliegue de sistemas empotrados en producción.

Fuente: ResinOS

Desarrollo de una aplicación basada en microservicios

Estructura general de la aplicación

Como ya se comentó en la introducción del artículo, la aplicación que vamos a desplegar en la Beaglebone será una evolución de un servidor OPC-UA para dotarlo de almacenamiento con una base de datos sencilla y separar la funcionalidad de lectura de datos del sensor de la implementación del propio servidor. Para la comunicación entre los microservicios emplearemos la librería gRPC [5], un framework de código abierto empleado para conectar servicios de manera eficiente. Para no complicar demasiado el contenido del artículo vamos a utilizar una librería de OPC-UA, en este caso node-opcua [6] que, como su propio nombre indica, está basada en NodeJS y por tanto emplea Javascript como lenguaje de programación.

La siguiente figura recoge por tanto la estructura que tendría la nueva aplicación, formada por tres microservicios y las relaciones entre los mismos. El microservicio de lectura de datos del sensor actuará como cliente, siendo el servidor el microservicio OPC-UA, al que le hará un “set” de los datos cada vez que estos cambien. A su vez, el microservicio OPC-UA actuará como cliente del  microservicio de base de datos, almacenando la información en Redis. Para ilustrar la posibilidad de emplear diversos lenguajes de programación para desarrollar cada uno de estos servicios en cada uno de ellos utilizaremos uno distinto (Python, Javascript y Go).

Configuración del entorno y dispositivo

En primer lugar deberemos configurar nuestro entorno de trabajo para poder utilizar los diversos lenguajes de programación (principalmente para generar los stubs de gRPC pues el código lo desplegaremos en contenedores). Para ello seguiremos las instrucciones que se proporcionan en los siguientes enlaces suponiendo que utilizamos Debian como sistema operativo base:

  • Python
  • Go (sustituyendo la versión 1.10 por la última disponible)
  • NodeJS

Instalación de ResinOS

A continuación, cargaremos el sistema operativo en nuestra placa. En este caso emplearemos una Beaglebone, por lo que seguiremos las instrucciones de este enlace. Una vez instalado el sistema ya podremos comenzar a desplegar los servicios de nuestra aplicación.

Microservicio de lectura de GPIO

Trabajaremos para cada microservicio dentro de su propio directorio, por lo que creamos uno para este microservicio:

A continuación deberemos instalar las utilidades de gRPC para Python:

Con esto podremos crear el código stub para un cliente y servidor gRPC en Python a partir del fichero de definición del servicio. Para el caso que nos concierne crearemos el siguiente fichero de definición del servicio:

Abriremos dicho fichero con nuestro editor favorito e introduciremos el siguiente contenido, donde se define el servicio que se utilizará entre el servidor OPC-UA y este mismo servicio, con sus correspondientes mensajes de petición y respuesta:

El siguiente paso será generar el código Python a partir de la definición del servicio, configurando como directorios de salida y de localización del fichero de servicio el directorio en el que nos encontramos en ese momento:

De esta forma se generarán dos ficheros de código Python:

  • py que contiene el código de los mensajes de petición y respuesta (Counter y Result respectivamente).
  • py que contiene el código del cliente y servidor gRPC para el servicio especificado. En el caso de este servicio únicamente emplearemos la parte de cliente como se ha comentado anteriormente. En el siguiente apartado veremos cómo generar el código del servidor para el microservicio OPC-UA a partir de esta misma definición de servicio.

Con este código generaremos el cliente gRPC en un nuevo fichero:

Incluyendo el siguiente código inicial:

El siguiente paso será actualizar este código para realizar la lectura del GPIO que nos dé el valor del sensor de paso.  El código actualizado es el siguiente, modificado para generar una petición gRPC con el valor de contador actualizado cada vez que se actualiza el valor del GPIO:

Finalmente, para poder desplegar la aplicación en ResinOS es necesario definir un Dockerfile que crearemos en el directorio del servicio:

Y editaremos con el siguiente contenido, donde se copian los scripts de Python que hemos creado anteriormente y se ejecuta el cliente gRPC:

Dado que todavía no hemos desarrollado el servidor para este cliente gRPC pospondremos el lanzamiento del servicio hasta que finalicemos la programación de todos los servicios.

Microservicio servidor OPC-UA

Como con el anterior microservicio, trabajaremos en un directorio propio:

Este servicio, además de comunicarse con el que se encarga de leer los datos del GPIO, también lo hará con el que actuará a modo de base de datos para el almacenamiento de históricos, actuando como cliente. Por tanto, definiremos ya el fichero de definición de este servicio y sus mensajes:

Dicho fichero tendrá el siguiente contenido:

Posteriormente, inicializamos node y cubrimos la información solicitada. Descargamos la librería node-opcua, la librería grpc, copiamos el fichero proto de definición del servicio al directorio actual y creamos el fichero en el que definiremos el servidor:

En el caso de NodeJS no es necesario realizar una generación previa de ficheros de código (aunque se podría hacer) ya que las propias funciones de la librería de gRPC permiten cargar los ficheros de definición y generar los métodos del servidor en tiempo de ejecución. El código del servidor, con la definición de los nodos OPC-UA que contiene, la funcionalidad de servidor gRPC para aceptar actualizaciones del contador de piezas y la funcionalidad de cliente gRPC para el almacenamiento de datos es el siguiente:

De nuevo, para poder desplegar el servicio en la Beaglebone deberemos crear un fichero Dockerfile:

Y editarlo con el siguiente contenido:

Microservicio de base de datos

Como base de datos para el almacenamiento emplearemos Redis como un contenedor independiente a este microservicio. La tarea de este microservicio será por tanto proporcionar una interfaz a otros para facilitar almacenamiento. Dado que ya vamos a usar un contenedor que permite al acceso remoto a Redis, podría tener sentido que el propio servidor OPC-UA implementara en su código las instrucciones necesarias para hablar con Redis. No obstante, siguiendo el esquema que proponemos en este apartado, si en algún momento se quiere cambiar el método de almacenamiento (por ejemplo a otro tipo de base de datos como SQLite) únicamente sería necesario cambiar el código de este microservicio para adaptarlo. En caso contrario habría que modificar el código de todos los servicios que accedieran al almacenamiento.

En el caso de Go debemos ceñirnos a utilizar la estructura de directorios recomendada para desarrollar en el lenguaje, por lo que en primer lugar crearemos un directorio para alojar el código con las funciones del servidor gRPC que generaremos:

Copiamos el fichero de definición de servicio que creamos en la sección anterior a este directorio e instalamos la utilidad necesaria para realizar la compilación:

Ejecutamos el siguiente comando, que generará el archivo storage-service.pb.go, para crear el código necesario en Go a partir de la definición del servicio:

A continuación crearemos el fichero que incluirá el servidor gRPC y que contendrá las interacciones necesarias a realizar con Redis para el almacenamiento de datos. Será necesario también descargar una librería cliente de Redis para Go:

Dicho fichero incluirá el siguiente código:

El siguiente paso será la creación de un archivo para la ejecución del programa principal. Para ello crearemos un nuevo directorio y un fichero main.go, además de instalar también la librería de gRPC:

Y dentro del fichero main.go incluiremos el siguiente código fuente:

Este servicio también lo desplegaremos en la Beaglebone dentro de un contenedor Docker, pero procederemos de una manera un poco distinta a los casos anteriores, en los que creábamos un Dockerfile para compilar completamente la aplicación al cargarla en la placa. En este caso, para minimizar el tamaño del contenedor vamos a compilarlo de manera estática para incluir en un único binario todas las librerías necesarias, haciéndolo ya para arquitectura ARM (este proceso podríamos hacerlo también dentro de un contenedor que genere el binario utilizando por ejemplo la funcionalidad de multi-stage builds de Docker):

Una vez que tenemos este binario deberemos retornar al directorio del proyecto inicial, donde estábamos trabajando y crear una nueva carpeta en la que copiaremos el binario y crearemos un fichero Dockerfile:

Finalmente, el Dockerfile que generaremos será muy sencillo, utilizando únicamente una imagen Alpine scratch para contener el servicio:

Una vez creados todos los servicios el único paso restante es desplegarlos en la Beaglebone.

Despliegue de los servicios

Para el despliegue de los servicios en la Beaglebone haremos uso de Docker Compose [7], una herramienta que permite definir aplicaciones multi-contenedor para facilitar su despliegue. Su uso está soportado por ResinOS desde hace pocos meses, con su introducción en la versión 2.12.0 [8]. Procederemos creando en primer lugar un fichero yaml en el raíz de nuestro proyecto en el que se incluye la definición de los distintos servicios y sus relaciones:

Su contenido será el que se expone a continuación:

Para realizar el despliegue simplemente deberemos ejecutar el siguiente comando, donde “nombre_host” es el que hayamos configurado al crear la imagen de ResinOS, y hacer una pausa para tomar un café, pues el despliegue de algunos contenedores (en especial el del servidor OPC-UA) llevará su tiempo: host

Cuando el despliegue finalice podemos probar que todo funciona correctamente conectándonos a Redis en remoto desde nuestro PC y verificando que la lista de valores se actualiza cada vez que el sensor de paso detecta un objeto. Lo haremos de la siguiente manera:

Dentro del cliente con el siguiente comando veremos la lista de valores del contador:

Conclusiones

A lo largo de este artículo hemos demostrado cómo ya es posible llevar los modelos de arquitectura de microservicios basados en contenedores, muy utilizados en el mundo de la nube, a los sistemas empotrados. Para ello hemos desarrollado una demostración sobre placas de prototipado tipo Raspberry Pi o Beaglebone. Como hemos comentado anteriormente, la utilización de este tipo de arquitectura, aunque tiene múltiples ventajas, también tiene una serie de desventajas. Analicemos algunos puntos sobre la aplicación que acabamos de desarrollar.

Tamaño de las imágenes

Si nos conectamos a la Beaglebone por SSH (deberemos estar utilizando la imagen de desarrollo de ResinOS) y ejecutamos el siguiente comando podremos ver el tamaño de las imágenes Docker generadas:

Que se recoge en la siguiente tabla:

Imagen

Lenguaje

Tamaño

gpio-clientPython498MB
opcua-serverJavascript595MB
storageGo16.3MB
redisN/A83.3MB

Como se puede observar, el tamaño final de la aplicación desarrollada es de unos nada despreciables 1,16GB, algo completamente desorbitado para una aplicación a desplegar en un sistema empotrado, en el que está ocupando más de una cuarta parte de su memoria disponible en el caso de una Beaglebone. No obstante, debemos destacar la diferencia entre el tamaño de las imágenes en las que empleamos Python y Javascript con aquella en la que empleamos Go. Aunque seguramente podríamos tomar varias decisiones para minimizar su tamaño, el de esta última es mucho más acorde con lo esperado para un sistema empotrado de este tipo. Por tanto, debemos ser muy cuidadosos a la hora de escoger los lenguajes y las imágenes base para utilizar este tipo de arquitecturas en sistemas empotrados. Habría sido más recomendable por ejemplo emplear la librería open62541 [9], en C, para desarrollar el servidor OPC-UA y código C o Go también para la lectura de los GPIO.

Complejidad del desarrollo

En el caso del ejemplo desarrollado en este artículo hemos empleado gRPC como mecanismo para posibilitar la comunicación entre los microservicios. Esto incluye una complejidad adicional en el ciclo de desarrollo pues es necesario realizar una definición concreta de todos los servicios y mensajes y generar el código adicional para la serialización e interacción con los servicios. Este proceso podría simplificarse a través de la utilización de otros mecanismos para la comunicación entre los servicios como por ejemplo MQTT o el propio Redis. No obstante, hay que tener en cuenta que a través de gRPC estamos formalizando unos servicios concretos con un formato de mensaje específico, mientras que MQTT o Redis son más laxos en ese sentido.

Escalabilidad

La división del código en microservicios permite escalar las funcionalidades desarrolladas con mayor facilidad. Por ejemplo, imaginemos que el almacenamiento asignado a Redis en una Beaglebone se agota, con esta arquitectura sería posible redirigir las peticiones de almacenamiento a otra Beaglebone con el mismo servicio situada dentro de la misma red. Sin embargo debemos de ser de nuevo cuidadosos con las funcionalidades que desarrollamos y cómo lo hacemos. En el ejemplo hemos creado un microservicio para la lectura de un GPIO pero, ¿tendría sentido replicar este microservicio cada vez que leamos un GPIO? La respuesta es no. Hacer esto causaría una alta ineficiencia, no sólo por el tamaño de las imágenes generadas sino también por la sobrecarga en las comunicaciones. En este caso, la solución más viable sería extender nuestro servicio para que permitiera suscribirse a las actualizaciones de varios GPIOs por ejemplo, sin replicarlo para cada uno de ellos.

Dentro de la línea de Internet de las Cosas de Gradiant, estamos trabajando con este tipo de arquitecturas para facilitar el desarrollo de nuevos servicios basados en tecnologías para sistemas empotrados, sobre todo para industria y sector primario, con el objetivo de facilitar su desarrollo y despliegue, minimizar los tiempos de instalación y configuración y ayudar en la orquestación de dichos servicios según las necesidades inherentes a cada caso de uso.

Referencias

[1] https://martinfowler.com/articles/microservices.html

[2] https://www.docker.com/

[3] https://resinos.io/

[4] https://www.yoctoproject.org/

[5] https://grpc.io/

[6] http://node-opcua.github.io/

[7] https://docs.docker.com/compose/

[8] https://resin.io/blog/multicontainer-on-resin-io-is-here/

[9] https://open62541.org/

 

 

 


Autor: Daniel García Coego, responsable técnico IoT y CPS en el área de Sistemas Inteligentes en Red (INetS) de Gradiant