How to deploy a SAM serverless application

In this guide we will demonstrate how to use the SAM step to deploy a SAM serverless application.

Introduction

AWS SAM is an extension of CloudFormation and is a popular framework for building serverless applications. Besides making CloudFormation easier to write, it also helps with building our code and punching it to S3.

Attini provides a dedicated step for delivering SAM applications, which we will demonstrate how to use in this guide.

How to use

In order to use the “AttiniSam” step, you should have a SAM app in your project. You then need to add the step to your deployment plan. The step has more properties but a simple configuration could look something like this:

- Name: DeploySamProject
  Type: AttiniSam
  Properties:
    Project:
      Path: /hello-world-app
    StackName: my-lambda-stack

The Project section of the steps properties contains the configuration for your SAM project. Because SAM is an extension of CloudFormation it also needs a stack name. This also means that most features from the AttiniCfn step are supported, such as configuration files. However, cross-account and cross-region deployment are not supported, this is because SAM requires the S3 code source to be located in the same region as the app.

When using Attini to deploy SAM apps, the Attini artifact store S3 bucket is used to store the code. This means that all the code will be subject to Attini’s life cycle. This is a good thing because SAM is known to create a lot of zombie data over time.

How it works

The Attini CLI will detect if a deployment plan contains an AttiniSam step. If the SAM app is not already built (sam build) the Attini CLI will build it when packaging the distribution. If you want to build the app yourself you are free to do so, possibly via the prePackage commands in the Attini configuration file.

At the beginning of the deployment plan execution, Attini will start a container to perform the sam package command from within the AWS account. This will make the code available on S3 and generate a CloudFormation template that correctly references the uploaded code. When you run a deployment plan containing a SAM step you will see that Attini has added a step called “AttiniSamPackage” at the beginning of your deployment plan. This step will perform the packaging of all SAM apps in your deployment plan. Packaging the SAM app is only necessary the first time you deploy a new version of a distribution, therefore Attini will add a choice step at the beginning of the deployment plan to check if it should perform the package step again.

image of deployment plan result

After the app is packaged Attini will treat the SAM step like a normal “AttiniCfn” step, meaning it will simply deploy the CloudFormation stack with any configuration provided to the step.

Demo

For this demo we will first create and deploy a distribution containing a single SAM step. We will then add another step to the deployment plan. The new step will deploy a DynamoDB table that our serverless app will use.

Before you begin, make sure to have the SAM CLI installed.

We can use the Attini CLI to create the example distribution for us.

mkdir attini-sam-demo
cd attini-sam-demo
attini init-project sam-project

The project contains a folder containing a “hello world” SAM app. This is a slightly updated version of the “hello world” app generated by the SAM CLI:s init command. The app is written in Python, but you can use any supported language. The deployment plan should look like this:

  SamAppDeploymentPlan:
    Type: Attini::Deploy::DeploymentPlan
    Properties:
      DeploymentPlan:
        - Name: DeploySamProject
          Type: AttiniSam
          Properties:
            Project:
              Path: /hello-world-app
            StackName: !Sub ${AttiniEnvironmentName}-sam-lambda

The “Project.Path” property tells the step where the SAM project is located and the “StackName” property contains the name of the stack. The stack name must be unique within the AWS account and region.

Before we start tinkering with it, let’s deploy it and see that it works. Run the deploy run commands:

attini environment create dev
attini deploy run .
image of deployment plan result

It deployed without any problems and we can see the result in the terminal. If we scroll in the terminal a bit we can see that the Attini CLI packaged the SAM app for us just after the prePackage commands were executed. If we want to take control over how to SAM app is packaged we can do it via the prePackage commands in the Attini configuration file. For example, if we wanted to add the --use-container option to the build command, we could update the attini-config.yaml file to look like this:

distributionName: sam-project
initDeployConfig:
  template: deployment-plan.yaml
  stackName: ${environment}-${distributionName}-deployment-plan

package:
  prePackage:
    commands:
      - attini configure set-dist-id --random
      - cd hello-world-app
      - sam build --use-container

Don’t use the --use-container option together with the Attini CLIs --container-build option because it will create a container in container scenario 🪆. If you get an “access denied” error when pulling from the public ecr repository, you probably have old docker credentials. If so, try running docker logout public.ecr.aws and then try again.

ATM our SAM app is a bit pointless, so let’s add a DynamoDB table to make it a bit more exciting. We could add the table directly in the SAM apps template.yaml file. However, this creates needlessly tight coupling and scales poorly and is considered bad practice. Better to create a new template containing the DynamoDB table and then pass its name to the SAM app via the deployment plans payload.

Let’s create a new CloudFormation template called “database.yaml” for our DynamoDB table.

AWSTemplateFormatVersion: '2010-09-09'
Description: >
    Contains a simple database

Resources:
  Database:
    Type: AWS::DynamoDB::Table
    Properties:
      AttributeDefinitions:
        - AttributeName: value
          AttributeType: S
      BillingMode: PAY_PER_REQUEST
      KeySchema:
        - AttributeName: value
          KeyType: HASH
Outputs:
  TableName:
    Description: "Database table name"
    Value: !Ref Database

Notice that the template outputs the table name. We then need to update the deployment plan to deploy the table before we deploy the SAM app. We also need to pass the table name to the SAM apps CloudFormation stack as a parameter.

  SamAppDeploymentPlan:
    Type: Attini::Deploy::DeploymentPlan
    Properties:
      DeploymentPlan:
        - Name: DeployDatabase
          Type: AttiniCfn
          Properties:
            StackName: !Sub ${AttiniEnvironmentName}-lambda-database
            Template: /database.yaml
        - Name: DeploySamProject
          Type: AttiniSam
          Properties:
            Project:
              Path: /hello-world-app
            StackName: !Sub ${AttiniEnvironmentName}-sam-lambda
            Parameters:
              TableName.$: $.output.DeployDatabase.TableName

We also need to update the SAM app template. It needs to read the parameter and set it as an environment variable for our lambda, and give the lambda permission to update the table. Let’s also rename the function resource as “HelloWorldFunction” does not really make sense anymore.

Parameters:
  TableName:
    Type: String

Resources:
  SaveValueFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: hello_world/
      Handler: app.lambda_handler
      Runtime: python3.9
      Architectures:
        - x86_64
      FunctionUrlConfig:
        AuthType: NONE
      Environment:
        Variables:
          TABLE_NAME: !Ref TableName
      Policies:
        - DynamoDBCrudPolicy:
            TableName: !Ref TableName

Finally, lets update our SAM app to do something with the table. In the example below it will simply read the value of a query parameter called “save_me”, if it is present. It will then save the value to DynamoDB and then return all saved values.


import json
import os
import boto3

dynamodb_client = boto3.client("dynamodb")


def lambda_handler(event, context):

    if "queryStringParameters" in event and "save_me" in event["queryStringParameters"]:
        save_data(event["queryStringParameters"]["save_me"])

    return {
        "statusCode": 200,
        "body": json.dumps({
            "saved_vales": get_items(),
        }),
    }


def get_items():
    return dynamodb_client.scan(
        TableName=os.environ["TABLE_NAME"]
    )["Items"]


def save_data(data):
    dynamodb_client.put_item(
        TableName=os.environ["TABLE_NAME"],
        Item={
            'value': {
                'S': data
            }
        }
    )

That’s it! Now we can go call the URL printed to the console. To save a value to dynamoDB, just add the “save_me” query parameter to the URL. For example:

curl "https://wb2gdbhlknrgook2zz5tqrpz5y0kixao.lambda-url.eu-west-1.on.aws?save_me=someValue"

And the request will return the value together with any previously saved values (because we used the value as primary key duplicates will be overwritten).

Find the complete example on GitHub.