Creando módulos especializados para Azure IoT Edge

Tiempo de lectura: 12 minutos
Imagen ornamental para la entrada "Creando módulos especializados para Azure IoT Edge"

Después de unas semanas muy liado con unas complicaciones personales (solo he tenido tiempo de publicar la review de NDepend), ya estamos de vuelta y además con una entrada que toca muchos palos. Vamos a hablar de Azure, vamos a hablar de C# y vamos a hablar de Docker.

Por el título de la entrada, no es ningún secreto el tema del que vamos a hablar de Azure IoT Edge y sus módulos. La idea de escribir esto es porque no hace mucho escribí en el blog de Plain Concepts una entrada sobre cómo desplegar cargas de trabajo ‘on-prem’ utilizando IoT Hub.

Aunque lo que se plantea en la entrada es totalmente cierto y el runtime de IoT Edge permite correr imágenes Docker sin más, la verdad es que nos permite ir un paso más allá. IoT Edge nos va a permitir crear módulos especializados con los que vamos a poder utilizar las funcionalidades propias de IoT Hub y todo ello empaquetado en una imagen Docker. Con esto vamos a conseguir que nuestro IoT Edge deje de ser un ‘nodo’ de Docker al que podemos mandar cosas y se convierta en un verdadero punto de computación de borde.

¿Por qué debería usar módulos de IoT Hub en lugar de imágenes Docker normales?

Es cierto que con una imagen Docker las cosas soy mucho más simples comunes a la hora de desarrollar. Tienes un fichero Dockerfile, expones unos puertos, creas tus APIs, y si necesitas comunicarte con IoT Hub para enviar mensajes, puedes utilizar directamente el cliente de IoT Hub para el lenguaje con el que estés trabajando. ¿Por qué debería entonces complicarme la vida?

Pues, aunque los módulos implican añadir ciertos cambios a la hora de programar (más bien de desplegar), no dejan de ser una aplicación de consola sin más, al que se le ha dotado de funcionalidad para interactuar con el runtime sobre el que está corriendo. Esto se traduce en 2 grandes ventajas:

  • Delegamos en el runtime el enrutado de los mensajes entre los diferentes módulos.
  • Tenemos a nuestra disposición toda la potencia de IoT Hub en cada módulo (envío de métricas/mensajes, dispositivo gemelo, mensajes desde la nube al dispositivo, etc…), utilizando la conexión del runtime de IoT Edge.

¿Las ventajas son realmente ventajas?

Vale, he planteado las 2 cosas que a mi parecer son las principales mejoras, ¿pero esto realmente son mejoras? Analicemos la primera de ellas, el enrutado de mensajes.

El planteamiento de IoT Edge es tener pequeños programas que vayan añadiendo procesado sobre los datos de entrada. Imaginemos un caso donde tenemos una cámara capturando imágenes de una autopista, las imágenes que se obtienen se procesan y por último se etiquetan. Es cierto que esto se podría hacer de manera monolítica todo en el mismo proceso, pero eso dificulta el reutilizar código. Si separamos la adquisición en una imagen, el procesado en otra y el etiquetado en otra, vamos a poder cambiar una de las piezas por otra y reutilizar al máximo.

Por poner un caso, podríamos tener otro escenario en el que solo queramos capturar imágenes y mandarlas a un almacenamiento, el hecho de que la adquisición este modularizada, nos va a permitir reaprovechar ese módulo y añadir solo la parte nueva de persistencia.

Hecha esta aclaración sobre la modularización y reutilización, podríamos extraer varios módulos del conjunto de los dos escenarios:

  • Adquisición de imágenes.
  • Procesado de las imágenes.
  • Etiquetado de las imágenes.
  • Persistencia de las imágenes.

Y su representación sería algo así:

La imagen muestra un diagrama de bloques en que que hay 2 zonas. La primera zona dice "escenario 1" y tienes 3 bloques unidos por flechas. Desde bloque Captura sale una flecha hacia Procesado, y desde ahí sale una flecha hacia Etiquetado.
La segunda zona es "escenario 2" y tiene una flecha que sale desde el bloque Captura y llega a Persistencia

En una situación multi contenedor corriendo sobre Docker, simplemente podríamos decirle a un contenedor, por ejemplo, Captura, que una vez que tenga una imagen la mande al siguiente contenedor siguiendo un modelo push (es el propio contenedor el que se encarga de empujar los datos al siguiente). Incluso podríamos cambiar el planteamiento y utilizar un modelo pull donde cada paso obtenga los datos del paso previo.

Esto tiene problemas a la hora de reutilizar los módulos, ya que cada módulo necesita tener consciencia o desde donde obtiene los datos o a donde los tiene que enviar. Tenemos acoplamiento entre los módulos. Si por ejemplo quisiéramos añadir al escenario 1 la persistencia entre la captura y el procesado (o en cualquier otra posición), tendríamos que modificar el código del módulo para que se adapte al nuevo escenario, no nos vale solo con modificar las configuraciones.

En este punto, voy a aclarar que en programación casi todo es posible, e incluso esto se puede solucionar añadiendo código o creando nosotros mismos sistemas complejos que lo solucionen. En algunos casos puede que sea necesario, pero generalmente es tiempo y esfuerzo en balde.

Para evitar esto, podríamos añadir algún sistema de colas donde cada módulo publique los resultados a una cola y lea las entradas desde otra cola. De este modo cada módulo no necesita saber nada al respecto del anterior ni del siguiente, simplemente lee los datos desde una cola y los deja en otra cola. El problema de este planteamiento es que somos nosotros los que vamos a tener que mantener la infraestructura de colas… y aquí es donde coge importancia el delegar en el runtime el enrutado de mensajes.

IoT Edge nos proporciona de serie un sistema de enrutado que podemos utilizar desde los módulos especializados de IoT Edge. Nosotros simplemente vamos a definir una o más entradas para nuestro módulo de IoT Edge, y de igual manera vamos a escribir una serie de salidas del módulo. Después, desde la propia configuración de IoT Edge vamos a definir mediante lenguaje de consulta el coger las salidas un el módulo A y enviarlas a la cola de entrada del módulo B. Es importante aquí el punto de lenguaje de consulta, ya que vamos a poder aplicar condiciones para que la ruta se cumpla.

Esto desacopla totalmente los módulos entre si, ya que estamos usando un sistema de colas, pero en este caso es el propio runtime quien lo gestiona.

Por otro lado, comentaba que la segunda gran ventaja era que el propio módulo de IoT Edge era capaz de ser un dispositivo IoT en si mismo, aprovechando la conexión del runtime. Esto significa que por ejemplo que podemos utilizar el sistema de rutas para enviar métricas a IoT Hub, es decir, es posible especificarle a IoT Edge que coja la cola de salida del módulo A y que directamente la envíe como una métrica a IoT Hub.

Creando la solución IoT Edge

Como hablar siempre es muy fácil, vamos a crear un módulo IoT Edge desde 0, que enrute los mensajes generados desde el simulador de pruebas que ofrece Microsoft. Lo primero de todo es preparar el entorno, el resumen ejecutivo de los elementos que necesitamos es .Net Core y una extensión para Visual Studio o Visual Studio Code que nos de soporte para desarrollar módulos IoT Edge. Una vez instalada la extensión, seguiremos la documentación específica para configurar la extensión con nuestro IoT Hub.

En este caso por versatilidad, yo estoy utilizando Visual Studio Code, por lo que, una vez instalada y configurada la extensión, basta con escribir «Azure IoT Edge: New IoT Edge solution» en la paleta de comandos.

La imagen muestra la paleta de Visual Studio Code con el comando "Azure IoT Edge: New IoT Edge solution" escrito

Esto iniciará un pequeño asistente en el que nos va a pedir una ruta, un nombre para la solución, y nos va a ofrecer una plantilla de módulo IoT Edge en varios lenguajes diferentes. Una vez seleccionado el nombre del módulo, nos va a pedir el último paso que es decirle cual es el registro de Docker en el que se tiene que subir la imagen con el módulo. Este registro puede ser público o privado.

Una vez hecho esto, ya tenemos listo el andamiaje del proyecto, y ya podemos editar y generar el módulo, desplegarlo, depurarlo,… Este andamiaje está compuesto de varios ficheros que vamos a analizar.

La imagen muestra la plantilla de IoT Edge end Visual Studio Code

El fichero .env contendrá variables que se reemplazan durante el proceso de generación. Principalmente valores sobre el registro de Docker.

Dentro de la carpeta .vscode, se ha creado el código necesario para ejecutar en local el código (que no deja de ser una aplicación de consola), o para depurarlo dentro de un contenedor Docker.

La carpeta config va a contener los ficheros de configuración que generamos como artefacto de salida del proceso. Estos ficheros son muy útiles ya que son el json que podemos utilizar para desplegar sobre un dispositivo IoT Edge el conjunto de módulos que definamos. Esto es así porque realmente hemos creado una solución completa de IoT Edge que contiene módulos, algunos pueden ser de terceros y algunos nuestros (como es el caso).

En la carpeta modules es donde encontramos la magia. Aquí es donde vamos a encontrar los diferentes módulos que estemos creando. Ahora entraremos en detalle.

Por último, nos encontramos los ficheros deployment.template.json y deployment.debug.template.json. Estos ficheros son los que van a servir de entrada para que durante el proceso de generación del módulo se cree el artefacto de salida en la carpeta config. La principal diferencia entre ellos es que el que contiene debug nos va a permitir depurar el código del contenedor.

El módulo IoT Edge

Para acabar de revisar que tenemos, cuando entramos en la carpeta modules nos encontramos con un buen número de ficheros Dockerfile, un fichero module.json y un program.cs.

Si comprobamos que contiene el fichero program.cs, nos encontramos con que no es más que una aplicación de consola. Hay que reconocer que la plantilla no es todo lo óptimo que debería ser en cuanto a buenas prácticas, pero podríamos reducirlo a algo así:

using System;
using System.Runtime.Loader;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Azure.Devices.Client;
using Microsoft.Azure.Devices.Client.Transport.Mqtt;

namespace SampleModule
{
    class Program
    {
        static int counter;

        static async Task Main(string[] args)
        {
            await InitAsync();

            var cts = new CancellationTokenSource();
            AssemblyLoadContext.Default.Unloading += (ctx) => cts.Cancel();
            Console.CancelKeyPress += (sender, cpe) => cts.Cancel();
            await WhenCancelledAsync(cts.Token);
        }

        public static Task WhenCancelledAsync(CancellationToken cancellationToken)
        {
            var tcs = new TaskCompletionSource<bool>();
            cancellationToken.Register(s => ((TaskCompletionSource<bool>)s).SetResult(true), tcs);
            return tcs.Task;
        }

        static async Task InitAsync()
        {
            MqttTransportSettings mqttSetting = new MqttTransportSettings(TransportType.Mqtt_Tcp_Only);
            ITransportSettings[] settings = { mqttSetting };

            ModuleClient ioTHubModuleClient = await ModuleClient.CreateFromEnvironmentAsync(settings);
            await ioTHubModuleClient.OpenAsync();

            await ioTHubModuleClient.SetInputMessageHandlerAsync("input1", PipeMessage, ioTHubModuleClient);
        }

        static async Task<MessageResponse> PipeMessage(Message message, object userContext)
        {
            int counterValue = Interlocked.Increment(ref counter);

            var moduleClient = userContext as ModuleClient;
            if (moduleClient == null)
            {
                throw new InvalidOperationException("UserContext doesn't contain " + "expected values");
            }           
            
            //Encolamos el mensaje como salida
            await moduleClient.SendEventAsync("output1", message);
            return MessageResponse.Completed;
        }
    }
}

Si analizamos el código (he cambiado ligeramente para usar await en vez de .Wait() respecto a la plantilla y borrado código que no aporta), podemos comprobar que tenemos un Main que inicializa el dispositivo llamando a InitAsync (Init en la plantilla original). Dentro de este método, se inicializa el módulo IoT Edge a través del tipo ModuleClient.

Sobre este módulo registramos un manejador para cada vez que llegue un mensaje por la cola «input1» con la línea:

await ioTHubModuleClient.SetInputMessageHandlerAsync("input1", PipeMessage, ioTHubModuleClient);

Añadir nuevas entradas es tan simple como registrar más manejadores para otras colas de entrada.

Para escribir los mensajes a una cola de salida, se utiliza la línea:

await moduleClient.SendEventAsync("output1", message);

En este caso, simplemente estamos utilizando el mensaje de la cola de entrada «input1» del módulo IoT Edge y lo estamos colocando en la cola de salida «output1».

Adicionalmente a lo que nos ofrece la plantilla, el módulo nos ofrece también varios métodos con los que vamos a poder ampliar la utilidad del módulo mediante configuración de dispositivo gemelo, recepción de mensajes CloudToDevice, o llamadas a métodos directos. Esto se consigue simplemente añadiendo a la inicialización las líneas:

await ioTHubModuleClient.SetDesiredPropertyUpdateCallbackAsync(OnDesiredPropertiesUpdateAsync, ioTHubModuleClient);
await ioTHubModuleClient.SetMessageHandlerAsync(HandleMessageAsync,ioTHubModuleClient);
await ioTHubModuleClient.SetMethodHandlerAsync("hello",HelloMethodHandlerAsync,ioTHubModuleClient);

A este código hay que añadirle los callback de los manejadores, para no alargar una entrada ya de por si larga, puedes consultar el código completo en el repositorio de Github.

Hay que tener en cuenta que tanto las colas de entrada y salida, como el resto de las funcionalidades como métodos directos o dispositivo gemelo están ligados al módulo y estarán disponibles en cualquier runtime de IoT Edge donde despleguemos el módulo y dependerá de si queremos usarlos o no.

Una vez visto el código, ¿por qué tantos Dockerfile?. La idea de que haya tantos es que estamos creando un módulo que queremos que se pueda desplegar en cualquier sitio que pueda correr IoT Edge, y esto son muchas arquitecturas de microprocesador diferente. Gracias a tener todos esos Dockerfile ya en la plantilla, nos abre la puerta a poder hacer que nuestro módulo de IoT Edge corra en Linux, Windows, en microprocesadores AMD, ARM,…

Vamos a poder configurar la arquitectura de destino simplemente ejecutando en la paleta de comandos «Azure IoT Edge: Set Default Target Platform for Edge Solution«. Esto nos va a dar varias opciones disponibles, que son las que están definidas en el fichero module.json, donde vamos a relacionar las arquitecturas con los Dockerfile concretos entre otras cosas como especificar la versión del módulo.

Actualmente, Docker soporta de forma experimental el crear manifiestos de imágenes para múltiples arquitecturas simultáneamente, siendo el runtime de Docker el que se descarga la imagen de su arquitectura especifica. Si tienes interés en el tema, hablo de ello en la entrada Cómo crear una imagen docker multiarquitectura desde cero.

Una vez que hemos terminado de configurar y programar nuestro módulo, vamos a poder generar la imagen o imágenes y subirlas al repositorio haciendo click derecho sobre el json del módulo o de la solución y pulsando sobre «Build and Push IoT Edge Module Image» o «Build and Push IoT Edge Solution» respectivamente.

En caso de que hayamos elegido la segunda opción, además de generar las imágenes y subirlas, va a generar el fichero de deployment en la carpeta config de la solución.

Configurando las rutas y desplegando la solución

Ya casi estamos listos para desplegar la solución con nuestro módulo en un dispositivo IoT Edge. Tenemos el módulo listo, pero para poder probar el sistema de módulos de manera sencilla, vamos a necesitar desplegar varios módulos juntos.

Por suerte la plantilla de la solución de IoT que hemos usado, ya nos ha creado los ficheros deployment con un módulo extra de simulación, que va a sacar por su cola de salida «temperatureOutput«. En la sección de rutas (routes) del json de despliegue deployment.template.json vamos a definir los diferentes enrutados internos y externos de los mensajes como parte de $edgeHub. En este caso la plantilla ya nos ofrece 2 rutas que son desde la salida del simulador a la entrada del módulo, y desde la salida del módulo a $upstream. Es importante aclarar $upstream significa que el mensaje será enviado directamente a IoT Hub como una métrica del dispositivo:

"routes": {
  "SampleModuleToIoTHub": "FROM /messages/modules/SampleModule/outputs/* INTO $upstream",
  "sensorToSampleModule": "FROM /messages/modules/SimulatedTemperatureSensor/outputs/temperatureOutput INTO BrokeredEndpoint(\"/modules/SampleModule/inputs/input1\")"
}

Cada vez que actualicemos la plantilla de despliegue, será necesario generar de nuevo el manifiesto de despliegue para la arquitectura concreta. Para esto vale con hacer click derecho sobre el json de la plantilla y seleccionar «Generate IoT Edge Deployment Manifest«.

Por último, ya solo queda desplegar la solución. Para esto es suficiente con hacer click derecho sobre el json del manifiesto en la carpeta de config y seleccionar «Create Deployment for Single Device«. Con esto vamos a poder elegir el dispositivo sobre el que queremos desplegar y «et voilà«. Ya hemos desplegado nuestro propio módulo IoT Edge y hemos configurado sus mensajes.

Conclusión

Soy consciente de que se han quedado muchas cosas en el tintero, no he entrado a como depurar el código, usar el simulador, configuraciones complejas,… La idea detrás de todo esto era dar una visión de alto nivel sobre cómo es posible crear y desplegar módulos IoT Edge sin sufrir mucho por el camino.

Siempre que he usado IoT Edge he intentado usar directamente imágenes stand-alone porque pensaba que el sistema de módulos era complejo y que no valía la pena entrar en él, pero evidentemente me equivocaba.

Una cosa importante que no he dicho pero que también es importante, es que IoT Edge no tiene coste adicional, sobre el coste de mensajes que tengas en IoT Hub y además funciona con el tier gratuito, por lo que es posible utilizar IoT Edge gratis siempre que no mandemos más de 8 mil mensajes al día, momento en el que se cortará la comunicación con IoT Hub hasta el día siguiente.

De momento, te dejo el enlace a la documentación oficial de IoT Edge, donde vas a poder encontrar no solo como desarrollar módulos, sino como hacer ajustes finos en muchas partes del sistema.

¿Y tú qué opinas? ¿Conocías los módulos para IoT Edge? Déjame en los comentarios si quieres que haga otra entrada más en detalle sobre cómo desarrollar módulos, depurarlos y hacer configuraciones más complejas.

Deja un comentario