AWS CDK:Using AWS Lambda with S3

In this article, we will examine how to leverage AWS Lambda and S3 services in various scenarios. We will present all code samples and utilize the AWS CDK for infrastructure-as-code.

In this article, we will look at leveraging AWS Lambda and S3 services in various scenarios. All code samples will be presented, and the AWS CDK will be utilized for infrastructure-as-code.

This section explains how to read S3 items from AWS Lambda.

Bucket Creation
A new storage bucket is created, as demonstrated in the code sample below. S3 is a global service, thus bucket names must be unique. As a result, we suffix the bucket ID with a unique identifier to ensure that AWS does not generate an error if this code is executed by several users.

If we destroy the stack in a development environment, we want to remove all objects from the bucket. In a production setting, we want to keep all of the items. We will employ the proper deletion approach based on the environmental variables.

const bucketId = ulid().toLowerCase();
    const isProd = process.env.isProd ?? false;
    const isDev = !isProd;
    const removalPolicy = isDev ? RemovalPolicy.DESTROY : RemovalPolicy.RETAIN;

const bucket = new s3.Bucket(this, 'S3Bucket', {
      bucketName: `aws-lambda-s3-${bucketId}`,
      autoDeleteObjects: isDev,
      removalPolicy,
    });
Copied

In this scenario, because we set the autoDeleteObjects option to true, AWS CDK will automatically create extra lambdas to delete the S3 bucket’s objects when we destroy the stack or if the bucket name changes. The constructed lambda can be seen in the Amazon Web Services dashboard.

The lambda function

We then create the lambda function (using the nodejs 16 runtime). We pass the bucket name to the lambda function as an environment variable to be read in the lambda function. Note that we need to give the lambda function read access to the bucket (as shown in the last line of the code snippet below)


    const nodeJsFunctionProps: NodejsFunctionProps = {
      bundling: {
        externalModules: [
          'aws-sdk', // Use the 'aws-sdk' available in the Lambda runtime
        ],
      },
      runtime: Runtime.NODEJS_16_X,
      timeout: Duration.minutes(3), // Default is 3 seconds
      memorySize: 256,
    };

    const readS3ObjFn = new NodejsFunction(this, 'readS3Obj', {
      entry: path.join(__dirname, '../src/lambdas', 'read-s3-obj.ts'),
      ...nodeJsFunctionProps,
      functionName: 'readS3Obj',
      environment: {
        bucketName: bucket.bucketName,
      },
    });

    bucket.grantRead(readS3ObjFn);
Copied

Roles and Policies for Lambda

By default, Lambda will have the AWSLambdaBasicExecutionRole, which has write permissions to CloudWatch. Because we gave read permissions to the bucket, the AWS CDK will build dynamic policies with the following permissions. The IAM service in the AWS console displays the same information.

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": [
                "s3:GetBucket*",
                "s3:GetObject*",
                "s3:List*"
            ],
            "Resource": [
                "arn:aws:s3:::aws-s3-lambda-random-id",
                "arn:aws:s3:::aws-s3-lambda-random-id/*"
            ],
            "Effect": "Allow"
        }
    ]
}
Copied

Lambda Function Code:

The Lambda function code is quite easy. We obtain the bucket name from the environment variable. We might have hard coded the bucket name. We pass the bucket name as an environment variable because it is dynamically generated.

We wish to read the contents of the S3 object input-file.txt, which we supplied as the object key.  Finally, we read the file’s contents and print them to the terminal (which we can see in CloudWatch).

import { S3Event } from 'aws-lambda';
import * as AWS from 'aws-sdk';

export const handler = async (
  event: S3Event,
  context: any = {}
): Promise<any> => {
  const bucketName = process.env.bucketName || '';
  const objectKey = 'input-file.txt';

  const s3 = new AWS.S3();
  const params = { Bucket: bucketName, Key: objectKey };
  const response = await s3.getObject(params).promise();
  const data = response.Body?.toString('utf-8') || '';

  console.log('file contents:', data);
};
Copied

All AWS resources are created after you deploy the stack with cdk deploy.

Testing

Log in to the AWS console and choose AWS S3. You can see bucket names starting with aws-lambda-s3. My bucket name is unique to me, whereas your bucket name will be different.

Create a text file called input-file.txt that contains some example content. You can enter anything into the text file. I simply typed “Hello world.” Save the file, then upload it to the load bucket.

Then, in the console, pick the Lambda service, the Test tab, and the Test Functions button, as shown below. You can send an empty object as an event because our lambda function will not use it.

When you click the Test button, lambda is executed, and you can view the results. You may also view the execution logs via CloudWatch.

Read an S3 item from AWS Lambda using a trigger.

In most circumstances, you don’t want to run lambda manually. Instead, we want the lambda to run automatically when uploading a file to an S3 bucket.

As illustrated in the image above, when an object is uploaded to an S3 bucket, a lambda function is called using S3 object metadata as the event payload. Within lambda, the contents of the S3 object are read and reported to the console.

Add an event source for the lambda function.

We only need to add the event source to the lambda function. In the code snippet below, we’ve added an S3 event source with OBJECT_CREATED as the trigger event for the lambda, ensuring that the lambda is called whenever an object is created in the S3 bucket. Internally, AWS CDK will construct new lambdas to handle the plumbing.

    readS3ObjFn.addEventSource(
      new S3EventSource(bucket, {
        events: [s3.EventType.OBJECT_CREATED],
      })
    );
Copied

There are numerous events available that can be used as event sources for your lambda functions. The following are some of the most often-used events.

  • OBJECT_CREATED_PUT
  • OBJECT_CREATED_POST
  • OBJECT_CREATED_COPY
  • OBJECT_REMOVED
  • OBJECT_REMOVED_DELETE

Lambda Function Code
The Lambda function code is modified, as shown below. We will get the bucket name from the event payload.

export const handler = async (
  event: S3Event,
  context: any = {}
): Promise<any> => {
  for (const record of event.Records) {
    const bucketName = record?.s3?.bucket?.name || '';
    const objectKey = record?.s3?.object?.key || '';

    const s3 = new AWS.S3();
    const params = { Bucket: bucketName, Key: objectKey };
    const response = await s3.getObject(params).promise();
    const data = response.Body?.toString('utf-8') || '';
    console.log('file contents:', data);
  }
};
Copied

Testing

You can delete the old file input-file.txt and upload it afresh. The lambda function will be performed as soon as you submit the file. You may see the execution logs in the CloudWatch service.

Uploading several files at once
When many items are uploaded, lambda will be called for each of them. For example, if you upload three files to the bucket, lambda will run three times.

Write a file to an S3 object using Lambda.

In this part, we will write contents to an S3 object.

Lambda Function Permissions

Lambda function attributes are comparable to previous lambda functions. The only difference is in permission. To allow Lambda to write to the S3 bucket, we must grant it write permission (as seen in the last line of the code snippet below).

const writeS3ObjFn = new NodejsFunction(this, 'writeS3ObjFn', {
      entry: path.join(__dirname, '../src/lambdas', 'write-s3-obj.ts'),
      ...nodeJsFunctionProps,
      functionName: 'writeS3ObjFn',
      environment: {
        bucketName: bucket.bucketName,
      },
    });

    bucket.grantWrite(writeS3ObjFn);
Copied

Lambda Function Code

We simply want to write some stuff to the s3 object output-file.txt. Please keep in mind that this lambda produces an S3 object with the contents.

import { S3Event } from 'aws-lambda';
import * as AWS from 'aws-sdk';
import { PutObjectRequest } from 'aws-sdk/clients/s3';

export const handler = async (
  event: S3Event,
  context: any = {}
): Promise<any> => {
  const bucketName = process.env.bucketName || '';
  const objectKey = 'output-file.txt';

  const s3 = new AWS.S3();
  const params: PutObjectRequest = {
    Bucket: bucketName,
    Key: objectKey,
    Body: 'Contents from Lambda',
  };
  await s3.putObject(params).promise();
  console.log('file is written successfully');
};
Copied

Once the file is written with the content supplied in the Body parameter on the putObject method, we write a success message to the console, which we can view later from CloudWatch.

Testing 
Open the AWS console and choose the Lambda service. Select the Test tab and click the Test button, passing an empty event object as we did earlier.

The function will be performed, and a new S3 object named output-file.txt will be created in the bucket.

Write to the local file system, then upload to S3.

In this part, we will use Lambda to upload a file from the local file system to an S3 bucket. Aside from the /tmp directory, Lambda has read-only access to the underlying file system. If you need to write anything, you can only do it in the /tmp directory.

import { S3Event } from 'aws-lambda';
import { promises as fsPromises } from 'fs';
import * as AWS from 'aws-sdk';
import { PutObjectRequest } from 'aws-sdk/clients/s3';

export const handler = async (
  event: S3Event,
  context: any = {}
): Promise<any> => {
  const bucketName = process.env.bucketName || '';
  const objectKey = 'output-file.txt';
  const filePath = `/tmp/${objectKey}`;

  await fsPromises.writeFile(filePath, 'Contents from Lambda to local file');
  const fileContents = await fsPromises.readFile(filePath);

  const s3 = new AWS.S3();
  const params: PutObjectRequest = {
    Bucket: bucketName,
    Key: objectKey,
    Body: fileContents,
  };
  await s3.upload(params).promise();
  console.log('file is uploaded successfully');
};
Copied

Please keep in mind that we are employing a different mechanism here–the S3 upload method.

Trigger AWS Lambda depending on the S3 object prefix and/or suffix.

In some circumstances, you may want to trigger a lambda function based on the S3 object key’s prefix and/or suffix. For instance, another application in your system may create S3 objects with the key format sales/{date-of-sale}.

For example, the name of the S3 object could be sales/10/12/2022, indicating that the sales occurred on October 12th. You must apply some business logic by processing the sales data.

For example, suppose we try to upload two files, one with a matching prefix (shown in green) and the other without (shown in red). S3 object notifications would only be issued for the prefix that matches.  Please keep in mind that nothing has changed with the lambda function. S3 event notifications handle all the filtering.

Lambda function: Trigger Source

You may apply filters to S3 event alerts, as seen below.

    readS3ObjPrefixFn.addEventSource(
      new S3EventSource(bucket, {
        events: [s3.EventType.OBJECT_CREATED],
        filters: [{ prefix: 'sales/' }],
      })
    );
Copied

eadS3ObjPrefixFn denotes the lambda function. Previously, we utilized simply the events property. We’ve now utilized the filters property to filter just the items with the prefix sales/.

As previously stated, AWS CDK will construct an extra lambda for this notice.

Lambda function’s source code

There will be no changes to the lambda source code. However, if you wish to extract the date component from the object key, use the code snippet below.

import { S3Event } from 'aws-lambda';
import * as AWS from 'aws-sdk';
import { format, parse } from 'date-fns';

export const handler = async (
  event: S3Event,
  context: any = {}
): Promise<any> => {
  for (const record of event.Records) {
    const bucketName = record?.s3?.bucket?.name || '';
    const objectKey = record?.s3?.object?.key || '';

    //Take only the date part
    const salesDateInStr = objectKey.replace(`sales/`, '').substring(0, 10);
    //Parse the date so that it can be used later
    const salesDate = parse(salesDateInStr, 'MM/dd/yyyy', new Date());

    const s3 = new AWS.S3();
    const params = { Bucket: bucketName, Key: objectKey };
    const response = await s3.getObject(params).promise();
    const data = response.Body?.toString('utf-8') || '';
    console.log(`sales on ${salesDate.toISOString()} :`, data);
  }
};
Copied

We’ve utilized the prefix filter. You may use both the prefix and suffix properties in filters.

Testing

When you try to upload a file that contains the / character, it will fail. So, to upload a typical file to an S3 object with keys including /, use the aws cli command listed below. In the following AWS CLI command, we want the file sales.txt to be uploaded as sales/10/12/2022/sales-data.txt S3 key in the bucket aws-lambda-s3-01gev17npa6m6yqfwy7rnfepda

aws s3 cp sales.txt s3://aws-lambda-s3-01gev17npa6m6yqfwy7rnfepda/sales/10/12/2022/sales-data.txt
Copied

Get S3 pre-signed URL from Lambda.

Consider the following scenario: you’re constructing an API and want to upload a huge file to one of its endpoints. A high-level system design might look somewhat like the following.

However, there is a little flaw with this strategy. If you attempt to upload a file larger than 10 MB, it will fail since the API Gateway’s payload size restriction is 10 MB.

The answer to this problem is to utilize lambda to build a pre-signed URL for the s3 object, which can then be used to upload a file straight from the client (in our example, a browser) by passing the full application backend (Gateway and Lambda).

How it works.

The call to construct the pre-signed URL makes use of the security token service to establish short-term credentials and a token. When you produce the pre-signed URL, you must give the operation name; the generated URL is only valid for the chosen operation (putObject in our example). When we attempt to upload the file, it utilizes the created security token. This token has an expiry time after which it will become invalid.

The created URL has the following query parameters.

  • X-Amz-Algorithm – The hash algorithm you used to generate the request signature.
  • X-Amz-Credential – The credential scope value is a string including your access key, the date, the area you are targeting, the service you are asking for, and a termination string (“aws4_request”). The value is in the following format: access_key/YYYYMMDD/region/service/aws4_request.
  • X-Amz-Date – The date used to generate the signature.
  • X-Amz-Expires – Time to expire (seconds)
  • X-Amz-Security-Token – A temporary security token received by using the AWS Security Token Service (AWS STS).
  • X-Amz-Signature – Specifies the hexadecimal signature generated from the signing string and the derived signing key.
  • X-Amz-SignedHeaders specifies all of the HTTP headers that were provided as part of the canonical request.

Infrastructure code

The infrastructure code will be identical to the current code. Also, please remember to allow bucket write access to the Lambda function.

const bucket = new s3.Bucket(this, 'S3Bucket', {
      bucketName: `aws-s3-lambda-presigned-url`,
      autoDeleteObjects: isDev,
      removalPolicy,
    });

 const nodeJsFunctionProps: NodejsFunctionProps = {
      bundling: {
        externalModules: [
          'aws-sdk', // Use the 'aws-sdk' available in the Lambda runtime
        ],
      },
      runtime: Runtime.NODEJS_16_X,
      timeout: Duration.minutes(3), // Default is 3 seconds
      memorySize: 256,
    };

 const presignedUrlFn = new NodejsFunction(this, 'preSignedUrlFn', {
      entry: path.join(__dirname, '../src/lambdas', 'pre-signed-url.ts'),
      ...nodeJsFunctionProps,
      functionName: 'preSignedUrlFn',
    });

    bucket.grantWrite(presignedUrlFn);
Copied

Testing
When you run lambda, we will obtain the pre-signed URL. We may make a PUT request to the pre-signed URL we received from Lambda.

To obtain the pre-signed URL, you may manually execute lambda from the AWS interface. In the Body tab of the following window, you may pick binary content and upload the image large-file.jpeg.

Once the request is successful, you may view the uploaded file in S3.