LoginSignup
7
8

AWS Lambdaを使ってメール整形してみた

Last updated at Posted at 2023-02-12

◆はじめに

今回、とあるプロジェクトでCloudWatch Alarmから(正確にはAWS SNSですが)
出るアラームのメール整形をLambdaを使用してやってみました。

使った言語はPythonです。

◆構成

簡単にですが構成の説明です。
単純な構成ですが、何しかしらの理由で発報したCloudWatch Alarmが、
SNSを使用してLambdaに飛ばしLambdaを使用してアラームの中身を整形。
LambdaがSNSを使用して任意のメールアドレスにアラームを
送信という流れです。

image.png

◆注意

今回は、Lambdaのコードの説明のみ行いますので、実際のLambdaの設定やCloudWatch及び
SNSの設定方法は省略しますのでご了承して頂ければと思います (すみません...)

◆Pythonコード

では、今回作成したPythonコード添付していきます。
ファイル2つです。メインのファイルである「 lambda_function.py 」と
メール本文の中身を作成する為の 「 template.py 」です。

template.pyには、メール題名(title)とメール本文を作成するclassメソッドを作成しています。
理由はを汎用性をよくする為と、ぱっとみの分かりやすさですね。

汎用性というのは、アラームごとにコンテンツ(メール本文)の内容を変えたいだとかが
起こった際、コンテンツの内容が書かれたclassメソッドの中に関数(defで始まる所)を
増やすだけでよいとかです。

余談ですが、アラームごとにコンテンツの内容を変更したいのであれば、そのアラームごとに
classメソッドを作ると方は良いとは思いますが、そこまですると管理が面倒くさそうですね。


lambda_function.py
import json
import boto3
import textwrap
import os

from template import Subjects, Messages


def lambda_handler(event, context):
    client = boto3.client('sns')

    # 環境変数代入
    TOPIC_ARN = os.environ['sns_arn']

    # Messageの中身を変数に代入
    m = event['Records'][0]['Sns']['Message']
    print(type(m))
    
    
    # 辞書型に変換
    e = json.loads(m)
    print(e)
    print(type(e))
    

    # インスタンス変数作成
    objects = Messages(e['Region'], e['AWSAccountId'], e['AlarmName'], e['Trigger']['MetricName'])
    
    # 共通変数
    content = objects.contents(str(e['AlarmArn']), str(e['AlarmDescription']))
    footer = objects.footer(e['Trigger']['ComparisonOperator'], str(e['Trigger']['Threshold']), e['Trigger']['Statistic'])
    
    # 条件分岐
    if e['NewStateValue'] == "OK":
        subject = objects.subject(e['NewStateValue'])
        header = objects.header(e['Trigger']['Namespace'], sentence="been Recovered to a \"Normal State\" !")
        
        message = textwrap.dedent("""\
            {Header}
            
            {Content}
        """).format(Header=header, Content=content).strip()
        
        print(subject)
        print(message)
        
    elif e['NewStateValue'] == "ALARM":
        subject = objects.subject(e['NewStateValue'])
        header = objects.header(e['Trigger']['Namespace'], sentence="detected an \"Abnormal State\" !")
        
        message = textwrap.dedent("""\
            {Header}
            
            {Content}
            
            {Footer}
        """).format(Header=header, Content=content, Footer=footer).strip()
        
        print(subject)
        print(message)
    
        
    response = client.publish(
        TopicArn = TOPIC_ARN,
        Message = message,
        Subject = subject
    )
    return response
    
template.py
import json
import textwrap
import time

now = time.strftime('%Y/%m/%d %H:%M:%S')

class Subjects(object):
    def __init__(self, region, account, alarmName, metricname):
        self.region = region
        self.account = account
        self.alarmName = alarmName
        self.metricname = metricname
    
    def subject(self, stateValue):
        title = '"' + stateValue + '!\": ' + self.alarmName + '.'
        
        return title
        
        
class Messages(Subjects):
    def header(self, namespace, sentence):
        header = (now + ". UTC. \n" 
        + "You are receiving this Alert By Amazon CloudWatch in the" + self.region + "\n\n"
        + namespace + " " + self.metricname + " " + "has" + " " + sentence
        )
        
        return header
        
    def contents(self, alarmArn, description=None):
        contents = ("Alarm Details: \n"
        + "- Alarm Name:" +  self.alarmName + "\n"
        + "- Alarm Description:" + description + "\n"
        + "- AWSAccountId:" + self.account + "\n"
        + "- Alarm Arn:" + alarmArn
        )
        
        return contents
        
    def footer(self, ComparisonOperator, threshold, statistic):
        footer = ("Threshold: \n"
        + "- The alarm is warning when the" + " " + self.metricname + " " +"is" + " " + ComparisonOperator + " " + threshold + " " + "on" + " " + statistic + "." 
        )
        
        return footer
        

◆コードの紐解き解説

コードの解説をしていきます。
拙い説明になると思いますので、これ変だなぁとか意味が分からないと
いった事があった際は各自ネット検索で補填して頂きたいです。

■ライブラリ

.py
import json
import boto3
import textwrap
import os

from template import Subjects, Messages

先ずは必要なライブラリをimportしていきます。

・import json
PythonでJsonを使用する事ができるようにするライブラリ

・import boto3
Python3でAWS環境を操作する為に必要なライブラリ

・import textwrap
テキストの折り返しと詰め込みするのに必要なライブラリ
※テキスト(文字列)を変数に入れてSNSを使いメール送信をするのですが
届いたメール本文のインデントがばらばらであったり、無駄な改行を修正する為に
使っています。

・import os
OSに依存しているさまざまな機能を利用する為、標準ライブラリ
今回は、環境変数を取得する為に使用しています。

・from template import Subjects, Messages
これは既存のライブラリをimportする訳ではなく、templateファイルの
Subjects及びMessagesのclassメソッドを呼び出す為の使っています。
実際はSubjectsの方は、クラスの継承を使用しいるので必要はないのですが一応です。

■boto3

.py
client = boto3.client('sns')

このように引数に、snsを指定することでAWS SNSを使用して
メール送信を行う事ができます。

■環境変数

.py
TOPIC_ARN = os.environ['sns_arn']

os.environを使用してSNSトピックのarnを環境変数にいれます。
勿論、os.environを使用しないでダイレクトにSNSトピックのarnを入力してもOKです。

今回環境変数に入れている理由は、terraformを使用して1つファイルでマルチアカウント管理
しているので各環境ごとにSNSトピックのarnが変わってしまうので変数にいれて管理しています。

なので、先程も記載した通りダイレクトにSNSトピックのarnを入力して貰っても大丈夫です。


■メッセージの中身を変数に代入

実際、CloudWatch AlarmがSNS使用して飛ばして来た中身を変数に入れてきます。
全部使用する訳ではなく、Messageの所だけ使用するので、そこだけ変数に入れます。
実際の中身の例が以下のものになります。

ほぼほぼ下記のようなファーマットで飛んで来るのですが、
アラームの発報条件がちょっと複雑だとTriggerの所が変わってしまいます。

例えば、RDSのパスワードローテーションが失敗した際に出すアラームは
失敗した回数 ÷ 全体回数 で割合を出す場合などのアラームを作成した場合は
Triggerの所が変わってしまいますね。

.text
{
  "Records": [
    {
      "EventSource": "aws:sns",
      "EventVersion": "1.0",
      "EventSubscriptionArn": "arn:aws:sns:us-east-1:{{{accountId}}}:ExampleTopic",
      "Sns": {
        "Type": "Notification",
        "MessageId": "95df01b4-ee98-5cb9-9903-4c221d41eb5e",
        "TopicArn": "arn:aws:sns:us-east-1:123456789012:ExampleTopic",
        "Subject": "example subject",
        "Message": {
          "AlarmName": "EC2 CPU-Warn",
          "AlarmDescription": "that signals when cpu utilization exceeds 80%",
          "AWSAccountId": "123456789",
          "AlarmConfigurationUpdatedTimestamp": "2022-12-14T03:57:32.761+0000",
          "NewStateValue": "ALARM",
          "NewStateReason": "Threshold Crossed: 1 out of the last 1 datapoints [1.3 (14/12/22 04:30:00)] was not less than or equal to the threshold (1.0) (minimum 1 datapoint for ALARM -> OK transition).",
          "StateChangeTime": "2022-12-14T04:34:48.015+0000",
          "Region": "Asia Pacific (Tokyo)",
          "AlarmArn": "arn:aws:cloudwatch:ap-northeast-1:123456789:alarm:EC2 CPU-Warn",
          "OldStateValue": "OK",
          "OKActions": [
            "arn:aws:sns:ap-northeast-1:123456789:to-lambd"
          ],
          "AlarmActions": [
            "arn:aws:sns:ap-northeast-1:123456789:to-lambda"
          ],
          "InsufficientDataActions": [],
          "Trigger": {
            "MetricName": "CPUUtilization",
            "Namespace": "AWS/EC2",
            "StatisticType": "Statistic",
            "Statistic": "MAXIMUM",
            "Unit": null,
            "Dimensions": [
              {
                "value": "i-123456789",
                "name": "InstanceId"
              }
            ],
            "Period": 60,
            "EvaluationPeriods": 1,
            "DatapointsToAlarm": 1,
            "ComparisonOperator": "LessThanOrEqualToThreshold",
            "Threshold": 1,
            "TreatMissingData": "",
            "EvaluateLowSampleCountPercentile": ""
          }
        },
        "Timestamp": "1970-01-01T00:00:00.000Z",
        "SignatureVersion": "1",
        "Signature": "EXAMPLE",
        "SigningCertUrl": "EXAMPLE",
        "UnsubscribeUrl": "EXAMPLE",
        "MessageAttributes": {
          "Test": {
            "Type": "String",
            "Value": "TestString"
          },
          "TestBinary": {
            "Type": "Binary",
            "Value": "TestBinary"
          }
        }
      }
    }
  ]
}

変数を代入するコードは下記のようになります。
Lambdaは基本的に取得したオブジェクト?が関数のenentの中に代入されています。
contextの方は基本的には使用しないですが、意味がない訳ではないので気になる方は調べてみてください。

print(type(m))は動作させるという事では特に意味はないのですが、
トラブルシュート為に使用しています。

print(type(m))は、変数mのタイプが何なのか確認しています。
恐らく現段階では文字列(str) として表記されるばずです。

.py
m = event['Records'][0]['Sns']['Message']
    print(type(m))

# → <class 'str'>

文字列では、キーバリューという形で値を取得する事が出来ません。

例えば、AlarmNameの値のEC2 CPU-Warnを取り出そうと、e['AlarmName']と
行ってもエラーになります。なので辞書型に変換してあげないとですね。


■辞書型に変換

ではでは、辞書型に変換していこうと思います。
辞書型に変換するにはjson.loads()を使用しています。
先程、Messageの中身を変数mにいれましたので、それを( )の中に入れて辞書型に変化して
変数eに入れます。勿論変数の名前はなんでもOKです。

ここでも、print(type(e))を使用してタイプの確認しています。
今回は、辞書型(dict)と出るとのではないかと思います。
ちなみに、print(e)は中身の確認したいだけでいれているものなので特に
深い意味ありませんので、なくても問題ありません。

.py
e = json.loads(m)
    print(e)
    print(type(e))

# → <class 'dict'>

■インスタンス変数の作成

ここからは、インスタンスの作成に入ります。
ここからは2つのファイルを使用するのでコードの所にファイル名記載していきます。

■classメソッド呼び出し

インスタンス変数とは、classメソッドを呼び出す際に必ず作らなけばいけないものです。
下記のコードでは、Messagesクラスのオブジェクトを、objects変数に入れています。
つまり、templateファイルにあるMessagesクラスが呼び出されています。
その際にtemplateファイルで共通でしようするであろうもの引数として入れています。

ここでは、リージョン名、アカウント名、アラーム名、メトリック名です。
人によって異なると思いますので、好きな引数を入れて貰えればと思います。

メトリック名はCPUのアラームだったら、CPUUtilizationとかで表記されていますかね。
引数の書き方は、先程SNSから飛んできた中身の例を見て貰えればわかるかと思います。

lambda_function.py
objects = Messages(e['Region'], e['AWSAccountId'], e['AlarmName'], e['Trigger']['MetricName'])

■templateファイル

ここで、templateファイルについてです。
import json、import textwrapは先程説明したので省略です。

・import time
現在の時刻を取得するライブラリです。
time.strftime('%Y/%m/%d %H:%M:%S')と書く事で下記のように表記されます。
→ 2023/02/10 15:38:56

先ずは、クラスの継承についてです。
名前の通りなのですが、MessagesクラスにSubjectsクラスを継承しています。
つまり、Messagesクラスのオブジェクトを作成すればSubjectsクラスの中の関数も
Messagesクラスのオブジェクトから使用する事が出来ます。
先程作成したオブジェクトが下記のものですね。
この1行で、Messagesクラス及びSubjectsクラスの関数の全てを呼び出す事が出来るようになります。

template.py
# インスタンス変数作成
objects = Messages(e['Region'], e['AWSAccountId'], e['AlarmName'], e['Trigger']['MetricName'])

継承の書き方は下記のようになります。
Subjectsクラスの横にあるobjectはクラスメソッドを使用する際には
書かなければならないものなのですが、Python3.0以上からは必要なくなっています。
しかし、現状昔名残りから書く事が多いようです。
なので、今回は記載しています。

template.py
class Subjects(object):
    
class Messages(Subjects):

続いては、initの所です。
init関数とは、凄く簡単に言えば「必ず最初に呼び出される特殊な関数(メソッド)」です。

template.py
def __init__(self, region, account, alarmName, metricname):
        self.region = region
        self.account = account
        self.alarmName = alarmName
        self.metricname = metricname

つまり、下記のコードで説明すると、

lambda_function.py
content = objects.contents(str(e['AlarmArn']), str(e['AlarmDescription']))

変数objectsには、Messagesクラスが代入されています。
なので、ここではMessagesクラスのcontents関数を呼び出しています。
しかし先程説明した通りでSubjectsクラスが継承されているので、
Subjectsクラスの中の関数もMessagesクラスを呼び出す事で、使用する事が出来ます。

そして、init関数は「必ず最初に呼び出される特殊な関数」です。
と言う事で、Messagesクラスのcontents関数を呼び出される前にinit関数が呼び出されて、
それからcontents関数が呼び出される、そんなイメージです (言葉が拙くてすみません...)

だからcontents関数を呼び出し際に、init関数の中で定義されている変数が使用できるという訳です。

・selfの役割
このselfの役割ですが、大体ネットで検索すると
「インスタンス自身」、「その時点の自分」、「メソッドの仮引数」など言われます。
クラス構造を取る際の定型の構文として必要だと思ってくれれば大丈夫です。

ちなみに、selfがなかった場合はTypeErrorというエラーが発生します。
これはどう言う事かと言いますと、先程のcontents関数を呼び出した時のもので
ご説明すると、引数は2つなのですが、実際は下記のようにインスタンス自身を
引数で渡しています。

lambda_function.py
content = objects.contents(str(e['AlarmArn']), str(e['AlarmDescription']))

# 記載していないけれども、インスタンス自身であるselfが引数にある
content = objects.contents((self)),str(e['AlarmArn']), str(e['AlarmDescription']))

余談ですが別にselfじゃなく違う単語でも大丈夫です。「インスタンス自身」、
「その時点の自分」と説明される事が多いのでselfという単語を使う方が分かりやすい為、
selfを使う事が一般的のようです。

最後に、init関数の変数の書き方ですが
まぁ、このように記載しますって感じ覚えればいいんじゃあないかと思います (雑ですみません...)

self.region = region
self.account = account
self.alarmName = alarmName
self.metricname = metricname

最後に今更ですがtemplateファイル構成ですが、
Subjectsクラスが、メールの題名の文です。
Messagesクラスがメールの本文の所の文です。header、contents、footerと関数を3つに
分けているのは、汎用性を高める為と、もし全部の文を1つ関数でまとめると、
メインファイル (lambda_function.py.py)で関数を呼び出す際に引数は多くなってコード的に
あまり綺麗ではないかなぁっと思ったからです。

もしこのコード使用する際は、適宜本文等の所は好きなように変更して貰えればと思います。

全て説明出来ている訳ではないかと思いますが
これで、templateファイルの説明は以上です。

■関数呼び出し

さてさて、メインファイル(lambda_function.py)に戻ります。

objectという、インスタンス変数を作成しましたので、
次は関数を呼び出して変数にいれています。勿論、変数の名前は自由につけて
貰って大丈夫です。
関数呼び出しの話は、templateファイルの所で説明したので省略します。

引数は各自で使いたいものを引数に指定して頂ければと思います。

後、引数でstr( )で恰好ている部分があります。これは恰好った所を文字列に
変換するというものです。
下記のコードで恰好っている所は、数値が入っていたりする箇所で、数値のままだと
TypeErrorでエラーが出てしまいますので、文字列に強制的変換しています。

lambda_function.py
# 関数呼び出し所をまとめて記載します。
subject = objects.subject(e['NewStateValue'])
header = objects.header(e['Trigger']['Namespace'], sentence="been Recovered to a \"Normal State\" !")
content = objects.contents(str(e['AlarmArn']), str(e['AlarmDescription']))
footer = objects.footer(e['Trigger']['ComparisonOperator'], str(e['Trigger']['Threshold']), e['Trigger']['Statistic'])

■条件分岐

ここでは、状態異常になった時と、状態異常が修正された時でメール文を変えている為、
CloudWatch Alarmのステータスの値で条件分岐させています。

分岐の条件が"NewStateValue"が、"ALARM" か ”OK” かで条件分岐させています。

分岐の中身ですが、大事というと大げさですが、重要なのは変数messageの所です。
最初に張り付けたコードでは、関数を呼び出して変数に入れる箇所も条件分岐の中に記載してますが、
これは条件分岐の外でも、中に入れてもどちらでも問題ないです。
そこはお好みで自由にやって貰って大丈夫です。
なので、下記のコードではその部分は省いて添付しています。

lambda_function.py
if e['NewStateValue'] == "OK":
        message = textwrap.dedent("""\
            {Header}
            
            {Content}
        """).format(Header=header, Content=content).strip()
        
    elif e['NewStateValue'] == "ALARM":
        
        message = textwrap.dedent("""\
            {Header}
            
            {Content}
            
            {Footer}
        """).format(Header=header, Content=content, Footer=footer).strip()     

さて、変数messageの中身ですが、ヒアドキュメントを使って記載しています。

ヒアドキュメントとは
文字列をシェルスクリプトやプログラミング言語に埋め込むための方法

要は、プログラミング言語の中に文章を書きたいという事ですね。
ヒアドキュメントの書き方は以下のように書きます。

message = """
こういう
改行
が入ったメールの文章が作れます
"""

下記のコードは、文章の余計な改行や余白を消す為のものです。
詳しくはネット検索でお願いします (ちょっとさぼらしてください(泣))

textwrap.dedent("""\

""").strip()

続いて、ヒアドキュメント外で作成した変数は、ヒアドキュメント内で使えません。
変数を使う為には下記のようにを記載します。
.format( )の中に、ヒアドキュメント外で作成した変数をイコールの右側に記載(header)
ヒアドキュメント内で使用する変数をイコールの左側に記載(Header)にしていきます。

実際に使用する場合、変数を "{ }"で恰好って使用します!

message = """
   {Header}
       
   {Content}
""").format(Header=header)

以上の点をふまえて書けば、余計な改行等なく左揃えのメール本文を作れる訳ですね。
変数messageの所の説明は終わりです。

ちなみに、 print(subject), print(message)はテストする際に中身を確認する際に
使用していたものなので特に必要なものではありません。

■返すレスポンスの作成

さぁ、最後です!

client.publishでAWS SNSを使用してメール送信をする事できます。
clientは最初の方で変数定義しましたね。
こんな文です。

"client = boto3.client('sns')"

変数 "TopicArn", "Message", "Subject" に今迄作成してきたメール本文、メール題名、SNSのarnを入れていきます。

ここの変数は確か定型文だったばずなので変更はしないで下さい。
※嘘だったらすみません...

lambda_function.py
response = client.publish(
        TopicArn = TOPIC_ARN,
        Message = message,
        Subject = subject
)
return response

◆最後に

これで、整形されたアラームが飛んでいくのではないかと思います。
拙い説明でしたが、どこかの誰かのお役に立てれば幸いです。

今回、初めてちゃんとLambda及びPython触りましたが、
なかなか奥が深いですね。また、Lambdaでコードを書く事があれば
自分のアウトプットにまあ書こうかと思います!

7
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
8