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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

```
+----------------------    G     C     P    ----------------------------+
| +-----------------+                                                   |
| | Cloud Scheduler |                                                   |
| +-----------------+                                                   |
|    |                                                                  |
|   pub                                                                 |
|    v                                                                  |
| +---------------+                                                     |
| | Cloud Pub/Sub |                                                     |
| +---------------+                                                     |
|    ^                                                                  |
|    |    +---------------------------+                                 | +--------+
|   sub   | Cloud Source Repositories |---------------mirror------------->| GitHub |
|    |    +---------------------------+                                 | +--------+
|    |            |                                                     |
|    |          deploy                                                  |
|    |            v                                                     |
| +-----------------+          +----------+           +---------------+ |
| | Cloud Functions |--query-->| BigQuery |<--export--| Cloud Billing | |
| +-----------------+          +----------+           +---------------+ |
+---------|-------------------------------------------------------------+
         post                            \
          v                               \
      +-------+                       +-----------------------+           +--------+
      | Slack |                       | Terraform    Atlantis |-----------| GitHub |
      +-------+                       +-----------------------+           +--------+
```

- Cloud Functions の日次実行には Cloud Scheduler と Cloud Pub/Sub を用いる。
- Cloud Functions は GitHub 上のリポジトリを直接参照してデプロイはできず、
  Google Source Repositories によるミラーリングリポジトリを介することになる。

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

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

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

```go
provider "google" {
  project = "<project_id>"
  region  = "asia-northeast1"
  zone    = "asia-northeast1-c"
}

resource "google_bigquery_dataset" "billing" {
  // 課金データエクスポート用の BigQuery データセット。
  dataset_id = "billing"
  location   = "asia-northeast1"
}

data "google_client_config" "current" {}

resource "google_pubsub_topic" "billing" {
  // Cloud Functions を定期実行させるために使用する。
  name = "billing_trigger"
}

resource "google_cloud_scheduler_job" "billing_report_trigger" {
  // Cloud Functions を定期実行させるために使用する。
  name        = "billing_trigger"
  description = "Trigger billing report"
  // 日本時間で朝9時。
  schedule    = "0 0 * * *"
  // 私のプロジェクトだと App Engine がどうこう言われて
  // asia-northeast1 に作れなかったので、 us-central1 を指定している。
  // プロジェクトの設定によっては、わざわざ指定しなおさなくてよいかもしれない。
  region      = "us-central1"

  pubsub_target {
    topic_name = "${google_pubsub_topic.billing.id}"
    data       = "${base64encode("BillingReportTriggered")}"
  }
}

resource "google_service_account" "billing_report" {
  // Cloud Functions 用のサービスアカウント。
  account_id = "billing-report"
}

resource "google_project_iam_binding" "billing_report_bigquery_data_viewer" {
  // Cloud Functions 用のサービスアカウントの権限。
  role = "roles/bigquery.dataViewer"
   
  members = ["serviceAccount:${google_service_account.billing_report.email}"]
}

resource "google_project_iam_binding" "billing_report_bigquery_user" {
  // Cloud Functions 用のサービスアカウントの権限。
  // クエリ打つだけならなくてもよさそうだが、 NodeJS クライアントライブラリの実行に必要な模様。
  role = "roles/bigquery.user"

  members = ["serviceAccount:${google_service_account.billing_report.email}"]
}
```

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

Terraform で作成したサービスアカウント `billing_report` の秘密鍵は開発に必要なので、
[サービスアカウント画面](https://console.cloud.google.com/iam-admin/serviceaccounts) から作成し、ダウンロードする。

課金データのエクスポートは Terraform では設定できないようなので、 GUI から操作する。
メニューから「お支払い」、「課金データのエクスポート」と辿り、
課金データを BigQuery にエクスポートする設定で、
Terraform で作成したデータセット `billing` を選択すればよい。

実際に課金データがエクスポートされるまで時間がかかるようなので、1日ゲームでもして過ごす。

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

今回は私の好みで Node.js ランタイムを使用することにした。
[セットアップ方法](https://cloud.google.com/nodejs/docs/setup?hl=ja)を参考に、Node.js の開発環境は準備済みとする。

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

```
npm init
```

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

```
npm install @google-cloud/bigquery
```

このクライアントライブラリは、先ほどダウンロードした秘密鍵を環境変数でパス指定すれば認証が通る。

```
export GOOGLE_APPLICATION_CREDENTIALS=/path/to/credentials.json
```

テストを Mocha で書きたいので、インストール：

```
npm install --save-dev mocha
```

Cloud Functions では、実行する関数を指定する。
開発中はテストを書いて実行するのがよいだろう。

例えば `package.json` 記載のエントリーポイントが `index.js` で、
`main` という名前の関数を Cloud Functions から指定するなら、

```javascript:index.js
function main() {
    console.log("hello");
}
module.exports.main = main;
```

```javascript:test/test_index.js
const index = require('../index');

describe('index', function () {
    this.timeout(10000);
    describe('#main()', function () {
        it('should be executed without exceptions', function () {
            index.main();
        });
    });
});
```

を作り、

```
npm test
```

とすれば `main` を実行させられる。

今回は BigQuery にクエリを発行するところと Slack の Incoming Webhook を叩くところでクラスを分け、

```javascript:billing_reporter.js
const { BigQuery } = require('@google-cloud/bigquery');
const query = billingAccountId => `
SELECT
  invoice.month,
  SUM(cost)
    + SUM(IFNULL((SELECT SUM(c.amount)
                  FROM UNNEST(credits) c), 0))
    AS total,
  (SUM(CAST(cost * 1000000 AS int64))
    + SUM(IFNULL((SELECT SUM(CAST(c.amount * 1000000 as int64))
                  FROM UNNEST(credits) c), 0))) / 1000000
    AS total_exact
FROM \`billing.gcp_billing_export_v1_${billingAccountId}\`
GROUP BY 1
ORDER BY 1 ASC
;
`; // cf. https://cloud.google.com/billing/docs/how-to/bq-examples?hl=ja
const billingReport = data => data[0].map(
    row => `month:${row.month} total:${row.total} total_exact:${row.total_exact}`
).join('\n');

class BillingReporter {
    constructor(projectId, billingAccountId) {
        this.bigquery = new BigQuery({ projectId: projectId });
        this.billingAccountId = billingAccountId;
    }
    query() {
        return this.bigquery.query({
            query: query(this.billingAccountId),
            useLegacySql: false,
        }).then(data => new Promise(
            resolve => resolve(billingReport(data))
        ));
    }
}
module.exports = BillingReporter;
```

```javascript:slack_webhooker.js
const https = require('https');
const { URL } = require('url');

class SlackWebhooker {
    constructor(webhookUrl) {
        this.webhookUrl = new URL(webhookUrl);
    }
    post(text) {
        return new Promise((resolve, reject) => {
            const request = https.request({
                method: 'POST',
                hostname: this.webhookUrl.hostname,
                path: this.webhookUrl.pathname,
                headers: { 'Content-Type': 'application/json' },
            }, response => {
                let rawData = '';
                response.on('data', (chunk) => { rawData += chunk; });
                response.on('end', () => {
                    if (response.statusCode === 200) resolve(rawData);
                    else reject(rawData);
                });
            });
            request.write(JSON.stringify({ text: text }));
            request.on('error', reject);
            request.end();
        });
    }
}
module.exports = SlackWebhooker;
```

プロジェクト ID と課金アカウント ID 、 Incoming Webhook の URL を環境変数から指定するようにした。
課金アカウント ID は、 `xxxxxx-xxxxxx-xxxxxx` のような形式をしていて、
課金データエクスポート先のテーブル名ではハイフンがアンダースコアとなっているので、置換している。

```javascript:index.js
const BillingReporter = require('./billing_reporter');
const SlackWebhooker = require('./slack_webhooker');

const PROJECT_ID = process.env.PROJECT_ID;
const BILLING_ACCOUNT_ID = process.env.BILLING_ACCOUNT_ID.replace(/-/g, '_');
const WEBHOOK_URL = process.env.WEBHOOK_URL;

function main() {
    const billingReporter = new BillingReporter(PROJECT_ID, BILLING_ACCOUNT_ID);
    const slackWebhooker = new SlackWebhooker(WEBHOOK_URL);
    return billingReporter.query().then(
        result => slackWebhooker.post(result)
    ).then(console.log).catch(console.error);
}
module.exports.main = main;
```

記事用に、なんとなく雰囲気が伝わるように載せているだけであり、
全体のソースコードは https://github.com/iijimakazuyuki/GCPBillingReport にある。

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

Google Source Repositories によるミラーリングリポジトリを用意してから、デプロイする。

1. GitHub に Cloud Functions 用リポジトリを作る。
1. ソースコードをプッシュする。
1. 1.0.0などタグを付与する。
1. Google Source Repositories で上記リポジトリを複製する。
   - `github_<user_name>_<project_name>` のような名前になる。
1. Terraform 用コンフィギュレーションをプッシュして、プルリクエストを作成する。

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

```go
variable "billing_account" {
  // 課金データエクスポート先のテーブル名に使用される課金アカウントID。
  // Cloud Functions からテーブル名を特定するために使用する。
  default = "<billing_account_id>"
}

resource "google_cloudfunctions_function" "billing_report" {
  name                = "billing_report"
  available_memory_mb = 128
  runtime             = "nodejs8"
  entry_point         = "main"

  source_repository {
    url = "https://source.developers.google.com/projects/${data.google_client_config.current.project}/repos/<repository_name>/fixed-aliases/<tag>/paths/"
  }

  service_account_email = "${google_service_account.billing_report.email}"

  event_trigger {
    event_type = "providers/cloud.pubsub/eventTypes/topic.publish"
    resource   = "${google_pubsub_topic.billing.name}"
  }

  environment_variables = {
    PROJECT_ID         = "${data.google_client_config.current.project}"
    BILLING_ACCOUNT_ID = "${var.billing_account}"
  }
}
```

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

```
month:201904 total:0 total_exact:0
```

・・・無料枠しか使っていなかったので0だが、ちゃんと表示されるはず。

なお、朝9時を待たずとも、 Cloud Scheduler の画面から実行すれば、その時点でメッセージを投稿させられる。

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

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

1. ソースコードをプッシュする。
1. 1.1.0などタグを付与する。
1. Terraform 用コンフィギュレーションをプッシュして、プルリクエストを作成する。

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

```go
  source_repository {
    url = "https://source.developers.google.com/projects/${data.google_client_config.current.project}/repos/<repository_name>/fixed-aliases/<tag>/paths/"
  }
```

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

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

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

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

- [Terraform のデバッグ方法](https://www.terraform.io/docs/internals/debugging.html
) を知らず苦戦した。
  - 初めに `apply` に失敗したとき `BadRequest` とだけ表示されるので何が悪いのかわからなかった。
    `DEBUG` ログを取ると、リクエスト・レスポンスの内容が分かるようになる。
    `export TF_LOG=DEBUG` と `export TF_LOG_PATH=$PWD/tf.log` を指定するとよいだろう。
  - Terraform は、コンフィギュレーションを GCP の API に適当に変換してくれているようなのだが、
    そのロジックが分からず、 `event_trigger` に PubSub の `name` を入れるのか `id` を入れるのかで延々とミスり続けた。