LambdaのErrorとWarningログをSlackに通知する

Lambdaのログに特定のキーワード(ErrorやWarning)があるとき、Slackに通知する仕組みを作りました。
2021.05.25

Lambdaにエラーが発生したとき、CloudWatch AlarmとChatbotでエラーを通知する仕組みはよく作ります。 しかし、Lambdaのエラーだけではなく、特定のキーワードを含む内容を通知したい場合もあります。

  • Lambda自体をエラーにできないが、Errorログがあるとき
  • Lambda自体は正常だが、Warningログがあるとき

というわけで、Lambdaのログに特定のキーワードがあるとき、Slackに通知する仕組みを作ってみました。

Slackに通知された様子

おすすめの方

  • Lambdaのログに特定キーワードがあるとき、Slack通知したい方
  • CloudWatch Logsのサブスクリプションフィルターを使いたい方

全体構成図

CloudWatch Logsのサブスクリプションフィルターで、キーワードマッチした内容をKinesis DataStreamsに流しています。 Kinesis DataStreamsに連結しいているLambdaから、Slackに投稿しています。

システム構成図

CloudWatch Logsから直接Lambdaに渡しても良いのですが、下記理由により、Kinesis DataStreamsを経由しています。

  • Lambdaのトリガー(サブスクリプションフィルター)の上限数が不明
  • 通知したい内容が短時間に大量発生すると、Lambdaの同時実行数が心配
  • 新しいLambdaを追加したとき、CloudFormation等で「Slackに通知するLambdaのトリガー」を追加するのが少し手間

LambdaのエラーをSlackに通知する仕組みを作成する

SSMパラメータストアにSlack通知先を追加

通知先URLを取得し、下記コマンドでSSM(AWS Systems Manager)のパラメータストアに追加します。 URLの先頭にhttps://があると、AWS CLIコマンド実行に失敗するため除去しています。

aws ssm put-parameter \
    --type 'String' \
    --name '/Slack/Url/LambdaErrorAndWarning' \
    --value 'hooks.slack.com/services/xxxxx/yyyyy/zzzzz'

SAM Init

sam init \
    --runtime python3.8 \
    --name Lambda-Error-Notify-Slack \
    --app-template hello-world \
    --package-type Zip

SAMテンプレート

template.yaml

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Lambda-Error-Notify-Slack

Parameters:
  NotifySlackUrl:
    Type: AWS::SSM::Parameter::Value<String>
    Default: /Slack/Url/LambdaErrorAndWarning

Resources:
  # ErrorとWarningログを出力するLambda
  HelloWorldFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: hello_world/
      Handler: target.lambda_handler
      Runtime: python3.8
      Timeout: 10
      Events:
        HelloWorld:
          Type: Api
          Properties:
            Path: /hello
            Method: get

  HelloWorldFunctionLogGroup:
      Type: AWS::Logs::LogGroup
      Properties:
        LogGroupName: !Sub /aws/lambda/${HelloWorldFunction}

  # Slackに通知するLambda
  NotifySlackFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: hello_world/
      Handler: notify.lambda_handler
      Runtime: python3.8
      Timeout: 10
      Environment:
        Variables:
          NOTIFY_SLACK_URL: !Ref NotifySlackUrl
      Events:
        Stream:
          Type: Kinesis
          Properties:
            Stream: !GetAtt LambdaLogStream.Arn
            StartingPosition: LATEST
            BatchSize: 10

  NotifySlackFunctionLogGroup:
      Type: AWS::Logs::LogGroup
      Properties:
        LogGroupName: !Sub /aws/lambda/${NotifySlackFunction}

  # サブスクリプションフィルター
  HelloWorldFunctionLogSubscriptionFilter:
    Type: AWS::Logs::SubscriptionFilter
    Properties:
      FilterPattern: "?ERROR ?WARNING"
      LogGroupName: !Ref HelloWorldFunctionLogGroup
      DestinationArn: !GetAtt LambdaLogStream.Arn
      RoleArn: !GetAtt LambdaLogSubscriptionFilterRole.Arn

  # サブスクリプションフィルター用のIAMロール
  LambdaLogSubscriptionFilterRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - !Sub logs.${AWS::Region}.amazonaws.com
            Action:
              - sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/AmazonKinesisFullAccess

  # サブスクリプションフィルターを通過したログを受け取るKinesis DataStreams
  LambdaLogStream:
    Type: AWS::Kinesis::Stream
    Properties:
        ShardCount: 1

Outputs:
  HelloWorldApi:
    Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/hello/"

Lambdaコード

ログの内容を監視して、通知したいLambda

target.py

import json
import logging

logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)

def lambda_handler(event, context):
    logger.error('This is error message.')
    logger.warning('This is warning message.')
    logger.info('This is info message.')
    logger.debug('This is debug message.')

    return {
        'statusCode': 200,
        'body': json.dumps({
            'message': 'hello world',
        }),
    }

Slackに通知するLambda

notify.py

import os
import json
import urllib.request
from base64 import b64decode
from gzip import GzipFile
from io import BytesIO

NOTIFY_SLACK_URL = os.environ['NOTIFY_SLACK_URL']


def lambda_handler(event, context):
    for item in event['Records']:
        data = json.loads(GzipFile(fileobj=BytesIO(b64decode(item['kinesis']['data']))).read())
        log_group = data['logGroup']
        log_stream = data['logStream']

        for log_event in data['logEvents']:
            message = log_event['message']
            payload = make_payload(log_group, log_stream, message)
            post_slack(payload)


def make_payload(log_group, log_stream, message):
    base_url = 'https://ap-northeast-1.console.aws.amazon.com/cloudwatch/home?region=ap-northeast-1#logsV2:'
    url = base_url + f'log-groups/log-group/{log_group.replace("/", "$252F")}/log-events/{log_stream.replace("/", "$252F")}'
    return {
        'blocks': [
            {
                'type': 'section',
                'fields': [
                    {
                        'type': 'plain_text',
                        'text': f'Log Group: {log_group}'
                    },
                    {
                        'type': 'plain_text',
                        'text': f'Log Stream: {log_stream}'
                    },
                    {
                        'type': 'mrkdwn',
                        'text': f'<{url}|Log Link>'
                    }
                ]
            },
            {
                'type': 'section',
                'text': {
                    'type': 'plain_text',
                    'text': f'{message}'
                }
            }
        ]
    }

def post_slack(payload):
    try:
        res = post(f'https://{NOTIFY_SLACK_URL}', data=payload)
    except requests.exceptions.RequestException as e:
        print(e)
        raise
    else:
        print(res.status)

def post(url: str, data: dict, headers:dict = {}):
    req = urllib.request.Request(url,
                                 data=json.dumps(data).encode('utf-8'),
                                 headers=headers,
                                 method='POST')
    return urllib.request.urlopen(req)

デプロイ

sam build

sam deploy \
    --template-file template.yaml \
    --stack-name Lambda-Error-Notify-Slack-Stack \
    --s3-bucket cm-fujii.genki-deploy \
    --capabilities CAPABILITY_NAMED_IAM \
    --no-fail-on-empty-changeset

動作確認

Errorを発生させる

curl https://z4m5yv7tj1.execute-api.ap-northeast-1.amazonaws.com/Prod/hello/

監視しているLambdaのログ

Lambdaのログの様子

Slackに通知された様子

ErrorWarningのログがSlackに通知されました!

Slackに通知された様子

さいごに

今回は単純な内容で作成しましたが、ErrorとWarningで通知先チャンネルを分けると、もっと分かりやすいです。

参考