はじめに
こんにちは!ピクシブ株式会社でデータ基盤を担当しているkashiraです。
ピクシブでは、全社員の約40%が月に一度はクエリを実行するなど、職種を問わずBigQueryを積極的に活用しています。その結果、データ量は2023年9月時点で7.8PiBに達するなど、その規模は年々拡大し続けています。 speakerdeck.com
このようにデータ活用が進む一方で、そのコスト規模も大きいので、コスト最適化は経営陣からも期待される重要なテーマとなっています。これまではパフォーマンスやSLAの観点からスロットコミットメントを購入していました。更新作業のタイミングが迫っていたこともあり、今回は、コスト削減の観点からもスロットコミットメント量を最適化する取り組みに着手しました。
しかし、実際に見積もりを進めようとすると「一体、どのプランを・どれだけ購入すれば最も効果的なのか?」という課題に直面しました。この記事では、同じような課題を抱える方々に向けて、私たちが実践した、最適なスロットコミットメント量を見積もるための手法をご紹介します。
BigQueryの料金体系は変更される可能性があります。この記事の情報は2025年6月時点のものです。必ずご自身で最新の公式ドキュメントをご確認ください。
コスト最適化を進める上での課題
私たちの課題は、費用対効果のシミュレーションが困難だった点に尽きます。
Google Cloudには費用最適化を提案してくれる、 BigQuery Slot Recommendationsが存在しますが、この機能にはいくつかの制約があり、私たちが求める詳細な分析には不十分でした。
Slot Recommendationsの主な制約と、私たちが独自分析で実現したかったことは以下です。
- 分析の柔軟性
- 分析期間
- 過去30日しか考慮されない
- スロット量
- 将来のワークロードも考慮した、任意のスロット量を設定できない
- プラン
- 1年契約、3年契約の設定を切り替える事ができない
- 分析期間
- 計算式のブラックボックス化
- BigQuery Slot Recommendationsの計算式が公開されていないので、考慮漏れにより意図しないコスト増に繋がることが懸念された
- 過大な権限要求
- 費用合計の確認に強すぎるBilling Accountsの権限が必要
これらの理由から、私たちはINFORMATION_SCHEMAのデータを元に、自分たちでシミュレーションを行うアプローチを選択しました。
INFORMATION_SCHEMAに基づいた見積もり手順
ここからは、私たちが実践した具体的な見積もり手順を紹介します。
1. シミュレーションSQLを使って、コスト変化額を見積もる
まず、INFORMATION_SCHEMAのスロット履歴データを使い、現状のオートスケール環境でどれだけのコストがかかっているかを把握します。そして、公式のスロットに関するSQLを参考に、「もし特定の量のスロットコミットメントを購入していたら、コストはどうなっていたか」をシミュレーションするSQLを作成しました。 cloud.google.com
このシミュレーションの基本的な考え方は、以下の図の通りです。Baseline Slots(スロットコミットメント)を購入するとその分の固定料金は発生しますが、代わりに割高なAutoscale Slotsの使用量が減り、その分の費用が削減されます。スロットコミットメントは単価が安いため、結果的にオートスケールで消費していた費用よりもスロットコミットメントの固定料金が下回れば、その差額がコスト削減につながる、という仕組みです。
【シミュレーションの前提】
- BigQuery Editions(容量課金モデル)を利用していること
- 過去のスロット使用量に基づいて費用を算出するため、分析に必要な利用履歴(INFORMATION_SCHEMA)が記録されていることが前提
- スロットコミットメントは全てベースラインに割り当てていること
- アイドルスロットが全ての予約間で共有する設定になっていること
【シミュレーションSQL】
以下の変数を自身の環境に合わせて設定することで、費用削減額を見積もることができます。
- start_time, end_time: シミュレーション対象期間
- edition_to_check: 対象のEdition
- purchase_commitments: 購入を検討しているスロットコミットメント量
- ...unit_price: 各種単価(ご自身の契約に基づく)
シミュレーションのSQL (長いので、必要に応じて展開してください)
-- variables DECLARE start_time, end_time TIMESTAMP; DECLARE edition_to_check STRING; DECLARE purchase_commitments NUMERIC; DECLARE before_commitment_edition_unit_price, after_commitment_edition_unit_price, before_pay_as_you_go_edition_unit_price, after_pay_as_you_go_edition_unit_price NUMERIC; /* Google uses Pacific Time to calculate the billing period for all customers, regardless of their time zone. Use the following format if you want to match the billing report. Change the start_time and end_time values to match the desired window. */ /* The following three variables (start_time, end_time, and edition_to_check) are the only variables that you need to set in the script. During daylight savings time, the start_time and end_time variables should follow this format: 2024-02-20 00:00:00-08. */ SET start_time = "2025-05-01 00:00:00-07"; SET end_time = "2025-06-01 00:00:00-07"; SET edition_to_check = 'ENTERPRISE'; /* 購入予定のスロットコミットメント量と、BigQueryのスロット時間の単価 */ SET purchase_commitments = ; SET before_commitment_edition_unit_price = 0.048; SET after_commitment_edition_unit_price = 0.036; SET before_pay_as_you_go_edition_unit_price = 0.060; SET after_pay_as_you_go_edition_unit_price = 0.060; /* The following function returns the slot seconds for the time window between two capacity changes. For example, if there are 100 slots between (2023-06-01 10:00:00, 2023-06-01 11:00:00), then during that window the total slot seconds will be 100 * 3600. This script calculates a specific window (based on the variables defined above), which is why the following script includes script_start_timestamp_unix_millis and script_end_timestamp_unix_millis. */ CREATE TEMP FUNCTION GetSlotSecondsBetweenChanges( slots FLOAT64, range_begin_timestamp_unix_millis FLOAT64, range_end_timestamp_unix_millis FLOAT64, script_start_timestamp_unix_millis FLOAT64, script_end_timestamp_unix_millis FLOAT64) RETURNS INT64 LANGUAGE js AS r""" if (script_end_timestamp_unix_millis < range_begin_timestamp_unix_millis || script_start_timestamp_unix_millis > range_end_timestamp_unix_millis) { return 0; } var begin = Math.max(script_start_timestamp_unix_millis, range_begin_timestamp_unix_millis) var end = Math.min(script_end_timestamp_unix_millis, range_end_timestamp_unix_millis) return slots * Math.ceil((end - begin) / 1000.0) """; /* Sample RESERVATION_CHANGES data (unrelated columns ignored): +---------------------+------------------+--------+---------------+---------------+ | change_timestamp | reservation_name | action | slot_capacity | current_slots | +---------------------+------------------+--------+---------------+---------------+ | 2023-07-27 22:24:15 | res1 | CREATE | 300 | 0 | | 2023-07-27 22:25:21 | res1 | UPDATE | 300 | 180 | | 2023-07-27 22:39:14 | res1 | UPDATE | 300 | 100 | | 2023-07-27 22:40:20 | res2 | CREATE | 300 | 0 | | 2023-07-27 22:54:18 | res2 | UPDATE | 300 | 120 | | 2023-07-27 22:55:23 | res1 | UPDATE | 300 | 0 | Sample CAPACITY_COMMITMENT_CHANGES data (unrelated columns ignored): +---------------------+------------------------+-----------------+--------+------------+--------+ | change_timestamp | capacity_commitment_id | commitment_plan | state | slot_count | action | +---------------------+------------------------+-----------------+--------+------------+--------+ | 2023-07-20 19:30:27 | 12954109101902401697 | ANNUAL | ACTIVE | 100 | CREATE | | 2023-07-27 22:29:21 | 11445583810276646822 | FLEX | ACTIVE | 100 | CREATE | | 2023-07-27 23:10:06 | 7341455530498381779 | MONTHLY | ACTIVE | 100 | CREATE | */ WITH /* The scaled_slots & baseline change history: +---------------------+------------------+------------------------------+---------------------+ | change_timestamp | reservation_name | autoscale_current_slot_delta | baseline_slot_delta | +---------------------+------------------+------------------------------+---------------------+ | 2023-07-27 22:24:15 | res1 | 0 | 300 | | 2023-07-27 22:25:21 | res1 | 180 | 0 | | 2023-07-27 22:39:14 | res1 | -80 | 0 | | 2023-07-27 22:40:20 | res2 | 0 | 300 | | 2023-07-27 22:54:18 | res2 | 120 | 0 | | 2023-07-27 22:55:23 | res1 | -100 | 0 | */ reservation_slot_data AS ( SELECT change_timestamp, reservation_name, CASE action WHEN "CREATE" THEN autoscale.current_slots WHEN "UPDATE" THEN IFNULL( autoscale.current_slots - LAG(autoscale.current_slots) OVER ( PARTITION BY project_id, reservation_name ORDER BY change_timestamp ASC, action ASC ), IFNULL( autoscale.current_slots, IFNULL( -1 * LAG(autoscale.current_slots) OVER ( PARTITION BY project_id, reservation_name ORDER BY change_timestamp ASC, action ASC ), 0))) WHEN "DELETE" THEN IF( LAG(action) OVER ( PARTITION BY project_id, reservation_name ORDER BY change_timestamp ASC, action ASC ) IN UNNEST(['CREATE', 'UPDATE']), -1 * autoscale.current_slots, 0) END AS autoscale_current_slot_delta, CASE action WHEN "CREATE" THEN slot_capacity WHEN "UPDATE" THEN IFNULL( slot_capacity - LAG(slot_capacity) OVER ( PARTITION BY project_id, reservation_name ORDER BY change_timestamp ASC, action ASC ), IFNULL( slot_capacity, IFNULL( -1 * LAG(slot_capacity) OVER ( PARTITION BY project_id, reservation_name ORDER BY change_timestamp ASC, action ASC ), 0))) WHEN "DELETE" THEN IF( LAG(action) OVER ( PARTITION BY project_id, reservation_name ORDER BY change_timestamp ASC, action ASC ) IN UNNEST(['CREATE', 'UPDATE']), -1 * slot_capacity, 0) END AS baseline_slot_delta, FROM `region-us.INFORMATION_SCHEMA.RESERVATION_CHANGES` WHERE edition = edition_to_check AND change_timestamp <= end_time ), -- Convert the above to running total /* +---------------------+-------------------------+----------------+ | change_timestamp | autoscale_current_slots | baseline_slots | +---------------------+-------------------------+----------------+ | 2023-07-27 22:24:15 | 0 | 300 | | 2023-07-27 22:25:21 | 180 | 300 | | 2023-07-27 22:39:14 | 100 | 300 | | 2023-07-27 22:40:20 | 100 | 600 | | 2023-07-27 22:54:18 | 220 | 600 | | 2023-07-27 22:55:23 | 120 | 600 | */ running_reservation_slot_data AS ( SELECT change_timestamp, SUM(autoscale_current_slot_delta) OVER (ORDER BY change_timestamp RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS autoscale_current_slots, SUM(baseline_slot_delta) OVER (ORDER BY change_timestamp RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS baseline_slots, FROM reservation_slot_data ), /* The committed_slots change history. For example: +---------------------+------------------------+------------------+ | change_timestamp | capacity_commitment_id | slot_count_delta | +---------------------+------------------------+------------------+ | 2023-07-20 19:30:27 | 12954109101902401697 | 100 | | 2023-07-27 22:29:21 | 11445583810276646822 | 100 | | 2023-07-27 23:10:06 | 7341455530498381779 | 100 | */ capacity_commitment_slot_data AS ( SELECT change_timestamp, capacity_commitment_id, CASE WHEN action = "CREATE" OR action = "UPDATE" THEN IFNULL( IF( LAG(action) OVER ( PARTITION BY capacity_commitment_id ORDER BY change_timestamp ASC, action ASC ) IN UNNEST(['CREATE', 'UPDATE']), slot_count - LAG(slot_count) OVER ( PARTITION BY capacity_commitment_id ORDER BY change_timestamp ASC, action ASC ), slot_count), slot_count) ELSE IF( LAG(action) OVER (PARTITION BY capacity_commitment_id ORDER BY change_timestamp ASC, action ASC) IN UNNEST(['CREATE', 'UPDATE']), -1 * slot_count, 0) END AS slot_count_delta FROM `region-us.INFORMATION_SCHEMA.CAPACITY_COMMITMENT_CHANGES_BY_PROJECT` WHERE state = "ACTIVE" AND edition = edition_to_check AND change_timestamp <= end_time ), /* The total_committed_slots history. For example: +---------------------+---------------+ | change_timestamp | capacity_slot | +---------------------+---------------+ | 2023-07-20 19:30:27 | 100 | | 2023-07-27 22:29:21 | 200 | | 2023-07-27 23:10:06 | 300 | */ running_capacity_commitment_slot_data AS ( SELECT change_timestamp, SUM(slot_count_delta) OVER (ORDER BY change_timestamp RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS capacity_slot FROM capacity_commitment_slot_data ), /* Add next_change_timestamp to the above data, which will be used when joining with reservation data. For example: +---------------------+-----------------------+---------------+ | change_timestamp | next_change_timestamp | capacity_slot | +---------------------+-----------------------+---------------+ | 2023-07-20 19:30:27 | 2023-07-27 22:29:21 | 100 | | 2023-07-27 22:29:21 | 2023-07-27 23:10:06 | 200 | | 2023-07-27 23:10:06 | 2023-07-31 00:14:37 | 300 | */ running_capacity_commitment_slot_data_with_next_change AS ( SELECT change_timestamp, IFNULL(LEAD(change_timestamp) OVER (ORDER BY change_timestamp ASC), CURRENT_TIMESTAMP()) AS next_change_timestamp, capacity_slot FROM running_capacity_commitment_slot_data ), /* Whenever we have a change in reservations or commitments, the scaled_slots_and_baseline_not_covered_by_commitments will be changed. Hence we get a collection of all the change_timestamp from both tables. +---------------------+ | change_timestamp | +---------------------+ | 2023-07-20 19:30:27 | | 2023-07-27 22:24:15 | | 2023-07-27 22:25:21 | | 2023-07-27 22:29:21 | | 2023-07-27 22:39:14 | | 2023-07-27 22:40:20 | | 2023-07-27 22:54:18 | | 2023-07-27 22:55:23 | | 2023-07-27 23:10:06 | */ merged_timestamp AS ( SELECT change_timestamp FROM running_reservation_slot_data UNION DISTINCT SELECT change_timestamp FROM running_capacity_commitment_slot_data ), /* Change running reservation-slots and make sure we have one row when commitment changes. +---------------------+-------------------------+----------------+ | change_timestamp | autoscale_current_slots | baseline_slots | +---------------------+-------------------------+----------------+ | 2023-07-20 19:30:27 | 0 | 0 | | 2023-07-27 22:24:15 | 0 | 300 | | 2023-07-27 22:25:21 | 180 | 300 | | 2023-07-27 22:29:21 | 180 | 300 | | 2023-07-27 22:39:14 | 100 | 300 | | 2023-07-27 22:40:20 | 100 | 600 | | 2023-07-27 22:54:18 | 220 | 600 | | 2023-07-27 22:55:23 | 120 | 600 | | 2023-07-27 23:10:06 | 120 | 600 | */ running_reservation_slot_data_with_merged_timestamp AS ( SELECT change_timestamp, IFNULL( autoscale_current_slots, IFNULL( LAST_VALUE(autoscale_current_slots IGNORE NULLS) OVER (ORDER BY change_timestamp ASC), 0)) AS autoscale_current_slots, IFNULL( baseline_slots, IFNULL(LAST_VALUE(baseline_slots IGNORE NULLS) OVER (ORDER BY change_timestamp ASC), 0)) AS baseline_slots FROM running_reservation_slot_data RIGHT JOIN merged_timestamp USING (change_timestamp) ), /* Join the above, so that we will know the number for baseline not covered by commitments. +---------------------+-----------------------+-------------------------+------------------------------------+ | change_timestamp | next_change_timestamp | autoscale_current_slots | baseline_not_covered_by_commitment | +---------------------+-----------------------+-------------------------+------------------------------------+ | 2023-07-20 19:30:27 | 2023-07-27 22:24:15 | 0 | 0 | | 2023-07-27 22:24:15 | 2023-07-27 22:25:21 | 0 | 200 | | 2023-07-27 22:25:21 | 2023-07-27 22:29:21 | 180 | 200 | | 2023-07-27 22:29:21 | 2023-07-27 22:39:14 | 180 | 100 | | 2023-07-27 22:39:14 | 2023-07-27 22:40:20 | 100 | 100 | | 2023-07-27 22:40:20 | 2023-07-27 22:54:18 | 100 | 400 | | 2023-07-27 22:54:18 | 2023-07-27 22:55:23 | 220 | 400 | | 2023-07-27 22:55:23 | 2023-07-27 23:10:06 | 120 | 400 | | 2023-07-27 23:10:06 | 2023-07-31 00:16:07 | 120 | 300 | */ scaled_slots_and_baseline_not_covered_by_commitments AS ( SELECT r.change_timestamp, IFNULL(LEAD(r.change_timestamp) OVER (ORDER BY r.change_timestamp ASC), CURRENT_TIMESTAMP()) AS next_change_timestamp, r.autoscale_current_slots, IF( r.baseline_slots - IFNULL(c.capacity_slot, 0) > 0, r.baseline_slots - IFNULL(c.capacity_slot, 0), 0) AS baseline_not_covered_by_commitment, IFNULL(c.capacity_slot, 0) AS capacity_slot FROM running_reservation_slot_data_with_merged_timestamp r LEFT JOIN running_capacity_commitment_slot_data_with_next_change c ON r.change_timestamp >= c.change_timestamp AND r.change_timestamp < c.next_change_timestamp ), /* The slot_seconds between each changes. For example: +---------------------+-----------------------------------+--------------------------------+------------------------------------+ | change_timestamp | before_pay_as_you_go_slot_seconds | before_commitment_slot_seconds | additional_commitment_slot_seconds | +---------------------+-----------------------------------+--------------------------------+------------------------------------+ | 2023-07-20 19:30:27 | 0 | 200 | 200 | | 2023-07-27 22:24:15 | 13400 | 379 | 499 | | 2023-07-27 22:25:21 | 91580 | 238 | 949 | */ slot_seconds_data AS ( SELECT change_timestamp, GetSlotSecondsBetweenChanges( autoscale_current_slots + baseline_not_covered_by_commitment, UNIX_MILLIS(change_timestamp), UNIX_MILLIS(next_change_timestamp), UNIX_MILLIS(start_time), UNIX_MILLIS(end_time)) AS before_pay_as_you_go_slot_seconds, GetSlotSecondsBetweenChanges( capacity_slot, UNIX_MILLIS(change_timestamp), UNIX_MILLIS(next_change_timestamp), UNIX_MILLIS(start_time), UNIX_MILLIS(end_time)) AS before_commitment_slot_seconds, /*購入したcommitmentsを全てbaselineとして割り当てる想定*/ GetSlotSecondsBetweenChanges( purchase_commitments, UNIX_MILLIS(change_timestamp), UNIX_MILLIS(next_change_timestamp), UNIX_MILLIS(start_time), UNIX_MILLIS(end_time)) AS additional_commitment_slot_seconds, FROM scaled_slots_and_baseline_not_covered_by_commitments WHERE change_timestamp <= end_time AND next_change_timestamp > start_time ), /* スロットを買うことでの変化を計算する +---------------------+-----------------------------------+--------------------------------+----------------------------------+-------------------------------+ | change_timestamp | before_pay_as_you_go_slot_seconds | before_commitment_slot_seconds | after_pay_as_you_go_slot_seconds | after_commitment_slot_seconds | +---------------------+-----------------------------------+--------------------------------+----------------------------------+-------------------------------+ | 2023-07-20 19:30:27 | 400 | 200 | 200| 400 | */ simulate_after AS ( SELECT change_timestamp, before_pay_as_you_go_slot_seconds, before_commitment_slot_seconds, IF( before_pay_as_you_go_slot_seconds >= additional_commitment_slot_seconds, before_pay_as_you_go_slot_seconds - additional_commitment_slot_seconds, 0 ) AS after_pay_as_you_go_slot_seconds, before_commitment_slot_seconds + additional_commitment_slot_seconds AS after_commitment_slot_seconds FROM slot_seconds_data ), /* ドルに変換する. */ calc_dollar AS ( SELECT SUM(before_pay_as_you_go_slot_seconds) / 3600 AS before_pay_as_you_go_slot_hour, SUM(before_commitment_slot_seconds) / 3600 AS before_commitment_slot_hour, SUM(after_pay_as_you_go_slot_seconds) / 3600 AS after_pay_as_you_go_slot_hour, SUM(after_commitment_slot_seconds) / 3600 AS after_commitment_slot_hour, before_pay_as_you_go_edition_unit_price * SUM(before_pay_as_you_go_slot_seconds) / 3600 AS before_pay_as_you_go_dollar, before_commitment_edition_unit_price * SUM(before_commitment_slot_seconds) / 3600 AS before_commitment_dollar, after_pay_as_you_go_edition_unit_price * SUM(after_pay_as_you_go_slot_seconds) / 3600 AS after_pay_as_you_go_dollar, after_commitment_edition_unit_price * SUM(after_commitment_slot_seconds) / 3600 AS after_commitment_dollar FROM simulate_after ) /* Final result for this example: */ SELECT *, ( after_pay_as_you_go_dollar + after_commitment_dollar ) - ( before_pay_as_you_go_dollar + before_commitment_dollar ) AS diff_dollar FROM calc_dollar
2. 複数パターンの比較表で最適なプランを検討する
次に、SQLのpurchase_commitments(購入量)やunit_price(契約プランごとの単価)の変数を変更しながら、複数パターンのシミュレーションを繰り返します。
その結果を以下のような比較表にまとめることで、チーム内での議論や意思決定がスムーズに進みました。
購入スロットコミットメント量 | 契約プラン | 利用Edition | 削減見込み額 (月額) | 備考 |
---|---|---|---|---|
500 | 1年契約 | Enterprise | -$XXXX | |
1000 | 1年契約 | Enterprise | -$YYYY | |
1000 | 3年契約 | Enterprise | -$ZZZZ | 割引率が最も高い |
1500 | 3年契約 | Enterprise | -$AAAA | 削減額は大きいが、将来の利用増加を考慮 |
まとめ
今回の取り組みにおける最大のメリットは、自分たちでシミュレーションすることで自社の事情を最大限考慮した上で、自信を持って意思決定ができたことです。実際に削減された金額も見込み通りでした。
BigQueryの利用が進んできた場合に、スロットコミットメントの購入はコスト最適化のための非常に有効な手段です。この記事で紹介した、データに基づいたシミュレーションや比較検討の方法が、皆さんのコスト最適化の一助となれば幸いです。