試験日まで毎日励ますサーバーレスLINE Botを作ろう(AWS CDK編)

こんにちは、SCSKの鈴木です。

本記事では「試験日まで毎日励ますサーバーレスLINE Botを作ろう(マネジメントコンソール編)」で作った構成をAWS CDKで構築したいと思います。(以下「マネジメントコンソール編」と呼びます)

「マネジメントコンソール編」の記事は以下から参照ください。

試験日まで毎日励ますサーバーレスLINE Botを作ろう(マネジメントコンソール編)
AWS LambdaやAmazon DynamoDB, Amazon API Gatewayを利用して、試験日まで毎日励ましてくれるLINE Botを作成しました。AWS CDK編の記事と合わせて読むことで理解が深まります。

AWS CDKとは?

以下の記事で概要について触れられているため、本記事では割愛します。

用語

以下、AWSサービスは次のように省略して呼びます。

  • AWS Lambda => Lambda
  • Amazon DynamoDB => DynamoDB
  • Amazon API Gateway => API Gateway
  • Amazon EventBridge => EventBridge
  • AWS Systems Manager => SSM
  • AWS KMS => KMS
  • AWS Cloud Development Kit => CDK
  • AWS CloudFormation => CloudFormation

前提

構成図のみ再掲しておきます。

architecture

事前準備

先程紹介した「マネジメントコンソール編」の以下の内容が完了していることが前提となります。リンクを再掲します。

  • LINE Botの準備
  • SSM Parameter StoreにLINEアクセスキーを保存
    ※ この手順のみマネジメントコンソールまたはAWS CLIから保存します。

また、CDKを利用するために、以下の準備も必要です。

環境の注意点

  • 本記事ではcdk 2.24.1を利用します。(2022/5/17 現在最新バージョン)
  • 本記事ではJavaScriptを利用しています。
    ※ TypeScriptが主流ですが、自信がないためJavaScriptを利用しました。ただ、TypeScriptを利用する場合も npm watch をすること以外はほぼ同様に実装可能だと思っています。

作成手順

プロジェクトの雛形作成

まずは、プロジェクトの雛形を作成します。空のディレクトリ上で以下を実行してください。

$ cdk init sample-app --language javascript

主要なファイルは以下ですが、主に触るのはlib/ディレクトリ名-stack.jsファイルになります。

  • package.json
    • aws-cdk-libにAWSをCDKで操作する主要なモジュールが含まれる
  • bin/ディレクトリ名.js
    • package.jsonから呼び出される。スタックのエントリーポイント
  • lib/ディレクトリ名-stack.js
    • CDKで起動するリソースを定義するファイル

lib/ディレクトリ名-stack.jsを編集

以下のように定義します。

const cdk = require('aws-cdk-lib');
const lambda = require('aws-cdk-lib/aws-lambda');
const apigw = require('aws-cdk-lib/aws-apigateway');
const dynamodb = require('aws-cdk-lib/aws-dynamodb');
const iam = require('aws-cdk-lib/aws-iam');
const events = require('aws-cdk-lib/aws-events');
const targets = require('aws-cdk-lib/aws-events-targets');

class CdkWorkshopStack extends cdk.Stack {
  /**
   * @param {Construct} scope
   * @param {string} id
   * @param {StackProps=} props
   */

  constructor(scope, id, props) {
    super(scope, id, props);
   
    // DynamoDBの準備
    const examTable = new dynamodb.Table(this, 'Exam', {
      partitionKey: {
        name: 'test_name',
        type: dynamodb.AttributeType.STRING
      },
      tableName: 'Exam'
    });

    // Lambda実行ロールにSSMへのアクセスを許可する
    const executionLambdaRole = new iam.Role(this, 'MyLambdaExecutionRole',
      {
        roleName: 'my-lambda-execution-role',
        assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
        managedPolicies: [
          iam.ManagedPolicy.fromAwsManagedPolicyName(
            'service-role/AWSLambdaBasicExecutionRole'
          ),
          iam.ManagedPolicy.fromAwsManagedPolicyName(
            'AmazonSSMReadOnlyAccess'
          )
        ]
      }
    );

    // 依存関係をLambda Layerとして保存
    const layer = new lambda.LayerVersion(this, 'MyLayer', {
      code: lambda.Code.fromAsset('lambda-layer'),
      compatibleRuntimes: [lambda.Runtime.NODEJS_16_X]
    });

    // 試験データ登録側のLambda関数を作成する
    const register = new lambda.Function(this, 'RegisterHandler', {
      runtime: lambda.Runtime.NODEJS_16_X,
      code: lambda.Code.fromAsset('lambda-register'),
      handler: 'register.handler',
      role: executionLambdaRole,
      layers: [layer]
    });

    // LINEにメッセージを送るLambda関数の定義
    const pushMessage = new lambda.Function(this, 'PushMessageHandler', {
      runtime: lambda.Runtime.NODEJS_16_X,
      code: lambda.Code.fromAsset('lambda-push-msg'),
      handler: 'pushMessage.handler',
      role: executionLambdaRole,
      layers: [layer]
    });

    // DynamoDBへの読み書き許可
    examTable.grantReadWriteData(register);
    examTable.grantReadData(pushMessage);

    // API Gatewayの定義
    new apigw.LambdaRestApi(this, 'Endpoit', {
      handler: register
    });

    // EventBrigeでLambdaを定期実行
    new events.Rule(this, 'ScheduleRule', {
      schedule: events.Schedule.cron({ minute: '0', hour: '13' }),
      targets: [new targets.LambdaFunction(pushMessage, {})]
    });
  }
}

module.exports = { CdkWorkshopStack }
先程紹介した「マネジメントコンソール編」のタイトルになるべく合わせて、コードにコメントを残しています。2つの記事を見比べてみることを推奨します。
 
こんなコード書けないよ!という方、安心してください。
CDKのAPIリファレンスにはConstruct Library(L2 Construct)が用意されています。基本的にはリファレンスから実現したいものを探してきて、引っ張ってくるといった形で実現しました。
 
一方、全てのリソースがL2 Constructで用意されているわけではありません。その場合、CloudFormationによって定義されたリソースとして定義することが必要です。(L1 Construct)
詳細はAWS Cloud Development Kit (CDK) v2を参照ください。

ディレクトリ階層の確認

今後の作業で新規フォルダとして作成し、Lambda関数コードを実装していきます。
ディレクトリ階層を間違えないように「linebot」というプロジェクトディレクトリでの階層を例に表します。※主要フォルダ/ファイルのみ載せています。

linebot
├── bin
│   └── linebot.js
├── lambda-layer
│   └── nodejs
│     ├── node_modules
│     ├── package-lock.json
│     └── package.json
├── lambda-push-msg
│   └── pushMessage.js
├── lambda-register
│   └── register.js
├── lib
│   └── linebot-stack.js
├── node_modules
├── cdk.json
├── package.json
└── package-lock.json

依存関係をLambda Layerとして保存する

続いて、Lambdaの実装に入ります。「マネジメントコンソール編」同様、Lambda内で利用する外部モジュールをLambda Layerに登録します。

先程の「lib/ディレクトリ名.js」内では以下のように定義しました。

// 依存関係をLambda Layerとして保存
const layer = new lambda.LayerVersion(this, 'MyLayer', {
  code: lambda.Code.fromAsset('lambda-layer'),
  compatibleRuntimes: [lambda.Runtime.NODEJS_16_X]
});

これは、lambda-layerフォルダ内をLambda Layerとして保存します。
そこで新しくlambda-layerフォルダを作成し、外部モジュールをインストールしておきます。
インストールするものは「マネジメントコンソール編」同様です。

$ mkdir -p lambda-layer/node && cd lambda-layer/node
$ npm init
$ npm install @line/bot-sdk
$ npm install aws-sdk
$ npm install dayjs
ここでLambda Layerにはハマりポイントがあります。
node_modules等を格納するフォルダは「nodejs」フォルダに同梱されている必要があります。先ほど示したディレクトリ階層を参考に作成ください。
ガイド:Lambdaレイヤーの作成と共有

試験データ登録側のLambda関数を作成する

続いて、Lambda関数の中身を実装します。先ほど、「lib/ディレクトリ名.js」の中で以下のように定義しました。

  // 試験データ登録側のLambda関数を作成する
  const register = new lambda.Function(this, 'RegisterHandler', {
    runtime: lambda.Runtime.NODEJS_16_X,
    code: lambda.Code.fromAsset('lambda-register'),
    handler: 'register.handler',
    role: executionLambdaRole,
    layers: [layer]
  });

ここでは、lambda-registerフォルダの中のregister.jsのhandlerを呼び出しています。
よって、新しくlambda-registerフォルダを追加し、register.jsを作成し、以下を追加します。

const AWS = require("aws-sdk");
const line = require('@line/bot-sdk');
const ssm = new AWS.SSM();
const dynamo = new AWS.DynamoDB.DocumentClient();

// SSM ParameterStoreから値を取得する
const getSSMParameter = async (name) => {
  let result = await ssm.getParameter({
    Name: name,
    WithDecryption: true
  }).promise();

  return result.Parameter.Value;
};

// DynamoDBに登録する
const insert = async exams => {
  await dynamo
    .put({
      TableName: "Exam",
      Item: {
        test_name: exams[0],
        test_day: exams[1],
        purpose: exams[2]
      }
    })
    .promise();
};

exports.handler = async (event, context, callback) => {
  const body = JSON.parse(event.body).events[0];
  const exams = body.message.text.split(":");

  // SSM ParameterStoreから値を取得する
  let LINE_ACCESS_KEY = await getSSMParameter('LINE_ACCESS_KEY');
  const client = new line.Client({
    channelAccessToken: LINE_ACCESS_KEY
  });

  let message;
  if (exams.length === 3) {
    insert(exams);  // DynamoDBに登録
    message = '登録したよ';
  } else {
    message = '登録したい試験を「試験名:日付(YYYY-MM-DD):目的」の形式で入力してね!';
  }

  await client.replyMessage(body.replyToken, {
    type: 'text',
    text: message
  });
};

コードに中身については「マネジメントコンソール編」と同様です。(筆者の都合で、DynamoDBのテーブル名など、一部が変わっている部分はありますが。)

LINEにメッセージを送るLambda関数を作成する

次にLINEに通知する側のLambda関数を定義します。lib/の中では以下のように定義しました。

  // LINEにメッセージを送るLambda関数の定義
  const pushMessage = new lambda.Function(this, 'PushMessageHandler', {
    runtime: lambda.Runtime.NODEJS_16_X,
    code: lambda.Code.fromAsset('lambda-push-msg'),
    handler: 'pushMessage.handler',
    role: executionLambdaRole,
    layers: [layer]
  });

ここでは、lambda-push-msgフォルダの中のpushMessage.jsのhandlerを呼び出しています。よって、新しくlambda-push-msgフォルダを追加し、pushMessage.jsを作成し、以下を追加します。

const dayjs = require('dayjs');
const AWS = require("aws-sdk");
const line = require('@line/bot-sdk');
const dynamo = new AWS.DynamoDB.DocumentClient();

let params = {
  TableName: 'Exam',
};

const ssm = new AWS.SSM();
const generateText = (test_name, day, purpose) => {
  const messages = [
    '君の本気はそんなもんじゃないでしょ?',
    '今日は何をしたんだい?内省してごらん?',
    '間にあいそ?',
    '明日は今日の倍がんばろうな',
    `目的は ${purpose} って言ってたよね?達成できそう?`
  ];

  return `${test_name}まで残り${day}日だよ!\n`
    + messages[Math.floor(Math.random() * messages.length)];
};

// SSM ParameterStoreから値を取得する
const getSSMParameter = async (name) => {
  let result = await ssm.getParameter({
    Name: name,
    WithDecryption: true
  }).promise();

  return result.Parameter.Value;
};

// 試験までの残り日数を計算
const calcRemainingDays = test_day => dayjs(test_day).diff(dayjs(), 'd');

const generateMessages = items => {
  let messages = [];
  items.forEach(function (exam, index) {
    messages.push({
      type: 'text',
      text: generateText(exam.test_name,
        calcRemainingDays(exam.test_day),
        exam.purpose)
    });
  });

  return messages;
};

exports.handler = async (event, context, callback) => {
  const LINE_ACCESS_KEY = await getSSMParameter('LINE_ACCESS_KEY');
  const USER_ID = await getSSMParameter('LINE_USER_ID');

  const client = new line.Client({
    channelAccessToken: LINE_ACCESS_KEY
  });

  // dynamoDBから取得
  let examObj = await dynamo.scan(params).promise();

  // メッセージ生成
  let replyMessage = generateMessages(examObj.Items);
  await client.pushMessage(USER_ID, replyMessage);
};

これで、デプロイする準備は完了です。

デプロイ手順

あとはCDKでデプロイするために以下のコマンドを実行することでデプロイできます。

$ cdk synth
$ cdk bootstrap
$ cdk deploy

各コマンドの説明を簡単に記載します。

  • cdk synth
    • ここまでに作成したものから、CloudFormationテンプレートを作成する
  • cdk bootstrap
    • CDKの初期設定として、リージョンごと、言語ごとに1回実行が必要
    • CloudFormationにCDKToolkitというスタックが生成される
  •  cdk deploy
    • CloudFormation経由でリソースが作成される

デプロイ完了後、CloudFormationに遷移すると新しくスタックが作られており、定義した各サービスが起動していることが確認できます。

デプロイされたURLをLINE Messaging APIと連携する

デプロイが完了すると、コンソールにURLが表示されます。またはマネジメントコンソールでAPI GatewayからでもデプロイされたURLを確認できます。

確認したURLを「マネジメントコンソール編」の「API Gatewayを設定し、LINE Messaging APIと連携する」同様にLINE Messaging APIに設定します。

これで完了です。LINEから動作確認をしてみてください。

おわりに

AWS CDKを利用することで「マネジメントコンソール編」で紹介した多くの手順が、1つのスタックファイルとLambda Layer含め3つのLambda関数ファイルで定義できました。

L2 Constructで表現できる範囲であれば、簡単に記述ができ便利に感じました。
本記事を読みながら1から同じものを構築してみると、より便利さが伝わるかもしれません。

最後まで読んでいただき、ありがとうございました。
もし、「マネジメントコンソール編」を読まれていない方、是非合わせてご覧ください。

タイトルとURLをコピーしました