はじめに:手作業のデータセット管理は、どこで詰まるか

BigQueryは最初のうち、Webコンソールで気軽に触れるのが魅力です。データセットを作って、テーブルをひとつ足して、便利なビューを保存しておく。中小ECの分析でも、この手軽さで十分にスタートできます。

ところが、運用が半年、一年と続くと、だんだん次のような状態になってきます。

  • どのデータセットを誰が何のために作ったのか、もう誰も覚えていない
  • 「このビューの定義、いつ変えたんだっけ」が追えない
  • 本番と検証で、テーブルのスキーマが微妙にズレている
  • パーティション設定や有効期限が、テーブルごとにバラバラ

つまり、構成の現状と、その変更の履歴が、どこにも残っていないわけです。クエリの中身はGitで管理していても、その土台になるデータセットやテーブルの「形」は、コンソールの操作ログに埋もれてしまいます。

この記事では、その土台をコードで管理する方法として Terraform を紹介します。Terraformを使うと、BigQueryのデータセット・テーブル・ビュー・関数(ルーティン)を、すべてHCLというコードで定義し、Gitで履歴を残しながら運用できます。いわゆる IaC(Infrastructure as Code)です。

データの中身(パイプラインや集計ロジック)の整理については、関連記事もあわせてどうぞ。

IaC化すると、何がうれしいのか

Terraform でBigQueryの構成を管理すると、手作業のときに抜け落ちていたものが、いくつも戻ってきます。

  • 変更履歴がGitに残る:データセットの追加、カラムの追加、ビュー定義の書き換えが、すべてコミットとして残ります。git log を見れば、いつ・誰が・なぜ変えたかを追えます。
  • 本番と検証を同じコードから作れる:同じHCLを使い回せば、prod と dev で同一構成を再現でき、環境差分による事故を減らせます。
  • レビューしてから反映できるterraform plan で適用前に差分を確認できるので、プルリクエストでレビューしてから apply する流れに乗せられます。
  • 消したものも記録される:不要なテーブルの削除もコードの差分として残り、「気づいたら無くなっていたテーブル」が生まれにくくなります。

準備:プロバイダの設定

まずはGoogle Cloudプロバイダを宣言します。バージョンは執筆時点での記述例なので、実際に使うときは公式レジストリ(registry.terraform.io)で最新の安定版を確認してください。

terraform {
  required_version = ">= 1.5"

  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "~> 6.0"
    }
  }
}

provider "google" {
  project = var.project_id
  region  = "asia-northeast1"
}

variable "project_id" {
  type        = string
  description = "対象のGCPプロジェクトID"
}

region を東京(asia-northeast1)にしている例ですが、データセットのロケーションは別途指定します。両者を混同しないよう注意してください。

データセットを定義する:google_bigquery_dataset

最初にデータセットを作ります。dataset_idlocation が実質的な要となる引数です。

resource "google_bigquery_dataset" "mart" {
  dataset_id    = "mart"
  friendly_name = "データマート"
  description   = "集計済みのレポート用テーブルを置く層"
  location      = "asia-northeast1"

  # テーブルのデフォルト有効期限(ミリ秒)。ここでは90日。
  default_table_expiration_ms = 90 * 24 * 60 * 60 * 1000

  labels = {
    env   = "prod"
    owner = "data-team"
  }
}

location はデータセット作成後に変更できません。後から変えたい場合は作り直しになるため、最初に決めておきます。labels を付けておくと、コスト管理や棚卸しのときに役立ちます。

テーブルを定義する:google_bigquery_table

次にテーブルです。スキーマはJSON文字列で渡します。schema を別ファイルに切り出しておくと、見通しが良くなります。

resource "google_bigquery_table" "orders" {
  dataset_id = google_bigquery_dataset.mart.dataset_id
  table_id   = "orders"

  # パーティション設定:注文日でパーティションを切る
  time_partitioning {
    type  = "DAY"
    field = "order_date"
  }

  # クラスタリングで絞り込みを速くする
  clustering = ["channel", "prefecture"]

  schema = jsonencode([
    {
      name = "order_id"
      type = "STRING"
      mode = "REQUIRED"
    },
    {
      name = "order_date"
      type = "DATE"
      mode = "REQUIRED"
    },
    {
      name = "channel"
      type = "STRING"
      mode = "NULLABLE"
    },
    {
      name = "amount"
      type = "NUMERIC"
      mode = "NULLABLE"
    }
  ])

  # 誤って消さないための保険
  deletion_protection = true
}

deletion_protectiontrue にしておくと、terraform destroy などで中身のあるテーブルをうっかり消そうとしたときに止めてくれます。本番テーブルでは付けておくと安心です。

なお、テーブルの依存関係はTerraformが自動で解決します。上の例では google_bigquery_dataset.mart.dataset_id を参照しているので、データセットが先に作られてからテーブルが作られます。

ビューを定義する:テーブルリソースの view ブロック

BigQueryのビューは、専用リソースではなく google_bigquery_tableview ブロックで定義します。これは少し直感に反するので覚えておいてください。

resource "google_bigquery_table" "monthly_sales" {
  dataset_id = google_bigquery_dataset.mart.dataset_id
  table_id   = "monthly_sales"

  view {
    query = <<-SQL
      SELECT
        FORMAT_DATE('%Y-%m', order_date) AS month,
        channel,
        SUM(amount) AS total_amount,
        COUNT(*) AS order_count
      FROM `${var.project_id}.mart.orders`
      GROUP BY month, channel
    SQL

    use_legacy_sql = false
  }
}

use_legacy_sql = false を必ず指定します。これを忘れると古い構文(レガシーSQL)として扱われ、標準SQLのクエリが通らなくなります。ビューのSQLからテーブルを参照する場合、Terraform側では暗黙の依存が読み取れないことがあるため、必要に応じて depends_on を足して作成順序を担保します。

関数を定義する:google_bigquery_routine

ユーザー定義関数(UDF)やストアドプロシージャは google_bigquery_routine で管理します。routine_type には SCALAR_FUNCTION / PROCEDURE / TABLE_VALUED_FUNCTION を指定できます。ここでは、税込価格を計算する単純なスカラー関数の例です。

resource "google_bigquery_routine" "with_tax" {
  dataset_id   = google_bigquery_dataset.mart.dataset_id
  routine_id   = "with_tax"
  routine_type = "SCALAR_FUNCTION"
  language     = "SQL"

  arguments {
    name      = "price"
    data_type = jsonencode({ typeKind = "NUMERIC" })
  }

  return_type     = jsonencode({ typeKind = "NUMERIC" })
  definition_body = "CAST(ROUND(price * 1.1) AS NUMERIC)"

  description = "税率10%を加えた税込価格を返す"
}

data_typereturn_type はJSON文字列で型を指定します。definition_bodyAS 句の中身、つまり関数本体の式です。プロシージャを作る場合は routine_type = "PROCEDURE" にして、definition_bodyBEGIN ... END のブロックを書きます。

plan と apply、そして state の扱い

定義が書けたら、適用の流れです。

# プロバイダなどを初期化(最初の一回 + 設定変更時)
terraform init

# 何が変わるかを確認(適用はしない)
terraform plan

# 確認した内容を実際に反映する
terraform apply

terraform plan は、コードと実際のBigQueryの状態を突き合わせて差分だけを表示します。新しく作られるものには +、変更されるものには ~、消されるものには - が付きます。apply の前にこの差分を必ず読み、とくに - が出ているときは本当に消していいか立ち止まってください。

state ファイルは共有・ロックする

Terraformは管理対象の現在の状態を state ファイルterraform.tfstate)に記録します。このファイルの扱いには注意が必要です。

  • ローカルに置きっぱなしにしない。 複数人で触ると state が食い違い、構成が壊れます。
  • GCS(Cloud Storage)などのリモートバックエンドに置く。 共有できて、変更時にロックがかかるため、同時適用の事故を防げます。
  • state を直接手で編集しない。 整合性が崩れる原因になります。

リモートバックエンドの設定例です。

terraform {
  backend "gcs" {
    bucket = "my-terraform-state"
    prefix = "bigquery/prod"
  }
}

state にはデータセット名などの構成情報が含まれます。バケットのアクセス権は絞り、誰でも読める状態にしないでください。

CIで運用する:レビューしてから反映する流れ

ひとりで使う分には手元で apply しても回りますが、チームで使うならCIに乗せると安全度が一段上がります。基本の形は次のとおりです。

  1. プルリクエスト時に terraform plan を自動実行し、差分を結果として残す。レビュアーは「何が変わるか」をPR上で確認できます。
  2. main へのマージ時にだけ terraform apply を実行する。人手の手元適用をなくし、反映の経路を一本化します。
  3. 実行用のサービスアカウントには、必要最小限の権限だけを与える。BigQueryの管理に必要なロールに絞り、広すぎる権限は避けます。

この流れにすると、「いきなり本番をいじる」操作がなくなり、すべての変更がPRとして記録されます。plan の結果がPRに出るので、レビューしながら反映できるのが大きな利点です。

権限設計そのものについては、別記事で詳しく扱っています。サービスアカウントのロール設計に迷ったら、あわせて読んでみてください。

まとめ

手作業で積み上げたBigQueryの構成は、便利な反面、変更の履歴も全体像も残らないという弱点を抱えています。Terraformで IaC化すると、その弱点をまとめて埋められます。

  • データセット・テーブル・ビュー・関数を、すべてコードで定義できる
  • 変更がGitの履歴として残り、いつ・誰が・なぜ変えたかを追える
  • plan で差分を確認してから apply できるので、事故を防ぎやすい
  • 本番と検証を同じコードから作れて、環境差分が減る
  • state をリモートで共有・ロックすれば、チーム運用にも耐える

最初からすべてをコード化する必要はありません。まずは新しく作るデータセットひとつをTerraform管理にしてみて、planapply の感触をつかむところから始めるのがおすすめです。土台が再現可能になると、その上に載るパイプラインやレポートの信頼性も、おのずと上がっていきます。