はじめに
この記事はmixiアドベントカレンダー2021 6日目の記事です。
みなさまはAWSで運用しているサービスに毎月どれくらいのコストがかかっているか、サービスのリソースごとにどれくらいの料金がかかっているかを確認していますでしょうか。
今回は、AWSの料金を毎日Slackに通知するシステムをLambda + Go + terraformで構築したのでそちらをご紹介させていただきます。
おすすめな人
- AWSにかかる料金を定期的に通知したい
- GoでLambdaをどう開発するか知りたい
- terraformでLambdaを管理する方法を知りたい
アーキテクチャー
- EventBridge
定時(AM10時)にLambdaを動作させるために使用 - Lambda
KMSでWebHookURLの複合化、AWS Cost Explorerから情報取得、Slackに送る文字列の整形、Slackへのリクエストを行う - KeyManagementService
WebhookUrlを暗号化するために使用 - AWS Cost Explorer
使用した料金の取得、今月の料金予測、サービス毎の料金取得を行う - CloudWatchAlarm & SNS & Chatbot
Lambda内の処理でエラーが起きた時にSlackに通知を送る
事前に
-
Slackに通知するため、下記を参考に通知したいチャンネルのWebhookURLを作成してください。
https://api.slack.com/messaging/webhooks -
Lambdaエラー時の通知先のワークスペースIDとチャンネルIDを取得してください。
Web版のSlack からログインし、通知したいチャンネルのURLから取得するのが簡単です。
チャンネルを表示すると以下のようなURLになります。
https://app.slack.com/client/ワークスペースID/チャンネルID
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/latestmodule "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(¤tMonth{})
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 ./
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を組み込んでください。
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通知先のチャンネル名"
}
provider "aws" {
region = "ap-northeast-1"
}
terraform {
required_version = "~> 1.0.8"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 3.62.0"
}
}
}
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時に以下のように通知が来るようになります🎉🎉🎉
終わりに
いかがでしたでしょうか。
料金を通知をすることで、思ったよりこのサービスに料金がかかっているなとか、使ってもいないサービスに料金がかかっていたなどに気づけるようになります。
また料金を抑えるためにはどうすればいいのかなどコスト最適化を考えるきっかけにもなります。
ぜひ、みなさまのシステムにも組み込んでいただければと思います。