Read OSS

スキーマ駆動設計:バリデーションからクエリまで、Kongのデータベース層を読み解く

上級

前提知識

  • 記事1〜4(アーキテクチャ全体とプラグインの理解)
  • Luaのメタテーブルとオブジェクト指向パターンの理解
  • データベースの基本概念(CRUD、マイグレーション、スキーマ)の知識

スキーマ駆動設計:バリデーションからクエリまで、Kongのデータベース層を読み解く

Kongのアーキテクチャにおいて特に洗練されている設計上の決断のひとつが、単一のスキーマ定義があらゆるものを駆動するという点です。データベーステーブルの生成、入力バリデーション、APIエンドポイントの生成、キャッシュキーの計算、外部キーの整合性チェックなど、これらすべてが同じスキーマから生み出されます。新しいエンティティを追加するだけで、CRUDエンドポイント、データベースクエリ、バリデーションロジックがボイラープレートなしに自動生成されるのです。

この記事では、頂点に位置するメタスキーマから、具体的なエンティティ定義、DAOレイヤー、データベースストラテジーまでを順に追い、同じスキーマがAdmin APIをどのように動かしているかを明らかにします。

メタスキーマ:スキーマのためのスキーマ

スキーマ自体をどうやって検証するのでしょうか?答えは「別のスキーマで」です。kong/db/schema/metaschema.lua は、エンティティスキーマを書くためのルールを定義しています。使用可能なフィールドタイプ、バリデーター、変換処理、制約条件がここで宣言されます。

フィールド属性のボキャブラリーは47〜60行目で定義されています:

local validators = {
  { between = { type = "array", elements = { type = "number" }, len_eq = 2 } },
  { eq = { type = "any" } },
  { ne = { type = "any" } },
  { gt = { type = "number" } },
  { len_eq = { type = "integer" } },
  { match = { type = "string" } },
  { starts_with = { type = "string" } },
  -- ...
}

各バリデーターは、Schemaクラス内の対応するバリデーション関数に紐付いています。エンティティスキーマで { type = "string", match = "^[a-z]+$" } と書いた場合、メタスキーマはまず matchstring 型の有効なフィールド属性であることを確認します。その後Schemaクラスが対応する match バリデーター関数を実際のバリデーションに使用します。

また、メタスキーマはプラグイン設定スキーマ向けの MetaSubSchema バリアントも定義しています。この区別は重要です。エンティティスキーマは主キーと外部キーを持つデータベーステーブルを定義するのに対し、サブスキーマは plugins エンティティ内の config フィールドを定義するものです。

classDiagram
    class MetaSchema {
        +validate(schema_def)
        +fields: validators, types, constraints
    }
    class MetaSubSchema {
        +validate(plugin_schema)
        +plugin-specific rules
    }
    class EntitySchema {
        +name: string
        +fields: field_defs[]
        +primary_key: string[]
        +entity_checks: check[]
    }
    class PluginSubSchema {
        +name: string
        +fields: config_fields[]
    }
    MetaSchema --> EntitySchema : validates
    MetaSubSchema --> PluginSubSchema : validates
    MetaSchema --> MetaSubSchema : derives from

エンティティスキーマの定義

具体的なエンティティスキーマを見ると、このシステムが実際にどう動くかがよくわかります。kong/db/schema/entities/routes.luaroutes エンティティを定義しており、フィールド定義、外部キー、エンティティレベルのチェックが含まれています:

local entity_checks = {
  { conditional = {
    if_field = "protocols",
    if_match = { elements = { type = "string", not_one_of = { "grpcs", "https", "tls", "tls_passthrough" }}},
    then_field = "snis",
    then_match = { len_eq = 0 },
    then_err = "'snis' can only be set when 'protocols' is 'grpcs', 'https', 'tls' or 'tls_passthrough'",
  }},
}

エンティティチェックは、個々のフィールドだけでは表現できないフィールド間の制約を強制します。この例では、SNI(Server Name Indication)はTLSベースのプロトコルでのみ意味を持つため、HTTPルートにSNIを設定しようとするとエラーになります。

constants.lua でのロード順序も重要です。137〜155行目CORE_ENTITIES リストでは、workspacesconsumerscertificatesservicesroutessnisupstreamstargetspluginstags の順序が定められています。依存される側が依存する側より前に来る構成です。RoutesはServicesへの外部キーを持つため、ServicesはRoutesより先にロードされなければなりません。

補足: CORE_ENTITIES テーブルは配列とセットを兼ねています。配列の構築後、307〜309行目のループで CORE_ENTITIES["routes"] = true のような形で各エントリーが追加され、O(1)のメンバーシップ確認が可能になります。このパターンはKongのLuaコード全体で頻繁に登場します。

DAOの自動生成とDBモジュール

スキーマが実際に機能するのは kong/db/init.lua においてです。DB.new() コンストラクターはコアエンティティスキーマをひとつずつ処理し、メタスキーマに対する検証、Entityオブジェクトの生成、DAOのインスタンス化を行います:

for _, entity_name in ipairs(constants.CORE_ENTITIES) do
  local entity_schema = require("kong.db.schema.entities." .. entity_name)
  local ok, err_t = MetaSchema:validate(entity_schema)
  -- ...
  local entity, err = Entity.new(entity_schema)
  schemas[entity_name] = entity
end

続いて108〜118行目でDAOが生成されます:

for _, schema in pairs(schemas) do
  local strategy = strategies[schema.name]
  daos[schema.name] = DAO.new(self, schema, strategy, errors)
end

31〜33行目__index メタメソッドにより、kong.db.routesself.daos['routes'] に透過的に解決されます:

DB.__index = function(self, k)
  return DB[k] or rawget(self, "daos")[k]
end

つまり kong.db.routes:select({ id = "..." }) を呼ぶと、Routes DAOに処理が委譲され、Routesスキーマによるバリデーションを経て、Routesストラテジーを通じてデータベースアクセスが行われます。

flowchart TD
    A[DB.new] --> B[Load each entity schema]
    B --> C[MetaSchema:validate]
    C --> D[Entity.new - create schema object]
    D --> E[Strategy.new - create DB strategy]
    E --> F[DAO.new - create DAO]
    F --> G["kong.db.routes = DAO(routes_schema, postgres_strategy)"]
    G --> H["kong.db.routes:select() → schema.validate → strategy.select → SQL"]

データベースストラテジー:PostgresとDB-less(LMDB)

DAOレイヤーはストラテジーを意識しません。各DAOは実際のデータベース操作を担うストラテジーオブジェクトに処理を委譲します。Kongには2種類のストラテジーが実装されています。

Postgres (kong/db/strategies/postgres/) — 従来型のストラテジーで、スキーマ定義からSQLクエリを生成します。コネクターは ngx_lua のcosocketベースのPostgresドライバーを通じてコネクションプールを管理し、setkeepalive() でリクエストをまたいだコネクションの再利用を実現しています。

Off/LMDB (kong/db/strategies/off/) — DB-lessモードとData Planeモードで使用されます。リモートデータベースへのクエリの代わりに、組み込みのキー・バリューストアであるLMDB(Lightning Memory-Mapped Database)からデータを読み取ります。LMDBはメモリマップされた読み取り専用のゼロコピーセマンティクスを提供しており、Control Planeから設定を受け取り高速なローカルルックアップが求められるData Planeに最適です。

flowchart LR
    subgraph "Traditional Mode"
        A1[DAO] --> B1[Postgres Strategy]
        B1 --> C1[(PostgreSQL)]
    end
    subgraph "DB-less / Data Plane"
        A2[DAO] --> B2[Off Strategy]
        B2 --> C2[(LMDB)]
    end
    D[Admin API / Plugin] --> A1
    D --> A2

156〜174行目ENTITY_CACHE_STORE マッピングは、各エンティティタイプをどの共有メモリキャッシュに格納するかを決定します。ルーティングに関わるコアエンティティ(routesservicespluginsupstreamstargets)は core_cache に、それ以外の重要度が低いエンティティ(consumerstags)は通常の cache に格納されます。この分離により、プラグインのキャッシュ圧力がルーティングデータの退出を引き起こすことを防いでいます。

宣言的設定とDB-lessモード

DB-lessモードでは、Kongはデータベースの代わりにYAML/JSONファイルから設定全体を読み込みます。kong/db/declarative/init.lua モジュールがこの処理を担っています:

function _M.new_config(kong_config, partial)
  local schema, err = declarative_config.load(
    kong_config.loaded_plugins,
    kong_config.loaded_vaults
  )
  local self = { schema = schema, partial = partial }
  return setmetatable(self, _MT)
end

宣言的設定のスキーマは、ロードされたプラグインに応じて動的に生成されます。すべてのコアエンティティのフィールドに加え、プラグイン固有のカスタムエンティティも含まれます。ロードのパイプラインは次のとおりです:

  1. パース — YAML/JSONをLuaテーブルに変換する
  2. バリデーション — 生成されたスキーマに対して検証する(データベースバリデーションと同じSchemaクラスを使用)
  3. フラット化 — エンティティタイプをキーとするエンティティレコードに変換する
  4. 書き込み — 単一トランザクションでLMDBに書き込む

第6回で取り上げますが、Data PlaneがControl Planeから設定を受け取る場合、それはシリアライズされた宣言的設定ペイロードとして届きます。同じ load_into_cache_with_events 関数が、ファイルベースの設定とネットワーク経由で受信した設定の両方を処理し、その後でルーターとプラグインイテレーターを再構築します。

flowchart TD
    A["kong.yml (YAML/JSON)"] --> B[Parse to Lua tables]
    B --> C["Validate against declarative schema"]
    C --> D{Valid?}
    D -->|No| E[Error with path to invalid field]
    D -->|Yes| F[Flatten into entity records]
    F --> G[Write to LMDB transaction]
    G --> H[Rebuild router]
    H --> I[Rebuild plugins iterator]

Admin API:スキーマ駆動のエンドポイント生成

kong/api/init.lua のAdmin APIは、エンティティスキーマからCRUDエンドポイントを自動生成します。まずコアルートがロードされ、続いてエンティティベースのエンドポイントが生成されます:

-- Load core routes
for _, v in ipairs({"kong", "health", "cache", "config", "debug"}) do
  local routes = require("kong.api.routes." .. v)
  api_helpers.attach_routes(app, routes)
end

kong/api/endpoints.lua モジュールが自動生成の仕組みを提供します。各エンティティスキーマに対して、以下のエンドポイントが生成されます:

  • GET /routes — ルート一覧の取得(ページネーションあり)
  • POST /routes — ルートの作成
  • GET /routes/:routes — 特定ルートの取得
  • PATCH /routes/:routes — ルートの更新
  • PUT /routes/:routes — ルートのアップサート
  • DELETE /routes/:routes — ルートの削除

外部キーの関係はネストされたルートも自動生成します。RoutesはServicesへの外部キーを持つため、特定のサービスに紐付くルートを取得する GET /services/:services/routes も自動的に生成されます。

プラグインは api.lua モジュールを通じてAdmin APIを拡張できます。58行目customize_routes 関数を使うと、プラグイン定義のエンドポイントで自動生成されたエンドポイントをオーバーライドしたりラップしたりでき、元の関数は parent パラメーターとして渡されます。

補足: endpoints.lua25〜40行目にあるエラーコードのマッピングは、内部エラータイプをHTTPステータスコードに対応付けています。UNIQUE_VIOLATION → 409、NOT_FOUND → 404、SCHEMA_VIOLATION → 400。Admin APIのエラーレスポンスにおける唯一の信頼できる情報源がここです。

スキーマのカスケード

この設計の真価は、カスケードにあります。単一のスキーマ定義から、以下のすべてが生み出されます:

成果物 生成元
データベーステーブル(DDL) スキーマのフィールドと型
入力バリデーション スキーマのバリデーターとentity_checks
キャッシュキーの計算 スキーマの cache_key フィールド
Admin APIエンドポイント スキーマ名と外部キー
宣言的設定フォーマット スキーマのフィールド(YAML構造)
プラグインサブスキーマ MetaSubSchemaによるバリデーション

Kongが新しいエンティティ(たとえばWasm用の filter_chains)を追加する場合、単一のスキーマファイルを作成するだけで、上記のすべてが自動的に生成されます。集中的なデータモデリングがシステム境界をまたいだ不整合をどれだけ削減できるかを示す、説得力のある例と言えるでしょう。

第6回では、このデータ層がKongの分散アーキテクチャとどう繋がるかを見ていきます。Control Planeが設定をシリアライズし、宣言的設定パイプラインを通じてそれを適用するData Planeへと送信する仕組みを詳しく解説します。