AWSの利用料金をChatworkに通知してくれるバッチをGoで作った

## はじめに
AWSの料金を毎日確認しにログインしてコンソールを開くのは面倒…。でも、気がついたら設定が間違っててすごい料金を請求されるなーんてことも。

そんなことが起こる前に、毎日使用料金を通知して把握しておこう！

ということで、Cost ExplorerのSDKを使ってGoでバッチを作成してみました。

## AWSの構成
![CostExplorer.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/546572/e7455498-048b-4e64-5bde-7eef464b1c06.png)

当記事はLambdaにアップするバッチにフォーカスしてます。

## 実装
### 通知したいこと
+ 昨日の利用料金 
+ 今月の利用料金(今月1日から昨日までの合計金額) or 今日が1日のときは先月の利用料金

### 必要なもの
+ Chatworkのルームid（トークルームURL：chatwork.com/#!rid＊＊＊＊＊＊＊＊＊ ←9桁の数字)
+ ChatworkのAPIトークン（画面右上の自分の名前 → API設定で発行できます）

### 気をつけるところ
料金を知るのにもお金がかかります。
Cost Explorerに対して1リクエストあたり 0.01USD かかります。

[AWSユーザーガイド](https://docs.aws.amazon.com/ja_jp/awsaccountbilling/latest/aboutv2/ce-what-is.html)

### ソースコード
```Go
package main

import (
	"bytes"
	"fmt"
	"github.com/pkg/errors"
	"io/ioutil"
	"log"
	"net/http"
	"net/url"
	"os"
	"time"

	"github.com/aws/aws-lambda-go/lambda"
	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/aws/session"
	"github.com/aws/aws-sdk-go/service/costexplorer"
	"github.com/aws/aws-sdk-go/service/costexplorer/costexploreriface"
)

var chatworkClient = ChatworkClient{
	APIURL:   "https://api.chatwork.com/",
	Resource: "/v2/rooms/＜ルームid＞/messages",
}

// ChatworkClient はチャットワークへ通知するためのクライアントに相当する構造体
type ChatworkClient struct {
	APIURL   string
	Resource string
}

// メッセージの通知
func (chatwork ChatworkClient) postMessage(msg string) error {
	u, _ := url.ParseRequestURI(chatwork.APIURL)
	u.Path = chatwork.Resource
	urlStr := fmt.Sprintf("%v", u)

	data := url.Values{}
	data.Set("body", msg)
	fmt.Printf(data.Encode())

	client := &http.Client{}
	r, err := http.NewRequest("POST", urlStr, bytes.NewBufferString(data.Encode()))
	if err != nil {
		fmt.Println("HTTPリクエストの生成に失敗しました。date:" + fmt.Sprint(data) + ", urlStr:" + urlStr + ", err:" + fmt.Sprint(err))
		return errors.WithStack(err)
	}

	r.Header.Add("X-ChatWorkToken", "＜APIトークン＞")
	r.Header.Add("Content-Type", "application/x-www-form-urlencoded")

	resp, err := client.Do(r)
	if err != nil {
		fmt.Println("HTTPリクエストに失敗しました。date:" + fmt.Sprint(data) + ", urlStr:" + urlStr + ", err:" + fmt.Sprint(err))
		return errors.WithStack(err)
	}

	defer resp.Body.Close()
	contents, _ := ioutil.ReadAll(resp.Body)
	fmt.Printf("Http Status:%s, result: %s\n", resp.Status, contents)

	return nil
}

// コストの取得
func GetCost(svc costexploreriface.CostExplorerAPI, period string) (result *costexplorer.GetCostAndUsageOutput) {
	// 現在時刻の取得
	jst, _ := time.LoadLocation("Asia/Tokyo")
	now := time.Now().UTC().In(jst)
	dayBefore := now.AddDate(0, 0, -1)
	first := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, jst)
	if now.Day() == 1 { // 月初のときは先月分
		first = first.AddDate(0, -1, 0)
	}
	nowDate := now.Format("2006-01-02")
	nowDateP := &nowDate
	dateBefore := dayBefore.Format("2006-01-02")
	dateBeforeP := &dateBefore
	firstDate := first.Format("2006-01-02")
	firstDateP := &firstDate

	start := dateBeforeP
	if period == "Monthly" {
		start = firstDateP
	}

	granularity := aws.String("DAILY")
	if period == "Monthly" {
		granularity = aws.String("MONTHLY")
	}

	metric := "NetUnblendedCost" // 非ブレンド純コスト
	metrics := []*string{&metric}

	timePeriod := costexplorer.DateInterval{
		Start: start,
		End:   nowDateP,
	}

	// Inputの作成
	input := &costexplorer.GetCostAndUsageInput{
		Granularity: granularity,
		Metrics:     metrics,
		TimePeriod:  &timePeriod,
	}

	// 処理実行
	result, err := svc.GetCostAndUsage(input)
	if err != nil {
		log.Println(err.Error())
	}

	// 処理結果を出力
	log.Println(result)

	return result
}

// 処理実行
func run() error {
	log.Println("--- コスト取得バッチ 開始")
	log.Println("----- セッション作成")
	svc := costexplorer.New(session.Must(session.NewSession()))

	log.Println("----- コスト取得 実行")
	costDaily := GetCost(svc, "Daily")
	costMonthly := GetCost(svc, "Monthly")
	log.Println("----- コスト取得 完了")

	log.Println("----- メッセージの通知 実行")
	err := chatworkClient.postMessage(costDaily.String())
	if err != nil{
		fmt.Printf("%+v\n", err)
		return err
	}
	err = chatworkClient.postMessage(costMonthly.String())
	if err != nil{
		fmt.Printf("%+v\n", err)
		return err
	}

	log.Println("--- コスト取得バッチ 完了")
	return nil
}

// メイン
func main() {
	lambda.Start(run)
}
```

### 実行結果
2019/12/16に実行してみました。

こちらが1日前（12/15）の利用料金を取得した結果です。期間（TimePeriod）を2019/12/15から2019/12/16で指定しています。料金はTotalの中にあるAmountで、今回だと約0.06USDとなっています。
<img width="880" alt="cost_explorer_json_daily.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/546572/9cb039f4-95ac-5849-d482-d1783054792a.png">

こちらは当月（2019/12/01~2019/12/15）の合計金額です。指定した期間は2019/12/01から2019/12/16で、費用は約1.35USDという結果が得られました。
<img width="882" alt="cost_explorer_json_monthly.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/546572/be1a7b07-eb19-eff2-3d52-130498e9c1ba.png">


## 解説

### コストの取得

今回は、API「GetCostAndUsage」のInputを構成する要素として以下の3つを使用しています。

+ Granularity
+ Metrics
+ TimePeriod

#### Granularity
Granularityは「粒度」という意味で、どの粒度の期間で取得するのか指定できます。選択肢は以下の3つがあります。

+ MONTHLY
+ DAILY
+ HOURLY

今回はMONTHLYとDAILYを使いました。

#### Metrics
Metricsは「指標」という意味で、コストをどう計算するかを指定できます。選択肢は以下の7つがあります。

+ AmortizedCost
+ BlendedCost
+ NetAmortizedCost
+ NetUnblendedCost
+ NormalizedUsageAmount
+ UnblendedCost
+ UsageQuantity

それぞれのメトリクスの内容はこの記事を参考にしました。
[AWS Cost Explorerに渡す、Metricsの値の意味](https://qiita.com/tamura_CD/items/4a9a412faf379b334986)<br>

今回は「NetUnblendedCost = ディスカウント適用後(EDP割引等)のコスト」を使っています。
ちなみに、コンソール上で見る場合は、Cost Explorer: コストと使用状況のページの右下、詳細オプション → コストの表示方法 → 非ブレンド純コスト  と同額になります。
<img width="975" alt="cost-explorer-console.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/546572/f2bfb2b0-7218-45a5-4c1b-be24c7a36864.png">


#### TimePeriod
Start（集計開始日時）とEnd（集計終了日時）を指定します。
フォーマットは " YYYY-MM-DD "です。

なお、Endに指定した日の利用料金は加算されません。
例えば、
Start : 2019-12-01
End : 2019-12-16
とした場合は1日から15日までの料金を取得することになります。


## 通知するベスト時間
最初、朝9時に前日の利用料金を通知するようにしていましたが、ある日、コスト通知用のトークルームを見ると「$ 0」。

昨日は無料キャンペーンだったのかなあ(わくわく)という話になりましたが、そんなことは…なかったです。

明確なコストエクスプローラーの更新時間は書かれていませんが、公式ページでは、「24 時間ごとに少なくとも一度コストデータを更新します。」とのことだったので、夜に通知するほうが正確な金額になると思われます。

ちなみに、私は朝9時に一昨日の利用料金を取得するようにしています。今のところ、それ以降の料金更新はないっぽいです。

## おまけ
### かわいく通知してみた
上記のままだと取得してきたJSONのまま表示されるので、お花をつけてかわいく整えてみました🌼

```Go
func MakeMassage(costMonthly, costDaily *costexplorer.GetCostAndUsageOutput) string {
	var msg bytes.Buffer
	msg.WriteString("(F)" + "AWS使用料金" + "(F)" + "\n\n")
	msg.WriteString("◆MONTHLY")
	msg.WriteString(" (" + *costMonthly.ResultsByTime[0].TimePeriod.Start + "~" +
		*costDaily.ResultsByTime[0].TimePeriod.Start + ")\n")
	msg.WriteString("　$ " + *costMonthly.ResultsByTime[0].Total["NetUnblendedCost"].Amount + "\n\n")
	msg.WriteString("◆DAILY")
	msg.WriteString(" (" + *costDaily.ResultsByTime[0].TimePeriod.Start + ")\n")
	msg.WriteString("　$ " + *costDaily.ResultsByTime[0].Total["NetUnblendedCost"].Amount + "\n")

	return msg.String()
}
```

処理実行箇所にメッセージ作成を追加し、実行コードを少し変更

```Go
log.Println("----- メッセージの作成 実行")
	msg := MakeMassage(costMonthly, costDaily)
log.Println("----- メッセージの作成 完了")

log.Printf("----- メッセージの通知 実行")
	err := chatworkClient.postMessage(msg)
```

### 実行結果
かわいくなりました🌼
<img width="791" alt="cost_explorer_msg.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/546572/6362d9dc-f235-e0ea-0057-b39e359d39d5.png">

## おわりに

コンソール上だと下2桁しか表示されないですけど、Cost Explorer APIで取得すると下10桁まで表示されるので、コンソール上では0.00USD。でも実際は0.0012345678USDってことも分かるのでいいですよね。

大変だったとこととしては、はじめましてのGo。C言語を昔授業でちょっとやっていたけどポインタから逃げたので、今となってポインタに苦しめられました。

あとは、AWSのSDKを使う時に公式ドキュメントを読んでコードを書くことが大変でした。あまりGoでCostExplorerを使っている記事がなくて、自力では完成させることができず…。SDKの使い方、Goの書き方、とても勉強になりました。

次は[藤原さんのQiitaの記事](https://qiita.com/shuhey_fujiwara/items/99dce403181119bb34bd)を見ながら、slackに通知できるようにしてみようと思っています！