はじめに:手作業のデータセット管理は、どこで詰まるか
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_id と location が実質的な要となる引数です。
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_protection を true にしておくと、terraform destroy などで中身のあるテーブルをうっかり消そうとしたときに止めてくれます。本番テーブルでは付けておくと安心です。
なお、テーブルの依存関係はTerraformが自動で解決します。上の例では google_bigquery_dataset.mart.dataset_id を参照しているので、データセットが先に作られてからテーブルが作られます。
ビューを定義する:テーブルリソースの view ブロック
BigQueryのビューは、専用リソースではなく google_bigquery_table の view ブロックで定義します。これは少し直感に反するので覚えておいてください。
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_type と return_type はJSON文字列で型を指定します。definition_body は AS 句の中身、つまり関数本体の式です。プロシージャを作る場合は routine_type = "PROCEDURE" にして、definition_body に BEGIN ... 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に乗せると安全度が一段上がります。基本の形は次のとおりです。
- プルリクエスト時に
terraform planを自動実行し、差分を結果として残す。レビュアーは「何が変わるか」をPR上で確認できます。 mainへのマージ時にだけterraform applyを実行する。人手の手元適用をなくし、反映の経路を一本化します。- 実行用のサービスアカウントには、必要最小限の権限だけを与える。BigQueryの管理に必要なロールに絞り、広すぎる権限は避けます。
この流れにすると、「いきなり本番をいじる」操作がなくなり、すべての変更がPRとして記録されます。plan の結果がPRに出るので、レビューしながら反映できるのが大きな利点です。
権限設計そのものについては、別記事で詳しく扱っています。サービスアカウントのロール設計に迷ったら、あわせて読んでみてください。
まとめ
手作業で積み上げたBigQueryの構成は、便利な反面、変更の履歴も全体像も残らないという弱点を抱えています。Terraformで IaC化すると、その弱点をまとめて埋められます。
- データセット・テーブル・ビュー・関数を、すべてコードで定義できる
- 変更がGitの履歴として残り、いつ・誰が・なぜ変えたかを追える
planで差分を確認してからapplyできるので、事故を防ぎやすい- 本番と検証を同じコードから作れて、環境差分が減る
- state をリモートで共有・ロックすれば、チーム運用にも耐える
最初からすべてをコード化する必要はありません。まずは新しく作るデータセットひとつをTerraform管理にしてみて、plan と apply の感触をつかむところから始めるのがおすすめです。土台が再現可能になると、その上に載るパイプラインやレポートの信頼性も、おのずと上がっていきます。