9
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Lambda + Go + terraformでAWSの料金をSlack通知してみた

Last updated at Posted at 2021-12-06

はじめに

この記事はmixiアドベントカレンダー2021 6日目の記事です。
みなさまはAWSで運用しているサービスに毎月どれくらいのコストがかかっているか、サービスのリソースごとにどれくらいの料金がかかっているかを確認していますでしょうか。
今回は、AWSの料金を毎日Slackに通知するシステムをLambda + Go + terraformで構築したのでそちらをご紹介させていただきます。

おすすめな人

  • AWSにかかる料金を定期的に通知したい
  • GoでLambdaをどう開発するか知りたい
  • terraformでLambdaを管理する方法を知りたい

アーキテクチャー

aws_billing_notification.png

  • EventBridge

    定時(AM10時)にLambdaを動作させるために使用
  • Lambda

    KMSでWebHookURLの複合化、AWS Cost Explorerから情報取得、Slackに送る文字列の整形、Slackへのリクエストを行う
  • KeyManagementService

    WebhookUrlを暗号化するために使用
  • AWS Cost Explorer

    使用した料金の取得、今月の料金予測、サービス毎の料金取得を行う
  • CloudWatchAlarm & SNS & Chatbot

    Lambda内の処理でエラーが起きた時にSlackに通知を送る

事前に

Lambdaのローカル実行環境作成

開発をスムーズに進めていくためにまずはローカルでLambdaを実行できる環境を作りましょう

  • イメージ作成用のファイルを用意

    ↓を参考に、RIEを組み込んだイメージを作成するためのDockerfileとscript.shを用意します

    https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/go-image.html#go-image-clients

    また、同じ階層にLambdaで起動したいgoのコードを用意します(今回は簡単にnameを受け取ってそのまま返すだけのものを実装してます)
Dockerfile
FROM public.ecr.aws/lambda/provided:al2 as build
RUN yum install -y golang
RUN go env -w GOPROXY=direct
ADD go.mod go.sum ./
RUN go mod download
ADD . .
RUN go build -o /main
FROM public.ecr.aws/lambda/provided:al2
COPY --from=build /main /main
COPY entry.sh /
RUN chmod 755 /entry.sh
ENTRYPOINT [ "/entry.sh" ]
entry.sh
#!/bin/bash
if [ -z "${AWS_LAMBDA_RUNTIME_API}" ]; then
  exec /usr/local/bin/aws-lambda-rie "$@"
else
  exec "$@"
fi
main.go
package main

import (
	"context"
	"fmt"

	"github.com/aws/aws-lambda-go/lambda"
)

type MyEvent struct {
	Name string `json:"name"`
}

func HandleRequest(ctx context.Context, name MyEvent) (string, error) {
	return fmt.Sprintf("Hello %s!", name.Name), nil
}

func main() {
	lambda.Start(HandleRequest)
}

go.mod
module example/helloworld

go 1.17

require github.com/aws/aws-lambda-go v1.27.0

go.sum

  • ディレクトリ構造

    aws_billing_notification
    ├── Dockerfile
    ├── README.md
    ├── entry.sh
    ├── go.mod
    ├── go.sum
    └── main.go
    
  • イメージの作成

    docker build --no-cache -t lambda_go_local .
    
  • コンテナ起動
    コンテナを起動しローカルでリクエストを受け付けるようにします。
    今回はAWSのリソースに接続する必要があるためAWS認証情報を環境変数にセットしています。
    なお、直接環境変数でAWS認証情報を持っているわけではなく、aws-vaultを使って管理しています。

    docker run \
    --rm \
    -p 9000:8080 \
    -e AWS_REGION \
    -e AWS_ACCESS_KEY_ID \
    -e AWS_SECRET_ACCESS_KEY \
    -e AWS_SESSION_TOKEN \
    -e AWS_SECURITY_TOKEN \
    lambda_go_local:latest /main
    
  • Lambdaのローカル実行
    リクエストを送りレスポンスが返ってくるか試してみましょう

    curl -XPOST "http://localhost:9000/2015-03-31/functions/function/invocations" -d '{"name":"saito"}'
    "Hello saito!"%
    

terraform

すでにterrafromで管理しているアカウントにも組み込みやすくするためにmoduleとして組み込めるようにしています。

main.tf
data "aws_caller_identity" "current" {}

# KMS
resource "aws_kms_key" "master_key" {
  description = "aws-billing-notification master key"
}

resource "aws_kms_alias" "alias" {
  name          = "alias/aws-billing-notification"
  target_key_id = aws_kms_key.master_key.key_id
}

locals {
  name                 = "aws-billing-notification"
  lambda_function_name = "${local.name}-lambda"

  lambda_policy_document_cloudwatch = {
    sid       = "AllowWriteToCloudwatchLogs"
    effect    = "Allow"
    actions   = ["logs:CreateLogStream", "logs:PutLogEvents"]
    resources = [replace("${element(concat(aws_cloudwatch_log_group.lambda[*].arn, [""]), 0)}:*", ":*:*", ":*")]
  }

  lambda_policy_document_kms = {
    sid       = "AllowKMSDecrypt"
    effect    = "Allow"
    actions   = ["kms:Decrypt"]
    resources = [aws_kms_key.master_key.arn]
  }

  lambda_policy_document_ce = {
    sid    = "AllowGetCostAndForecast"
    effect = "Allow"
    actions = [
      "ce:GetCostAndUsage",
      "ce:GetCostForecast"
    ]
    resources = ["*"]
  }
}

data "aws_iam_policy_document" "lambda" {
  dynamic "statement" {
    for_each = [
      local.lambda_policy_document_cloudwatch,
      local.lambda_policy_document_kms,
      local.lambda_policy_document_ce,
    ]
    content {
      sid       = statement.value.sid
      effect    = statement.value.effect
      actions   = statement.value.actions
      resources = statement.value.resources
    }
  }
}

# EventBridge
resource "aws_cloudwatch_event_rule" "billing_event_for_lambda" {
  name        = "daily-aws-billing-notification"
  description = "Launch AWS Billing Notification Lambda at 10:00 every morning."

  # 毎朝10時に通知(USTで設定するので1時)
  schedule_expression = "cron(0 1 * * ? *)"
}

resource "aws_cloudwatch_event_target" "lambda" {
  rule      = aws_cloudwatch_event_rule.billing_event_for_lambda.name
  target_id = "AwsBillingNotification"
  arn       = aws_lambda_function.function.arn

  depends_on = [aws_lambda_function.function]
}

# SNS
resource "aws_sns_topic" "aws_billing_notification_lambda_error" {
  name = "${local.name}-lambda-error"
}

resource "aws_sns_topic_policy" "default" {
  arn = aws_sns_topic.aws_billing_notification_lambda_error.arn

  policy = data.aws_iam_policy_document.sns_topic_policy.json
}

resource "aws_sns_topic_subscription" "chatbot_target" {
  topic_arn = aws_sns_topic.aws_billing_notification_lambda_error.arn
  protocol  = "https"
  endpoint  = "https://global.sns-api.chatbot.amazonaws.com"
}

data "aws_iam_policy_document" "sns_topic_policy" {
  statement {
    actions = [
      "SNS:GetTopicAttributes",
      "SNS:SetTopicAttributes",
      "SNS:AddPermission",
      "SNS:RemovePermission",
      "SNS:DeleteTopic",
      "SNS:Subscribe",
      "SNS:ListSubscriptionsByTopic",
      "SNS:Publish",
      "SNS:Receive"
    ]

    condition {
      test     = "StringEquals"
      variable = "AWS:SourceOwner"

      values = [
        data.aws_caller_identity.current.account_id,
      ]
    }

    effect = "Allow"

    principals {
      type        = "Service"
      identifiers = ["cloudwatch.amazonaws.com"]
    }

    resources = [
      aws_sns_topic.aws_billing_notification_lambda_error.arn,
    ]

  }
}

# Chatbot
resource "aws_iam_role" "chatbot_role" {
  count = var.create_chatbot ? 1 : 0

  name = "${local.name}-chatbot-role"

  assume_role_policy = jsonencode({
    "Version" : "2012-10-17",
    "Statement" : [
      {
        Action : "sts:AssumeRole",
        Principal : {
          Service : "chatbot.amazonaws.com"
        },
        Effect : "Allow",
      }
    ]
  })
}

resource "aws_iam_role_policy" "chatbot_role_policy" {
  count = var.create_chatbot ? 1 : 0

  name = "${local.name}-chatbot-policy"
  role = aws_iam_role.chatbot_role[0].id
  policy = jsonencode({
    "Version" : "2012-10-17",
    "Statement" : [
      {
        Effect : "Allow"
        Action : [
          "cloudwatch:Describe*",
          "cloudwatch:Get*",
          "cloudwatch:List*",
        ],
        Resource : "*"
      }
    ]
  })
}

module "chatbot_slack_configuration" {
  count = var.create_chatbot ? 1 : 0

  source  = "waveaccounting/chatbot-slack-configuration/aws"
  version = "1.0.0"

  configuration_name = local.name
  iam_role_arn       = aws_iam_role.chatbot_role[0].arn
  logging_level      = "NONE"
  slack_channel_id   = var.chatbot_slack_channel_id
  slack_workspace_id = var.chatbot_slack_workspace_id

  sns_topic_arns = [
    aws_sns_topic.aws_billing_notification_lambda_error.arn,
  ]

}

# Lambda
resource "null_resource" "handler_file" {
  triggers = {
    source_code_hash = base64sha256(join("", [
      file("${path.module}/../../../lambda/aws_billing_notification/main.go"),
      file("${path.module}/../../../lambda/aws_billing_notification/go.mod"),
      file("${path.module}/../../../lambda/aws_billing_notification/go.sum"),
      file("${path.module}/../../../lambda/aws_billing_notification/Makefile")
    ]))
  }

  provisioner "local-exec" {
    working_dir = "${path.module}/../../../lambda/aws_billing_notification"
    command     = "make dist/handler"
  }
}

data "archive_file" "lambda_archive" {
  type        = "zip"
  source_dir  = "${path.module}/../../../lambda/aws_billing_notification/dist"
  output_path = "${path.module}/../../../lambda/aws_billing_notification/dist/handler.zip"
  excludes    = ["handler.zip"]

  depends_on = [
    null_resource.handler_file,
  ]
}

resource "aws_cloudwatch_log_group" "lambda" {
  name              = "/aws/lambda/${local.lambda_function_name}"
  retention_in_days = var.cloudwatch_log_group_retention_in_days

  tags = merge(var.tags, var.cloudwatch_log_group_tags)
}

resource "aws_iam_role" "lambda_role" {
  name = "${local.lambda_function_name}-role"

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

resource "aws_iam_role_policy" "lambda_role_policy" {
  name   = "${local.lambda_function_name}-policy"
  role   = aws_iam_role.lambda_role.id
  policy = element(concat(data.aws_iam_policy_document.lambda[*].json, [""]), 0)
}

resource "aws_lambda_function" "function" {
  function_name    = local.lambda_function_name
  description      = "This lambda notifies slack every morning at 10am with the previous day's billing"
  handler          = "handler"
  filename         = data.archive_file.lambda_archive.output_path
  runtime          = "go1.x"
  role             = aws_iam_role.lambda_role.arn
  source_code_hash = data.archive_file.lambda_archive.output_base64sha256
  timeout          = 30
  publish          = true
  environment {
    variables = {
      KMS_KEY_ID            = aws_kms_key.master_key.key_id
      SLACK_WEBHOOK_URL_ENC = var.slack_webhook_url_enc
      DISPLAY_COUNT         = var.display_count
      LOG_LEVEL             = var.log_level
    }
  }

  tags = merge(var.tags, var.lambda_function_tags)

  depends_on = [aws_cloudwatch_log_group.lambda]
}

resource "aws_lambda_permission" "allow_eventbridge" {
  statement_id  = "AllowExecutionFromEventBridge"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.function.function_name
  principal     = "events.amazonaws.com"
  source_arn    = aws_cloudwatch_event_rule.billing_event_for_lambda.arn
}

# Cloudwatch
resource "aws_cloudwatch_metric_alarm" "lambda_errors" {
  alarm_name          = "AWSBillingNotification 関数エラーが発生"
  comparison_operator = "GreaterThanOrEqualToThreshold"
  evaluation_periods  = "1"
  metric_name         = "Errors"
  namespace           = "AWS/Lambda"
  period              = "60"
  statistic           = "Sum"
  threshold           = "1"
  treat_missing_data  = "notBreaching"
  alarm_description   = "AWSBillingNotificationのLambdaで関数エラーが発生"

  alarm_actions = [aws_sns_topic.aws_billing_notification_lambda_error.arn]

  dimensions = {
    FunctionName = aws_lambda_function.function.function_name
  }
}
valiable.tf
variable "create_chatbot" {
  description = "AWS chatbotを作成するかどうかのフラグ。すでに通知したいSlackチャンネルに設定しているChatbotがある場合はfalseにする。"
  type        = bool
  default     = true
}

variable "chatbot_slack_workspace_id" {
  description = "通知先の Slack ワークスペース名"
  type        = string
  default     = null
}

variable "chatbot_slack_channel_id" {
  description = "通知先の Slack チャンネル名"
  type        = string
  default     = null
}

variable "cloudwatch_log_group_retention_in_days" {
  description = "AWS料金通知のLambdaのログローテーション期間"
  type        = number
  default     = 30
}

variable "tags" {
  description = "全てのリソースに指定するタグ"
  type        = map(string)
  default     = {}
}

variable "cloudwatch_log_group_tags" {
  description = "Cloudwatch log groupに指定するタグ"
  type        = map(string)
  default = {
    "Name" : "aws-billing-notification-lambda-logs"
  }
}

variable "display_count" {
  description = "Slackに通知するサービス毎に料金を何個まで表示するかを指定するcount"
  type        = number
  default     = 5
}

variable "slack_webhook_url_enc" {
  description = "AWSの料金を通知するSlackのWebhookURLをKMSで暗号化した文字列"
  type        = string
  default     = ""
}

variable "lambda_function_tags" {
  description = "Lambdaに指定するタグ"
  type        = map(string)
  default = {
    "Name" : "aws-billing-notification-lambda"
  }
}

variable "log_level" {
  description = "Lambda内でのログレベルの設定(info or error)"
  type        = string
  default     = "error"
} 
  • handler.zipの作成
    Lambdaにアップロードするzipファイルは以下のようにしてterraformで生成してます。

    resource "null_resource" "handler_file" {
      triggers = {
        source_code_hash = base64sha256(join("", [
          file("${path.module}/../../../lambda/aws_billing_notification/main.go"),
          file("${path.module}/../../../lambda/aws_billing_notification/go.mod"),
          file("${path.module}/../../../lambda/aws_billing_notification/go.sum"),
          file("${path.module}/../../../lambda/aws_billing_notification/Makefile")
        ]))
      }
    
      provisioner "local-exec" {
        working_dir = "${path.module}/../../../lambda/aws_billing_notification"
        command     = "make dist/handler"
      }
    }
    
    data "archive_file" "lambda_archive" {
      type        = "zip"
      source_dir  = "${path.module}/../../../lambda/aws_billing_notification/dist"
      output_path = "${path.module}/../../../lambda/aws_billing_notification/dist/handler.zip"
      excludes    = ["handler.zip"]
    
      depends_on = [
        null_resource.handler_file,
      ]
    }
    

    このlambda_archiveをLambdaのterraformのoutput_base64sha256に指定してあげることで、terraform apply時にzipファイル作成→Lambda作成を可能にしてます。

    resource "aws_lambda_function" "function" {
      function_name    = local.lambda_function_name
      description      = "This lambda notifies slack every morning at 10am with the previous day's billing"
      handler          = "handler"
      filename         = data.archive_file.lambda_archive.output_path
      runtime          = "go1.x"
      role             = aws_iam_role.lambda_role.arn
      # ここで指定
      source_code_hash = data.archive_file.lambda_archive.output_base64sha256
      timeout          = 30
      publish          = true
      environment {
        variables = {
          KMS_KEY_ID            = aws_kms_key.master_key.key_id
          SLACK_WEBHOOK_URL_ENC = var.slack_webhook_url_enc
          DISPLAY_COUNT         = var.display_count
          LOG_LEVEL             = var.log_level
        }
      }
    
      tags = merge(var.tags, var.lambda_function_tags)
    
      depends_on = [aws_cloudwatch_log_group.lambda]
    }
    
  • Chatbot
    terraformは現時点でAWSChatbotに対応していません。
    なので、コード管理するなら、Cloudformationは対応しているので、Cloudformationを作り、それをterraformで読み込む形になります。
    そこら辺をいい感じでやってくれるmoduleがあったのでそちらを採用しています。
    https://registry.terraform.io/modules/waveaccounting/chatbot-slack-configuration/aws/latest

    module "chatbot_slack_configuration" {
      count = var.create_chatbot ? 1 : 0
    
      source  = "waveaccounting/chatbot-slack-configuration/aws"
      version = "1.0.0"
    
      configuration_name = local.name
      iam_role_arn       = aws_iam_role.chatbot_role[0].arn
      logging_level      = "NONE"
      slack_channel_id   = var.chatbot_slack_channel_id
      slack_workspace_id = var.chatbot_slack_workspace_id
    
      sns_topic_arns = [
        aws_sns_topic.aws_billing_notification_lambda_error.arn,
      ]
    
    }
    

GoによるLambdaの実装

main.go
package main

import (
	"context"
	"fmt"
	"net/http"
	"os"
	"sort"
	"strconv"
	"time"

	"github.com/aws/aws-lambda-go/lambda"
	log "github.com/sirupsen/logrus"
)

const Layout = "2006-01-02"

const (
	ErrorParseLogLevel              = "[ERROR] LogLevelのparseに失敗しました"
	ErrorConvertDisplayCount        = "[ERROR] 表示数をintに変換する処理に失敗しました"
	ErrorFetchCurrentExchangeRate   = "[ERROR] USDとJPYの交換レートの取得に失敗しました"
	ErrorGetPreviousDayCost         = "[ERROR] 前々日のサービス毎の料金の取得に失敗しました"
	ErrorParseCostToFloat           = "[ERROR] 合計金額の処理時にstringからfloat64への変換に失敗しました"
	ErrorConvertSumCostDisplay      = "[ERROR] 前々日の料金の合計を表示用の文字列に変換する処理に失敗しました"
	ErrorCurrentMonthCostForecast   = "[ERROR] 今月の料金予測の取得に失敗しました"
	ErrorConvertForecastCostDisplay = "[ERROR] 今月の料金予測を表示用の文字列に変換する処理に失敗しました"
	ErrorConvertTableCostDisplay    = "[ERROR] サービス毎の料金を表に変換する処理に失敗しました"
	ErrorNewAWSService              = "[ERROR] AWS Configのロードに失敗しました"
	ErrorDecryptByKMS               = "[ERROR] Slack Web Hook URL の復号に失敗しました"
	ErrorPostMessage                = "[ERROR] Slackへのメッセージ送信に失敗しました"
)

const SuccessPostMessage = "Slackへのメッセージ送信に成功しました"

type currentMonth struct {
	Today              string
	Yesterday          string
	DayBeforeYesterday string
	NextMonth          string
}
type response struct {
	Message string `json:"message"`
}

func setCurrentMonthDays(m *currentMonth) *currentMonth {
	now := time.Now()
	beginningOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
	m.Today = now.Format(Layout)
	m.Yesterday = now.AddDate(0, 0, -1).Format(Layout)
	m.DayBeforeYesterday = now.AddDate(0, 0, -2).Format(Layout)
	m.NextMonth = beginningOfMonth.AddDate(0, 1, 0).Format(Layout)

	return m
}

func errorResponse(message string, err error) (response, error) {
	e := fmt.Errorf(message+": %w", err)
	return response{
		Message: e.Error(),
	}, e
}

func main() {
	lambda.Start(Handle)
}

func Handle(ctx context.Context) (response, error) {
	logLevel := os.Getenv("LOG_LEVEL")
	log.SetFormatter(&log.JSONFormatter{})
	parseLevel, err := log.ParseLevel(logLevel)
	if err != nil {
		return errorResponse(ErrorParseLogLevel, err)
	}
	log.SetLevel(parseLevel)

	kmsKeyID := os.Getenv("KMS_KEY_ID")
	slackWebhookaURLEnc := os.Getenv("SLACK_WEBHOOK_URL_ENC")
	displayCountEnv := os.Getenv("DISPLAY_COUNT")

	displayCount, err := strconv.Atoi(displayCountEnv)
	if err != nil {
		return errorResponse(ErrorConvertDisplayCount, err)
	}

	// 交換レート取得
	httpClient := httpClient{
		http: &http.Client{
			Timeout: time.Second * 10,
		},
	}
	rate, err := httpClient.fetchCurrentExchangeRateBtwUSDAndJPY()
	if err != nil {
		return errorResponse(ErrorFetchCurrentExchangeRate, err)
	}

	// Costexploerから前々日の使用料金と今月の料金予測を取得
	m := setCurrentMonthDays(&currentMonth{})

	ceService, err := newCostExplorerService(ctx)
	if err != nil {
		return errorResponse(ErrorNewAWSService, err)
	}

	costs, err := ceService.getPreviousDayCostGroupByService(m)
	if err != nil {
		return errorResponse(ErrorGetPreviousDayCost, err)
	}

	forecast, err := ceService.getCurrentMonthCostForecast(m)
	if err != nil {
		return errorResponse(ErrorCurrentMonthCostForecast, err)
	}

	// 前々日のサービス毎の使用料金を価格が高い順に降順でソート
	sort.Slice(costs, func(i, j int) bool {
		floatPreCost, _ := strconv.ParseFloat(*costs[i].Metrics["UnblendedCost"].Amount, 64)
		floatNextCost, _ := strconv.ParseFloat(*costs[j].Metrics["UnblendedCost"].Amount, 64)
		return floatPreCost > floatNextCost
	})

	// 前々日の使用料金の合計を取得
	sum := 0.0
	for _, c := range costs {
		f, err := strconv.ParseFloat(*c.Metrics["UnblendedCost"].Amount, 64)
		if err != nil {
			return errorResponse(ErrorParseCostToFloat, err)
		}
		sum += f
	}

	// Slack送信用のtextを作成
	sumCostDisplay := convertCostToCostDisplay(sum, rate)
	if err != nil {
		return errorResponse(ErrorConvertSumCostDisplay, err)
	}

	forecastCostDisplay := convertCostToCostDisplay(forecast, rate)
	if err != nil {
		return errorResponse(ErrorConvertForecastCostDisplay, err)
	}

	tableCostDisplay, err := convertCostsToTableString(costs, rate, displayCount)
	if err != nil {
		return errorResponse(ErrorConvertTableCostDisplay, err)
	}

	EnclosingCharForCodeBlock := "```"

	slackText := fmt.Sprintf(`前々日の料金: %s
今月の予測料金: %s
%s
%s
%s
`,
		sumCostDisplay,
		forecastCostDisplay,
		EnclosingCharForCodeBlock,
		tableCostDisplay,
		EnclosingCharForCodeBlock,
	)

	log.Info("SLACK_TEXT: \n" + slackText)

	// Slackに通知
	kmsService, err := newKMSService(ctx)
	if err != nil {
		return errorResponse(ErrorNewAWSService, err)
	}

	webhookURL, err := kmsService.decryptByKMS(kmsKeyID, slackWebhookaURLEnc)
	if err != nil {
		return errorResponse(ErrorDecryptByKMS, err)
	}

	err = httpClient.sendSlackMessage(slackText, webhookURL)
	if err != nil {
		return errorResponse(ErrorPostMessage, err)
	}

	return response{SuccessPostMessage}, nil
}

kms_service.go
package main

import (
	"context"
	"encoding/base64"
	"fmt"

	"github.com/aws/aws-sdk-go-v2/config"
	"github.com/aws/aws-sdk-go-v2/service/kms"
)

const (
	ErrorBase64Decode = "failed to base64-decode: %w"
	ErrorDecrypt      = "failed to decrypt ciphertext by KMS: %w"
)

type KMSServiceInterface interface {
	Decrypt(ctx context.Context,
		params *kms.DecryptInput,
		optFns ...func(*kms.Options)) (*kms.DecryptOutput, error)
}

type KMSService struct {
	kms KMSServiceInterface
	ctx context.Context
}

func newKMSService(ctx context.Context) (*KMSService, error) {
	cfg, err := config.LoadDefaultConfig(ctx)
	if err != nil {
		return nil, fmt.Errorf(ErrorLoadConfig, err)
	}

	return &KMSService{
		kms: kms.NewFromConfig(cfg),
		ctx: ctx,
	}, nil
}

// decryptByKMS KMSを用いての復号
func (kmsService *KMSService) decryptByKMS(keyID string, enc string) (string, error) {
	base64DecodedEnc, err := base64.StdEncoding.DecodeString(enc)
	if err != nil {
		return "", fmt.Errorf(ErrorBase64Decode, err)
	}

	res, err := kmsService.kms.Decrypt(kmsService.ctx, &kms.DecryptInput{
		KeyId:          &keyID,
		CiphertextBlob: base64DecodedEnc,
	})
	if err != nil {
		return "", fmt.Errorf(ErrorDecrypt, err)
	}

	return string(res.Plaintext), nil
}

costxplorer_service.go
package main

import (
	"context"
	"fmt"
	"math"
	"strconv"
	"strings"

	"github.com/aws/aws-sdk-go-v2/config"
	"github.com/aws/aws-sdk-go-v2/service/costexplorer"
	"github.com/aws/aws-sdk-go-v2/service/costexplorer/types"
	"github.com/dustin/go-humanize"
	"github.com/olekukonko/tablewriter"
)

const (
	ErrorLoadConfig      = "failed to load default config: %w"
	ErrorGetCostAndUsage = "failed to get cost groupby service by CostExplorer: %w"
	ErrorGetCostForecast = "failed to get current month cost forecast by CostExplorer: %w"
	ErrorParseFloat      = "failed to parse cost from string to float64: %w"
)

type costExplorerServiceInterface interface {
	GetCostAndUsage(ctx context.Context, params *costexplorer.GetCostAndUsageInput, optFns ...func(*costexplorer.Options)) (*costexplorer.GetCostAndUsageOutput, error)
	GetCostForecast(ctx context.Context, params *costexplorer.GetCostForecastInput, optFns ...func(*costexplorer.Options)) (*costexplorer.GetCostForecastOutput, error)
}

type costExplorerService struct {
	ce  costExplorerServiceInterface
	ctx context.Context
}

func newCostExplorerService(ctx context.Context) (*costExplorerService, error) {
	cfg, err := config.LoadDefaultConfig(ctx)
	if err != nil {
		return nil, fmt.Errorf(ErrorLoadConfig, err)
	}

	return &costExplorerService{
		ce:  costexplorer.NewFromConfig(cfg),
		ctx: ctx,
	}, nil
}

// Costexplorerから前々日のサービス毎の使用料金を取得
func (s *costExplorerService) getPreviousDayCostGroupByService(m *currentMonth) ([]types.Group, error) {
	groupByKey := "SERVICE"

	var groups []types.Group
	var nextPageToken *string
	for {
		res, err := s.ce.GetCostAndUsage(s.ctx, &costexplorer.GetCostAndUsageInput{
			Granularity:   types.GranularityDaily,
			Metrics:       []string{"UnblendedCost"},
			TimePeriod:    &types.DateInterval{Start: &m.DayBeforeYesterday, End: &m.Yesterday},
			GroupBy:       []types.GroupDefinition{{Key: &groupByKey, Type: types.GroupDefinitionTypeDimension}},
			NextPageToken: nextPageToken,
		})
		if err != nil {
			return nil, fmt.Errorf(ErrorGetCostAndUsage, err)
		}

		nextPageToken = res.NextPageToken
		groups = append(groups, res.ResultsByTime[0].Groups...)

		if nextPageToken == nil {
			break
		}
	}

	return groups, nil
}

// Costexplorerから今月の料金予測を取得
func (s *costExplorerService) getCurrentMonthCostForecast(m *currentMonth) (float64, error) {
	res, err := s.ce.GetCostForecast(s.ctx, &costexplorer.GetCostForecastInput{
		Granularity: types.GranularityMonthly,
		Metric:      types.MetricUnblendedCost,
		// Endの日は含まれない仕様なので、○月1日で指定して当日から月末までの予測料金を取得
		TimePeriod: &types.DateInterval{Start: &m.Today, End: &m.NextMonth},
	})

	if err != nil {
		return 0, fmt.Errorf(ErrorGetCostForecast, err)
	}

	f, err := strconv.ParseFloat(*res.Total.Amount, 64)
	if err != nil {
		return 0, fmt.Errorf(ErrorParseFloat, err)
	}

	return f, nil
}

// USDからJPYを計算し、小数第一位で四捨五入して3桁毎にカンマ区切りした後に、日本円 (ドル)の文字列に変換
func convertCostToCostDisplay(costUSD float64, rate float64) string {
	costJPY := costUSD * rate

	roundHarfUpUSD := int64(math.Round(costUSD))
	roundHarfUpJPY := int64(math.Round(costJPY))

	formatUSD := humanize.Comma(roundHarfUpUSD)
	formatJPY := humanize.Comma(roundHarfUpJPY)

	return fmt.Sprintf("%s円 ($%s)", formatJPY, formatUSD)
}

// Costexplorerから取得した結果をService, Costの表に変換
func convertCostsToTableString(costs []types.Group, rate float64, displayCount int) (string, error) {
	tableDatas := make([][]string, len(costs))

	for i, c := range costs {
		if i == displayCount {
			break
		}

		serviceName := c.Keys[0]
		floatCost, err := strconv.ParseFloat(*c.Metrics["UnblendedCost"].Amount, 64)
		if err != nil {
			return "", fmt.Errorf(ErrorParseFloat, err)
		}

		// JPYで小数第一位で四捨五入して0円になるものは表示しない
		if floatCost*rate < 0.5 {
			break
		}

		serviceCostDisplay := convertCostToCostDisplay(floatCost, rate)
		tableDatas[i] = []string{serviceName, serviceCostDisplay}
	}

	tableString := &strings.Builder{}
	table := tablewriter.NewWriter(tableString)
	table.SetHeader([]string{"Service", "Cost"})
	table.SetBorder(false)
	table.AppendBulk(tableDatas)
	table.Render()

	return tableString.String(), nil
}

request.go
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io/ioutil"
	"net/http"
)

const CurrentExchangeRateBtwUSDAndJPYURL = "https://www.floatrates.com/daily/jpy.json"

const (
	ErrorNewRequest         = "failed to new request %s: %w"
	ErrorRequest            = "failed to request %s: %w"
	ErrorHttpStatus         = "HTTP status error"
	ErrorReadResponse       = "failed to read the response body: %w"
	ErrorDecodeExchangeRate = "failed to decode exchange rate responce"
)

type exchangeRateResponce struct {
	*USD `json:"usd"`
}

type USD struct {
	InverseRate float64 `json:"inverseRate"`
}

type httpClientInterface interface {
	Do(req *http.Request) (*http.Response, error)
}

type httpClient struct {
	http httpClientInterface
}

// JPYとUSDの交換レートをAPIから取得
func (client httpClient) fetchCurrentExchangeRateBtwUSDAndJPY() (float64, error) {
	req, err := http.NewRequest(http.MethodGet, CurrentExchangeRateBtwUSDAndJPYURL, nil)
	if err != nil {
		return 0, fmt.Errorf(ErrorNewRequest, CurrentExchangeRateBtwUSDAndJPYURL, err)
	}

	req.Header.Add("Content-Type", "application/json")
	resp, err := client.http.Do(req)
	if err != nil {
		return 0, fmt.Errorf(ErrorRequest, CurrentExchangeRateBtwUSDAndJPYURL, err)
	}

	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		byteArray, err := ioutil.ReadAll(resp.Body)
		if err != nil {
			return 0, fmt.Errorf(ErrorHttpStatus+" %d", resp.StatusCode)
		}
		return 0, fmt.Errorf(ErrorHttpStatus+" %d: %s", resp.StatusCode, string(byteArray))
	}

	var e exchangeRateResponce
	dec := json.NewDecoder(resp.Body)
	if err := dec.Decode(&e); err != nil {
		return 0, fmt.Errorf(ErrorReadResponse, err)
	}

	chackEmpty := exchangeRateResponce{}

	if e == chackEmpty {
		return 0, fmt.Errorf(ErrorDecodeExchangeRate)
	}

	return e.USD.InverseRate, nil
}

// SlackにCostexplorerから取得した結果を通知
func (client httpClient) sendSlackMessage(text string, url string) error {
	name := "AWS前々日の料金教えるくん"
	color := "good"
	var jsonStr = []byte(
		fmt.Sprintf(
			`{"attachments": [{"title": "%s", "text": "%s", "color": "%s"}]}`,
			name,
			text,
			color,
		),
	)

	req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(jsonStr))
	if err != nil {
		return fmt.Errorf(ErrorNewRequest, url, err)
	}

	req.Header.Add("Content-Type", "application/json")
	resp, err := client.http.Do(req)
	if err != nil {
		return fmt.Errorf(ErrorRequest, url, err)
	}

	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		byteArray, err := ioutil.ReadAll(resp.Body)
		if err != nil {
			return fmt.Errorf(ErrorHttpStatus+" %d", resp.StatusCode)
		}
		return fmt.Errorf(ErrorHttpStatus+" %d: %s", resp.StatusCode, string(byteArray))
	}

	return nil
}

Makefile
dist/handler: main.go go.mod go.sum
	CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-s -w" -o dist/handler ./
- AWS Cost Explorerから情報を取得
costexplorer_service.goでAWS Cost Explorerから前々日のリソースごとの料金と今月の予測料金を取得してます。
日付を指定するTimePeriodはEndの日は含まれない仕様なので、翌月の1日を指定して当月の料金予測を取得しています。
また、元々は前日に使った料金を通知したかったのですが、AWS Cost Explorerの料金確定タイミングが大体翌々日のため、前々日の料金を取得するようにしています。

デプロイ

  • 上記のコード群をディレクトリに配置
    こんな感じのディレクトリにしてコードを配置してください。

    aws_billing_notification
    ├── lambda
    │   └── aws_billing_notification
    │       ├── Makefile
    │       ├── costexplorer_service.go
    │       ├── costexplorer_service_test.go
    │       ├── go.mod
    │       ├── go.sum
    │       ├── kms_service.go
    │       ├── kms_service_test.go
    │       ├── main.go
    │       ├── main_test.go
    │       ├── request.go
    │       └── request_test.go
    └── terraform
        ├── backend.tf
        ├── main.tf
        ├── modules
        │   ├── aws_billing_nitification
        │   │   ├── main.tf
        │   │   └── valiable.tf
        └── provider.tf
    
  • main.tfにmoduleを組み込む

    main.tf,backend.tf,provider.tfを用意し、moduleを組み込んでください。

main.tf
module "aws-billing-notification" {
    source = "./modules/aws_billing_nitification"
    cloudwatch_log_group_retention_in_days = 30
    
    slack_webhook_url_enc = ""
    create_chatbot             = true
    chatbot_slack_workspace_id = "Slack通知先のワークスペースID"
    chatbot_slack_channel_id   = "Slack通知先のチャンネル名"
  }
backend.tf
provider "aws" {
  region = "ap-northeast-1"
}

terraform {
  required_version = "~> 1.0.8"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 3.62.0"
    }
  }
}
provider.tf
terraform {
  backend "s3" {
    bucket = "aws-billing-notification"
    key    = "terraform.tfstate"
    region = "ap-northeast-1"
  }
}
  • デプロイ
    リソースをAWS環境にデプロイします。
    KMSで暗号化したWebhookURLを使用する都合上、デプロイ→KMSでURL暗号化→再度デプロイという流れになります。
    • デプロイ(1回目)

      terraform plan
      terraform apply
      
    • KMSでURL暗号化
      デプロイ時に作成されたKMSを使ってWebhookURLを暗号化

      aws kms encrypt --key-id alias/aws-billing-notification --plaintext "{{WebHookURL}}" --output text --query CiphertextBlob --cli-binary-format raw-in-base64-out
      
    • デプロイ(2回目)
      暗号化したURLをmain.tfに追加し再度デプロイする

        module "aws-billing-notification" {
          source = "./modules/aws_billing_nitification"
          cloudwatch_log_group_retention_in_days = 30
          # ここに追加
          slack_webhook_url_enc = "××××××××××××××××××"
          create_chatbot             = true
          chatbot_slack_workspace_id = "Slack通知先のワークスペースID"
          chatbot_slack_channel_id   = "Slack通知先のチャンネル名"
        }
      
      terraform plan
      terraform apply
      

完成

ここまでで問題なければ毎朝10時に以下のように通知が来るようになります🎉🎉🎉
スクリーンショット 2021-12-06 3.51.56.png

終わりに

いかがでしたでしょうか。
料金を通知をすることで、思ったよりこのサービスに料金がかかっているなとか、使ってもいないサービスに料金がかかっていたなどに気づけるようになります。
また料金を抑えるためにはどうすればいいのかなどコスト最適化を考えるきっかけにもなります。
ぜひ、みなさまのシステムにも組み込んでいただければと思います。

9
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?