Proxying Azure Container Apps With Yarp
In an Azure Container Apps cloud solution, implementing a reverse proxy serves as a crucial component for several reasons. Firstly, a reverse proxy enhances security by acting as a gateway between external clients and backend services, effectively shielding the underlying containerized applications from direct exposure to the internet. Overall, incorporating a reverse proxy in the Azure Container Apps environment not only enhances security but also contributes to scalability, reliability, and streamlined management of containerized applications. This post explains how to use YARP as a reverse proxy in your container apps environment.

In an Azure Container Apps cloud solution, implementing a reverse proxy serves as a crucial component for several reasons. Firstly, a reverse proxy enhances security by acting as a gateway between external clients and backend services, effectively shielding the underlying containerized applications from direct exposure to the internet. This additional layer of security helps prevent unauthorized access and potential attacks and mitigates security vulnerabilities. Secondly, a reverse proxy facilitates load balancing and traffic distribution across multiple container instances, optimizing resource utilization and ensuring high availability and performance. Moreover, it simplifies the management of SSL/TLS certificates, handling encryption and decryption centrally, which is essential for securing data in transit. Overall, incorporating a reverse proxy in the Azure Container Apps environment not only enhances security but also contributes to scalability, reliability, and streamlined management of containerized applications.

Container apps & Microservices

Our industry is currently somewhat moving away from the microservices architecture and for good reason. A good amount of solutions don’t actually benefit from this architecture, but do have had the struggle to implement them and do all the messaging and stuff. Despite all that, there are still many software systems still running taking advantage of this architecture. And, there are real use cases for software systems moving towards microservices. As written in one of my previous posts I believe Modular Monoliths are the way to go for greenfield projects, simply because they are flexible enough to break apart into different systems, while not requiring all the plumbing from the get-go.

And then, if you move to microservices, and you want to host these services in Azure, then Azure Container Apps may be a good fit for you. Azure Container Apps runs Kubernetes under the hood but has this nice abstraction layer on top that allows you to configure the services easier, compared to all the knobs and wheels you need to dial in Kubernetes (k8s).

The online store

Microservices in Azure Container Apps

Let’s assume that you have created an online store using the microservices architecture. Ideally, each Microservice has its own storage mechanism. Yes, you could go for a single storage mechanism, but…

  • Autonomy - Each microservice is designed to operate independently
  • Scalability - Different services may have different scalability requirements, and so does the storage for that service
  • Resilience and Fault Isolation - Microservices often run in distributed environments, and failures can occur at various levels. If all services share a common storage mechanism, a failure in that storage system could potentially impact all services simultaneously
  • Technology Diversity - Different services might have varying data requirements and characteristics. For example, one service might benefit from a relational database, while another might be better suited to a NoSQL solution
  • Ease of Maintenance and Evolution - Services evolve independently over time, and their data models may change. With separate storage mechanisms, the impact of such changes is limited to the service in question

Anyways, in this diagram, each and every service accepts its own external internet traffic and should thus take action to protect them. Also, these services are not designed to do rate limiting, throttling, and load balancing (and they should not).

Let’s get to the point

Microservices in Azure Container Apps with Reverse Proxy OK, so let’s change the architecture a little bit so that all external internet traffic flows through a single service, the reverse proxy. Now, there is only one entity responsible for accepting internet traffic and this service is designed to distribute requests to downstream services. And this comes with some advantages:

  • Simplified Client Access - Clients only interact with the reverse proxy as a single entry point, simplifying the client-side configuration and making it easier to adapt to changes in the microservices architecture.
  • Load Balancing - A reverse proxy can distribute incoming client requests across multiple instances of a microservice to achieve load balancing.
  • Service Discovery - Microservices often dynamically scale up or down based on demand. A reverse proxy can integrate with a service discovery mechanism to automatically detect and route requests to available instances of a microservice.
  • Security - A reverse proxy can enhance security by serving as a gateway between external clients and the microservices. It can handle tasks such as SSL termination, authentication, and authorization, consolidating security concerns in one component.
  • Caching - A reverse proxy can implement caching mechanisms to store and serve frequently requested data. This helps reduce the load on microservices and improves response times for clients.
  • Request Transformation and Aggregation - The reverse proxy can transform requests and responses, performing tasks such as protocol translation, data format conversion, or content compression. It can also aggregate data from multiple microservices into a single response, reducing the number of client-server round trips and optimizing communication.

Reverse proxy technologies

There are multiple reverse proxy implementations that you can take advantage of. When you work with Azure, then API Management may sound familiar. Although this service is very feature-rich, it is on the basis just a reverse proxy. But just a very sophisticated one. Another familiar kid on the block is NGINX. Now I recently played with YARP. An abbreviation of Yet Another Reverse Proxy. And to be honest, I love it. You can run the proxy within an ASP.NET Core project and thus in a container in your Container Apps Environment. Compared to API Management, this will save you a lot of money, because the only thing you pay for is running the container.

Configuration

I created an Azure App Configuration instance to share configuration between services. When deploying the service, I store the name of the service in the configuration. This is a bicep snippet that stores my service name:

resource apiContainerApp 'Microsoft.App/containerApps@2022-03-01' = {
  name: '${defaultResourceName}-aca'
  location: location
  identity: {
    type: 'SystemAssigned'
  }
  ...
}

module serviceNameConfigurationValue 'configuration-value.bicep' = {
  name: 'serviceNameConfigurationValue'
  scope: resourceGroup(integrationEnvironment.resourceGroup)
  params: {
    appConfigurationName: integrationEnvironment.appConfiguration
    settingName: 'Services:UsersService'
    settingValue: apiContainerApp.name
  }
}

The resource creates a Container App in a central, already existing Container Apps Environment. Then I add a configuration value to the (also already existing) App Configuration instance. The value I store is the name of the Container App. So in this case the name of the Users Service Container App.

I did this for all the services in my environment, so there is one single configuration object called Services, that contains all the names of the Container Apps that host that service.

YARP

If you want to use YARP, you create a new ASP.NET Core project. I created an ASP.NET Core Web API project, that works fine. Next is to install YARP using NuGet. Search for a package called Yarp.ReverseProxy.

Before you can run YARP, you need to configure it. There are several ways to configure YARP. One is to use the App Configuration in appsettings.json, but for this example, I configure it using code. You configure YARP with a Proxy Configuration Provider interface called IProxyConfigProvider.

In the basis, there are two configuration entities, one is a cluster and one is a route. The cluster is the configuration of the downstream (micro)service you want to target with YARP. The route is the route to expose to the public internet.

You complete the configuration of YARP by configuring arrays of clusters and routes.

Cluster configuration example

A cluster configuration could look like this:

var clusterConfigs = new[]
{
    new ClusterConfig
    {
        ClusterId = "usersCluster",
        LoadBalancingPolicy = LoadBalancingPolicies.RoundRobin,
        Destinations = new Dictionary<string, DestinationConfig>
        {
            { "default", new DestinationConfig
            {
                Address = $"http://{configuration.UsersService}",
                Health =  $"http://{configuration.UsersService}/health",
            } }
        }
    },
    new ClusterConfig
    {
        ClusterId = "catalogCluster",
        LoadBalancingPolicy = LoadBalancingPolicies.RoundRobin,
        Destinations = new Dictionary<string, DestinationConfig>
        {
            { "default", new DestinationConfig
            {
                Address = $"http://{configuration.CatalogService}",
                Health =  $"http://{configuration.CatalogService}/health",
            } }
        }
    }
};

The cluster configuration above contains two clusters. One points to the underlying users service, the other to the catalog service. Note that the hostnames that the clusters target, come from configuration. These are the values stored in App Configuration and match the name of the corresponding Azure Container App instance.

Routes configuration example

Then the routes array would look like this:

var routeConfigs = new[] {
    new RouteConfig
    {
        RouteId = "usersRoute",
        ClusterId = "usersCluster",
        Match = new RouteMatch
        {
            Path = "/api/users/{**catch-all}"
        }
    },
    new RouteConfig
    {
        RouteId = "catalogRoute",
        ClusterId = "catalogCluster",
        Match = new RouteMatch
        {
            Path = "/api/catalog/{**catch-all}"
        }
    }
};

One of the most important parts is the RouteMatch here. This is where to configure what route must match for it to forward the call. In this case, the matches are pretty straightforward. Forward every request that starts with /api/users to the usersCluster and /api/catalog to the catalogCluster. But although this configuration is somewhat simple, you can make the RouteMatch as very specific.

Service Configuration

My Complete IProxyConfigProvider implementation looks like so:

public class CustomProxyConfigProvider : IProxyConfigProvider
{
    //private readonly ServicesConfiguration _configuration;

    public CustomProxyConfigProvider(IOptions<ServicesConfiguration> options,
        ILogger<CustomProxyConfigProvider> logger)
    {
        var configuration = options.Value;
        logger.LogInformation("Creating proxy config provider with configuration: {@configuration}", configuration);

        var routeConfigs = new[] {
            // Array configured as described above
        };

        var clusterConfigs = new[] {
            // Array configured as described above
        };

        _config = new CustomMemoryConfig(routeConfigs, clusterConfigs);
    }

    private CustomMemoryConfig _config;

    public IProxyConfig GetConfig() => _config;
}

As you can see, it returns an object called CustomMemoryConfig. I named it this way because it is an in-memory configuration. The class implements an interface called IProxyConfig and looks like so:

internal class CustomMemoryConfig : IProxyConfig
{
    private readonly CancellationTokenSource _cts = new CancellationTokenSource();

    public CustomMemoryConfig(IReadOnlyList<RouteConfig> routes, IReadOnlyList<ClusterConfig> clusters)
    {
        Routes = routes;
        Clusters = clusters;
        ChangeToken = new CancellationChangeToken(_cts.Token);
    }

    public IReadOnlyList<RouteConfig> Routes { get; }

    public IReadOnlyList<ClusterConfig> Clusters { get; }

    public IChangeToken ChangeToken { get; }

    internal void SignalChange()
    {
        _cts.Cancel();
    }
}

All is good now, the service is configured. The only thing we need to do now is to hook YARP up in the startup of the application. So I adjusted Program.cs a little so it looks like so:

using Wam.Core.ExtensionMethods;
using Wam.Core.Identity;
using Wam.Proxy;
using Wam.Proxy.ExtensionMethods;
using Yarp.ReverseProxy.Configuration;

var corsPolicyName = "DefaultCors";
var builder = WebApplication.CreateBuilder(args);

var azureCredential = CloudIdentity.GetCloudIdentity();
try
{
    builder.Configuration.AddAzureAppConfiguration(options =>
    {
        var appConfigurationUrl = builder.Configuration.GetRequiredValue("AzureAppConfiguration");
        options.Connect(new Uri(appConfigurationUrl), azureCredential)
            .UseFeatureFlags();
    });
}
catch (Exception ex)
{
    throw new Exception("Failed to configure the service, Azure App Configuration failed", ex);
}

builder.Services.AddHealthChecks();
builder.Services.AddApplicationInsightsTelemetry();
builder.Services.AddOptions<ServicesConfiguration>()
.Bind(configuration.GetSection(ServicesConfiguration.SectionName));
builder.Services
    .AddSingleton<IProxyConfigProvider, CustomProxyConfigProvider>()
    .AddReverseProxy();

// Add services to the container.
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHealthChecks("/health");
app.UseRouting();
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapReverseProxy();
app.Run();

So first I get a CloudIdentity which is a custom class that basically returns a ManagedIdentity in order to connect with the Azure App Configuration service. Then I start adding services to the Service Collection container to configure Dependency Injection. One important part here is the builder.Services.AddOptions<>(). This is where I inject the Services object configuration using the Options Pattern.

The second important part is to configure our IProxyConfigProvider implementation:

builder.Services
    .AddSingleton<IProxyConfigProvider, CustomProxyConfigProvider>()
    .AddReverseProxy();

At the end of the startup procedure, app.MapReverseProxy() to complete the YARP configuration.

Conclusion

In conclusion, integrating YARP (Yet Another Reverse Proxy) into a microservices environment provides a highly advantageous solution. YARP’s load balancing capabilities ensure efficient resource distribution, preventing bottlenecks and adapting dynamically to varying demands through seamless service discovery integration. Notably, YARP excels in enhancing security by serving as a centralized gateway for authentication and authorization, consolidating protective measures, and shielding microservices from direct exposure to the internet. YARP’s flexibility, customization options, and simplified client access further establish it as a valuable component for organizations seeking a feature-rich and scalable reverse proxy solution in their microservices architecture.


Last modified on 2024-01-18

Hi, my name is Eduard Keilholz. I'm a Microsoft developer working at 4DotNet in The Netherlands. I like to speak at conferences about all and nothing, mostly Azure (or other cloud) related topics.