GCP プロジェクトにおけるコスト監視

by yohan | February 28, 2020 updated
tips | #dena #GCP #google-bigquery #ruby

はじめに

こんにちは、IT基盤部のヨハンです。

担当の業務内容は、ゲームタイトル及びプラットフォーム、オートモーティブ事業のインフラを運用をメインとしておりますが、兼任で DeNA が持つパブリッククラウドについて横断で管理及び運用する業務も担当しております。
パブリッククラウドには AWS, GCP を含め様々なサービスを利用しており、数でいうと1000以上存在しますが、それらのアカウントの作成/解約フローや、セキュリティなど運用するにあたって、生じる様々な問題や課題を改善していくことで効率よくより良い形で利用者に提供ができて、管理者にとっても大きな負担なく多数のアカウントを管理できることをミッションとしています。

今回は多数のパブリッククラウドを管理する業務の中で、GCP プロジェクトに対しどのような方法でコスト監視を行っているのか、その仕組みと設定について焦点をあて紹介させていただきいと思います。

現状の課題

DeNA は注力のゲーム事業に加え、エンタメ、オートモーティブ、AI関連など、多種多様な事業を展開しております。そのサービスを支える基盤はパブリッククラウドサービスも活用しており、GCP プロジェクトだけで数百個存在します。

パブリッククラウドサービスは便利で高度なスキルがなくとも Availability が高く安定的な基盤を簡単に作れるメリットがある反面、何も考えず使ってしまうと翌月に高額請求が来て、泣きそうになったの経験話はネット上にたくさんあると思います。DeNA も同様に様々な理由で使いすぎてしまい想定外の高額請求がきてしまったという相談をたくさん受けます。

という課題を抱えている中で、要望のある事業部に対しては毎月一定のタイミングで billing 情報を csv にエクスポートし、渡したり運用をしていたのですが、手動オペレーションは限界があるのと要望がない大半の部署は引き続きコストが監視できてない状態が継続しておりました。

GCP プロジェクト数は日々増えていくので、優先度高く改善策の検討を始めざる得ませんでした。改善案で GCP 機能である 予算アラート もありましたが、組織リソース(organization)のルートノードに対して、強い権限が必要(同じ組織の全プロジェクトの請求情報が誰にも参照可能な状態になる)だったのと個々の project で設定管理するよりある監視環境下で一括に管理したかったので、候補から外しました。

課題からのコスト管理要件を考える

さて、これほどのプロジェクト数を保有していると、効率的且つセキュアに管理していくためには様々な課題が生じると思いますが、今回はコスト管理にフォーカスをあててブログを作成したいと思います。

IT基盤では個々のプロジェクトに対して、利用状況及びコストのモニタリングを行い、利用料金が高額になっているアカウントの担当者へ通知を出すことで今のコスト感を知ってもらう、且つコスト意識を高めてもらうためにはいかなる方法で実現できるかにつき検討を重ねました。

議論を重ねていくなかで以下の通りに要件が決まりました。

  • 監視条件は以下項目のいずれかに当てはまるとアラートを発行
    • 月のトータルコストが 100万円を超過
    • 前月比の増加率が 150% 以上である、且つ今月のコストが80万円を超過
  • 個々のプロジェクトコスト情報は関係者のみ公開
  • プロジェクトコスト情報はセキュア且つアクセスしやすい場所に保存
  • コスト監視が動作する環境はログイン制御や credential 管理はなるべく回避
  • 新規のプロジェクトを作成する度に人によるオペレーションは不要
  • アラートの送信先はコスト情報が閲覧可能なグループに連絡
  • コストアラートには現在のコストだけでなく、前月比も含めることで増減が把握できる

要件に対する実現方法

結論から言うと、GCP BigQuery, GAE flexible, GAE cron サービスを利用し、環境を整えることになりました。

GCP BigQuery(以下 bq)の活用

  • GCP billing export から bq に全プロジェクトの billing 情報を格納
  • 一箇所にまとまった billing 情報に対し、別のバッチによりプロジェクトごと dataset を作成し、コストテーブルを create & insert
  • dataset ごとに共有設定が可能なので、適宜担当部門の group alias を指定し連携することで、参照権限を制限
  • bq への参照は select など DDL によるアクセスと bq をデータソースとして使い BI ツール(e.g., Tableau, DataStudio..)による可視化

GAE flexible(以下 GAE), GAE cron(以下 cron)の活用

  • GAE Standard と GAE Flexible があるが、リソース制限なくより柔軟性の高い GAE Flexible 環境下でバッチを設置
    • web application は ruby + sinatra で実装されていて、docker化され GAE Flexible 上で動作
    • 開発言語は GAE Flexible 環境をサポートする ruby を用いる
  • GAE cron を用いて一定の間隔で GAE Flexible 上にあるバッチをキック
    • GAE cron は GAE Flexible に対してスケジュール通りに指定の url(path)にリクエスト
# sample) cron.yaml
cron:
- description: "cost alert for GCP"
  url: /tasks/GCPーcost-alert
  schedule: 5,10,15,20,25,30 of month 10:00

構成について

プロジェクトごと dataset 分割

  1. 組織 Billing 情報が任意のタイミングで指定の bq project:dataset.table に Export される
  2. cron で GAE に対して、 https://… リクエスト
  3. GAE 上でバッチが動作し、全件が載っているテーブルに対し select
  4. select 結果をプロジェクトごと分割し、必要に応じて dataset, table を作成しつつ、billing 情報を insert

コスト監視

cron がトリガになり、GAE が全件が入っているテーブルから select クエリを駆使しデータを抽出しメールを送信するだけで、基本登場人物や構成が同様なため、図面による説明は割愛

監視で参照するカラム

コスト監視にあたって、使うカラムは以下3つとなりますが、他にもたくさんありますので、GCP の公式ページを参照してください。

フィールド名 タイプ モード
service.description STRING NULLABLE
project.name STRING NULLABLE
cost FLOAT NULLABLE

コスト監視環境の構築

バッチ作成

クエリを作成
高額プロジェクトと前月比コストデータを取得 (#{COST_SQL})
WITH
  -- 今月の各プロジェクトのトータルコストを取得
  C AS (
    SELECT
      project.name, SUM(cost) AS totalcost
    FROM
      `#{BILLING_DATASET}.#{BILLING_TABLE}`
    WHERE
      _PARTITIONTIME >= TIMESTAMP(?) AND _PARTITIONTIME <= TIMESTAMP(?) AND invoice.month = ? AND cost != 0
    GROUP BY
      project.name ),
  -- 前月の各プロジェクトのトータルコストを取得
  P AS (
    SELECT
      project.name, SUM(cost) AS totalcost
    FROM
      `#{BILLING_DATASET}.#{BILLING_TABLE}`
    WHERE
      _PARTITIONTIME >= TIMESTAMP(?) AND _PARTITIONTIME <= TIMESTAMP(?) AND invoice.month = ? AND cost != 0
    GROUP BY
      project.name ),
  -- C & Pから前月比における、差額と変動率を求める
  DIFF AS (
    SELECT
      C.name, C.totalcost AS current_cost, P.totalcost AS past_cost, C.totalcost - P.totalcost AS diff_cost, FLOOR((C.totalcost / P.totalcost) *100) AS increase_rate
    FROM
      C INNER JOIN P ON C.name = P.name )
  -- DIFF から閾値を条件に高額を判定
  SELECT
    name, round(current_cost,1) AS current_cost, round(past_cost,1) AS past_cost, round(diff_cost,1) AS diff_cost, increase_rate
  FROM
    DIFF
  WHERE
    (current_cost >= ? OR ( current_cost > ? AND increase_rate > ? ))
高額と判定されたプロジェクトにおけるコストの内訳を取得(#{COST_DETAIL_SQL})
WITH
  -- 今月分の内訳
  A AS (
    SELECT
      service.description AS des,
      ROUND(SUM(cost),1) AS cost
    FROM
      `#{BILLING_DATASET}.#{BILLING_TABLE}`
    WHERE
      _PARTITIONTIME >= TIMESTAMP(?) AND _PARTITIONTIME <= TIMESTAMP(?) AND invoice.month = ? AND project.name = ?
    GROUP BY
      des ),
  -- 前月分の内訳
  B AS (
    SELECT
      service.description AS des,
      ROUND(SUM(cost),1) AS cost_prev
    FROM
      `#{BILLING_DATASET}.#{BILLING_TABLE}`
    WHERE
      _PARTITIONTIME >= TIMESTAMP(?) AND _PARTITIONTIME <= TIMESTAMP(?) AND invoice.month = ? AND project.name = ?
    GROUP BY
      des )
  -- A, B結果を一つのテーブルに纏める
  SELECT
    IFNULL(A.des, B.des) AS product, IFNULL(B.cost_prev, 0.0) AS cost_prev , IFNULL(A.cost, 0.0) AS cost
  FROM
    A FULL JOIN B ON A.des = B.des
  ORDER BY
    A.cost DESC

対象のプロジェクトを取得

  • 2 / 1 ~ 2 / 10 利用分につき、コスト監視を実施したとの仮定として、前月の 1 / 1 ~ 1 / 10 コストも一緒に取得
  • bigquery.query_job 実行時に注意点として、結果が大きい場合は table: にて一時テーブルを指定する必要がある
  • standard sql でのみ実行が可能
bigquery = Google::Cloud::Bigquery.new(
  project: BIGQUERY_PROJECT_ID,
  keyfile: CREDENTIAL_FILE,
  timeout: 600
)
# past_start, cur_start     = 2020-01-01, 2020-02-01
# past_end, cur_end         = 2020-01-10, 2020-02-10
# past_invoice, cur_invoice = 202001, 202002
# THR_COST_TOTAL_MONTH      = 10000 ($)
# THR_COST_DIFF_LOWEST      = 8000  ($)
# THR_COST_RATE_LOWEST      = 150   (%)
begin
  job = bigquery.query_job COST_SQL, params: ["#{cur_start}", "#{cur_end}", "#{cur_invoice}", "#{past_start}", "#{past_end}", "#{past_invoice}", THR_COST_TOTAL_MONTH, perDiff, THR_COST_RATE_LOWEST] \
                                , standard_sql: true, large_results: true, table: tmp_table
rescue Google::Cloud::InvalidArgumentError, Google::Apis::ServerError, Google::Apis::ClientError, Google::Apis::AuthorizationError => ex
  logger.error(ex.message)
  exit rtn_not_ok
end

job.wait_until_done!
rows = job.query_results
if rows.all.count == 0
  logger.info("Query returned zero record.")
  exit rtn_ok
end
# main loop
rows.each do |row|
  # プロジェクトごと費用の内訳を取得
  # メール送信先の取得
  # メール本文作成
  # メール送信
end

プロジェクトごと費用の内訳を取得

# main loop
rows.each do |row|
# ...
  # past_start, cur_start     = 2020-01-01, 2020-02-01
  # past_end, cur_end         = 2020-01-10, 2020-02-10
  # past_invoice, cur_invoice = 202001, 202002
  # row[:name]                = project name
  begin
    records = bigquery.query COST_DETAIL_SQL, params: ["#{cur_start}", "#{cur_end}", "#{cur_invoice}", "#{row[:name]}", "#{past_start}", "#{past_end}", "#{past_invoice}", "#{row[:name]}"], standard_sql: true
  rescue Google::Cloud::InvalidArgumentError, Google::Apis::ServerError, Google::Apis::ClientError, Google::Apis::AuthorizationError => ex
    logger.error(ex.message)
    records = nil
  end
# ...
end

メール送信先の取得

  • 前提として
    • プロジェクト作成時に billing 管理用のプロジェクト上 bq にプロジェクト名と同じにした dataset を作成
    • dataset 共有設定にはそのプロジェクトの管理チームの ML を READER 権限で付与
    • row[:name] にはプロジェクト名が保存されている
  • dataset に READER 権限を持っているメールアドレスにアラート送信
# main loop
rows.each do |row|
# ...
  addr = []
  logger.info("[#{row[:name]}] Get the e-mail address")
  dt = bigquery.dataset row[:name]
  acc = dt.access

  for acc_hash in acc.to_a.select{|i| i[:role] == "READER"}
    acc_hash.each do |key, value|
      next if "#{key}" != "group_by_email"
      addr.push("#{value}")
    end
  end
# ...
end

メール本文作成

# function: メール本文を作成
def func_createMAIL(pj, rate, dateOrig, dateCur, origCost, curCost, breakdown, rateMaxCost, maxAmount)
  realMax = THR_COST_TOTAL_MONTH
  realMax = maxAmount/100 if maxAmount != 0
  contents = <<EOS
<html>
<h2><%= pj %> 担当各位</h2> 

<%= pj %> の費用について、監視閾値を越えておりますため、<br/>
以下の観点からご確認の上返信をお願いいたします。<br/>
<ul>
<li>費用増加の原因</li>
<li>費用/売上比</li>
</ul>
....
<h3>費用の詳細</h3>
<table border="1" width="400" cellspacing="0" cellpadding="5" bordercolor="#333333">
<tr>
<th bgcolor="#808080" width="200"><font color="#FFFFFF">Product</font></th>
<th bgcolor="#808080" width="100"><font color="#FFFFFF"><%= dateOrig %>($)</font></th>
<th bgcolor="#808080" width="100"><font color="#FFFFFF"><%= dateCur %>($)</font></th>
</tr>
<% if !breakdown.nil? and breakdown.count != 0
     breakdown.each do |data| %>

<tr>
<td bgcolor="#FFFFFF" valign="top"><%= data[:product] %></td>
<td bgcolor="#FFFFFF" valign="top" align="right"><%= data[:cost_prev] %></td>
<td bgcolor="#FFFFFF" valign="top" align="right"><%= data[:cost] %></td>
</tr>
<%   end %>
<% else %>
....
EOS
  erb = ERB.new(contents)
  return erb.result(binding)
end

# main loop
rows.each do |row|
# ...
  # row[:name]                  = project name
  # row[:increase_rate]         = 前月比増加率(%)
  # past_start_md, cur_start_md = 01/01, 02/01
  # past_end_md, cur_end_md     = 01/10, 02/10
  # row[:past_cost]             = 01/01~01/10 費用($)
  # row[:current_cost]          = 02/01~02/10 費用($)
  # records                     = {"product", "product 前月のコスト", "product 今月のコスト"}
  # perDiff                     = "前月比の増加率が 150% 以上である、且つ今月のコストが80万円を超過" に対し、80万円を日割り ($)
  # maxAmount                   = 10k($)
  body = func_createMAIL(row[:name], row[:increase_rate], "#{past_start_md} ~ #{past_end_md}", "#{cur_start_md} ~ #{cur_end_md}", row[:past_cost], row[:current_cost], records, perDiff, maxAmount)
# ...
end

メール送信

  • 冒頭にて当実行環境は GAE であると記載しましたが、GAE の場合メール送信には制約があるため、サードパーティーのサービスを使うしかない
    • GAE Standard 環境では App Engine Mail API が使える
  • サードパーティーのサービスで mailgun を利用
# function: メール転送
def func_sendMail(mg, to_addr, subject, body, debug_flag)
  to_addr = MAIL_ADMIN_ADDR if to_addr.nil?
  message = Mailgun::MessageBuilder.new

  message.add_recipient  :to, "#{to_addr.join(',')}"
  message.add_recipient  :cc, "#{MAIL_CC_ADDR.join(',')}" if !debug_flag
  message.add_recipient  :from, MAIL_FROM_ADDR
  message.subject        subject
  message.body_html      body

  mg.send_message MAILGUN_DOMAIN_NAME, message
end

# main loop
rows.each do |row|
# ...
  mailgun = Mailgun::Client.new MAILGUN_API_KEY
  subject="[#{cur_end}] Cost management of #{row[:name]}"
  logger.debug("subject: #{subject}")
  logger.info("Send mail to #{addr}.")
  begin
    func_sendMail(mailgun, addr, subject, body, OPTIONS[:debug])
  rescue Mailgun::CommunicationError => ex
    logger.error("Failed send mail : #{ex.message}")
    rtn_ok = rtn_not_ok
  end
# ...
end

GAE 環境構築

  • GAE デプロイ環境を別途用意するのが面倒だったのと、既に CLOUD SDK がインストール済みですぐ使える状態のため、CLOUD SHELL を使うことにしました。
  • GAE を初期化します
    • GAE 操作に関して、GCP 公式ドキュメントに詳しく書いてあるので、詳細は割愛とします。
  • https://github.com/GoogleCloudPlatform/ruby-docs-samples にサンプルがあるので、clone し適宜必要な箇所を修正しながら、使います。
  • Gemfile 修正
    必要な gem library を定義し、「bundle install」 します。
source "https://rubygems.org"

gem "sinatra"
gem 'google-api-client'
gem 'google-cloud-bigquery'
gem 'google-cloud-storage'
gem 'systemu'
gem 'mailgun-ruby'
  • cron.yaml 修正
    schedule 日時通りに GAE アプリケーションに対して、url path でリクエストを送信してくれます。
cron:
- description: dena-cost-alert-for-gcp
  url: /hogehoge/check-cost-for-gcp
  schedule: 5,10,15,20,25,30 of month 11:00
  timezone: Asia/Tokyo
  target: default
  • app.yaml 修正
    やっていることは単純で sinatra を用いて web application を稼働させ、上部で作ったバッチを実行するだけです。
require "sinatra"
require "date"
require "systemu"
get "/hogehoge/check-cost-for-gcp" do
  rtn_code = 500
  batch_command = "ruby scripts/check-cost-for-gcp.rb"

  batch_status, batch_stdout, batch_stderr = systemu batch_command
  if batch_status.inspect.include?('exit 0')
    rtn_code = 200
  end

  batch_stdout.each_line {|line| p line.chomp }
  p batch_stderr
  status rtn_code
end
  • web application ディレクトリ内の scripts/check-cost-for-gcp.rb バッチと credential を配置します。
  • GAE に deploy
  • GAE に Cron を deploy

メールサンプル

大体このようなメールを担当に送信します。

効果

2018/7頃より運用を開始して以来、コスト増の早期検知が可能になったことで大きなコスト削減効果が得られました。

そして、これまで人の手で面倒みていた業務が不要となり、且つ新規のプロジェクトを作成したら自動で監視対象になることで本仕組みの運用において別途人的リソースを必要としないため、人材の有効活用に繋がりましたし、プロジェクト管理者(事業部)においてもコスト意識の向上にも繋がりましたので、目に見えるコスト削減それ以上の効果があったと考えます。

最後に

かなりボリュームのある内容のため、監視の条件や宛先をカスタマイジングしたり、実際は運用するにあたって必要になり得る機能について、用意はしてあるのですが、当記事にはスキップとさせていただきました。その辺りも含め aws も似たような環境下で運用しているので、また機会があれば紹介させていただきたいと思います。

今回紹介させていただいた監視について、様々な課題や改善余地がありますが、GCP プロジェクトコスト監視の導入の参考になれば幸いです。