Has oído hablar de la potencia y versatilidad que tienen las aplicaciones serverless. Te han contado las maravillas de las Azure Functions. Ahora necesitas conocer las mejores prácticas para llevar al límite tus desarrollos. En el siguiente post veremos precisamente eso, qué mejores prácticas puedes emplear para implementar tus servicios serverless en la nube.
¿En qué consisten las mejores prácticas?
Para nosotros, las mejores prácticas en un desarrollo serverless consisten en procedimientos bien definidos y estructurados, tanto del diseño y codificación de la función como de su configuración en la nube.
Las mejores prácticas las vamos a separar en 3 grupos de recomendaciones.
De propósito general.
De seguridad.
De manejo de errores.
Mejores prácticas de propósito general
Haz uso SIEMPRE del motor de inyección de dependencias.
Evita implementar funciones muy complejas y de larga duración. Cada función debe tender al principio de responsabilidad única. En el caso de una función muy grande debemos evaluar si se puede separar en varias.
Evita comunicar una función con otra de forma directa por Http, lo ideal es usar algún método de intercambio de mensajes o eventos como Storage Queue, Service Bus, Event Hub.
Reutiliza componentes de comunicación de forma Singleton para tus clientes Http en vez de crear uno en cada invocación a la función.
Programa siempre de forma asíncrona, evitas así bloqueos de llamadas en tu código que puede incrementar el riesgo de tener ejecuciones fallidas.
Separa la cuenta de almacenamiento de tus funciones de otras cuentas de almacenamiento, así garantizas un escalado correcto de la misma.
En Azure Durable Functions cualquier método que hagas de forma asíncrona usa por debajo una llamada a una actividad empleando el contexto de ejecución para no llevarte sorpresas.
En Azure Durable Functions, toda llamada a una actividad que necesite parámetros emplea tipos base o clases SERIALIZABLES.
Algo de código de estas mejores prácticas de propósito general
Para emplear el motor de inyección de dependencias, punto 1 anteriormente descrito, sólo es necesario descargar este paquete Nuget Microsoft.Azure.Functions.Extensions e implementar una clase Startup que herede de FunctionsStartup. Expone varios métodos que se pueden sobrescribir como Configure() y ConfigureAppConfiguration().
1using Microsoft.Azure.Functions.Extensions.DependencyInjection;2using Microsoft.Extensions.Configuration;3using Microsoft.Extensions.DependencyInjection;4using System.IO;56[assembly: FunctionsStartup(typeof(Azbp.Async.Functions.Startup))]7namespace Azbp.Async.Functions8{9 public class Startup : FunctionsStartup10 {11 public override void Configure(IFunctionsHostBuilder builder)12 {13 ConfigurationBuilder configurationBuilder = new ConfigurationBuilder();14 var configuration = configurationBuilder15 .SetBasePath(Directory.GetCurrentDirectory())16 .AddJsonFile("local.settings.json", optional: true, reloadOnChange:17 true)18 .AddEnvironmentVariables()19 .Build();2021 builder.Services.AddSingleton<IConfiguration>(configuration);22 builder.Services.AddHttpClient<ISwapiClient, SwapiClient>();23 builder.Services.AddSingleton<IQueueStorageRepository, QueueStorageRepository>();24 }25 }26}27
En este ejemplo vemos cómo existe un cliente Http que lo agregamos con el método de extensión AddHttpClient() que lo que hace es registrar dicho cliente en el motor de inyección de dependencias de forma Singleton, así el punto 4 anterior comprobamos que lo cumplimos.
Vale, en el punto 2 os lo dejamos a vuestro criterio... ya sabéis, funciones pequeñas, que ejecuten una funcionalidad muy concreta y lo más rápidamente posible. Pensad que se cobra por tiempo de ejecución y memoria consumida, así que, si no queréis sorpresas en la factura, prestad mucha atención a este punto.
El punto 3 es importante sobre todo por temas de desacoplamiento y de resiliencia. Los sistemas de intercambio de mensajes poseen modos de realizar reintentos.
En este ejemplo se usa un repositorio para enviar mensajes a una cola, luego se implementa una función con un disparador que se ejecuta cuando en dicha cola se recibe un mensaje. En caso de que en esta función disparada se produjera alguna excepción, el gestor de colas volvería a procesar el evento varias veces.
1private async Task CallAzureFunctionSendingMessage(ILogger log)2{3 log.LogInformation($"Sending message to queue.");4 await queueStorageRepository.CreateMessageAsync("This is a test queue message");5}67[FunctionName(nameof(CallAzureFunctionQueue))]8public async Task CallAzureFunctionQueue([QueueTrigger("demo",9 Connection = "ConnectionStrings:QueueDemo")] string myQueueItem,10 ILogger log)11{12 log.LogInformation($"C# Queue trigger function processed: {myQueueItem}");13 throw new NotImplementedException();14}15
Pues sí, como se puede observar en el código siguiente, hay que emplear llamadas asíncronas para evitar cualquier tipo de bloqueo.
1[FunctionName(nameof(CallAzureFunction))]2public async Task<IActionResult> CallAzureFunction(3[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = null)]4HttpRequest req,5ILogger log)6{7 var parsed = Enum.TryParse(req.Query["commandType"], true, out CommandType commandType);8 if (parsed)9 {10 switch (commandType)11 {12 case CommandType.CallAzureFunctionDirectly:13 log.LogInformation($"Calling function directly.");14 await CallAzureFunctionDirectly(log);15 log.LogInformation($"Function called.");16 break;17 case CommandType.CallAzureFunctionSendingMessage:18 log.LogInformation($"Calling function using queue.");19 await CallAzureFunctionSendingMessage(log);20 log.LogInformation($"Function called.");21 break;22 }23 return new OkObjectResult("success");24 }25 return new BadRequestObjectResult("Invalid value in 'commandType' QueryString");26}27
Para las Azure Durable Functions, los puntos 7 y 8 son muy importantes ya que el motor de ejecución graba el estado de las actividades en un Table Storage asociado y para ello necesita realizar una serialización. Una vez que las orquestaciones procesan los mensajes correctamente, los registros de sus acciones resultantes se conservan en la tabla del Historial. Las entradas y salidas de orquestación, y los datos de estado personalizados también se conservan en la tabla de Instancias denominada Instances.
Se muestra una lista de los diferentes tipos de datos que se serializarán y persistirán al usar las características de Durable Functions:
Todas las entradas y salidas de las funciones de orquestador, actividad y entidad, incluidos los identificadores y las excepciones no controladas.
Nombres de las funciones de orquestador, actividad y entidad.
Nombres y cargas de eventos externos.
Cargas de estado de la orquestación personalizada.
Mensajes de finalización de orquestación.
Cargas de temporizador duraderas.
Direcciones URL, encabezados y cargas de solicitud y respuesta HTTP duraderas.
Cargas de llamadas y señales de entidades.
Cargas de estado de entidades.
Aquí vemos ejemplos de llamadas asíncronas empleando el contexto de ejecución de las Durable Function y sin usar el contexto.
1[FunctionName(nameof(RunOrchestrator))]2public async Task RunOrchestrator(3[OrchestrationTrigger] IDurableOrchestrationContext context,4ILogger log)5{6 int maxNumberOfAttempts = 3;7 try8 {9 var commandType = Enum.Parse(typeof(CommandType),10 context.GetInput<string>());1112 log.LogInformation($"Received command '{commandType}'.");1314 switch (commandType)15 {16 case CommandType.CallAsyncWithActivity:17 log.LogInformation($"Calling async method using activity with retry options.");18 await CallAsyncWithActivity(context, maxNumberOfAttempts);19 log.LogInformation($"Async method using activity called.");20 break;21 case CommandType.CallAsyncWithoutActivity:22 log.LogInformation($"Calling async method in orchestrator directly.");23 await CallAsyncWithoutActivity();24 log.LogInformation($"Async method called.");25 break;26 case CommandType.PassingNonSerializableModel:27 log.LogInformation($"Passing Non Serializable parameter to Activity.");28 await PassingNonSerializableModel(context, maxNumberOfAttempts);29 log.LogInformation($"Async method called.");30 break;31 }32 }33 catch (Exception ex)34 {35 log.LogError($"Durable Function retried {maxNumberOfAttempts} attempts.");36 log.LogError($"Exception message: {ex.Message}.");37 }38}3940[FunctionName(nameof(ActivityWithRetryAsync))]41public async Task ActivityWithRetryAsync([ActivityTrigger] string42item)43{44 // call external Api45 await swapiClient.CallApiAsync();46}4748[FunctionName(nameof(ActivityWithRetryAsyncNonSerializable))]49public async Task50ActivityWithRetryAsyncNonSerializable([ActivityTrigger] object item)51{52 // call external Api53 await swapiClient.CallApiAsync();54}5556private async Task57PassingNonSerializableModel(IDurableOrchestrationContext context, int58maxNumberOfAttempts)59{60 var retryOptions = new RetryOptions(TimeSpan.FromSeconds(5), maxNumberOfAttempts);61 await context.CallActivityWithRetryAsync(nameof(ActivityWithRetryAsyncNonSerializable), retryOptions, new MemoryStream(100));62}6364private async Task CallAsyncWithActivity(IDurableOrchestrationContext65context, int maxNumberOfAttempts)66{67 var retryOptions = new RetryOptions(TimeSpan.FromSeconds(5), maxNumberOfAttempts);68 await context.CallActivityWithRetryAsync(nameof(ActivityWithRetryAsync), retryOptions, context.GetInput<string>());69}7071private async Task CallAsyncWithoutActivity()72{73 // call external Api74 await swapiClient.CallApiAsync();75}76
Mejores prácticas de seguridad
Securiza los endpoints usando siempre HTTPS.
Toda clave de configuración que sea sensible debe de estar en un Azure Key Vault.
Evita la configuración en archivo json, emplea variables de entorno o Azure App Configuration.
Haz uso de las Managed Identities para securizar el acceso a recursos.
Securiza tus funciones con Azure Active Directory y claves de función.
Te recomendamos implementar un Azure Api Management para exponer tus funciones.
Valida siempre tus inputs.
Restringe el acceso con CORS.
Deshabilita el acceso por FTP a tu Function App.
Si es posible, emplea Private Links, Azure Application Gateway o Azure FrontDoor.
Todas estas recomendaciones se pueden realizar por ejemplo desde el portal de Azure o por comandos.
Mejores prácticas de manejo de errores
Toda función debe ser implementada de forma idempotente, misma entrada produce la misma salida, y sin guardar estados (stateless). En una arquitectura serverless es necesario poder aceptar solicitudes idénticas y de mantener la integridad de los datos y la estabilidad del sistema, por lo que la idempotencia se logra al garantizar que una acción determinada es posible y sólo se ejecuta una vez.
Valida siempre tus inputs.
Usa manejadores de excepciones en cada función.
Implementa siempre políticas de reintentos tanto en tus Azure Functions, como en las Azure Durable Functions al llamar a una actividad.
Siempre debes programar de forma defensiva (no te fíes de nadie XD).
Monitoriza, monitoriza, ¿te hemos dicho que monitorices?
Algo de código de estas mejores prácticas de manejo de errores
Validar tus "inputs", es decir, hay que validar siempre el cuerpo de la petición recibida para no llevarnos sorpresas. Para ello vamos a suponer que se recibe una petición con este cuerpo.
1public class Item2{3 [JsonProperty("id")]4 [Required]5 public string Id { get; set; }67 [JsonProperty("name")]8 [Required]9 [StringLength(10)]10 public string Name { get; set; }11}12
Como se puede comprobar, se definen dos propiedades obligatorias y una de ellas el contenido debe de ser de 10 caracteres. Para realizar la validación de dicho contenido podemos implementarla de la siguiente forma (empleando la clase System.ComponentModel.DataAnnotations.Validator)
1[FunctionName(nameof(ValidateRequestBody))]2public static async Task<IActionResult> ValidateRequestBody(3[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = null)]4HttpRequest req,5ILogger log)6{7 log.LogInformation("C# HTTP trigger function processed a request.");8 string requestBody = await new StreamReader(req.Body).ReadToEndAsync();9 var data = JsonConvert.DeserializeObject<Item>(requestBody);10 var validationResults = new List<ValidationResult>();11 var isValid = Validator.TryValidateObject(data, new12 ValidationContext(data, null, null), validationResults, true);13 string responseMessage = string.Empty;14 if (isValid)15 {16 responseMessage = "Model is valid";17 log.LogInformation(responseMessage);18 return new OkObjectResult(responseMessage);19 }20 else21 {22 responseMessage = $"Model is invalid: {string.Join(", ",23 validationResults.Select(s => s.ErrorMessage).ToArray())}";24 log.LogInformation(responseMessage);25 return new BadRequestObjectResult(responseMessage);26 }27}28
Como podéis ver, deserializamos la petición a nuestra clase con anotaciones y luego ejecutar la validación correspondiente.
El punto 3 indicamos que todo código de una función debe de estar SIEMPRE dentro de un bloque try/catch como podemos ver en el siguiente ejemplo.
1[FunctionName(nameof(Simplest))]2public static async Task<IActionResult> Simplest(3[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = null)]4HttpRequest req,5ILogger log)6{7 log.LogInformation("C# HTTP trigger function processed a request.");8 try9 {10 var parsed = Enum.TryParse(req.Query["exceptionType"], true, out ExceptionType exceptionType);11 if (parsed)12 {13 Eval(exceptionType);14 }15 throw new InvalidCastException();16 }17 catch (Exception ex)18 {19 string responseMessage = string.Empty;20 switch (ex)21 {22 case ArgumentNullException t:23 responseMessage = nameof(ArgumentNullException);24 break;25 case ArgumentException t:26 responseMessage = nameof(ArgumentException);27 break;28 case InvalidOperationException t:29 responseMessage = nameof(InvalidOperationException);30 break;31 case OutOfMemoryException t:32 responseMessage = nameof(OutOfMemoryException);33 break;34 case InvalidCastException t:35 responseMessage = nameof(InvalidCastException);36 break;37 }3839 log.LogError(responseMessage);40 return new OkObjectResult(responseMessage);41 }42}43
Para la parte de reintentos del punto 4 podemos decorar nuestras funciones con los siguientes atributos:
FixedDelayRetry: Cada reintento se ejecuta en un determinado espacio de tiempo fijo, en el ejemplo siguiente cada 5 segundos.
ExponentialBackoffRetry: Cada reintento se ejecuta dentro de un rango de tiempo establecido, en el ejemplo siguiente cada reintento se ejecuta entre 5 segundos y 5 minutos.
Los reintentos requieren el paquete NuGet Microsoft.Azure.WebJobs >= 3.0.23.
1[FunctionName(nameof(FixedDelayRetry))]2[FixedDelayRetry(3, "00:00:05")]3public static async Task<IActionResult> FixedDelayRetry(4[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = null)]5HttpRequest req,6ILogger log)7{8 log.LogInformation("C# HTTP trigger function processed a request.");9 log.LogInformation("FixedDelayRetry throwing exception");10 var exceptionMessage = "FixedDelayRetry thrown exception";11 throw new ApplicationException(exceptionMessage);12}1314[FunctionName(nameof(ExponentialBackoffRetry))]15[ExponentialBackoffRetry(3, "00:00:05", "00:05:00")]16public static async Task<IActionResult> ExponentialBackoffRetry(17[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = null)]18HttpRequest req,19ILogger log)20{21 log.LogInformation("C# HTTP trigger function processed a request.");22 log.LogInformation("ExponentialBackoffRetry throwing exception");23 var exceptionMessage = "ExponentialBackoffRetry thrown exception";24 throw new ApplicationException(exceptionMessage);25}26
Veamos ahora cómo definir los reintentos en una Azure Durable Function al llamar a una Actividad, empleando RetryOptions y el método CallActivityWithRetryAsync(). En el ejemplo se define un máximo de 3 reintentos al ejecutar una actividad y cada reintento se ejecuta cada 5 segundos.
1[FunctionName(nameof(RunOrchestrator))]2public static async Task RunOrchestrator(3[OrchestrationTrigger] IDurableOrchestrationContext context,4ILogger log)5{6 int maxNumberOfAttempts = 3;7 try8 {9 var item = context.GetInput<Item>();10 log.LogInformation($"Received new item in orchestration '{JsonConvert.SerializeObject(item)}'.");11 log.LogInformation($"Launch activity with retry options.");12 var retryOptions = new RetryOptions(TimeSpan.FromSeconds(5), maxNumberOfAttempts);13 await context.CallActivityWithRetryAsync(nameof(ActivityWithRetryAsync), retryOptions, item);14 }15 catch (Exception)16 {17 log.LogError($"Durable Function retried {maxNumberOfAttempts} attempts.");18 }19}20
NOTA: Algunos recursos de Azure como Azure Blob Storage, Azure Queue Storage, Azure Service Bus, Azure Cosmos DB, al emplear su SDK, se permite definir reintentos, a parte de los definidos a nuestras funciones.
¿Dónde puedo encontrar el código de ejemplo?
Podéis descargaros los proyectos de ejemplo en https://github.com/sparraguerra/compartimoss/tree/master/AzureFunctionsBestPractices
Conclusiones
Como conclusión diremos que deberías seguir este conjunto de buenas prácticas para que tus desarrollos serverless alcancen todo su potencial.
Happy coding!
Sergio Parra Guerra
Software & Cloud Architect at Encamina
https://twitter.com/sparraguerra
David Vidal Castillo
Team Leader at Encamina
Microsoft MVP Developer Technologies
https://twitter.com/D_Vid_45