.NET  

Service Discovery using Consul

In this article, we will explore how to set up Service Discovery using Consul.

In the context of microservices, service discovery is the process by which individual services can automatically detect each other without hardcoding network details. The services could scale up/down frequently, and IP/port configuration could change dynamically. This makes hardcoding the locations (port,ip) unreliable.

Service registry

Service Discovery solves this issue by allowing services to register themselves with the Service Discovery nodes and discover others dynamically. Consul is an open-source tool developed by HashiCorp that provides service discovery, health checking, and service mesh features in distributed systems. In this example, we will use the official .NET library provided by Consul.

In the context of this article, we will be aiming to build the following structure.

User service

There are two independent services, UserService and PaymentService, which provide their own functions. The AggregatorService is an endpoint exposed to the client, which would need to fetch information from both UserService and PaymentService, aggregate the result, and send it back to the client. For this, the AggregatorService would need to use ServiceDiscovery to resolve the details of both independent services.

Consul

The first step is to ensure our Consul service is running. In this example, we will use Docker containers.

services:
  servicediscovery:
    image: hashicorp/consul
    container_name: servicediscovery
    ports:
      - "9500:8500"    # HTTP UI/API
      - "9600:8600/udp" # DNS
    command: agent -dev -client=0.0.0.0
    networks:
        commonnetwork:
networks:
  commonnetwork:
    driver: bridge

We already have a common network, which would be used by other services as well, so that the containers have a common network to communicate.

Services

The next step is to create our services, which would register itself in the Consul registry. Let us begin with UserService.For the sample scenario, we will create a demo test endpoint to fetch user info.

[ApiController]
[Route("[controller]")]
public class UserController : ControllerBase
{
    private readonly ILogger<UserController> _logger;

    public UserController(ILogger<UserController> logger)
    {
        _logger = logger;
    }

    [HttpGet]
    [Route("GetUserInfo")]
    public ActionResult<UserInfo> Get([FromQuery]string userName)
    {
        return Ok(new UserInfo("John Doe","1234456","[email protected]"));
    }
}

public record UserInfo([property: JsonPropertyName("id")] string Name, [property: JsonPropertyName("name")] string Phone, [property: JsonPropertyName("email")] string Email);

As you can observe, the UserController exposes a single endpoint to fetch the User details when provided with a username.

We need to make an additional endpoint, which would be used by the Consul service for health checks on the UserService.

[ApiController]
[Route("[controller]")]
public class HeartBeatController:ControllerBase
{
    private readonly ILogger<HeartBeatController> _logger;

    public HeartBeatController(ILogger<HeartBeatController> logger)
    {
        _logger = logger;
    }

    [HttpGet]
    [ProducesResponseType(StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status400BadRequest)]
    [Route("health")]
    public ActionResult Health()
    {
        return Ok();
    }
}

The health check-up endpoint is a single HttpGet request that returns HTTP 200. This indicates to the Consul Service (or any other client, which requires checking the health of the service) that the service is up and running.

We can proceed to register the UserService with consul. We need to install the Consul.Net nuget package for the same.

Install-Package Consul

We can define our configuration for the service in appsettings.json as

"ConsulConfig": {
  "serviceName": "userservice", // The name under which the service will be registered in Consul
  "serviceId": "userservice001", // Unique service ID for Consul registration
  "serviceAddress": "userservice", // The address or hostname for Consul to reach the service (can be a Docker container name or IP)
  "servicePort": 8081, // The port that the service is listening on
  "healthCheckUrl": "/HeartBeat/health", // The health check URL to monitor the service's health
  "consulAddress": "http://servicediscovery:8500", // Address of the Consul agent (can be changed based on your setup)
  "deregisterAfterMinutes": 5, // Time to wait before deregistering a service after health check failure
  "TLSSkipVerify": true // Skip TLS verification for Consul (useful for self-signed certificates)
}

With the configuration in place, we can now register our service as follows.

var consulConfig = builder.Configuration.GetSection(nameof(ConsulConfig)).Get<ConsulConfig>();
if(consulConfig is not null)
{
    var consulClient = new ConsulClient(x => x.Address = new Uri(consulConfig.ConsulAddress));
    var registration = new AgentServiceRegistration
    {
        ID = consulConfig.ServiceId,
        Name = consulConfig.ServiceName,
        Address = consulConfig.ServiceAddress,
        Port = consulConfig.ServicePort,
        Check = new AgentServiceCheck
        {
            HTTP = $"https://{consulConfig.ServiceAddress}:{consulConfig.ServicePort}{consulConfig.HealthCheckUrl}",
            Interval = TimeSpan.FromSeconds(10),
            Timeout = TimeSpan.FromSeconds(5),
            DeregisterCriticalServiceAfter = TimeSpan.FromMinutes(consulConfig.DeregisterAfterMinutes),
            TLSSkipVerify = consulConfig.TLSSkipVerify,
        }
    };


    // Register service with Consul
    await consulClient.Agent.ServiceRegister(registration);
}

Where ConsulConfig is defined as

public record ConsulConfig
{
    public string ConsulAddress { get; set; } = null!;
    public string ServiceName { get; set; } = null!;
    public string ServiceId { get; set; } = null!;
    public string ServiceAddress { get; set; } = null!;
    public int ServicePort { get; set; }
    public string HealthCheckUrl { get; set; } = null!;
    public int DeregisterAfterMinutes { get; set; }
    public bool TLSSkipVerify { get; set; } = true;
}

The last step is to ensure our Docker Compose runs the UserService in a container and shares the common network with Consul.

userservice:
    image: ${DOCKER_REGISTRY-}userservice
    container_name: userservice
    build:
      context: .
      dockerfile: UserService/Dockerfile
    ports:
      - "7000:8080"
      - "7001:8081"
    networks:
      commonnetwork:
    depends_on:
      - "servicediscovery" 

We can proceed to create another Service (namely, PaymentService) and register it with the Consul. I have skipped the sample here for brevity, but refer to the source code enclosed for details.

Once both services are registered, we can view them in the Consul dashboard.

Consul Dashboard

AggregatorService

In our example context, the consumer code is an aggregator service, which would fetch data from both UserService and PaymentService to aggregate the results. Once the individual services register themselves with Consul, we can resolve them from the AggregatorService.

Our aim would be to create an endpoint that can use both individual services and aggregate the results.

[HttpGet]
public async Task<ActionResult<PaymentDetails?>> Get([FromQuery]string userName)
{
    var user = await _userService.GetUserByIdAsync(userName).ConfigureAwait(false);
    var paymentDetails = await _paymentService.GetPaymentInfo("123").ConfigureAwait(false);

    return Ok(new PaymentDetails()
    {
        User = user,
        Payment = paymentDetails
    });
}

We will delve into the details of the UserService and PaymentService classes in a bit. But to resolve the API Services, we need to configure the Consul service details in AggregatorService.

"ServiceDiscoveryOptions": {
  "ResolverName": "servicediscovery",
  "ResolverPort": 8500,
  "Services": [
    {
      "Key": "UserService",
      "Name": "userservice"
    },
    {
      "Key": "PaymentService",
      "Name": "paymentservice"
    }
  ]
}

The configuration can of course, be resolved using the IOptions<T> pattern

builder.Services.Configure<ServiceDiscoveryOptions>(
    builder.Configuration.GetSection(nameof(ServiceDiscoveryOptions)));


public record ServiceDiscoveryOptions
{
    public List<Service> Services { get; set; } = [];
    public string ResolverName { get; set; } = null!;
    public string ResolverPort { get; set; } = null!;
}

public record Service(string Key, string Name);

We can now create our ConsulServiceResolver, which would be responsible for resolving services.

public class ConsulServiceResolver : IDisposable
{
    private readonly ConsulClient _client;
    private bool _disposed = false;
    public ConsulServiceResolver(IOptions<ServiceDiscoveryOptions> serviceDiscoveryOptions)
    {
        var serviceDiscovery = serviceDiscoveryOptions.Value;
        _client = new ConsulClient(cfg => cfg.Address = new Uri($"http://{serviceDiscovery.ResolverName}:{serviceDiscovery.ResolverPort}"));
    }

    /// <summary>
    /// Resolves a healthy instance of the given service name from Consul.
    /// </summary>
    public async Task<(string Address, int Port)> ResolveServiceAsync(string serviceName)
    {
        var result = await _client.Health.Service(serviceName, tag: null, passingOnly: true);

        if (result.Response == null || result.Response.Length == 0)
            throw new Exception($"No healthy instances found for service '{serviceName}'");

        var serviceEntry = result.Response.First();

        return (serviceEntry.Service.Address, serviceEntry.Service.Port);
    }


    public void Dispose()   
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }

    protected virtual void Dispose(bool disposing)
    {
        if (_disposed)
            return;

        if (disposing)
        {
            // Dispose managed state (managed objects).
            _client?.Dispose();
        }
        _disposed = true;
    }

    // Destructor (finalizer) only if needed
    ~ConsulServiceResolver()
    {
        Dispose(false);
    }
}

The ResolveServiceAsync method is used to resolve the individual services based on the service name. We use the Consul.Health.Service() method to list the services that are available (healthy) with the given service name. If we find more than one service (in case, multiple instances), we return the first instance. In the real world, we could use an efficient load balancing pattern, but for simplicity of this example, we will take the first one.

We can register the ConsulServiceResolver in our DI as well.

builder.Services.AddScoped<ConsulServiceResolver>();

We, however, have one complicity. We cannot hook up the code to resolve the services at the startup of AggregatorService, as the UserService and PaymentService might not have yet registered themselves, even if we set dependencies in Docker Compose. Additionally, since we are using a secure connection (HTTPS), we somehow need to bypass the SSL Validation in the developer environment.

For the later, we introduce our custom HttpClientFactory, which would be used to initialize our HttpClient.

public class DevelopmentHttpClientFactory : IHttpClientFactory
{
    private readonly IServiceProvider _serviceProvider;

    public DevelopmentHttpClientFactory(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }

    public HttpClient CreateClient(string name)
    {
        var handler = new HttpClientHandler
        {
            ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
        };
        return new HttpClient(handler);
    }
}

builder.Services.AddSingleton<IHttpClientFactory>(sp => new DevelopmentHttpClientFactory(sp));

The IHttpClientFactory.CreateClient() allows us to create a new HttpClient, which bypass the SSL Validation.

To ensure that individual services might have already registered with Consul, we delay the creation of the HttpClient instance until we actually require it for the first time. This can be done in the Services classes.

public class UserService : ServiceBase, IUserService
{
    private readonly ILogger<UserService> _logger;
    public UserService(
        IHttpClientFactory httpClientFactory,
        ConsulServiceResolver consulResolver,
        ILogger<UserService> logger,
        IOptions<ServiceDiscoveryOptions> serviceDiscovery) : base(httpClientFactory, logger, consulResolver,serviceDiscovery,nameof(UserService))
    {
        _logger = logger;
    }

    public async Task<UserDto?> GetUserByIdAsync(string userId)
    {
        var client = await GetClientAsync();
        var response = await client.GetAsync($"/user/GetUserInfo?userName={userId}");

        if (response.IsSuccessStatusCode)
        {
            var json = await response.Content.ReadAsStringAsync();
            return JsonSerializer.Deserialize<UserDto>(json);
        }

        _logger.LogError("Failed to get user {UserId}: {StatusCode}", userId, response.StatusCode);
        throw new Exception($"Failed to get user {userId}: {response.StatusCode}");
    }
}

As observed in the UserService, the GetUserByIdAsync() we create/get instance of HttpClient specifically for the Service using the GetClientAsync() method. This is defined in the base class ServiceBase.

public abstract class ServiceBase
{
    protected readonly Task<HttpClient> _httpClientTask;
    protected readonly ConsulServiceResolver _consulResolver;
    protected ServiceBase(IHttpClientFactory httpClientFactory, ILogger<ServiceBase> logger,ConsulServiceResolver consulResolver,IOptions<ServiceDiscoveryOptions> serviceDiscoveryOptions, string serviceName)
    {
        _consulResolver = consulResolver;
        var registeredService = serviceDiscoveryOptions.Value.Services.FirstOrDefault(s => s.Key == serviceName)?.Name;
        if (registeredService == null)
        {
            logger.LogError("Service {ServiceName} not found in service discovery options", serviceName);
            throw new ArgumentException($"Service {serviceName} not found in service discovery options");
        }

        _httpClientTask = InitializeHttpClientAsync(httpClientFactory,registeredService);
    }

    private async Task<HttpClient> InitializeHttpClientAsync(IHttpClientFactory httpClientFactory,string serviceName)
    {
        var client = httpClientFactory.CreateClient(); // unnamed/default
        var (address, port) = await _consulResolver.ResolveServiceAsync(serviceName);
        client.BaseAddress = new Uri($"https://{address}:{port}");
        return client;
    }

    protected Task<HttpClient> GetClientAsync()
    {
        return _httpClientTask;
    }
}

The InitializeHttpClientAsync() method creates a new Instance of HttpClient using HttpClientFactory, which is the custom HttpClientFactory we created using DevelopmentHttpClientFactory, which disabled the SSL Validation. We then use ConsulService to resolve the service based on the serviceName parameter and assign the HttpClient.BaseAddress is based on the values resolved.

Later on, the UserService and PaymentService wrapper classes use the specifically initialized HttpClient to make the request to the specific API service.

Conclusion

The article outlines the importance of Service Discovery and uses the Consul library for service discovery. The complete source code of the sample application is attached to the article for further reference.