Terraform + GitHub でデータマート基盤を作った話

こんにちは。TVer でデータ分析をしている高橋です。
こちらは TVer Advent Calendar 2024 の3日目の記事です。
2日目の記事は @takanamito さんの connect-goでHTTP GETリクエストを受け取る でした。
この記事では分析環境を効率化するために弊社で活用している、Terraform と GitHub を使ったデータマート基盤をご紹介します。

開発のきっかけ

これまで分析業務は、データレイクに集約された生ログを都度前処理し、個別の集計作業を行っていました。クエリ作成のたびに手作業でロジックを組み立てるか過去のクエリからロジックをコピペするような状況で、あまり作業効率が良いとは言えません。
時々前処理が刷新されることがあり、全員で過去のクエリを全て修正するというマンパワーに頼った運用をしていました。
またスケジュールクエリの管理もコンソール上での手作業が中心で、更新作業はコンフルやスクショに証跡を残しながら行っており何とも手間がかかる状態でした。

......

そのような状況の中、昨年からダッシュボード構築などのニーズが高まり、コスト観点からデータマート開発が求められました。そこでコスト観点とともに前述の作業効率化と品質向上を目指し、汎用的なデータマート基盤を構築することにしました。
サンプルのリポジトリはこちらです。

github.com

基盤の構成と運用

以下はディレクトリ構成です。

├─.github
│  └─workflows
├─scheduled_query
│  └─dataset.sample_mart
├─scripts
├─table_valued_function
├─terraform
│  ├─environments
│  │  ├─devintegrate
│  │  ├─production
│  │  └─staging
│  └─modules
├─user_defined_function
└─view

スケジュールクエリとテーブルを作成する流れを例に、具体的な手順を以下に示します。

  1. ディレクトリ作成
    scheduled_query配下にリソースごとのディレクトリを作成(例: dataset.sample_mart)。
  2. 必要ファイルの作成
    • query.sql: 定期実行するクエリ
    • settings.json: スケジュールクエリやテーブルの設定
    • schema.json: テーブルスキーマ(テーブルを作成する場合のみ)
  3. Terraform コマンド実行
cd terraform
terraform init -reconfigure -backend-config=./environments/devintegrate/dev.tfbackend
terraform apply -var-file ./environments/devintegrate/terraform.tfvars

工夫したこと

Terraform の知識不要で簡単リソース作成

query.sqlsettings.jsonschema.jsonの3つのファイルを用意するだけでリソース作成が可能です。Terraform の Data Sourceを活用し、ディレクトリ配下の設定情報を動的に読み込む仕組みを導入してこれを実現しています。
以下は、settings.json を収集するスクリプトの例です。

BASE_PATH=$1
declare -A queries
for settings_file in $(find "$BASE_PATH" -name 'settings.json'); do
    key=$(basename "$(dirname "$settings_file")")
    content=$(jq 'if has("dataset_id") then 
                    .dataset_id |= if type == "object" then . 
                    else {prd: ., stg: ., dev: .}
                    end 
                  else . end | @json' "$settings_file")
    queries["$key"]=$content
done
result="{"
for key in "${!queries[@]}"; do
    result+="\"$key\": ${queries[$key]},"
done
result="${result%,}}"
echo $result

Terraform でスクリプトを実行し、リソース情報をローカル変数として扱えるようにしています。

data "external" "scheduled_query_config" {
  program = ["bash", "${path.module}/../scripts/list_queries.sh", "${path.module}/../scheduled_query"]
}

locals {
  scheduled_queries = { for k, v in data.external.scheduled_query_config.result : k => jsondecode(v) }
}

環境ごとの設定管理を簡略化

environments ディレクトリに各環境の設定をまとめ、共通するリソースは再利用可能にしています。これにより、開発・検証・本番といった複数環境を扱う場合でも効率よくメンテナンスを行うことが可能になっています。

例: devintegrate環境の設定ファイル

# dev.tfbackend
bucket = "sample-datamart-bucket-dev"
prefix = "sample-datamart"

# terraform.tfvars
environment       = "dev"
project           = "project-dev"

service_account_email = "datamart-creator@project-dev.iam.gserviceaccount.com"

GitHub Actions を活用した CI/CD の自動化

GitHub Actions を利用して以下のような作業を自動化しています。

  1. terraform plan の結果を出力
    プルリクエストを作成すると、GitHub Actions が terraform plan の実行と対応する Actions のリンクをプルリクエストに投稿します。これにより差分確認が容易になりレビューがスムーズになります。
  2. mainブランチへのマージ後、自動で環境ごとのブランチへプルリクエスト作成
    開発ブランチからmainブランチに変更がマージされると、GitHub Actions が自動で各環境に対応するブランチ(例: devintegrate, staging, production)へのプルリクエストを作成します。
  3. 各環境へのプルリクエストマージ時に terraform apply を自動実行
    環境ごとのブランチへのプルリクエストがマージされると、GitHub Actions が terraform apply を実行して該当環境に変更を反映します。これにより簡単にデプロイ作業を行うことができます。

また、既存リソースを Terraform 管理したい場合もあります。これは import ブロックを使って取り込みを自動化しました。
各環境の terraform.tfvarsにインポートするリソース名とidを記述し、main.tfimport ブロックで呼び出します。
ちなみに import ブロック内での for_each は Terraform 1.7 以降で利用可能です

# terraform/environments/production/terraform.tfvars
~~~

import_resources = {
    "table": [
        {
            "resource_name": "dataset.sample_mart",
            "id": "projects/{project}/datasets/{dataset_id}/tables/{table_id}"
        }
    ],
    "view": [
        {}
    ],
    "scheduled_query": [
        {}
    ],
    "tvf": [
        {}
    ]
}

# terraform/main.tf
import {
    for_each = { for v in var.import_resources.table : v.resource_name => v if length(var.import_resources.table) > 0 }
    id       = each.value.id
    to       = module.bigquery_resources.google_bigquery_table.tables[each.value.resource_name]
}

導入の効果

以下の表は、導入前後の作業比較です。導入前に比べて多くの作業が効率化されました。やったー!

作業内容 導入前 導入後
スケジュールクエリやViewの更新 更新前後のクエリをコンフルに貼り付けてレビュー。レビュー通過後クエリをコンソールにコピペ 開発ブランチで更新してプルリク作成。レビュー通過後マージ
汎用的な処理の管理 以前のクエリからコピペ or Redash のスニペットを利用 UDF や TVF を利用
汎用処理を更新 関連クエリを全て手作業で修正 プルリク→マージ

また、スケジュールクエリの管理が容易になったことで分析用の中間テーブルが数多く生成され、こちらも作業効率化やコスト削減にも貢献しています。

課題

このように様々な作業の効率化が進んでいる一方で、この基盤には以下の課題が残っています。

依存関係が管理できない

例えば新規にテーブルとそれを参照するViewを同時に作成する場合、先に View の生成が実行されて参照先が見つからずエラーになることがあります。
本来であれば Terraformの depends_on 引数を使って依存関係を指定すれば解決できますが、この引数は静的参照しか受け付けておらず利用できません。
このため現状は GitHub Actions でエラー発生時に再実行することで対応していますが、これはあくまで暫定的な方法です。
また、中間テーブルをもとに別の中間テーブルを作成するようなケースでは依存関係が複雑化し、管理が困難になる点も課題です。

デプロイ前の確認が不十分

現在の CI/CD パイプラインでは、terraform plan の出力と terraform applyの実行確認しか行っておらず、以下の観点での確認は出来ていません。

  1. SQLクエリの構文的な正しさ 構文が間違った状態のままデプロイし、実行エラーで初めて気付くといったことがしばしばありました。
  2. SQLクエリの出力内容の正しさ SQLの実行環境がないため、前処理が正しく行えているかや、確からしい集計値が得られているかなどの確認ができません。

これらの課題に対応するために、現在も継続して検討を行っています。
例えば以下のような方針を検討しています。

  • クエリをコピペ実行可能な状態で管理
    • query.sql にはコピペ実行可能な形式で記述し、デプロイ時に変数を上書きすることでクエリ実行のハードルを下げる
  • Redash との連携
    • プルリクエストに query.sql が含まれている場合、Redash 上にそのクエリを自動生成する
  • dataformに乗り換える
    • 上記に加え依存関係の管理も可能であり、現在仕様調査を進めています

おわりに

データマートの管理方法は、世の中に多種多様な手法が存在します。本記事では、その中の一例として弊社が採用した基盤の構築と運用方法をご紹介しました。
この記事がみなさんの運用負荷を軽減し、より効率的なデータマート管理のヒントとなれば幸いです。

明日の記事は @_kurose さんの「BigQueryのExternal Tableのスキーマ変更に対応する方法の一つ」です!お楽しみに!

qiita.com