はじめに

クラウドインテグレーション事業部の三木です。
既知の原因により継続的にALBで5xxエラーが発生している状況がありました。
5xxエラー発生時に、CloudWatch Logs ログストリーム上に特定の文字列が出力されていないか、随時確認を行っていました。
毎回ログストリームを確認するのが手間に感じたため、特定の文字列を検知した際に特定の文字列を含むログをSlack通知できる様にしました。

構成

以下の様に、EC2へインストールしているApacheのアクセスログをCloudWatch Logs へ出力していました。

今回Terraformで以下のAWSリソースを作成します。

  • CloudWatch Logs ロググループ
  • CloudWatch Logs ログストリーム
  • CloudWatch Logs サブスクリプションフィルター
  • Lambda
  • IAM Role
  • IAM Policy

TerraformでAWSリソースを作成した後の構成は以下の様になります。
赤で囲っている部分がTerraformで作成するAWSリソースとなります。

準備

Terraformに必要なファイルの作成

ディレクトリ構造

miki1:Downloads miki$ tree
.
├── backend.tf
├── functions
│   └── lambda_function.py
├── main.tf
└── variable.tf

1 directory, 4 files
miki1:Downloads miki$

backend.tfの内容

backend.tfでは「プロバイダー情報」を記載しています。

miki1:Downloads miki$ cat backend.tf
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 3.44.0"
    }
  }

  required_version = "~> 1.3.1"
}

provider "aws" {
  region     = "ap-northeast-1"
  access_key = var.aws_access_key
  secret_key = var.aws_secret_key
}
miki1:Downloads miki$

variable.tfの内容

variable.tfでは他のファイルで利用している変数定義を記載しています。

miki1:Downloads miki$ cat variable.tf
variable "aws_access_key" {}
variable "aws_secret_key" {}
miki1:Downloads miki$

lambda_function.pyの内容

Webhook URLは以下リンク先より作成できます。[1]

https://slack.com/signin?redir=%2Fservices%2Fnew%2Fincoming-webhook

miki1:Downloads miki$ cat functions/lambda_function.py
import logging
import json
import urllib.request
import base64
import gzip

logger = logging.getLogger()
logger.setLevel(logging.INFO)

def post_slack(argStr):
    message = argStr

    send_data = {
    <span class="loom_code_cs_string">"username"</span>: <span class="loom_code_cs_string">"alert_not<span class="loom_code_cs_reserved">if</span>ication"</span>,
    <span class="loom_code_cs_string">"icon_emoji"</span>: <span class="loom_code_cs_string">":bell:"</span>,
    <span class="loom_code_cs_string">"text"</span>: message,
    }

    send_text = <span class="loom_code_cs_string">"payload="</span> + json.dumps(send_data)

    # URLにはご自分のWebhook URLを入力してください
    request = urllib.request.Request(
    <span class="loom_code_cs_string">"https:<span class="loom_code_cs_linecomment">//hooks.slack.com/services<span class="loom_code_cs_blockcomment">/*********/</span>***********/************************"</span>,</span>
    data=send_text.encode(<span class="loom_code_cs_string">"utf-8"</span>),
    method=<span class="loom_code_cs_string">"POST"</span>
    )

    with urllib.request.urlopen(request) <span class="loom_code_cs_reserved">as </span>response:
        response_body = response.read().decode(<span class="loom_code_cs_string">"utf-8"</span>)

def lambda_handler(event, context):

    # CloudWatch Logsからのデータはbase64エンコードされているのでデコード
    decoded_data = base64.b64decode(event['awslogs']['data'])

    # バイナリに圧縮されているため展開
    json_data = json.loads(gzip.decompress(decoded_data))

    # ログデータ取得
    error_log = json_data['logEvents'][0]['message']

    post_slack(error_log)
miki1:Downloads miki$

main.tfの内容

「************」には自身のAWS アカウントIDを記載します。

miki1:Downloads miki$ cat main.tf
resource "aws_cloudwatch_log_group" "log_group" {
  name = "iret-media"
}

resource "aws_cloudwatch_log_stream" "log_stream" {
  name           = "iret-media"
  log_group_name = aws_cloudwatch_log_group.log_group.name
}

resource "aws_cloudwatch_log_subscription_filter" "logfilter" {

    distribution = "ByLogStream"
    filter_pattern = "ERROR"
    log_group_name = aws_cloudwatch_log_group.log_group.name
    name = "iret-media"
    destination_arn = aws_lambda_function.lambda.arn
}

resource "aws_lambda_function" "lambda" {
    function_name = "iret-media"
    handler = "lambda_function.lambda_handler"
    memory_size = "128"
    role = aws_iam_role.role.arn
    runtime = "python3.8"
    filename = data.archive_file.function_source.output_path
    source_code_hash = data.archive_file.function_source.output_base64sha256
    timeout = "60"
}
data "archive_file" "function_source" {
  type        = "zip"
  source_dir  = "${path.module}/functions"
  output_path = "${path.module}/archive/function.zip"
}

resource "aws_lambda_permission" "lambda_permission" {
    statement_id  = "AllowExecutionFromCloudWatch"
    action        = "lambda:InvokeFunction"
    function_name = aws_lambda_function.lambda.function_name
    principal     = "logs.amazonaws.com"
    source_account = "************"
    source_arn    = "arn:aws:logs:ap-northeast-1:************:log-group:iret-media:*"
}

resource "aws_iam_role" "role" {
  name = "iret-media"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "lambda.amazonaws.com"
        }
      },
    ]
  })
}

resource "aws_iam_policy" "policy" {
  name        = "iret-media"

  policy = <<EOF
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": "logs:CreateLogGroup",
            "Resource": "arn:aws:logs:ap-northeast-1:************:*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": [
                "arn:aws:logs:ap-northeast-1:************:log-group:/aws/lambda/iret-media:*",
                "arn:aws:logs:ap-northeast-1:************:log-group:/aws/lambda/iret-media:*"
            ]
        }
    ]
}
EOF
}

resource "aws_iam_role_policy_attachment" "CloudWatchLogsFullAccess" {
  role       = aws_iam_role.role.name
  policy_arn = "arn:aws:iam::aws:policy/CloudWatchLogsFullAccess"
}

resource "aws_iam_role_policy_attachment" "LambdaAccess" {
  role       = aws_iam_role.role.name
  policy_arn = aws_iam_policy.policy.arn
}
miki1:Downloads miki$

TerraformでAWSリソースの作成

main.tfがあるディレクトリ上で、terraform initを実行します。

miki1:Downloads miki$ ls
backend.tf  functions   main.tf     variable.tf
miki1:Downloads miki$
miki1:Downloads miki$ terraform init

Initializing the backend...

Initializing provider plugins...
- Finding hashicorp/aws versions matching "~> 3.44.0"...
- Finding latest version of hashicorp/archive...
.....省略
miki1:Downloads miki$

terraform applyを実行するとアクセスキー/シークレットアクセスキーが求められるので入力します。

miki1:Downloads miki$ terraform apply
var.aws_access_key
  Enter a value: 

var.aws_secret_key
  Enter a value: 

.....省略

Plan: 8 to add, 0 to change, 0 to destroy.

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes

.....省略

aws_lambda_permission.lambda_permission: Creation complete after 0s [id=****************************]

Apply complete! Resources: 8 added, 0 changed, 0 destroyed.
miki1:Downloads miki$

通知確認

CloudWatchのコンソールでキャプチャに記載している数字の順番で画面遷移を行います。

「ログイベントの作成」をクリックします。

「ERR0R」と入力します。

Webhook URLを作成する時に指定したSlack チャンネルに通知が飛ぶことを確認できます。

参考記事

[1]:https://qiita.com/vmmhypervisor/items/18c99624a84df8b31008

まとめ

CloudWatch Logs ログストリーム上に特定文字列を検知した際に、Slackに通知する方法について解説しました。
これで毎回AWSコンソールを開かないで済むので手間が省けました。