13
4

More than 5 years have passed since last update.

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

Last updated at Posted at 2019-04-29

背景

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 によるワークフローを適用する。

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 を作る。
  2. GitHub に Terraform 用コンフィギュレーションリポジトリを作る。
  3. Terraform 用コンフィギュレーションをプッシュして、プルリクエストを作成する。

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

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 の秘密鍵は開発に必要なので、
サービスアカウント画面 から作成し、ダウンロードする。

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

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

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

今回は私の好みで Node.js ランタイムを使用することにした。
セットアップ方法を参考に、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 から指定するなら、

index.js
function main() {
    console.log("hello");
}
module.exports.main = main;
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 を叩くところでクラスを分け、

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;
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 のような形式をしていて、
課金データエクスポート先のテーブル名ではハイフンがアンダースコアとなっているので、置換している。

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

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

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. ソースコードをプッシュする。
  2. 1.1.0などタグを付与する。
  3. Terraform 用コンフィギュレーションをプッシュして、プルリクエストを作成する。

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

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

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
13
4