C#
lambda
Slack
CloudWatch-Logs
GoogleHome

CloudWatch Logsに書き込まれた内容をSlackとGoogle Homeで通知するAWS Lambda using dotnet

概要

  • CloudWatch Logsのメトリクスフィルターに特定の文字列を登録してLambdaにストリーミングし、Slackに通知するようにしました
  • C#(.NET)向けのライブラリのaws/aws-lambda-dotnetではCloudWatch Logsのイベントに型をつけるためのクラスがなかったので、プルリクを出してみました
  • ついでにエラー時にGoogle Homeでも知らせてくれるようにしました

課題

このようなアーキテクチャでLambdaを運用し始めたのですが、DLQの通知先として選択したメールでしか通知を行っていません。

DLQ.png

詳細はfreee developers Advent Calendar 2017 21日目『Rubyがマジョリティな会社でC#を使ってAWS Lambdaの本番運用を開始した話』に書きました。

次のような課題がありました。

  • DLQ経由で通知されるのはイベントのみ。イベントの中のrequest IDでCloudWatch Logsを検索してスタックトレースにたどりつかないといけない。
  • 実際にDLQに積まれてメール通知される(2回リトライに失敗する)ことはほとんどない。Lambdaのメトリクスページでエラーが発生しているのを見つけるとCloudWatch Logsを検索してエラーをたどってしまう。

エラー回数もあまり多くないので、エラーの発生を直接通知してみることにしました。

CloudWatch Logsの監視と通知

CloudWatch Logsにログを吐き出しているLambda内に直接通知ロジックを組み込んでしまうのも1つの手だと思います。

しかし、通知先や通知手段を変えたくなったり、他でも使いまわしたりできるはずなので通知用のLambdaを準備することにしました。

アイディアはつぎの記事からいただきました。

AWS Lambda で CloudWatch Logs のログ本文をSlack通知(2)

そのため、自社で運用しているLambdaは最終的にこんなアーキテクチャになるイメージです。

cloudwatchlogs.png
※DLQの通知も残します

最初のLambdaをC#で書いたので、せっかくなので通知用のLambdaもC#で実装することにしました。

C#で実装

出来上がったものはこちらにおいてあります。フォルダ構成等はGitHubのソースコードを参照してください。

toshi0607/ErrorNotificationLambda

Lambda本体

まずコードです。

Function.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using System.Text;
using System.IO;
using System.IO.Compression;

using Newtonsoft.Json;
using Newtonsoft.Json.Linq;

using Amazon.Lambda.Core;

[assembly: LambdaSerializer(typeof(Amazon.Lambda.Serialization.Json.JsonSerializer))]

namespace ErrorNotificationLambda
{
    public class Function
    {

        /// <summary>
        /// Notify contents waritten out on CloudWatch Logs
        /// </summary>
        /// <param name="logEvent"></param>
        /// <param name="context"></param>
        /// <returns></returns>
        public async Task<bool> FunctionHandler(LogEvent logEvent, ILambdaContext context)
        {
            var slackWebhookUrl = Environment.GetEnvironmentVariable("SLACK_WEBHOOK_URL");
            var cloudWatchLogGroupUrl = Environment.GetEnvironmentVariable("CLOUDWATCH_LOG_GROUP_URL");
            var cloudWatchMetricsUrl = Environment.GetEnvironmentVariable("CLOUDWATCH_METRICS_URL");

            var payload = new
            {
                channel = "dev",
                username = "CloudWatch Notification",
                text = $"{Decode(logEvent.Awslogs.Data)}"+ Environment.NewLine +
                       $"Logs: <{cloudWatchLogGroupUrl}|Click here>" + Environment.NewLine +
                       $"Metrics: <{cloudWatchMetricsUrl}|Click here>",
            };

            var jsonString = JsonConvert.SerializeObject(payload);

            var content = new FormUrlEncodedContent(new Dictionary<string, string>
            {
                { "payload", jsonString}
            });

            try
            {
                using (var client = new HttpClient())
                {
                    await client.PostAsync(slackWebhookUrl, content);
                }
            }
            catch (Exception e)
            {
                context.Logger.LogLine("error!!!!" + Environment.NewLine + $"{e.Message}" + Environment.NewLine + $"{e.StackTrace}");
                throw;
            }

            return true;
        }

        private string Decode(string encodedString)
        {
            var decodedString = "";

            byte[] data = Convert.FromBase64String(encodedString);

            using (GZipStream stream = new GZipStream(new MemoryStream(data), CompressionMode.Decompress))
            {
                const int size = 4096;
                byte[] buffer = new byte[size];
                using (MemoryStream memory = new MemoryStream())
                {
                    int count = 0;
                    do
                    {
                        count = stream.Read(buffer, 0, size);
                        if (count > 0)
                        {
                            memory.Write(buffer, 0, count);
                        }
                    }
                    while (count > 0);

                    var messages = JObject.Parse(Encoding.UTF8.GetString(memory.ToArray())).GetValue("logEvents");
                    foreach (var item in messages)
                    {
                        decodedString += item["message"].Value<string>() + Environment.NewLine;
                    }
                }
            }

            return decodedString;
        }

    }

    public class LogEvent
    {
        public  Log Awslogs { get; set; }
        public class Log
        {
            public  string Data { get; set; }
        }
    }
}

イベントのデシリアライズ

メトリクスフィルタを設定し、LambdaにストリーミングするとLambdaにはつぎのようなイベントが通知されます。

{
  "awslogs": {
    "data": "H4sIAAAAAAAAAHWPwQqCQBCGX0Xm7EFtK+smZBEUgXoLCdMhFtKV3akI8d0bLYmibvPPN3wz00CJxmQnTO41whwWQRIctmEcB6sQbFC3CjW3XW8kxpOpP+OC22d1Wml1qZkQGtoMsScxaczKN3plG8zlaHIta5KqWsozoTYw3/djzwhpLwivWFGHGpAFe7DL68JlBUk+l7KSN7tCOEJ4M3/qOI49vMHj+zCKdlFqLaU2ZHV2a4Ct/an0/ivdX8oYc1UVX860fQDQiMdxRQEAAA=="
   }
 }

Amazon CloudWatch Logs のイベント例 より

Lambda レコードの [Data] 属性は、Base64 でエンコードされており、gzip 形式で圧縮されています

という説明もあります。

例 2: AWS Lambda のサブスクリプションフィルタ

これをFunctionHandlerで受け取る際はAmazon.Lambda.Serialization.Json.JsonSerializerを使ってデシリアライズされるため、デシリアライズされる結果(クラス)を定義する必要があります。

その結果がLogEventです。

aws-lambda-dotnetではS3用のイベントクラスDynamoDB用のイベントクラスが定義されており、今回のように定義する必要はありません。

CloudWatch Logsの情報がそれほど複雑でないからでしょうか。
※この記事のあとに出したPRがマージされたため、2017.12.23時点ではCloudWatch Logs用のイベントクラスは存在します。
Amazon.Lambda.CloudWatchLogsEvents

SlackのIncoming Webhook

FunctionHandlerの最初の数行で環境変数から3つのURLを読み込んでいます。

var slackWebhookUrl = Environment.GetEnvironmentVariable("SLACK_WEBHOOK_URL");
var cloudWatchLogGroupUrl = Environment.GetEnvironmentVariable("CLOUDWATCH_LOG_GROUP_URL");
var cloudWatchMetricsUrl = Environment.GetEnvironmentVariable("CLOUDWATCH_METRICS_URL");

SLACK_WEBHOOK_URLはつぎのページに説明があります。

Incoming Webhooks

Slackの設定ページから取得できます。


slack1.png


slack2.png


通知メッセージ内のリンク用のURLも2つ追加しています。

エラーが発生したとき、Lambdaのログを吐き出すロググループのページとメトリクスのページ(時間ごとのLambda呼び出し回数やエラー数が確認できるダッシュボードのページ)も見たくなるかなぁくらいの意味合いです。

Slackへの通知は次のようになり、アイコンやユーザ名も上書きできます。

slack3.png

データのデコード

先ほども触れたように、Amazon CloudWatch Logs のイベントはエンコードされています。

それをデコードするためのメソッドがDecodeです。

ます、dataの中身をBase64デコードしてバイト配列にしています。(Convert.FromBase64String(encodedString)

次に、unzipしてJObjectにパースし、必要なメッセージだけを取得しています。

json全体は次のようになっています。

{
  "messageType":"DATA_MESSAGE",
  "owner":"123456789123",
  "logGroup":"testLogGroup",
  "logStream":"testLogStream",
  "subscriptionFilters":["testFilter"],
  "logEvents":
    [
      {
        "id":"eventId1",
        "timestamp":1440442987000,
        "message":"[ERROR] First test message"
      },
      {
        "id":"eventId2",
        "timestamp":1440442987001,
        "message":"[ERROR] Second test message"
      }
    ]
}

1つのlogEventsにはそれほど多くのmessageは詰め込まれない(???)はずなのでStringBuilderでなく+演算子でdecodedStringを組み立てています。

例外処理まったくないのでだいぶ脆いですね。

Amazon.Lambda.CloudWatchLogsEventsが追加されたときにデコード後のデータもCloudWatchLogsEventクラスでプロパティとして持つようになりました。

Lambdaテスト

.NETでLambda開発を行う場合、ローカルで動作確認を行うときはテストを利用すると便利です。

AWS Toolkit for Visual StudioをインストールしたじょうたいでLambdaプロジェクトを作成するとき、テスト付きのテンプレートを選択することができます。

コードはこんな感じです。

FunctionTest.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

using Xunit;
using Amazon.Lambda.Core;
using Amazon.Lambda.TestUtilities;

using ErrorNotificationLambda;

namespace ErrorNotificationLambda.Tests
{
    public class FunctionTest : IClassFixture<LaunchSettingsFixture>
    {
        LaunchSettingsFixture _fixture;

        public FunctionTest(LaunchSettingsFixture fixture)
        {
            _fixture = fixture;
        }

        [Fact]
        public async void TestErrorNotificationFunction()
        {
            var function = new Function();
            var context = new TestLambdaContext();

            var evnt = new LogEvent
            {
                Awslogs = new LogEvent.Log
                {
                    Data = "H4sIAAAAAAAAAHWPwQqCQBCGX0Xm7EFtK+smZBEUgXoLCdMhFtKV3akI8d0bLYmibvPPN3wz00CJxmQnTO41whwWQRIctmEcB6sQbFC3CjW3XW8kxpOpP+OC22d1Wml1qZkQGtoMsScxaczKN3plG8zlaHIta5KqWsozoTYw3/djzwhpLwivWFGHGpAFe7DL68JlBUk+l7KSN7tCOEJ4M3/qOI49vMHj+zCKdlFqLaU2ZHV2a4Ct/an0/ivdX8oYc1UVX860fQDQiMdxRQEAAA=="
                }
            };

            var notification = await function.FunctionHandler(evnt, context);

            Assert.True(notification);
        }
    }
}
LaunchSettingsFixture.cs
using System;
using System.IO;
using System.Collections.Generic;
using System.Linq;
using System.Text;

using Newtonsoft.Json;
using Newtonsoft.Json.Linq;

namespace ErrorNotificationLambda.Tests
{
    public class LaunchSettingsFixture : IDisposable
    {
        public LaunchSettingsFixture()
        {
            using (var file = File.OpenText("Properties" + Path.DirectorySeparatorChar + "launchSettings.json"))
            {
                var reader = new JsonTextReader(file);
                var jObject = JObject.Load(reader);

                var variables = jObject
                    .GetValue("profiles")
                    .SelectMany(profiles => profiles.Children())
                    .SelectMany(profile => profile.Children<JProperty>())
                    .Where(prop => prop.Name == "environmentVariables")
                    .SelectMany(prop => prop.Value.Children<JProperty>())
                    .ToList();

                foreach (var variable in variables)
                {
                    Environment.SetEnvironmentVariable(variable.Name, variable.Value.ToString());
                }
            }
        }

        public void Dispose()
        {
            // ... clean up
        }
    }
}

環境変数の設定

本番実行する際、環境変数はポータルで設定したものか、Visual Studioから発行するときに設定するものを使用できます。

ローカルではテストケース実行前にxUnitのClass Fixturesを利用して環境変数を設定しています。

Shared Context between Tests

Disposeはサボってます…

これ(本番デプロイした本体の方)をCloudWatch Logsメトリックフィルタの接続先に設定するとSlackで通知を受け取ることができます。

aws-lambda-dotnetへのコントリビューション

先ほどこんなことを書きました。

aws-lambda-dotnetではS3用のイベントクラスDynamoDB用のイベントクラスが定義されており、今回のように定義する必要はありません。

というわけで、CloudWatch Logs用のクラスを追加してみましょう。

Lambdaは次のイベントソースからのイベントで呼び出すことができ、イベントのサンプルも見ることができます。

サポートされているイベントソース

  • Amazon S3
  • Amazon DynamoDB
  • Amazon Kinesis Streams
  • Amazon Simple Notification Service
  • Amazon Simple Email Service
  • Amazon Cognito
  • AWS CloudFormation
  • Amazon CloudWatch Logs
  • Amazon CloudWatch Events
  • AWS CodeCommit
  • スケジュールされたイベント (Amazon CloudWatch Events を使用)
  • AWS Config
  • Amazon Alexa
  • Amazon Lex
  • Amazon API Gateway
  • AWS IoT ボタン
  • Amazon CloudFront
  • Amazon Kinesis Firehose
  • その他カスタムイベント

イベントソースによって公開されたサンプルイベント

これらの中でjsonをaws-lambda-dotnetの定義するクラスにデシリアライズできるものは限られています。

2017.12.17時点でこれだけです。

lambdadotnet.png

aws-lambda-dotnet/Libraries/src/

これらのクラスはどんなことをしてくれているのでしょうか?

たとえば、AWS Configイベントの対応関係はつぎのようになっています。

イベント
{
    "invokingEvent": "{\"configurationItem\":{\"configurationItemCaptureTime\":\"2016-02-17T01:36:34.043Z\",\"awsAccountId\":\"000000000000\",\"configurationItemStatus\":\"OK\",\"resourceId\":\"i-00000000\",\"ARN\":\"arn:aws:ec2:us-east-1:000000000000:instance/i-00000000\",\"awsRegion\":\"us-east-1\",\"availabilityZone\":\"us-east-1a\",\"resourceType\":\"AWS::EC2::Instance\",\"tags\":{\"Foo\":\"Bar\"},\"relationships\":[{\"resourceId\":\"eipalloc-00000000\",\"resourceType\":\"AWS::EC2::EIP\",\"name\":\"Is attached to ElasticIp\"}],\"configuration\":{\"foo\":\"bar\"}},\"messageType\":\"ConfigurationItemChangeNotification\"}",
    "ruleParameters": "{\"myParameterKey\":\"myParameterValue\"}",
    "resultToken": "myResultToken",
    "eventLeftScope": false,
    "executionRoleArn": "arn:aws:iam::012345678912:role/config-role",
    "configRuleArn": "arn:aws:config:us-east-1:012345678912:config-rule/config-rule-0123456",
    "configRuleName": "change-triggered-config-rule",
    "configRuleId": "config-rule-0123456",
    "accountId": "012345678912",
    "version": "1.0"
}
Amazon.Lambda.ConfigEvents/ConfigEvent.cs
namespace Amazon.Lambda.ConfigEvents
{
    using System;

    /// <summary>
    /// AWS Config event
    /// http://docs.aws.amazon.com/config/latest/developerguide/evaluate-config_develop-rules.html
    /// http://docs.aws.amazon.com/config/latest/developerguide/evaluate-config_develop-rules_example-events.html
    /// </summary>
    public class ConfigEvent
    {
        /// <summary>
        /// The ID of the AWS account that owns the rule.
        /// </summary>
        public string AccountId { get; set; }

        /// <summary>
        /// The ARN that AWS Config assigned to the rule.
        /// </summary>
        public string ConfigRuleArn { get; set; }

        /// <summary>
        /// The ID that AWS Config assigned to the rule.
        /// </summary>
        public string ConfigRuleId { get; set; }

        /// <summary>
        /// The name that you assigned to the rule that caused AWS Config
        /// to publish the event and invoke the function.
        /// </summary>
        public string ConfigRuleName { get; set; }

        /// <summary>
        /// A Boolean value that indicates whether the AWS resource to be
        /// evaluated has been removed from the rule's scope.
        /// </summary>
        public bool EventLeftScope { get; set; }

        /// <summary>
        /// The ARN of the IAM role that is assigned to AWS Config.
        /// </summary>
        public string ExecutionRoleArn { get; set; }

        /// <summary>
        /// If the event is published in response to a resource configuration
        /// change, the value for this attribute is a string that contains
        /// a JSON configuration item.
        /// </summary>
        public string InvokingEvent { get; set; }

        /// <summary>
        /// A token that the function must pass to AWS Config with the
        /// PutEvaluations call.
        /// </summary>
        public string ResultToken { get; set; }

        /// <summary>
        /// Key/value pairs that the function processes as part of its
        /// evaluation logic.
        /// </summary>
        public string RuleParameters { get; set; }

        /// <summary>
        /// A version number assigned by AWS.
        /// The version will increment if AWS adds attributes to AWS Config
        /// events.
        /// If a function requires an attribute that is only in events that
        /// match or exceed a specific version, then that function can check
        /// the value of this attribute.
        /// </summary>
        public string Version { get; set; }

    }
}

つまり、Json.NETでデシリアライズするためのクラスを定義しているだけです。

aws-lambda-dotnet/Libraries/src/Amazon.Lambda.ConfigEvents/

一番単純なプロジェクトはクラス定義 + README.md + テストです。

というわけで真似してPRを出してみました。

add cloudwatch logs event #188

ちょっとよくわからない部分もあったのでどうなるかわかりませんが、本番で便利に使えるよう粘ります。

※2017.12.23にマージされました🎉

おまけ

Echo招待来ないです。Google Home miniが届きました。

そういうわけで、SlackだけでなくGoogle Home miniでも通知してみましょう。

特に詳細に説明することはないのですが、Google Homeに喋らせるには現状noelportugal/google-home-notifierを利用するのが便利そうです。

Googleアシスタント用のIFTTTもいくつかありますが、Google Homeにしゃべらすことはできません。

そこでgoogle-home-notifierをサーバで立ち上げ、それを経由してGoogle Homeで通知してもらいます。

とりあえずこの記事が最高です。

Google Home開発入門 / google-home-notifier解説

設定はこの記事が詳しいです。

https://qiita.com/azipinsyan/items/db4606aaa51426ac8dac

本家のexample.jsのままでは日本語を喋ってくれないことに注意してください。

ややこしい部分はPR出してみました。

to enable language settings #31

Lambdaにはこんなコードを追加すればOKです。

var googleHomeWebhookUrl = "https://xxxxxxxx.ngrok.io/google-home-notifier";

var content = new FormUrlEncodedContent(new Dictionary<string, string>
{
    { "text", "AWS Lambdaでエラーが発生しました。早く直してください。お大事に。"}
});

using (var client = new HttpClient())
{
    var res = await client.PostAsync(googleHomeWebhookUrl, content);
}

動作確認してみましょう。

Google Homeは完全な出来心ですが、使いながら改善していこうと思います。