AWS SDK (Node.js) で作成した タイムベース CloudWatch Events で Lambda Function を起動する

サーバーレスでAWSサービスを組み合わせてアプリケーション開発をしていると、連携するために必要な設定の不足で動かない…というシーンに出くわします。今回、以下のような構成で AWS Lambda をスケジュール起動する CloudWatch Events を作成したのですが、うまく動かず苦労したので記録を残します。こういうのを経験すると思うのですが、AWSコンソールで画面から操作するとき、裏でいろいろなリソースを作ってくれてるんですよね。

images/arch.png

まずは大まかな流れと注意点から。AWS SDK を使って CloudWatch Events を作成する際は、以下のリソースを作成します。

  1. CloudWatch Events(タイムベース)のルールを作成する
  2. CloudWatch Events から起動するターゲットを指定する。今回のターゲットは Lambda Function
  3. Lambda Function に、CloudWatch Events からの起動を許可するようポリシーを作成する

次に注意点です。

  • Lambda Function に、CloudWatch Events からの起動を許可するようポリシーを作成しないと、Lambda Function が起動しない
  • このポリシーを作成する際、SourceAccount というパラメータを指定してしまうと Lambda Function が起動しない

これらはドキュメントCloudWatch イベント のトラブルシューティングおよび CloudFormation で CloudWatch Events を作成するブログにいずれも記載されていますが見事にハマったので記載しておきます。

動作環境

使用ツール バージョン
AWS Lambda Node.js Node.js 10.x
aws-sdk-js v2.467.0
TypeScript 3.4.5

Lambda Function (Node.js) による CloudWatch Events の作成

CloudWatch Events の作成は Lambda Function から行いました。CloudWatch Events を作成して Lambda Function と接続するためには、以下の材料が必要になります。動作確認程度であれば決め打ちでも良いでしょう。

  • CloudWatch Events のルール名
  • 起動スケジュール。cron式で指定する
  • CloudWatch Events から起動したい Lambda Function の ARN
  • (必要に応じて)CloudWatch Events から起動したい Lambda Function への入力

ソースコードです。

import * as AWS from 'aws-sdk';
import {
    DeleteRuleRequest,
    PutRuleRequest,
    PutRuleResponse,
    PutTargetsRequest,
    RemoveTargetsRequest
} from 'aws-sdk/clients/cloudwatchevents';
import { AddPermissionRequest, GetPolicyRequest, RemovePermissionRequest } from 'aws-sdk/clients/lambda';
import { IGreetingRequest } from '../greeting';

const Env = process.env.ENV!;
const Region = process.env.REGION!;
const GreetingLambdaArn = process.env.GREETING_LAMBDA_ARN!;
const CloudWatchEvent = new AWS.CloudWatchEvents({
    apiVersion: '2015-10-17',
    region: Region
});
const AWSLambda = new AWS.Lambda({
    apiVersion: '2015-03-31',
    region: Region
});


exports.createEvent = (event: ICreateEventRequest): Promise<ICreateRuleResult> => {
    return CloudWatchEventHandler.createStartRule(event);
};

interface ICreateEventRequest {
    ruleName: string;
    triggerHour: number;
    triggerMinutes: number;
}

interface ICreateRuleResult {
    ruleName: string;
}

class CloudWatchEventHandler {

    static async createStartRule(request: ICreateEventRequest): Promise<ICreateRuleResult> {

        // ① - スケジュールベースのルールを作成
        const scheduleExpression = `${request.triggerMinutes} ${request.triggerHour} * * ? *`;
        const ruleName = `${Env}-${request.ruleName}`;
        const createRuleParam: PutRuleRequest = {
            Name: ruleName,
            State: 'ENABLED',
            ScheduleExpression: `cron(${scheduleExpression})`
        };

        const putResult: PutRuleResponse = await CloudWatchEvent.putRule(createRuleParam).promise();


        // ② - イベントのターゲットを作成。ここでは Lambda Function を起動する。JSONペイロードを渡す
        const lambdaInputJson: IGreetingRequest = {
            greet: 'こんにちは!CloudWatch Events から Lambda Functionを起動しました。'
        };
        const putTargetParam: PutTargetsRequest = {
            Rule: ruleName,
            Targets: [
                {
                    Id: ruleName,
                    Arn: GreetingLambdaArn,
                    Input: JSON.stringify(lambdaInputJson)
                }
            ]
        };
        await CloudWatchEvent.putTargets(putTargetParam).promise();

        // ③ - Lambda Function が CloudWatch Eventsからの起動を許可するよう、パーミッションを追加
        const addPermissionParams: AddPermissionRequest = {
            Action: 'lambda:InvokeFunction',
            FunctionName: GreetingLambdaArn,
            Principal: 'events.amazonaws.com',
            SourceArn: putResult.RuleArn,
            StatementId: createRuleParam.Name
        };
        await AWSLambda.addPermission(addPermissionParams).promise();

        return {
            ruleName
        }
    }
}

① - スケジュールベースのルールを作成

CloudWatch Events のルールを作成します。今回はスケジュール実行としたいので 入力値から cron 式を構築します。スケジュールベースのイベントを構築する際は以下の2点に注意してください。

  1. cron で指定する時間は UTC です。日本時間で入力があった場合、時間や曜日を調整する必要があります
  2. PutRuleRequest で指定する ScheduleExpression へは文字列を指定しますが、スケジュールベースで起動したい場合は明示的に cron() という文字列を含めなければなりません。ちなみに定期実行の場合は rate() という書き方になります。文字列で書くというのが、少し違和感がありますね

パラメータを指定して CloudWatchEvent.putRule() でイベントを作成すると、 PutRuleResponse が手に入ります。ここに含まれる RuleArn は後で使うので保持しておきます。

② - イベントのターゲットを作成

CloudWatch Events から Lambda Function を作成するためターゲットを作成します。PutTargetsRequest の形式を見るに、どうやらひとつのルールから複数のターゲットを起動できるみたいですね。このように、 TypeScript でコーディングを進めると AWS SDK のパラメータの型から必要な情報が類推できることも多く、開発効率の向上を実感できます。

今回つくるターゲットはひとつだけです。DynamoDB にあいさつを書き込む簡単な Lambda Function を用意しておき、それを起動します。ここで、起動する対象の Lambda Function ARN が必要になります。今回は環境変数で定義することとしました。また、あいさつ Lambda の入力値をJSON形式で指定します。CloudWatchEvent.putTargets() を実行することでターゲットを作成できました。

③ - Lambda Function にパーミッションを追加

さて、これだけでは起動できません。正確には、CloudWatch Events 自体はその時が来れば動きますが、 Lambda Function を実行する権限がなく、見かけ上なにも起きないまま終わってしまいます。そこで、 Lambda Function 側に CloudWatch Events からの起動を許可するよう、ポリシーを追加します。 AddPermissionRequest を構築しましょう。

  • Action: Lambda Function を起動したい場合は 'lambda:InvokeFunction' 決め打ちでOKです。
  • FunctionName: Function 名か ARN を指定します。ここでは環境変数から ARN が手に入っているのでそれを指定しています。
  • Principal: CloudWatch Events からの起動を示す 'events.amazonaws.com' 決め打ちです。
  • SourceArn: ①で保持した putResult.RuleArn を使います。
  • StatementId: 他のルールと重複しなければ何でも良いです。ここでは単純にルール名をステートメントIDとしています。

!注意

  • AddPermissionRequerst には SourceAccount というキーも指定できますが、これを指定してしまうとこれまた Lambda Function が起動できなくなるのでご注意ください。
  • 一連の作成処理を行うためには、CloudWatch Events を作成するポリシーと AWS Lambda にパーミッッションを付与するポリシーが必要です。

起動

CloudWatch Events 作成用の Lambda Function を、 AWS CLI から起動してみます。

aws lambda invoke \
--function-name stg-cloud-watch-event-sample-create-cloud-watch-event --log-type Tail \
--payload '{"ruleName":"wada-rule", "triggerHour":"12", "triggerMinutes":"30"}' \
outputfile.txt

{
    "StatusCode": 200,
    "LogResult": "XXXXXXXXXXXXXXX",
    "ExecutedVersion": "$LATEST"
}

すると、CloudWatch Events が作成され、ターゲットも起動しており…

create_rule.png

Lambda Function の起動トリガーとして CloudWatch Events が追加されていることが確認できます。この状態になればOKです。

create_lambda-permission.png

CloudWatch Events のトリガーにより Lambda Function が起動

CloudWatch Events のトリガーにより Lambda Function が起動するところまで見届けましょう。起動される Lambda はあいさつ文を受け取りそれを DynamoDB へ保存するシンプルな Function です。

import 'source-map-support/register';
import * as AWS from 'aws-sdk';
import { UpdateItemInput } from 'aws-sdk/clients/dynamodb';
import * as uuid from 'uuid';
import { IGreetingRequest } from '../greeting';

const EnvironmentVariableSample = process.env.GREETING_TABLE_NAME!;
const Region = process.env.REGION!;

const DYNAMO = new AWS.DynamoDB(
    {
        apiVersion: '2012-08-10',
        region: Region
    }
);

exports.handler = async (event: IGreetingRequest) => {
    return HelloWorldController.hello(event);
};

export class HelloWorldController {

    public static hello(payload: IGreetingRequest): Promise<IGreeting> {
        console.log(payload);
        return GreetingDynamodbTable.greetingStore(this.createMessage(payload));
    }

    private static createMessage(payload: IGreetingRequest): IGreeting {
        return {
            title: 'hello, lambda!',
            description: payload.greet,
        }
    }
}

class GreetingDynamodbTable {

    public static async greetingStore(greeting:IGreeting): Promise<any> {

        const params: UpdateItemInput = {
            TableName: EnvironmentVariableSample,
            Key: {greetingId: {S: uuid.v4()}},
            UpdateExpression: [
                'set title = :title',
                'description = :description'
            ].join(', '),
            ExpressionAttributeValues: {
                ':title': {S: greeting.title},
                ':description': {S: greeting.description}
            }
        };
        return DYNAMO.updateItem(params).promise()
    }
}

export interface IGreeting {
    title: string;
    description: string;
}

create_dynamo.png

スケジュールでトリガーされ、あいさつ用 Lambda Function が起動すると、DynamoDB に書き込まれることが確認できました。

Lambda Function (Node.js) による CloudWatch Events の削除

作成したなら削除もしたいよねと考えるのが人のサガでしょう。考え方としては作成したリソースをすべて消せばOKです。リソースによっては順番が関係する(例えば、起動ターゲットが残っている場合にその CloudWatch Events は削除できない、など)ため、作成した順序の逆をたどると安全なことが多いです。今回の場合も以下の順で削除しています。

  1. あいさつ用 Lambda Function からパーミッションを削除
  2. CloudWatch Events からターゲットを削除
  3. CloudWatch Events のルールを削除

参考までにソースコードです。

import * as AWS from 'aws-sdk';
import {
    DeleteRuleRequest,
    PutRuleRequest,
    PutRuleResponse,
    PutTargetsRequest,
    RemoveTargetsRequest
} from 'aws-sdk/clients/cloudwatchevents';
import { AddPermissionRequest, GetPolicyRequest, RemovePermissionRequest } from 'aws-sdk/clients/lambda';
import { IGreetingRequest } from '../greeting';

const Env = process.env.ENV!;
const Region = process.env.REGION!;
const GreetingLambdaArn = process.env.GREETING_LAMBDA_ARN!;
const CloudWatchEvent = new AWS.CloudWatchEvents({
    apiVersion: '2015-10-17',
    region: Region
});
const AWSLambda = new AWS.Lambda({
    apiVersion: '2015-03-31',
    region: Region
});


exports.removeEvent = async (event: ICreateRuleResult): Promise<void> => {
    await CloudWatchEventHandler.removeRule(event);
};

interface ICreateRuleResult {
    ruleName: string;
}

class CloudWatchEventHandler {

    static async removeRule(result: ICreateRuleResult): Promise<void> {

        // ① - Lambda Function のパーミッションを削除
        const getStartPolicyParam: GetPolicyRequest = {
            FunctionName: GreetingLambdaArn
        };
        const policy = await AWSLambda.getPolicy(getStartPolicyParam).promise();

        if (policy.Policy) {
            const policyDocument: IPolicyDocument = JSON.parse(policy.Policy) as IPolicyDocument;

            // 削除しようとしている CloudWatch Events に相当するポリシードキュメントを探す
            const targetPolicy = policyDocument.Statement
                .find(statement => statement.Condition.ArnLike['AWS:SourceArn'].includes(result.ruleName));

            // ポリシーが見つかったらSidを指定して削除する
            if (targetPolicy) {
                console.log('policy found:', targetPolicy);
                const removePolicyParam: RemovePermissionRequest = {
                    FunctionName: GreetingLambdaArn,
                    StatementId: targetPolicy.Sid
                };

                await AWSLambda.removePermission(removePolicyParam).promise();
            }
        }

        // ② - CloudWatch Events のターゲットを削除する
        const removeTargetParam: RemoveTargetsRequest = {
            Ids: [result.ruleName],
            Rule: result.ruleName
        };

        try {
            await CloudWatchEvent.removeTargets(removeTargetParam).promise();
        } catch (e) {
            console.log(`failed to delete targets:`, e);
        }

        // ③ - CloudWatch Events のルールを削除する
        const deleteRuleParam: DeleteRuleRequest = {
            Name: result.ruleName,
            Force: true
        };
        await CloudWatchEvent.deleteRule(deleteRuleParam).promise();
    }
}

interface IPolicyDocument {
    Statement: Statement[];
}

interface Statement {
    Condition: Condition
    Sid: string;
}

interface Condition {
    ArnLike: ArnLike;
}

interface ArnLike {
    'AWS:SourceArn': string;
}

SAM テンプレート

これらをデプロイするための SAM テンプレートも載せておきます。まず、TypeScript のソースコードを Lambda Function 用にビルドするために webpack を利用しています。

const path = require('path');
const nodeExternals = require('webpack-node-externals');

module.exports = {
    mode: 'development',
    target: 'node',
    entry: {
        'hello-world': path.resolve(__dirname, './src/lambda/handlers/hello/hello-world.ts'),
        'cloud-watch-event-handler': path.resolve(__dirname, './src/lambda/handlers/cli/cloud-watch-event-handler.ts'),
    },
    ...
};

そしてビルドされた JavaScript ファイルを指定して CloudFormation でデプロイします。

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Resources:
  HelloWorldHelloLambda:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: !Sub ${Env}-${AppName}-hello
      Role: !GetAtt HelloWorldLambdaRole.Arn
      Handler: hello-world/index.handler
      Runtime: nodejs8.10
      CodeUri:
        Bucket: !Ref DeployBucketName
        Key: !Sub ${ChangeSetHash}/dist.zip
      Timeout: 5
      Environment:
        Variables:
          ENV: !Ref Env
          GREETING_TABLE_NAME: !Ref GreetingTableName
          REGION: !Ref AWS::Region
  HelloWorldCreateCloudWatchEventLambda:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: !Sub ${Env}-${AppName}-create-cloud-watch-event
      Role: !GetAtt HelloWorldLambdaRole.Arn
      Handler: cloud-watch-event-handler/index.createEvent
      Runtime: nodejs10.x
      CodeUri:
        Bucket: !Ref DeployBucketName
        Key: !Sub ${ChangeSetHash}/dist.zip
      Timeout: 5
      Environment:
        Variables:
          ENV: !Ref Env
          REGION: !Ref AWS::Region
          GREETING_LAMBDA_ARN: !GetAtt HelloWorldHelloLambda.Arn # ① 
  HelloWorldRemoveCloudWatchEventLambda:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: !Sub ${Env}-${AppName}-remove-cloud-watch-event
      Role: !GetAtt HelloWorldLambdaRole.Arn
      Handler: cloud-watch-event-handler/index.removeEvent
      Runtime: nodejs10.x
      CodeUri:
        Bucket: !Ref DeployBucketName
        Key: !Sub ${ChangeSetHash}/dist.zip
      Timeout: 5
      Environment:
        Variables:
          ENV: !Ref Env
          REGION: !Ref AWS::Region
          GREETING_LAMBDA_ARN: !GetAtt HelloWorldHelloLambda.Arn # ②

  HelloWorldLambdaRole:
    Type: 'AWS::IAM::Role'
    Properties:
      RoleName: !Sub ${Env}-${AppName}-lambda-role
      ManagedPolicyArns:
        - 'arn:aws:iam::aws:policy/AmazonDynamoDBFullAccess'
        - 'arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole'
        - 'arn:aws:iam::aws:policy/AmazonS3FullAccess'
        - 'arn:aws:iam::aws:policy/CloudWatchEventsFullAccess'   #
        - 'arn:aws:iam::aws:policy/AWSLambdaFullAccess'          # ③
      Policies:
        - PolicyName: PermissionToPassAnyRole
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              Effect: Allow
              Action:
                - iam:PassRole
              Resource: !Sub arn:aws:iam::${AWS::AccountId}:role/*
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          -
            Effect: 'Allow'
            Principal:
              Service:
                - 'lambda.amazonaws.com'
            Action:
              - 'sts:AssumeRole'
  • ①②: あいさつ Lambda の ARN は同一テンプレート内であれば !GetAtt で取得できます。それを環境変数に設定しています。
  • ③: CloudWatch Events を作成するためにマネージドポリシーを利用しています。CloudWatch Events と Lambda Function への強い権限をもたせました。

まとめ

Node.js の AWS SDK から、CloudWatch Events の作成と削除をやってみました。AWS SDK を使って開発していると、マネージドコンソールでの作業では見えないリソース作成手順や設定内容が見えたりして、面白いです。ときにはハマることもありますが、逆に AWS の内部構造が垣間見えることも多く、それは結果的にたくさんの応用が効く要素知識になりえます。

例えば今回は CloudWatch Events から Lambda Function を起動するためのポリシー設定を Lambda Function側に行う ことを見落としていたため時間を要しました。今後、別のサービスで「おかしい。Lambda Function が起動しない」というシーンに出くわしたときに、「もしかしたら Lambda 側にポリシー設定が必要なのかも?」と疑うことができます。このように要素知識を増やしていって、どんどん開発を効率的に進めていけたら良いなと思っています。

ソースコード