従量課金のクラウドサービスにおいて利用料金を管理・把握したいと思ってた~GCP編~

はじめまして。入社2年目、インフラの植垣です。

前職は営業をしており、エンジニア未経験でアドウェイズに入社し日々奮闘しております。

皆さん、クラウドサービスの課金(料金)ってどのように管理・監視されてますか?

利用しているサービスやアカウント、プロジェクト数が少なければ、管理画面でたまに見るというのはあまり大変ではありませんが管理するものが増えるほど地味に大変だと思います。

またAmazon Web ServicesGoogle Cloud Platform(以後GCP)は従量課金制のため
こまめに確認してあげると意図していない料金が請求されることを防ぐことができると思います。

そんなこんなで最近はGCPに携わっているのでGCPの課金周りでやってみたことについて少し書こうと思います。

GCPの課金データのエクスポートについて

GCPは課金データを請求先アカウント毎にエクスポート設定をすることができます。
エクスポート先は

  • BigQuery
  • Cloud Storage

を指定することが可能で、BigQueryの場合は事前にデータセットの作成、Cloud Storageの場合はバケットの作成をしておく必要があります。

さて、エクスポートされた課金データをどうしましょう?
課金データなので、プロジェクト毎、リソース毎といった細かい粒度で現在の利用料金について知れたらいいですよね。

Google先生に聞いてみるとBigQuery + DatastudioBigQuery + BIツールといった記事は見るのですが
Cloud Storageの方の活用シーンがあまり見られません。

ふむふむ。何かできないかな?と思ったところ、Cloud Functionsでイベントドリブン的な事ができるなと思ったのでとりあえずやってみました。

f:id:AdwaysEngineerBlog:20170623113251p:plain

全体のフローはこんな感じです。

さあ、やっていきましょう。まずは事前準備があるのでやっていきます。(今回はその方法については記載しません)

  • 課金データをエクスポートするバケットの作成
  • 課金データのエクスポート設定(Cloud Storage)
  • 課金データのエクスポート設定時に設定したprefixをつけたファイルを同じバケットにアップロード
    • ファイル名: prefixがbillingの場合はbilling.json
    • ファイルの中身: {}

実装

さあ、実装していきましょう。 まずは今回必要なライブラリをpackage.jsonに記載します。

package.json

{
  "name": "billing-notify",
  "version": "0.0.1",
  "dependencies": {
    "google-cloud": "latest",
    "@google-cloud/storage": "latest",
    "@slack/client": "latest"
  }
}

次に通知に関する設定やできるだけjsファイルのコード変更を行わずに色々コントロールしたいと思ったので設定ファイルを作成しました。
今回通知先はslackにしたのでslack.json、コントロールするファイルをopt.jsonファイルとしました。

slack.json

{
  "token": token, // 取得したトークン
  "message": {
    "channel": channel, // 通知するチャンネル
    "text": null,
    "opt": { // 通知に関するオプション
      "username": username // 通知するユーザの名前
    }
  }
}

opt.json

{
  "project": プロジェクト名, // Cloud Functionsのトリガーとして設定しているバケットがあるプロジェクト
  "billingPrefix": prefix, // 課金データのエクスポート設定時のprefix
  "contact": ["slack", "message"] // 通知方法
}

後はindex.jsに全ての処理を記載すればいいのですが、ファイルを分ける事も可能です。
今回はあまりプログラムは長くないためindex.jsに全て処理を記述することにしました。

index.js

var optObj = require('./opt.json');
exports.billingNotify = function(event, callback) {
  var dateBillingFileName = event.data.name;
  var billingPrefix = optObj["billingPrefix"];
  var billingRegexp = new RegExp(billingPrefix + '-\\d{4}-\\d{2}-\\d{2}');
  var aggregateBillingFileName = billingPrefix + ".json";
  if (!dateBillingFileName.match(billingRegexp)){
    callback();
    return;
  }
  var gcs = storage({
    projectId: optObj["project"]
  });
  var bucket = gcs.bucket(event.data.bucket);
  var dateBillingFile = bucket.file(dateBillingFileName);
  
  dateBillingFile.exists(function(err, exists) {
    if(!exists) {
      console.log(dateBillingFileName + " is not found");
      callback();
      return;
    }
  });
  var buffer = new Buffer('');
  dateBillingFile.createReadStream()
    .on('data', function(chunk) {
      buffer = Buffer.concat([buffer, chunk]);
    })
    .on('end', function() {
      var dateBillingObj = JSON.parse(buffer);
      var aggregateBillingFile = bucket.file(aggregateBillingFileName);
      buffer = new Buffer('');
      aggregateBillingFile.createReadStream()
        .on('data', function(chunk) {
          buffer = Buffer.concat([buffer, chunk]);
        })
        .on('end', function() {
          var aggregateBillingObj = JSON.parse(buffer);
          aggregateBillingObj = aggregateBilling(aggregateBillingObj, dateBillingObj);
          
          aggregateBillingFile.save(JSON.stringify(aggregateBillingObj), { metadata: {contentType: 'application/json'} }, function(err) {
            if(err) {
              console.log("save err = " + err);
              callback();
            } else {
              notify(createNotifyObj(aggregateBillingObj, createNotifyMonths(dateBillingObj)));
              callback();
            }
          });
      });
    });
};
function aggregateBilling(aggregateBillingObj, dateBillingObj) {
  dateBillingObj.forEach(function(billingObj) {
    var month = billingObj.startTime.match(/\d{4}-\d{2}/)[0];
    var project = billingObj.projectName;
    var service = billingObj.lineItemId.match(/services\/(.*)$/)[1].split("/");
  
    if (!aggregateBillingObj[project]) {
      aggregateBillingObj[project] = {};
    }
    if (aggregateBillingObj[project][month]) {
      aggregateBillingObj[project][month]["sum"] += parseFloat(billingObj.cost.amount);
    } else {
      aggregateBillingObj[project][month] = {"sum": 0.0};
    }
    
    if (aggregateBillingObj[project][month][service[0]]) {
      aggregateBillingObj[project][month][service[0]]["sum"] += parseFloat(billingObj.cost.amount);
    } else {
      aggregateBillingObj[project][month][service[0]] = {"sum": 0.0};
    }
    if (aggregateBillingObj[project][month][service[0]][service[1]]) {
      aggregateBillingObj[project][month][service[0]][service[1]] += parseFloat(billingObj.cost.amount);
    } else {
      aggregateBillingObj[project][month][service[0]][service[1]] = parseFloat(billingObj.cost.amount);
    }
  });
  return aggregateBillingObj;
}
function createNotifyMonths(dateBillingObj) {
  var notifyMonths = [];  
  dateBillingObj.forEach(function(billingObj) {
    var month = billingObj.startTime.match(/\d{4}-\d{2}/)[0];
    if (notifyMonths.indexOf(month) < 0) {
      notifyMonths.push(month);
    }
  });
  return notifyMonths;
}
function createNotifyObj(aggregateBillingObj, notifyMonths) {
  var tmpJsonObj = JSON.parse(JSON.stringify(aggregateBillingObj));
  var notifyObj = {};
  Object.keys(tmpJsonObj).forEach(function(key) {
    var tmp = {};
    notifyMonths.forEach(function(month) {
      tmp[month] = tmpJsonObj[key][month]
    });
    notifyObj[key] = tmp;
  });
  return notifyObj;
}
function notify(notifyObj) {
  var contact = optObj["contact"];
  switch (contact[0]) {
    case "slack":
      var webClient = require('@slack/client').WebClient;
      var opt = require('./slack.json');
      var client = new webClient(opt["token"]);
      var notifyOpt = opt[contact[1]];
      switch (contact[1]) {
        case "message":
          client.chat.postMessage(notifyOpt["channel"], JSON.stringify(notifyObj, null, "\t"), notifyOpt["opt"],function(err, res) {
            if(err) {
              console.log("Error: ", err);
            }
          });
          break;
        default:
          break;
      }
      break;
    default:
      break;
  }
}

後はデプロイするだけです。Cloud SDKでCloud Functionsをデプロイすると同じディレクトリにある全てのファイルをzipにまとめてデプロイしてくれるのでとても便利です。

gcloud beta functions deploy billingNotify --trigger-bucket bucketName --stage-bucket bucketName

後は課金データがCloud Storageにエクスポートされるとこんな感じで通知されます。

{
    プロジェクト名: {
        yyyy-mm: {
            "sum": 10,
            "big-query": { 
                "sum": 5,
                "ActiveStorage": 0,
                "Storage": 0,
                "StreamingInsertBytes": 5,
                "Analysis": 0
            },
            "app-engine": {
                "sum": 0,
                "OutgoingBandwidthJp": 0
            },
            "cloud-storage": {
                "sum": 10.5,
                "ClassARequestMultiRegional": 10.0,
                "StorageMultiRegionalUsGbsec": 0,
                "StorageRegionalJapanGbsec": 0.5
            }
        }
    },
    プロジェクト名: {
        yyyy-mm: {
          ・・・
        }
    }
}

さいごに

Cloud StorageにエクスポートされたデータをCloud Functionsを使って通知させることができました。
・・・・・ふむ。BigQueryにエクスポートできるのは便利だな、と正直思いました。
やはりBIツールと簡単に連携できるのがいいですよね。
Cloud Storageにエクスポートするのは、あくまでバックアップ的な意味合いに使われているのかなとやってみて思いました。
ただBIツールの画面をわざわざ開かなくても普段よく見るchatツールで手軽に見る事ができるのは便利ですよね。
単純に現在の利用額について知りたいという目的ならCloud Storage + Cloud Functionsの組み合わせもありだなと思いました。

それでは今回はこのあたりで失礼します。