Implementando Aplicaciones Resilientes
Sus aplicaciones basadas en microservicios y en la nube deben manejar las fallas parciales que con certeza ocurrirán eventualmente. Debe diseñar su aplicación para que sea resiliente a esas fallas parciales.
La resiliencia es la capacidad de recuperarse de las fallas y continuar funcionando. No se trata de evitar fallas, sino de aceptar el hecho de que las fallas ocurrirán y responder a ellas de una manera que evite la pérdida del servicio o la pérdida de datos. El objetivo de la resiliencia es devolver la aplicación a un estado completamente funcional después de una falla.
Es todo un desafío diseñar y desplegar una aplicación basada en microservicios. Pero también necesita mantener su aplicación ejecutándose en un entorno donde es seguro que habrá algún tipo de falla. Por lo tanto, su aplicación debe ser resiliente. Debe estar diseñada para hacer frente a fallas parciales, como interrupciones de la red, o nodos o máquinas virtuales que se cuelgan en la nube. Incluso los microservicios (contenedores) que se mueven a un nodo diferente dentro de un cluster pueden causar fallas cortas e intermitentes dentro de la aplicación.
Los muchos componentes individuales de su aplicación también deben incorporar características de monitorización de la salud. Al seguir las pautas de este capítulo, puede crear una aplicación que funcione sin problemas a pesar del tiempo transitorio sin servicio, o las interrupciones normales que ocurren en las implementaciones complejas y basadas en la nube.
En sistemas distribuidos como aplicaciones basadas en microservicios, existe un riesgo constante de falla parcial. Por ejemplo, un microservicio/contenedor puede fallar o no estar disponible por un corto tiempo o una VM o servidor puede fallar. Como los clientes y los servicios son procesos separados, es posible que un servicio no pueda responder de manera oportuna a la solicitud de un cliente. Es posible que el servicio esté sobrecargado y responda con extrema lentitud a las solicitudes, o que simplemente no sea accesible durante un breve período de tiempo debido a problemas de red.
Por ejemplo, considere la página de detalles del pedido de la aplicación de ejemplo eShopOnContainers. Si el microservicio de pedido no responde cuando el usuario intenta enviar un pedido, una mala implementación del proceso del cliente (la aplicación web MVC), por ejemplo, si el código del cliente utilizara RPC síncronas sin tiempo de espera, bloquearía los hilos indefinidamente esperando por una respuesta. Además de crear una mala experiencia de usuario, cada espera que no responde consume o bloquea un hilo, y los hilos son extremadamente valiosos en aplicaciones altamente escalables. Si hay muchos subprocesos bloqueados, con el tiempo el motor de ejecución de la aplicación puede quedarse sin subprocesos. En ese caso, la aplicación puede dejar de responder globalmente en lugar de simplemente no responder parcialmente, como se muestra en la Figura 8-1.
Figura 8-1. Fallas parciales por dependencias que afectan la disponibilidad de los hilos del servicio
En una aplicación grande basada en microservicios, cualquier falla parcial puede amplificarse, especialmente si la mayor parte de la interacción interna de los microservicios se basa en llamadas HTTP síncronas (lo que se considera un anti patrón). Piense en un sistema que recibe millones de llamadas entrantes por día. Si su sistema tiene un diseño incorrecto que se basa en cadenas largas de llamadas HTTP síncronas, estas llamadas entrantes podrían resultar en muchos millones más de llamadas salientes (supongamos una proporción de 1: 4) a docenas de microservicios internos como dependencias síncronas. Esta situación se muestra en la Figura 8-2, especialmente en la dependencia #3.
Figura 8-2. El impacto de un diseño incorrecto, con cadenas largas de peticiones HTTP
La falla intermitente está prácticamente garantizada en un sistema distribuido y basado en la nube, incluso si cada dependencia en sí tiene una excelente disponibilidad. Esto es un hecho que debe tener en cuenta.
Si no diseña e implementa técnicas para garantizar la tolerancia a fallas, incluso los pequeños tiempos de inactividad pueden amplificarse. Como ejemplo, 50 dependencias cada una con 99.99% de disponibilidad resultaría en varias horas de inactividad cada mes debido a este efecto de onda expansiva. Cuando se produce un error en la dependencia de un microservicio al manejar un gran volumen de solicitudes, esa falla puede saturar rápidamente todos los hilos de solicitud disponibles en cada servicio y bloquear toda la aplicación.
Figura 8-3. Falla parcial amplificada por microservicios con cadenas largas de llamadas HTTP síncronas
Para minimizar este problema, en la sección "La integración asíncrona refuerza la autonomía de los microservicios" (en el capítulo de arquitectura), le alentamos a usar comunicación asíncrona entre los microservicios internos. Explicaremos más sobre esto, brevemente, en la siguiente sección.
Además, es esencial que diseñe sus microservicios y aplicaciones cliente para manejar fallas parciales, es decir, para construir microservicios y aplicaciones cliente resilientes.
Las estrategias para lidiar con fallas parciales incluyen lo siguiente.
Use comunicación asíncrona (por ejemplo, comunicación basada en mensajes) a través de microservicios internos. Es muy recomendable no encadenar llamadas HTTP síncronas en los microservicios internos, porque ese diseño incorrecto eventualmente se convertirá en la causa principal de fallas de servicio. Por el contrario, a excepción de las comunicaciones de front-end entre las aplicaciones cliente y el primer nivel de microservicios o API Gateways de grano fino, se recomienda usar sólo comunicación asíncrona (basada en mensajes) una vez pasado el ciclo de petición/respuesta inicial, entre los microservicios internos. La consistencia eventual y las arquitecturas basadas en eventos ayudarán a minimizar problemas por “efectos dominó”. Estos enfoques refuerzan un nivel más alto de autonomía de los microservicios y, por lo tanto, evitan el problema que se menciona aquí.
Use reintentos con retroceso exponencial. Esta técnica ayuda a evitar fallas cortas e intermitentes al realizar reintentos de llamadas un cierto número de veces, en caso de que el servicio no esté disponible por período de tiempo corto. Esto puede ocurrir debido a problemas de red intermitentes o cuando un microservicio/contenedor se mueve a un nodo diferente en un cluster. Sin embargo, si estos reintentos no se diseñan correctamente con interruptores automáticos, pueden agravar los efectos dominantes, lo que en última instancia puede causar una denegación de servicio (DoS).
Busque paliativos a las fallas de la red. En general, los clientes deben estar diseñados para no bloquearse indefinidamente y poner siempre límites de tiempo mientras espera una respuesta. El uso de tiempos de espera garantiza que los recursos nunca estén inmovilizados indefinidamente.
Use el patrón de Interruptor Automático. En este enfoque, el proceso cliente rastrea la cantidad de solicitudes fallidas. Si la tasa de error excede un límite configurado, un "interruptor automático de circuito" se dispara para que los intentos posteriores fallen inmediatamente. (Si una gran cantidad de solicitudes están fallando, eso sugiere que el servicio no está disponible y que no tiene sentido hacer más peticiones). Después de un tiempo de espera, el cliente debe volver a intentarlo y, si las nuevas solicitudes son exitosas, desactivar el interruptor automático.
Proporcionar planes alternativos (fallbacks). En este enfoque, el proceso del cliente realiza una lógica alternativa cuando falla una solicitud, como devolver datos en caché o un valor predeterminado. Este es un enfoque adecuado para consultas, pero es más complejo para actualizaciones o comandos.
Limite el número de solicitudes en cola. Los clientes también deben imponer un límite superior a la cantidad de peticiones pendientes, que puede enviar a un servicio en particular. Si se ha alcanzado el límite, probablemente no tenga sentido realizar peticiones adicionales y esos intentos deberían fallar inmediatamente. En términos de implementación, la política de Bulkhead Isolation (asilamiento por mamparos, las divisiones internas de los barcos para contener los daños si se rompe el casco) Polly se puede utilizar para cumplir este requisito. Este enfoque es esencialmente un regulador de paralelización con la clase SemaphoreSlim como implementación. También permite una "cola" fuera del mamparo (de protección). Puede eliminar de forma proactiva el exceso de carga incluso antes de la ejecución (por ejemplo, porque la capacidad se considere completa). Esto hace que su respuesta a ciertos escenarios de falla sea más rápida de lo que sería un interruptor automático, ya que éste espera a que ocurran las fallas. El objeto BulkheadPolicy en Polly expone cuán llenos están el mamparo y la cola y ofrece eventos en desbordamiento, por lo que también se puede usar para impulsar el escalado horizontal automático.
Los reintentos con retroceso exponencial son una técnica que trata de reintentar una operación, con un tiempo de espera que aumenta exponencialmente, hasta que se alcanza un conteo máximo de reintentos (el retroceso exponencial). Esta técnica abarca el hecho de que los recursos de la nube pueden no estar disponibles intermitentemente por más de unos pocos segundos por cualquier motivo. Por ejemplo, un orquestador podría mover un contenedor a otro nodo en un cluster para balancear la carga. Durante ese tiempo, algunas solicitudes pueden fallar. Otro ejemplo podría ser una base de datos como SQL Azure, donde una base de datos se puede mover a otro servidor para balancear la carga, lo que hace que la base de datos no esté disponible durante unos segundos.
Hay muchos enfoques para implementar la lógica de reintentos con retroceso exponencial.
Para Azure SQL DB, Entity Framework Core ya ofrece resiliencia interna de conexión de base de datos y lógica de reintento. Pero debe habilitar la estrategia de ejecución de Entity Framework para cada conexión DbContext si desea tener conexiones resilientes en EF Core.
Por ejemplo, el siguiente código en el nivel de conexión de EF Core permite conexiones SQL resilientes que se reintentan si la conexión falla.
// Startup.cs from any ASP.NET Core Web API
public class Startup
{
// Other code ...
public IServiceProvider ConfigureServices(IServiceCollection services)
{
// ...
services.AddDbContext<OrderingContext>(options =>
{
options.UseSqlServer(Configuration["ConnectionString"],
sqlServerOptionsAction: sqlOptions =>
{
sqlOptions.EnableRetryOnFailure(
maxRetryCount: 5,
maxRetryDelay: TimeSpan.FromSeconds(30),
errorNumbersToAdd: null);
});
});
}
//...
Cuando los reintentos están habilitados en las conexiones de EF Core, cada operación que realice utilizando EF Core se convierte en su propia operación “reintentable”. Cada consulta y cada llamada a SaveChanges, se reintentarán como una unidad si ocurre una falla transitoria.
Sin embargo, si su código inicia una transacción con BeginTransaction, está definiendo su propio grupo de operaciones que deben tratarse como una unidad: todo lo que se encuentra dentro de la transacción se revierte si ocurre una falla. Verá una excepción como la siguiente si intenta ejecutar esa transacción cuando usa una estrategia de ejecución (política de reintento) e incluye varias llamadas a SaveChanges desde múltiples DbContexts en la transacción.
System.InvalidOperationException: The configured execution strategy 'SqlServerRetryingExecutionStrategy' does not support user initiated transactions. Use the execution strategy returned by 'DbContext.Database.CreateExecutionStrategy()' to execute all the operations in the transaction as a retriable unit.
La solución es invocar manualmente la estrategia de ejecución de EF con un delegado que represente todo lo que se debe ejecutar. Si se produce una falla transitoria, la estrategia de ejecución invocará al delegado nuevamente. Por ejemplo, el siguiente código muestra cómo se implementa en eShopOnContainers con dos DbContexts múltiples (_catalogContext y IntegrationEventLogContext) al actualizar un producto y luego guarda el objeto ProductPriceChangedIntegrationEvent, que necesita utilizar un DbContext diferente.
public async Task<IActionResult> UpdateProduct([FromBody]CatalogItem
productToUpdate)
{
// Other code ...
// Update current product
catalogItem = productToUpdate;
// Use of an EF Core resiliency strategy when using multiple DbContexts
// within an explicit transaction
// See:
// https://docs.microsoft.com/ef/core/miscellaneous/connection-resiliency
var strategy = _catalogContext.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
// Achieving atomicity between original Catalog database operation and the
// IntegrationEventLog thanks to a local transaction
using (var transaction = _catalogContext.Database.BeginTransaction())
{
_catalogContext.CatalogItems.Update(catalogItem);
await _catalogContext.SaveChangesAsync();
// Save to EventLog only if product price changed
if (raiseProductPriceChangedEvent)
await _integrationEventLogService.SaveEventAsync(priceChangedEvent);
transaction.Commit();
}
});
El primer DbContext es _catalogContext y el segundo DbContext está dentro del objeto _integrationEventLogService. El commit se realiza en múltiples DbContexts utilizando una estrategia de ejecución de EF.
Para crear microservicios resilientes, debe manejar posibles escenarios de falla HTTP. Para ese propósito, podría crear su propia implementación de reintentos con retroceso exponencial.
Además de manejar la no disponibilidad temporal de recursos, el retroceso exponencial también debe tener en cuenta que el proveedor de la nube puede reducir la disponibilidad de recursos para evitar la sobrecarga de uso. Por ejemplo, crear demasiadas solicitudes de conexión muy rápidamente puede verse como un ataque de denegación de servicio (DoS) por parte del proveedor de la nube. Como resultado, debe proporcionar un mecanismo para reducir las solicitudes cuando se ha encontrado un umbral de capacidad.
Como exploración inicial, puede implementar su propio código con una clase utilitaria para el retroceso exponencial como en RetryWithExponentialBackoff.cs, más un código como el siguiente (que también está disponible en un repositorio de GitHub).
public sealed class RetryWithExponentialBackoff
{
private readonly int maxRetries, delayMilliseconds, maxDelayMilliseconds;
public RetryWithExponentialBackoff(int maxRetries = 50,
int delayMilliseconds = 200,
int maxDelayMilliseconds = 2000)
{
this.maxRetries = maxRetries;
this.delayMilliseconds = delayMilliseconds;
this.maxDelayMilliseconds = maxDelayMilliseconds;
}
public async Task RunAsync(Func<Task> func)
{
ExponentialBackoff backoff = new ExponentialBackoff(this.maxRetries,
this.delayMilliseconds,
this.maxDelayMilliseconds);
retry:
try
{
await func();
}
catch (Exception ex) when (ex is TimeoutException ||
ex is System.Net.Http.HttpRequestException)
{
Debug.WriteLine("Exception raised is: " +
ex.GetType().ToString() +
" –Message: " + ex.Message +
" -- Inner Message: " +
ex.InnerException.Message);
await backoff.Delay();
goto retry;
}
}
}
public struct ExponentialBackoff
{
private readonly int m_maxRetries, m_delayMilliseconds, m_maxDelayMilliseconds;
private int m_retries, m_pow;
public ExponentialBackoff(int maxRetries, int delayMilliseconds,
int maxDelayMilliseconds)
{
m_maxRetries = maxRetries;
m_delayMilliseconds = delayMilliseconds;
m_maxDelayMilliseconds = maxDelayMilliseconds;
m_retries = 0;
m_pow = 1;
}
public Task Delay()
{
if (m_retries == m_maxRetries)
{
throw new TimeoutException("Max retry attempts exceeded.");
}
++m_retries;
if (m_retries < 31)
{
m_pow = m_pow << 1; // m_pow = Pow(2, m_retries - 1)
}
int delay = Math.Min(m_delayMilliseconds * (m_pow - 1) / 2,
m_maxDelayMilliseconds);
return Task.Delay(delay);
}
}
El uso de este código en una aplicación C# cliente (otro microservicio cliente API Web, una aplicación ASP.NET MVC o incluso una aplicación C# Xamarin) es sencillo. El siguiente ejemplo muestra cómo, usando la clase HttpClient.
public async Task<Catalog> GetCatalogItems(int page,int take, int? brand, int? type)
{
_apiClient = new HttpClient();
var itemsQs = $"items?pageIndex={page}&pageSize={take}";
var filterQs = "";
var catalogUrl =
$"{_remoteServiceBaseUrl}items{filterQs}?pageIndex={page}&pageSize={take}";
var dataString = "";
//
// Using HttpClient with Retry and Exponential Backoff
//
var retry = new RetryWithExponentialBackoff();
await retry.RunAsync(async () =>
{
// work with HttpClient call
dataString = await _apiClient.GetStringAsync(catalogUrl);
});
return JsonConvert.DeserializeObject<Catalog>(dataString);
}
Sin embargo, este código es adecuado sólo como una prueba de concepto. La siguiente sección explica cómo usar librerías más sofisticadas y comprobadas.
El enfoque recomendado para los reintentos con retroceso exponencial es aprovechar librerías .NET más avanzadas, como la librería open source Polly.
Polly es una librería .NET que proporciona resiliencia y capacidad de recuperación de fallas transitorias. Puede implementar esas capacidades fácilmente aplicando las políticas de Polly, como Retry, Circuit Breaker, BulkheadIsolation, Timeout y Fallback. Polly apunta a .NET 4.x y .NET Standard Library 1.0 (que es compatible con .NET Core).
La política de reintento de Polly es el enfoque utilizado en eShopOnContainers al implementar reintentos HTTP. Puede implementar una interfaz, para poder inyectar un HttpClient estándar de HttpClient o una versión resiliente de HttpClient utilizando Polly, dependiendo de qué configuración de la directiva de reintento desee usar.
El siguiente ejemplo muestra la interfaz implementada en eShopOnContainers.
public interface IHttpClient { Task<string> GetStringAsync(string uri, string authorizationToken = null, string authorizationMethod = "Bearer");
Task<HttpResponseMessage> PostAsync<T>(string uri, T item, string authorizationToken = null, string requestId = null, string authorizationMethod = "Bearer");
Task<HttpResponseMessage> DeleteAsync(string uri, string authorizationToken = null, string requestId = null, string authorizationMethod = "Bearer"); // Other methods ... } |
Puede utilizar la implementación estándar si no desea usar un mecanismo resiliente, como cuando desarrolla o prueba enfoques más simples. El siguiente código muestra la implementación estándar de HttpClient que permite, opcionalmente, solicitudes con tokens de autenticación.
public class StandardHttpClient : IHttpClient
{
private HttpClient _client;
private ILogger<StandardHttpClient> _logger;
public StandardHttpClient(ILogger<StandardHttpClient> logger)
{
_client = new HttpClient();
_logger = logger;
}
public async Task<string> GetStringAsync(string uri,
string authorizationToken = null,
string authorizationMethod = "Bearer")
{
var requestMessage = new HttpRequestMessage(HttpMethod.Get, uri);
if (authorizationToken != null)
{
requestMessage.Headers.Authorization =
new AuthenticationHeaderValue(authorizationMethod, authorizationToken);
}
var response = await _client.SendAsync(requestMessage);
return await response.Content.ReadAsStringAsync();
}
public async Task<HttpResponseMessage> PostAsync<T>(string uri, T item,
string authorizationToken = null, string requestId = null,
string authorizationMethod = "Bearer")
{
// Rest of the code and other Http methods ...
La implementación interesante es codificar otra clase similar, pero utilizando Polly para implementar los mecanismos resilientes que desea usar, en el siguiente ejemplo, reintentos con retroceso exponencial.
public class ResilientHttpClient : IHttpClient
{
private HttpClient _client;
private PolicyWrap _policyWrapper;
private ILogger<ResilientHttpClient> _logger;
public ResilientHttpClient(Policy[] policies,
ILogger<ResilientHttpClient> logger)
{
_client = new HttpClient();
_logger = logger;
// Add Policies to be applied
_policyWrapper = Policy.WrapAsync(policies);
}
private Task<T> HttpInvoker<T>(Func<Task<T>> action)
{
// Executes the action applying all
// the policies defined in the wrapper
return _policyWrapper.ExecuteAsync(() => action());
}
public Task<string> GetStringAsync(string uri,
string authorizationToken = null,
string authorizationMethod = "Bearer")
{
return HttpInvoker(async () =>
{
var requestMessage = new HttpRequestMessage(HttpMethod.Get, uri);
// The Token's related code eliminated for clarity in code snippet
var response = await _client.SendAsync(requestMessage);
return await response.Content.ReadAsStringAsync();
});
}
// Other Http methods executed through HttpInvoker so it applies Polly policies
// ...
}
Con Polly, se define una política para reintentar, con el número de reintentos, la configuración de retroceso exponencial y las acciones a tomar cuando hay una excepción HTTP, como registrar el error. En este caso, la política está configurada, por lo que intentará la cantidad de veces especificada al registrar los tipos en el contenedor IoC. Debido a la configuración de retroceso exponencial, cada vez que el código detecta una excepción HttpRequestException, reintenta la solicitud HTTP después de esperar una cantidad de tiempo que aumenta exponencialmente según cómo se haya configurado la política.
El método más importante es HttpInvoker, que realiza las solicitudes HTTP en este utilitario. Ese método ejecuta internamente la solicitud HTTP con _policyWrapper.ExecuteAsync, que tiene en cuenta la política de reintento.
En eShopOnContainers, usted especifica las políticas de Polly cuando registra los tipos en el contenedor IoC, como en el siguiente código de la clase Startup.cs en aplicación web MVC.
// Startup.cs class
if (Configuration.GetValue<string>("UseResilientHttp") == bool.TrueString)
{
services.AddTransient<IResilientHttpClientFactory,
ResilientHttpClientFactory>();
services.AddSingleton<IHttpClient,
ResilientHttpClient>(sp =>
sp.GetService<IResilientHttpClientFactory>().
CreateResilientHttpClient());
}
else
{
services.AddSingleton<IHttpClient, StandardHttpClient>();
}
Tenga en cuenta que los objetos IHttpClient se instancian como singleton en lugar de transitorios, de modo que el servicio utilice las conexiones TCP de manera eficiente y no se produzca un problema con los sockets. Además, cuando se trabaja con IHttpClient como singleton, también hay que manejar este problema con los cambios de DNS.
Pero el punto importante sobre la resiliencia es que se aplica la política WaitAndRetryAsync de Polly dentro de ResilientHttpClientFactory en el método CreateResilientHttpClient, como se muestra en el siguiente código:
public ResilientHttpClient CreateResilientHttpClient()
=> new ResilientHttpClient(CreatePolicies(), _logger);
// Other code
private Policy[] CreatePolicies()
=> new Policy[]
{
Policy.Handle<HttpRequestException>()
.WaitAndRetryAsync(
// number of retries
6,
// exponential backoff
retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)),
// on retry
(exception, timeSpan, retryCount, context) =>
{
var msg = $"Retry {retryCount} implemented with Pollys
RetryPolicy " +
$"of {context.PolicyKey} " +
$"at {context.ExecutionKey}, " +
$"due to: {exception}.";
_logger.LogWarning(msg);
_logger.LogDebug(msg);
}),
}
Como se señaló anteriormente, debe manejar las fallas que pueden tomar un tiempo variable para recuperarse, como podría ocurrir cuando intenta conectarse a un recurso o servicio remoto. El manejo de este tipo de falla puede mejorar la estabilidad y la resiliencia de una aplicación.
En un entorno distribuido, las llamadas a recursos y servicios remotos pueden fallar debido a problemas transitorios, como conexiones de red lentas y tiempos de espera o si los recursos están respondiendo lentamente o no están disponibles temporalmente. Por lo general, estas fallas se corrigen luego de un corto período de tiempo y una aplicación robusta en la nube debería estar preparada para manejarlas. utilizando una estrategia como el patrón de Reintento.
Sin embargo, también puede haber situaciones en las que las fallas se deban a eventos imprevistos, que pueden tardar mucho más en solucionarse. Estas fallas pueden variar en severidad desde una pérdida parcial de conectividad, hasta la falla completa de un servicio. En estas situaciones, puede no tener sentido que una aplicación intente continuamente una operación, que probablemente no tenga éxito. En cambio, la aplicación debe codificarse para aceptar que la operación ha fallado y manejar la falla en consecuencia.
El patrón de Interruptor Automático tiene un propósito diferente al patrón de Reintento. El patrón de Reintento permite a una aplicación reintentar una operación con la expectativa de que finalmente tendrá éxito. El patrón de Interruptor Automático impide que una aplicación realice una operación que probablemente falle. Una aplicación puede combinar estos dos patrones utilizando el patrón de reintento para invocar una operación a través de un interruptor automático. Sin embargo, la lógica de reintento debe ser sensible a cualquier excepción devuelta por el interruptor automático y debe abandonar los reintentos si el interruptor automático indica que una falla no es transitoria.
Al igual que cuando se implementan reintentos, el enfoque recomendado para los interruptores automáticos es aprovechar las librerías probadas de .NET como Polly.
La aplicación eShopOnContainers usa la política Circuit Breaker de Polly, al implementar reintentos HTTP. De hecho, la aplicación aplica ambas políticas a la clase utilitaria ResilientHttpClient. Siempre que utilice un objeto de tipo ResilientHttpClient para peticiones HTTP (desde eShopOnContainers), estará aplicando ambas políticas, pero también podría añadir políticas adicionales.
La única adición aquí al código utilizado para los reintentos de llamadas HTTP es el código donde se agrega la política de Interruptor Automático a la lista de políticas a usar, como se muestra al final del siguiente código:
public ResilientHttpClient CreateResilientHttpClient()
=> new ResilientHttpClient(CreatePolicies(), _logger);
private Policy[] CreatePolicies()
=> new Policy[]
{
Policy.Handle<HttpRequestException>()
.WaitAndRetryAsync(
// number of retries
6,
// exponential backofff
retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)),
// on retry
(exception, timeSpan, retryCount, context) =>
{
var msg = $"Retry {retryCount} implemented with Polly
RetryPolicy " +
$"of {context.PolicyKey} " +
$"at {context.ExecutionKey}, " +
$"due to: {exception}.";
_logger.LogWarning(msg);
_logger.LogDebug(msg);
}),
Policy.Handle<HttpRequestException>()
.CircuitBreakerAsync(
// number of exceptions before breaking circuit
5,
// time circuit opened before retry
TimeSpan.FromMinutes(1),
(exception, duration) =>
{
// on circuit opened
_logger.LogTrace("Circuit breaker opened");
},
() =>
{
// on circuit closed
_logger.LogTrace("Circuit breaker reset");
})};
}
El código agrega una política al wrapper (envoltorio) HTTP. Esta política define un interruptor automático que se abre cuando el código detecta el número especificado de excepciones consecutivas, tal y como se indica en el parámetro exceptionsAllowedBeforeBreaking (5 en este caso). Cuando el circuito está abierto, las peticiones HTTP no funcionan, sino que se presenta una excepción.
Los interruptores automáticos también se deben utilizar para redirigir las peticiones a una infraestructura de respaldo, si existe la posibilidad de que tenga problemas en un recurso que se despliegue en un entorno diferente al de la aplicación o servicio cliente que realiza la llamada HTTP. De esta manera, si hay una interrupción en el centro de datos, que afecta sólo a sus microservicios back-end pero no a sus aplicaciones cliente, las aplicaciones cliente pueden redirigirse a los servicios fallback. Polly tiene planeada una nueva política para automatizar este escenario con la política de conmutación por error (failover policy).
Por supuesto, todas esas características son para los casos en los que se está gestionando el failover desde el código. NET, en lugar de que Azure lo gestione automáticamente por usted, con transparencia de ubicación.
Puede usar la clase de utilitaria ResilientHttpClient de forma similar a como usa la clase .NET HttpClient. En el siguiente ejemplo de la aplicación web eShopOnContainers MVC (la clase de agente OrderingService utilizada por OrderController), el objeto ResilientHttpClient se inyecta a través del parámetro httpClient del constructor. A continuación, el objeto se utiliza para realizar peticiones HTTP.
public class OrderingService : IOrderingService
{
private IHttpClient _apiClient;
private readonly string _remoteServiceBaseUrl;
private readonly IOptionsSnapshot<AppSettings> _settings;
private readonly IHttpContextAccessor _httpContextAccesor;
public OrderingService(IOptionsSnapshot<AppSettings> settings,
IHttpContextAccessor httpContextAccesor,
IHttpClient httpClient)
{
_remoteServiceBaseUrl = $"{settings.Value.OrderingUrl}/api/v1/orders";
_settings = settings;
_httpContextAccesor = httpContextAccesor;
_apiClient = httpClient;
}
async public Task<List<Order>> GetMyOrders(ApplicationUser user)
{
var context = _httpContextAccesor.HttpContext;
var token = await context.Authentication.GetTokenAsync("access_token");
_apiClient.Inst.DefaultRequestHeaders.Authorization = new
System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
var ordersUrl = _remoteServiceBaseUrl;
var dataString = await _apiClient.GetStringAsync(ordersUrl);
var response = JsonConvert.DeserializeObject<List<Order>>(dataString);
return response;
}
// Other methods ...
async public Task CreateOrder(Order order)
{
var context = _httpContextAccesor.HttpContext;
var token = await context.Authentication.GetTokenAsync("access_token");
_apiClient.Inst.DefaultRequestHeaders.Authorization = new
System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
_apiClient.Inst.DefaultRequestHeaders.Add("x-requestid",
order.RequestId.ToString());
var ordersUrl = $"{_remoteServiceBaseUrl}/new";
order.CardTypeId = 1;
order.CardExpirationApiFormat();
SetFakeIdToProducts(order);
var response = await _apiClient.PostAsync(ordersUrl, order);
response.EnsureSuccessStatusCode();
}
}
Cada vez que se utiliza el campo _apiClient, utiliza internamente la clase “envoltorio” con las políticas de Polly: la política de reintentos, la política de interruptor automático y cualquier otra política, de la colección de políticas de Polly, que desee aplicar.
Siempre que inicie la solución eShopOnContainers en un servidor Docker, necesita iniciar varios contenedores. Algunos de los contenedores son más lentos para arrancar e inicializar, como el contenedor de SQL Server. Esto es especialmente cierto la primera vez que despliega la aplicación eShopOnContainers en Docker, ya que necesita configurar las imágenes y la base de datos. El hecho de que algunos contenedores empiecen más despacio que otros puede hacer que el resto de los servicios eleven inicialmente excepciones HTTP, incluso si se establecen dependencias entre contenedores al nivel de docker-compose, como se explicó en secciones anteriores. Esas dependencias entre contenedores se encuentran sólo a nivel del proceso. Es posible que se inicie el proceso a nivel del contenedor, pero es posible que SQL Server no esté preparado para las consultas. El resultado puede ser una cascada de errores y la aplicación puede obtener una excepción al intentar consumir de ese contenedor en particular.
Es posible que también vea este tipo de error al iniciar la aplicación cuando se está implementando en la nube. En ese caso, los orquestadores podrían estar moviendo contenedores de un nodo o máquina virtual a otro (es decir, iniciando nuevas instancias) al balancear el número de contenedores a través de los nodos del cluster.
eShopOnContainers resuelve este problema usando el patrón de reintento que ilustramos anteriormente. También es por eso que, al iniciar la solución, puede obtener trazas de registro o advertencias como las siguientes:
"Retry 1 implemented with Polly's RetryPolicy, due to: System.Net.Http.HttpRequestException: An error occurred while sending the request. ---> System.Net.Http.CurlException: Couldn't connect to server\n at System.Net.Http.CurlHandler.ThrowIfCURLEError(CURLcode error)\n at [...].
Hay algunas formas de abrir el circuito y probarlo con eShopOnContainers.
Una opción es reducir el número permitido de reintentos a 1 en la política de interruptor automático y redesplegar toda la solución a Docker. Con un solo reintento, existe una buena probabilidad de que una petición HTTP falle durante el despliegue, el interruptor automático se abrirá y obtendrá un error.
Otra opción es utilizar el middleware personalizado que se implementa en el microservicio Basket (el del carrito de compras). Cuando este middleware está activado, captura todas las peticiones HTTP y devuelve el código de estado 500. Puede habilitar el middleware haciendo una petición GET al URI que falla, como se indica a continuación:
Esta petición devuelve el estado actual del middleware. Si el middleware está habilitado, la petición devuelve el código de estado 500. Si el middleware está desactivado, no hay respuesta.
Esta petición habilita el middleware.
Esta petición desactiva el middleware.
Por ejemplo, una vez que la aplicación se está ejecutando, puede habilitar el middleware haciendo una petición utilizando el siguiente URI en cualquier navegador. Tenga en cuenta que el microservicio de pedido utiliza el puerto 5103 en este ejemplo.
http://localhost:5103/failing?enable
Entonces puede comprobar el estado utilizando el URI http://localhost:5103/failing, como se muestra en la Figura 8-4.
Figura 8-4. Revisando el estado del middleware ASP.NET Core que está “fallando”, en este caso, deshabilitado.
En este punto, el microservicio del carrito de compras responde con el código de estado 500 cada vez que se llama a invocarlo.
Una vez que el middleware se está ejecutando, puede intentar hacer un pedido desde la aplicación web MVC. Debido a que las solicitudes fallan, el circuito se abrirá.
En el siguiente ejemplo, puede ver que la aplicación web MVC tiene un bloque catch en la lógica para realizar un pedido. Si el código detecta una excepción de circuito abierto, muestra al usuario un mensaje sencillo diciéndole que espere.
public class CartController : Controller
{
//…
public async Task<IActionResult> Index()
{
try
{
//… Other code
}
catch (BrokenCircuitException)
{
// Catch error when Basket.api is in circuit-opened mode
HandleBrokenCircuitException();
}
return View();
}
private void HandleBrokenCircuitException()
{
TempData["BasketInoperativeMsg"] = "Basket Service is inoperative, please try later on. (Business Msg Due to Circuit-Breaker)";
}
}
En resumen. La política de reintento intenta varias veces hacer la petición HTTP y obtiene errores HTTP. Cuando el número de intentos alcanza el número máximo establecido para la política de interruptor automático (en este caso, 5), la aplicación lanza una excepción BrokenCircuitException. El resultado es un mensaje amigable, como se muestra en la Figura 8-5.
Figura 8-5. Interruptor automático devolviendo un error a la interfaz de usuario
Puede implementar diferentes lógicas para cuándo abrir el circuito. También puede probar una petición HTTP contra un microservicio back-end diferente, si existe un centro de datos de respaldo o un sistema back-end redundante.
Por último, otra posibilidad para la política CircuitBreakerPolicy es usar Isolate (que fuerza a abrir y mantiene abierto el circuito) y Reset (que lo vuelve a cerrar). Éstos se pueden utilizar para crear un endpoint utilitario HTTP que invoque Isolate y Reset directamente en la política. Un endpoint HTTP de este tipo también podría utilizarse, asegurándolo adecuadamente, en producción para aislar temporalmente un sistema aguas abajo, como cuando se desea actualizarlo. O podría disparar el circuito manualmente para proteger un sistema aguas abajo del que sospecha que está fallando.
Una política de Reintento regular puede afectar su sistema en casos de alta concurrencia y escalabilidad y con alta contención. Para superar picos de reintentos similares provenientes de muchos clientes en caso de interrupciones parciales, una buena solución es agregar una estrategia de fluctuación al algoritmo/política de reintento. Esto puede mejorar el rendimiento general del sistema añadiendo aleatoriedad al retroceso exponencial. Esto esparce los picos cuando surgen problemas. Cuando se usa Polly, el código para implementar fluctuaciones podría verse como el siguiente ejemplo:
Random jitterer = new Random(); Policy .Handle<HttpResponseException>() // etc .WaitAndRetry(5, // exponential back-off plus some jitter retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)) + TimeSpan.FromMilliseconds(jitterer.Next(0, 100)) ); |
La monitorización de la salud puede dar información casi en tiempo real sobre el estado de sus contenedores y microservicios. La monitorización de la salud es crítica para múltiples aspectos de los microservicios operativos y es especialmente importante cuando los orquestadores realizan actualizaciones parciales de la aplicación en fases, como se explica más adelante.
Las aplicaciones basadas en microservicios a menudo utilizan heartbeats (latidos) o chequeos de salud para permitir que sus monitores de rendimiento, programadores y orquestadores puedan llevar un registro de la multitud de servicios. Si los servicios no pueden enviar algún tipo de señal tipo "Estoy vivo", ya sea bajo demanda o según una programación, su aplicación podría correr riesgos cuando se desplieguen las actualizaciones o simplemente se podrían detectar fallas demasiado tarde y no ser capaz de detener fallas en cascada, que puedan terminar en grandes interrupciones.
En el modelo típico, los servicios envían informes sobre su estado y esa información se agrega para proporcionar una visión general del estado de salud de su aplicación. Si está utilizando un orquestador, puede proporcionar información de salud al cluster del orquestador, para que éste pueda actuar en consecuencia. Si usted invierte en informes de salud personalizados de alta calidad para su aplicación, puede detectar y corregir problemas en tiempo ejecución con mucha más facilidad.
Cuando desarrolle un microservicio o aplicación web ASP. NET Core, puede utilizar una librería llamada HealthChecks del equipo de ASP. NET. (versión preliminar disponible en GitHub).
Esta librería es fácil de usar y proporciona características que le permiten validar que cualquier recurso externo específico necesario para su aplicación (como una base de datos SQL Server o API remota) funciona correctamente. Cuando usted usa esta librería, también puede decidir qué significa que el recurso esté saludable, como explicaremos más adelante.
Para poder usar esta librería, primero debe referenciarla en sus microservicios. En segundo lugar, necesita una aplicación de front-end que pregunte por los informes de salud. Esa aplicación de front-end podría ser una aplicación personalizada de informes o podría ser un orquestador mismo que pueda reaccionar adecuadamente a los estados de salud.
Puede ver cómo se utiliza la librería HealthChecks en la aplicación eShopOnContainers. Para empezar, es necesario definir lo que constituye un estado saludable para cada microservicio. En la aplicación de ejemplo, los microservicios están saludables si la API del microservicio es accesible vía HTTP y si su base de datos SQL Server relacionada también está disponible.
En el futuro, podrá instalar la librería HealthChecks como un paquete NuGet. Pero, hasta el momento de escribir esta guía, necesita descargar y compilar el código como parte de su solución. Clone el código disponible en https://github.com/dotnet-architecture/HealthChecks y copie las siguientes carpetas a su solución.
src/common
src/Microsoft.AspNetCore.HealthChecks
src/Microsoft.Extensions.HealthChecks
src/Microsoft.Extensions.HealthChecks.SqlServer
También podría usar las verificaciones adicionales para Azure (Microsoft.Extensions.HealthChecks.AzureStorage), pero como esta versión de eShopOnContainers no tiene ninguna dependencia de Azure, no la necesita. No necesita los controles de salud de ASP.NET, porque eShopOnContainers está basada en ASP.NET Core.
La Figura 8-6 muestra la librería HealthChecks en Visual Studio, lista para ser utilizada como un bloque de construcción para cualquier microservicio.
Figura 8-6. Código fuente de la librería ASP.NET Core HealthChecks en una solución Visual Studio
Como se presentó anteriormente, lo primero que hay que hacer en cada proyecto de microservicio es agregar una referencia a las tres librerías de HealthChecks. Después de eso, agregue las acciones de control de salud que desea realizar en ese microservicio. Estas acciones son básicamente dependencias de otros microservicios (HttpUrlCheck) o bases de datos (actualmente SqlCheck* para bases de datos SQL Server). Añada la acción dentro de la clase Startup de cada microservicio o aplicación web ASP.NET.
Se debe configurar cada servicio o aplicación web, añadiendo todas sus dependencias HTTP o de base de datos como un método AddHealthCheck. Por ejemplo, la aplicación web MVC de eShopOnContainers depende de muchos servicios, por lo que tiene varios métodos AddCheck añadidos a los controles de salud.
Por ejemplo, en el siguiente código se puede ver cómo el microservicio de catálogo añade una dependencia a su base de datos SQL Server.
// Startup.cs from Catalog.api microservice // public class Startup { public void ConfigureServices(IServiceCollection services) { // Add framework services services.AddHealthChecks(checks => { checks.AddSqlCheck("CatalogDb", Configuration["ConnectionString"]); }); // Other services } } |
Sin embargo, la aplicación web MVC de eShopOnContainers tiene múltiples dependencias sobre el resto de los microservicios. Por lo tanto, llama un método AddUrlCheck para cada microservicio, como se muestra en el siguiente ejemplo:
// Startup.cs from the MVC web app public class Startup { public void ConfigureServices(IServiceCollection services) { services.AddMvc(); services.Configure<AppSettings>(Configuration);
services.AddHealthChecks(checks => { checks.AddUrlCheck(Configuration["CatalogUrl"]); checks.AddUrlCheck(Configuration["OrderingUrl"]); checks.AddUrlCheck(Configuration["BasketUrl"]); checks.AddUrlCheck(Configuration["IdentityUrl"]); }); } } |
Por lo tanto, un microservicio no proporcionará un estado "saludable" hasta que todas sus verificaciones estén saludables también.
Si el microservicio no tiene una dependencia de un servicio o de SQL Server, sólo tiene que añadir un resultado de Healthy ("Ok"). El siguiente código es del microservicio del carrito de compras de eShopOnContainers basket.api. (El microservicio del carrito de compras usa el caché de Redis, pero la librería todavía no incluye un proveedor de control de salud de Redis.
services.AddHealthChecks(checks => { checks.AddValueTaskCheck("HTTP Endpoint", () => new ValueTask<IHealthCheckResult>(HealthCheckResult.Healthy("Ok"))); }); |
Para que un servicio o aplicación web exponga el endpoint del control de salud, debe habilitar el extension method UserHealthChecks([url_del_control_de_salud]). Este método va a nivel del WebHostBuilder en el método Main de la clase de Program de su servicio o aplicación web ASP.NET Core, justo después de UseKestrel como se muestra en el código de abajo.
namespace Microsoft.eShopOnContainers.WebMVC { public class Program { public static void Main(string[] args) { var host = new WebHostBuilder() .UseKestrel() .UseHealthChecks("/hc") .UseContentRoot(Directory.GetCurrentDirectory()) .UseIISIntegration() .UseStartup<Startup>() .Build(); host.Run(); } } } |
El proceso funciona así: cada microservicio expone el endpoint /hc. Ese endpoint es creado por el middleware de ASP.NET Core de la librería HealthChecks. Cuando se invoca ese endpoint, ejecuta todas las comprobaciones de estado configuradas en el método AddHealthChecks de la clase Startup.
El método UseHealthChecks espera un puerto o una ruta. Ese puerto o ruta es el endpoint que se usará utilizar para comprobar el estado de salud del servicio. Por ejemplo, el microservicio de catálogo utiliza la ruta /hc.
Dado que no desea causar una Denegación de Servicio (DoS) en sus servicios, o simplemente no desea afectar el rendimiento del servicio comprobando los recursos con demasiada frecuencia, puede almacenar en caché las devoluciones y configurar una duración del caché para cada control de salud.
De forma predeterminada, la duración de la caché se establece internamente en 5 minutos, pero puede cambiarla al configurar cada control de salud, como en el código siguiente:
checks.AddUrlCheck(Configuration["CatalogUrl"],1); // 1 min as cache duration |
Cuando haya configurado los controles de salud como se describe aquí, una vez que el microservicio se esté ejecutando en Docker, puede comprobar directamente desde un navegador si está en buen estado. (Esto requiere que publique el puerto de contenedores fuera del servidor Docker, para poder acceder al contenedor a través de localhost o a través de la IP externa del servidor Docker. La figura 8-7 muestra una petición en un navegador y la respuesta correspondiente.
Figura 8-7. Revisando el estado de salud de un microservicio desde un navegador
En esa prueba, puede ver que el microservicio catalog.api (corriendo en el puerto 5101) está saludable, devolviendo el estado 200 de HTTP y la información de estado en JSON. También significa que el servicio también ha comprobado internamente la salud de su dependencia de la base de datos SQL Server y que el control de salud se ha reportado como saludable.
Un guardián (watchdog) es un servicio separado que puede vigilar la salud y carga entre los servicios, e informar sobre la salud de los microservicios, consultando con la librería HealthChecks presentada anteriormente. Esto puede ayudar a evitar errores que no se detectarían basándose en la visualización de un servicio único. Los watchdogs también son un buen lugar para alojar código que puede realizar acciones de remediación para condiciones conocidas, sin la intervención del usuario.
El ejemplo de eShopOnContainers contiene una página web que muestra reportes de control de salud, como se muestra en la Figura 8-8. Este es el watchdog más simple que se puede tener, ya que sólo muestra el estado de los microservicios y aplicaciones web en eShopOnContainers. Por lo general, un watchdog también toma medidas cuando detecta servicios no saludables.
Figura 8-8. Ejemplo de una consulta del estado de salud en eShopOnContainers
En resumen, el middleware ASP.NET de la librería ASP.NET Core HealthChecks, proporciona un endpoint único de control de salud para cada microservicio. Esto ejecutará todos los controles de salud definidos en el mismo y devolverá un estado de salud general dependiendo de todos esos controles.
La librería HealthChecks es extensible a través de nuevos controles de salud de futuros recursos externos. Por ejemplo, esperamos que en el futuro tenga controles para Redis y para otras bases de datos. La librería permite la presentación de informes de salud por parte de múltiples dependencias de servicios o aplicaciones y, a continuación, usted puede tomar medidas basadas en esos resultados si es necesario.
Para monitorizar la disponibilidad de los microservicios, los orquestadores como Docker Swarm, Kubernetes y Azure Service Fabric, realizan revisiones periódicas de salud, enviando peticiones para probar los microservicios. Cuando un orquestador determina que un servicio/contenedor no está saludable, deja de enrutar las peticiones a esa instancia. También suele crear una nueva instancia de ese contenedor.
Por ejemplo, la mayoría de los orquestadores pueden usar controles de salud para administrar despliegues sin ocasionar interrupciones del servicio. Sólo cuando el estado de un servicio/contenedor cambia a saludable, el orquestador comenzará a enrutar el tráfico hacia las instancias del servicio/contenedor.
La monitorización de la salud es especialmente importante cuando un orquestador realiza la actualización de la aplicación. Algunos orquestadores (como Azure Service Fabric) actualizan los servicios por fases, por ejemplo, pueden actualizar una quinta parte de la superficie del cluster para aplicar la actualización de cada aplicación. El conjunto de nodos que se actualiza al mismo tiempo se denomina dominio de actualización. Después de que cada dominio de actualización se haya actualizado y esté disponible para los usuarios, debe pasar las comprobaciones de estado antes de que el despliegue pase al siguiente dominio de actualización.
Otro aspecto de la salud de los servicios es reportar métricas del servicio. Esta es una capacidad avanzada del modelo de salud de algunos orquestadores, como Azure Service Fabric. Las métricas son importantes cuando se usa un orquestador, porque se usan para balancear el uso de recursos. Las métricas también pueden ser un indicador de la salud del sistema. Por ejemplo, es posible que tenga una aplicación con muchos microservicios y cada instancia reporte una métrica de peticiones por segundo (RPS – requests per second). Si un servicio está utilizando más recursos (memoria, procesador, etc.) que otro servicio, el orquestador podría mover las instancias de servicio en el cluster para intentar mantener uniforme el uso de recursos.
Tenga en cuenta que, si usted está usando Azure Service Fabric, éste proporciona su propio modelo de monitorización de la salud, que es más avanzado que los simples controles de salud.
La parte final de la monitorización consiste en visualizar el flujo de eventos, informar sobre el rendimiento del servicio y alertar cuando se detecte un problema. Puede utilizar diferentes soluciones para este aspecto de la monitorización.
Puede utilizar aplicaciones sencillas y personalizadas que muestren el estado de sus servicios, como la página personalizada que mostramos cuando explicamos los ASP.NET Core HealthChecks. O bien, puede utilizar herramientas más avanzadas como Azure Application Insights y Operations Management Suite para crear alertas basadas en los flujos de eventos.
Por último, si estaba almacenando todos los flujos de eventos, puede utilizar Microsoft Power BI o una solución de terceros como Kibana o Splunk para visualizar los datos.