NTTドコモR&Dの技術ブログです。

AWS CDKを使って、LambdaからSlackに通知する仕組みを作ってみた

はじめに

こんにちは、NTTドコモ サービスイノベーション部の林です。普段は全国の基地局からの情報をリアルタイムで収集・加工するシステムであるミナデンの開発に携わっています。

本記事の背景

Slackは業務上で非常に便利なチャットツールですよね。でも使っているうちに「もっとこういう通知が自動で来たらいいのに」と思うことはありませんか? 例えば私たちのチームでも、「AWSコストが前週より増えてたら教えてほしい」とか「チャットログを集計したい」といったシーンがありました。 こうしたSlack連携のバックエンドとして、必要な時だけ起動して課金されるAWS Lambdaは相性の良い選択肢です。しかし、環境構築が大変である上に、マネジメントコンソール(GUI)で手動構築しようとすると、以下のような課題に直面します。

  • 「あれ、ここの設定どうしたっけ?」と、自分でやった設定を忘れる
  • 手順書がないと再現できず、別のプロジェクトで使い回しにくい
  • 他者から設定の意図が見えにくく、引き継ぎが大変

本記事の目的

そこで今回は、面倒なライブラリのビルドを自動化し、インフラ構成をコードとして管理することで、「誰でも再利用できる」Slack連携の仕組みを構築します。
具体的には「AWSコストの定期通知」を作りますが、インフラ部分はテンプレート化されているので、Lambdaの中身(Pythonコード)を書き換えるだけで、皆さんの好きな通知Botの土台として使えます。 さらに、地味に面倒なライブラリ(Lambda Layer)のビルドも自動化しています!

対象読者

  • Slackで標準機能以上のことをしたいが、難しそうでどうすればいいかわからない方
  • リソース構築を手動でポチポチ行っている方
  • AWS CDKを聞いたことがあるが、始め方がよくわからない方
  • Lambda Layer(外部ライブラリ)作成が非常に面倒だと感じており、自動化したい方

今回はLambda Layerの構築・デプロイもCDKで自動化しているため、Layerの作成手順や管理で苦戦した経験がある方にも役立つ内容となっています!


1. AWS CDK (Cloud Development Kit) とは?

AWS CDKとは、ざっくり言うと、「TypeScriptやPythonなどのプログラミング言語を使って、AWSのインフラを作れるツール」です。

CDKを使うと、こんな嬉しいことがあります。

  • 誰でも同じ環境が作れる: 手順書を見ながらの作業と違い、誰が実行しても全く同じインフラが出来上がります。
  • 管理が楽になる: ループや条件分岐といった、プログラミング言語の便利な機能をそのままインフラ構築に使えます。

CDKでは、AWSリソースをひとまとめにした箱のようなものを「スタック」と呼びます。 大規模開発の場合はネットワーク、DB、アプリなどでスタックを分割して管理します。 今回は小規模なツール作成のため、シンプルに「1つのスタック」で構築します。


2. 環境構築

AWS CDKの環境構築手順(Node.js, Docker)になります。 CloudShellやAWS CLIを通したローカルPCなどさまざまな環境で実行できますが、 今回はAmazon Linux 2023のEC2インスタンスを起動した状態での環境構築方法を紹介します。


2-1. Node.js と AWS CDK のインストール

まずはNode.jsのバージョン管理ツール(nvm)導入し、推奨版のNode.jsとCDK本体をインストールします。

# nvm のインストール
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash

# 設定の読み込み(シェル再起動の代わり)
source ~/.bashrc

# Node.js LTS(推奨)版をインストール
nvm install --lts

# AWS CDK をグローバルにインストール
npm install -g aws-cdk

2-2. Docker のインストールと設定

CDKでLambdaのレイヤーをビルドする際、Dockerが必須となるため導入します。 (すでに作成済みのレイヤーを使う場合はDockerは不要です)

# 現在のDockerバージョン確認(未インストールの確認)
docker --version

# システムアップデートとDockerのインストール
sudo dnf update -y
sudo dnf install -y docker

# Dockerサービスの起動
sudo service docker start

# ec2-user にDocker実行権限を付与
sudo usermod -a -G docker ec2-user

重要: usermod で権限を変更した後、設定を反映させるには一度ログアウトして再ログインするか、インスタンスの再起動が必要です。

再ログイン/再起動後の確認:

# エラーが出ずに実行できればOK
docker ps

事前の環境構築手順は以上です。


3. Slack appの作成

Slackで通知するためにはSlack botを作成する必要があります。 以下のリンクにアクセスし、設定を行います。 Slack API: Applications | Slack


3-1. アプリの作成

Create New App をクリック。

From scratch を選択。

App Name(Botの名前)を入力し、開発するワークスペースを選択して Create App。


3-2. 権限(Scope)の設定

左メニュー OAuth & Permissions を選択。

下へスクロールし Scopes > Bot Token Scopes に以下を追加します。

chat:write (メッセージを送信するため)

(注)自分の作りたいappによって、ここの権限を適切に付与する必要があります。


3-3. アプリのインストール

左メニュー Install App を選択。

Install to Workspace をクリックして許可。

表示された xoxb-... から始まるトークン(Bot User OAuth Token)をコピーして控えておく。


3-4. チャンネルへの招待

作成したBotは、通知を送りたいチャンネルに招待しないとメッセージを送れません。 Slackアプリ上で通知先チャンネルを開き、以下のコマンドを実行してください。

/invite @[App Name]

やっと、準備が終わりました...


4. プロジェクトの作成

いよいよ実装に入ります。


4-1. プロジェクトの初期化

まず、プロジェクト用のディレクトリを作成し、TypeScriptで初期化します。

# プロジェクト用ディレクトリの作成と移動(例: slack-notification)
mkdir slack-notification && cd slack-notification

# CDKプロジェクトの初期化
cdk init --language typescript

4-2. ディレクトリ構成の整備

Lambdaのソースコード用ディレクトリ(src)と、ライブラリ定義用ディレクトリ(layer)を作成します。

mkdir src layer

最終的なディレクトリ構成が以下のようになります。

slack-notification/
├── bin/
│   └── slack_notification.ts   # エントリーポイント(スタックを定義してアプリに追加)
├── lib/
│   └── slack-notification-stack.ts   # 実際のリソースを定義するスタックファイル
├── src/                            
│   └── lambda_function.py # 【新規作成】Lambdaコード置き場
├── layer/                          
│   └── requirements.txt   # 【新規作成】ライブラリ定義置き場
├── test/
│   └── my-cdk-project.test.ts   # Jestによる単体テスト
├── node_modules/          # npm依存パッケージ
├── package.json           # npmパッケージ管理ファイル
├── cdk.json               # CDK CLI設定ファイル(エントリーポイント指定)
├── tsconfig.json          # TypeScriptコンパイル設定
└── README.md              # プロジェクト説明

最初はたくさんファイルがあり混乱しますが、今回編集するのはslack_notification.ts, slack-notification-stack.tsになります。 ここで今回は@aws-cdk/aws-lambda-python-alpha をインストールします。このモジュールをインストールすることで、Lambdaのレイヤー作成を自動化できます。

(標準のaws-lambdaモジュールのbuildingオプションを使ってレイヤーを構築することもできますが、python-alpha モジュールの方がdockerコマンドを記述する必要がなく自動で適切なlayer階層に配置してくれるので、今回はこちらを使用します。)


4-3. @aws-cdk/aws-lambda-python-alpha のインストール

このモジュールはまだ「Alpha(実験的)」ステータスであり、CDK本体(aws-cdk-lib)と厳密にバージョンを合わせる必要があります。これを怠ると依存関係エラーが発生します。

4-3-1: 現在のCDKバージョンを確認

まず、プロジェクトに入っている aws-cdk-lib のバージョンを調べます。

npm list aws-cdk-lib

出力例: aws-cdk-lib@2.110.0 ->この 2.110.0 という数字を控えておきます。

4-3-2: バージョンを指定してインストール

控えたバージョンに -alpha.0 を付けたものを指定してインストールします。

構文: npm install @aws-cdk/aws-lambda-python-alpha@<バージョン>-alpha.0

実行コマンド例(バージョンが 2.110.0 の場合):

npm install @aws-cdk/aws-lambda-python-alpha@2.110.0-alpha.0

4-3-3: インストール成功の確認

確認 (package.json): dependencies 内でバージョンが揃っていることを確認します。

"dependencies": {
  "aws-cdk-lib": "2.110.0",
  "aws-cdk/aws-lambda-python-alpha": "2.110.0-alpha.0"
}

4-4. slack_notification.tsのコーディング

cdkのエントリーポイントであるslack_notification.tsのコーディングを以下のように行います。

#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from 'aws-cdk-lib';
import { SlackNotificationStack } from '../lib/slack-notification-stack';

const app = new cdk.App();

// スタックの作成
new SlackNotificationStack(app, 'SlackNotificationStack', {
  description: 'Slack notification system',
});

4-5. slack-notification-stack.tsのコーディング

実際のリソースを定義するスタックファイルのコーディングを以下のように書きます。

import * as cdk from 'aws-cdk-lib';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as events from 'aws-cdk-lib/aws-events';
import * as targets from 'aws-cdk-lib/aws-events-targets';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as logs from 'aws-cdk-lib/aws-logs';
import { Construct } from 'constructs';
import { PythonLayerVersion } from '@aws-cdk/aws-lambda-python-alpha';
import * as path from 'path';

export class SlackNotificationStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // パラメータの定義(slack-bot-token)
    const slackBotToken = new cdk.CfnParameter(this, 'SlackBotToken', {
      type: 'String',
      description: 'Slack Bot Token (xoxb-...)',
      noEcho: true,
      minLength: 50,
      maxLength: 200,
    });

 // パラメータの定義(slack-channel ID)
    const slackChannelId = new cdk.CfnParameter(this, 'SlackChannelId', {
      type: 'String',
      description: 'Slack Channel ID (C03XXXXXXXXX)',
      minLength: 9,
      maxLength: 15,
      allowedPattern: '^C[A-Z0-9]+$',
      constraintDescription: 'Must be a valid Slack channel ID starting with C'
    });

    // Lambda実行ロールの作成
    const lambdaRole = new iam.Role(this, 'SlackNotificationLambdaRole', {
      assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
      description: 'IAM role for Slack alert aggregation Lambda function',
      managedPolicies: [
        iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole'),
      ],
      inlinePolicies: {
        CostExplorerAccess: new iam.PolicyDocument({
          statements: [
            new iam.PolicyStatement({
              effect: iam.Effect.ALLOW,
              actions: [
                'ce:GetCostAndUsage' //Cost Explorerのデータを読み取るために必要な権限
              ],
              resources: ['*'],
            }),
          ],
        }),
      },
    });

    // Pythonレイヤーの作成
    const dataProcessingLayer = new PythonLayerVersion(this, 'DataProcessingLayer', {
      entry: path.join(__dirname, '../layer'),
      compatibleRuntimes: [lambda.Runtime.PYTHON_3_13],
      description: 'Layer containing data processing libraries slack_sdk and matplotlib',
      removalPolicy: cdk.RemovalPolicy.DESTROY,
    });

    // Lambda関数の作成
    const slackAlertFunction = new lambda.Function(this, 'SlackCostNotificationFunction', {
      runtime: lambda.Runtime.PYTHON_3_13,
      handler: 'lambda_function.lambda_handler',
      code: lambda.Code.fromAsset(path.join(__dirname, '../src')),
      timeout: cdk.Duration.minutes(15), 
      memorySize: 1024, 
      role: lambdaRole,
      environment: {
        SLACK_BOT_TOKEN: slackBotToken.valueAsString,
        SLACK_CHANNEL_ID: slackChannelId.valueAsString,
        LOG_LEVEL: 'INFO',
      },
      layers: [dataProcessingLayer],
      description: 'Sends daily AWS cost notifications to Slack',
      logRetention: logs.RetentionDays.ONE_MONTH,
      reservedConcurrentExecutions: 1,
    });

    // EventBridgeルールの作成(毎日朝9時(JST)に実行)
    const dailyRule = new events.Rule(this, 'DailyCostNotificationRule', {
      schedule: events.Schedule.cron({
        minute: '0',
        hour: '0', // UTC 0時 = JST 9時
        day: '*',
        month: '*',
        year: '*',
      }),
      description: 'Triggers daily AWS cost notification at 9 AM JST',
    });

    // Lambda関数をターゲットとして追加
    dailyRule.addTarget(new targets.LambdaFunction(slackAlertFunction, {
      event: events.RuleTargetInput.fromObject({
        source: 'eventbridge.daily.schedule',
        executionType: 'scheduled',
      })
    }));

    // CloudWatch Logsグループ(Lambda関数用)
    new logs.LogGroup(this, 'SlackAlertLambdaLogGroup', {
      logGroupName: `/aws/lambda/${slackAlertFunction.functionName}`,
      retention: logs.RetentionDays.ONE_MONTH,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
    });
 
    
    // スタック作成後に参照可能な値
    new cdk.CfnOutput(this, 'LambdaFunctionName', {
      value: slackAlertFunction.functionName,
      description: 'Lambda function name for cost notifications',
      exportName: 'SlackCostNotification-LambdaFunction',
    });

    new cdk.CfnOutput(this, 'LambdaFunctionArn', {
      value: slackAlertFunction.functionArn,
      description: 'Lambda function ARN for cost notifications',
      exportName: 'SlackCostNotification-LambdaArn',
    });

    new cdk.CfnOutput(this, 'EventBridgeRuleArn', {
      value: dailyRule.ruleArn,
      description: 'EventBridge rule ARN for daily execution',
      exportName: 'SlackCostNotification-EventRule',
    });

  }
}

4-6. Lambda関数のコーディング(Python)

新しくslack-notificationの配下にsrcディレクトリを作成し、そこに新しくlambda_function.pyのファイルを作成し、Lambda関数のコーディングを行います。

import json
import os
import boto3
import matplotlib.pyplot as plt
from datetime import datetime, timedelta, date
from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError

def lambda_handler(event, context):
    slack_token = os.environ.get('SLACK_BOT_TOKEN')
    channel_id = os.environ.get('SLACK_CHANNEL_ID')
    
    end_date = date.today()
    start_date = end_date - timedelta(days=7)
    
    str_start = start_date.strftime('%Y-%m-%d')
    str_end = end_date.strftime('%Y-%m-%d')

    # Cost Explorerからデータ取得
    ce = boto3.client('ce')
    try:
        response = ce.get_cost_and_usage(
            TimePeriod={'Start': str_start, 'End': str_end},
            Granularity='DAILY',
            Metrics=['UnblendedCost']
        )
    except Exception as e:
        print(f"Cost Explorer Error: {e}")
        return {'statusCode': 500, 'body': 'Failed to fetch costs'}

    dates = []
    costs = []
    
    for item in response['ResultsByTime']:
        # 日付
        dates.append(item['TimePeriod']['Start'][5:]) # "2023-10-27" -> "10-27" (見やすくするため)
        # 金額 (ドル)
        cost = float(item['Total']['UnblendedCost']['Amount'])
        costs.append(cost)

    # グラフの描画 (Matplotlib)
    plt.figure(figsize=(10, 5))
    plt.plot(dates, costs, marker='o', linestyle='-', color='b', label='Daily Cost ($)')
    
    plt.title(f"AWS Cost ({str_start} - {str_end})")
    plt.xlabel("Date")
    plt.ylabel("Cost (USD)")
    plt.grid(True)
    plt.legend()
    plt.xticks(dates, rotation=45) 
    plt.tight_layout()

    # 画像を一時ファイルとして保存
    image_path = '/tmp/cost_graph.png'
    plt.savefig(image_path)
    plt.close() 

    # Slackに画像アップロード
    client = WebClient(token=slack_token)
    try:
        response = client.files_upload_v2(
            channel=channel_id,
            file=image_path,
            title="AWS Cost Trends (Last 7 Days)",
            initial_comment="過去1週間のAWSコスト推移"
        )
        print("File uploaded successfully")
        
    except SlackApiError as e:
        print(f"Slack Upload Error: {e}")
        return {'statusCode': 500, 'body': str(e)}

    return {
        'statusCode': 200,
        'body': json.dumps('Graph sent to Slack!')
    }

4-7. requirements.txtの作成

最後に、Pythonプロジェクトで外部ライブラリや依存関係を管理するための標準的なファイルであるrequirements.txtを作成します。今回はグラフを作成するためのmatplotlibと、Slackとの連携のためのslack-sdkを記載します。 slack-notificationディレクトリの配下にlayerディレクトリを作成し、そこに以下のrequirements.txtを作成してください。

slack-sdk
matplotlib

5. デプロイ

いよいよデプロイ作業に入ります。

※初めてCDKを使う環境の場合、最初にBootstrapが必要です。

cdk bootstrap

リソースを作成します。パラメータには先ほど取得したSlackのトークンとチャンネルIDを指定してください。

cdk deploy --parameters SlackBotToken=xoxb-xxxxxxxxxxxx --parameters SlackChannelId=C0xxxxxxxxx

これでエラーが出なければデプロイ完了です!お疲れ様です!

コンソールで確認してみると、以下のようにステータスがUPDATE_COMPLETEになってます!

スタックの確認

これで、毎朝9時(またはEventBridgeからテスト実行)になると、指定したSlackチャンネルにAWSコストのグラフが通知されます。


6. 実行結果

指定した時間にグラフを出力してくれてますね!

実行結果


7. 感想

私も最近までAWSインフラをコンソールでポチポチ手動構築していましたが、CDKを導入してからはリソースの作成・管理が劇的に楽になりました! 削除もコマンド一発で完了するのもいいですね!

個人的に感動したのは『Lambda Layer構築の自動化』です。これまではローカルの開発環境とAWS環境のOSや言語バージョンの違いによるエラーによく悩まされていましたが、CDKのおかげで楽になりました!

AWSを使っていてまだCDKに触れていない方は、ぜひ試してみてください!

🎄メリークリスマス🎄


8. 参考

AWS:CloudShell+CDK+GitHub で簡単 IaC

AWS CDK とは - AWS Cloud Development Kit (AWS CDK) v2