mackerelのグラフアノテーションをChatOpsに加えてみた
mackerelからリリースされたグラフアノテーションを追加できるコマンドラインツールをgoで作りました。
作ったコマンドをBot経由で実行できるうようにしてチームのChatOpsに加えていきたいのでbot scriptも作りました。
コマンド作成方法やslack経由で実行できるようにする手順などの紹介エントリです。
コマンドラインツールをつくる
それではmackerelにグラフアノテーションを追加するコマンドラインツールを作っていきます。
クライアントにはmackerel-client-goを使います。
cobraはKubernetesやDocker(distribution)などで採用されています。
cobraを使えば簡単にサブコマンドを作れます。
どんなコマンドを作るか
次のようなコマンドを作ります。
$ graph-annotation post -s 'アノテーションのタイトル' MackerelRole1 MackerelRole2
flagの-sでグラフに表示されるアノテーションのタイトルを指定できます。
パラメータにはrole名を複数指定できます。
チャットからメッセージを入力したいのでflagsを少なくします。
ファイル構成
ファイル構成は次のようになりました。
./cmd ├── graph-annotations │ └── main.go ├── post.go └── root.go
次からはcobraでコマンドを作る手順です。
コマンドの概要はroot.goにまとめる
コマンドの概要をroot.goにまとめます。
var RootCmd = &cobra.Command{ Use: "graph-annotations", Short: "Graph-annotations is a very simple tool for mackerel graph annotations API.", Long: "Complete API documentation is available at https://mackerel.io/api-docs/entry/graph-annotations", Run: func(cmd *cobra.Command, args []string) { }, } func init() { cobra.OnInitialize() }
グラフアノテーションの追加はpost.goにまとめる
グラフアノテーションの追加(mackerel-client-goをつかっているところ)はpost.goに実装しています。
func init() { postCmd.Flags().StringVarP(&title, "title", "s", "", "required: annotation title") postCmd.Flags().StringVar(&description, "description", "", "[optional] annotation details") postCmd.Flags().StringVar(&service, "service", os.Getenv("MACKEREL_SERVICE_NAME"), "required: service name, when it's empty value then will use enviroment variable of 'MACKEREL_SERVICE_NAME''") RootCmd.AddCommand(postCmd) } var title string var description string var service string var postCmd = &cobra.Command{ Use: "post", Short: "Creating graph annotations", Long: "Tne post command creates graph annotations via graph-annotations API.", Example: `graph-annotations post -s 'deploy application' ExampleRole1 ExampleRole2 graph-annotations post --service ExampleService -s 'deploy application' ExampleRole1 ExampleRole2`, RunE: func(cmd *cobra.Command, args []string) error { // flagのバリデーションを省略しています time := time.Now().Unix() client := mkr.NewClient(os.Getenv("MACKEREL_API_KEY")) annotation := &mkr.GraphAnnotation{ Service: service, Roles: args, From: time, To: time, Title: title, Description: description, } err := client.CreateGraphAnnotation(annotation) if err != nil { return errors.Wrap(err, "client error.") } fmt.Printf("completed. params title:%s, from:%d to:%d, service:%s, roles:%s", title, time, time, service, args) return nil }, }
このようにグラフの追加はpost.goにして削除が必要であればdelete.goを追加してサブコマンドを分けることができます。
※ 今回のコマンドではアノテーションをつける時刻範囲は現在時刻にしています。
※ mackerelのサービス名の指定がなければ環境変数の'MACKEREL_SERVICE_NAME'を参照しています。
post.goで整理したコマンドのヘルプ
post.goに整理したコマンドのヘルプは次のように参照できます。
$ graph-annotation help post Tne post command creates graph annotations via graph-annotations API. Usage: graph-annotations post [flags] Examples: graph-annotations post -s 'deploy application' ExampleRole1 ExampleRole2 graph-annotations post --service ExampleService -s 'deploy application' ExampleRole1 ExampleRole2 Flags: --description string [optional] annotation details --service string required: service name, when it's empty value then will use enviroment variable of 'MACKEREL_SERVICE_NAME'' (default "local") -s, --title string required: annotation title
hubotを起動してslackに常駐させたBotにコマンドを実行させる
ローカルでhubotを起動してslackを連携させます。
slackで発行したトークンを環境変数に加えます。
$ export HUBOT_SLACK_TOKEN=<your slack token>
hubotを起動します。
$ cd (path-to-hubot) $ ./bin/hubot --adapter slack
すべてローカルで実行することでローカルで起動したhubotがトークンを使いslackと連携できます。
slackでの利用イメージ
次のようなメッセージをBotに送ると先程つくったコマンドが実行されるようにします。
@localbot note -s 'プッシュ通知を送信しました' web
コマンドを実行するscript
コマンドを実行するscriptは次のように'note '以降の文字列をコマンドに渡して実行しています。
child_process = require 'child_process' module.exports = (robot) -> robot.respond /note\s+(.+)$/, (msg) -> option = msg.match[1] child_process.exec "graph-annotation post #{option}", (error, stdout, stderr) -> if !error output = stdout+'' msg.send output else output = stderr+'' msg.send output
まとめ
ソースを公開しています。
Pact Broker DockerコンテナをつかってPact Broker環境を構築してみた
Consumer-Driven Contract テストをフレームワークさせるPactをつかったサンプルプロジェクトを前回のエントリでは紹介しました。
naruto-io.hatenablog.com
前回のエントリではPactファイルをConsumerとProviderともにファイルシステムを用いて参照していました。
Pact Brokerを導入すればPactファイルのレポジトリ環境が構築できます。
今回のエントリではPact Brokerの構築を紹介します。
Pact Broker
Pact Brokerはこちらのgithubレポジトリから参照できます。
github.com
Pact Brokerの特徴を抜粋すると
- ConsumerとProviderの間のPact共有の課題を解決する
- Pactの管理をPact Brokerが一元管理するためConsumerとProviderともにPact管理/Pactリリースの手間がなくなる
- 最新のAPIドキュメント管理をPact Borkerが保証する
- サービスが提供するAPIのインタラクションが確認できる
- microserviceの依存関係をビジュアライズする
特徴についてはスクリーンショットも合わせて参照ください。
Pact Broker Docker container
Pact Brokerをローカルに構築していきます。Pact BrokerのDockerコンテナが用意されていますので今回はこちらを使います。
https://hub.docker.com/r/dius/pact_broker/
DBにPostgresを推奨していますので合わせてPostgresのDocker Containerを使います。
ここで2つのコンテナが必要になったのでDocker Composeで構成をまとめるついでにMackerelのコンテナも構成に入れてコンテナ監視もさせてみます。
dind(Docker in Docker)をつかってローカルでPact Brokerを動かす
Mackerelコンテナはホストのdocker.sockをvolumesを用いてコンテナにリンクさせる必要があるのでローカルで動かすときはDocker In Dockerがしたくなります。
ローカルPCにホストとなるコンテナを立てて、その中にPact Broker、Postgres、Mackerelのコンテナをぶら下げたいのですが、この課題を解決してくれるのがこちらの記事です。
blog.stormcat.io
この記事に習い次のような構成でDocker in DockerをローカルPCに構築しました。
pact-broker-host ├── container │ ├── mackerel-agent │ │ ├── Dockerfile │ │ ├── mackerel-agent │ │ │ └── mackerel-agent.conf │ │ └── startup.sh │ ├── pact-broker │ │ └── Dockerfile │ └── postgres │ ├── Dockerfile │ └── init_db.sql └── docker-compose.yml
dind + direnvコンボのローカル環境構築はとても捗ります。ぜひ参考に。
Pact Brokerの機能をザッと確認
先のコンテナ構成を起動しlocalhost:8080にアクセスするとPact Brokerの管理ツールがお目見えします。
1:ConsumerがPactファイルをPact Brokerにパブリッシュする
Pact BrokerにPactファイルがないと機能が確認できませんので次のPactファイルをPact Brokerへパブリッシュします。
Pactファイル
{ "consumer": { "name": "gateway_service" }, "provider": { "name": "user_service" }, "interactions": [ { "provider_state": "there is a user named 1192-User", "description": "a get request for a user", "request": { "method": "GET", "path": "/user/1192" }, "response": { "body": { "Name": "1192-User" }, "headers": { "Content-Type": "application/json" }, "status": 200 } } ], "metaData": { "pactSpecificationVersion": "1.1.0" } }
Pactファイルをパブリッシュする
curl -v -XPUT \-H "Content-Type: application/json" \ -d@pacts/gateway_service-user_service.json \ http://localhost:8080/pacts/provider/user_service/consumer/gateway_service/version/1.0.1
運用イメージとしてはこのパブリッシュアクションをCircleCIなどのビルドタスクに入れます。
※ 1.0.1のバージョンを更新していくことでPact Brokerがバージョン管理をしてくれます。
2:Pact Brokerではgateway_service(Consumer)とuser_serivce(Provider)の関係が管理されている
ConsumerとProviderのリスト
- user_serviceのConsumerがProviderであることが明確になっています。
パブリッシュしたPactファイルから生成されたAPIドキュメント
ConsumerとProviderのNetwork Graph
- サービスから矢印が向いているとConsumer→Providerの関係を表します。
- このサービスはどのサービスから参照されているか図解してくれます。
※ 関係図を充実させるためにgateway_serviceとuser_serivce以外のサービスも増やしました。
まとめ
Pactを使えばCDCテストのフレームワーク化の恩恵が受けられ更にPact Brokerを導入することでPactファイルの管理からAPIドキュメント、ConsumerとProviderのNetwork GraphなどなどCDCテストの運用の手助けが手厚く受けられます。
管理下のmicroserviceすべてにCDCテスト + Pactの導入は腰が重いのでクリティカルなmicroservice間の連携の一部から導入を開始したり、外部に公開するAPIはProviderとしてPactファイルのテストを導入してConsumer側にAPI仕様をクリアにすることもできますしCDCテスト+Pactの知見を活かせるところを今後は探っていきます。
ソースを公開しています
関連エントリ
golang - Kotlinのmicroservice構成のConsumer-Driven Contract testingをpactをつかって作ってみた
今回はConsumer-Driven Contract testingのサンプルを作ってみました。以前のSelenideを使ったE2Eの記事の流れからConsumer-Driven Contract testingも試してみようというモチベーションです。
Consumer-Driven Contract testingとは
Consumer-Driven Contract(以下、CDC)で検索すると定義についての記事がたくさん見つかりますので詳細な説明は他の記事にお任せします。
- Consumer-Driven Contracts testingを徹底解説! - Qiita
- Consumer-Driven Contracts: A Service Evolution Pattern
CDCテストはmicroservice architectureをベースに複数のmicroserviceでサービス全体を構築しサービスが成長する過程で直面する問題に向き合うためのテスト手法の1つ、というのが私の理解です。
どういった問題に直面するか
- 複雑化するmicroservice間の依存関係
- 依存関係が正常に保たれているかを検証するためのコスト
- 不安要素の蓄積がmicroserviceの拡張難易度を上げる
これらの問題要素がデスマーチのように回り始めるとmicroserviceの拡張が止まり更にサービス全体の成長が止まります。
問題を解消するには
多くのmicroservice間の連携はAPIの提供と利用で成り立っています。
2つのmicroserviceの関係はAPIを提供する側(Provider)、APIを利用する側(Consumer)になります。またConsumerのmicroserviceの機能はProviderが提供するAPIを基盤として動きます。このように整理するとProviderが提供するAPIに不備があったり不明なAPI仕様があったりするとConsumerは困ってしまいます。
そのためにお互いにAPIのルール(Contract)を定義します。そしてルールの定義をConsumer側が行い(Consumer-Driven Contract)、Providerがルールを守ることでmicroservice間の連携を保ちます。
このCDCテストを複数のmicroservce間で継続的に行うことで先に挙げた問題を解消します。
Pact
Pactはmicroservce間のCDCテストを順序立て実行するためのフレームワークです。
github.com
今回はPactをインプリメントした次のプロジェクトを利用してCDCテストを作りました。
github.com
Pactをインプリメントした各種言語のライブラリを使う
次のプロジェクトに各種言語のプロジェクトがまとまっていますので参考にしました。
https://github.com/DiUS/pact-jvm
各ライブラリにはCDCテストをするために次のような仕組みを用意しています。
- ConsumerがProviderのAPIをモック化する仕組み
- ConsumerとProviderがルールを共有するためのPactファイルを生成する仕組み
- ProviderがAPI仕様を満たしているかテストする仕組み
golangのライブラリ
kotlinのライブラリ
Pact仕様はversion1.1を使う
go-langのライブラリが1.1までの対応のため今回作ったテストもversion1.1を使っています。
- GitHub - pact-foundation/pact-specification at version-1.1
- 現在はVersion4まで開発が進んでいます。その他のVersionはこちらで確認できます。
Consumer-Driven Contract testをつくる
まずmicroserviceの定義ですがConsumerのmicroserviceはgolang、Providerのmicroserviceはkotlinで構築しました。
シンプルなユーザ情報を取得するAPIをProvider(kotlin)が提供しConsumer(golang)がAPIを利用するという構成でCDC testをつくっていきます。
Consumerのコード(golang)
Provider APIへ接続するClient定義です。ユーザIDを受け取りAPIリクエストを送るシンプルなつくりです。
package client import ( "encoding/json" "fmt" "net/http" ) type UserClient struct { baseURL string } type User struct { Name string } func (c *UserClient) GetResource(id int) (*User, error) { url := fmt.Sprintf("%s/user/%d", c.baseURL, id) req, _ := http.NewRequest("GET", url, nil) client := &http.Client{} resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() var res User decoder := json.NewDecoder(resp.Body) if err := decoder.Decode(&res); err != nil { return nil, err } return &res, nil }
上記のClinetのリクエストとレスポンスをテストコードでモック化しAPI仕様のバリエーションを作成しPactファイルを生成します。
次のコードはテストコードです。
package client import ( pact "github.com/SEEK-Jobs/pact-go" "github.com/SEEK-Jobs/pact-go/provider" "net/http" "testing" ) func buildPact() pact.Builder { return pact. NewConsumerPactBuilder(&pact.BuilderConfig{PactPath: "../../pacts"}). ServiceConsumer("consumer_user_client"). HasPactWith("provider_user_client") } func Test_ContractUserClientProvider_StatusIsOk(t *testing.T) { builder := buildPact() ms, msUrl := builder.GetMockProviderService() request := provider.NewJSONRequest("GET", "/user/1192", "", nil) header := make(http.Header) header.Add("content-type", "application/json") response := provider.NewJSONResponse(200, header) response.SetBody(`{"Name": "1192-User"}`) if err := ms.Given("fetch user by id 1192"). UponReceiving("get request for user with id 1192"). With(*request). WillRespondWith(*response); err != nil { t.Error(err) t.FailNow() } // Test request user client client := &UserClient{baseURL: msUrl} if _, err := client.GetResource(1192); err != nil { t.Error(err) t.FailNow() } // Verify registered interaction if err := ms.VerifyInteractions(); err != nil { t.Error(err) t.FailNow() } // Clear interaction for this test scope, if you need to register and verify another interaction for another test scope ms.ClearInteractions() //Finally, build to produce the pact json file if err := builder.Build(); err != nil { t.Error(err) } }
- buildPact()でPactファイルの生成フォルダの指定とConsumer名称のconsumer_user_clientとProvider名称のprovider_user_clientを定義しています。
- Test_ContractUserClientProvider_StatusIsOk(t *testing.T)ではUserClientのリクエストとレスポンスを定義しモック化しPactファイルを生成します。
- fetch user by id 1192の文字列はprovider stateとして定義されます。このprovider stateの数がAPI仕様の数と一致するようにUserClientのリクエストとレスポンスを定義します。例えばデータが存在しない404エラーレスポンスや入力値が不正な400エラーレスポンスが必要であればprovider stateとして定義します。
テストを実行してPactファイルを生成します。
$ go test -v ./... === RUN Test_ContractUserClientProvider_StatusIsOk --- PASS: Test_ContractUserClientProvider_StatusIsOk (0.00s) PASS ok github.com/nsoushi/cdc-test/pact-go-consumer/client 0.014s $ ls ../pacts consumer_user_client-provider_user_client.json
生成したPactファイルは次のようになりました。
{ "consumer": { "name": "consumer_user_client" }, "provider": { "name": "provider_user_client" }, "interactions": [ { "provider_state": "fetch user by id 1192", "description": "get request for user with id 1192", "request": { "method": "GET", "path": "/user/1192" }, "response": { "body": { "Name": "1192-User" }, "headers": { "Content-Type": "application/json" }, "status": 200 } } ], "metaData": { "pactSpecificationVersion": "1.1.0" } }
このPactファイルをProvider(kotlin)が読み込みProvider側で更にテストを実行します。
Providerのコード(kotlin)
@RunWith(PactRunner::class) @Provider("provider_user_client") @PactFolder("pacts") @WebAppConfiguration open class ContractUserTest { lateinit var wireMockServer: WireMockServer companion object { // mock port private val port = 8080 @TestTarget lateinit var target: Target @BeforeClass @JvmStatic fun setUpService() { target = HttpTarget(port) } } @Before fun before() { wireMockServer = WireMockServer(port) wireMockServer.start() WireMock.configureFor(port) } @State("fetch user by id 1192") open fun toDefaultState() { val path = "/user/1192" val target = UserController() val mvc = MockMvcBuilders.standaloneSetup(target).build() val mvcResult = mvc.perform(MockMvcRequestBuilders.get(path)).andReturn() WireMock.stubFor(WireMock.get(WireMock.urlEqualTo(path)) .willReturn(WireMock.aResponse() .withStatus(mvcResult.response.status) .withHeader("Content-Type", mvcResult.response.getHeader("Content-Type")) .withBody(mvcResult.response.contentAsString))) } }
- @RunWith(PactRunner::class)/テスト実行クラスにPactRunnerクラスを指定
- @Provider("provider_user_client")/Pactファイルから参照するProvider名称を指定
- @PactFolder("pacts")/Pactファイルの保存ディレクトリを指定
- private val port = 8080/PactRunnerクラスはテスト実行時にポートをポーリングし実行したリクエストとレスポンスを監視します。このコードでポーリングするポートを指定します。
- @State("fetch user by id 1192")/Pactファイルにあるinteractionsの1つのprovider_stateを指定しています。このメソッドのスコープ内でAPIのリクエストを実行しレスポンスをモック化し定義したAPIの振る舞いを再現することでAPI定義と一致しているか検証が行われます。(ポーリングの仕組みがこのリクエストとレスポンスを検証します)
Consumerが定義したPactファイルをProviderがリクエストとレスポンスをモック化してAPI定義を満たしているか検証します。今回はPactファイルにあるinteractionsが1つのみでしたが複数ある場合はProviderのテストでも複数の@Stateを作り検証します。
このようにConsumerとProviderの両者で共通の定義を不足なく検証を行うことができます。
まとめ
2つの異なる言語のmicroservice間のCDCテストをPactの仕組みを使いテストを行いました。ConsumerとProviderで最新の定義ファイルを共有することでデプロイ時などにテストを走らせることでmicroservice間の連携エラーになることを防げます。
今回は定義ファイルをファイルシステムで参照しましたがgithubにファイルをプッシュし参照する方法やbrokerと呼ばれるPactファイルの中継地点を介する方法などPactファイルを参照する様々な仕組みがあります。