Globally-distributed Serverless Application in 100 Lines of Code. Infrastructure Included!

Posted on

Pulumi is excellent at connecting multiple cloud components into a cohesive application. In my previous post, I introduced the way to mix JavaScript or TypeScript serverless functions directly into the cloud infrastructure programs.

Today, I will build a serverless application with both the data store and the HTTP endpoint located close to end users to ensure prompt response time. The entire application runs on top of managed Azure services and is defined as a single Pulumi program in TypeScript.

Baseline

I’m going to build a URL shortener: a simple HTTP endpoint which accepts a shortcode in the URL and then redirects a user to the full URL associated with the given short code.

I start simple and make a non-distributed version of the application first. It consists of two main components: a Cosmos DB container to store URL mappings and two Azure Functions to handle HTTP requests:

The basic version of URL Shortener with Azure and Pulumi

Let’s define this infrastructure in a Pulumi program.

Cosmos DB Collection

Arguably, Cosmos DB is overkill in such a simple scenario: we just need a key-value store, so Table Storage would suffice. However, Cosmos DB comes handy at the multi-region setup later on.

Next to a standard resource group definition, I create a Cosmos DB account with location set to a single region and Session consistency level:

import * as pulumi from "@pulumi/pulumi";
import * as azure from "@pulumi/azure";
import { CosmosClient } from "@azure/cosmos";

const location = "westus"; // To be changed to multiple configurable locations

let resourceGroup = new azure.core.ResourceGroup("UrlShorterner", {
    location,
});

let cosmosdb = new azure.cosmosdb.Account("UrlStore", {
    resourceGroupName: resourceGroup.name,
    location,
    geoLocations: [{ location, failoverPriority: 0 }],
    offerType: "Standard",
    consistencyPolicy: {
        consistencyLevel: "Session",
        maxIntervalInSeconds: 5,
        maxStalenessPrefix: 100,
    },
});

Cosmos DB has a hierarchical structure of Accounts, Databases, and Containers. Let’s define a database and a SQL collection to store our URL mappings:

const database = new azure.cosmosdb.SqlDatabase("Database", {
    resourceGroupName: resourceGroup.name,
    accountName: account.name,
});

const collection = new azure.cosmosdb.SqlContainer("Urls", {
    resourceGroupName: resourceGroup.name,
    accountName: account.name,
    databaseName: database.name,
});

Function App

Serverless Azure Functions are going to handle the HTTP layer in my application. I use the technique of serverless functions as callbacks to define Azure Functions inline inside my Pulumi program:

const fn = new azure.appservice.HttpEventSubscription("GetUrl", {
    resourceGroup,
    location,
    route: "{key}",
    callback: async (_, request: azure.appservice.HttpRequest) => {
        const endpoint = cosmosdb.endpoint.get();
        const masterKey = cosmosdb.primaryMasterKey.get();

        const client = new CosmosClient(
            { endpoint, key, connectionPolicy: { preferredLocations: [location] } });
        const container = client.database(database.name.get()).container(collection.name.get());

        const key = request.params['key'];
        try {
            const response = await container.item(key.toString()).read();
            return {
                status: 301,
                headers: { "location": response.body.url },
                body: '',
            };
        } catch (e) {
            return { status: 404, body: 'Short URL not found' }


});

export const url = fn.url;

I supply the template {key} as route parameter so that the function accepts wildcard URLs and extract the wildcard value as a key parameter available inside the request object. Then, I use the received key to look up the full URL. The URL is returned to the client as a header of the 301 Redirect response. 404 Not Found is returned in case the requested document does not exist—the naughty Cosmos SDK throws an error in this scenario.

Note how the Cosmos DB parameters endpoint and primaryMasterKey are used directly inside the function. There is no need to manage the keys manually or explicitly put them into application settings: the generated keys are injected into the code at the time of deployment. In practice, a better idea is to store the key in a KeyVault and read it via application settings, but I’ll leave this for a separate article.

A function to add a URL to the database looks quite similar to the above:

const fn = new azure.appservice.HttpEventSubscription("AddUrl", {
    resourceGroup,
    methods: ["POST"],
    callback: async (_, request: azure.appservice.HttpRequest) => {
        const endpoint = cosmosdb.endpoint.get();
        const masterKey = cosmosdb.primaryMasterKey.get();

        const client = new CosmosClient(
            { endpoint, key, connectionPolicy: { preferredLocations: [primaryLocation] } });
        const container = client.database(database.name.get()).container(collection.name.get());

        await container.items.create(request.body);
        return { status: 200, body: 'Short URL saved' };

});
export const addEndpoint = fn.url;

The key differences are:

  • Using POST as the accepted HTTP method
  • Creating a new item in Cosmos DB
  • Using request.body as the payload to save

I might need to add some validation in a real application—skipped for now.

Trying It Out

Now, when a user navigates to an address like https://geturl-xyz.azurewebsites.net/api/URLKEY, they get redirected to the full URL associated with the key. The URL is not quite short yet, but we could make it so with a custom domain configuration.

Presumably, we expect our URL shortener to be popular around the world. Therefore, one key aspect is to make sure that the service responds fast and enables a smooth user experience. Let’s measure the response time of our first version from different spots around the world:

LocationResponse time
San Francisco133 ms
New York272 ms
London383 ms
Frankfurt420 ms
Tel-Aviv470 ms
Hong-Kong437 ms
Brisbane449 ms

The further we get from the West US region, the slower the responses become.

How can we do better?

Bring Compute and Data Closer to Users

It’s hard to beat the speed of light, so if we want to respond fast, we need to bring both code and data close to any target user.

Luckily, the most involved aspect of that—the data distribution—can be handled by Cosmos DB multi-region accounts. We need to take care of deploying the code in multiple locations and routing the traffic to the nearest one. Here is the plan:

The distributed version of URL Shortener with Azure and Pulumi

I don’t care too much about the latency of Add URL function, so I left it out of the picture.

Configuring the locations

The above picture shows three Azure regions, but since I’m using a general-purpose programming language, I can handle any number of locations in the same way. Actually, I put the list of target regions in Pulumi config file and read it at execution time:

const config = new pulumi.Config();
const locations = config.require("locations").split(',');
const primaryLocation = locations[0];

The order of regions defines the priority.

Multi-region Cosmos DB Account

Since locations is a simple array, I can map this array to produce the array of geoLocations to be set for my Cosmos DB:

let cosmosdb = new azure.cosmosdb.Account("url-cosmos", {
    location: primaryLocation,
    geoLocations: locations.map((location, failoverPriority) => ({
        location,
        failoverPriority
    })),
    /* ... other properties stay the same ... */

Right here, the full power of a programming language at my fingertips!

Multi-region Serverless App

Azure Traffic Manager is a DNS-based traffic load balancer that enables you to distribute traffic optimally to services across global Azure regions while providing high availability and responsiveness.

Sounds like what I need for my global application! Let’s define a Traffic Manager profile:

const profile = new azure.trafficmanager.Profile("UrlShortEndpoint", {
    resourceGroupName: resourceGroup.name,
    trafficRoutingMethod: 'Performance',
    dnsConfigs: [{
        relativeName: "shorturls",
        ttl: 60,
    }],
    monitorConfigs: [{
        protocol: 'HTTP',
        port: 80,
        path: '/api/ping',
    }]
});

I set the routing method to Performance:

Select Performance when you have endpoints in different geographic locations and you want end users to use the “closest” endpoint in terms of the lowest network latency.

Now, I need to create a Function App in each of the target regions. That’s easy to achieve with a for..of loop:

for (const location of locations) {
    const fn = new azure.appservice.HttpEventSubscription(`GetUrl-${location}`, {
        resourceGroup,
        location,
        route: "{key}",
        callback: /* ... the function stays the same ... */
    });
}

Finally, I set up an Endpoint for each Function App to bind them to the Traffic Manager. I do so in the same loop as above:

for (const location of locations) {
    const fn = new azure.appservice.HttpEventSubscription(`GetUrl-${location}`, {
        /* ... function definition as shown above ... */
    });

    const app = fn.functionApp;

    new azure.trafficmanager.Endpoint(`tme${location}`, {
        resourceGroupName: resourceGroup.name,
        profileName: profile.name,
        type: 'azureEndpoints',
        targetResourceId: app.id,
        target: app.defaultHostname,
        endpointLocation: app.location,
    });
}

I assign the properties of each Function App to the corresponding Endpoint with a simple object initializer expression.

Everything is in place. I can export the Traffic Manager endpoint:

export const endpoint = pulumi.interpolate `http://${profile.fqdn}/api/{key}`;

Time to re-run the latency tests.

Results

I deployed the distributed infrastructure to five Azure regions: West US, East US, West Europe, East Asia, and Australia East. The results are shown in the table below, next to the values from the initial run:

LocationSingle regionMultiple regions
San Francisco133 ms140 ms
New York272 ms152 ms
London383 ms150 ms
Frankfurt420 ms130 ms
Tel-Aviv470 ms307 ms
Hong-Kong437 ms149 ms
Brisbane449 ms529 ms

We got a much smoother distribution of response time. There’s no region close enough to Tel-Aviv, so its latency is still high-ish. I checked Brisbane, and the traffic was somehow directed to the East US region, so the latency hasn’t improved at all. For today, I guess I’ll stick to the hypothesis “Australian internet is slow” and advise to always test your target scenarios without trusting the providers blindly.

More importantly, I walked you through a scenario of developing an end-to-end application with Pulumi. I highlighted the power of leveraging familiar techniques coming from the TypeScript realm. You can find the full code in Pulumi examples.

Cloud brings superpowers to developer’s hands. You just need to use those efficiently.