はじめに:請求書を見て驚く前に

BigQueryを使い始めてしばらくすると、ふと不安になる瞬間があります。「先月より請求額が増えている気がするけれど、何が原因だろう?」というあのモヤモヤです。

BigQueryのオンデマンド課金は、クエリが読み込んだデータ量(スキャン量)に応じて決まります。普段の集計が無料枠の範囲に収まっていても、誰かが SELECT * で巨大テーブルを全期間スキャンしてしまったり、重いクエリを何度も流してしまったりすると、その月だけ請求がぽんと跳ねることがあります。

問題は、こうした「事故」が起きても、月末の請求書を見るまで気づきにくいことです。請求書には「合計いくら」しか出てこないので、「誰の・どのクエリが原因だったのか」は分かりません。

ここで役立つのが INFORMATION_SCHEMA です。BigQueryは自分自身のクエリ実行履歴を、SQLで読めるビューとして公開してくれています。これを使えば「いつ・誰が・どんなクエリを流して・何バイトスキャンしたか」を、追加ツールなしで見える化できます。

この記事では、INFORMATION_SCHEMA.JOBS の中身を確認し、高コストクエリやユーザー別のスキャン量を集計するSQL、そしてそれを日次で監視テーブルにためてしきい値超過をSlackに通知する仕組みまでを順に紹介します。なお、コストを「そもそも増やさない」設計の話はBigQueryのクエリコストを月1万円以下に抑える7つの実践テクニックにまとめています。あわせてどうぞ。

INFORMATION_SCHEMA.JOBS とは

INFORMATION_SCHEMA は、BigQueryのメタデータ(テーブル定義やジョブ履歴など)をSQLで参照できるようにした仕組みです。その中でもコスト監視に使うのが、クエリの実行履歴を持つ JOBS 系のビュー です。

代表的なものは次の3つです。

  • INFORMATION_SCHEMA.JOBS_BY_PROJECT — そのプロジェクトで実行されたジョブを参照します。単に JOBS と書いた場合もこれを指します。
  • INFORMATION_SCHEMA.JOBS_BY_USER — 実行したユーザー本人のジョブだけを参照します。
  • INFORMATION_SCHEMA.JOBS_BY_ORGANIZATION — 組織全体のジョブを参照します。

中小ECや個人事業の規模では、まず JOBS_BY_PROJECT(自分のプロジェクトで動いたクエリ全部を見られるビュー)を押さえれば十分です。

このビューには、コスト分析に使える列がそろっています。よく使うものを挙げておきます。

  • creation_time — ジョブが作られた時刻。日付での絞り込みに使います。
  • user_email — 実行したユーザー(またはサービスアカウント)。
  • job_type / statement_type — ジョブ種別と文の種類(QUERYSELECT など)。
  • total_bytes_processed — そのクエリがスキャンしたバイト数。コストの直接の原因になる値です。
  • total_bytes_billed — 課金対象として計上されたバイト数。
  • query — 実行されたSQL本文。原因クエリの特定に役立ちます。
  • error_result — 失敗時の情報。失敗ジョブの監視にも使えます。

コスト監視の主役は total_bytes_processed(または total_bytes_billed)です。これを集計すれば「どのクエリが・どれだけ読んだか」が分かります。

補足: INFORMATION_SCHEMA.JOBS ビューを参照すること自体は、データのスキャンが発生しないためコストの心配はほとんどありません。履歴が保持される期間には上限がある(おおむね直近180日程度)ので、長期で残したい場合は後述の監視テーブルにためていくのがおすすめです。

高コストクエリ・ユーザー別を集計するSQL

それでは実際の集計クエリを見ていきます。まずは「直近7日間で、スキャン量が多かったクエリ ワースト20」を出すSQLです。

SELECT
  creation_time,
  user_email,
  job_id,
  -- スキャン量をGB・概算コスト(USD)に変換
  ROUND(total_bytes_processed / POW(1024, 3), 2) AS scanned_gb,
  ROUND(total_bytes_processed / POW(1024, 4) * 6.25, 4) AS est_cost_usd,
  -- クエリ本文は長いので先頭だけ
  SUBSTR(query, 0, 120) AS query_head
FROM
  `region-asia-northeast1`.INFORMATION_SCHEMA.JOBS_BY_PROJECT
WHERE
  creation_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 7 DAY)
  AND job_type = 'QUERY'
  AND statement_type != 'SCRIPT'   -- 親スクリプトの二重計上を避ける
  AND total_bytes_processed IS NOT NULL
ORDER BY
  total_bytes_processed DESC
LIMIT 20;

ポイントを補足します。

  • region-asia-northeast1 がリージョン指定 です。INFORMATION_SCHEMA.JOBS はリージョンごとに分かれているため、データセットを置いているリージョン(東京なら asia-northeast1)を region- の接頭辞つきで指定する必要があります。ここを間違えると「結果が空っぽ」になります。
  • est_cost_usd はあくまで概算です。1TB あたり約 $6.25 という目安で計算していますが、実際の料金は契約や時期で変わります(次のとおりです)。

補足: 本記事の概算コストは 「1TBのスキャンあたり 約 $6.25」「毎月最初の1TBは無料」 という2026年時点のおおよその目安で計算しています。料金はリージョンや時期で変わるため、実際の金額は必ず公式の最新料金表で確認してください。

次に「ユーザー(サービスアカウント含む)別の、直近30日のスキャン量合計」です。誰の利用が多いのかをつかむのに使います。

SELECT
  user_email,
  COUNT(*) AS query_count,
  ROUND(SUM(total_bytes_processed) / POW(1024, 4), 3) AS scanned_tb,
  ROUND(SUM(total_bytes_processed) / POW(1024, 4) * 6.25, 2) AS est_cost_usd
FROM
  `region-asia-northeast1`.INFORMATION_SCHEMA.JOBS_BY_PROJECT
WHERE
  creation_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 30 DAY)
  AND job_type = 'QUERY'
  AND statement_type != 'SCRIPT'
GROUP BY
  user_email
ORDER BY
  scanned_tb DESC;

このクエリを定期的に眺めるだけでも、「特定のダッシュボードの自動更新が重い」「あるサービスアカウントだけスキャン量が突出している」といった偏りに気づけます。原因が分かれば、列を絞る・パーティションを使う・中間テーブルを挟むといった対策につなげられます。

日次で監視テーブルにためて、しきい値超過を通知する

INFORMATION_SCHEMA.JOBS の履歴は保持期間に上限があるため、長期でコストの推移を追いたいなら、日次の集計結果を専用テーブルにためていくのが確実です。ここでは次の流れで組みます。

  1. 前日分のジョブを日次サマリーとして集計し、監視テーブルに追記する
  2. その集計の中で、しきい値を超えた日や利用者がいたら通知する

1. 日次サマリーを監視テーブルにためる

まず、ためる先のテーブルを用意します。

CREATE TABLE IF NOT EXISTS `mart.bq_cost_daily` (
  usage_date    DATE,
  user_email    STRING,
  query_count   INT64,
  scanned_bytes INT64,
  est_cost_usd  FLOAT64
);

次に、前日分を集計してこのテーブルへ追記するクエリです。これをスケジュールクエリで毎朝1回流す想定です。

INSERT INTO `mart.bq_cost_daily`
SELECT
  DATE(creation_time, 'Asia/Tokyo') AS usage_date,
  user_email,
  COUNT(*) AS query_count,
  SUM(total_bytes_processed) AS scanned_bytes,
  ROUND(SUM(total_bytes_processed) / POW(1024, 4) * 6.25, 2) AS est_cost_usd
FROM
  `region-asia-northeast1`.INFORMATION_SCHEMA.JOBS_BY_PROJECT
WHERE
  -- 前日(日本時間)の分だけを対象にする
  DATE(creation_time, 'Asia/Tokyo') = DATE_SUB(CURRENT_DATE('Asia/Tokyo'), INTERVAL 1 DAY)
  AND job_type = 'QUERY'
  AND statement_type != 'SCRIPT'
  AND total_bytes_processed IS NOT NULL
GROUP BY
  usage_date, user_email;

これで「日付 × ユーザー」の粒度で、コストの履歴が手元に残り続けます。Looker Studioやスプレッドシートにつなげればコストのグラフをいつでも見られます。

スケジュールクエリの設定や、失敗を見逃さない監視の組み方はBigQueryのスケジュールクエリでデータマートを毎朝自動更新する設定と監視方法にまとめています。

2. しきい値超過をSlackに通知する

ためたデータを使って、「昨日のプロジェクト全体のスキャン量が一定を超えたら知らせる」仕組みを作ります。やり方はいくつかありますが、ここでは比較的シンプルな「クエリで超過を判定 → Slackに投稿」の構成を紹介します。

まず、超過判定のクエリです。前日合計が 0.5TB を超えたかどうかを返します。

SELECT
  usage_date,
  ROUND(SUM(scanned_bytes) / POW(1024, 4), 3) AS scanned_tb,
  ROUND(SUM(est_cost_usd), 2) AS est_cost_usd,
  SUM(scanned_bytes) > 0.5 * POW(1024, 4) AS is_over_threshold
FROM
  `mart.bq_cost_daily`
WHERE
  usage_date = DATE_SUB(CURRENT_DATE('Asia/Tokyo'), INTERVAL 1 DAY)
GROUP BY
  usage_date;

この結果を受け取って、is_over_thresholdtrue のときだけSlackに投稿します。Slack側で Incoming Webhook のURLを発行しておけば、軽いスクリプトから通知を送れます。

import json
import urllib.request
from google.cloud import bigquery

WEBHOOK_URL = "https://hooks.slack.com/services/XXXX/YYYY/ZZZZ"  # 実際の値は環境変数などで管理する

client = bigquery.Client()
sql = """
SELECT usage_date,
       ROUND(SUM(scanned_bytes) / POW(1024, 4), 3) AS scanned_tb,
       SUM(scanned_bytes) > 0.5 * POW(1024, 4) AS is_over
FROM `mart.bq_cost_daily`
WHERE usage_date = DATE_SUB(CURRENT_DATE('Asia/Tokyo'), INTERVAL 1 DAY)
GROUP BY usage_date
"""

row = next(iter(client.query(sql).result()), None)
if row and row["is_over"]:
    text = (
        f":warning: BigQueryスキャン量が基準を超えました\n"
        f"日付: {row['usage_date']} / スキャン: {row['scanned_tb']} TB"
    )
    req = urllib.request.Request(
        WEBHOOK_URL,
        data=json.dumps({"text": text}).encode("utf-8"),
        headers={"Content-Type": "application/json"},
    )
    urllib.request.urlopen(req)

このスクリプトをCloud Functionsや手元のサーバーから毎朝1回呼び出せば、「昨日のスキャン量が基準を超えた日だけ」Slackに通知が飛びます。何も起きない日は静かなので、通知が来た日だけ原因クエリを INFORMATION_SCHEMA.JOBS で掘り下げればよく、運用の負担も小さく済みます。

しきい値(ここでは 0.5TB)は、普段のスキャン量を1〜2週間ほど観察してから、平常時の少し上に設定するのがコツです。最初から厳しくしすぎると毎日通知が来て、かえって見なくなってしまいます。

補足: Webhook URLは秘密情報です。コードに直書きせず、環境変数やSecret Managerなどで管理してください。本記事の例は仕組みを説明するためのもので、そのまま本番に置くことは想定していません。

注意点:リージョンとビューの選び方

最後に、つまずきやすいポイントを2つ挙げておきます。

1つめはリージョンです。 INFORMATION_SCHEMA.JOBS はリージョンごとに分かれています。`region-asia-northeast1` のように、データセットを置いているリージョンを指定しないと結果が返りません。「クエリは通るのに行が1件も出ない」というときは、たいていリージョン指定の間違いです。複数リージョンにデータがある場合は、それぞれのリージョンを別々に参照する必要があります。

2つめはビューの選択です。 JOBS_BY_PROJECTJOBS_BY_USERJOBS_BY_ORGANIZATION は見える範囲が異なります。プロジェクト全体のコストを把握したいなら JOBS_BY_PROJECT、自分の分だけでよいなら JOBS_BY_USER です。JOBS_BY_PROJECT を参照するには相応の権限(プロジェクトに対する閲覧権限など)が必要なので、権限エラーが出る場合は付与状況を確認してください。

加えて、statement_type = 'SCRIPT' の行はスクリプト全体の親ジョブで、子クエリと二重に集計されてしまうことがあります。スキャン量を合計するときは、本記事の例のように SCRIPT を除外しておくと安全です。

まとめ

BigQueryのコストは「見えないから不安」になりがちですが、INFORMATION_SCHEMA.JOBS を使えば「誰の・どのクエリが・どれだけスキャンしたか」をSQLだけで見える化できます。

  • INFORMATION_SCHEMA.JOBS_BY_PROJECT で実行履歴を参照でき、total_bytes_processed がコストの直接の手がかりになる
  • 高コストクエリ ワースト・ユーザー別合計を集計すれば、コスト増の原因に当たりをつけられる
  • 日次サマリーを監視テーブルにためれば、保持期間に縛られず長期の推移を追える
  • しきい値超過のSlack通知を組めば、請求書を見る前に異常へ気づける
  • リージョン指定とビューの選び方が定番のつまずきポイント

まずは高コストクエリを出すSQLを一度流してみるところから始めてみてください。自分のプロジェクトで何が重いのかが分かるだけでも、コスト管理の景色がずいぶん変わります。料金の数字は目安なので、実運用の前には必ず公式の最新情報をあわせて確認してくださいね。