Serverless Upgrade Path in AWS (Part 2)

Serverless Upgrade Path in AWS (Part 2)

This is part one, of a two part series on serverless architectures.

Serverless Upgrade Path in AWS (Part 1)

Previously I introduced two serverless architectures. The first was a basic non-serverless architecture for our example application. This architecture worked well, but it had a very high compute delta. A key metric we are measuring how serverless an architecture is by compute delta.

The second architecture was ambitiously attempted to reduce compute delta without any changes to our application design. This was done by switching EC2 to ECS Fargate, and switch RDS with Aurora Serverless. While this was effective in reducing the compute delta, Aurora Serverless was not cost effective for our example application.

Let’s correct our original misguided approach with an incremental pattern with reduced risk and business disruption. Allow me to introduce Strangler Fig.

Table of contents

Strangler Fig

Monolithic applications are created to provide all of their functionality within a single process. This pattern can reduce architectural complexity and is a stronghold of legacy application development.

With the advent of cloud computing and infrastructure-as-code(IaC), creating more complex architectures such as microservices suddenly became much easier to do. As corporations migrated to the cloud and re-architected their applications, an approach called Stranger Fig was introduced by Martin Fowler.

The Strangler Fig pattern involves gradually replacing specific functionalities with new applications and services, you can effectively strangle the old system and pave the way for its decommissioning. As you replace features from the legacy system, the new system takes over, eventually replacing all the old system’s functionalities. This incremental approach ensures a smooth transition and minimizes disruption to the business.

Stage 3 - Reconstruction

Today we’re going to implement the Strangler Fig approach to upgrade our Stage 2 architecture to a serverless microservices architecture to achieve the lowest compute delta possible.

We’ve chosen to build new functionality as the first step in our Strangler Fig to validate the serverless microservices architecture. Following that, the remainder of the application will be migrated over to serverless, completing the Strangler Fig migration.

Architecture

Stage 3

Stage 3

Stage 3

The approach we’re taking here is slicing off chunks of the API and replacing them with Lambda and DynamoDB as the database. This type of approach is made possible by API Gateway.

AWS API Gateway is able to forward specific requests to different resources in AWS. In our case /weather will be handled by Lambda, with the remainder of the API being handled by the same ECS resource as in Stage 2.

Bit by bit components of the API can be migrated to AWS Lambda. Named API resource paths will override the proxy and enable a mixture of Lambda and ECS as the backend.

Both DynamoDB and Lambda represent the golden standard of extremely low compute delta. Our goal is to migrate all API functionality over to these services.

Compute Delta (Lower is better)

  • Compute: 3/10
  • Database: 3/10

Press here to skip to stage 3 discussion

Stage 3: Root URL

Stage 3: Root URL

Stage 3: Root URL

Stage 3: Time API

Stage 3: Time API

Stage 3: Time API

Stage 3: Weather API

Stage 3: Weather API

Stage 3: Weather API

Code

Stage 3 Construct

export class Stage3 extends Construct {
  constructor(scope: Construct, id: string, props: Stage3Props) {
    super(scope, id);

    const base = new BaseStack(this, 'BaseStack', {
        env: props.env,
        cidr: '10.102.0.0/16',
    });

    const route53 = new Route53Stack(this, 'Route53Stack', {
      env: props.env,
      subdomain: 'stage3'
    });

    const alb = new ALBStack(this, 'ALBStack', {
        env: props.env,
        baseStack: base,
        targetType: elbv2.TargetType.IP,
        route53Stack: route53,
        enableHttps: false,
        enableHttpRedirect: false,
        createDNSRecord: false,

    });

    const getWeather = new LambdaStack(this, 'GetWeatherLambdaStack', {
        env: props.env,
        lambdaDir: '../../../lambda/get_weather',
    });

    new APIStack(this, 'APIStack', {
      env: props.env,
      baseStack: base,
      albStack: alb,
      route53Stack: route53,
      createDNSRecord: true,
      lambdaIntegration: [
        {
          stack: getWeather,
          path: '/weather',
          methods: [ apigw.HttpMethod.GET ]
        }
      ],
    });

    const ecs = new ECSStack(this, 'ECSStack', {
      env: props.env,
      baseStack: base,
      albStack: alb,
      environmentVariables: {'TEMPLATE': 'index_v2.html'}
    });

    new DynamoDBStack(this, 'DynamoDBStack', {
      env: props.env,
      tables: [
        {
          tableName: 'Weather',
          partitionKey: 'City',
          sortKey: 'Date',
          lambdaStacks: [getWeather]
        }
      ]
    });

    new AuroraStack(this, 'AuroraStack', {
        env: props.env,
        baseStack: base,
        ecsStack: ecs,
    });
  }
}

API Gateway

export class APIStack extends Stack {
  constructor(scope: Construct, id: string, props: APIStackProps) {
    super(scope, id, props);

    const httpApi = new apigw.HttpApi(this, 'HttpApi');

    props.lambdaIntegration.forEach((lambda) => {
      const integration = new apigw_integration.HttpLambdaIntegration('LambdaIntegration', lambda.stack.lambda);

      httpApi.addRoutes({
        path: lambda.path,
        methods: lambda.methods,
        integration: integration,
      });
    });

    if (props.albStack != undefined) {
      httpApi.addRoutes({
        path: '/{proxy+}',
        methods: [ apigw.HttpMethod.ANY ],
        integration: new apigw_integration.HttpAlbIntegration(
            'GithubReposAppIntegration',
            props.albStack.alb.listeners[0],
        ),
      });
    }

    if (props.createDNSRecord) {
      const domainName = new apigw.DomainName(this, 'DomainName', {
        domainName:  props.route53Stack.subdomain + '.jeremyritchie.com',
        certificate:  props.route53Stack.cert,
      });
      new apigw.ApiMapping(this, 'MyApiMapping', {
        api: httpApi,
        domainName: domainName,
        stage: httpApi.defaultStage,
      });
      new route53.ARecord(this, 'AliasRecord', {
        zone: props.route53Stack.hostedzone,
        target: route53.RecordTarget.fromAlias(new route53_targets.ApiGatewayv2DomainProperties(domainName.regionalDomainName, domainName.regionalHostedZoneId)),
        recordName: props.route53Stack.subdomain,
      });
    }
  }
}

API Gateway is fantastic for Strangler Fig pattern migrations due to it’s ability to fully customize API paths to different AWS resources.

In our case we are extending the existing ECS based API with /weather and integrating that request with a Lambda function. All other requests to the API will be handled by the default integration which is the /{proxy+} path that forwards to the ALB.

This stack will be reused for Stage 4.

Lambda

export class LambdaStack extends Stack {
  lambda: _lambda.Function;
  constructor(scope: Construct, id: string, props: LambdaStackProps) {
    super(scope, id, props);

    this.lambda = new _lambda.DockerImageFunction(this, 'Lambda', {
        code: _lambda.DockerImageCode.fromImageAsset(path.join(__dirname, props.lambdaDir)),
        architecture: _lambda.Architecture.ARM_64,
        timeout: Duration.seconds(30),
    });

  }
}

This is a simple Lambda stack that is created for each Lambda function. I’ve containerized all the lambda functions for simplicity.

This stack will be reused for Stage 4.

DynamoDB

export class DynamoDBStack extends Stack {
  constructor(scope: Construct, id: string, props: DynamoDBStackProps) {
    super(scope, id, props);

    props.tables.forEach(table => {
      const dbTable = new dynamodb.Table(this, table.tableName, {
        partitionKey: { name: table.partitionKey, type: dynamodb.AttributeType.STRING },
        sortKey: table.sortKey ? { name: table.sortKey, type: dynamodb.AttributeType.STRING } : undefined,
        tableName: table.tableName,
      });
      table.lambdaStacks.forEach(lambdaStack => {
        dbTable.grantReadWriteData(lambdaStack.lambda);
      });
    });
  }
}

A basic, example DyanmoDB table stack. We create the tables in a loop, attaching permissions for each table to it’s own Lambda.

This is just an example of how different microservices might have their own key-value database for their own functionality.

Discussion

Compute

Using the Strangler Fig pattern we’ve built new functionality around the existing application. Lambda was the compute platform of choice due to it’s per request execution and pricing model. A lambda function executes the code required for each API request, individually for every request. This means the compute delta for AWS Lambda is zero, as there is zero wasted compute capacity.

The remainder of the monolithic API application is still running on ECS, and we will continue Strangler Fig in Stage 4 to upgrade it.

Database

DynamoDB is a common approach with Lambda based microservices. DynamoDB is a serverless, fast, NoSQL database that fit’s perfectly into our architecture.

Migrating from a Relational to a NoSQL is not a small task, and requires good understanding of your data requirements, data consistency and a proper data model. While that’s all very daunting, the pay-off can be exceptional!

Stage 4 - Acceptance

After continuing the Strangler Fig pattern beyond Stage 3, we have successfully migrated all existing functionality off the monolith and into Lambda & DynamoDB.

Architecture

Stage 4

Stage 4

Stage 4

The all API paths are now handled via Lambda, with each API resource having it’s own DynamoDB table.

Compute Delta (Lower is better)

  • Compute: 0/10 (Perfection?!)
  • Database: 1/10

Press here to skip to the conclusion

Stage 4: Root URL

Stage 4: Root URL

Stage 4: Root URL

Stage 4: Time API

Stage 4: Time API

Stage 4: Time API

Stage 4: Weather API

Stage 4: Weather API

Stage 4: Weather API

Code

Stage 4 Construct

export class Stage4 extends Construct {
  constructor(scope: Construct, id: string, props: Stage4Props) {
    super(scope, id);

    const base = new BaseStack(this, 'BaseStack', {
        env: props.env,
        cidr: '10.103.0.0/16',
    });

    const route53 = new Route53Stack(this, 'Route53Stack', {
      env: props.env,
      subdomain: 'stage4'
    });

    const getWeather = new LambdaStack(this, 'GetWeatherLambdaStack', {
        env: props.env,
        lambdaDir: '../../../lambda/get_weather',
    });

    const getTime = new LambdaStack(this, 'GetTimeLambdaStack', {
      env: props.env,
      lambdaDir: '../../../lambda/get_time',
    });

    const getRoot = new LambdaStack(this, 'GetRootLambdaStack', {
      env: props.env,
      lambdaDir: '../../../lambda/get_root',
    });

    new APIStack(this, 'APIStack', {
      env: props.env,
      baseStack: base,
      lambdaIntegration: [
        {
          stack: getWeather,
          path: '/weather',
          methods: [ apigw.HttpMethod.GET ]
        },
        {
          stack: getTime,
          path: '/time',
          methods: [ apigw.HttpMethod.GET ]
        },
        {
          stack: getRoot,
          path: '/',
          methods: [ apigw.HttpMethod.GET ]
        }
      ],
      route53Stack: route53,
      createDNSRecord: true,
    });

    new DynamoDBStack(this, 'DynamoDBStack', {
      env: props.env,
      tables: [
        {
          tableName: 'Weather',
          partitionKey: 'City',
          sortKey: 'Date',
          lambdaStacks: [getWeather]
        },
        {
          tableName: 'Time',
          partitionKey: 'City',
          sortKey: 'Date',
          lambdaStacks: [getTime]
        }
      ]
    });
  }
}

Conclusion

We’ve taken our monolithic application on quite the journey through this two-part series. Starting from a traditional architecture with high compute delta, we’ve incrementally transformed it into a fully serverless solution.

The Strangler Fig pattern proved invaluable, allowing us to gradually introduce serverless components without disrupting our existing application. By leveraging AWS Lambda, API Gateway, and DynamoDB, we’ve achieved a near-perfect compute delta score.

Key takeaways:

  • Incremental migration reduces risk and allows for validation at each stage
  • API Gateway is a powerful tool for implementing Strangler Fig
  • Serverless architectures can dramatically reduce compute delta
  • Database choices significantly impact overall architecture efficiency

Remember, the perfect architecture depends on your specific use case. While we’ve reached serverless nirvana here, your optimal solution might be somewhere along this upgrade path. Get in touch with the experts to explore what is best for you!

As cloud services continue to evolve, staying informed about serverless patterns will be crucial for building efficient, scalable applications. Keep exploring, and may your compute delta always trend towards zero!

Full source code available here

comments powered by Disqus