😸

EventBridge+Lambdaの構成で「月初からのAWS利用額を定期的にLINE通知するアプリケーション」をCDKで作成してみた

2022/08/15に公開

1. はじめに

定期的に月初からのAWS利用額を把握したいなと思い、毎週LINE通知してくれるアプリケーションを作成することにしてみました。

ちなみに、似たようなことをしてくれるサービスとしてAWS Budgetsがあり、こちらのサービスを利用すると、利用額に閾値を設定できて、その設定値を超える度に通知してくれるようになります。通知の方法にはメールとChatbotとSNSが用意されているので、Chatbot経由でのSlack通知はもちろん、SNSLambdaを組み合わせることで構成の選択肢は無限大に広がります!

個人的には、AWS Budgetsを意図せず使いすぎないようにガードレール的な意味合いで設定しているのですが、今回はそこから一歩進めて定期的にAWSの利用額を把握したいと思い、タイトルに掲げたアプリケーションを作成してみることにしました!

2. インフラ構成

作成したアプリケーションのインフラ構成を下図に示します。

図に示した通り、EventBridgeでLambdaを定期的にスケジューリングし、Lambdaがコンテナ化されたコードをECRから取得&実行して、LINE通知を行うという構成になります。

3. ソースコードの説明

3-1. CDKのディレクトリ構成

タイトルにもある通り、前節で説明したインフラ構成をCDKで作成しました。また、Lambda関数で実行するコードも同じリポジトリ内で記述しており、CDKからイメージをビルドしてECRへプッシュさせています(ちょっとしたアプリケーションであれば全てCDKで完結できるので、素晴らしい機能ですね^^)。

ということで、作成したCDKのディレクトリ構成を本記事に関係ある部分のみ抜粋して以下に示します。

bash
$ tree .
.
├── main.go # CDKからデプロイできるスタックの一覧
├── go.mod
├── go.sum
├── images
│   └── notifybilling # 今回Lambda関数上で実行するコードを格納したディレクトリ
│       ├── Dockerfile
│       ├── go.mod
│       ├── go.sum
│       └── main.go
└── stacks
    └── notify_aws_billing_stack.go # 今回作成したインフラのスタック定義

3-2. インフラのスタック定義

ルート直下のmain.goでは、このリポジトリ内で定義されているスタックを宣言しており、ここで宣言したスタックがcdk deployコマンドによってデプロイ可能となります。ですので、ここには他で作ったインフラスタックが多数宣言されているのですが、ここでは今回関係しているコードのみを抜粋してmain.goの中身を示します。

main.go
package main

import (
	"os"

	"mycdk/stacks"

	cdk "github.com/aws/aws-cdk-go/awscdk/v2"
	jsii "github.com/aws/jsii-runtime-go"
)

func main() {
	app := cdk.NewApp(nil)
	props := &cdk.StackProps{Env: env(),}

	// 月初からのAWS利用料を定期的にLINE通知するインフラスタック
	stacks.NewNotifyAWSBillingStack(app, "NotifyAWSBillingStack", props)
	
	app.Synth(nil)
}

func env() *cdk.Environment {
	return &cdk.Environment{
	 	Account: jsii.String(os.Getenv("CDK_DEFAULT_ACCOUNT")),
	 	Region:  jsii.String(os.Getenv("CDK_DEFAULT_REGION")),
	}
}

上に示したコードからわかるように、main.goNewNotifyAWSBillingStack関数によって「月初からのAWS利用料を定期的にLINE通知するインフラスタック」が作成されます。そして、この関数自体はstacks/notify_aws_billing_stack.goで定義しており、ちょっと長くなりますが、ファイルの中身を以下に示します。文章での説明はしませんが、コード内に詳しくコメントしていますので、そちらをご参照ください。

stacks/notify_aws_billing_stack.go
package stacks

import (
	"os"

	cdk "github.com/aws/aws-cdk-go/awscdk/v2"
	ecrassets "github.com/aws/aws-cdk-go/awscdk/v2/awsecrassets"
	events "github.com/aws/aws-cdk-go/awscdk/v2/awsevents"
	targets "github.com/aws/aws-cdk-go/awscdk/v2/awseventstargets"
	iam "github.com/aws/aws-cdk-go/awscdk/v2/awsiam"
	lambda "github.com/aws/aws-cdk-go/awscdk/v2/awslambda"
	constructs "github.com/aws/constructs-go/constructs/v10"
	jsii "github.com/aws/jsii-runtime-go"
)

func NewNotifyAWSBillingStack(scope constructs.Construct, id string, props *cdk.StackProps) (stack cdk.Stack) {
	stack = cdk.NewStack(scope, &id, props)

	// ********************************************************************************
	// 1. Dockerイメージを作成してECRへプッシュ
	// ********************************************************************************
	// [NewDockerImageAsset](https://pkg.go.dev/github.com/aws/aws-cdk-go/awscdk/v2/awsecrassets#NewDockerImageAsset)
	// [DockerImageAssetProps](https://pkg.go.dev/github.com/aws/aws-cdk-go/awscdk/v2/awsecrassets#DockerImageAssetProps)
	dockerImageAsset := ecrassets.NewDockerImageAsset(stack, jsii.String("NotifyAWSBillingImageAsset"), &ecrassets.DockerImageAssetProps{
		Directory: jsii.String("./images/notifybilling/"), // ビルドコンテキストをリポジトリルートからのパスで指定する
  		File: jsii.String("Dockerfile"), // Dockerfile名をDirectoryプロパティで指定したディレクトリからの相対パスで指定する
		Platform: ecrassets.Platform_LINUX_ARM64(), // 今回はM1 Macbookからビルドを行うのでPlatform_LINUX_ARM64を指定する
  	})
	// ***注意点***
	// ビルドしたイメージのプッシュ先リポジトリはCDKのデフォルトリポジトリです。
	// プッシュ先リポジトリを指定する機能は2022/8/15時点ではありません。
	// 指定したリポジトリにイメージを格納したい場合はサードパーティ製のツール(AWS公式ドキュメントでも説明されている)を使う必要があり、このツールではデフォルトリポジトリへプッシュされたイメージを指定したリポジトリへコピーしてくれます。
	// ただし、このサードパーティツールはTypeScriptで開発されており、Goのライブラリでは用意されていません。
	// 詳しくは[公式ドキュメント](https://pkg.go.dev/github.com/aws/aws-cdk-go/awscdk/v2/awsecrassets#section-readme)をご参照ください。

	// ********************************************************************************
	// 2. ECRへプッシュしたイメージを実行するLambda関数の作成
	// ********************************************************************************
	// 2-1. AWSのコストと使用量を取得するポリシーの作成
	// [NewManagedPolicy](https://pkg.go.dev/github.com/aws/aws-cdk-go/awscdk/v2/awsiam#NewManagedPolicy)
	// [ManagedPolicyProps](https://pkg.go.dev/github.com/aws/aws-cdk-go/awscdk/v2/awsiam#ManagedPolicyProps)
	readBillingPolicy := iam.NewManagedPolicy(stack, jsii.String("ReadAWSBillingPolicy"), &iam.ManagedPolicyProps{
		ManagedPolicyName: jsii.String("read-aws-billing-policy"),
		Document: iam.NewPolicyDocument(&iam.PolicyDocumentProps{
			Statements: &[]iam.PolicyStatement{
				iam.NewPolicyStatement(&iam.PolicyStatementProps{
					Effect: iam.Effect_ALLOW,
					Resources: &[]*string{jsii.String("*")},
					Actions: &[]*string{
						jsii.String("ce:GetCostAndUsage"),
					},
				}),
			},
		}),
	})

	// 2-2. Lambda関数の実行ロールの作成
	// [NewRole](https://pkg.go.dev/github.com/aws/aws-cdk-go/awscdk/v2/awsiam#NewRole)
	// [RoleProps](https://pkg.go.dev/github.com/aws/aws-cdk-go/awscdk/v2/awsiam#RoleProps)
	role := iam.NewRole(stack, jsii.String("NotifyAWSBillingRole"), &iam.RoleProps{
		AssumedBy: iam.NewServicePrincipal(jsii.String("lambda.amazonaws.com"), &iam.ServicePrincipalOpts{}), // Lambda関数がこのロールを引き受けられるようにする
		RoleName: jsii.String("notify-aws-billing-role"),
		ManagedPolicies: &[]iam.IManagedPolicy{readBillingPolicy,}, // 2-1で作成したAWSのコストと使用量を取得するポリシーをロールに紐づける
  	})

	// 2-3. LINE_NOTIFY_TOKENを環境変数から取得
	accessToken, ok := os.LookupEnv("LINE_NOTIFY_TOKEN") // 秘匿情報なので環境変数にして値を渡している
	if !ok {
		panic("LINE_NOTIFY_TOKEN NOT FOUND") // 環境変数の設定が漏れていた場合にすぐ気づけるようパニックを起こす(これがしたかったのでos.Getenvを使っていない)
	}

	// 2-4. Lambda関数の作成
	// [NewDockerImageFunction](https://pkg.go.dev/github.com/aws/aws-cdk-go/awscdk/v2/awslambda#NewDockerImageFunction)
	// [DockerImageFunctionProps](https://pkg.go.dev/github.com/aws/aws-cdk-go/awscdk/v2/awslambda#DockerImageFunctionProps)
	handler := lambda.NewDockerImageFunction(stack, jsii.String("DockerImageFunction"), &lambda.DockerImageFunctionProps{
		Code: lambda.DockerImageCode_FromEcr(dockerImageAsset.Repository(), &lambda.EcrImageCodeProps{
			TagOrDigest: dockerImageAsset.AssetHash(),
		}), // このLambda関数が実行するイメージをECRリポジトリとイメージのハッシュ値で指定する
		Architecture: lambda.Architecture_ARM_64(), // 今回はM1 Macbookからビルドしたイメージを実行するので、Lambda関数のアーキテクチャもそれに合わせる
		Environment: &map[string]*string{
			"LINE_NOTIFY_TOKEN": jsii.String(accessToken),
		},
		FunctionName: jsii.String("notify-aws-billing"),
		MemorySize: jsii.Number(1024),
		Role: role, // 2-2で作成したIAMロールを紐づける
		Timeout: cdk.Duration_Seconds(jsii.Number(10)),
	})
	
	// ********************************************************************************
	// 3. Lambda関数を定期実行させるスケジュールの作成
	// ********************************************************************************
	events.NewRule(stack, jsii.String("NotifyAWSBillingRule"), &events.RuleProps{
		Enabled: jsii.Bool(true),
		RuleName: jsii.String("notify-aws-billing"),
		Schedule: events.Schedule_Cron(&events.CronOptions{
			Minute: jsii.String("0"),
			Hour: jsii.String("1"),
			WeekDay: jsii.String("MON"),
		}), // JST で毎週月曜日の AM10:00 に定期実行
		Targets: &[]events.IRuleTarget{
			targets.NewLambdaFunction(handler, &targets.LambdaFunctionProps{
				RetryAttempts: jsii.Number(3),
			}),
		},
	})

	return
}

3-3. Lambda上で実行するコード

Lambda上で実行するコードはimages/notifybilling/main.goに記述しています。このファイルの中身は以下の通りです。

images/notifybilling/main.go
package main

import (
	"fmt"
	"net/http"
	"net/url"
	"os"
	"sort"
	"strconv"
	"strings"
	"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/jsii-runtime-go"
)

const lineNotifyURL = "https://notify-api.line.me/api/notify"

type Response struct {
	Code float64 `json:"code"`
	Message string `json:"message"`
}

type CostPerService struct {
	Service string
	Cost float64
}

// LINE通知を行う関数
func notifyToLINE(message *string) error {
	// アクセストークンを環境変数から取得する
	lineNotifyToken, ok := os.LookupEnv("LINE_NOTIFY_TOKEN")
	if !ok {
		return fmt.Errorf("notifyToLINE: NOT FOUND LINE_NOTIFY_TOKEN")
	}

	// LINE通知のリクエストを作成する
	// 詳しくは[LINE Notify API Document](https://notify-bot.line.me/doc/ja/)をご参照ください
	body := strings.NewReader(url.Values{
		"message": []string{*message},
	}.Encode())
	req, err := http.NewRequest(http.MethodPost, lineNotifyURL, body)
	if err != nil {
		return fmt.Errorf("notifyToLINE: %w", err)
	}
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", lineNotifyToken))

	// リクエストを実行する
	client := new(http.Client)
	resp, err := client.Do(req)
	if err != nil {
		return fmt.Errorf("notifyToLINE: %w", err)
	}	
	defer resp.Body.Close() // resp.BodyをクローズしないとTCPコネクションが張られたままになる
	if resp.StatusCode != 200 {
		return fmt.Errorf("notifyToLINE: %s", resp.Status)
	}

	return nil
}

// LINE通知で送るメッセージを整形する関数
func toMessage(CostsPerService *[]CostPerService) *string {
	// コストの大きい順に並べ替える
	sort.Slice(*CostsPerService, func(i, j int) bool {
		return (*CostsPerService)[i].Cost > (*CostsPerService)[j].Cost
	})

	// 合計コストを算出しつつ、Taxをスライスから除外する
	var totalCost float64
	var tax float64
	for i, v := range *CostsPerService {
		totalCost += v.Cost
		if v.Service == "Tax" {
			tax = v.Cost
			*CostsPerService = append((*CostsPerService)[:i], (*CostsPerService)[i+1:]...) // Taxをsliceから除外する
		}
	}

	// メッセージを整形する
	message := "\n***********************"
	message += fmt.Sprintf("\nTotal: $%.1f (Tax: $%.1f)", totalCost, tax)
	message += "\n***********************"
	for _, v := range *CostsPerService {
		message += fmt.Sprintf("\n%s: $%.1f", v.Service, v.Cost)
	}

	return &message
}

// 指定されて期間におけるAWS利用額を取得する関数
func getCostAndUsagePerService(CostExplorer *costexplorer.CostExplorer, startDate *string, endDate *string) (*[]CostPerService, error) {
	// 指定されて期間におけるAWS利用額をサービス単位で取得する
	output, err := CostExplorer.GetCostAndUsage(&costexplorer.GetCostAndUsageInput{
		TimePeriod: &costexplorer.DateInterval{
			Start: startDate,
			End: endDate,
		},
		GroupBy: []*costexplorer.GroupDefinition{
			{Key: jsii.String("SERVICE"), Type: jsii.String("DIMENSION"),},
		},
		Granularity: jsii.String("MONTHLY"),
		Metrics: []*string{jsii.String("BlendedCost"),},
	})
	if err != nil {
		return nil, fmt.Errorf("getTotalCostAndUsage: %w", err)
	}

	// 取得した結果をパースする
	var CostsPerService []CostPerService
	for _, result := range output.ResultsByTime {
		for _, group := range result.Groups {
			if *group.Metrics["BlendedCost"].Amount != "0" {
				CostFloat64, err := strconv.ParseFloat(*group.Metrics["BlendedCost"].Amount, 64)
				if err != nil {
					return nil, fmt.Errorf("getCostAndUsagePerService: %w", err)
				}
				CostsPerService = append(CostsPerService, CostPerService{Service: *group.Keys[0], Cost: CostFloat64})
			}
		}
	}

	return &CostsPerService, nil
}

// ハンドラ関数 = Lambda関数として実行されるロジック
func handleRequest() (Response, error) {
	session := session.Must(session.NewSession())
	CostExplorer := costexplorer.New(session, aws.NewConfig().WithRegion("ap-northeast-1"))

	// 月初から現在日におけるAWS利用額をサービス単位で取得する
	currentTime := time.Now()
	firstDayOfMonth := time.Date(currentTime.Year(), currentTime.Month(), 1, 0, 0, 0, 0, time.UTC).Format("2006-01-02")
	today := currentTime.Format("2006-01-02")
	CostsPerService, err := getCostAndUsagePerService(CostExplorer, &firstDayOfMonth, &today)
	if err != nil {
		return Response{Code: 500, Message: "Internal Server Error"}, err
	}

	// 取得したAWS利用額を整形してLINE通知する
	message := toMessage(CostsPerService)
	err = notifyToLINE(message)
	if err != nil {
		return Response{Code: 500, Message: "Internal Server Error"}, err
	}

	return Response{Code: 200, Message: "OK"}, nil
}

// Lambda関数のコードが実行されるエントリポイント
func main() {
	lambda.Start(handleRequest)
}

Lambda上で実行させるコードを記述する際の注意点としては、func main()をLambda関数のエントリポイントとして機能させることです。func main()の中にlambda.Start(handleRequest)を記述することによってLambda関数を実行させており、ここに渡されるfunc handleRequest()がLambda関数で実行されるロジックになります[1]。このfunc handleRequest()で、月初からのAWS利用額を取得してLINE通知を行う処理を行なっています。

そして、このLambda関数を実行することによって送られるLINE通知は下図のようになります!

3-4. Lambda関数で実行するコードをコンテナ化するDockerfile

最後に、Lambda関数で実行するコードをコンテナ化するためのDockerfileを以下に示します。

images/notifybilling/Dockerfile
FROM public.ecr.aws/lambda/provided:al2-arm64 as build
WORKDIR /go/src/project
RUN yum install -y golang
ADD go.mod go.sum main.go ./
RUN GOOS=linux GOARCH=arm64 go build -o /main ./main.go

FROM public.ecr.aws/lambda/provided:al2-arm64
COPY --from=build /main /main
RUN chmod 755 /main
ENTRYPOINT [ "/main" ]

Lambda関数で実行するコンテナを作成する方法については、公式ドキュメントを参考にしました。ただし、public.ecr.aws/lambda/provided:al2-arm64のイメージではgo mod downloadコマンド時に以下のようなエラーが出てしまったので、モジュールのキャッシュとGoバイナリのビルドとを分けずに、一気にgo buildを実行するように変更しています。(ca-certificatesをいじったり、ビルドステージだけイメージを変更したりと色々試したのですが、個人開発だとこれが一番スッキリするかなという結論に至りました😙)

bash
> [build 6/8] RUN go mod download:
#10 30.46 go: github.com/aws/jsii-runtime-go@v1.64.0 requires
#10 30.46       github.com/stretchr/testify@v1.8.0 requires
#10 30.46       gopkg.in/yaml.v3@v3.0.1: reading gopkg.in/yaml.v3/go.mod at revision v3.0.1: git ls-remote -q origin in /root/go/pkg/mod/cache/vcs/0901dc1ef67fcce1c9b3ae51078740de4a0e2dc673e720584ac302973af82f36: exit status 128:
#10 30.46 	remote: Cannot obtain refs from GitHub: cannot talk to GitHub: Get https://github.com/go-yaml/yaml.git/info/refs?service=git-upload-pack: net/http: request canceled (Client.Timeout exceeded while awaiting headers)
#10 30.46 	fatal: unable to access 'https://gopkg.in/yaml.v3/': The requested URL returned error: 502
------
executor failed running [/bin/sh -c go mod download]: exit code: 1

また、今回はローカルで実行することを想定した作り込みを行っていませんが、もしローカルでテストを行いたい場合にはLambda コンテナイメージをローカルでテストするをご参照ください。

4. まとめ

月初からのAWS利用額を定期的にLINE通知するアプリケーションをCDK for Goで構築したので、これについて説明してみました。インフラとしては、コンテナを実行するLambda関数 + EventBridgeという構成を採用しており、Lambda関数上で実行させるアプリケーションコンテナも同じCDKリポジトリ内で記述しました。ちょっとしたアプリケーションであれば、インフラもアプリケーションも同じリポジトリに記述できるという爽快感を味わうことができ、よりCDKが好きになりました😆

脚注
  1. 詳しくはGo の AWS Lambda 関数ハンドラーをご参照ください。 ↩︎

Discussion