One Lambda to rule them all

Whenever I got a new laptop or was (re-)installing macOS from scratch, a Java JDK, IntelliJ IDEA, and Tomcat, the “pure Java” HTTP web server environment, were always among the 1st things I installed.
How times have changed. Now it’s Docker, Python3, PyCharm, and AWS and SAM CLIs that go on first. I still do Java, quite a bit actually, but Python and AWS Lambda are on the rise.

AWS Lambda

I used to think of the AWS Lambda environment as of a Java Virtual Machine or a Python virtual environment, but it has more to offer, it’s a microVM. AWS Lambda uses Firecracker as the foundation for provisioning and running sandboxes upon which we execute our custom code. Each Firecracker microVM uses 5.24 MB of memory and a single Firecracker microVM can be launched in 125 ms. Only Intel x86_64 and AMD x86_64 CPUs are supported, (which is important to know, when it comes to including native libs or executables.)

Runtime Environment limits

  • The /tmp directory storage is limited to 512 MB.
  • File descriptors: 1024
  • Processes / threads: 1024
  • Deployment package size is 50 MB (compressed) 250MB (uncompressed)
  • Invocation payload (request and response) synchronous calls 6 MB, async. 256KB
  • Invocation frequency per function: 10x provisioned concurrency
  • Function timeout: 900s (15 minutes)
  • Memory range is from 128 to 3008 MB, in 64MB increments

To explore inside the AWS Lambda infrastructures, here is fun a project, essentially giving you direct shell access, and here is a related project on GitHub. And since the size of deployment packages is limited to 50 MB, it might be good to know, which modules are already available and therefore don’t have to be a part of the deployment package.

An AWS Lambda function can be simple but still quite powerful, doing many things, I used to do with Tomcat. I will show an AWS Lambda function, implemented in Python, performing things like:

  1. Serving an HTML page
  2. Consuming HTTP Post requests sent from that page HTML page
  3. Securely storing received information in a Dynamo DB
  4. Synthesizing text into speech, i.e. returning MP3 (digital audio)
  5. Calling native libraries or executables that were deployed with the lambda function.
  6. Calling other AWS Lambda functions

Since the last item on that list mentions calling another Lambda, we need two Lambda functions. Before writing any code, there are a few things that need to be installed.


1 Prerequisite

  1. Install Python (consider using a version that is supported by AWS Lambda) and PyCharm with the AWS Toolkit plugin enabled
  2. Install AWS CLI and AWS SAM CLI
  3. Create an AWS Account

1.1  PyCharm with the AWS Toolkit plugin enabled

After installing PyCharm, open Preferences, and then Plugins. There you can search for and enable the AWS Toolkit:

1.2 Installing AWS CLI and AWS SAM CLI

Jetbrains’ AWS Toolkit is just a UI accessing the AWS Command-line Tools, which therefore must be installed before we can use the Toolkit.

1.2.1 AWS Command Line Interface

How to install the AWS CLI version 2 is explained here. However, with Homebrew installed on your Mac, it can be installed easier and faster like so:

brew install awscli

and confirming the installation like so:

aws --version
aws-cli/2.5.6 Python/3.9.12 Darwin/21.4.0 source/x86_64 prompt/off

1.2.2 AWS Serverless Application Model Command Line Interface

How to install the AWS SAM CLI is explained here. Again, with Homebrew installed on your Mac, it can be installed like so:

brew tap aws/tap
brew install aws-sam-cli

and confirming the installation like so:

sam --version
SAM CLI, version 1.46.2

1.3 Creating an AWS Account

AWS Accounts (aka Root User Accounts) can be created here:
https://portal.aws.amazon.com/gp/aws/developer/registration/index.html students should start here instead: https://aws.amazon.com/education/awseducate/
A credit card number and a phone is needed during the setup process. Select “Basic” for the support plan.

1.3.1 Bucket

Click in Services and navigate/find S3 or go directly to https://s3.console.aws.amazon.com/s3/ . There you will have the chance to create a bucket and provide it with a unique name. Go ahead and do that. I used com.wolfpaulus.edu-bucket for my bucket’s name and clicked through, going with all the defaults.

1.3.2 Creating an IAM Account and User

Create an IAM Account (aka AWS Identity and Access Management (IAM) user) here:
https://console.aws.amazon.com/iam

 

Select ‘User’ on the left side and then click the “Add user” button, to create a new IAM user.
Create a user name (e.g. MyEduUser) and select “Programmatic access” only.

Next, under ‘Set permissions’ select ‘Attach existing policies directly’ and select:

  • IAMFullAccess
  • PowerUserAccess

Review and click the “Create user” button and don’t forget to download the csv file, containing the IAM user’s credentials.

Now open a terminal or shell on your computer and enter ‘aws configure –profile <profile name>’ and provide the key and secret from the dowloaded csv file. I used ‘edu’ for the profile name. Then copy/paste the key-id, copy/paste access-key, for the region I used ‘us-west-2’, and for the default output format I used ‘json’

▶ aws configure --profile edu
AWS Access Key ID [None]: AKIA...
AWS Secret Access Key [None]: zjPG...
Default region name [None]: us-west-2
Default output format [None]: json

2 A simple AWS Lambda function for translating English text to German

As mentioned above, we actually need two Lambda functions for this demo. Let’s start by creating the simpler function first.

https://github.com/wolfpaulus/EN2DE

2.1 Create

I open PyCharm and create a new project. No 3rd party modules are required (or more precisely need to be deployed) for this simple function and I don’t even bother with a virtual environment. Since this function will translate English text into German, I’m calling it EN2DE.

I usually create a sub-directory inside the project and name lambda. In there, I create two files: an empty requirements.txt file and an app.py file.

2.1.1 Lambda Function Code

import logging

import boto3
logging.getLogger().setLevel(logging.INFO)


def lambda_handler(event: dict, context) -> dict:
    """
    :param event: input data, usually a dict, but can also be list, str, int, float, or NoneType type.
    :param context: object w/ methods a. properties providing info about invocation, function, and execution environment
    :return: dict with TranslatedText, SourceLanguageCode, TargetLanguageCode
    """
    logging.info(str(event))
    text = event.get('text', 'no input text provided')
    translate = boto3.client(service_name='translate', region_name='us-west-2', use_ssl=True)
    return translate.translate_text(Text=text, SourceLanguageCode='en', TargetLanguageCode='de')

While simply using Python’s builtin print method would already write into the CloudWatch log, using Python’s logging library allows for log levels and also writes a timestamp into each log entry.

def lambda_handler(event: dict, context)

The lambda function will receive an event dictionary with input parameters as well as a context object with methods and properties, providing information about the invocation, function, and execution environment. Here I’m assuming that the function will be called with a dictionary containing a ‘text’ item. Its value will be passed to the boto3 translate client.

boto3
Rob Bricheno’s cool Inside Lambda service shows that the boto3 module is already available and therefore doesn’t need to be mentioned as a requirement and thereby keeping the deployment package nice and small.

2.1.2 Lambda CloudFormation Template

This Lambda CloudFormation Template (template.yaml), stored inside the project directory, allows for super convenient deployment. The template declares the required runtime (i.e. python3.8), the source code directory, the python file, and handler function (e.g. app.lambda_handler), the policy required at runtime, and also the log group with a retention policy.

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Lambda, translating English to German
Resources:
  TranslateFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: EN2DE
      CodeUri: lambda/
      Handler: app.lambda_handler
      Runtime: python3.8
      Timeout: 30
      MemorySize: 512
      Policies:
        - TranslateFullAccess

  LogsLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub '/aws/lambda/${TranslateFunction}'
      RetentionInDays: 7

Outputs:
  TranslateFunction:
    Description: "Translate EN to DE Lambda Function ARN"
    Value: !GetAtt TranslateFunction.Arn

After selecting the profile (e.g. edu) and region (e.g. us-west-2) in the AWS Explorer panel, I just right-click the template.yaml file and select the last menu-item: Deploy Serverless Application.

Finally, I create a Run-Configuration in PyCharm, which allows me to execute the remote lambda function:


Which returns the following result:

Invoking Lambda function: EN2DE
Logs: 
START RequestId: c227fde4-25eb-4ec8-ac68-df87fd2ddbfc Version: $LATEST
[INFO]	2020-11-03T21:35:33.303Z	c227fde4-25eb-4ec8-ac68-df87fd2ddbfc	{'text': 'Hello, how are you today?'}
[INFO]	2020-11-03T21:35:33.328Z	c227fde4-25eb-4ec8-ac68-df87fd2ddbfc	Found credentials in environment variables.
END RequestId: c227fde4-25eb-4ec8-ac68-df87fd2ddbfc
REPORT RequestId: c227fde4-25eb-4ec8-ac68-df87fd2ddbfc	Duration: 344.52 ms	Billed Duration: 400 ms	Memory Size: 512 MB	Max Memory Used: 72 MB	Init Duration: 296.76 ms	

Output: 
{
  "TranslatedText": "Hallo, wie geht's dir heute?",
  "SourceLanguageCode": "en",
  "TargetLanguageCode": "de",
  "ResponseMetadata": {
    "RequestId": "efac8c3a-c2f7-40d4-8238-904e44d81704",
    "HTTPStatusCode": 200,
    "HTTPHeaders": {
      "x-amzn-requestid": "efac8c3a-c2f7-40d4-8238-904e44d81704",
      "cache-control": "no-cache",
      "content-type": "application/x-amz-json-1.1",
      "content-length": "101",
      "date": "Tue, 03 Nov 2020 21:35:32 GMT"
    },
    "RetryAttempts": 0
  }
}

3 One Lambda to rule them all

https://github.com/wolfpaulus/one

Now to the more capable Lambda function, still  following the same recipe:

  1. Create a new project PyCharm, this is named “ONE”
  2. Create and ./lambda directory with two files: app.py and requirements.txt

Additionally, I create a ./lambda/ui directory, which contains the webpage the Lambda function will serve when an HTTP GET request is received. While the web page to be served is rather simple, I still want to adhere to the separation of concerns (SoC) design principal and put the style sheet and javascript into separate files.

Therefore, the header of /ui/index.hml page will look like this:

<head>
    <meta charset="UTF-8">
    <title>Speech Synthesizer</title>
    <link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
    <link rel="stylesheet" href="./style.css"/>
    <script type="text/javascript" src="./script.js"></script>
</head>

In a user’s web browser, the page will eventually look something like this:

Text can be entered and submitted. The Lambda function will receive the text in an HTTP POST request and synthesize it. The output format (mp3 or wav) can be selected and optionally, the text can also be translated into German before the speech synthesis happens.

In summary, here is what the Lambda function will do:

  1. Serve the ./ui/index.hml page (including its resources) when an HTTP GET request for /ui/index.html is received
  2. Process HTTP POST requests, received at /synthesize
  3. Call  the translate Lambda function EN2DE to translate the text, if the translation into German is requested
  4. Call the AWS Polly to synthesize text into speech (mp3)
  5. Store the submitted text in a DynamoDB
  6. Log events in a cloud watch log group “/aws/lambda/EN2DE” and keep them for 7 days
  7. Convert the MP3 into WAV using the native FFmpeg executable (built for amd64 Linux kernels 3.2.0)
  8. Return the base64 encoded binary data to the requesting HTML page for consumption

 

It’s all in the template.yaml

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: One Lambda to rule them all

Globals:
  Api:
    Cors:
      AllowMethods: "'GET,POST,OPTIONS'"
      AllowHeaders: "'content-type'"
      AllowOrigin: "'*'"
      AllowCredentials: "'*'"

Resources:
  DynamoDBTable:
    Type: AWS::DynamoDB::Table
    Properties:
      AttributeDefinitions:
        - AttributeName: "key"
          AttributeType: "S"
        - AttributeName: "scope"
          AttributeType: "S"
      KeySchema:
        - AttributeName: "key"  # Partition key
          KeyType: "HASH"
        - AttributeName: "scope"  # Sort key
          KeyType: "RANGE"
      ProvisionedThroughput:
        ReadCapacityUnits: 5
        WriteCapacityUnits: 5
      SSESpecification:
        SSEEnabled: true
      TableName: "lambdaOneRequests"

  LambdaOneFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: ONE
      CodeUri: lambda/
      Handler: app.lambda_handler
      Runtime: python3.8
      Timeout: 3
      MemorySize: 512
      Policies:
        - AmazonDynamoDBFullAccess
        - AmazonPollyFullAccess
        - Version: '2012-10-17'
          Statement:
            - Effect: Allow
              Action:
                - 'polly:SynthesizeSpeech'
              Resource: '*'
            - Effect: Allow
              Action:
                - logs:*
              Resource: arn:aws:logs:*:*:*
            - Effect: Allow
              Action:
                - lambda:InvokeFunction
              Resource: '*'
      Events:
        GetEvent:
          Type: Api
          Properties:
            Path: /ui/{filename}
            Method: get
        PostEvent:
          Type: Api
          Properties:
            Path: /{function}
            Method: post
        UpdateSchedule:
          Type: Schedule
          Properties:
            Schedule: rate(5 minutes)
            Input: '{"req":"poll"}'
  LogsLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub '/aws/lambda/${LambdaOneFunction}'
      RetentionInDays: 7

Outputs:
  LambdaOneApi:
    Description: "Prod stage API Gateway endpoint URL for LambdaOne"
    Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/{function}"
  LambdaOneFunction:
    Description: "LambdaOne Function ARN"
    Value: !GetAtt LambdaOneFunction.Arn
  LambdaOneIamRole:
    Description: "Implicit IAM Role created for LambdaOne"
    Value: !GetAtt LambdaOneFunction.Arn

This template.yaml is a bit more complex than the previous one, so let’s look at some of the declarations is more detail:

  • The API declaration contains the CORS settings, allowing requests from any origin.
  • The declaration of the DynamoDBTable will create the table “lambdaOneRequests” with a hash key and a search key
  • The Lambda function declaration contains, the handler, runtime information, and also all the needed policies, like accessing DynamoDB, Speech Synthesis, accessing the log, or calling a Lambda function.
  • Events declares the request PATH for HTTP GET and POST requests. Moreover, the UpdateSchedule configures that the Lambda function will be called every 5 minutes, which should keep it warm, i.e. remove startup delays.
  • The declaration of the LogsLogGroup will create the /aws/lambda/ONE log group and retain entries for 7 days.
  • The Output declaration will expose the function’s ARN and API Gateway URL

Project View

1st Deployment

Like before, just right-click the template.yaml file and select the last menu-item: Deploy Serverless Application, to get the (re-)deployment started

CloudFormation after deployment

3.1 HTTP GET Requests – Serving HTML, CSS, and Script files

In the lambda_handler, the entry point for any request the lambda function receives, I’m looking for either GET or POST requests and branch accordingly. The CORS header, which is repeated in the template yaml file, is important to allow regular REST clients to call the API Gateway as well, in case that is what you want.

status_code, content_type, content = get(event['path']) if 'GET' == event['httpMethod'] else post(event)
return {
    "statusCode": 200,
    "headers": {
        # Cross-Origin Resource Sharing (CORS) allows a server to indicate any other origins than its own,
        # from which a browser should permit loading of resources.
        'Access-Control-Allow-Origin': '*',
        'Access-Control-Allow-Credentials': True,
        'Access-Control-Allow-Methods': 'OPTIONS,POST,GET',
        'Content-Type': content_type,
    },
    "body": content
}

If a GET request was received, I’m calling the get() function, which then tries to find the requested text file, or returns index.html instead.

if not file_path.startswith('/ui/'):
    file_path = '/ui/index.html'
if file_path.endswith('.css'):
    content_type = 'text/css; charset=UTF-8'
elif file_path.endswith('.js'):
    content_type = 'text/javascript; charset=UTF-8'
else:
    content_type = 'text/html; charset=UTF-8'
    file_path = '/ui/index.html'
try:
    with open('.' + file_path, 'r') as file:
        content = file.read()
except:
    content_type = 'text/html; charset=UTF-8'
    content = file_path

3.2 Processing HTTP POST requests

In the post() function, depending on the requested audio format, the content_type is set and the speech synthesis is requested.

content_type = "audio/wav" if params['format'] == 'wav' else 'audio/mpeg'
content = synthesize(params['text'], params['translate'], params['format'])
return 200, content_type, json.dumps({"b64": content})

3.3 Calling other Lambda Functions

Boto is the Amazon Web Services (AWS) SDK for Python and in the polly.to_german() function, I’m calling the above mentioned EN2DE lambda function, using the boto3 provides the client, which is called with 3 parameters: function-name, invocation-type (in the case RequestResponse .. we’ll wait for the response) and the payload, i.e. the English text.

def to_german(english: str) -> str:
    """
    Calling our translate lambda function
    doc: https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/lambda.html
    """
    response_from_lambda = client('lambda').invoke(
        FunctionName='EN2DE',
        InvocationType='RequestResponse',
        Payload=bytes(json.dumps({'text': english}), encoding='utf8')
    )
    response = json.load(response_from_lambda['Payload'])
    return response.get('TranslatedText')

3.4 Calling AWS Polly

Once again, the boto3 library’s client is used: polly_client = boto3.client('polly'), and based on the requested language (English or German) a different voice is requested:

response = polly_client.synthesize_speech(Engine="standard" if translate else "neural",
                                          VoiceId="Vicki" if translate else "Joanna",  # or Marlene or Hans
                                          OutputFormat="mp3",
                                          Text=text,
                                          TextType="ssml")

The response contains the mp3 data, which needs to be base64 encoded, to be able to travel inside JSON documents, all the way to the requesting Web browser:

return base64.b64encode( response["AudioStream"].read() ).decode("utf-8")

3.5 Store requests in a DynamoDB

This time it’s the boto3 library’s resource that is used to connect to an AWS Service, namely DynamoDB.

from boto3 import resource
cls.dynamodb = resource("dynamodb")
cls.table = cls.dynamodb.Table("lambdaOneRequests")

The RequestDB class in dynamo.py shows how new records get created, and the text and hashed IP address get stored. Here I’m using the Dynamo DB GUI client to take a quick look at the table:

3.6 Log events in a cloud watch log group

Using Python’s logger, writing entries into a cloys watch log group could not be any easier.

import logging
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
..
logger.info(...)

Conveniently, PyCharm provides access to AWS resources, including the log groups, without having to leave the IDE.

3.7 Convert the MP3 into WAV

FFmpeg is the leading multimedia framework, able to decode, encode, or transcode media files. I’m using it here for the rather mundane task, converting mp3 encoded media into wav. I could have requested a wav encoded synthesis from AWS Polly, but using FFmpeg allows me to show how native Linux executables can easily be packaged with a Lambda function.

Since the Firecracker microVM only supports Intel x86_64 and AMD x86_64 CPUs, we know the hardware platform. Packaging of native binaries with their dependencies is explained here, but using a static build (a binary with all the libs included inside the binary itself) is even easier if you can find one. Fortunately, John van Sickle regularly provides static builds for FFmpeg.

After downloading and extracting the latest ffmpeg-git-amd64-static.tar.xz archive, I copy the ffmpeg binary into the project’s lambda directory (adding 75 MB). However, to use it within the AWS Lambda environment, it needs to be moved into the /tmp directory and also made executable. Therefore, the Python file that contains the lambda_handler also contains these instructions:

from os import chmod
from shutil import copyfile
copyfile("./ffmpeg", '/tmp/ffmpeg')
chmod("/tmp/ffmpeg", 755)

after that is done, I can call it to convert mp3 content stored in an mp3_data variable like so:

wav_data = 
subprocess.Popen(["/tmp/ffmpeg", "-i", "pipe:0", "-f", "wav", "pipe:1"], shell=False, stdout=subprocess.PIPE,
stdin=subprocess.PIPE, stderr=subprocess.PIPE).communicate(mp3_data)[0]

Summary

PyCharm has a simple REST client builtin that can be used to quickly see the Lambda function working:
Right-click the project and select New and then HTTP Request .. enter something like this:

POST https://le95wslzi8.execute-api.us-west-2.amazonaws.com/Prod/synthesize
Content-Type: application/json
{
  "scope" : "awscd",
  "text" : "Hello Tom",
  "translate": "False",
  "format" : "mp3"
}

Right-click the text and select Run and see the result.

POST https://le95wslzi8.execute-api.us-west-2.amazonaws.com/Prod/synthesize

HTTP/1.1 200 OK
Content-Type: audio/mpeg
Content-Length: 8015
Connection: keep-alive
Date: Tue, 10 Nov 2020 22:47:49 GMT
x-amzn-RequestId: dde1e43f-8db8-4832-a2b7-81001ed222dc
Access-Control-Allow-Origin: *
x-amz-apigw-id: V0DHKExVPHcFsnQ=
Access-Control-Allow-Methods: OPTIONS,POST,GET
X-Amzn-Trace-Id: Root=1-5fab1894-41804fcf5874364b7f71a62d;Sampled=0
Access-Control-Allow-Credentials: true
X-Cache: Miss from cloudfront
Via: 1.1 abfc920f60e32b50b36ecc54c5a19cf4.cloudfront.net (CloudFront)
X-Amz-Cf-Pop: PHX50-C1
X-Amz-Cf-Id: 1q4b-F2pbldyZPXd7n4SoljByAd3mv5GaADXiQ_h0b3W2qJY3uID_g==

{"b64": "SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU4LjQ1LjEwMAAAAAAAAAAAAAAA//NgxAAdSPooAGPMCAAw/FY/y6Hg/snDrZ2MBhYDAYWTAAACDCBAgCBBMmDgNOzyacRERERF37u7u4iIiIQcD58HwfBAEDhQEwfB8Hx4IAgcEEnLh9SjgYEfunLvBAAAgCAJg/ioYUCEEwfD5eCAIAgGMMdQIBAEz4Pyfog/D+n+t6oOREqCWVjEjrooHl15ZfhO/HAdCuB8/HMG//NixBkfub5tRmJFSOW4EpbQgBB1A+uWg3Rkg3HAGgAgBBEFZGHRY2/uTpAxcsbq21HIDF7OaNucCAkxcVjAYdDPCAAhvU9/o1GQwhXqv4kBAuD+s

All done, we did what we wanted to do: use an AWS Lambda to serve a simple web page and process its HTTP POST requests. PyCharm has a simple REST client builtin that can be used to quickly see the Lambda function working:

Along the way, we called another lambda, called AWS Polly, stored some data in a DynamoDB, logged events in a cloud watch log-group, and used the native FFmpeg executable to convert an mp3 into wav media file.

Leave a Reply