Rego でマイグレーション SQL を分割して稼働中のシステムに適用する

この記事は BBSakura Networks アドベントカレンダー2023の16日目の記事です。 adventar.org

書いているのは BBSakura Networks 株式会社 金井(@masu_mi, @masu-mi.bsky.social)です。

今回は OPA/Rego(以後、 Rego) を自作ツール(ddl-planner) に組み込んで利用してみたので振り返りたいと思います。

この記事は Rego を試してみたいから始まっています。また一般にテーブルマイグレーションはアプリケーション実装やデータベースの機能を熟慮しないと危険です。そのため今回の方法は汎用的なアプローチと言えません。

daichirata/hammer(以後、hammer) というツールは Google Cloud Spanner(以後、 Spanner) のテーブルマイグレーション支援ツールです。マイグレーション前後のテーブル定義からマイグレーション SQL を生成してくれます。しかし単純に差を埋める SQL を生成するため稼働中のシステムには適用できません。

そこで自作ツール(ddl-planner)を使ってみました。与えられたマイグレーションを稼働中に適用できるように3ステップに分解しステップごとのマイグレーション SQL ファイルを生成します。

自作ツールではポリシー記述言語および処理系である Rego を利用します。

まず動かしてみる

hammer が生成するマイグレーション SQLddl-planner で分割してみます。

hammerSpanner 用テーブルマイグレーション支援ツールです。データベースからスキーマを取得したり、変更を適用したりと色々な機能があります。今回は新旧のテーブル定義を比較して変更に必要な SQL を生成する diff サブコマンドを使います。

hammer でマイグレーション SQL を生成する

次のような更新を実験してみます。

更新前 ER図

erDiagram

ExampleAlter {
    INT64 Id PK
    INT64 Col_1 "NOT NULL"
    INT64 Col_2
    STRING(MAX) Col_3
}

OutOfScope {
    INT64 IdA PK
    STRING(MAX) Meta
}

ExampleRecreated {
    INT64 IdA PK,FK
    INT64 IdB PK
    INT64 IdC PK
    STRING(MAX) Meta
}
OutOfScope ||--o{ ExampleRecreated : "INTEREAVE IN PARENT"

更新後 ER図

erDiagram

ExampleAlter {
    INT64 Id PK
    INT64 Col_1 ": NOT NULL が外れたよ"
    STRING(MAX) Col_3
}

OutOfScope {
    INT64 IdA PK
    STRING(MAX) Meta
}

ExampleRecreated {
    INT64 IdA PK,FK
    INT64 IdB PK
    STRING(MAX) Meta
}
OutOfScope ||--o{ ExampleRecreated : "INTEREAVE IN PARENT"

つぎのように新旧定義 DDL ファイルからマイグレーション SQL を生成します。

hammer diff ./example/old.ddl ./example/new.ddl > ./example/hammer-gen.ddl

生成ファイル (hammer-gen.sql) はつぎのようになっています。 DROP 文や CREATE 文が一つにまとめられていて稼働しているシステムで行うとデータが消えたり、アプリケーションが期待する列を見つけられなくなり失敗しそうだとわかります。

hammer-gen.sql

ALTER TABLE ExampleAlter ALTER COLUMN Col_1 INT64;
ALTER TABLE ExampleAlter DROP COLUMN Col_3;
ALTER TABLE ExampleAlter ADD COLUMN Col_3 INT64;
ALTER TABLE ExampleAlter DROP COLUMN Col_2;
DROP TABLE ExampleRecreated;
CREATE TABLE ExampleRecreated (
  IdA INT64 NOT NULL,
  IdB INT64 NOT NULL,
  Meta STRING(MAX),
) PRIMARY KEY(IdA, IdB),
  INTERLEAVE IN PARENT OutOfScope ON DELETE NO ACTION;

hammer は便利で変更前として spanner://projects/proj_name/instances/instance_name/databases/db_name のようにデータベースのリソースIDを使って直接指定できます。

ddl-planner で分割する

ddl-planner はマイグレーション SQL を3つのフェーズ(prepare, recreate, cleanup)に分割します。それぞれに対応した SQL ファイルは指定したディレクトリ配下に置かれます。

./ddl-planner -input ./example/hammer-gen.ddl -output ./dist

上のように呼び出すことでつぎのように分割されます。

ls ./dist
0_prepare.ddl  1_recreate.ddl  2_cleanup.ddl

0_prepare.ddl

ALTER TABLE ExampleAlter ALTER COLUMN Col_1 INT64;
CREATE TABLE Tmp_ExampleRecreated (IdA INT64 NOT NULL, IdB INT64 NOT NULL, Meta STRING(MAX)) PRIMARY KEY (IdA, IdB), INTERLEAVE IN PARENT OutOfScope ON DELETE NO ACTION;
ALTER TABLE ExampleAlter ADD COLUMN Tmp_Col_3 INT64;

1_recreate.ddl

ALTER TABLE ExampleAlter DROP COLUMN Col_2;
DROP TABLE ExampleRecreated;
ALTER TABLE ExampleAlter DROP COLUMN Col_3;
CREATE TABLE ExampleRecreated (IdA INT64 NOT NULL, IdB INT64 NOT NULL, Meta STRING(MAX)) PRIMARY KEY (IdA, IdB), INTERLEAVE IN PARENT OutOfScope ON DELETE NO ACTION;
ALTER TABLE ExampleAlter ADD COLUMN Col_3 INT64;

2_cleanup.ddl

DROP TABLE Tmp_ExampleRecreated;
ALTER TABLE ExampleAlter DROP COLUMN Tmp_Col_3;

それぞれのステップでの変更は追加かアプリケーション側で利用しなくなった要素の削除に限定されるため、ステップ適用前にアプリケーションのバージョンアップとデータマイグレーションを挟めばシステムを稼働しつつテーブル定義を更新できると考えています。

ddl-planner のなかのはなし

よくあるテキスト処理です。

  1. memefish を使い SQL をパースする
  2. 得られた AST に含まれる SQL 文を Rego をつかい分類する
  3. SQL 文の分類にもとづいて実行クエリをそれぞれのフェーズに登録する
  4. それぞれのフェーズの SQL ファイルを生成する

SQL パーサー(memefish)

Rego の評価する SQLAST について説明します。 memefish はGoで書かれた SpannerSQL パーサーです。ドキュメントはそれほど充実しているわけではないため公式サンプルコードを読むと早いです。ほかには ZetaSQL もありますが Go にバインドするのに苦労しそうです。

使うのには、クエリ文字列を Buffer に与えた *memefish.Lexer*memefish.Parser に渡して ().ParseDDLs() などを呼ぶだけです。(FilePath フィールドは構文エラーの表示などデバッグ用メタ情報なので test.sql などでも大丈夫です)

parser := &memefish.Parser{
        Lexer: &memefish.Lexer{File: &token.File{
                FilePath: "test.sql",
                Buffer:   "DROP TABLE foo; DROP TABLE bar;",
        }},
}
asts, _ := parser.ParseDDLs()
pp.Println(asts)

出力結果は下のようになります。 SQL 文ごとに型があります。ドキュメントはないですが複雑ではないので *memefish/ast/ast.go を読めばすぐに理解できます。 Rego は値を JSON として受け取って評価するので型名は使えません。 しかし DropTable 構造体は Drop フィールドを持つなどの特徴があるため、これを文の種類の判断材料にしようと考えました。

[]ast.DDL{
  &ast.DropTable{
    Drop:     0,
    IfExists: false,
    Name:     &ast.Ident{
      NamePos: 11,
      NameEnd: 14,
      Name:    "foo",
    },
  },
  &ast.DropTable{
    Drop:     16,
    IfExists: false,
    Name:     &ast.Ident{
      NamePos: 27,
      NameEnd: 30,
      Name:    "bar",
    },
  },
}

OPA/Rego について

SQL 文の分類や同一テーブルを対象とした更新をみつけるために Rego を使います。 OPA という汎用ポリシーエンジンで中核言語として使われています。 OPA を用いることで認証認可や k8s のリソース定義についてポリシーを定められるようです。ポリシーエンジンは OPA のほかに Hashicorp Sentinel, jsPolicy, Kyverno などがあります。

Hashicorp Sentinel はエンタープライズライセンスが必要ですが OPAApache-2.0 license で提供されていて無料で商用利用も可能です。 また jsPolicy, Kyverno は主に k8s のリソースに対するポリシー適用を目的にしていますが Rego は汎用的であることを目指しています。とくにランタイムをプログラムに埋め込んで使うこともできるため遊びやすいです。

RegoDatalog に影響を受けた論理型言語です。 再帰的な定義が許可されないためデータベースに例えやすいです。最初は、ビューと関数とテーブルとオブジェクトがある関係データベースみたいなものだと思っておくと入門しやすいと思います。

まずはインストールして opa runREPL を起動しましょう。そして the basics を読みながら実験してれば数分で感覚がつかめます。 遊んでいたときにだいたい下みたいな事を感じました。あくまでこれは実験して掴んだ認識なので正確さのためにドキュメントと実装を確認しましょう。

  • Rego ではポリシーを定義する
  • ポリシーはデータ(と仮想データ)の集まりに対する問い合わせ(クエリ)である
  • ポリシーに基づく判定は、クエリを評価(Eval)することで実現する
  • クエリは式であり、式はデータや仮想データで構成される
  • クエリの評価(Eval)では充足性判定が行われる
    • 充足性判定は、クエリの式に含まれるすべての変数について正しい束縛を探す(探索する)ことで実現する
      • 全ての正しい束縛を列挙できる
    • 探索空間はクエリ(問い合わせ)が起点となって決定される
  • 正しい束縛は変数を含むすべての比較を充たしている
    • 矛盾の生じる束縛は正しい束縛ではない
  • 変数に束縛される可能性のある値の一覧を束縛候補と呼ぶことにする
    • 束縛候補は勝手に作った造語
    • 変数に代入があれば、束縛候補は代入の右辺値となる
    • 変数が複合オブジェクトの参照に使われていると、インデックス全てが束縛候補に含まれる
    • 代入とオブジェクト参照を通じて、複数の束縛候補が提示されるときは積集合が束縛候補となる
      • そもそも束縛候補が造語なのだけど、コンパイル時に行われるのか評価時に判定されるのかは謎
    • グローバルを除き、同一変数に対する複数回の代入はコンパイル時に拒否される
    • グローバルでの複数回の代入がなされた変数をルール本文で利用するとコンパイルエラー
  • データは、明示的に定義されているものを指す
  • 仮想データは、事前に定義されたルールから評価時に導出される
    • ユーザーはポリシー定義でルールを記述する
  • 再帰定義は使えない
    • おそらく停止性を保証し探索範囲を定めるため
  • 全ての変数に有限の束縛候補がなければコンパイルは通らない
    • 関数の仮引数は評価時に実引数が代入される
  • Unification(=) は、右左辺の式の一致を意味する
    • オブジェクトはコンパイル時に内部を再帰的に比較される
      • 片方が値の場合は比較として扱われる
        • 矛盾があればコンパイル時にエラー
      • 束縛候補を持たない変数と束縛候補をもつ変数が両辺に並んだ場合は代入と解釈される
        • 束縛候補を持たない変数に束縛候補が設定される
      • 束縛候補のない変数が両辺に並んだ場合はコンパイルエラー

等価性はとても大事なので REPL で遊びながら公式資料 Equality に目を通してみてください。 REPL だけではわからないことも多いのでドキュメントは大事です。 事前に定義するデータやルール(に基づく仮想データ)はグローバル変数の data 配下に入ります。ただし評価(Eval)の時に渡されるデータは input に代入されます。 詳しくは Document Model を読んで下さい。

ここでは簡単な例を書いておきます。まずデータとルールの準備です。

# base data
servers := {
    "h1": {"site": "tokyo", "role": "db"},
    "h3": {"site": "tokyo", "role": "ntp"},
    "h5": {"site": "tokyo", "role": "web"},
    "h7": {"site": "osaka", "role": "step"},
}
## rule: 仮想オブジェクト(集合)を定義する
> ssh_disabled[host] { host := servers[_]; host.role != "step" }
## 導出される集合を確認する
> ssh_disabled
[
  {
    "role": "db",
    "site": "tokyo"
  },
  {
    "role": "ntp",
    "site": "tokyo"
  },
  {
    "role": "web",
    "site": "tokyo"
  }
]
## 関数を試す
> in_tokyo(host) := result { result := host.site == "tokyo" }
## これでも大丈夫
> in_tokyo(host) { host.site == "tokyo" }
> in_tokyo({"site": "tokyo", "role": "db"})
true

Rego ランタイムの呼び出し

Rego ランタイムは *rego.New() で生成します。そして PrepareForEval() メソッドで *rego.PreparedEvalQuery を準備します。さいごに input を引数にわたして評価(Eval())を呼び出します。クエリは PrepareForEval() より先にランタイムに設定する必要がありました。

// 評価結果の値が Go のネイティブ型ではないため変換が必要になる
int64Id := func (v interface{}) int64 {
    r, _ := v.(json.Number).Int64()
    return r
}

//go:embed module.rego
var module string
runtime := rego.New(
    rego.Query(`data.ddl.add[id]`),
    rego.Module("data.ddl", module),
)
prepared, _ := runtime.PrepareForEval(context.Background())

var inputData interface{}

results, _ := prepared.Eval(context.Background(), rego.EvalInput(inputData))
for _, r := range results {
    id := int64Id(r.Bindings["id"])
    // ...
}

inputData に与えられたインスタンスは RegoJSON エンコーディングされます。そのためアノテーションを使って Rego 内でのフィールド名を変更できます。 また結果(サンプルコード内 results)はネイティブ型ではないため利用には変換が必要となります。評価直前に渡されるデータは Rego 言語内の input に代入されます。この input は特別で Unsafe な変数を受け付けないルール定義本文に残っていてもエラーになりません。

ddl-planner でも同じ流れで利用しています。 inputData 相当の変数に memefish でパースした結果(AST のリスト)が含まれています。

ポリシー定義を抜粋(すこし訂正)しました。フィールドが存在するという条件は下のようにフィールド名を書くだけで表せます。

package ddl

# DROP TABLE 文の集合を定義: 条件 .Drop フィールドが存在する
drop_table[x] { input[x].Drop }

# CREATE TABLE 文の集合を定義: 条件 .Create フィールドが存在する
create_table[x] { input[x].Create }

# 同一テーブルを DROP して CREATE している組み合わせの集合
replace_table_pair[c_id][d_id] {
        create_table[c_id]
        drop_table[d_id]
        # テーブル名が Name.Name に含まれているのは memefish 由来
        input[c_id].Name.Name == input[d_id].Name.Name
}

このように複数オブジェクトの関係式を簡単に定義することで列挙に使えます。

ふりかえり

勢いに任せてアドベントカレンダー締め切り駆動で実験・執筆しました。制作物自体の問題やほかのアプローチとの比較、本筋から外れる感想や得たことの整理などやっておきます。

ddl-planner の課題

3つ挙げておきます。

1つめは実装が間に合っておらず hammer が生成する SQL 文すべてに対応できていないことです。 実はマイグレーション SQL として DDL のほかに UPDATE 文も含まれます。いまは UPDATE 文に対応できていません。これは実装すればほぼ解決します。 UPDATE 文は DDL ではないので ddl-planner という名前が不自然になってしまうところくらいです。

2つめは ddl-plannerhammer に依存していてオーナーが分かれていることです。そのため hammer の変更にすばやく追従できません。気づかずに危険なマイグレーション SQL を利用してしまう危険が残ります。

最後の問題は ddl-planner は衝突を避けるためのプレフィックスを引数として受け取ることです。 ddl-planner はマイグレーションを3ステップに分離します。その途中で使われる一時用途のカラム名が他のカラム名と衝突してはいけません。テーブル名も同様です。しかし hammer が生成するマイグレーション SQL には適用先のテーブルの情報が残っていません。そのため引数で渡しました。一方で hammer であればそもそも引数を受け取る必要はありません。新旧のテーブル定義を持っているからです。 どちらのツールも新旧のテーブル定義を比較してマイグレーション SQL を生成するという目的を共にしています。そのため今回の3ステップ分解は hammer のオプションとして提案した方がよかったかも知れません。

ただし hammer としては責任範囲を広げることになるので簡単に受け入れるとは限らないです。たとえば hammer オーナーは中間的なテーブルも更新後のテーブル定義として与えれば十分と考えているかも知れません。

マイグレーション SQL 分離のアプローチ

SQL を分割してもマイグレーションはできません。アプリケーションの更新も必要だからです。たとえばテーブル定義として互換性を維持できていても、アプリケーションが利用している ORM やクエリビルダーが * を利用していたりするとデータ不整合でアプリケーションが障害を起こしたりします。どうせ詳細な検討してからアプリケーションと合わせて作り直す可能性が高いのであれば、誤りを許容して参考資料を素早く作るだけで十分です。それなら ChatGPT など生成 AI に作ってもらえば解決します。

やった感想

Regoプラグイン機構も用意されていてビルトイン関数を追加したりもできるようです。 特にビルトイン関数内部では再帰処理が使えるので探索結果も提供できます。(ただ Go には引数がインタフェースとして渡ってきて型アサーションが頻繁に必要になるので相応に価値がないと実装する気にはなれません。) OPA として外部データの利用パターンも整理されているので設計の参考になりそうです。

言語ランタイムをプログラムに埋め込むというのは楽しく思います。何かにつけて入れたくなります。ほかの言語を利用できるというのは価値があります。カスタマイズの提供や責任分担があります。また部分問題を別の言語に任せられるのはアプローチや思考の幅を広げてくれます。たぶん。

ポリシーを簡単に定義・更新できるのでアプリケーション固有の問題に利用してもコストが膨らみすぎないということです。システム的にはメモリ消費が大きくならないものに利用できます。モデルとしては再起処理が不要で複数ドキュメントにまたがった条件の組み合わせを評価したい場面に適しています。用途はセキュリティや k8s に限らず広く探せそうです。とりあえず、ライセンスや規約、アプリケーションリソースの衝突回避、ガイドラインの強制、gopacket と組み合わせパケット列の取り出しなどに使えそうだと考えています。

今回の実装は、いいアプローチとは言い切れないですが、実用性もある試作品を作ると理解がすすむと思えました。

例えば *memefish.Lexer も数値周りで特殊な処理をしています。また *memefish.Parser は LL(1) の独自拡張した手書きのパーサーでした。あとから調べてたら作成者の方の解説記事がみつかりました。独自拡張の部分など苦労がわかる楽しい記事でした。

上の記事を読んでいて SQLiteLemon(LALR(1) のパーサージェネレータ) を使っていることを思い出しました。未検証ですが LALR(1) パーサージェネレータの nihei9/vartan が使えるかも知れません。

また *memefish.Parser を読んでいたら SELECT はキーワードなのに INSERT は識別子として扱っていて気になりました。もちろんこれはSpanner のキーワード定義を確認すれば正しいことがわかります。 Spanner の理解がすすみました。

課題についての理解も深まるしドキュメントも必死に読みます。締め切りに追い立てられて良かったです。