AWS SAM

AWS Serverless Application Model + Python.

Introduction

In my previous blog post Using AWS Serverless Application Model (SAM) First Impressions I wrote about AWS Serverless Application Model (SAM). I have been using SAM in my current project, and SAM is a good, simple tool to create cloud infrastructure on AWS. However, one developer complained in a meeting today, that it is rather slow to develop AWS Lambdas with SAM: for each change you need to deploy the Lambda using SAM and test it on AWS cloud infrastructure. In this blog post, I consider some AWS Lambda development practices.

Strategy

The development strategy for implementing AWS Lambdas I explain in this blog post can be summarized like this: Try to minimize the development that you have to do in the AWS/SAM context. I.e., if you develop every piece of code in the AWS/SAM context, you have to build the Lambda, and either test it locally with SAM or deploy the code to AWS. There is a SAM sync command that you can use to sync your local changes with the AWS Lambda, but let’s not talk about that command in this blog post, but consider other alternatives.

Using the strategy mentioned above, my AWS Lambda development practices can be listed using these steps - the first step providing the fastest development feedback, and the last one providing the slowest development feedback.

  1. Just Use the Python interpreter.
  2. Develop with SAM local.
  3. Deploy Lambda to AWS.

1. Just Use the Python Interpreter

The idea is that you minimize the code in your Lambda handler file. In that file, just read the event that triggered the Lambda request. Here is an example from the handler.py file that is the main entry point to my Lambda function:

from my_package_a import handler_logic as hl
...
@logger.inject_lambda_context(log_event=True)
def lambda_handler(event, context):
    logger.debug(f"Context: {context}")
    if event:
        logger.debug(f"event: {event}")
        for record in event["Records"]:
            try:
                entity = hl.parse_entity(record)
                logger.info(f"Got entity: {entity}")
                hl.process_entity(entity)
            except Exception as e:
...

So, the lambda_handler just receives the event, and then delegates everything else to the handler_logic.py: parsing the entity from the record, and processing the entity. This way, I can skip the AWS/SAM context and develop the application logic just using the Python interpreter with the handler_logic.py file. Another advantage is that you can then implement various unit and integration tests that do not need the AWS/SAM context.

The project uses a certain Lambda package hierarchy which forced me to do a simple trick to import the database module depending on whether we are running the lambda_handler.py as is, or it is called from the handler.py:

if __name__ == '__main__':
    # Run this file as a main script for development purposes.
    import database as db
else:
    # This file was imported as a module by the Lambda handler (AWS/SAM context).
    from my_package_a import database as db

Then we have the parse_entity and process_entity functions:

def parse_entity(record: dict) -> str:
    """ Parses the entity from the event record."""
...

def process_entity(entity: str) -> None:
    """ Main entry point to the application logic."""
...

And finally, add the main method at the end of the file. This way you can call your application logic from the command line, as well as call it from the Lambda handler, example:

# For development purposes without the AWS / SAM context.
def main(_):
    entity = "MY_CRYPTIC_ENTITY_XXX_YYY_20220608T043249"
    process_entity(entity)
    return 0

if __name__ == '__main__':
    main(sys.argv)

So, we have skipped the parse_entity part, and call directly the process_entity which is under development.

How About the Database?

Our application logic calls the database. Let’s use the real AWS RDS development database. The database is in a private subnet, so we need to create an SSH tunnel to the development database:

aws-vault my-aws-dev-profile --no-session --  ssh -i ~/.ssh/ssm-dev-instance-key.pem ec2-user@DB_JUMP_SERVER_ID -L MY_LOCAL_IP:54321:dev-db.XXXXXXXXXXXX.eu-west-1.rds.amazonaws.com:5432

Ok. Now we have SSH tunnel to the RDS development database.

I usually create under the personal folder some personal development stuff I use during development (e.g., environment scripts that have some confidential information). My .gitignore file comprises the personal directory so that it is not stored in the git repo. Let’s see the set_env_vars.sh file:

λ> cat personal/set_env_vars.sh 
#!/bin/bash

export ENV=dev
export DB_HOST="MY_LOCAL_IP"
export DB_PORT=54321
export DB_NAME="my_database"
...

Just source the file in the terminal you call the handler_logic.py, and then you can call it:

source ../../personal/set_env_vars.sh
aws-vault exec my-aws-dev-profile --no-session -- python my_package_a/handler_logic.py

Running the python file in the Python interpreter is fast (it starts immediately). The application logic can connect to the RDS development database via SSH tunnel, and you can call all AWS services using the boto3 library and using your AWS profile that you provide using aws-vault.

2. AWS SAM Context

In the previous chapter, I created the SSH tunnel to the RDS development server using my Linux host machine IP. The reason for not using localhost is that the SAM docker container is a black box and the port forwarding is a bit tricky. I also needed to open the firewall to the 54321 port in my Linux host machine, so that the Lambda application running in the SAM docker container can connect to the Linux host machine port:

sudo ufw allow 54321

Create a JSON file in which you define the environment variables your lambda needs:

λ> cat personal/.entity_lambda_env_local.json 
{
  "EntityFunction": {
    "ENV": "dev",
    "DB_HOST": "MY_LOCAL_IP",
    "DB_PORT": 54321,
...

Now you can build && invoke the AWS Lambda to run it using the local SAM docker container running in your machine:

sam build && aws-vault exec my-aws-dev-profile --no-session -- sam local invoke EntityFunction --env-vars personal/.entity_lambda_env_local.json --event events/my_test_entity_a1.json

Your lambda running locally in the SAM docker container can access the RDS development database and call AWS services using your AWS profile that you provide using aws-vault.

This development cycle is a lot slower than running your application logic directly in the Python interpreter: the SAM build takes some 10 seconds, and calling sam local invoke takes another 10 seconds on my machine. Therefore, I do most of the development in the python interpreter, and just occasionally test the application logic in the SAM context.

3. Deploy to AWS

Finally, the slowest method is to build the lambda using SAM (just 10 seconds), and deploy the lambda package to AWS using SAM (about 1 minute), and test it. The testing phase in my case takes some 6 minutes: use AWS CLI to send an SNS message, which triggers an SQS event, which finally triggers the lambda under development. So, after 6 minutes I can go to CloudWatch logs to see if the test was successful. The development cycle is some 7 minutes altogether. Since the development cycle is several minutes, I just occasionally test the lambda in the real AWS context to see that it is still working.

Developer Experience: Python vs Clojure

This chapter is not related to the content of this blog post. But whenever I do other than Clojure development I miss the Clojure REPL. Doing Python development like this running the file is ok, but nothing like interacting in the editor with the Clojure REPL evaluating S-expressions. I have tried various Python REPLs, but they are nothing compared to a real Clojure REPL, so with Python, I mostly just run my Python code from the terminal and occasionally start the Python terminal REPL e.g., to try some standard library function.

Conclusions

When developing AWS Lambda code, you have several options to speed up the development cycle - if you are using Python, you should do most of the application logic just using the Python interpreter.

The writer is working at a major international IT corporation building cloud infrastructures and implementing applications on top of those infrastructures.

Kari Marttila

Kari Marttila’s Home Page in LinkedIn: https://www.linkedin.com/in/karimarttila/