背景
GCP を使っていると、単純に忘れたり、認識していないリソースがあったり、リソースの落とし忘れで
いつの間にか課金されていることが多い。
意識しなくても把握できるように自動通知したい。
現状、Cloud Billing のアラートは閾値をこえたときに発報されるルールであるなど制限が多い。
利用者からすると、予算のある値を超えたときでは遅くて、
金額の傾向を知りたいので金額ベースでなく日次でほしい。
概要
今回は Cloud Functions をメインに使って、 GCP 課金額を日次で Slack に通知できるようにする。
Cloud Functions を採用したのは、 GCP サービス内なので権限管理しやすく、
また無料枠もあってランニングコストを抑えられるからである。
さらに、 Terraform と GitHub を構成管理に使うことで、そもそも何のリソースが何に使われているか把握しやすくする。
使用した Cloud Functions のソースコードは
https://github.com/iijimakazuyuki/GCPBillingReport
に、 Terraform のコンフィギュレーションは本文中に記載している。
環境の説明
GCP 内のリソース管理は、今回は Terraform を使う。
GUI や CLI ベースだと手続き的になるので、手順が煩雑になりやすいためである。
Terraform を使うと、宣言的にリソースを記述できる。
Terraform を使う上では、コンフィギュレーションファイルを GitHub で管理し、
Atlantis によるワークフローを適用する。
Atlantis については、今回は深く触れない。
GitHub でプルリクエストを作ると Terraform を動かしてくれる人がいる、というイメージ。
その人は、 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: 必要な環境を準備する
- Slack に Incoming Webhook を作る。
- GitHub に Terraform 用コンフィギュレーションリポジトリを作る。
- 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 から指定するなら、
function main() {
console.log("hello");
}
module.exports.main = main;
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 を叩くところでクラスを分け、
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;
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
のような形式をしていて、
課金データエクスポート先のテーブル名ではハイフンがアンダースコアとなっているので、置換している。
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 によるミラーリングリポジトリを用意してから、デプロイする。
- GitHub に Cloud Functions 用リポジトリを作る。
- ソースコードをプッシュする。
- 1.0.0などタグを付与する。
- Google Source Repositories で上記リポジトリを複製する。
-
github_<user_name>_<project_name>
のような名前になる。
-
- 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.1.0などタグを付与する。
- 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=DEBUG
とexport TF_LOG_PATH=$PWD/tf.log
を指定するとよいだろう。 - Terraform は、コンフィギュレーションを GCP の API に適当に変換してくれているようなのだが、
そのロジックが分からず、event_trigger
に PubSub のname
を入れるのかid
を入れるのかで延々とミスり続けた。
- 初めに