概要
- CloudWatch Logsのメトリクスフィルターに特定の文字列を登録してLambdaにストリーミングし、Slackに通知するようにしました
- C#(.NET)向けのライブラリのaws/aws-lambda-dotnetではCloudWatch Logsのイベントに型をつけるためのクラスがなかったので、プルリクを出してみました
- ついでにエラー時にGoogle Homeでも知らせてくれるようにしました
課題
このようなアーキテクチャでLambdaを運用し始めたのですが、DLQの通知先として選択したメールでしか通知を行っていません。
詳細は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は最終的にこんなアーキテクチャになるイメージです。
最初のLambdaをC#で書いたので、せっかくなので通知用のLambdaもC#で実装することにしました。
C#で実装
出来上がったものはこちらにおいてあります。フォルダ構成等はGitHubのソースコードを参照してください。
toshi0607/ErrorNotificationLambda
Lambda本体
まずコードです。
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はつぎのページに説明があります。
Slackの設定ページから取得できます。
通知メッセージ内のリンク用のURLも2つ追加しています。
エラーが発生したとき、Lambdaのログを吐き出すロググループのページとメトリクスのページ(時間ごとのLambda呼び出し回数やエラー数が確認できるダッシュボードのページ)も見たくなるかなぁくらいの意味合いです。
Slackへの通知は次のようになり、アイコンやユーザ名も上書きできます。
データのデコード
先ほども触れたように、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プロジェクトを作成するとき、テスト付きのテンプレートを選択することができます。
コードはこんな感じです。
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);
}
}
}
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を利用して環境変数を設定しています。
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時点でこれだけです。
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"
}
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);
}
動作確認してみましょう。
Qiita用
— かえる氏の闘争 (@toshi0607) 2017年12月19日
/ CloudWatch Logに特定のエラーが吐かれたことをGoogle Homeに喋らせる https://t.co/wdRJno14Iw
Google Homeは完全な出来心ですが、使いながら改善していこうと思います。