🚀

kong-client-rubyを使用してKongに設定を適用する

2022/02/06に公開

はじめに

kong-client-rubyのgemを使用してKong OSS版へServiceなどを登録する際に、いろいろ詰まってしまいました。
そのため、kong-client-rubyの使用方法、使用時の問題と対応について記載します。

kong-client-rubyについて

オリジナルのkong-client-rubyは以下になります。
https://github.com/kontena/kong-client-ruby

ただし、本記事執筆時点(2/6)で確認したところ、このgemは2019年でメンテナンスが止まっているらしく、Kongの最新バージョンに追従できていないようです。おそらくKongの1.1に未対応と思われます。

このKong 1.1に対応したgemが、以下フォーク先のリポジトリのkong1dot1ブランチとなっています。
https://github.com/kontena/kong-client-ruby/pull/38

https://github.com/mtmail/kong-client-ruby/tree/kong1dot1

また、フォーク先の作者曰く、このブランチはKong 2系にも対応しているようです。
https://github.com/kontena/kong-client-ruby/pull/38#issuecomment-743141527

My fork works with Kong 2.x

本記事では、このmtmail/kong-client-rubyリポジトリのkong1dot1ブランチを使用して、Kongへの登録処理を行います。
なお、このkong1dot1ブランチを使用しても、Kongの最新バージョン(2.7)に未対応の部分があるため、都度自作パッチをあてて対応します。

最初に結論

この項目に記載している構成、コードを使用し、docker compose起動、rubyファイルの実行によって、kong-client-rubyを使用してKongに登録が可能となります。
今回Kongに登録するものは、以下ConsumerからTargetまでの6つとなります。

  • Consumer
  • Service
  • Route
  • Plugin
  • Upstream
  • Target

使用バージョン

Ruby: 2.7.5
Kong: 2.7.0
Docker Desktop: 4.4.2
docker compose: 3.9

構成

.
├── POSTGRES_PASSWORD
├── client
│   ├── Dockerfile
│   ├── Gemfile
│   ├── kong2_compatible.rb
│   └── main.rb
├── config
│   └── kong.yaml
└── docker-compose.yml

本記事では、docker composeを使用し、Kong、Postgresql、kong-client-rubyを使用するclientサービスを立ち上げます。
使用するdocker-compose.yml、POSTGRES_PASSWORD(Postgresqlへの接続のパスワード)、configディレクトリに関しては、公式がテンプレートを提供しているので、そのテンプレートを使用します。
https://github.com/Kong/docker-kong/tree/master/compose

clientディレクトリ配下の各ファイルの中身は以下のとおりです。

Gemfile
source 'https://rubygems.org'

git_source(:github) { |repo_name| "https://github.com/#{repo_name}" }

gem 'kong', git: 'https://github.com/mtmail/kong-client-ruby', branch: 'kong1dot1'
kong2_compatible.rb
# kong-client-rubyのパッチ
module Kong
  class Plugin
    def create
      super
    end
  end

  class Target
    def initialize(attributes = {})
      super(attributes)
    end

    def use_upstream_end_point
      if self.attributes['upstream'] && self.attributes['upstream'][:id]
        self.api_end_point = "/upstreams/#{self.attributes['upstream'][:id]}#{self.class::API_END_POINT}" 
      end
    end
  end
end
main.rb
require 'kong'
require_relative './kong2_compatible.rb'

Kong::Client.api_url = 'http://kong:8001'

# Kongに登録されているサービスなどを全て削除
Kong::Upstream.all.each(&:delete)
Kong::Plugin.all.each(&:delete)
Kong::Route.all.each(&:delete)
Kong::Service.all.each(&:delete)
Kong::Consumer.all.each(&:delete)

# Consumer登録
consumer = Kong::Consumer.new({ username: 'test-user' })
consumer.create

# Service登録
service = Kong::Service.new(
  {
    name: 'example_service',
    protocol: 'https',
    host: 'mockbin.org',
    path: '/request'
  }
)
service.create

# Route登録
route = Kong::Route.new(
  {
    name: 'mocking',
    service: {
      id: service.id
    },
    paths: ['/mock'],
    methods: ['GET']
  }
)
route.create

# Rate LimitingのPlugin登録
plugin = Kong::Plugin.new(
  {
    service: {
      id: service.id
    },
    consumer: {
      id: consumer.id
    },
    name: "rate-limiting",
    config: {
      minute: 20,
      hour: 500
    }
  }
)
plugin.create

# Upstream登録
upstream = Kong::Upstream.new({ name: 'upstream-service'})
upstream.create

# Upstreamに紐づくTarget登録
mockbin_target = Kong::Target.new( {
  upstream: {
    id: upstream.id
  },
  target: 'mockbin.org:443'
})
mockbin_target.create

httpbin_target = Kong::Target.new( {
  upstream: {
    id: upstream.id
  },
  target: 'httpbin.org:443'
})
httpbin_target.create

# Upstreamを既存のServiceに紐付けて更新
service2 = Kong::Service.new(
  {
    id: service.id,
    host: 'upstream-service'
  }
)

service2.update
Dockerfile
FROM ruby:2.7.5-alpine

RUN apk add git

RUN gem install bundle

WORKDIR /app

COPY Gemfile .

RUN bundle config path vendor/bundle && bundle install

COPY . .

CMD ["sleep", "infinity"]
version: '3.9'

x-kong-config: &kong-env
  KONG_DATABASE: ${KONG_DATABASE:-postgres}
  KONG_PG_DATABASE: ${KONG_PG_DATABASE:-kong}
  KONG_PG_HOST: db
  KONG_PG_USER: ${KONG_PG_USER:-kong}
  KONG_PG_PASSWORD_FILE: /run/secrets/kong_postgres_password

volumes:
  kong_data: {}
  kong_prefix_vol:
    driver_opts:
     type: tmpfs
     device: tmpfs
  kong_tmp_vol:
    driver_opts:
     type: tmpfs
     device: tmpfs

networks:
  kong-net:
    external: false

services:
  kong-migrations:
    image: "${KONG_DOCKER_TAG:-kong:latest}"
    command: kong migrations bootstrap
    profiles: ["database"]
    depends_on:
      - db
    environment:
      <<: *kong-env
    secrets:
      - kong_postgres_password
    networks:
      - kong-net
    restart: on-failure

  kong-migrations-up:
    image: "${KONG_DOCKER_TAG:-kong:latest}"
    command: kong migrations up && kong migrations finish
    profiles: ["database"]
    depends_on:
      - db
    environment:
      <<: *kong-env
    secrets:
      - kong_postgres_password
    networks:
      - kong-net
    restart: on-failure

  kong:
    platform: linux/arm64
    image: "${KONG_DOCKER_TAG:-kong:latest}"
    user: "${KONG_USER:-kong}"
    environment:
      <<: *kong-env
      KONG_ADMIN_ACCESS_LOG: /dev/stdout
      KONG_ADMIN_ERROR_LOG: /dev/stderr
      KONG_PROXY_LISTEN: "${KONG_PROXY_LISTEN:-0.0.0.0:8000}"
      KONG_ADMIN_LISTEN: "${KONG_ADMIN_LISTEN:-0.0.0.0:8001}"
      KONG_PROXY_ACCESS_LOG: /dev/stdout
      KONG_PROXY_ERROR_LOG: /dev/stderr
      KONG_PREFIX: ${KONG_PREFIX:-/var/run/kong}
      KONG_DECLARATIVE_CONFIG: "/opt/kong/kong.yaml"
    secrets:
      - kong_postgres_password
    networks:
      - kong-net
    ports:
      # The following two environment variables default to an insecure value (0.0.0.0)
      # according to the CIS Security test.
      - "${KONG_INBOUND_PROXY_LISTEN:-0.0.0.0}:8000:8000/tcp"
      - "${KONG_INBOUND_SSL_PROXY_LISTEN:-0.0.0.0}:8443:8443/tcp"
      # Making them mandatory but undefined, like so would be backwards-breaking:
      # - "${KONG_INBOUND_PROXY_LISTEN?Missing inbound proxy host}:8000:8000/tcp"
      # - "${KONG_INBOUND_SSL_PROXY_LISTEN?Missing inbound proxy ssl host}:8443:8443/tcp"
      # Alternative is deactivating check 5.13 in the security bench, if we consider Kong's own config to be enough security here

      - "127.0.0.1:8001:8001/tcp"
      - "127.0.0.1:8444:8444/tcp"
    healthcheck:
      test: ["CMD", "kong", "health"]
      interval: 10s
      timeout: 10s
      retries: 10
    restart: on-failure:5
    read_only: true
    volumes:
      - kong_prefix_vol:${KONG_PREFIX:-/var/run/kong}
      - kong_tmp_vol:/tmp
      - ./config:/opt/kong
    security_opt:
      - no-new-privileges

  db:
    image: postgres:9.5
    profiles: ["database"]
    environment:
      POSTGRES_DB: ${KONG_PG_DATABASE:-kong}
      POSTGRES_USER: ${KONG_PG_USER:-kong}
      POSTGRES_PASSWORD_FILE: /run/secrets/kong_postgres_password
    secrets:
      - kong_postgres_password
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "${KONG_PG_USER:-kong}"]
      interval: 30s
      timeout: 30s
      retries: 3
    restart: on-failure
    stdin_open: true
    tty: true
    networks:
      - kong-net
    volumes:
      - kong_data:/var/lib/postgresql/data

  client:
    build: ./client
    volumes:
      - ./client:/app
    networks:
      - kong-net


secrets:
  kong_postgres_password:
    file: ./POSTGRES_PASSWORD

docker compose起動、Kongへの登録

上記のファイルを使用し、まずPostgresqlを起動します。

> docker compose up db

次に、kong-migrationsサービスを起動しmigrationを実行させます。

> docker compose up kong-migrations

これでmigrationが完了するので、docker compose upでサービスを起動させます。

> docker compose up

最後にclientサービスに入り、main.rbを実行することで、Kongへの登録が完了します。

> docker compose exec client /bin/sh
> bundle exec ruby main.rb

以降は、各登録時に詰まるポイントと対応について記載します。

Consumer登録

Consumer登録では、kong-client-rubyののREADMEの手順と同じく、以下コードを使用することで登録可能となります。

main.rb
consumer = Kong::Consumer.new({ username: 'test-user' })
consumer.create

Service登録

こちらもConsumer登録と同じく、READMEの手順と同じ方法で登録できます。

main.rb
service = Kong::Service.new(
  {
    name: 'example_service',
    protocol: 'https',
    host: 'mockbin.org',
    path: '/request'
  }
)
service.create

Route登録

Route登録では、READMEとコードが少し異なります。
READMEでは以下のようにservice_idを使用するようになっています。

REAMEのコード
route = Kong::Route.new({
  name: 'Mockbin',
  service_id: '5fd1z584-1adb-40a5-c042-63b19db49x21',
  uris: ['/someservice'],
  methods: ['GET'],
  strip_path: false,
  preserve_host: false
})

ですがKong 2.7.0に対してこのコードを実行すると、Kongのスキーマにservice_idがなくunknown fieldとなり、登録ができません。

/app/vendor/bundle/ruby/2.7.0/bundler/gems/kong-client-ruby-19764e083d27/lib/kong/client.rb:229:in `handle_error_response': {"fields":{"service_id":"unknown field"},"name":"schema violation","message":"schema violation (service_id: unknown field)","code":2} (Kong::Error)

Kongのコードを詳しく見たわけではありませんが、serviceのidをKongへ渡す際には、リクエストのボディをservice: {id: xxx}のjson形式にしないといけないようです。
json形式への変換はkong-client-ruby側で行ってくれるので、client側はハッシュ形式でkong-clent-ruby側に渡す必要があります。
そのため、以下のコードにして実行するとRouteが登録されます。

main.rb
route = Kong::Route.new(
  {
    name: 'mocking',
    service: {
      id: service.id
    },
    paths: ['/mock'],
    methods: ['GET']
  }
)

route.create

Plugin登録

特定のConsumerの特定のServiceに対し、Rate Limitingを設定するとします。

READMEでは、以下のようにコード例が記載されています。

READMEのコード
plugin = Kong::Plugin.new({
  service_id: '5fd1z584-1adb-40a5-c042-63b19db49x21',
  consumer_id: 'a3dX2dh2-1adb-40a5-c042-63b19dbx83hF4',
  name: 'rate-limiting',
  config: {
    minute: 20,
    hour: 500
  }
})

ですが、このコードのまま実行してもエラーになってしまいます。
これは以下2つの問題があるためです。

  • 1つ目: Route登録と同様に、service_idconsumer_idがKongになくschema violationとなってしまう。
  • 2つ目: 1つ目の問題を対応してcreateしても、schema violationが発生して登録できない。

1つ目に関しては、Route登録の対応と同じように、service: { id: xxx}consumer: {id: xxx}とすることで解決できます。

2つ目に関して、schema violationのエラーは以下のようなエラーとなります。

/app/vendor/bundle/ruby/2.7.0/bundler/gems/kong-client-ruby-19764e083d27/lib/kong/client.rb:229:in `handle_error_response': {"code":2,"name":"schema violation","fields":{"config.hour":"unknown field","config.minute":"unknown field","@entity":["at least one of these fields must be non-empty: 'config.second', 'config.minute', 'config.hour', 'config.day', 'config.month', 'config.year'"]},"message":"3 schema violations (at least one of these fields must be non-empty: 'config.second', 'config.minute', 'config.hour', 'config.day', 'config.month', 'config.year'; config.hour: unknown field; config.minute: unknown field)"} (Kong::Error)

エラーにある通り、config.hourconfig.minuteはKongのスキーマになく、unknown fieldとなります。

この理由は、Pluginクラスのcreate時に、Plugin.newで与えた引数を修正してリクエストのボディに使用しているためです。

plugin.rb
module Kong
  class Plugin
    include Base
    include BelongsToService
・・・略・・・
    def create
      flatten_config
      super
    end
・・・略・・・
    private

    def flatten_config
      if attributes['config']
        attributes.merge!(Util.flatten(attributes.delete('config'), 'config'))
      end
    end
  end
end   
util.rb
module Kong
  module Util
    def self.flatten(cursor, parent_key = nil, memo = {})
      memo.tap do
        case cursor
        when Hash
          cursor.keys.each do |key|
            flatten(cursor[key], [parent_key, key].compact.join('.'), memo)
          end
        else
          memo["#{parent_key}"] = cursor
        end
      end
    end
  end
end

plugin.rbで使用されているattributesには、Plugin.newで与えた引数のキーの文字列と値が入っています。
Pluginクラスのcreateメソッドを実行すると、attributesのキーがflatten_configメソッドによって、ドットで結合された形となります。
よって、以下のようなconfigが引数で渡された場合、attributesにはconfig.minutes: 20, config.hour: 500がセットされます。

  config: {
    minute: 20,
    hour: 500
  }

このattributeをリクエストのボディに渡してリクエストするとエラーになるため、Routeの登録と同じように、config: {minutes: xxx}のようにリクエストのボディにセットする必要があるようです。
そのため、Pluginクラスのflatten_configメソッドを実行させなければ、想定通りのボディの形になるので、Pluginクラスのcreateメソッドをオーバーライドします。

kong2_compatible.rb
module Kong
  class Plugin
    def create
      super
    end
  end
・・・略・・・

このオーバーライドにより、Pluginを登録できるようになります。

※余談:config.hourをボディで渡しているのに、config.hourが無いとエラーが返ってくるのは結構謎でした。

Upstream, Target登録

Upstreamの登録、Upstremに紐づくTargetの登録、ServiceにTargetを紐付けて更新を行います。
Targetでは以下2つのAPIを登録します。

  • ドメイン
  • mockbin.org
  • httpbin.org

※ポートは443を使用

またServiceは前述で作成したServiceを使用するため、ここではServiceの登録は省略します。

Upstreamの登録やTargetの登録についても、READMEと同じ手順を行ってもエラーとなり登録ができません。
これは以下3つの問題のためです。

  • 1つ目: Route登録と同じように、upstream_idを使用するとschema violationになる。
  • 2つ目: Target.new時、引数にupstream_idがセットされていない場合、upstream_id未使用の例外が発生する。
  • 3つ目: Target.new時、引数にupstream_idがセットされていない場合、createメソッドを実行すると、Target登録用のエンドポイントへリクエストされない。http://kong:8001 へリクエストがされてしまう。

1つ目はRoute登録の対応と同じように、upstream: {id: xxx}の形式で引数に渡すことで解決できます。

2つ目については、Targetクラスの初期化時に、upstream_idがないため例外が投げられるためです。

target.rb
module Kong
  class Target
    include Base
・・・略・・・
    def initialize(attributes = {})
      super(attributes)
      raise ArgumentError, 'You must specify an upstream_id' unless self.upstream_id
    end
・・・略・・・

1つ目の問題を対応してupstream: {id: xxx}を使用する場合、例外が投げられないようにする必要があります。
そのため以下のようにオーバーライドして、初期化時に例外を投げないようにします。

kong2_compatible.rb
module Kong
・・・略・・・
  class Target
    def initialize(attributes = {})
      super(attributes)
    end
・・・略・・・    
  end 
end

3つ目については、Target登録のエンドポイント作成が、upstream_idを使用した作成になっているためです。
Target.new時、以下のようにBaseクラスのinitializeメソッドが実行されます。

base.rb
module Kong
  module Base
・・・略・・・  
    ##
    # @param [Hash] attributes
    def initialize(attributes = {})
      init_api_end_point
      init_attributes(attributes)
    end
・・・略・・・

    private

    def init_attributes(attributes)
      @attributes = {}
      attributes.each do |key, value|
        @attributes[key.to_s] = value
      end
      use_consumer_end_point if respond_to?(:use_consumer_end_point)
      use_api_end_point if respond_to?(:use_api_end_point)
      use_service_end_point if respond_to?(:use_service_end_point)
      use_upstream_end_point if respond_to?(:use_upstream_end_point)
    end
  end
end
target.rb
module Kong
  class Target
    include Base

    ATTRIBUTE_NAMES = %w(id upstream_id target weight).freeze
    API_END_POINT = '/targets/'.freeze
・・・略・・・    
    def use_upstream_end_point
      self.api_end_point = "/upstreams/#{self.upstream_id}#{self.class::API_END_POINT}" if self.upstream_id
    end
・・・略・・・    

initializeメソッドではinit_api_end_pointメソッドを呼び出します。
このメソッドで、エンドポイントを作成するuse_upstream_end_pointメソッドが子クラスにあれば、そのメソッドを呼び出します。
Targetクラスでは、use_upstream_end_pointメソッドを定義しているため、そのメソッドが呼び出されます。

このuse_upstream_end_pointメソッドがupstream_idを使用したエンドポイント作成となっているので、use_upstream_end_pointメソッドをオーバーライドして、upstream: {id: xxx}を使用したエンドポイント作成に修正します。
修正後のコードは以下の通りです。

kong2_compatible.rb
module Kong
・・・略・・・
  class Target
・・・略・・・
    def use_upstream_end_point
      self.api_end_point = "/upstreams/#{self.attributes['upstream'][:id]}#{self.class::API_END_POINT}" if self.attributes['upstream'] && self.attributes['upstream'][:id]
    end
  end 
end

※補足:attributes['upstream'][:id]となっている点について
ruby-kong-clientでは、Targetクラス初期化時にincludeしているBaseクラスで、init_attributesメソッドを呼び出します。
そのメソッド内部で、初期化時に与えた引数(attributes)から、キーの文字列と値を@attributesにセットしています。

    def init_attributes(attributes)
      @attributes = {}
      attributes.each do |key, value|
        @attributes[key.to_s] = value
      end
      use_consumer_end_point if respond_to?(:use_consumer_end_point)
      use_api_end_point if respond_to?(:use_api_end_point)
      use_service_end_point if respond_to?(:use_service_end_point)
      use_upstream_end_point if respond_to?(:use_upstream_end_point)
    end

そのためupstream: {id: xxx}を初期化時の引数で与えると、attributes['upstream'][:id]の形式で保存されるためです。

おわりに

kong-client-rubyを使用したKongへの登録について記載しました。
gemのメンテナンスが止まっているようなので、自分でできる範囲で対応できることをやっていきたいと考えています。
この記事が誰かのお役に立てれば幸いです。

Discussion