これは、なにをしたくて書いたもの?
最近AWS SAM+LocalStackで、Amazon API Gateway+AWS 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.json
のscripts
は、こんな定義にしておきました。
"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に記載されていた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_': {}}
こちらと同じ問題にぶつかります。
この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 } ] }
どうも、これと同じ問題にぶつかっているようなのですが
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に置き換えても試してみましたが、こちらはデプロイがうまくいかないという問題にハマったので…。
今回は、ここまでで。