Lambda (Node.js 8) 等を Terraform で立ててサーバレスにAWSの課金額をSlackに自動通知する

背景
---------------

AWS を使っていると、単純に忘れたり、認識していないリソースがあったり、リソースの落とし忘れで
いつの間にか課金されていることが多い。 :cry:
意識しなくても把握できるように自動通知したい。 :point_up:

現状、Cloud Watch のアラートは閾値をこえたときに発報されるルールであるなど制限が多い。 :pensive:
利用者からすると、予算のある値を超えたときでは遅くて、
金額の傾向を知りたいので金額ベースでなく日次でほしい。 :grinning:

関連
-----------------

拙項ですが、[GCP 版](https://qiita.com/iijimakazuyuki/items/982971d9f52c1d18e9b4)。

AWS 版については、下記記事を参考にさせていただいた。 :bow:

- [LambdaでAWSの料金を毎日Slackに通知する（Python3）](https://qiita.com/isobecky74/items/88e8e0dcb0ee224a31e4)
- [AWSの料金をLambdaのcronで定期チェックしてSlackに通知する](https://qiita.com/saku/items/fc6b70a420a5c510de2b)
- [Amazon Web Services(AWS) で無料枠を超えた時にアラートを飛ばす（日本語メニュー）](https://qiita.com/murachi1208/items/118f016983e26d3338b2)

概要
--------------

今回は Lambda をメインに使って、 AWS 課金額を日次で Slack に通知できるようにする。

Lambda を採用したのは、 AWS サービス内なので権限管理しやすく、
また無料枠もあってランニングコストを抑えられるからである。 :thumbsup:

さらに、 Terraform と GitHub を構成管理に使うことで、そもそも何のリソースが何に使われているか把握しやすくする。

使用した Lambda のソースコードは
https://github.com/iijimakazuyuki/AWSBillingReport
に、 Terraform のコンフィギュレーションは本文中に記載している。

環境の説明
--------------

AWS 内のリソース管理は、今回は Terraform を使う。

GUI や CLI ベースだと手続き的になるので、手順が煩雑になりやすいためである。
Terraform を使うと、宣言的にリソースを記述できる。

Terraform を使う上では、コンフィギュレーションファイルを GitHub で管理し、
[Atlantis](https://www.runatlantis.io/) によるワークフローを適用する。

Atlantis については、今回は深く触れない。 :bow:
GitHub でプルリクエストを作ると Terraform を動かしてくれる人がいる、というイメージ。 :muscle: 
その人は、 AWS の強い権限を持っている前提で話を進める。

今回使用するリソースは、次の図のようになる：

```
+-----------    A     W     S    ------------+
| +-------------------+                      |
| | CloudWatch Events |                      |
| +-------------------+                      |
|    |                                       |
| execute                                    |
|    | +----+                                |
|    | | S3 |                                |
|    | +----+                                |
|    |   ^                                   |
|    |  refer                                |
|    v   |                                   |
| +--------+          +--------------------+ |
| | Lambda |--query-->| CloudWatch Metrics | |
| +--------+          +--------------------+ |
+---------|----------------------------------+
         post          \
          v             \
      +-------+    +-----------------------+   +--------+
      | Slack |    | Terraform    Atlantis |---| GitHub |
      +-------+    +-----------------------+   +--------+
```

- Lambda の日次実行には、広く知られている通り CloudWatch Events ルールを用いる。
- Lambda へのデプロイは S3 を通じて行う。

ステップ0: 必要な環境を準備する
----------------

1. Slack に Incoming Webhook を作る。
1. GitHub に Terraform 用コンフィギュレーションリポジトリを作る。
1. Terraform 用コンフィギュレーションをプッシュして、プルリクエストを作成する。

次のようなコンフィギュレーションをプッシュする：

```go
data "aws_iam_policy_document" "billing_report_assume_role_policy" {
  // Lambda の AssumeRole のポリシー。
  statement {
    effect  = "Allow"
    actions = ["sts:AssumeRole"]

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

resource "aws_iam_role" "billing_report" {
  // Lambda のロール。
  name               = "billingReport"
  assume_role_policy = "${data.aws_iam_policy_document.billing_report_assume_role_policy.json}"
}

data "aws_iam_policy_document" "billing_report" {
  // Lambda のロールに与えるポリシー。
  statement {
    effect = "Allow"

    // 過不足ありそうだが
    // とりあえず CloudWatch の Read っぽいことと、
    // Lambda の実行ログを出力する権限を与えている。
    actions = [
      "cloudwatch:Describe*",
      "cloudwatch:Get*",
      "cloudwatch:List*",
      "logs:CreateLogStream",
      "logs:PutLogEvents",
    ]

    resources = ["*"]
  }
}

resource "aws_iam_user" "billing_report_developer" {
  // 開発用に IAM ユーザを作成する。
  name = "billing_report_developer"
}

resource "aws_iam_user_policy" "billing_report_developer" {
  // 開発用 IAM ユーザに権限を付与する。
  name   = "billing_report_developer_policy"
  user   = "${aws_iam_user.billing_report_developer.name}"
  policy = "${data.aws_iam_policy_document.billing_report.json}"
}

resource "aws_iam_role_policy" "billing_report" {
  // Lambda のロールにポリシーを紐づけ。
  name   = "billing_report_policy"
  role   = "${aws_iam_role.billing_report.id}"
  policy = "${data.aws_iam_policy_document.billing_report.json}"
}

resource "aws_cloudwatch_event_rule" "billing_report" {
  // Lambda の日次実行のための CloudWatch Events ルール。
  name                = "billingReport"
  description         = "Send billing report daily"
  schedule_expression = "cron(0 0 * * ? *)"
}
```

Atlantis はプルリクエストを作成すると `terraform plan` を実行してくれ、
`atlantis apply` とコメントするとが実際に適用されて便利だが、
ともかく、 `terraform apply` まで実行できて、上記リソースが作成済みになった、とする。

Terraform で作成した IAM ユーザ `billing_report_developer` のアクセスキーが開発に必要なので、
[IAM 管理画面](https://console.aws.amazon.com/iam/home?region=ap-northeast-1#/users/billing_report_developer) から作成し、メモする。

課金データのエクスポートは Terraform では設定できないようなので、 GUI から操作する。
参考：[Amazon Web Services(AWS) で無料枠を超えた時にアラートを飛ばす（日本語メニュー）](https://qiita.com/murachi1208/items/118f016983e26d3338b2)

ステップ1: ローカルで作ってみる
----------------------------

今回は私の好みで Node.js ランタイムを使用することにした。

まずはディレクトリを切って、

```
npm init
```

を実行し、適当に答えつつ Enter を連打する。
AWS SDK を使用するので、インストール：

```
npm install aws-sdk
```

このクライアントライブラリは、先ほどメモしたアクセスキーID、シークレットアクセスキーを環境変数で与えれば認証が通る。

```
export AWS_ACCESS_KEY_ID=<access_key_id>
export AWS_SECRET_ACCESS_KEY=<secret_access_key>
```

Lambda では、実行する関数を指定する。
開発中はテストを書いて実行するのがよいだろうが、今回は省略する。
Node.js の REPL を立ち上げて、下記のように実行して動作確認はできる。

```javascript
> const index = require('./index.js');
undefined
> index.handler();
Promise {
  <pending>,
  domain:
   Domain {
     domain: null,
     _events: { error: [Function: debugDomainError] },
     _eventsCount: 1,
     _maxListeners: undefined,
     members: [] } }
>
```

ステップ2: デプロイする
----------------------------

ZIP で固めて S3 にアップロードする。
AWS SDK 以外を使用する場合、 `node_modules` 内も固める必要があるが、今回は不要なので、次のようにアップロードする。
なお開発用の IAM ユーザは権限が足りない。
アップロード可能な IAM ユーザを使うよう、環境変数を指定しなおす必要があることに注意。

```
$ zip v1.0.0.zip index.js
  adding: index.js (deflated 61%)
$ aws s3 cp v1.0.0.zip s3://<bucket_name>/v1.0.0.zip
```

次に、 Terraform 用コンフィギュレーションをプッシュして、プルリクエストを作成する。

次のようなコンフィギュレーションを追加してプッシュする：

```go
resource "aws_lambda_function" "billing_report" {
  // Lambda 関数を ZIP で固めて S3 にアップロードしたものを参照させてデプロイする。
  s3_bucket     = "<bucket_name>"
  s3_key        = "v1.0.0.zip"
  function_name = "billing_report"
  role          = "${aws_iam_role.billing_report.arn}"
  handler       = "index.handler"
  runtime       = "nodejs8.10"

  environment {
    variables = {
      // Webhook URL を環境変数で与える。
      WEBHOOK_URL = "<webhook_url>"
    }
  }
}

resource "aws_cloudwatch_event_target" "billing_report" {
  // 日次実行の CloudWatch Events ルールで Lambda を実行する。
  target_id = "billingReport"
  arn       = "${aws_lambda_function.billing_report.arn}"
  rule      = "${aws_cloudwatch_event_rule.billing_report.name}"
}

resource "aws_lambda_permission" "billing_report" {
  // 日次実行の CloudWatch Events ルールで Lambda を実行させるのに必要なパーミッションを作成する。
  statement_id  = "AllowExecutionFromCloudWatch"
  action        = "lambda:InvokeFunction"
  function_name = "${aws_lambda_function.billing_report.function_name}"
  principal     = "events.amazonaws.com"
  source_arn    = "${aws_cloudwatch_event_rule.billing_report.arn}"
}
```

これで、CloudWatch Events ルールが実行される毎日朝9時に Slack に次のようなメッセージが投稿される。

```
2019/4/30 0.39
```

CloudWatch Events ルールを手動で実行させることはできないようだが、
Lambda から適当なテストを実行すれば、その時点でメッセージを投稿させられる。

ステップ3: 機能改善
--------------------------

機能を追加したいとか、メッセージをもうちょっと丁寧にしたいとかで、アップデートしたい場合は、次のような手順を踏む。

1. ソースコードを ZIP に固める。
   - 参考：https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/nodejs-create-deployment-pkg.html
1. ZIP ファイルを S3 にアップロードする。バージョン名をつけるとわかりやすい。
1. Terraform 用コンフィギュレーションをプッシュして、プルリクエストを作成する。

下記 `<version>` を更新して、 `apply` すればよい。

```go
resource "aws_lambda_function" "billing_report" {
  // Lambda 関数を ZIP で固めて S3 にアップロードしたものを参照させてデプロイする。
  s3_bucket     = "<bucket_name>"
  s3_key        = "v<version>.zip"

  // (略)
```

まとめ
---------------------------

AWS の課金額を意識せずとも把握できるよう、日次で Slack に通知されるようにした。

- その際、広く用いられている構成ではあるが、 Lambda を中心としたサーバレス構成とすることで、通知システム自体のランニングコストを抑えた。
- Terraform (+ Atlantis) と GitHub を構成管理に使うことで、システムに使用しているリソースを把握しやすくした。

付録：躓いたところ
--------------------------

- 何のリソースを作ればサービス間が連携できるのかが直ちには分からないし、今後どうしたらよいかもよく分かっていない。
  Lambda パーミッションを作り忘れていることに気づかず、
  Lambda 関数の GUI (Designer) に CloudWatch Events が表示されないなとしばらく悩んでいた。