docker
microservices

Kong を使った Microservices Architecture API Gateway Pattern の実現方法

More than 1 year has passed since last update.

この記事は、Kong - API Gateway Pattern速習会@Wantedlyの資料として作られたものです。

サービスが大きくなるとやりたくなってくること

  • より高速な実装に置き換えたい
  • APIを複数のサービスに分けて開発したい
    • マイクロサービス化

何故マイクロサービスか

いろいろ理由は言われてるけど...

  • 人が扱える大きさの限界
  • 「明確な」境界が必要
    • 名前空間やスコープなど、プログラミング言語でも使用している
    • 一段上の概念だと思うと良い
    • 大きいメソッドが管理できないのと同様に、大きいサービスも管理できない
  • 組織体系に影響を与える
    • 100人のチームで開発するのが嫌ならやった方が良い
    • 数人のチーム * 20個とかでもコンフリクト無く進められる

ここで行うこと

どうやって複数のAPIを

  • 連携するか
  • 管理するか
    • 存在するAPIを見失わないか
    • 使っていいAPIだけ使えるか
  • 変更するか
    • 特に内部の実装言語の変更するか

という問題を解決できるソフトウェアの使い方を学び、ある程度触ってみる。
(ついでにDocker Composeとかにも詳しくなる)

API Gateway Pattern

「見た目はモノリシック、実装はマイクロサービス」

  • 一箇所見に行けば全てのAPIを見つけられる
  • 細かい権限管理も可能
  • 各APIで何回も実装しないといけない部分を省略できる
    • Authentication
    • Rate Limiting

実際の例

https://console.developers.google.com/apis/library

Kobito.6jXok7.png

どう実現するか

Kongを使いましょう。

Kobito.LNDNIw.png

公式サイトより。左がLegacy、右がKongを使った場合。

他の方法

自分の知る限り、実際いいのが他にないです。

あり得る方法:

  • Nginxで自前実装
    • KongはOAuthまで実装しているのでそれを自前でやるのは大変そう
  • AWS API Gateway
    • 下におけるものがLambda等なので、マイクロを通り越してナノサービスな気がして実際使いにくそう

気をつけなければならないこと

  • Gatewayが死んだら全てのAPIが終わり
    • 絶対に(99.99...%)死なないようにする
    • 早急な復旧手順を作っておく

Kongの場合

Kongを使ってみる

インストール

Dockerを使って行います。
以下の2通りのどちらでもいいです。

  1. https://getkong.org/install/docker/ に従うだけ
  2. Docker Composeで立ち上げる

1. ドキュメントの通りにやる

$ docker pull cassandra:2.2.5
$ docker pull mashape/kong
$ docker run -p 9042:9042 -d --name cassandra cassandra:2.2.5

ここでちょっと待つ。(cassandraがきちんとreadyになってからでないと次が失敗する)

$ docker run -d --name kong \
            --link cassandra:cassandra \
            -p 8000:8000 \
            -p 8443:8443 \
            -p 8001:8001 \
            -p 7946:7946 \
            -p 7946:7946/udp \
            --security-opt seccomp:unconfined \
            mashape/kong

動いているか確認

$ curl http://$(docker-machine ip default):8001

2. Docker Composeで立ち上げる

作業用ディレクトリを作り、docker-compose.ymlを以下のように書きます。

docker-compose.yml
version: '2'
services:
  kong:
    image: mashape/kong
    depends_on:
      - cassandra
    ports:
      - 8000:8000
      - 8443:8443
      - 8001:8001
      - 7946:7946
      - 7946:7946/udp
    security_opt:
      - seccomp:unconfined
  cassandra:
    image: cassandra:2.2.5
    ports:
      - 9042:9042

このあと

docker-compose up

するだけです。

ただ以下に書くWaitに関する悲しい現実があるので、1回目はkongの立ち上げが失敗すると思います。その場合、

  • 2回docker-compose up
  • 以下のように別々で、少し間を空けてコマンドを叩く
$ docker-compose up cassandra
$ docker-compose up kong

をするとよいです。

悲しいことにdepends_on系の書き方全て、きちんと立ち上がるまで待ってくれない

Compose always starts containers in dependency order....
However, Compose will not wait until a container is “ready”
https://docs.docker.com/compose/startup-order/

3. できあいのものを使う

Kongだけじゃなく、goやrailsで実装したサンプルJSON APIを含んだものを用意しました。

https://github.com/awakia/modern-architecture-2016

git clone git@github.com:awakia/modern-architecture-2016.git
docker-compose up cassandra

少し待って、

docker-compose up --no-deps kong

時間があるようだったら、

docker-compose build

しておいてください。

既に立っているコンテナの止め方

docker rm -f <container-name>

で立ち上がっているコンテナを止めて削除することが出来ます。

docker rm -f $(docker ps -a -q)

で、全てのコンテナを消すことが出来ます。

ちなみに、

docker rm $(docker ps -a --filter 'status=exited' -q) 

で、全ての終了しているコンテナを消すことが出来ます。

APIの登録

関連公式ドキュメント:

まず知っておくと良いこと

  • 8001番がAPIのAdmin用 (admin_api_listen)
  • 8000番がHTTP経由のAPIのユーザー用 (proxy_listen)
  • 8443番がHTTPS経由のAPIのユーザー用 (proxy_listen_ssl)

なので管理系のリクエストは基本的に以下の形になります。

curl -X POST http://$(docker-machine ip default):8001/apis/ --data 'hoge=fuga'

APIの準備

先ほどの

git clone git@github.com:awakia/modern-architecture-2016.git

を使い、

docker-compose up go-api

しましょう。

中味は go-api/server.go とそれを立ち上げるDockerfileなので、多少目を通しておきましょう。

APIの追加

$ curl -i -X POST \
%   --url http://$(docker-machine ip default):8001/apis/ \
%   --data 'name=go-api' \
%   --data 'upstream_url=http://go-api:5000' \
%   --data 'request_path=/go'
HTTP/1.1 201 Created
Date: Wed, 30 Mar 2016 19:40:37 GMT
Content-Type: application/json; charset=utf-8
Transfer-Encoding: chunked
Connection: keep-alive
Access-Control-Allow-Origin: *
Server: kong/0.7.0

{"upstream_url":"http:\/\/go-api:5000","request_path":"\/go","id":"93148ea0-0776-4f18-a426-605418158c17","created_at":1459366837000,"name":"go-api"}

コピー用

curl -i -X POST \
  --url http://$(docker-machine ip default):8001/apis/ \
  --data 'name=go-api' \
  --data 'upstream_url=http://go-api:5000' \
  --data 'request_path=/go'

登録されているAPIの確認

コマンドライン上で確認

$ curl http://$(docker-machine ip default):8001/apis | jq

ブラウザで確認

$ open http://$(docker-machine ip default):8001/apis

実際に動いているか確認

open http://$(docker-machine ip default):8000/go

Kobito.OLDmw6.png

追加したAPIの修正

https://getkong.org/docs/0.7.x/admin-api/#update-api

$ curl -i -X PATCH \
%   --url http://$(docker-machine ip default):8001/apis/go-api \
%   --data 'strip_request_path=true'
HTTP/1.1 200 OK
Date: Wed, 30 Mar 2016 19:55:45 GMT
Content-Type: application/json; charset=utf-8
Transfer-Encoding: chunked
Connection: keep-alive
Access-Control-Allow-Origin: *
Server: kong/0.7.0

{"upstream_url":"http:\/\/go-api:5000","request_path":"\/go","id":"93148ea0-0776-4f18-a426-605418158c17","name":"go-api","strip_request_path":true,"created_at":1459366837000}

コピー用

curl -i -X PATCH \
  --url http://$(docker-machine ip default):8001/apis/go-api \
  --data 'strip_request_path=true'

何が変わったか確認

open http://$(docker-machine ip default):8000/go

Kobito.5Xu8Qr.png

/go => /

としてupstreamのAPIに送られるようになっている!

プラグインの利用

https://getkong.org/plugins/ にあるように様々なプラグインがあります。

現状、以下の6種類にわけられています。

  • Authentication
  • Security
  • Traffic Control
  • Analytics & Monitoring
  • Transformations
  • Logging

また、プラグインは自分で作ることもできます

OAuthプラグインの利用

https://getkong.org/plugins/oauth2-authentication/

これが、この中で一番使うのが難しいプラグインだと思います。

なお、公式チュートリアルではもう少し簡単なAuthenticationの例を使っているのでこれがToo muchな人はそちらを参考にしてください

OAuthプラグインの追加

https://getkong.org/plugins/oauth2-authentication/

$ curl -X POST http://$(docker-machine ip default):8001/apis/go-api/plugins \
%   --data "name=oauth2" \
%   --data "config.enable_client_credentials=true"
{"api_id":"93148ea0-0776-4f18-a426-605418158c17","id":"56971111-d189-4377-811f-deee02f1374d","created_at":1459371154000,"enabled":true,"name":"oauth2","config":{"mandatory_scope":false,"token_expiration":7200,"enable_implicit_grant":false,"hide_credentials":false,"provision_key":"21eaa43d19934b60a7198ab463040af0","accept_http_if_already_terminated":false,"enable_authorization_code":true,"enable_client_credentials":true,"enable_password_grant":false}}

コピー用

curl -X POST http://$(docker-machine ip default):8001/apis/go-api/plugins \
  --data "name=oauth2" \
  --data "config.enable_client_credentials=true"
  • config.enable_client_credentials

というオプションをtrueにしています。

これはOAuthの4つのGrantフローのうち最も簡単な"Client Credentials Grant"というフローを有効にしています。

ページの確認

open http://$(docker-machine ip default):8000/go

Kobito.nm3NIL.png

access_tokenが提供されていないので、error: invalid_requestになっていればうまく動いています。

OAuthの4つのフロー

OAuthでは、Access Tokenを得るためにどう認証するかが大きく4つに分かれています。
フローはいろいろありますが、結局はAccess Tokenを得て、それと一緒にAPIリクエストをしたらきちんと結果が帰ってくることだけわかっていれば大丈夫です。

名前 どんな時に使うか
Authorization Code Grant RailsなどバックエンドのサーバーサイドでOAuthする時
Implicit Grant JavascriptなどWebブラウザのクライアントサイドでOAuthする時
Resource Owner Password Credentials Grant PCやモバイルアプリなどで、他の方法が使えない環境でOAuthする時
Client Credentials Grant 社内のAPIサーバー等信頼できるクライアントからOAuthする時

4つの承認フロー - OAuth 2.0 の仕様を読むために

Kongのデフォルトでは、1番上のWebバックエンドサーバーでの認証でよく使われている"Authorization Code Grant"フローだけONになっています。

ただ、内部公開用APIだと、このフローはUIまで作らないといけなくめんどうなので、ここでは"Client Credentials Grant"フローを使います。

Client Credentials Grant で Access Token を取得

以下のフローで行います。

  1. まずAPIを使うサービス(または人)を表すConsumerを作成する
    • 例えば、Facebook APIをWantedlyが使う時、Wantedly自体、もしくはWantedlyを作っている開発者がConsumer
  2. ConsumerがApplicationを作る
    • 例えば、wantedly.comやSyncがApplication
  3. Access Tokenを取得

1. Consumerの作成

example_consumerというコンシューマを作る場合、以下のようにします。

$ curl -X POST http://$(docker-machine ip default):8001/consumers/ --data username=example_consumer
{"username":"example_consumer","created_at":1459404233000,"id":"b5b221ec-12b7-45be-9830-3a84a97fbb6e"}

2. Applicationの登録

example_consumerというコンシューマが、サイト https://example.com 内の"Example App"というものを作ろうとした場合以下のようになります。

$ curl -X POST http://$(docker-machine ip default):8001/consumers/example_consumer/oauth2 --data name=Example%20Application --data redirect_uri=https://example.com/oauth2_callback 
{"consumer_id":"b5b221ec-12b7-45be-9830-3a84a97fbb6e","client_id":"da5da65dca1044a2aac7d86a694b9536","id":"fdac9287-0c4b-4ffd-89a0-8ad711b6f758","name":"Example App","created_at":1459404289000,"redirect_uri":"https:\/\/example.com\/oauth2_callback","client_secret":"8df2d9f629ee4d049292614e1ee0524f"}

Application登録の際の必須項目は以下の2つです。

これにより、以下のものが手に入ります。

  • client_id
  • client_secret

ちなみに、この2つの値をパラメータで与えて直接指定してしまうこともできます。

3. Access Tokenの取得

先ほど取得した、client_id、client_secretを元にリクエストを行います。
リクエストの際には、この2つの値の他に、Grantの方法を表すgrant_type=client_credentialsをつけなければなりません。

$ curl -k -X POST https://192.168.99.100:8443/oauth2/token \
% --data grant_type=client_credentials \
% --data client_id=da5da65dca1044a2aac7d86a694b9536 \
% --data client_secret=8df2d9f629ee4d049292614e1ee0524f
{"token_type":"bearer","access_token":"4f739dc516a74360bcd00d47fc517955","expires_in":7200}

コピー用

curl -k -X POST https://192.168.99.100:8443/oauth2/token \
--data grant_type=client_credentials \
--data client_id=da5da65dca1044a2aac7d86a694b9536 \
--data client_secret=8df2d9f629ee4d049292614e1ee0524f

見事Access Tokenが取れていることがわかります。

これを元にアクセスをすれば、

Kobito.LNuPLG.png

とこれまでどおりアクセス出来るようになっていることがわかります。

Access Token取得のハマリポイント

実は、この3個めの段階はいくつかハマリポイントがあります。

  1. 使用するAPIのRequestHostを指定しておかなければならない
  2. HTTPSでPOSTアクセスしなければならない

実は上のリクエストではこの2つの問題を先に解決した結果を載せています。
これから、その2つのステップを解説します。

3.1. APIのRequest Hostを指定

何も考えずにリクエストすると、

$ curl -X POST http://$(docker-machine ip default):8000/oauth2/token \
--data grant_type=client_credentials \
--data client_id=da5da65dca1044a2aac7d86a694b9536 \
--data client_secret=8df2d9f629ee4d049292614e1ee0524f
{"request_path":"\/oauth2\/token","message":"API not found with these values","request_host":["192.168.99.100"]}

エラーとしてはそんなAPIは、そのリクエストホストでは存在しないよという感じになっています。

これは、先ほど作成したgo-apiのRequest Hostを指定していないためです。

3.1.1. Request Hostの追加方法1

request_hostとしてdocker-machine ip defaultの値、192.168.99.100を指定してあげると解決できます。

$ curl -X PATCH --url http://$(docker-machine ip default):8001/apis/go-api --data request_host=$(docker-machine ip default)
{"upstream_url":"http:\/\/go-api:5000","request_path":"\/go","id":"93148ea0-0776-4f18-a426-605418158c17","name":"go-api","strip_request_path":true,"created_at":1459366837000,"request_host":"192.168.99.100"}
3.1.2. Request Hostの追加方法2

適当にRequest Hostが192.168.99.100のOAuthつきAPIを登録してしまいましょう。

$ curl -i -X POST \
  --url http://$(docker-machine ip default):8001/apis/ \
  --data 'name=for_host' \
  --data 'upstream_url=http://go-api:5000' \
  --data request_host=$(docker-machine ip default)
HTTP/1.1 201 Created
Date: Thu, 31 Mar 2016 09:58:50 GMT
Content-Type: application/json; charset=utf-8
Transfer-Encoding: chunked
Connection: keep-alive
Access-Control-Allow-Origin: *
Server: kong/0.7.0

{"upstream_url":"http:\/\/go-api:5000","id":"800b9b40-4a23-43db-b9ab-e1d214f6c7c3","name":"for_host","created_at":1459418330000,"request_host":"192.168.99.100"}
$ curl -X PUT http://$(docker-machine ip default):8001/apis/for_host/plugins \
  --data "name=oauth2" \
  --data "config.enable_client_credentials=true" \
  --data "config.token_expiration=0"
{"api_id":"800b9b40-4a23-43db-b9ab-e1d214f6c7c3","id":"67d4d8d2-f1fb-4f25-9a0b-ab106cfd4191","created_at":1459418404000,"enabled":true,"name":"oauth2","config":{"mandatory_scope":false,"token_expiration":0,"enable_implicit_grant":false,"hide_credentials":false,"provision_key":"cda2051a3f6b43d1aa079ca6b7d078a5","accept_http_if_already_terminated":false,"enable_authorization_code":true,"enable_client_credentials":true,"enable_password_grant":false}}

気持ち悪いけれど、実はこちらがオススメです。
実はRequestHostは他のAPI含めユニークに指定しないといけません。なので、複数のrequest_pathを使う実装が出来なくなてしまいます。

確認

この上で、先ほどと同じコマンドをもう一度打ってみましょう。

curl -X POST http://$(docker-machine ip default):8000/oauth2/token \
--data grant_type=client_credentials \
--data client_id=da5da65dca1044a2aac7d86a694b9536 \
--data client_secret=8df2d9f629ee4d049292614e1ee0524f
{"error_description":"You must use HTTPS","error":"access_denied"}

HTTPSでアクセスしろと言われていますが、一つ進みましたね。

3.2. HTTPSでのアクセス

HTTPS用のポートは8443なので、そこのアクセスしてあげれば良いはずです。

ただ普通にリクエストしただけでは、SSLのcertificateの警告がでて、curl: (60) SSL certificate problem: Invalid certificate chainといったエラーになってしまいます。

実際に使う際は、SSL Pluginをつかて、Certificateを入れたらいいですが、今回はローカルで実験するだけなのでこの警告を無視しましょう。

curlだと-kオプションをつけるとセキュリティの警告を無視できます。

$ curl -k -X POST https://$(docker-machine ip default):8443/oauth2/token \
--data grant_type=client_credentials \
--data client_id=da5da65dca1044a2aac7d86a694b9536 \
--data client_secret=8df2d9f629ee4d049292614e1ee0524f
{"token_type":"bearer","access_token":"8d32e180038647e388d8eee748b82908","expires_in":7200}

これで新しいAccess Tokenが得られる状態になりました。

OAuth Pluginの削除

OAuthがあると今のうちはいちいちAccess Tokenを入れないとアクセスできなくなるので、消してしまいましょう。

PluginにはIDがあります。それを取得しましょう

$ curl http://$(docker-machine ip default):8001/apis/go-api/plugins | jq '.data[0].id' 
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   476    0   476    0     0  34472      0 --:--:-- --:--:-- --:--:-- 36615
"ce0d7078-31ea-4475-ad92-7fd567a4a106"

これをコピーして、DELETEクエリを発行しましょう。

curl -X DELETE http://192.168.99.100:8001/plugins/ce0d7078-31ea-4475-ad92-7fd567a4a106

おまけ: こんなことがしたい

  • Expireされると困るので、Expireしないようにしたい。
    • 追加したAPIのConfigurationで、config.token_expiration=0と設定すれば出来ます。
  • コンシューマごとに使えるAPIの権限管理をしたい
    • ACL Pluginを使えば出来ます。
    • 具体的には、ConsumerにGroupを付与し、各APIはそのGroupをホワイトリストに入れるかブラックリストに入れるかのどちらかを選べます。
  • OAuthで、scopeを使いたい
    • config.scopesの[configuration]で指定できます。
    • Authorizeの時はscopeパラメータに渡します。
    • API側では、X-Authenticated-Scopeに認証済みのものがカンマ区切りで渡されるのでそれを使います。

参考URL

別の実装も立ち上げてみよう

Railsのサーバーも立ち上げてみよう

docker-compose up rails-api

をしてrails-apiが立っていることを確認

$ curl -i -X POST \
%   --url http://$(docker-machine ip default):8001/apis/ \
%   --data 'name=rails' \
%   --data 'upstream_url=http://rails-api:3000' \
%   --data 'request_path=/rails' \
%   --data 'strip_request_path=true'
HTTP/1.1 201 Created
Date: Thu, 31 Mar 2016 09:47:12 GMT
Content-Type: application/json; charset=utf-8
Transfer-Encoding: chunked
Connection: keep-alive
Access-Control-Allow-Origin: *
Server: kong/0.7.0

{"upstream_url":"http:\/\/rails-api:3000","request_path":"\/rails","id":"74c79c94-d723-4434-aaae-27af812c0eed","created_at":1459417632000,"strip_request_path":true,"name":"rails"}

コピー用

curl -i -X POST \
  --url http://$(docker-machine ip default):8001/apis/ \
  --data 'name=rails-api' \
  --data 'upstream_url=http://rails-api:3000' \
  --data 'request_path=/rails' \
  --data 'strip_request_path=true'

立っているか確認

open http://$(docker-machine ip default):8000/rails

Kobito.NNC6UD.png

これだけですが、RailsでもGoでも同じHost内で両方の実装が立ち上がることが確かめられました。
それぞれの言語で、同じ実装をして移行をするか、役割分担をする構造にするかはアナタ次第です。

まとめ

最初あった課題

どうやって複数のAPIを

  • 連携するか
  • 管理するか
    • 存在するAPIを見失わないか
    • 使っていいAPIだけ使えるか
  • 変更するか
    • 特に内部の実装言語の変更するか

どうやって複数のAPIを連携するか

  • それぞれのマイクロサービスでAPIを作っていき、request_pathにより分岐させる
  • Rate Limitingにより、内部APIでも他に高負荷をかけすぎる実装を排除

どうやって複数のAPIを管理するか

  • 存在するAPIを見失わないか
    • open http://$(docker-machine ip default):8001/apis
    • もちろんこれを元にUIを作っても良い
  • 使っていいAPIだけ使えるか
    • ACL Pluginを使って権限管理
    • 内部からの呼び出しも、OAuthのアクセストークンを使う

どうやってAPIの内部の実装言語の変更するか

request_pathにより、それぞれのAPIの実装を分けておき徐々に移行していく。つまり、

  1. /api/user, /api/company, /api/job をれぞれ最初は同じUpstreamURL
  2. /api/user だけ別の実装をし、UpstreamURLを変える
  3. これを続けていって移行完了

トラブルシューティング

ネットワークの問題でImageがPull出来ない

注: 本当にネットワークにつながってないなんてことがないかは確認してください。

エラーメッセージ

Network timed out while trying to connect to https://index.docker.io/v1/repositories/usename/reponame/images. You may want to check your internet connection or if you are behind a proxy.

対処法

virtual machineの問題みたいです。再起動させると直ります。

docker-machine restart default

http://stackoverflow.com/questions/31990757/network-timed-out-while-trying-to-connect-to-https-index-docker-io

サービスが動いているはずのURLにアクセスしてもつながらない

対処法

まず、docker-machineを使っている場合は、localhost, 127.0.0.1ではなく

docker-machine ip default

で出てくるIPアドレス(通常192.168.99.100)に繋ぐ必要があるので、そこが正しいか確認します。

正しいIPで正しいPortに接続している場合、とりあえず、

docker ps

の出力を見て、ちゃんと動いているか見てみましょう。

何らかの場合で死んでいたり、portが正しくexposeされていないなどがわかったりします。

死んでいる場合、そもそも表示されていないはずなので、

docker ps -a

でexitedになっているもののnameを見つけ

docker logs <container-name>

で、何故死んだかを把握すると良いです。

動いている場合

docker logs <container-name>

でエラーが吐かれていないかログを見たり、

docker exec -it <container-name> bash

など(imageによって、bashbin/shにする必要があったりします)で、中に入って、

  • fileに吐かれているログを見てみる
  • 実際に起動コマンドやコンソールコマンドを叩いてみる
  • ネットワークの状況(ifconfig, ip addrなど)を確認してみる

などをして原因を探ります。

rails-apiコンテナでDBが作成されていない、migrateされていない

対処法

docker上でコマンドを走らせるにはdocker runもしくは、docker-compose runを使います。
今回の場合、以下のコマンドで動くはずです。

docker-compose run rails-api rake db:create db:migrate

もちろん何らかの理由でDBに接続できていないと出来ないので、その場合は、

  • きちんとDBコンテナが立っているか
    • docker psで確認し、docker-compose up postgresで起動
  • ネットワークはpostgresというホスト名で引きに行くことが出来るか
    • docker exec -it <rails-container-name> bashで中にはいり、ping postgresが通るか調べる

をすると良いです。

kongのコンテナが立っていない

対処法

cassandraのコンテナが立ち上がって、readyになっていない状態でkongを立ち上げようとすると死んでしまいます。
ちょっと待ってから立ち上げたらいいだけなので、もう一度立ち上げてみる(docker-compose up kongなど)とすんなり動くはずです。