Microsoft Azure Batch: procesando por lotes en la nube

En el día a día de nuestro trabajo siempre nos encontramos, de una forma u otra, con esos procesos lotes que requieren un tiempo mayor a otras tareas del negocio: facturación, renderización de imágenes, generación de informes (nóminas, cálculos de notas, cartas) y un largo etcétera. Son ese tipo de tareas nocturas, que llevan tiempo en procesar o que necesitan un alto rendimiento de computación. Para este tipo de escenarios es para lo que está pensado Azure Batch, que permite de manera programática definir un conjunto de recursos en Azure encargados de ejecutar trabajos bajo demanda o programados, sin la necesidad de configurar un clúster HPC, máquinas virtuales, etcétera.

Antes de ver un ejemplo hay algunos conceptos básicos que deberías de conocer dentro del servicio:

  • Pool: se trata del contenedor que gestionará los nodos y recibirás los jobs a gestionar.
  • Nodos: son las máquinas virtuales que procesarán las tareas.
  • Job: Se trata del trabajo al que irán asociadas las tareas a realizar.
  • Tareas: pueden ser desde una linea de comando con un echo hasta lanzar un ejecutable que realice el proceso más complejo. La tarea no tiene por qué estar desarrollada en .NET. De hecho, en este ejemplo te mostraré cómo lanzar un jar como tarea.

Para probar el servicio, lo primero que necesitas es crear una cuenta de Azure Batch. Para ello, accede a https://portal.azure.com > New > Compute > Batch Service.

Create new Azure Batch Service
Create new Azure Batch Service

Para este ejemplo, he creado un proyecto con la librería en .NET, que proporciona el servicio, con el que serás capaz de generar un job que procese diferentes tareas:

  1. Llamada echo.
  2. Recuperar los paquetes instalados a través de Chocolatey en el nodo.
  3. Conocer el valor de la variable de entorno java_home.
  4. Lanzar un ejecutable en Java que realice una serie de acciones.

Como ves, la mayoría de ellas son demasiado simples, pero es importante que conozcas cómo configurar este surtido de tareas para que los nodos sean capaces de procesarlas.

Lo primero que voy a hacer es crear un método asíncrono, CreateTasksAsync, que sirva de punto de entrada a toda la configuración del servicio:

        static void Main(string[] args)
        {
            try
            {
                CreateTasksAsync().Wait();
            }
            catch (AggregateException aggregateException)
            {
                foreach (var exception in aggregateException.InnerExceptions)
                {
                    Console.WriteLine(exception.ToString());
                    Console.WriteLine();
                }
            }
            Console.WriteLine("Press return to exit...");
            Console.ReadLine();
        }

Como lo ideal es que el proceso sea asíncrono, se ha dividido en el código las tareas de configuración, creación y espera. CreateTaskAsync se encarga de la configuración:

        private static async Task CreateTasksAsync()
        {
            //1. Connect with you Azure Batch account
            BatchSharedKeyCredentials credentials = new BatchSharedKeyCredentials(
                                                        Settings.Default.BatchURL,
                                                        Settings.Default.AccountName,
                                                        Settings.Default.AccountKey);

            using (var batchClient = await BatchClient.OpenAsync(credentials))
            {
                //2. Add a retry policy
                batchClient.CustomBehaviors.Add(RetryPolicyProvider.LinearRetryProvider(TimeSpan.FromSeconds(10), 3));
                //3. Create a job id
                var jobId = string.Format("MyTasks-{0}", DateTime.Now.ToString("yyyyMMdd-HHmmss"));
                try
                {
                    //4. Submit the job
                    await SubmitJobAsync(batchClient, jobId);
                    //5. Wait for the job to complete
                    await WaitForJobAndPrintOutputAsync(batchClient, jobId);
                }
                finally
                {
                    Console.WriteLine("Press enter to delete the job");
                    Console.ReadLine();
                    if (!string.IsNullOrEmpty(jobId))
                    {
                        Console.WriteLine("Deleting job: {0}", jobId);
                        batchClient.JobOperations.DeleteJob(jobId);
                    }
                }
            }
        }
  1. Lo primero es recuperar los valores de la cuenta de Azure Batch que acabas de crear. En este ejemplo están almacenados en un archivo Settings y puedes localizarlos en el portal:

    Batch info - Azure portal
    Batch info – Azure portal
  2. Se añade una política de reintento cada 10 segundos con tres reintentos.
  3. Se genera un id para el job que se va a configurar.
  4. Se pasa el cliente y el id al siguiente paso, que es la creación, a través del método SubmitJobAsync.
  5. Comienza la espera de la finalización del proceso, haciendo uso de un último método llamado WaitForJobAndPrintOutputAsync.

Respecto a los dos últimos pasos, vamos a ver en qué consiste el SubmitJobAsync:

        private static async Task SubmitJobAsync(BatchClient batchClient, string jobId)
        {
            Console.WriteLine("Creating the job {0}", jobId);
            //1. Create a job
            CloudJob job = batchClient.JobOperations.CreateJob();
            job.Id = jobId;
            //2. Define a start tasks for the nodes
            //Start task: Installing Chocolatey - A Machine Package Manager
            var startTask = new StartTask()
            {
                ResourceFiles = new List<ResourceFile>() {
                    new ResourceFile("https://returngisbatch.blob.core.windows.net/resources/install-choco.bat", "install-choco.bat")
                },
                CommandLine = "install-choco.bat",
                WaitForSuccess = true, //Specifies if other tasks can be scheduled on a VM which has not run the start task
                RunElevated = true
            };
            //3. For this job, ask the Azure Batch service to automatically create a pool of VMs when the job is submitted
            job.PoolInformation = new PoolInformation
            {
                AutoPoolSpecification = new AutoPoolSpecification
                {
                    AutoPoolIdPrefix = "returngis",
                    PoolSpecification = new PoolSpecification
                    {
                        TargetDedicated = 3,
                        OSFamily = "4",
                        VirtualMachineSize = "small",
                        StartTask = startTask
                    },
                    KeepAlive = false,
                    PoolLifetimeOption = PoolLifetimeOption.Job
                }
            };
            //4. Commit job to create it in the service
            await job.CommitAsync();
            //5. Define my tasks
            var commandLine = "cmd /c echo Hello world from the Batch Hello world sample!";
            Console.WriteLine("Task #1 command line: {0}", commandLine);
            await batchClient.JobOperations.AddTaskAsync(jobId, new CloudTask("taskHelloWorld", commandLine));
            var commandChoco = "cmd /c choco list --local-only";
            Console.WriteLine("Task # 2 command line: {0}", commandChoco);
            await batchClient.JobOperations.AddTaskAsync(jobId, new CloudTask("taskchoco", commandChoco));
            var echoJavaHome = "cmd /c echo %java_home%";
            Console.WriteLine("Task # 3 command line: {0}", echoJavaHome);
            await batchClient.JobOperations.AddTaskAsync(jobId, new CloudTask("taskecho", echoJavaHome));
            var jarTask = new CloudTask("jartask", "cmd /c java -jar AzureStorage-0.0.1.jar");
            Console.WriteLine("Task # 4 command line: {0}", "java -jar AzureStorage-0.0.1.jar");
            jarTask.ResourceFiles = new List<ResourceFile>();
            jarTask.ResourceFiles.Add(new ResourceFile("https://returngisbatch.blob.core.windows.net/resources/AzureStorage-0.0.1.jar", "AzureStorage-0.0.1.jar"));
            await batchClient.JobOperations.AddTaskAsync(jobId, jarTask);
        }

Este método es el que tiene toda la carga de la creación tanto del pool como de cada una de las tareas que se van a crear:

  1. Crea el job con el id que has elegido durante la parte de configuración.
  2. Se ha definido una start task para los nodos ¿esto que significa? Cuando hice un repaso de las tareas que ibamos a lanzar, pudiste ver que dos de ellas están haciendo uso de Chocolatey y Java, lo cual no está disponible en los nodos que ejecutarán las tareas por defectoAzure Batch te permite definir tareas de arranque para preparar los nodos antes de la ejecución de las tareas, con el fin de configurar e instalar el software necesario para poder ejecutarlas. Para este escenario, he creado un .bat con la información necesaria para instalar Chocolatey y también el JDK de Java.
    @powershell -NoProfile -ExecutionPolicy Bypass -Command "iex((new-object net.webclient).DownloadString('https://chocolatey.org/install.ps1'))" && SET PATH=%PATH%;%ALLUSERSPROFILE%\chocolatey\bin"
    choco install jdk8 --force --yes --acceptlicense --verbose
    exit /b 0

    Tanto este archivo, install-choco.bat, como el jar que utilizaré para una de las tareas, están alojados en una cuenta de Azure, con el modo  de acceso Blob en el container.

    Azure Batch - Resource Files - Microsoft Azure Storage Explorer - Public Read Access Blob
    Azure Batch – Resource Files – Microsoft Azure Storage Explorer – Public Read Access Blob
  3. Existen diferentes formas de crear un pool. En este caso estamos haciéndolo de tal manera que cuando el job se envie al servicio se generará el mismo y cuando este haya completado todas las tareas el pool se eliminará. Se puede ver que se definen el número de máquinas que inicialmente se crearán (TargetDedicated = 3)la familia del SO a 4 (1 = Windows Server 2008, 2 = Windows Server 2008 R2, 3 = Windows Server 2012 y 4 = Windows Server 2012 R2), el tamaño de las máquinas y se asocia la start task que definiste anteriormente.
  4. Se hace un commit del job en el servicio con la configuración que acabas de establecer.
  5. Añades cada una de las tareas que te prometí en el primer punto. La última, que lanza un ejecutable de Java, es la que difiere del resto, ya que es necesario utilizar un ResourceFile como parte de la tarea e invocarlo dentro del apartado command line.

Por último queda la fase de espera, donde pasamos al último método del ejemplo, WaitForJobAndPrintOutputAsync:

        private static async Task WaitForJobAndPrintOutputAsync(BatchClient batchClient, string jobId)
        {
            Console.WriteLine("Waiting for all tasks to complete on job: {0} ...", jobId);
            //1. Use a task state monitor to monitor the status of your tasks
            var taskStateMonitor = batchClient.Utilities.CreateTaskStateMonitor();
            List<CloudTask> myTasks = await batchClient.JobOperations.ListTasks(jobId).ToListAsync();
            //2. Wait for all tasks to reach the completed state.
            bool timedOut = await taskStateMonitor.WhenAllAsync(myTasks, TaskState.Completed, TimeSpan.FromMinutes(15));
            if (timedOut)
            {
                throw new TimeoutException("Timed out waiting for tasks.");
            }
            //3. Dump task output
            foreach (var task in myTasks)
            {
                Console.WriteLine("Task {0}", task.Id);
                //4. Read the standard out of the task
                NodeFile standardOutFile = await task.GetNodeFileAsync(Constants.StandardOutFileName);
                var standardOutText = await standardOutFile.ReadAsStringAsync();                
                Console.WriteLine("Standard out: ");
                Console.WriteLine(standardOutText);
                Console.WriteLine();
            }
        }
  1. Se crea un objeto del tipo TaskStateMonitor que te ayudará a esperar todas las tareas del listado que le pases como parámetro.
  2. Este es el punto de la aplicación donde se requerirá la mayor paciencia, ya que el monitor estará esperando a que todas las tareas estén en el estado Completed dentro de 15 minutos. Dependiendo del tipo de tarea habría que aumentar el tiempo de timeout de la espera.
  3. Si todo ha ido bien, por último se mostrará por pantalla el output, del archivo stdout.txt de la tarea, de cada una de las tareas procesadas.  Existe otro archivo por tarea, stderr.txt, que almacena los errores que han ocurrido durante la ejecución de la misma.

Mientras que estás esperando, lo único que puedes a través de la consola es lo siguiente:

Azure Batch - Waiting for the tasks - Console
Azure Batch – Waiting for the tasks – Console

O esto a través del portal:

Azure Batch - Info on Azure portal
Azure Batch – Info on Azure portal

Hay una herramienta que te recomiendo: Azure Batch Explorer. Con ella serás capaz de ver qué es lo que ocurre en el servicio, en qué estado está el pool, los nodos, tareas, etcétera.

Batch Explorer - Jobs - Tasks
Batch Explorer – Jobs – Tasks

Además tiene una opción muy interesante que es el Heatmap, que te permite ver el estado de los nodos: si están a la espera, ejecutando algo, iniciandose o si ocurrió algún error.

Azure Batch - Heatmap
Azure Batch – Heatmap

También te permite gestionar de forma sencilla la creación de un usuario y el RDP para el acceso remoto a cualquiera de los nodos.

Batch Explorer - Add User and Connect
Batch Explorer – Add User and Connect

Una vez que ha finalizado el proceso podrás ver el resultado en la consola y finalizar el job.

Azure Batch - All tasks done
Azure Batch – All tasks done

En este enlace puedes ver las cuotas y límites del servicio. Por ahora sólo está disponible el procesamiento de tareas sobre máquinas Windows, aunque ya se han visto videos donde se está trabajando el uso de Linux.

Aquí tienes el proyecto del ejemplo en mi cuenta de GitHub, por si fuera de utilidad.

¡Saludos!