Read OSS

プラグインシステム:解決・実行・イテレーターの設計思想

上級

前提知識

  • 第1〜3回(アーキテクチャ、起動処理、リクエストパイプライン)
  • LuaのメタテーブルとクロージャーへのⅠ理解
  • APIゲートウェイのプラグイン概念(認証、レート制限など)に対する基礎知識

プラグインシステム:解決・実行・イテレーターの設計思想

Kongというプロジェクトが存在する根本的な理由は、プラグインシステムにあります。プラグインのないAPIゲートウェイはただのリバースプロキシに過ぎません。プラグインシステムがあってこそ、Kongは認証・レート制限・ロギング・リクエスト変換・AIプロキシングといった機能を、独立して設定可能なモジュールとして組み合わせられる「プログラマブルなプラットフォーム」へと変貌します。

この記事では、プラグインがどのように発見・ロードされるか、イテレーターが特定のリクエストにどのプラグインを適用するかをどう判断するか、そして8段階の優先度解決システムが正しい設定をどう選択するかを見ていきます。具体例としてkey-authrate-limitingプラグインを使用します。

プラグインのディスカバリーとロード

プラグインのディスカバリーは、kong/constants.lua内のBUNDLED_PLUGINSマップから始まります。ここには45個のプラグイン名が列挙されています。Kong.init()の実行時、このリストがロード処理の起点となります。

assert(db.plugins:load_plugin_schemas(config.loaded_plugins))

スキーマのロードはkong/db/schema/plugin_loader.luaで行われます。各プラグインに対してload_subschemakong.plugins.<name>.schemaをrequireし、MetaSubSchemaに対してバリデーションを行い、pluginsエンティティのサブスキーマとして登録します。

function plugin_loader.load_subschema(parent_schema, plugin, errors)
  local plugin_schema = "kong.plugins." .. plugin .. ".schema"
  local ok, schema = load_module_if_exists(plugin_schema)
  -- validate against MetaSubSchema
  ok, err_t = MetaSchema.MetaSubSchema:validate(schema)
  -- register as subschema
  ok, err = Entity.new_subschema(parent_schema, plugin, schema)
  return schema
end

つまり、各プラグインの設定スキーマはデータベース上のpluginsエンティティのサブスキーマとして扱われます。POST /plugins{ "name": "key-auth", "config": { ... } }を送ると、configフィールドはkey-auth固有のスキーマに対してバリデーションされますが、保存先は共通のpluginsテーブルです。

flowchart TD
    A[constants.lua: BUNDLED_PLUGINS] --> B[db.plugins:load_plugin_schemas]
    B --> C{For each plugin}
    C --> D["require kong.plugins.<name>.schema"]
    D --> E[MetaSubSchema:validate]
    E --> F[Entity.new_subschema]
    F --> G[Plugin config validated<br>against its own schema]
    C -->|Next plugin| C

collecting/collectedイテレーターパターン

kong/runloop/plugins_iterator.luaのプラグインイテレーターは、Kongのパフォーマンスの要となる2モードの実行モデルを実装しています。

collectingフェーズ(HTTPの場合はaccess、streamの場合はpreread)では、現在のリクエストに適用するプラグインを解決し、実行リストを構築します。collectedフェーズheader_filterbody_filterlogresponse)では、そのリストを再解決なしにそのまま再生します。

フェーズの分類は29〜65行目で定義されています。

NON_COLLECTING_PHASES = {
  "certificate", "rewrite", "response",
  "header_filter", "body_filter", "log",
}
COLLECTING_PHASE = "access"

この設計には重要な理由があります。accessフェーズでは、マッチしたRoute・Service・認証済みConsumerという情報がすでに揃っています。これがプラグイン設定の解決に必要な情報です。一方、後続のフェーズ(header_filter、body_filterなど)では再解決は不要であり、無駄なコストになります。accessで実行したプラグインをそのまま後続フェーズで使い回せばよいのです。

372〜411行目のcollectingイテレーターが実際の処理を担います。各プラグインに対してload_configuration_through_combosを呼び出して適用可能な設定を見つけ、そのプラグインが実装している各ダウンストリームフェーズに対してプラグインと設定のペアを記録します。

for j = 1, DOWNSTREAM_PHASES_COUNT do
  local phase = DOWNSTREAM_PHASES[j]
  if handler[phase] then
    local n = collected[phase][0] + 2
    collected[phase][0] = n
    collected[phase][n] = cfg
    collected[phase][n - 1] = plugin
  end
end

収集されたデータはctx.pluginsに格納されます。これはプールから確保されるテーブルで、各ダウンストリームフェーズごとにサブテーブルを持ちます。各サブテーブルは[plugin, config, plugin, config, ...]というフラットな配列で、要素数はインデックス[0]に格納されます。

sequenceDiagram
    participant Access as access phase (collecting)
    participant Iterator as plugins_iterator
    participant HeaderFilter as header_filter (collected)
    participant BodyFilter as body_filter (collected)
    participant Log as log (collected)

    Access->>Iterator: get_collecting_iterator(ctx)
    Iterator->>Iterator: For each loaded plugin...
    Iterator->>Iterator: load_configuration_through_combos()
    Iterator->>Iterator: If config found, record in ctx.plugins
    Note over Iterator: ctx.plugins.header_filter = [plugin1, cfg1, plugin2, cfg2]
    Note over Iterator: ctx.plugins.log = [plugin1, cfg1, plugin2, cfg2]
    Iterator-->>Access: yield (plugin, config) for access handler
    
    HeaderFilter->>Iterator: get_collected_iterator("header_filter", ctx)
    Iterator-->>HeaderFilter: replay ctx.plugins.header_filter
    
    BodyFilter->>Iterator: get_collected_iterator("body_filter", ctx)
    Iterator-->>BodyFilter: replay ctx.plugins.body_filter
    
    Log->>Iterator: get_collected_iterator("log", ctx)
    Iterator-->>Log: replay ctx.plugins.log

補足: rewriteがcollectingイテレーターではなくグローバルイテレーターを使う理由が気になるかもしれません。rewriteフェーズはルーティング処理のに実行されるため、Routeがまだマッチしていません。Route・Service・Consumerに紐づいた設定を解決する術がないため、グローバルプラグイン(Route・Service・Consumerのいずれにも紐付けられていないプラグイン)だけがrewriteで実行されます。

8段階の設定解決

プラグインをリクエストに適用する際、複数のプラグインインスタンスが存在する可能性があるため、その設定を解決する必要があります。rate-limitingプラグインがグローバル、特定のService、さらに特定のRoute+Consumerの組み合わせに設定されている場合、より具体的な設定が優先されます。

215〜267行目lookup_cfg関数が、この8段階の優先度ルックアップを実装しています。

優先度 組み合わせ 具体性
1 Route + Service + Consumer 最も具体的
2 Route + Consumer
3 Service + Consumer
4 Route + Service
5 Consumer のみ
6 Route のみ
7 Service のみ
8 グローバル(紐付けなし) 最も汎用的

各組み合わせは85行目build_compound_keyによって複合キーを生成します。

local function build_compound_key(route_id, service_id, consumer_id)
  return format("%s:%s:%s", route_id or "", service_id or "", consumer_id or "")
end

ルックアップは最初にマッチした時点で短絡評価されます。Route+Service+Consumerの設定が存在すれば、Route+Consumerは確認されません。グローバル設定はフォールバックとして機能し、より具体的な設定が見つからない場合にのみ適用されます。

282〜291行目load_configuration_through_combos関数はさらに一段階の仕組みを加えています。プラグインハンドラーはno_routeno_serviceno_consumerフラグを宣言することで、ルックアップ前にその次元をフィルタリングできます。これはまれなケースですが、特定のスコープレベルをオプトアウトしたい特殊なプラグインのために用意されています。

flowchart TD
    A[Request Context] --> B{Route + Service + Consumer?}
    B -->|Found| Z[Use this config]
    B -->|Not found| C{Route + Consumer?}
    C -->|Found| Z
    C -->|Not found| D{Service + Consumer?}
    D -->|Found| Z
    D -->|Not found| E{Route + Service?}
    E -->|Found| Z
    E -->|Not found| F{Consumer only?}
    F -->|Found| Z
    F -->|Not found| G{Route only?}
    G -->|Found| Z
    G -->|Not found| H{Service only?}
    H -->|Found| Z
    H -->|Not found| I{Global?}
    I -->|Found| Z
    I -->|Not found| J[Plugin does not apply]

プラグインハンドラーの構造と優先度順序

Kongのプラグインはすべて同じ構造に従っています。key-authという名前のプラグインはkong/plugins/key-auth/ディレクトリに配置され、以下のファイルで構成されます。

  • handler.lua — フェーズメソッド(accessheader_filterlogなど)
  • schema.lua — 設定のバリデーションスキーマ
  • daos.lua(任意) — カスタムデータベースエンティティ
  • api.lua(任意) — Admin API拡張

ハンドラーはPRIORITYVERSIONフィールド、そしてNginxのフェーズ名をメソッド名として持つテーブルをエクスポートする必要があります。kong/plugins/key-auth/handler.luaの例を見てみましょう。

local KeyAuthHandler = {
  VERSION = kong_meta.version,
  PRIORITY = 1250,
}

PRIORITYは実行順序を制御し、値が大きいプラグインほど先に実行されます。これは非常に重要な設計です。認証プラグイン(key-authは1250、jwtは1250、basic-authは1100)が認可プラグイン(aclは950)より先に実行され、さらにレート制限プラグイン(rate-limitingは910)より先に実行される順序が保証されます。

key-authプラグインは262〜273行目に示すように、accessフェーズのみを実装しています。

function KeyAuthHandler:access(conf)
  if not conf.run_on_preflight and kong.request.get_method() == "OPTIONS" then
    return
  end
  if conf.anonymous then
    return logical_OR_authentication(conf)
  else
    return logical_AND_authentication(conf)
  end
end

一方、kong/plugins/rate-limiting/handler.luaaccesslogの両フェーズを実装しています。

RateLimitingHandler.VERSION = kong_meta.version
RateLimitingHandler.PRIORITY = 910

rate-limitingプラグインはaccessフェーズでレート制限のチェックと適用を行い、logフェーズでカウンターのインクリメントを非同期に同期します(clusterポリシーでsync rateを使用する場合)。このマルチフェーズパターンはよく見られる手法で、プラグインは早い段階で制御を行い、後処理は遅いフェーズに委ねます。

PDK:Plugin Development Kit APIサーフェス

プラグインはPDK(Plugin Development Kit)、すなわちkong.*名前空間を通じてのみKongと対話します。このAPIサーフェスはkong/pdk/init.luaで定義されています。

local MAJOR_MODULES = {
  "table", "node", "log", "ctx", "ip", "client",
  "service", "request", "service.request", "service.response",
  "response", "router", "nginx", "cluster", "vault",
  "tracing", "plugin", "telemetry",
}

各モジュールはkong/pdk/<name>.luaからロードされ、kongグローバルに追加されます。主要な名前空間を以下に示します。

名前空間 用途
kong.request 受信リクエストの読み取り kong.request.get_header("Authorization")
kong.response クライアントへのレスポンス送信 kong.response.exit(403, { message = "Forbidden" })
kong.service.request アップストリームへのリクエスト変更 kong.service.request.set_header("X-Custom", "value")
kong.service.response アップストリームのレスポンス読み取り kong.service.response.get_header("Content-Type")
kong.client Consumer・クレデンシャル情報 kong.client.authenticate(consumer, credential)
kong.log 構造化ロギング kong.log.err("something failed")
kong.ctx プラグインスコープのコンテキスト kong.ctx.plugin.my_data = "value"
kong.cache データベースキャッシュ kong.cache:get(key, opts, loader_fn, ...)

PDKにはフェーズチェックの仕組みも備わっています。例えばkong.service.request.set_header()logフェーズで呼び出すとエラーになります。リクエストはすでに送信済みだからです。このチェックにより、プラグイン開発時の見つけにくいバグを未然に防ぐことができます。

補足: プラグインを書く際、1リクエスト内でフェーズをまたいで保持したいがプラグインインスタンスにスコープされるデータにはkong.ctx.pluginを使いましょう。プラグイン間で共有するデータにはkong.ctx.sharedを使います。モジュールレベルの変数をリクエストごとの状態管理に使ってはいけません。Nginxワーカーは複数のリクエストを並行して処理するためです。

全体像

1つのリクエストに対して、プラグイン実行パイプライン全体がどのように機能するかをまとめます。

flowchart TD
    subgraph "init time"
        A[Plugin schemas loaded] --> B[Plugin handlers loaded]
        B --> C[Plugins sorted by PRIORITY]
    end
    subgraph "per-request: access phase"
        D[Get collecting iterator] --> E[For each plugin in priority order]
        E --> F[lookup_cfg: 8-level resolution]
        F --> G{Config found?}
        G -->|Yes| H[Record for downstream phases]
        H --> I[Execute plugin.access]
        G -->|No| J[Skip plugin]
        I --> E
        J --> E
    end
    subgraph "per-request: downstream phases"
        K[Get collected iterator] --> L[Replay recorded plugin+config pairs]
        L --> M[Execute plugin.header_filter]
        M --> N[Execute plugin.body_filter]
        N --> O[Execute plugin.log]
    end

第5回では、プラグイン設定・ルート定義・サービスメタデータを格納するデータベース層を掘り下げます。バリデーション・シリアライゼーション・Admin API生成をすべて単一の情報源から担うスキーマシステムの仕組みを見ていきましょう。