When moving from a monolithic architecture to microservice or serverless based architecture models we might want to use API Gateway to provide API to external users. This solution will allow us to independently evolve our internal architecture since services/functions won’t be exposed anymore. Furthermore, by using API Gateway we can remove chatty interactions between services/functions and clients by replacing them with internal calls inside our system, which can highly improve performance of our system since LAN is typically much faster than the mobile network.

In this article, we are going to use Ocelot and .NET Core Web API for API Gateway implementation and HashiCorp Consul for service discovery. So let’s begin by configuring and running our services inside Docker.

Preparing our system in Docker

For those who are not familiar with Docker, it allows us to create isolated containers to run our applications. Because we will be running our containers inside Docker we don’t need to care about different environments.

In this article, we are going to have two microservices, API Gateway and Consul service. Since we will need to deploy multiple services we are going to use docker compose tool, that allows to configure and run multi-container Docker applications. Let’s begin by defining docker-compose.yml file, which contains definitions of services that we are going to use:

version: '3.4' services:

orders:

image: orders:latest

build:

context: .

dockerfile: src/services/Orders/Dockerfile

container_name: orders deliveries:

image: deliveries:latest

build:

context: .

dockerfile: src/services/Deliveries/Dockerfile

container_name: deliveries api.gateway:

image: apigateway:latest

build:

context: .

dockerfile: src/gateways/API.Gateway/Dockerfile

container_name: apigateway consul:

image: consul:latest

command: consul agent -dev -log-level=warn -ui -client=0.0.0.0

hostname: consul

container_name: consul

As we can see docker-compose.yml doesn’t contain any configurations for services, for that purpose, we are going to use docker-compose.override.yml file:

version: '3.4' services:

orders:

environment:

- ASPNETCORE_ENVIRONMENT=Development

- ServiceConfig__serviceDiscoveryAddress=http://consul:8500

- ServiceConfig__serviceAddress=http://orders:80

- ServiceConfig__serviceName=orders

- ServiceConfig__serviceId=orders-v1

ports:

- "80" deliveries:

environment:

- ASPNETCORE_ENVIRONMENT=Development

- ServiceConfig__serviceDiscoveryAddress=http://consul:8500

- ServiceConfig__serviceAddress=http://deliveries:80

- ServiceConfig__serviceName=deliveries

- ServiceConfig__serviceId=deliveries-v1

ports:

- "80" api.gateway:

environment:

- ASPNETCORE_ENVIRONMENT=Development

ports:

- "80:80" consul:

ports:

- "8500"

As we can see from the configuration file, there is no way for services to know about each other existence. We could pass this information through environment variables, however, we won’t be able to change it in the runtime.

Implementing service self-registration

Before getting into details how to implement self-registration to Consul, let’s look into how service discovery with self-registration works.

Service discovery using a self-registration pattern

First, a service instance registers itself to the service discovery service by providing its name and address. After this step client is able to get information about this service by querying the service discovery.

Let’s look at how we can implement self-registration in the .NET Core application. First, we need to read the configuration required for service discovery from environment variables, that were passed through the

docker-compose.override.yml file.

public static class ServiceConfigExtensions

{

public static ServiceConfig GetServiceConfig(this IConfiguration configuration)

{

if (configuration == null) {

throw new ArgumentNullException(nameof(configuration));

}



var serviceConfig = new ServiceConfig {

ServiceDiscoveryAddress = configuration.GetValue<Uri>("ServiceConfig:serviceDiscoveryAddress"),

ServiceAddress = configuration.GetValue<Uri>("ServiceConfig:serviceAddress"),

ServiceName = configuration.GetValue<string>("ServiceConfig:serviceName"),

ServiceId = configuration.GetValue<string>("ServiceConfig:serviceId")

};



return serviceConfig;

}

}

After reading the configuration required to reach service discovery service, we can use it to register our service. The code below is implemented as a background task, that registers the service in Consul by overriding previous information about service if such existed. If the service is shutting down it deregisters service from Consul.

public class ServiceDiscoveryHostedService : IHostedService

{

private readonly IConsulClient _client;

private readonly ServiceConfig _config;

private string _registrationId; public ServiceDiscoveryHostedService(IConsulClient client, ServiceConfig config)

{

_client = client;

_config = config;

} public async Task StartAsync(CancellationToken cancellationToken)

{

_registrationId = $"{_config.ServiceName}-{_config.ServiceId}";

var registration = new AgentServiceRegistration {

ID = _registrationId,

Name = _config.ServiceName,

Address = _config.ServiceAddress.Host,

Port = _config.ServiceAddress.Port

}; await _client.Agent.ServiceDeregister(registration.ID, cancellationToken);

await _client.Agent.ServiceRegister(registration, cancellationToken);

} public async Task StopAsync(CancellationToken cancellationToken)

{

await _client.Agent.ServiceDeregister(_registrationId, cancellationToken);

}

}

Finally, we need to register our configuration and hosted service with Consul dependencies to dependency injection container. For that, we can create a simple extension method, that we can share within our services.

public static void RegisterConsulServices(this IServiceCollection services, ServiceConfig serviceConfig) {

if (serviceConfig == null)

{

throw new ArgumentNullException(nameof(serviceConfig));

}



var consulClient = CreateConsulClient(serviceConfig);

services.AddSingleton(serviceConfig);

services.AddSingleton<IHostedService, ServiceDiscoveryHostedService>();

services.AddSingleton<IConsulClient, ConsulClient>(p => consulClient);

}

After we have registered our services to the service discovery service we can start implementing API Gateway.

Creating API Gateway using Ocelot

Ocelot is an API Gateway based on the .NET Core framework and a rich set of features including:

Request Aggregation

WebSockets support

Rate Limiting

Load Balancing

Configuration / Administration REST API

QoS using Consul and Polly

Distributed Tracing

Authentication/Authorization

Ocelot requires to provide configuration file, that has a list of ReRoutes (configuration used to map upstream request) and Global Configuration (other configuration like QoS, Rate limiting, etc.). In the ocelot.json file provided below we can see that we are forwarding HTTP GET requests from /api/ordering and /api/logistics to internal services.

{

"ReRoutes": [

{

"DownstreamPathTemplate": "/api/{everything}",

"DownstreamScheme": "http",

"ServiceName": "orders",

"UpstreamPathTemplate": "/api/ordering/{everything}",

"UpstreamHttpMethod": [ "Get" ]

},

{

"DownstreamPathTemplate": "/api/{everything}",

"DownstreamScheme": "http",

"ServiceName": "deliveries",

"UpstreamPathTemplate": "/api/logistics/{everything}",

"UpstreamHttpMethod": [ "Get" ]

}

],

"GlobalConfiguration": {

"ServiceDiscoveryProvider": {

"Host": "consul",

"Port": 8500,

"Type": "Consul"

}

}

}

After we have defined our configuration we can start to implement API Gateway based on .NET Core and Ocelot in less than 30 lines. Below we can see the implementation of Ocelot API Gateway service, that uses our configuration file and Consul as a service registry.

public static void Main(string[] args)

{

new WebHostBuilder()

.UseKestrel()

.UseContentRoot(Directory.GetCurrentDirectory())

.ConfigureAppConfiguration((context, config) =>

{

config

.SetBasePath(context.HostingEnvironment.ContentRootPath)

.AddJsonFile("appsettings.json", true, true)

.AddJsonFile($"appsettings.{context.HostingEnvironment.EnvironmentName}.json", true, true)

.AddJsonFile("ocelot.json")

.AddEnvironmentVariables();

})

.ConfigureServices(s => {

s.AddOcelot().AddConsul();

})

.ConfigureLogging((hostingContext, logging) =>

{

logging.AddConsole();

})

.UseIISIntegration()

.Configure(app =>

{

app.UseOcelot().Wait();

})

.Build()

.Run();

}

Conclusions

By placing API Gateway between a client and our services/functions we can have a centralized component that will be responsible for API management, logging, authentication/authorization, rate limiting and load balancing and distributed tracing. However, we will introduce a component that can become a single point of failure to our system, so we need to deploy at least two replicas of it to have high availability and scale up depending on load.

Code used in this article can be at https://github.com/Skisas/ApiGateway-example

What’s next

In the following article, I’m gonna integrate HashiCorp Vault service to this system in order to have a centralized place to store and access secrets including configurations for services.