Amazon SNSからSlackに通知する仕組みを導入した話(実装編)

宮下(mizzy)です。

Amazon SNSからSlackに通知する仕組みを導入した話(概要編)でご紹介した仕組みの実装面について解説します。


TerraformによるAWS上のリソース設定

SNS TopicとLambda Functionの関連づけ

Slack通知用SNS Topicの作成と、実際に通知を行うLambda Functionの関連づけは、Terraformコードでこのように記述しています。

ただし、Lambda FunctionはTerraformではなくecspressoで管理しているため、Lambda Functionの情報はData Sourceで読み込んでいます。

data "aws_lambda_function" "sns_to_slack" {
  function_name = "sns-to-slack"
}

resource "aws_sns_topic" "slack_notification" {
  name = "slack-notification"
}

resource "aws_sns_topic_subscription" "slack_notification" {
  endpoint  = data.aws_lambda_function.sns_to_slack.arn
  protocol  = "lambda"
  topic_arn = aws_sns_topic.slack_notification.arn
}

resource "aws_lambda_permission" "slack_notification" {
  action        = "lambda:InvokeFunction"
  function_name = data.aws_lambda_function.sns_to_slack.function_name
  principal     = "sns.amazonaws.com"
  source_arn    = aws_sns_topic.slack_notification.arn
}

Bot User OAuth Tokenの格納場所作成とアクセス権の設定

Lambda FunctionからSlackへの接続時に必要な、Bot User OAuth Tokenを格納するためのSSMパラメータを作成し、Lambda Functionから値を読み取れるよう権限設定しています。

Bot User OAuth Tokenの値はTerraformでは設定しないため、ダミーの値を設定し、ignore_changesで変更を無視するようにしています。

resource "aws_ssm_parameter" "talentio_ruboty_bot_user_oauth_token" {
  name  = "/slack/bot-user-oauth-token/TalentioRuboty"
  type  = "SecureString"
  value = "dummy"

  lifecycle {
    ignore_changes = [value]
  }
}

resource "aws_iam_role" "sns_to_slack" {
  name               = "sns-to-slack"
  assume_role_policy = data.aws_iam_policy_document.sns_to_slack_assume_role.json
  inline_policy {
    name   = "inline"
    policy = data.aws_iam_policy_document.sns_to_slack.json
  }
}

data "aws_iam_policy_document" "sns_to_slack_assume_role" {
  statement {
    effect  = "Allow"
    actions = ["sts:AssumeRole"]
    principals {
      identifiers = ["lambda.amazonaws.com"]
      type        = "Service"
    }
  }
}

data "aws_iam_policy_document" "sns_to_slack" {
  statement {
    effect  = "Allow"
    actions = ["ssm:GetParameter"]
    resources = [
      aws_ssm_parameter.talentio_ruboty_bot_user_oauth_token.arn,
    ]
  }
}

SNSにPublishする際、以下のようにSlackAppでSlack App名を指定するようにしていますが、ここで指定された値によって、Tokenを読み取るSSMパラメータを変更することで、複数のSlack Appに対応できるようにしています。

aws sns publish \
  --topic-arn arn:aws:sns:ap-northeast-1:xxxxxxxxxxx:slack-notification \
  --subject 'Message from CLI' \
  --message '{
    "SlackApp": "TalentioRuboty",
    "SlackChannel": "mizzy-test"
  }'

通知用Lambda Function

Slackへ通知を行うLambda Function本体は、以下のような125行のRubyコードです。

require 'json'
require 'aws-sdk-ssm'
require 'uri'
require 'net/http'
require 'slack-ruby-client'

def handler(event:, context:)
  event = event['Records'][0]

  subject = event['Sns']['Subject']
  message = JSON.load(event['Sns']['Message'])

  if message['Status'] == 'Success'
    title = ":white_check_mark: #{subject}"
  elsif message['Status'] == 'Fail'
    title = ":x: #{subject}"
  elsif message['Status'] == 'Warning'
    title = ":warning: #{subject}"
  else
    title = ":information_source: #{subject}"
  end

  blocks = [{
              type: 'header',
              text: {
                type: 'plain_text',
                text: title,
              },
            }]

  if !message['User'].nil?
    blocks.append(
      {
        type: 'section',
        text: {
          type: 'mrkdwn',
          text: "<@#{message['User']}>",
        },
      }
    )
  end

  fields = []

  if !message['Parameters'].nil?
    message['Parameters'].each do |key, value|
      fields.append({
        type: 'mrkdwn',
        text: "*#{key}:*\n #{value}",
      })
    end
  end

  if !message['StartTime'].nil?
    start_time = Time.parse(message['StartTime'])
    end_time = Time.now
    fields.append({
      type: 'mrkdwn',
      text: "*Elapsed time:*\n #{((end_time - start_time) / 60).round(1)} minutes",
    })
  end

  # fieldsが空の場合Slackがinvalid_blocksエラーを返すので空白文字で埋める
  if fields.size == 0
    fields.append({
      type: 'mrkdwn',
      text: ' ',
    })
  end

  blocks.append({
    type: 'section',
    fields: fields,
  })

  if !message['Arn'].nil?
    link = arn_to_url(message['Arn'])
    blocks.append(
      {
        type: 'section',
        text: {
          type: 'mrkdwn',
          text: "*Link:*\n<#{link}|#{link}>",
        },
      }
    )
  end

  if !message['Url'].nil?
    blocks.append(
      {
        type: 'section',
        text: {
          type: 'mrkdwn',
          text: "*Link:*\n<#{message['Url']}|#{message['Url']}>",
        },
      }
    )
  end

  ssm = Aws::SSM::Client.new

  req = {
    name: "/slack/bot-user-oauth-token/#{message['SlackApp']}",
    with_decryption: true,
  }

  res = ssm.get_parameter(req)

  Slack.configure do |config|
    config.token = res.parameter.value
  end

  client = Slack::Web::Client.new
  client.auth_test
  client.chat_postMessage(channel: message['SlackChannel'], text: title, blocks: JSON.dump(blocks), as_user: true)

  return
end

def arn_to_url(arn)
  elements = arn.split(':')
  region = elements[3]
  "https://console.aws.amazon.com/go/view?arn=#{arn}"
end

おわりに

Amazon SNSからSlackに通知する仕組みは、各社さんそれぞれ実装していると思いますし、検索すると色々と記事が出てくるのですが、実運用レベルでどのように実装しているか、といった記事はあまりなさそうでしたので、実例として紹介してみました。

通知にはLambda FunctionではなくAWS Chatbotの利用も考えましたが、通知内容のカスタマイズを柔軟に行うことができなさそうだったので、Lambda Functionを利用する形になりました。

Amazon SNSからSlackに通知する仕組みを導入した話(概要編)でも書きましたが、元々、このような仕組みを導入したのは、AWS Step Functionsから簡単に通知できるようにしたかったからです。

Step Functionsを実際の運用でどのように利用して、どのように通知しているか、という具体例については、また後日ご紹介できればと思います。