Top View


Author Naoya Miyagawa

RDS, Lambda, CloudWatchでサービスの成長を見届けよう!

2020/12/23

はじめに

自社サービスを始めシステム開発における悲しみの一つとして、頑張って作ったシステムがユーザーに使われないことが挙げられると思います。

  • 「自分たちが作ったサービスは世間に受けているのか」
  • 「システムの利用者は一体増えているのだろうか」

ふとしたとき感じるこれらの気持ちを解決するべく、
今回はAWSサービスを使って、 利用者数の推移を定期的に教えてくれる機構 を作ってみたのでご紹介します。

※ タイトル内「サービスの成長」=「利用者数の増加」🙈


アーキテクチャ

アプリケーションのDBに、プライベートサブネットに置いたRDS (MySQL) が利用されているケースを想定しています。

今回のメインとなる機構では、2つのLambdaを用いており、
それぞれ 「①ユーザー数のメトリクス保存」 「②メトリクスのSlack通知」を行うようにしました。

前者の「ユーザー数のメトリクス保存」に関しては、プライベート空間に配置されたRDSへアクセスできるようにLambdaを同サブネットへ紐付けし、VPC Endpointを介してCloudWatchとの通信行っています。

※ 今回はアプリケーション部の作り込みはしない代わりに、検証用にRDSにmysql接続できるEC2サーバーも用意しています。(構成図内の左の破線部)


AWSアーキテクチャ図


事前準備

各AWSサービス要素間で通信が行えるようにIAMやセキュリティグループの設定をしておきます。

また、RDSのDBには検証用にある程度レコードがあるとうれしいので、検証用EC2サーバーを使ってテーブル作成とレコードインサートを行っておきます。


①ユーザー数のメトリクス保存

ここでは、Lambdaが以下の二つを行っています

  • RDSに対してSQLを実行し、監視対象テーブル(今回は users)のレコード件数を取得する
  • CloudWatchに対してメトリクスを送信する(put-metric-data API)

実際はこれをCloudWatch Eventで毎分発火させるようにしています。


RDS MySQLとの接続はパッケージ pymysql を利用していますが、
importに記述するだけではLambda実行時に package not found でエラーが発生します。(psycopg2でも同様)

これを回避するためにローカルでパッケージをダウンロードし、zip化して、Lambdaにアップロードします。

# ローカルにて
pip install pymysql -t .

# ファイル構成
.
├── lambda_function.py (下記記載)
└── pymysql/

# zip化(生成されるzipファイルをコンソールからポチポチでアップロードすればOK)
zip -r app.zip ./*

CloudWatchへのAPI通信は、Python製AWS SDKのBoto3を利用しています。


以下が今回作ったLambda関数です。

※ コンソール上で環境変数を埋め込めますが、今回は簡易的にコード内に直書きしています。

lambda_rds_custom_metrics / lambda_function.py

# -*- coding: utf-8 -*-
import json
import boto3
import sys
import logging
import pymysql

# RDS設定
RDS_HOST = {RDSのエンドポイント}
USERNAME = {DBユーザー名}
PASSWORD = {DBパスワード}
DB_NAME = {DB名}

logger = logging.getLogger()
logger.setLevel(logging.INFO)

# RDSとの接続確保
try:
    conn = pymysql.connect(RDS_HOST, user=USERNAME, passwd=PASSWORD, db=DB_NAME, connect_timeout=5)
except pymysql.MySQLError as e:
    logger.error("ERROR: Unexpected error: Could not connect to MySQL instance.")
    logger.error(e)
    sys.exit()
logger.info("SUCCESS: Connection to RDS MySQL instance succeeded")

def lambda_handler(event, context):
    """Lambda関数"""
    # DB Usersテーブルレコード数取得
    value = get_records_num()
    # メトリクス送信
    metric_data = get_put_metric_data(value)
    namespace = 'RDS/RECORDS'
    put_metric_data(metric_data, namespace)

    return {'statusCode': 200, 'body': json.dumps('Success')}

# -----------------------------------------------------------
# 関数定義

def get_records_num():
    """Usersテーブルレコード数取得"""
    record_count = 0
    with conn.cursor() as cur:
        # 今回はシンプルなselect文で取得
        cur.execute("select * from users")
        record_count = cur.rowcount
    conn.commit()
    return record_count

def put_metric_data(metric_data, namespace):
    """CloudWatchにデータ送信"""
    cloudwatch = boto3.client('cloudwatch')
    cloudwatch.put_metric_data(MetricData=metric_data, Namespace=namespace)

def get_put_metric_data(value):
    """CloudWatchにputするメトリクスデータ取得"""
    return [
        {
            'MetricName': 'USERS',
            'Dimensions': [{'Name': 'RDS', 'Value': 'RECORDS'}],
            'Value': value,
            'Unit': 'None'
        }
    ]

②メトリクスのSlack通知

ここでは、Lambdaが以下の二つを行っています

  • CloudWatchからメトリクス画像を取得する(ついでに最新のメトリクス値も取得)
  • Slack APIで画像投稿

CloudWatchへのAPI通信は、同様にBoto3を利用しています。

また、メトリクス画像の表示調整に関してはこちらの記事が大変参考になりました。 ありがとうございます! https://gugutasuujimasa.blogspot.com/2018/11/awsslack.html


Slack APIを利用するためには、Slack上でいくつか準備が必要です。

  • こちらからSlackAppを作成し、 Add features and functionality 項目で BotsPermissions を設定。

    • 今回ご紹介する画像投稿のみであれば files:write のみで大丈夫だと思います。必要に応じて chat:write chat:write.customize を適用すればいいかと思います。
    • こちらで後ほど必要なトークンが取得できます。
  • 通知したいチャンネルにSlackAppをInviteする。 /invite @{作成したSlackApp名}

  • チャンネルIDを取得。

    • ブラウザ版Slackで通知を送りたいチャンネルを開いたときのURLの最後一区画がチャンネルIDです。

また、Slack API通信を行うために、 requests パッケージを利用しています。
こちらも pymysql 同様にimportではエラーが発生するので、事前にダウンロードとzip化後にアップロードが必要です。

# ローカルにて
pip install requests -t .

# ファイル構成(依存パッケージがいくつか一緒にダウンドーロされます)
.
├── certifi
├── chardet
├── idna
├── lambda_function.py (下記記載)
└── requests

# zip化(生成されるzipファイルをコンソールからポチポチでアップロードすればOK)
zip -r app.zip ./*

以下が今回作ったLambda関数です。

※ コンソール上で環境変数を埋め込めますが、今回は簡易的にコード内に直書きしています。

lambda_slack / lambda_function.py

# -*- coding: utf-8 -*-
import json
import boto3
import datetime
import requests

SLACK_API_URL = 'https://slack.com/api/files.upload'
SLACK_APP_ACCESS_TOKEN = {SlackAppのトークン(Bot User OAuth Access Token)}
SLACK_CHANNEL_ID = {チャンネルID}

cloudwatch = boto3.client('cloudwatch')

def lambda_handler(event, context):
    """Lambda関数"""
    # CloudWatch Widget画像取得
    latest_users_num = get_cloudwatch_metrics_latest()
    upload_files = get_cloudwatch_metrics_widget_image(latest_users_num)
    # Slack通知
    requests.post(url=SLACK_API_URL, params=get_slack_params(), files=upload_files)
    return {'statusCode': 200, 'body': json.dumps("Success")}

# ----------------------------------------------------------------------------
# 関数定義

def get_cloudwatch_metrics_latest():
    """最新のメトリクス1点取得"""
    metrics = cloudwatch.get_metric_data(
        MetricDataQueries=[{
            'Id': 'users',
            'MetricStat': {
                'Metric': {
                    'Namespace': 'RDS/RECORDS',
                    'MetricName': 'USERS',
                    'Dimensions': [{'Name': 'RDS', 'Value': 'RECORDS'}]
                },
                'Period': 60,
                'Stat': 'Maximum',
            },
        }],
        StartTime=datetime.datetime.now() - datetime.timedelta(minutes=10),
        EndTime=datetime.datetime.now(),
        ScanBy='TimestampDescending',
        MaxDatapoints=1
    )
    latest_value = int(metrics['MetricDataResults'][0]['Values'][0])
    return latest_value

def get_cloudwatch_metrics_widget_image(latest_value):
    """CloudWatchメトリクス画像取得"""
    graph_title = f"TREND NUMBER OF USERS (Today: {latest_value})"
    # ウィジェット画像表示設定
    metric_widget_settings = json.dumps({
        "view": "timeSeries",
        "stacked": True,
        "metrics": [["RDS/RECORDS", "USERS", "RDS", "RECORDS"]],
        "timezone": "+0900",
        "annotations": {
            "horizontal": [
                {"color": "#8ec6ff", "label": "Today", "value": latest_value},
                {"color": "#ff8e8e", "label": "Target", "value": 100},
            ]
        },
        "title": graph_title,
        "width": 600,
        "height": 400,
        "start": "-PT24H",  # 1日前から
        "yAxis": {
            "left": {"label": "", "min": 0, "max": latest_value * 1.5}
        },
    })
    # ウィジェット画像取得
    response = cloudwatch.get_metric_widget_image(MetricWidget=metric_widget_settings, OutputFormat='png')
    image = {'file': response['MetricWidgetImage']}
    return image

def get_slack_params():
    """Slack投稿用パラメータ取得"""
    file_title = 'Regular Notification'
    return {'token': SLACK_APP_ACCESS_TOKEN, 'channels': SLACK_CHANNEL_ID, 'title': file_title}

【結果】サービスの成長は見守れました(?)

設定した時刻に、見守り隊から温かみのあるSlack通知がお届けられました。

CloudWatch Eventsの発火タイミングだけでなく、コードからも分かる通り、画像の時間軸や件数軸の表示領域も自由に変更ができます。
下の画像では1日分だけを表示していますが、
 毎週もしくは隔週に一度、直近1ヶ月分のメトリクス画像を表示する
など工夫するとよさそうです。

この画像のようにサービスが目指すユーザー数にアノテーションを付けておくと、現状との差分が分かって「もっと頑張るぞー」となりますね👍

見守り隊からお届けされる温かみのあるSlack通知


おわりに

サービスの成長を見守るために、DBのユーザー数を定期保存するカスタムメトリクス化して、定期通知を行いました。

ちなみにSlack通知に関してはChatbotの利用も試してみましたが、
アラームや一部イベント発火時にのみ対応しているようで、
今回のような定期実行ではうまく組み合せられませんでした。

また、今回はユーザー数取得としてシンプルなselect文を使っていますが、
サービスの実情に合わせてユーザー数取得のSQLを変更したり、
例えば「注文数」「売上データ」なども一緒に表示すると、より便利でおもしろくなりそうです!


よかったら試してみてくださいー。

Naoya Miyagawa

Naoya Miyagawa

Twitter X

Hi🙋🏻‍♂️ Web Developer in Fukuoka, Japan🇯🇵 ❄️ Fusic Co., Ltd.|360 (さんろくまる) ❄️ PHP: CakePHP ❄️ JS: Vue.js ❄️ AWS: SAP ❄️ FAV: nm7/🍣/SG🇸🇬