AWS Lambdaの実行結果をメールで通知する

Lambdaを作ってEventBridgeで定期実行させてたけど、色々環境をいじってるうちに動かなくなってたってことがあります。そんなときにすぐに気づけるようにメール通知を実装したいと思います。
#構成
![template1-designer (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/996764/b0a888e3-14cf-1e27-e80a-506bd04d4130.png)
サブスクリプションフィルター＋Lambda＋SNSで実現します。

![template1-designer.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/996764/5e9cc12e-4704-8d71-dd97-021758346a8f.png)
実際に動作させるには権限周りや入れ子になっている設定が必要なのでこのようになります。(このあたりも後で解説します。)

#Lambdaコード
こちらの記事を参考にカスタマイズしました。
https://dev.classmethod.jp/articles/notification_cloudwatchlogs_subscriptoinfilter/

```python
import base64
import json
import zlib
import datetime
import os
import boto3
from botocore.exceptions import ClientError

print('Loading function')

def lambda_handler(event, context):
    data = zlib.decompress(base64.b64decode(event['awslogs']['data']), 16+zlib.MAX_WBITS)
    data_json = json.loads(data)
    log_entire_json = json.loads(json.dumps(data_json["logEvents"], ensure_ascii=False))
    log_entire_len = len(log_entire_json)
    
    #メールの件名用にロググループ名を取得
    logGroup = data_json['logGroup']
    #ログ全体格納用
    entire_message = ''

    print(log_entire_json)

    for i in range(log_entire_len): 
        log_json = json.loads(json.dumps(data_json["logEvents"][i], ensure_ascii=False))
        entire_message += log_json['message']
    
    #複数のキーワードを設定
    keywords = ['error', 'timed out']
    
    #キーワードのいずれかを検知した時点でメールを送信する
    for keyword in keywords:
        if keyword in entire_message.lower():
            try:
                sns = boto3.client('sns')
                
                #SNS Publish
                publishResponse = sns.publish(
                    TopicArn = os.environ['SNS_TOPIC_ARN'],
                    Message = entire_message,
                    Subject = f'[{keyword.capitalize()}] {logGroup} Log stream'
                )
                print(f"keyword:'{keyword}' in, send email")
                
            except Exception as e:
                print(e)
                
            finally:
                return
            
    print("keywords not in, don't send email")
    return
```

検知したキーワードを件名に入れ、ログ全体(対象Lambdaの開始から終了まで)をメール通知します。
![SnapCrab_NoName_2021-7-20_2-52-24_No-00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/996764/ef6b71b4-1165-5326-0dca-5811c9e87ad2.png)

最初は検知しない場合も通知するものを考えていたのですが、うっとうしかったり、Gmailのスレッド形式表示だと埋もれてしまったりするので、errorとタイムアウトのときのみ通知するようにしました。
参考にした記事もそうでしたが、よくあるのは検知したメッセージ部分だけをメール通知するというもの。でも、エラーの詳しい内容も確認できたらいいよなあと思い、全体を通知するようにしました。なので、サブスクリプションフィルターではあえてフィルターせずに全体をLambdaに送信し処理を行います。
今回は主にエラー検知を目的としていますが、正常に終了した場合でも実行過程や結果をprint出力し、それをキーワード検知してメール送信するといった使い方も視野に入れています。(エラーなしの場合も通知するバージョンも後で載せます。)

#サブスクリプションフィルター
![SnapCrab_NoName_2021-7-20_1-56-54_No-00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/996764/fd216b2b-4665-00dc-cdf8-52bfbe176bce.png)
通知を行いたいLambda(例えばEC2の起動停止Lambda)のロググループで設定を行います。フィルターパターンがこのように指定なしになっていればOKです。コンソールによる作成時に実際のログでフィルターのテストもできます。また、メール通知Lambdaへの権限も付与されます。作成後は編集できませんが、CloudFormationテンプレートも後で載せておきますので参考にしてください。
CloudWatch Logsにログが出力されていることが前提となりますので、対象のLambdaのCloudWatch Logsへのアクセス権限を確認し、一度テストで実行するなりしてログを出力しておきましょう。

#IAMロール
以下のポリシーをアタッチしてメール通知Lambdaに使います。一応、メール通知Lambda自身もCloudWatch Logsにログを送信しています。

```json
{
  "Version": "2012-10-17",
  "Statement": [
      {
          "Effect": "Allow",
          "Action": [
            "logs:CreateLogStream",
            "sns:Publish",
            "logs:CreateLogGroup",
            "logs:PutLogEvents"
          ],
          "Resource": "*"
      }
  ]
}
```

#リソース作成
後はSNSトピックにメールアドレスのサブスクリプションを設定しておけば、一通り揃いますので組み合わせていくだけですが…そのへんは面倒なのでCloudFormationテンプレートを載せておきます。ターゲットや名前はパラメータにしているので、任意の値を入力して使うこともできます。前述のLambdaコードも含んでいるので少し長くなっています。

```yaml
AWSTemplateFormatVersion: "2010-09-09"
Metadata:
    Generator: "former2"
Description: ""
Parameters:
    ManagedPolicyName:
        Type: String
        Default: lambda-error-send-email-policy
    RoleName:
        Type: String
        Default: LambdaErrorSendEmailRole
    TopicName:
        Type: String
        Default: LambdaErrorSendEmailTopic
    Endpoint:
        Type: String
        Default: info@example.com
    FunctionName:
        Type: String
        Default: LambdaErrorSendEmailFunction
    LogGroupName:
        Type: String
        Default: /aws/lambda/[TargetFunctionName]
Resources:
    IAMManagedPolicy:
        Type: "AWS::IAM::ManagedPolicy"
        Properties:
            ManagedPolicyName: !Sub "${ManagedPolicyName}"
            Path: "/"
            PolicyDocument: |
                {
                    "Version": "2012-10-17",
                    "Statement": [
                        {
                            "Effect": "Allow",
                            "Action": [
                              "logs:CreateLogStream",
                              "sns:Publish",
                              "logs:CreateLogGroup",
                              "logs:PutLogEvents"
                            ],
                            "Resource": "*"
                        }
                    ]
                }

    IAMRole:
        Type: "AWS::IAM::Role"
        Properties:
            Path: "/"
            RoleName: !Sub "${RoleName}"
            AssumeRolePolicyDocument: "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"lambda.amazonaws.com\"},\"Action\":\"sts:AssumeRole\"}]}"
            MaxSessionDuration: 3600
            ManagedPolicyArns: 
              - !Ref IAMManagedPolicy
            Description: ""

    SNSTopic:
        Type: "AWS::SNS::Topic"
        Properties:
            DisplayName: ""
            TopicName: !Sub "${TopicName}"

    SNSSubscription:
        Type: "AWS::SNS::Subscription"
        Properties:
            TopicArn: !Ref SNSTopic
            Endpoint: !Sub "${Endpoint}"
            Protocol: "email"
            Region: !Ref AWS::Region

    LambdaFunction:
        Type: "AWS::Lambda::Function"
        Properties:
            Description: ""
            Environment: 
                Variables: 
                    SNS_TOPIC_ARN: !Ref SNSTopic
            FunctionName: !Sub "${FunctionName}"
            Handler: "index.lambda_handler"
            Code: 
                ZipFile: |
                    import base64
                    import json
                    import zlib
                    import datetime
                    import os
                    import boto3
                    from botocore.exceptions import ClientError

                    print('Loading function')

                    def lambda_handler(event, context):
                        data = zlib.decompress(base64.b64decode(event['awslogs']['data']), 16+zlib.MAX_WBITS)
                        data_json = json.loads(data)
                        log_entire_json = json.loads(json.dumps(data_json["logEvents"], ensure_ascii=False))
                        log_entire_len = len(log_entire_json)
                        
                        #メールの件名用にロググループ名を取得
                        logGroup = data_json['logGroup']
                        #ログ全体格納用
                        entire_message = ''

                        print(log_entire_json)

                        for i in range(log_entire_len): 
                            log_json = json.loads(json.dumps(data_json["logEvents"][i], ensure_ascii=False))
                            entire_message += log_json['message']
                        
                        #複数のキーワードを設定
                        keywords = ['error', 'timed out']
                        
                        #キーワードのいずれかを検知した時点でメールを送信する
                        for keyword in keywords:
                            if keyword in entire_message.lower():
                                try:
                                    sns = boto3.client('sns')
                                    
                                    #SNS Publish
                                    publishResponse = sns.publish(
                                        TopicArn = os.environ['SNS_TOPIC_ARN'],
                                        Message = entire_message,
                                        Subject = f'[{keyword.capitalize()}] {logGroup} Log stream'
                                    )
                                    print(f"keyword:'{keyword}' in, send email")
                                    
                                except Exception as e:
                                    print(e)
                                    
                                finally:
                                    return
                                
                        print("keywords not in, don't send email")
                        return
            MemorySize: 128
            Role: !GetAtt IAMRole.Arn
            Runtime: "python3.8"
            Timeout: 60
            TracingConfig: 
                Mode: "PassThrough"

    LambdaPermission:
        Type: "AWS::Lambda::Permission"
        Properties:
            Action: "lambda:InvokeFunction"
            FunctionName: !GetAtt LambdaFunction.Arn
            Principal: "logs.amazonaws.com"
            SourceArn: !Sub "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:${LogGroupName}:*"

    LogsSubscriptionFilter:
        Type: "AWS::Logs::SubscriptionFilter"
        Properties:
            LogGroupName: !Sub "${LogGroupName}"
            FilterPattern: ""
            DestinationArn: !GetAtt LambdaFunction.Arn
```
#エラーなしでも通知するLambda
```python
import base64
import json
import zlib
import datetime
import os
import boto3
from botocore.exceptions import ClientError

print('Loading function')

def lambda_handler(event, context):
    data = zlib.decompress(base64.b64decode(event['awslogs']['data']), 16+zlib.MAX_WBITS)
    data_json = json.loads(data)
    log_entire_json = json.loads(json.dumps(data_json["logEvents"], ensure_ascii=False))
    log_entire_len = len(log_entire_json)
    
    logGroup = data_json['logGroup']
    entire_message = ''

    print(log_entire_json)

    for i in range(log_entire_len): 
        log_json = json.loads(json.dumps(data_json["logEvents"][i], ensure_ascii=False))
        entire_message += log_json['message']
    
    keywords = ['error', 'timed out']
    
    result = 'notification'
    for keyword in keywords:
        if keyword in entire_message.lower():
            result = keyword
            break

    try:
        sns = boto3.client('sns')
        
        #SNS Publish
        publishResponse = sns.publish(
            TopicArn = os.environ['SNS_TOPIC_ARN'],
            Message = entire_message,
            Subject = f'[{result.capitalize()}] {logGroup} Log stream'
        )
        print(f'send [{result.capitalize()}] email')

    except Exception as e:
        print(e)
```
先ほどと同様に、error、タイムアウトの際は件名に入れてメール通知しますが、それらを検知しなかった場合も件名[Notification]としてログ全体をメール通知します。正常終了した場合でも結果をメールで確認したい場合は、Lambdaコードをこちらに書き換えればOKです。