CLOVER🍀

That was when it all began.

AWS SAM+LocalStackで、Amazon S3のイベント通知を受け取るAWS Lambda関数をTypeScriptで書いてみる

これは、なにをしたくて書いたもの?

最近AWS SAM+LocalStackで、Amazon API GatewayAWS Lambdaなサンプルを書いているのですが。

他のパターンも試してみたくなったので、AWS SAMを使ってAmazon S3を扱うAWS Lambda関数を書いてみようと
思います。

今回はsam initで作成されたアプリケーションを、JavaScriptからTypeScriptに置き換えるところを目標にします。

が、オチを書くと、LocalStackだけではうまくいかなかったのですが。

Amazon S3のイベント通知をAmazon Lambda関数で受け取る

AWS Lambdaのイベントソースマッピングのドキュメントには、AWS S3は載っていません。

AWS Lambda イベントソースマッピング - AWS Lambda

「イベント通知」という扱いで、「他のサービスでの使用」欄に記載されていたり

Amazon S3 での AWS Lambda の使用 - AWS Lambda

Amazon S3のドキュメント側に載っています。

Amazon S3 イベント通知 - Amazon Simple Storage Service

こちらとAWS SDK for JavaScriptのドキュメントを見つつ、進めていきましょう。

AWS SDK for JavaScript とは - AWS SDK for JavaScript

File: README — AWS SDK for JavaScript

環境

今回の環境は、こちらです。デプロイ先および各種CLIには、LocalStackとその提供ツールを使用します。

$ localstack --version
0.13.3


$ python3 -V
Python 3.8.10


$ awslocal --version
aws-cli/2.4.9 Python/3.8.8 Linux/5.4.0-92-generic exe/x86_64.ubuntu.20 prompt/off


$ samlocal --version
SAM CLI, version 1.36.0

LocalStackの起動。

$ LAMBDA_EXECUTOR=docker-reuse localstack start

アプリケーションの作成に使う、Node.jsのバージョンはこちら。

$ node --version
v14.18.2


$ npm --version
6.14.15

AWS SAMを使った、AWS Lambdaアプリケーションの雛形作成とTypeScript向けのセットアップ

まずは、sam initを使ってquick-start-s3なアプリケーションを作成します。ランタイムはNode.js 14.xです。

$ samlocal init --name sam-lambda-s3 --runtime nodejs14.x --app-template quick-start-s3 --package-type Zip

プロジェクト内に移動。

$ cd sam-lambda-s3

作成されたディレクトリツリーは、こんな感じです。

$ tree
.
├── README.md
├── __tests__
│   └── unit
│       └── handlers
│           └── s3-json-logger-handler.test.js
├── buildspec.yml
├── events
│   └── event-s3.json
├── package.json
├── src
│   └── handlers
│       └── s3-json-logger.js
└── template.yaml

6 directories, 7 files

ひととおり、中身を開いてみます。

package.json

{
  "name": "replaced-by-user-input",
  "description": "replaced-by-user-input",
  "version": "0.0.1",
  "private": true,
  "dependencies": {
    "aws-sdk": "^2.799.0"
  },
  "devDependencies": {
    "jest": "^26.6.3",
    "aws-sdk-mock": "^5.1.0"
  },
  "scripts": {
    "test": "jest"
  }
}

src/handlers/s3-json-logger.js

const AWS = require('aws-sdk');

const s3 = new AWS.S3();

/**
 * A Lambda function that logs the payload received from S3.
 */
exports.s3JsonLoggerHandler = async (event, context) => {
  // All log statements are written to CloudWatch by default. For more information, see
  // https://docs.aws.amazon.com/lambda/latest/dg/nodejs-prog-model-logging.html
  const getObjectRequests = event.Records.map(record => {
    const params = {
      Bucket: record.s3.bucket.name,
      Key: record.s3.object.key
    };
    return s3.getObject(params).promise().then(data => {
      console.info(data.Body.toString());
    }).catch(err => {
      console.error("Error calling S3 getObject:", err);
      return Promise.reject(err);
    })
  });
  return Promise.all(getObjectRequests).then(() => {
    //console.debug('Complete!');
  });
};

__tests__/unit/handlers/s3-json-logger-handler.test.js

const AWS = require('aws-sdk-mock');

describe('Test s3JsonLoggerHandler', () => {
  it('should read and log S3 objects', async () => {
    const objectBody = '{"Test": "PASS"}';
    const getObjectResp = {
      Body: objectBody
    };

    AWS.mock('S3', 'getObject', function(params, callback) {
      callback(null, getObjectResp);
    });

    const event = {
      Records: [
        {
          s3: {
            bucket: {
              name: "test-bucket"
            },
            object: {
              key: "test-key"
            }
          }
        }
      ]
    }

    console.info = jest.fn();
    let handler = require('../../../src/handlers/s3-json-logger.js');

    await handler.s3JsonLoggerHandler(event, null);

    expect(console.info).toHaveBeenCalledWith(objectBody);
    AWS.restore('S3');
  });
});

events/event-s3.json

{
  "Records": [
    {
      "eventVersion": "2.0",
      "eventSource": "aws:s3",
      "awsRegion": "us-east-1",
      "eventTime": "1970-01-01T00:00:00.000Z",
      "eventName": "ObjectCreated:Put",
      "userIdentity": {
        "principalId": "EXAMPLE"
      },
      "requestParameters": {
        "sourceIPAddress": "127.0.0.1"
      },
      "responseElements": {
        "x-amz-request-id": "EXAMPLE123456789",
        "x-amz-id-2": "EXAMPLE123/5678abcdefghijklambdaisawesome/mnopqrstuvwxyzABCDEFGH"
      },
      "s3": {
        "s3SchemaVersion": "1.0",
        "configurationId": "testConfigRule",
        "bucket": {
          "name": "example-bucket",
          "ownerIdentity": {
            "principalId": "EXAMPLE"
          },
          "arn": "arn:aws:s3:::example-bucket"
        },
        "object": {
          "key": "test/key",
          "size": 1024,
          "eTag": "0123456789abcdef0123456789abcdef",
          "sequencer": "0A1B2C3D4E5F678901"
        }
      }
    }
  ]
}

template.yaml

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
  sam-lambda-s3

Parameters:
  AppBucketName:
    Type: String
    Description: "REQUIRED: Unique S3 bucket name to use for the app."

Resources:
  S3JsonLoggerFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: src/handlers/s3-json-logger.s3JsonLoggerHandler
      Runtime: nodejs14.x
      Architectures:
        - x86_64
      MemorySize: 128
      Timeout: 60
      Policies:
        S3ReadPolicy:
          BucketName: !Ref AppBucketName
      Events:
        S3NewObjectEvent:
          Type: S3
          Properties:
            Bucket: !Ref AppBucket
            Events: s3:ObjectCreated:*
            Filter:
              S3Key:
                Rules:
                  - Name: suffix
                    Value: ".json"
  AppBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Ref AppBucketName

buildspec.yml

version: 0.2
phases:
  install:
    commands:
      # Install all dependencies (including dependencies for running tests)
      - npm install
  pre_build:
    commands:
      # Discover and run unit tests in the '__tests__' directory
      - npm run test
      # Remove all unit tests to reduce the size of the package that will be ultimately uploaded to Lambda
      - rm -rf ./__tests__
      # Remove all dependencies not needed for the Lambda deployment package (the packages from devDependencies in package.json)
      - npm prune --production
  build:
    commands:
      # Use AWS SAM to package the application by using AWS CloudFormation
      - aws cloudformation package --template template.yaml --s3-bucket $S3_BUCKET --output-template template-export.yml
artifacts:
  type: zip
  files:
    - template-export.yml

Node.jsのアプリケーションが個別のディレクトリになっていなかったり、buildspec.ymlがついていたりと、
Amazon API Gateay+AWS Lambdaなhello-worldとはだいぶ雰囲気が違いますね。

ひとまず、依存ライブラリをインストール。

$ npm i

次に、アプリケーションをTypeScriptに変えていきます。TypeScript、Node.jsやAWS Lambdaに関する型宣言を
インストール。Prettierはお好みで。

$ npm i -D typescript
$ npm i -D -E prettier
$ npm i -D @types/node@v14 @types/aws-lambda

設定ファイル。

tsconfig.json

{
  "compilerOptions": {
    "target": "esnext",
    "module": "commonjs",
    "baseUrl": "./src",
    "outDir": "dist",
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noFallthroughCasesInSwitch": true,
    "noImplicitOverride": true,
    "noImplicitReturns": true,
    "noPropertyAccessFromIndexSignature": true,
    "esModuleInterop": true
  },
  "include": [
    "src"
  ]
}

prettierrc.json

{
  "singleQuote": true
}

JestもTypeScriptに合わせようと思ったのですが

$ npm i -D @types/jest ts-jest

作成されたpackage.jsonに記載されているJestが古くてそのままでは入らないようなので

$ npm i -D @types/jest ts-jest                                                                                                   
npm ERR! code ERESOLVE                                                                                                                                                                   
npm ERR! ERESOLVE unable to resolve dependency tree                                                                                                                                      
npm ERR!                                                                                                                                                                                 
npm ERR! While resolving: replaced-by-user-input@0.0.1                                                                                                                                   
npm ERR! Found: jest@26.6.3                                                                                                                                                              
npm ERR! node_modules/jest
npm ERR!   dev jest@"^26.6.3" from the root project
npm ERR! 
npm ERR! Could not resolve dependency:
npm ERR! peer jest@"^27.0.0" from ts-jest@27.1.2
npm ERR! node_modules/ts-jest
npm ERR!   dev ts-jest@"*" from the root project
npm ERR! 
npm ERR! Fix the upstream dependency conflict, or retry
npm ERR! this command with --force, or --legacy-peer-deps
npm ERR! to accept an incorrect (and potentially broken) dependency resolution.
npm ERR! 
npm ERR! See $HOME/.npm/eresolve-report.txt for a full report.

npm ERR! A complete log of this run can be found in:
npm ERR!     $HOME/.npm/_logs/2022-01-08T17_33_07_128Z-debug.log

1度Jestをアンインストールして

$ npm uninstall jest

Jestも含めて再インストール。

$ npm i -D jest @types/jest ts-jest

こんな感じになりました。

  "dependencies": {
    "aws-sdk": "^2.799.0"
  },
  "devDependencies": {
    "@types/aws-lambda": "^8.10.89",
    "@types/jest": "^27.4.0",
    "@types/node": "^14.18.5",
    "aws-sdk-mock": "^5.1.0",
    "jest": "^27.4.7",
    "prettier": "2.5.1",
    "ts-jest": "^27.1.2",
    "typescript": "^4.5.4"
  },

Jestの設定。

$ npx ts-jest config:init

jest.config.js

/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
};

package.jsonscriptsは、こんな定義にしておきました。

  "scripts": {
    "build": "tsc --project . && cp package*.json dist",
    "format": "prettier --write src __tests__",
    "test": "jest"
  }

ここまでで、セットアップ的なものはおしまい。

TypeScriptでソースコードを書き直す

では、アプリケーションをTypeScriptに書き直していきます。

AWS Lambda関数側。

src/handlers/s3-json-logger.ts

import AWS from 'aws-sdk';
import { Context, S3Event } from 'aws-lambda';

const s3 = new AWS.S3();

export const s3JsonLoggerHandler = async (
  event: S3Event,
  context: Context
): Promise<void> => {
  const getObjectRequests = event.Records.map(async (record) => {
    const params = {
      Bucket: record.s3.bucket.name,
      Key: record.s3.object.key,
    };

    try {
      const data = await s3.getObject(params).promise();

      console.debug(`received data: ${data.Body}`);
      console.info(`${data.Body}`);

      return data.Body;
    } catch (err) {
      console.error(`Error calling S3 getObject: ${err}`);
      throw err;
    }
  });

  return Promise.all(getObjectRequests).then(() => console.debug('Complete!'));
};

テストコード側。

__tests__/unit/handlers/s3-json-logger-handler.test.ts

import { Context, S3Event } from 'aws-lambda';
import AWSMock from 'aws-sdk-mock';
import AWS from 'aws-sdk';

//import { s3JsonLoggerHandler } from '../../../src/handlers/s3-json-logger';

test('Test s3JsonLoggerHandler', async () => {
  const objectBody = '{"Test": "PASS"}';
  const getObjectResp = {
    Body: objectBody,
  };

  AWSMock.setSDKInstance(AWS);

  AWSMock.mock('S3', 'getObject', function (params, callback) {
    callback(null, getObjectResp);
  });

  const event = {
    Records: [
      {
        s3: {
          bucket: {
            name: 'test-bucket',
          },
          object: {
            key: 'test-key',
          },
        },
      },
    ],
  };

  console.info = jest.fn();
  const handler = require('../../../src/handlers/s3-json-logger');

  await handler.s3JsonLoggerHandler(event, null);
  //await s3JsonLoggerHandler(event as S3Event, null as unknown as Context);

  expect(console.info).toHaveBeenCalledWith(objectBody);
  AWSMock.restore('S3');
});

aws-sdk-mockは、初めて使います。

GitHub - dwyl/aws-sdk-mock: AWSomocks for Javascript/Node.js aws-sdk tested, documented & maintained. Contributions welcome!

使い方は、元のソースコードaws-sdk-mockに記載されていたTypeScriptのコード例に合わせてみました。

aws-sdk-mock / Using TypeScript

AWS SDK Mockの設定をした後に、テスト対象のモジュールをrequireしないとうまくモック化できませんでした…。
コメントアウトしている箇所を戻し、先にimport ... fromするようにすると、このテストは失敗します。

元のJavaScriptソースコードは削除して

$ rm src/handlers/s3-json-logger.js __tests__/unit/handlers/s3-json-logger-handler.test.js

確認。

$ npm test

OKですね。

 PASS  __tests__/unit/handlers/s3-json-logger-handler.test.ts (7.152 s)
  ✓ Test s3JsonLoggerHandler (41 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        7.221 s, estimated 8 s
Ran all test suites

ビルド。

$ npm run build

結果。

$ tree dist
dist
├── package-lock.json
├── package.json
└── s3-json-logger.js

0 directories, 3 files

LocalStackにデプロイする

では、こちらをLocalStackにデプロイしましょう。

が、その前にtemplate.yamlを修正する必要があります。

最初はこうすればいいのかなと思っていたのですが、

Resources:
  S3JsonLoggerFunction:
    Type: AWS::Serverless::Function
    Properties:
      #Handler: src/handlers/s3-json-logger.s3JsonLoggerHandler
      Handler: dist/handlers/s3-json-logger.s3JsonLoggerHandler

このままだと、LocalStackではうまくいかないようです。

2022-01-09T08:04:37.832:WARNING:localstack.utils.cloudformation.template_deployer: Error calling <bound method ClientCreator._create_api_method.<locals>._api_call of <botocore.client.S3 object at 0x7fe48b7fde20>> with params: {'Bucket': '', 'ACL': 'public-read'} for resource: {'Type': 'AWS::S3::Bucket', 'DependsOn': ['S3JsonLoggerFunctionS3NewObjectEventPermission'], 'LogicalResourceId': 'AppBucket', 'Properties': {'BucketName': '', 'NotificationConfiguration': {'LambdaConfigurations': [{'Function': 'arn:aws:lambda:us-east-1:000000000000:function:my-stack-S3JsonLoggerFunction-e30badce', 'Filter': {'S3Key': {'Rules': [{'Name': 'suffix', 'Value': '.json'}]}}, 'Event': 's3:ObjectCreated:*'}]}}, '_state_': {}}

こちらと同じ問題にぶつかります。

localstack.utils.cloudformation.template_deployer Error calling <bound method ClientCreator._create_api_method.<locals>._api_call of <botocore.client.Lambda object at · Issue #157 · localstack/serverless-localstack · GitHub

このissueはServerless Frameworkで検出されたものですが、どうやらCodeUriを使わずHandlerのみで定義すると
AWS SAMでも発生するようなので、今回は素直にCodeUriを追加してnpm run buildの結果を参照するように
しました。

Resources:
  S3JsonLoggerFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: dist/
      #Handler: src/handlers/s3-json-logger.s3JsonLoggerHandler
      #Handler: dist/handlers/s3-json-logger.s3JsonLoggerHandler
      Handler: s3-json-logger.s3JsonLoggerHandler
      Runtime: nodejs14.x

では、sam build

$ samlocal build

デプロイ用のAmazon S3バケットを作成して、デプロイ。

$ awslocal s3 mb s3://my-bucket
$ samlocal deploy --stack-name my-stack --region us-east-1 --s3-bucket my-bucket --parameter-overrides AppBucketName=event-bucket

この時、パラメーターの部分を-parameter-overrides AppBucketName=event-bucketで指定します。

Parameters:
  AppBucketName:
    Type: String
    Description: "REQUIRED: Unique S3 bucket name to use for the app."

今回は、イベント通知の元になるAmazon S3バケット名をevent-bucketとしました。

デプロイが完了。

2022-01-09 17:11:13 - Waiting for stack create/update to complete

CloudFormation events from stack operations
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------
ResourceStatus                              ResourceType                                LogicalResourceId                           ResourceStatusReason
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------
CREATE_COMPLETE                             AWS::CloudFormation::Stack                  S3JsonLoggerFunctionRole                    -
CREATE_COMPLETE                             AWS::CloudFormation::Stack                  my-stack                                    -
CREATE_COMPLETE                             AWS::CloudFormation::Stack                  AppBucket                                   -
CREATE_COMPLETE                             AWS::CloudFormation::Stack                  S3JsonLoggerFunctionS3NewObjectEventPermi   -
                                                                                        ssion
CREATE_COMPLETE                             AWS::CloudFormation::Stack                  S3JsonLoggerFunction                        -
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------

Successfully created/updated stack - my-stack in us-east-1

動作確認してみます。template.yamlを見ると、AppBucketNameで指定したAmazon S3バケットのキーの末尾が.json
終わるものを見るようなので

      Events:
        S3NewObjectEvent:
          Type: S3
          Properties:
            Bucket: !Ref AppBucket
            Events: s3:ObjectCreated:*
            Filter:
              S3Key:
                Rules:
                  - Name: suffix
                    Value: ".json"

作成したAmazon S3バケットに、オブジェクトを作成してみます。

$ echo '{"event": "object put"}' > sample1.json
$ awslocal s3 cp sample1.json s3://event-bucket/sample1.json

すると、AWS Lambda関数は動いているような雰囲気が見えるのですが…

2022-01-09T14:55:51.220:INFO:localstack.services.awslambda.lambda_utils: Determined lambda container network: bridge
2022-01-09T14:55:51.226:INFO:localstack.services.awslambda.lambda_utils: Determined main container target IP: 172.17.0.2
2022-01-09T14:55:51.226:INFO:localstack.services.awslambda.lambda_executors: Running lambda: arn:aws:lambda:us-east-1:000000000000:function:my-stack-S3JsonLoggerFunction-97e7c844

実際にはうまく起動できていないようです。何回繰り返しても、Amazon CloudWatch Logsにログが保存されません。

$ awslocal logs describe-log-groups 
{
    "logGroups": [
        {
            "logGroupName": "/aws/lambda/my-stack-S3JsonLoggerFunction-97e7c844",
            "creationTime": 1641740159197,
            "metricFilterCount": 0,
            "arn": "arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/my-stack-S3JsonLoggerFunction-97e7c844",
            "storedBytes": 0
        }
    ]
}

どうも、これと同じ問題にぶつかっているようなのですが

S3 event not triggering the lambda function in localstack · Issue #3183 · localstack/localstack · GitHub

question: AWS Lambda + S3 + SNS · Issue #4238 · localstack/localstack · GitHub

調べていくのも面倒になり、もう直接AWS CLIからAWS Lambda関数を呼び出すことにしました。

AWS SAMを使って、イベント用のデータを作成。

$ samlocal local generate-event s3 put --region us-east-1 --bucket event-bucket --key sample1.json > events/event-s3-sample1.json

これを、awslocal lambda invokeで呼び出します。関数名は、AWS SAMでデプロイされたものを確認して記載しています。

$ awslocal lambda invoke --function-name my-stack-S3JsonLoggerFunction-db576ba1 --payload "$(cat events/event-s3-sample1.json)" --cli-binary-format raw-in-base64-out result.json
{
    "StatusCode": 200,
    "LogResult": "",
    "ExecutedVersion": "$LATEST"
}

lambda invokeの結果自体はなにもありません。AWS Lambda関数自体、結果を返すように実装していませんからね。

result.json

null

Amazon CloudWatch Logsの方も確認。

$ awslocal logs tail /aws/lambda/my-stack-S3JsonLoggerFunction-db576ba1
2022-01-09T15:26:31.387000+00:00 2022/01/09/[LATEST]1497ec4d 2022-01-09T15:26:31.380Z   751ea32f-ab75-1b8d-3c20-4779dc2855ee    INFO    received data: {"event": "object put"}
2022-01-09T15:26:31.395000+00:00 2022/01/09/[LATEST]1497ec4d 2022-01-09T15:26:31.380Z   751ea32f-ab75-1b8d-3c20-4779dc2855ee    INFO    {"event": "object put"}
2022-01-09T15:26:31.403000+00:00 2022/01/09/[LATEST]1497ec4d 2022-01-09T15:26:31.381Z   751ea32f-ab75-1b8d-3c20-4779dc2855ee    DEBUG   Complete!

OKですね。

まとめ

AWS SAM+LocalStackで、Amazon S3のイベント通知を受け取るAWS Lambda関数をTypeScriptで書いてみました。

なのですが、だいぶ中途半端なことになりましたが…。
この原因をこれ以上追う意味もなさそうなのと、時間がかかりすぎるので諦めました。

Amazon SQSに置き換えても試してみましたが、こちらはデプロイがうまくいかないという問題にハマったので…。

今回は、ここまでで。