CLOVER🍀

That was when it all began.

AWS SAM+TypeScriptでAWS Lambda関数(+ Amazon API Gateway)を作成して、LocalStack上にデプロイする

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

AWS SAMを使って、TypeScriptで書いたアプリケーションを試してみたいなということで。

やり方

TypeScriptのビルド結果+package.jsonを配置したディレクトリに、AWS SAMのテンプレート(template.yaml)に
書かれているCodeUriを合わせてあげれば良さそうです。

環境

今回の環境は、こちら。LocalStackで試します。

$ localstack --version
0.13.2.1


$ python3 -V
Python 3.8.10


$ awslocal --version
aws-cli/2.4.7 Python/3.8.8 Linux/5.4.0-91-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 SAMのプロジェクトを作成します。

$ samlocal init
Which template source would you like to use?
        1 - AWS Quick Start Templates
        2 - Custom Template Location
Choice: 1
What package type would you like to use?
        1 - Zip (artifact is a zip uploaded to S3)
        2 - Image (artifact is an image uploaded to an ECR image repository)
Package type: 1

Which runtime would you like to use?
        1 - nodejs14.x
        2 - python3.9
        3 - ruby2.7
        4 - go1.x
        5 - java11
        6 - dotnetcore3.1
        7 - nodejs12.x
        8 - nodejs10.x
        9 - python3.8
        10 - python3.7
        11 - python3.6
        12 - python2.7
        13 - ruby2.5
        14 - java8.al2
        15 - java8
        16 - dotnetcore2.1
Runtime: 1

Project name [sam-app]: sam-typescript-example

Cloning from https://github.com/aws/aws-sam-cli-app-templates

AWS quick start application templates:
        1 - Hello World Example
        2 - Step Functions Sample App (Stock Trader)
        3 - Quick Start: From Scratch
        4 - Quick Start: Scheduled Events
        5 - Quick Start: S3
        6 - Quick Start: SNS
        7 - Quick Start: SQS
        8 - Quick Start: Web Backend
Template selection: 1

    -----------------------
    Generating application:
    -----------------------
    Name: sam-typescript-example
    Runtime: nodejs14.x
    Architectures: x86_64
    Dependency Manager: npm
    Application Template: hello-world
    Output Directory: .

    Next application steps can be found in the README file at ./sam-typescript-example/README.md


    Commands you can use next
    =========================
    [*] Create pipeline: cd sam-typescript-example && sam pipeline init --bootstrap
    [*] Test Function in the Cloud: sam sync --stack-name {stack-name} --watch

ランタイムはNode.js 14.xです。

作成されたディレクトリ、ファイル。

$ tree sam-typescript-example
sam-typescript-example
├── README.md
├── events
│   └── event.json
├── hello-world
│   ├── app.js
│   ├── package.json
│   └── tests
│       └── unit
│           └── test-handler.js
└── template.yaml

4 directories, 6 files

ディレクトリ内に移動して

$ cd sam-typescript-example

少し中身を見てみます。

template.yaml

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
  sam-typescript-example

  Sample SAM Template for sam-typescript-example

# More info about Globals: https://github.com/awslabs/serverless-application-model/blob/master/docs/globals.rst
Globals:
  Function:
    Timeout: 3

Resources:
  HelloWorldFunction:
    Type: AWS::Serverless::Function # More info about Function Resource: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#awsserverlessfunction
    Properties:
      CodeUri: hello-world/
      Handler: app.lambdaHandler
      Runtime: nodejs14.x
      Architectures:
        - x86_64
      Events:
        HelloWorld:
          Type: Api # More info about API Event Source: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#api
          Properties:
            Path: /hello
            Method: get

Outputs:
  # ServerlessRestApi is an implicit API created out of Events key under Serverless::Function
  # Find out more about other implicit resources you can reference within SAM
  # https://github.com/awslabs/serverless-application-model/blob/master/docs/internals/generated_resources.rst#api
  HelloWorldApi:
    Description: "API Gateway endpoint URL for Prod stage for Hello World function"
    Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/hello/"
  HelloWorldFunction:
    Description: "Hello World Lambda Function ARN"
    Value: !GetAtt HelloWorldFunction.Arn
  HelloWorldFunctionIamRole:
    Description: "Implicit IAM Role created for Hello World function"
    Value: !GetAtt HelloWorldFunctionRole.Arn

hello-world/package.json

{
  "name": "hello_world",
  "version": "1.0.0",
  "description": "hello world sample for NodeJS",
  "main": "app.js",
  "repository": "https://github.com/awslabs/aws-sam-cli/tree/develop/samcli/local/init/templates/cookiecutter-aws-sam-hello-nodejs",
  "author": "SAM CLI",
  "license": "MIT",
  "dependencies": {
    "axios": "^0.21.1"
  },
  "scripts": {
    "test": "mocha tests/unit/"
  },
  "devDependencies": {
    "chai": "^4.2.0",
    "mocha": "^8.2.1"
  }
}

hello-world/app.js

// const axios = require('axios')
// const url = 'http://checkip.amazonaws.com/';
let response;

/**
 *
 * Event doc: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html#api-gateway-simple-proxy-for-lambda-input-format
 * @param {Object} event - API Gateway Lambda Proxy Input Format
 *
 * Context doc: https://docs.aws.amazon.com/lambda/latest/dg/nodejs-prog-model-context.html 
 * @param {Object} context
 *
 * Return doc: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html
 * @returns {Object} object - API Gateway Lambda Proxy Output Format
 * 
 */
exports.lambdaHandler = async (event, context) => {
    try {
        // const ret = await axios(url);
        response = {
            'statusCode': 200,
            'body': JSON.stringify({
                message: 'hello world',
                // location: ret.data.trim()
            })
        }
    } catch (err) {
        console.log(err);
        return err;
    }

    return response
};

README.mdには、使い方が書かれています。

生成されたAWS Lambda関数を、TypeScriptで書き直す

この時点で、hello-worldディレクトリ内にはNode.js向けのAWS Lambda関数があるわけですが、こちらをTypeScriptで
書き直していきます。

まずはディレクトリ内へ。

$ cd hello-world

testsはいったん削除。

$ rm -rf tests

package.jsondevDependenciesは、空にしておきます。dependenciesに書かれているaxiosも使わないのですが、
今回はこのまま残しておくことにします。

  "dependencies": {
    "axios": "^0.21.1"
  },
  "scripts": {
    "test": "mocha tests/unit/"
  },
  "devDependencies": {
  }

TypeScriptとPrettierをインストール。

$ npm i -D typescript
$ npm i -D -E prettier

ソースコードは、srcディレクトリに配置することにします。

$ mkdir src

TypeScriptの設定。

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"
  ]
}

TypeScriptファイルのビルド結果は、distディレクトリに出力するようにします。

Prettierの設定。

.prettierrc.json

{
  "singleQuote": true
}

ビルドとフォーマットをscriptsに定義。この内容は、また後で変更します。

  "scripts": {
    "build": "tsc --project .",
    "format": "prettier --write src"
  },

Node.jsとAWS Lambdaの型宣言のインストール。

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

調べていて、aws-lambdaの型宣言の意味がずっとわからなかったのですが、README.mdに書かれていました。

https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/aws-lambda/README.md

Types helpful for implementing handlers in the AWS Lambda NodeJS runtimes, the handler interface and types for AWS-defined trigger sources.

Unrelated to the npm package aws-lambda, a CLI tool.

aws-lambdaパッケージとは関係がなく、あくまで型宣言のみのようです。

scriptsも書き直して、この時点でこうなりました。

  "dependencies": {
    "axios": "^0.21.1"
  },
  "scripts": {
    "build": "tsc --project .",
    "format": "prettier --write src"
  },
  "devDependencies": {
    "@types/aws-lambda": "^8.10.89",
    "@types/node": "^14.18.4",
    "prettier": "2.5.1",
    "typescript": "^4.5.4"
  }

TypeScriptで書き直したソースコード。元にしたJavaScriptファイルから、QueryStringを見るように少し変更しています。

src/app.ts

import { APIGatewayProxyEvent, Context } from 'aws-lambda';

export const lambdaHandler = async (
  event: APIGatewayProxyEvent,
  context: Context
) => {
  try {
    let message;

    if (event.queryStringParameters && event.queryStringParameters['message']) {
      message = event.queryStringParameters['message'];
    } else {
      message = 'world';
    }

    return {
      statusCode: 200,
      body: JSON.stringify({ message: `hello ${message}` }),
    };
  } catch (err) {
    console.log(err);
    return err;
  }
};

TypeScriptでのソースコードは書き終えたので、生成されていたJavaScriptファイルは削除します。

$ rm app.js

最後に、scriptsの内容をもう1度変更します。buildで、TypeScriptのビルド後にpackage.jsonおよびpackage-lock.json

distディレクトリにコピーするように変更しておきます。
※ただ、sam buildの時はpackage-lock.jsonを見ていないかも…

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

ビルド。

$ npm run build

出力結果。

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

0 directories, 3 files

これで、AWS Lambda関数側の準備は終わりました。

LocalStackにデプロイする

続いて、作成したAWS Lambda関数をLocalStackにデプロイします。

ひとつ上のディレクトリに移動して

$ cd ..

現時点でこのような構成になっています。

$ tree -L 3
.
├── README.md
├── events
│   └── event.json
├── hello-world
│   ├── dist
│   │   ├── app.js
│   │   ├── package-lock.json
│   │   └── package.json
│   ├── node_modules
│   │   ├── @types
│   │   ├── prettier
│   │   └── typescript
│   ├── package-lock.json
│   ├── package.json
│   ├── src
│   │   └── app.ts
│   └── tsconfig.json
└── template.yaml

8 directories, 10 files

ここでtemplate.yamlCodeUriを、hello-worldからhello-world/distに変更します。

Resources:
  HelloWorldFunction:
    Type: AWS::Serverless::Function # More info about Function Resource: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#awsserverlessfunction
    Properties:
      CodeUri: hello-world/dist
      Handler: app.lambdaHandler
      Runtime: nodejs14.x

これで、sam build時にこの場所を見てくれるようです。

では、ビルド(LocalStackのAWS SAM CLIを使っていますが)。

$ samlocal build

このような表示になり

Building codeuri: /path/to/sam-typescript-example/hello-world/dist runtime: nodejs14.x metadata: {} architecture: x86_64 functions: ['HelloWorldFunction']                                                                                                                                                                       
Running NodejsNpmBuilder:NpmPack                                                                                                                                            
Running NodejsNpmBuilder:CopyNpmrc                                                                                                                                          
Running NodejsNpmBuilder:CopySource                                                                                                                                         
Running NodejsNpmBuilder:NpmInstall                                                                                                                                         
Running NodejsNpmBuilder:CleanUpNpmrc                                                                                                                                       
                                                                                                                                                                            
Build Succeeded                                                                                                                                                             
                                                                                                                                                                            
Built Artifacts  : .aws-sam/build                                                                                                                                           
Built Template   : .aws-sam/build/template.yaml                                                                                                                             
                                                                                                                                                                            
Commands you can use next                                                                                                                                                   
=========================                                                                                                                                                   
[*] Invoke Function: sam local invoke
[*] Test Function in the Cloud: sam sync --stack-name {stack-name} --watch
[*] Deploy: sam deploy --guided
        

distディレクトリに配置した内容が.aws-sam/build/HelloWorldFunctionに配置されているとともに、package.json
dependenciesの内容(今回はaxios)がインストールされていることもわかります。

$ tree .aws-sam -L 4
.aws-sam
├── build
│   ├── HelloWorldFunction
│   │   ├── app.js
│   │   ├── node_modules
│   │   │   ├── axios
│   │   │   └── follow-redirects
│   │   └── package.json
│   └── template.yaml
└── build.toml

5 directories, 4 files

devDependendiesは含まれていませんね。

そしてここで気づいたのですが、package-lock.jsonがありませんね…。package.jsonのバージョン指定を注意して
おかないと、ここでモジュールのバージョンがずれるかもしれません…。

あとは、デプロイするだけです。Amazon S3バケットを作成して

$ awslocal s3 mb s3://my-bucket

デプロイ。

$ samlocal deploy --stack-name my-stack --region us-east-1 --s3-bucket my-bucket

Amazon API GatewayREST APIのIDを取得して

$ REST_API_ID=$(awslocal apigateway get-rest-apis --query 'items[0].id' --output text)

確認。

$ curl http://localhost:4566/restapis/$REST_API_ID/Prod/_user_request_/hello
{"message":"hello world"}


$ curl http://localhost:4566/restapis/$REST_API_ID/Prod/_user_request_/hello?message=lambda
{"message":"hello lambda"}

OKですね。

まとめ

AWS SAMを使って、TypeScriptで書いたNode.jsアプリケーションをLocalStackにAWS Lambda関数としてデプロイして
みました。

package.jsonの扱いと、template.yamlCodeUriを調整すればなんとかなりそうなので、これで覚えておきましょう。