FinOps FOCUS dbt BigQuery データ基盤 データエンジニアリング

クラウドから物理サーバーまでのコストを共通スキーマFOCUSで統一して可視化する

FinOps FOCUS dbt BigQuery データ基盤 データエンジニアリング

技術部データ基盤チームの@zaimyです。

ペパボでは、クラウドや物理サーバーなど複数のプラットフォームにまたがるコストを社内ダッシュボードで可視化しています。このダッシュボードに供給するコストデータを、FinOps Foundation1が策定する共通仕様FOCUSに沿って正規化する層をdbtで構築しました。その結果、ダッシュボード側のSQLは約10分の1(数百行から数十行)に縮小し、新しいコストソースの追加が容易な定型作業になりました。

FOCUSを実際のデータ基盤に適用した事例はまだ少ないようです。この記事では、コスト可視化をFinOpsの共通スキーマで統一したいデータエンジニアやSREの方に向けて、正規化層の設計判断と、共通スキーマに揃えたことで何が容易になったかを紹介します。

  1. ペパボのコスト可視化基盤
  2. 課題はソースごとにバラバラなコストデータ
  3. FOCUSとは
  4. FOCUSコスト層の設計
    1. 課金期間はソースのnativeな粒度のまま持つ
    2. native通貨を保持し、換算列を併設する
    3. 組織固有の軸もx_拡張カラムに乗せる
    4. データマートも請求の最小粒度を保持する
  5. 新しいコストの追加が定型作業になった
    1. クラウド以外のコストも同じ受け皿に乗る
    2. 共通スキーマの上に機能が積み上がる
  6. 得られたものと工夫のまとめ
  7. まとめ

ペパボのコスト可視化基盤

前提として、ペパボのコスト可視化の構成を簡単に紹介します。社内データ基盤「Bigfoot」に各プラットフォームのコストデータを収集し、dbtで変換してBigQueryのテーブルとして提供しています。可視化はCloud Run上のStreamlitで運用している社内ダッシュボード基盤が担っています。コスト用のダッシュボード(「Bigfootを使ったbillingの可視化」ということで社内では「Billingfoot」と呼んでいます)が、BigQueryのコストテーブルを参照する構成です。

コストの発生源は従来8種類ありました。Google CloudやAWSなどのパブリッククラウド、社内で構築したプライベートクラウド、データセンターの物理サーバー、種々のSaaSやIaaSです。これらをひとつのダッシュボードで横断的に見ていました。

課題はソースごとにバラバラなコストデータ

問題は、これらのコストデータが「各ソースのAPIから取得した形のまま」BigQueryに置かれていたことです。命名や構造はソースごとに独自でした。たとえばコスト額のカラム名だけを見ても、cost/cost_usd/total_usd/billed_costとバラバラです。期間の持ち方も日次明細から月次請求、請求サイクル単位まで様々で、通貨はUSD建てとJPY建てが混在しています。

この状態でダッシュボードに8ソースを並べるため、ダッシュボード側のSQLがすべての差異を吸収していました。ソースごとにCTEを作ってカラム名を読み替え、月次請求は日次に按分し、USDは為替テーブルでJPYに換算して、最後にUNIONするという構造です。当初は2ソース程度だったため、ダッシュボードのSQLが直接参照する構成で作られていました。その後ソースが増え、このSQLは8ソース時点で数百行に達していました。ソースを1つ追加するたびにCTEが1つ増える構造なので、構造を変えない限りこの行数は今後も増え続けるしかありません。今後全社のコスト管理を進めていく上で、Billingfootが参照するコストの発生源は増える一方ですから、放置すればSQLは数千行に向かっていくことになります。

さらに、コストの分類軸(どの事業部のコストか)はダッシュボード側のYAMLファイルが請求アカウント名の文字列から引き当てており、データ変換のロジックがdbtとダッシュボードの2箇所に分裂していました。

この構造の実務上の問題は、新しいコストソースを1つ追加するたびに、変換ロジックを作る必要があることです。カラムの読み替え、期間の按分、通貨の換算、事業部の引き当てをソースごとに一から設計し直す必要があり、追加のコストが下がりません。また、変換ロジックはダッシュボードのSQLに埋まっているため、dbtのlineageやテストの対象外でした。

FOCUSとは

そこで採用したのがFOCUS(FinOps Open Cost and Usage Specification)です。FOCUSは、FinOps Foundationが策定しているクラウド請求データの共通仕様です2

クラウドベンダーの請求データは、ベンダーごとにスキーマが異なります。FOCUSは、これを横断して扱うための共通カラムを定義しています。たとえば次のようなカラムがあります。

  • BilledCost:請求書に載る金額
  • EffectiveCost:割引やクレジットを反映した実効コスト
  • ChargePeriodStart / ChargePeriodEnd:その課金が対応する期間
  • ServiceCategory:ComputeやStorageといったベンダー横断のサービス分類
  • BillingAccountId / BillingAccountName:請求アカウント

主要クラウドベンダー自身もFOCUS形式での請求データエクスポートを提供し始めており、マルチクラウドのコスト管理では共通語彙としての地位を固めつつあります。

私たちにとってFOCUSが魅力的だったのは、カラム定義そのものよりも「クラウドコストというドメインの語彙が仕様として整理されている」ことでした。請求額と実効コストの区別、請求期間と課金期間の区別、ベンダー横断のサービス分類など、コストデータを正規化するときに自分たちで決めなければならない論点に、参照できる基準が用意されています。仕様がカバーしていないデータであっても「FOCUSで言うとどうなるか」という指針を持って、自分たちでマッピングを判断できます。

FOCUSコスト層の設計

各ソースのコストデータをFOCUSの共通カラムへ正規化する層をdbtに作り、ダッシュボードはその最終出力だけを参照する構成にしました。

source(各コストソースのrawテーブル)
      ↓
stg / int(ソースごとにFOCUS共通列へ正規化。全モデルが同一の列セットを出力)
      ↓
int_focus_costs(全ソースをUNIONし、サービス分類・事業部・換算列を付与)
      ↓
mart_focus_costs(ダッシュボードが参照する唯一のマート)

ポイントは、ソースごとの正規化モデルが「同一の列セットを出力する」という規約です。UNIONする側のモデルは各ソースのモデルを列挙して機械的にUNIONするだけで、ソース固有の事情を一切知りません。

{% set focus_cost_source_models = [
    ref("billing__stg_google_cloud_costs"),
    ref("billing__int_aws_focus_costs"),
    -- (中略)
] %}

WITH costs AS (
    {% for relation in focus_cost_source_models %}
        SELECT
            provider_name,
            service_name,
            billing_account_id,
            billing_currency,
            billed_cost,
            effective_cost,
            charge_period_start,
            charge_period_end,
            -- (中略)
        FROM {{ relation }}
        {% if not loop.last %}UNION ALL{% endif %}
    {% endfor %}
)

カラム名はdbtの慣習に合わせてsnake_caseとし、FOCUS公式のカラム名(BilledCostなど)はカラムのdescriptionに併記しています。

設計では判断に迷う点がいくつかありました。FOCUSを適用してみたからこそ言えることだと思うので、順に紹介します。

課金期間はソースのnativeな粒度のまま持つ

当初は、月次請求のソース(SaaSや物理サーバーなど)をFOCUS層で日次に按分し、全ソースを日次に揃えることを考えていました。ダッシュボードは日次でコストを表示するためです。

しかし、日次表示はあくまでダッシュボードの表示都合であって、コストデータ自体の性質ではありません。FOCUSのChargePeriodも「その課金が実際に対応する期間」を表す仕様なので、月次請求を日次に割った時点で仕様から離れてしまいます。月あたり約30倍に行数が膨らみ、billed_costが日次の端数になって請求書と突き合わせられなくなるという実務上の扱いづらさもあります。

そこでFOCUS層は「日次明細のソースは日次、月次請求のソースは月次」というnativeな粒度をそのまま保持し、日次への按分は表示側の責務としました。按分といっても、charge_period_startcharge_period_endが期間を表しているので、表示側のSQLは期間の日数で割って日付を展開するだけです。

SELECT
    expanded_date,
    SUM(
        billed_cost
        / DATE_DIFF(charge_period_end, charge_period_start, DAY)
    ) AS daily_cost,
FROM
    billing.mart_focus_costs,
    UNNEST(GENERATE_DATE_ARRAY(
        charge_period_start,
        DATE_SUB(charge_period_end, INTERVAL 1 DAY)
    )) AS expanded_date
GROUP BY expanded_date

かつてはこの按分ロジックがソースごとに8通り存在していました。期間の持ち方をFOCUSに揃えた結果、1つに共通化できました。

native通貨を保持し、換算列を併設する

USD建てのソースをJPYに換算して1つの通貨に統一してしまうと、為替レートの変動とコスト実体の増減が区別できなくなります。円安でJPY額が膨らんだのか、利用が増えたのかを切り分けたい場面は、コストの振り返りで頻繁にあります。

そこでbilled_cost/effective_costは換算前のnative通貨のまま持ち、billing_currencyで通貨を示すことにしました。その上で、JPYとUSDそれぞれへ正規化した換算列を併設しています。これはFOCUSの流儀にも沿っています。FOCUSは仕様外のカスタムカラムをx_プレフィックスで持つことを認めているためです。換算列はx_billed_cost_jpy/x_billed_cost_usdのような拡張カラムとして定義しています。

組織固有の軸もx_拡張カラムに乗せる

コストを事業部別に見るための軸はFOCUSの仕様にはありません。これもx_departmentという拡張カラムとしてFOCUS層で付与することにし、請求アカウントから事業部を引き当てるマッピングをdbtのseedに移しました。ダッシュボード側のYAMLで行っていた処理がdbtのlineageに乗り、コスト分類のロジックが1箇所に集約されました。

ServiceCategoryも同様にseedで管理しています。各ソースのサービス分類(AWSのserviceCode、Google Cloudのservice.descriptionなど)からFOCUS標準の分類への対応表を、単一のseedとして持ちます。これで、クラウドと物理サーバーをComputeという同じカテゴリで横並びに比較できます。

データマートも請求の最小粒度を保持する

FOCUS層の最終マートは、ダッシュボードの表示単位に集計せず、各ソースが持つ最も細かい粒度(resource_id/sku_id/charge_description込み)で保持しています。集計はいつでもできますが、逆は戻せないためです。ダッシュボードで異常なコスト増を見つけたとき、どのリソースのどのSKUが増えたのかまで同じマートで掘れる状態を保っています。

新しいコストの追加が定型作業になった

FOCUS層を構築してダッシュボードを切り替えた結果、冒頭に書いたとおりダッシュボード側のSQLは数百行から数十行になり、通貨換算テーブルの参照と事業部マッピングのYAMLも撤去できました。行数の削減以上に大きいのは、この数十行はコストのソースがいくつ増えても変わらないという点です。ソースの差異はFOCUS層が吸収するので、表示側のSQLはマートを期間で絞って按分するだけの形のまま変わりません。

新しいコストソースを追加する際の作業は「FOCUSへの変換モデルを1つ書いてUNIONのリストに足す」という定型の内容になりました。たとえば新しいSaaSのコストを取り込むモデルは、実質これだけです。

SELECT
    "SaaS Name" AS provider_name,
    "SaaS Name" AS service_name,
    monthly_costs.note AS charge_description,
    "JPY" AS billing_currency,
    CAST(monthly_costs.amount_jpy AS FLOAT64) AS billed_cost,
    CAST(monthly_costs.amount_jpy AS FLOAT64) AS effective_cost,
    monthly_costs.charge_month AS charge_period_start,
    DATE_ADD(monthly_costs.charge_month, INTERVAL 1 MONTH) AS charge_period_end,
    CONCAT("saasname-", monthly_costs.department) AS billing_account_id,
    -- 取れないカラムはNULLで埋める
    CAST(NULL AS STRING) AS resource_id,
    CAST(NULL AS STRING) AS sku_id,
FROM monthly_costs

期間按分、通貨換算、事業部の引き当てはいずれもFOCUS層の共通処理が引き受けるので、ソース側のモデルはカラムの対応だけを書けば済みます。実際、このパターンに沿って、さらに8つのソースからのコストデータが次々と追加されているところです。

クラウド以外のコストも同じ受け皿に乗る

FOCUSはクラウド請求データの共通仕様ですが、BillingfootではAPIから直接請求データを得られないコストや、クラウド以外のコストも適用範囲に含めています。

たとえば、APIでは請求額を取得できないSaaSのコストは、経理システムの会計データから月次の実額をdbt seedに投入する方式で取り込んでいます。また、物理サーバーのコストや決済代行の手数料のようなクラウド以外のコストも、同じFOCUSのカラムに対応づけるだけでBillingfootで扱うコストデータとして合流できます。仕様が想定する対象から外れていても「請求額はどれか。課金期間はどこからどこまでか。請求アカウントに相当する単位は何か」というFOCUSの問いに答えれば受け皿に乗せられます。

共通スキーマの上に機能が積み上がる

軸が統一されたことで、マートの上に機能を追加することが容易になりました。

  • サービス分類のseedにAIコスト判定のフラグを1列足すだけで、全ソース横断の「AI関連コスト」の推移がダッシュボードで見られるようになった
  • 予算データをFOCUSと同じ軸(事業部、プロバイダー)でseedに持つことで、予算と実績の突き合わせがFOCUS層の延長で成立する

得られたものと工夫のまとめ

この取り組みで得られたものを整理します。

  • ダッシュボード側のSQLが数百行から数十行になり、ソースが増えてもSQLは一定のままになった。変換ロジックがdbtのlineage・テストの対象に乗った
  • 新しいコストソースの追加が、FOCUSへの変換モデル1つを書く定型作業になった
  • 通貨・期間・分類の扱いがソース横断で一貫し、為替と実コストの切り分けやカテゴリ横断の比較ができるようになった
  • AIコスト集計や予算対比のような横断機能を、共通スキーマの上に小さな差分で追加できるようになった

工夫として挙げたいのは、FOCUSに追従する範囲の線引きです。課金期間や通貨は仕様に忠実にnativeを保持し、日次按分やJPY換算のような表示都合・組織都合はx_拡張カラムと表示側の責務に逃がしました。仕様に完全準拠したエクスポートを作ることが目的ではありません。共通語彙として活用できる部分をFOCUSから採用し、組織固有の要件は仕様が用意している拡張の作法か表示側の責務に振り分ける、という距離感が運用しやすいと感じています。

また、この規模の変更を短期間で進められたのは、dbtでデータ変換してStreamlitで表示するという基盤が既にあったからです。FOCUS層の追加は「dbtのモデルとして正規化層を足し、参照先を切り替える」という、基盤の通常の開発サイクルに乗る作業でした。コスト可視化を専用のツールで作り込むのではなく、社内データ基盤の一部として開発・運用してきたことが、共通スキーマへの移行を容易にしてくれたと思います。

まとめ

複数のプラットフォームにまたがるコストデータを、FOCUS共通スキーマを活用し、dbtで正規化して統一しました。バラバラのスキーマを都度読み替えるコストが消え、新しいコストの取り込みと横断機能の追加が定型化されました。コスト可視化のスキーマ設計に悩んでいる方、複数クラウドのコストを横断して扱いたい方の参考になれば幸いです。

  1. https://www.finops.org/ 

  2. https://focus.finops.org/ で仕様が公開されています。