DEV Community

Frédéric Barthelet for Kumo

Posted on

Serverless Framework ❤️ AWS CDK

There are plenty of articles out there comparing major Infrastructure as Code frameworks when it comes to building and deploying an AWS serverless stack. A serverless newcomer can easily be overwhelmed by the wide variety of solutions available: Serverless Framework, AWS CDK, Pulumi, Terraform, SST, Architect...
Rather than pitting those solutions against each other, this article focus on leveraging two of them, Serverless Framework and AWS CDK, for what they are best at and making them work together seamlessly.

This solution does not require any additional dependency, plugin or CLI tool. It works right out of the box, using native CDK API and Serverless Framework service definition properties.

TL;DR

The following service file definition provides a simple way to deploy an application relying on Lamba-centric resources as well as other type of resources, leveraging respectively Serverless Framework and CDK, all using a single CloudFormation deployment. This solution improves greatly the developer experience by:

  • leveraging the simple configuration format of the Serverless Framework
  • leveraging AWS CDK constructs rather than vanilla CloudFormation for resources outside of the scope of the Serverless Framework
  • using a single language to define the entire stack
// serverless.ts

import type { AWS } from '@serverless/typescript';
import { App, Stack } from '@aws-cdk/core';
import { AttributeType, Table } from '@aws-cdk/aws-dynamodb';

const app = new App();
const stack = new Stack(app);

const table = new Table(stack, 'MyDynamoDBTable', {
  partitionKey: { name: 'PK', type: AttributeType.STRING }
});

const serverlessConfiguration: AWS = {
  service: 'serverless-framework-loves-aws-cdk',
  provider: {
    name: 'aws',
    runtime: 'nodejs14.x',
    iam: {
      role: {
        statements: [
          {
            Effect: 'Allow',
            Action: [ 'dynamodb:PutItem' ],
            Resource: [ stack.resolve(table.tableArn) ]
          }
        ]
      }
    }
  },
  functions: {
    saveUser: {
      handler: 'saveUser.main',
      environment: {
        TABLE_NAME: stack.resolve(table.tableName)
      },
      events: [
        { http: 'POST /user' }
      ]
    }
  },
  package: { individually: true },
  resources: app.synth().getStackByName(stack.stackName).template
};

module.exports = serverlessConfiguration;
Enter fullscreen mode Exit fullscreen mode

How does it actually work?

Let's deep dive, step by step, in this service file, and understand how Serverless Framework and AWS CDK work together seamlessly at provisioning a state of the art serverless application.

Serverless Framework TypeScript service file

Since v1.72.0, the Serverless framework accepts serverless.ts as a valid service file in addition to the more commonly-known serverless.yml, serverless.json and serverless.js file formats. Using serverless.js or serverless.ts service definition is a requirement to implement this solution. Both those formats allow programmatic execution using Node.js in order to build the output service definition. In addition, you benefit from TypeScript definitions exported by @serverless/typescript package to help you build your Serverless Framework service definition properly.

// serverless.ts

import type { AWS } from '@serverless/typescript';

const serverlessConfiguration: AWS = {
  service: 'serverless-framework-loves-aws-cdk',
  provider: {
    name: 'aws',
    runtime: 'nodejs14.x'
  }
};

module.exports = serverlessConfiguration;
Enter fullscreen mode Exit fullscreen mode

AWS CDK pure resources

Within the same serverless.ts file, or in any other file, you can bootstrap your CDK application, creating a new App and Stack. You can then start to add resources not natively handled by the Serverless Framework, based on your app's requirements, referencing the newly created stack as the resource scope.

import { App, Stack } from '@aws-cdk/core';
import { AttributeType, Table } from '@aws-cdk/aws-dynamodb';

const app = new App();
const stack = new Stack(app);

const table = new Table(stack, 'MyDynamoDBTable', {
  partitionKey: { name: 'PK', type: AttributeType.STRING }
});
Enter fullscreen mode Exit fullscreen mode

Shipping AWS CDK resources within Serverless Framework generated CloudFormation

In order to bridge AWS CDK and Serverless Framework, you can use native features from both frameworks.

On one side, Serverless Framework provides an option to include custom CloudFormation, using the resources property from the service definition. This property is usually used to inject vanilla CloudFormation, but we'll leverage AWS CDK to avoid doing so.

On the other, AWS CDK provides an API to programmatically generate a Cloud Assembly using the synth method of any App instance. A Cloud Assembly instance is a CDK internal class used to represent a deployable cloud application. Each CDK App can contain multiple CDK Stack, but once again, the CDK provides an API to select a specific stack representation from a Cloud Assembly instance. Finally, each stack of a Cloud Assembly instance exposes the underlying CloudFormation template, as generated by AWS CDK CLI operations.

Using AWS CDK API to generate desired template, and injecting it within Serverless Framework service definition resources property does the trick bridging both frameworks.

const serverlessConfiguration: AWS = {
  // ...
  resources: app.synth().getStackByName(stack.stackName).template
};
Enter fullscreen mode Exit fullscreen mode

Adding references to AWS CDK resources within Serverless Framework service definition

There's no good bridging both frameworks if they can't interact with each other. Serverless Framework Lambda handlers usually need resource specific identifiers in order to interact with those resources.

Exporting CDK-generated DynamoDB table name to Serverless function

In order to insert new items within AWS CDK provisioned DynamoDB table, let's create a new function using Serverless Framework service definition. This function will implement AWS SDK @aws-sdk/lib-dynamodb to execute a PutItem command. This method requires the DynamoDB table name in order to insert the new item in the correct table. One way to pass the variable representing the DynamoDB table name is to use the environment property of the function definition.

const serverlessConfiguration: AWS = {
  // ...
  functions: {
    saveUser: {
      handler: 'saveUser.main',
      environment: {
        TABLE_NAME: 'Injecting DynamoDB table name here'
      },
      events: [
        { http: 'POST /user' }
      ]
    }
  },
};
Enter fullscreen mode Exit fullscreen mode

The actual value for the DynamoDB table, provisioned using AWS CDK, is not known before deployment. One could solve this using tableName props from CDK Table construct, however it is good practice NOT constraining its name value as conflict may occur if two tables share the same name within the same AWS account and region (which will happen if you deploy the same Serverless service twice with different stage value). This goes for many resources where unicity constraints are imposed within the same AWS account, or even globally (like S3 bucket name)!

In order to resolve at deployment the actual table name, one can use CloudFormation intrinsic functions. In the case of a DynamoDB table name, the Ref intrinsic functions will do the trick following DynamoDB CloudFormation documentation. With AWS CDK, there is no need to bother with intrinsic function syntax. Each construct exposes a set of property representing the resource return values, that will ultimately resolves to intrinsic functions under the hood. For DynamoDB Table construct, table.tableName resolves to the actual table name.

TABLE_NAME: table.tableName
Enter fullscreen mode Exit fullscreen mode

The above statement will however translate to a completely unexpected value in the generated CloudFormation template:

TABLE_NAME: ${Token[TOKEN.183]}
Enter fullscreen mode Exit fullscreen mode

AWS CDK actually resolves some value at a later stage of the app's lifecycle and uses tokens as placeholder in the meantime. In order to get the underlying value behind this CDK generated token, we can use resolve method from the CDK Stack:

TABLE_NAME: stack.resolve(table.tableName)
Enter fullscreen mode Exit fullscreen mode

This completes proper setup of the Serverless Framework function in order to perform operations with the CDK generated DynamoDB table.

const serverlessConfiguration: AWS = {
  // ...
  functions: {
    saveUser: {
      handler: 'saveUser.main',
      environment: {
        TABLE_NAME: stack.resolve(table.tableName)
      },
      events: [
        { http: 'POST /user' }
      ]
    }
  },
};
Enter fullscreen mode Exit fullscreen mode

Granting Serverless function permissions to interact with CDK-generated DynamoDB table

Similarly, Serverless Framework service definition will require additional configuration to ensure the saveUser function is actually allowed to insert new items in the DynamoDB table.

This is usually done using the provider.iam.role.statements configuration, adding a new IAM Policy statement which allows DynamoDB PutItem operation.

const serverlessConfiguration: AWS = {
  // ...
  provider: {
    iam: {
      role: {
        statements: [
          {
            Effect: 'Allow',
            Action: [ 'dynamodb:PutItem' ],
            Resource: *,
          }
        ]
      }
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

While opening up IAM Policy statement effect to all DynamoDB table is tempting and very easy to implement, it is not recommended, is considered a way too broad permission and may raise security concerns.

You can however overcome this issue following the same principal than before: CDK Table construct exposes a tableArn property than can be used to narrow down resources with whom PutItem is actually permitted.

Resource: stack.resolve(table.tableArn)
Enter fullscreen mode Exit fullscreen mode

Limitations

Using synth API from AWS CDK to access the underlying Cloud Assembly instance and its CloudFormation template has its limits: no asset can be uploaded on AWS as part of the normal deployment cycle of AWS CDK. AWS CDK relies on a bootstrapping separate stack, that needs to be provisioned independently, whenever one of the resource leverages CDK Assets. For example, deploying Function constructs as part of your CDK stack requires file and/or Docker images to be uploaded to your AWS account, and therefore requires the bootstrapping stack to perform such upload.

Using AWS CDK to complement the Serverless Framework often mean that you won't actually ever rely on Function construct, Lambda provisioning being Serverless Framework scope, but it's worth mentioning as a limitation in case another resource type, not handled by Serverless Framework, also relies on assets being uploaded.

Conclusion

This code snippet and detailed explanations is of course only demonstration of what can be achieved using both Serverless Framework and AWS CDK to deploy a serverless application, using best of both worlds to improve consequently the overall developer experience.

I strongly advise to structure and split your CDK resources in a dedicated project directory, in order to keep serverless.ts as minimal as possible and to implement CDK recommendations in terms of construct architecture.

Discussion (0)