kakakakakku blog

Weekly Tech Blog : Keep on Learning 👍

Envoy の Health Checking と Outlier Detection の違いを学べる「Detecting Down Services with Health Checks」を試した

今回は「Try Envoy」の「Detecting Down Services with Health Checks」を紹介する.高可用性のために Envoy でサポートされている「ヘルスチェック (Health Checking)」と「外れ値検出 (Outlier Detection)」を学べる.

Detecting Down Services with Health Checks

手順は以下の「計8種類」ある.

  • Step.1 「Proxy Configuration」
  • Step.2 「Add Health Check」
  • Step.3 「Start Proxy」
  • Step.4 「Failed Services」
  • Step.5 「Healthy Services」
  • Step.6 「Total Failure」
  • Step.7 「Outlier Detection Configuration」
  • Step.8 「Testing Outlier Detection」

www.envoyproxy.io

www.katacoda.com

Step.1 「Proxy Configuration」

まず,用意された envoy.yaml の設定を確認する.もう完全に読み慣れたと思う.ポイントは clusters で,全てのリクエストを 172.18.0.3 と 172.18.0.4 にラウンドロビンでルーティングする.例えば「もし一部のエンドポイントに障害が発生した場合」に Envoy ではどう対応したら良いのだろう?そこで「ヘルスチェック (Health Checking)」を使う.

static_resources:
  listeners:
  - name: listener_0
    address:
      socket_address: { address: 0.0.0.0, port_value: 8080 }
    filter_chains:
    - filters:
      - name: envoy.http_connection_manager
        config:
          codec_type: auto
          stat_prefix: ingress_http
          route_config:
            name: local_route
            virtual_hosts:
            - name: backend
              domains:
                - "*"
              routes:
              - match:
                  prefix: "/"
                route:
                  cluster: targetCluster
          http_filters:
          - name: envoy.router
  clusters:
  - name: targetCluster
    connect_timeout: 0.25s
    type: STRICT_DNS
    dns_lookup_family: V4_ONLY
    lb_policy: ROUND_ROBIN
    hosts: [
      { socket_address: { address: 172.18.0.3, port_value: 80 }},
      { socket_address: { address: 172.18.0.4, port_value: 80 }}
    ]

Step.2 「Add Health Check」

ヘルスチェックは clusters の中に設定する.代表的なパラメータは以下となる.

  • interval : 間隔
  • unhealthy_threshold : 異常と判断する閾値
  • healthy_threshold : 正常と判断する閾値
  • http_health_check.path : ヘルスチェックをする URL

今回は /health に対して「10秒間隔」でヘルスチェックをし,1回成功すると正常と判断する.他にも「Jitter(ランダムな遅延時間)」の設定も入っている.

clusters:
- name: targetCluster
  connect_timeout: 0.25s
  type: STRICT_DNS
  dns_lookup_family: V4_ONLY
  lb_policy: ROUND_ROBIN
  hosts: [
    { socket_address: { address: 172.18.0.3, port_value: 80 }},
    { socket_address: { address: 172.18.0.4, port_value: 80 }}
  ]
  health_checks:
    - timeout: 1s
      interval: 10s
      interval_jitter: 1s
      unhealthy_threshold: 6
      healthy_threshold: 1
      http_health_check:
        path: "/health"

他にも細かなパラメータは多くあるため,必要に応じてドキュメントを参照する.

www.envoyproxy.io

Step.3 「Start Proxy」

さっそく Envoy を起動する.同時にバックエンドのホストとして katacoda/docker-http-server:healthy を2個起動する.コンテナイメージ(healthy タグ)の解説は書かれてなく,あくまで予想だけど,デフォルトでヘルスチェックに成功する実装になっていると思う.そして curl で状態を変えることもできる.

$ docker run -d --name proxy1 -p 80:8080 -v /root/:/etc/envoy envoyproxy/envoy

$ docker run -d katacoda/docker-http-server:healthy

$ docker run -d katacoda/docker-http-server:healthy

Envoy にリクエストを送ると,正常に動いている.やっと検証環境が整った!

$ curl localhost
<h1>A healthy request was processed by host: daab80baf4ae</h1>

$ curl localhost
<h1>A healthy request was processed by host: 80461e934cfd</h1>

$ curl localhost
<h1>A healthy request was processed by host: daab80baf4ae</h1>

Step.3 完了時点で構成図は以下のようになる.

f:id:kakku22:20200107012133p:plain

Step.4 「Failed Services」

意図的に障害を発生させるため,172.18.0.3 に対して /unhealthy エンドポイントを呼び出す.すると,レスポンスに unhealthy request と表示される.レスポンスコードも 500 となり,1個のエンドポイントを落とすことができた.

$ curl 172.18.0.3/unhealthy

$ curl 172.18.0.3 -i
HTTP/1.1 500 Internal Server Error
(中略)
<h1>A unhealthy request was processed by host: daab80baf4ae</h1>

実際に while を使って 0.5 秒ごとに curl をすると,unhealthy_threshold に該当したタイミングから unhealthy request の発生は止まる.期待通りにヘルスチェックが動いていることを確認できる.

$ while true; do curl localhost; sleep .5; done
<h1>A unhealthy request was processed by host: daab80baf4ae</h1>
<h1>A healthy request was processed by host: 80461e934cfd</h1>
<h1>A unhealthy request was processed by host: daab80baf4ae</h1>
<h1>A healthy request was processed by host: 80461e934cfd</h1>
<h1>A unhealthy request was processed by host: daab80baf4ae</h1>

(中略)

<h1>A healthy request was processed by host: 80461e934cfd</h1>
<h1>A healthy request was processed by host: 80461e934cfd</h1>
<h1>A healthy request was processed by host: 80461e934cfd</h1>
<h1>A healthy request was processed by host: 80461e934cfd</h1>
<h1>A healthy request was processed by host: 80461e934cfd</h1>

Step.4 完了時点で構成図は以下のようになる.

f:id:kakku22:20200107012153p:plain

Step.5 「Healthy Services」

障害を復旧するため,172.18.0.3 に対して /healthy エンドポイントを呼び出す.すると,またエンドポイントは2個に戻る.

$ curl 172.18.0.3/healthy

$ curl localhost
<h1>A healthy request was processed by host: daab80baf4ae</h1>

$ curl localhost
<h1>A healthy request was processed by host: 80461e934cfd</h1>

$ curl localhost
<h1>A healthy request was processed by host: daab80baf4ae</h1>

Step.6 「Total Failure」

今度は全面障害を発生させるため,172.18.0.3 と 172.18.0.4 に対して /unhealthy エンドポイントを呼び出す.

$ curl 172.18.0.3/unhealthy

$ curl 172.18.0.4/unhealthy;

「Try Envoy」の手順だと Envoy から 503 が返ってくると書いてあるけど,実際にはそうならなかった.全面障害になると,全てのエンドポイントにリクエストをルーティングしているため,期待していた挙動とも異なる.設定変更など,もう少し調査が必要そう.なんだろう.

$ curl localhost -i
HTTP/1.1 500 Internal Server Error
(中略)
<h1>A unhealthy request was processed by host: daab80baf4ae</h1>

$ curl localhost -i
HTTP/1.1 500 Internal Server Error
(中略)
<h1>A unhealthy request was processed by host: 80461e934cfd</h1>

Step.7 「Outlier Detection Configuration」

Step.7 と Step.8 は「外れ値検出 (Outlier Detection)」を試す.ドキュメントを読むと,以下のように表現されている.なお「ヘルスチェック」と「外れ値検出」は併用もできる.

  • "Active" Health Checking : ヘルスチェック (Health Checking)
  • "Passive" Health Checking : 外れ値検出 (Outlier Detection)

Active と Passive という表現にもある通り,意図的にリクエストを投げて正常を確認するのが「ヘルスチェック」で,エンドポイントからのレスポンスをダイナミックに確認して正常を確認するのが「外れ値検出」となる.

www.envoyproxy.io

実際のレスポンスを判断材料にするため,設定項目は少なく使うことができる.envoy.yaml は以下のようになり,5xx のレスポンスコードが「3回」連続して返された場合に base_ejection_time に設定した時間はルーティング対象から除外する(Ejection と言う).その後もう1度ルーティング対象になる.

clusters:
- name: targetCluster
  connect_timeout: 0.25s
  type: STRICT_DNS
  dns_lookup_family: V4_ONLY
  lb_policy: ROUND_ROBIN
  hosts: [
    { socket_address: { address: 172.18.0.5, port_value: 80 }},
    { socket_address: { address: 172.18.0.6, port_value: 80 }}
  ]
  outlier_detection:
      consecutive_5xx: "3"
      base_ejection_time: "30s"

ただし,base_ejection_time は実際にはもう少し複雑で,ドキュメントを読むと以下のように書いてある.ようするに「base_ejection_time に設定した時間に Ejection された回数を掛ける」となり,繰り返し 5xx を返すエンドポイントほど「長時間 Ejection される」アプローチが実装されている.

The base time that a host is ejected for. The real time is equal to the base time multiplied by the number of times the host has been ejected.

www.envoyproxy.io

Step.8 「Testing Outlier Detection」

最後は「外れ値検出」を試す.katacoda/docker-http-server:healthy を追加で2個起動してから outlier_detection の設定をした Envoy も起動する.

$ docker run -d katacoda/docker-http-server:healthy

$ docker run -d katacoda/docker-http-server:healthy

$ docker run -d --name proxy2 -p 81:8080 \
    -v /root/:/etc/envoy \
    -v /root/envoy1.yaml:/etc/envoy/envoy.yaml \
    envoyproxy/envoy

while を使って 0.5 秒ごとに curl をしながら 172.18.0.5 に対して /unhealthy エンドポイントを呼び出すと,計3回 unhealthy が出て,その後は止まっている.期待通りに外れ値検出が動いていることを確認できる.

$ curl 172.18.0.5/unhealthy

$ while true; do curl localhost:81; sleep .5; done
<h1>A healthy request was processed by host: f586da250a29</h1>
<h1>A unhealthy request was processed by host: 932aa85e6053</h1>
<h1>A healthy request was processed by host: f586da250a29</h1>
<h1>A unhealthy request was processed by host: 932aa85e6053</h1>
<h1>A healthy request was processed by host: f586da250a29</h1>
<h1>A unhealthy request was processed by host: 932aa85e6053</h1>
<h1>A healthy request was processed by host: f586da250a29</h1>
<h1>A healthy request was processed by host: f586da250a29</h1>
<h1>A healthy request was processed by host: f586da250a29</h1>

まとめ

  • 「Try Envoy」のコンテンツ「Detecting Down Services with Health Checks」を試した
  • Envoy では「ヘルスチェック (Health Checking)」以外に「外れ値検出 (Outlier Detection)」もあることを学べた

引き続き,進めていくぞ!

プルリクエスト

試しながら気付いた誤りを修正してプルリクエストを送っておいた!

github.com

Try Envoy 関連

mdline を使ってタイムライン(年表)を作ろう!

「mdline」を使うと「タイムライン(年表)」を簡単に作れる.実装は必要なく,Markdown から HTML に変換できる.シチュエーションは限定的かもしれないけど,非常に面白く,試してみた!

github.com

Ruby Releases History

今回は mdline を試すサンプルとして「Ruby Releases History」を作った.Ruby のバージョンごとにリリース日をタイムラインとしてプロットしている.そして Netlify に配信をしたため,以下の URL から実際にタイムラインを見れるようにしてある.

f:id:kakku22:20200106203239p:plain

リリース日など,データセットは以下の公式サイトを参考にした.流石に量が多く,今回は Mechanize を使ってシュッと Markdown を作った.

www.ruby-lang.org

コード関連は全て GitHub に公開してある.Netlify のビルド設定はプロジェクト設定にすることもできるけど,今回は netlify.toml を使ってリポジトリ管理にしてみた.Netlify も機能が多くてたくさん遊べそう!

github.com

mdline フォーマット

GitHub に記載されている通り,フォーマットは非常にシンプルで,以下の2種類となる.

## {{Date}}: TITLE

MARKDOWN BODY

## {{Date}}--{{Date}}: TITLE

MARKDOWN BODY

今回作った「Ruby Releases History」の一部を抜粋すると以下のようになる.

## 2002-03-01: Ruby 1.6.7

[more...](https://www.ruby-lang.org/en/news/2002/03/01/167-is-released/)

## 2003-08-04: Ruby 1.8.0

[more...](https://www.ruby-lang.org/en/news/2003/08/04/ruby-180-released/)

mdline 実行

1番簡単に使うなら「mdline CLI」をインストールし,mdline コマンドを実行する.オプションは少なく,HTML を指定する -o と --output 以外はなかった.例えば <title> を指定できるようにしたり,あると便利な機能はまだまだありそう.

$ npm install -g mdline
$ mdline ruby-releases.md -o ruby-releases.html

まとめ

「mdline」を使ってオリジナルな「タイムライン(年表)」を作ろう!

Apollo Server と Apollo Client を写経しながら GraphQL を学べる「初めての GraphQL」を読んだ

2019年11月に発売された「初めての GraphQL」を読んだ.1度ザッと読んだ後に,気になっていた Apollo Server と Apollo Client の実装を写経しながら理解を深めていたため,書評をまとめるのに少し遅れてしまった.

タイトルに「初めての」とある通り,GraphQL 初学者をターゲットに網羅的に学ぶことができる1冊だった.特に「背景 → クエリ → スキーマ → リゾルバ → クライアント → 実戦投入」という流れは素晴らしく,一言で表現すると「知りたい!を知れる本」かなと!5章と6章は時間を取って写経するのが良いと思う.

初めてのGraphQL ―Webサービスを作って学ぶ新世代API

初めてのGraphQL ―Webサービスを作って学ぶ新世代API

  • 作者:Eve Porcello,Alex Banks
  • 出版社/メーカー: オライリージャパン
  • 発売日: 2019/11/13
  • メディア: 単行本(ソフトカバー)

目次

  • 1ç«  : GraphQLへようこそ
  • 2ç«  : グラフ理論
  • 3ç«  : GraphQLの問い合わせ言語
  • 4ç«  : スキーマの設計
  • 5ç«  : GraphQLサーバーの実装
  • 6ç«  : GraphQLクライアントの実装
  • 7ç«  : GraphQLの実戦投入にあたって
  • 付録A : Relay各仕様解説

以下のサイトに誤植は公開されていなかった.正直言って「初版第1刷」は誤植がそこそこある.記事の最後にメモ程度に残しておく.

www.oreilly.co.jp

GraphQL API とクエリ環境

本書を読みながら理解度を高めるため,気軽に試せる GraphQL API とクエリ環境を準備しておくと良いと思う.本書では「Snowtooth GraphQL API」をメインで使うけど,他にも「GitHub GraphQL API」や「Star Wars API」もある.

また,個人的に好きな「GraphQL Pokémon」もあり,詳しくは前回の記事にまとめてある.

kakakakakku.hatenablog.com

クエリ環境は「GraphQL Playground (Web / App)」や「GraphiQL (Web / App)」など,慣れたもので良いと思う.個人的には「GraphQL Playground」の Mac App を使っている.

GraphQL オペレーション

3章「GraphQLの問い合わせ言語」では,GraphQL オペレーション(query と mutation と subscription)を学ぶ.本書で使う「Snowtooth GraphQL API」は「スノートゥース山」という名前のゲレンデ(架空)のリフト情報とトレイル(コース)情報を管理する.

1. query

まず query オペレーションでは,シンプルなクエリを実行したり,複数クエリを実行したり,条件付きのクエリを実行したり,ステップバイステップにクエリを学べる.また「GraphQL Pokémon」の記事でも紹介した「フラグメント」も本書で紹介されている.以下のクエリは liftCount() と allLifts() と allTrails() の3種類のクエリを実行し,リフト件数は status: OPEN の条件付きとなる.

query liftsAndTrails {
  liftCount(status: OPEN)
  allLifts {
    name
    status
  }
  allTrails {
    name
    difficulty
  }
}

クエリを実行すると,以下のように結果が返ってくる.

f:id:kakku22:20200105051036p:plain

2. mutation

次に mutation オペレーションでは,データ更新を実行するミューテーションクエリを学べる.構文だけではなく,実際に「Snowtooth GraphQL API」を使って試すこともできる.公開 API だけど,実はリフトとトレイルのステータスを更新する setLiftStatus() と setTrailStatus() は使えるようになっている.ビックリ!

f:id:kakku22:20200105051053p:plain

実際にリフトのステータスを CLOSED に更新するミューテーションクエリは以下となる.

mutation closeLift {
  setLiftStatus(id: "jazz-cat", status: CLOSED) {
    name
    status
  }
}

3. subscription

最後に subscription オペレーションでは,GraphQL のポイントとも言える「WebSocket を使ったデータのリアルタイム反映」を学べる.以下のようなサブスクリプションクエリを実行すると,データの更新待ちになるため,別途ミューテーションクエリを実行すると,すぐに反映される.REST のように定期的に API を実行する必要もなく,データによっては便利な機能だと思う.

subscription {
  liftStatusChange {
    name
    capacity
    status
  }
}

f:id:kakku22:20200105051110p:plain

なお,「GraphQL Playground」の Mac Appだと WebSocket をローカルホストに接続するため,うまく動かなかった.設定変更をすることもできず,今回は Web で確認した.

{
  "error": "Could not connect to websocket endpoint ws://localhost:4000/. Please check if the endpoint url is correct."
}

GraphQL スキーマ : 多対多

4章「スキーマの設計」では,写真共有アプリケーションをテーマとし,GraphQL スキーマの仕様と「スキーマファースト」と呼ばれる設計思想を学べる.そして,シンプルな Photo 型だけではなく,User 型と多対多の関係を作るために中間テーブルを用意したりする「よくある設計」に関してもまとまっていて良かった.

例えば,よくある「タグ付け」という機能を実装する場合,以下のように Photo 型と User 型に「タグ付け」を表現するフィールドを追加する.! は「null ではない」を意味するため,[Photo!]! は「null ではない配列に,null ではない Photo が入っている」となる.

type User {
  (中略)
  inPhotos: [Photo!]!
}

type Photo {
  (中略)
  taggedUsers: [User!]!
}

さらに,型同士に関係だけではなく,追加情報(例えば「知り合ってからの期間」)も持たせたい場合は,新しく型を作ることになる.これを「スルー型」と呼ぶ.以下のように Friendship 型を定義し,追加情報は Friendship 型に持たせられる.

type User {
  friendship: [Friendship!]!
}

type Friendship {
  friend_a: User!
  friend_b: User!
  howLong: Int!
  whereWeMet: Location
}

GraphQL サーバの実装を写経する : apollo-server

5章「GraphQLサーバーの実装」では,apollo-server を使って,実際に GraphQL サーバを実装していく.GraphQL のクエリを実行するためには,リゾルバ(特定のデータを返す関数)が必要となり,実際に実装するのは大変だと思う.

github.com

本書を流し読みするだけだと理解が浅くなりそうだったので,時間を取って写経をしてみた.是非写経をオススメするけど,今回はその一部を載せておこうと思う.なお,完成形は GitHub に公開されているので,動作確認から先に進めても良いと思う.

github.com

まず最初にプロジェクトを作成する.Apollo 関連とホットリロードをするための nodemon もインストールしておく.

$ npm init -y
$ npm install apollo-server graphql nodemon

さっそく index.js を作成する.構成としてはクエリを定義した typeDefs (型定義) と resolvers (リゾルバ実装) となる.そして,最後に typeDefs と resolvers を指定した Apollo Server を起動する.totalPhotos() を実行すると,固定値 42 を返す.

const {
    ApolloServer
} = require(`apollo-server`)

const typeDefs = `
    type Query {
        totalPhotos: Int!
    }
`

const resolvers = {
    Query: {
        totalPhotos: () => 42
    }
}

const server = new ApolloServer({
    typeDefs,
    resolvers
})

server
    .listen()
    .then(({
        url
    }) => console.log(`GraphQL Service running on ${url}`))

さっそく npm start で Apollo Server を起動する.

$ npm start
(中略)
GraphQL Service running on http://localhost:4000/

うまく起動できていると,GraphQL Playground でクエリを実行できる.以下のようになれば OK!

{
  totalPhotos
}

f:id:kakku22:20200105051336p:plain

次にミューテーションを実装する.名前は写真を登録するため postPhoto() とする. パラメータとしては name と description を定義する.登録後は Boolean を返す定義となり,今回は固定値 true 返す.リゾルバとしては,配列 photos にデータを追加する実装になっている.コードの差分を中心に以下に載せた.

// 中略

const typeDefs = `
    type Query {
        totalPhotos: Int!
    }

    type Mutation {
        postPhoto(name: String! description: String): Boolean!
    }
`

var photos = []

const resolvers = {
    Query: {
        totalPhotos: () => photos.length
    },
    Mutation: {
        postPhoto(parent, args) {
            photos.push(args)
            return true
        }
    }
}

// 中略

動作確認のために,まず Query Variables に以下の JSON を定義する.

{
  "name": "sample photo A",
  "description": "A sample photo for our dataset"
}

そして,ミューテーションクエリを実行する.

mutation newPhoto($name: String!, $description: String) {
  postPhoto(name: $name, description: $description)
}

すると,リゾルバの実装通りに true が返ってくる.

{
  "data": {
    "postPhoto": true
  }
}

f:id:kakku22:20200105051355p:plain

実際に使おうとすると,写真の一覧が欲しかったり,ミューテーションクエリから true が返ってくるのは微妙だったりする.次に allPhotos() クエリを追加したり,ミューテーションクエリから追加した写真を返せるようにする.そのために Photo 型を定義したり,postPhoto() の定義で Photo を返すように修正したり,ID を連番で採番するように修正している.コードの差分を中心に以下に載せた.

// 中略

const typeDefs = `
    type Photo {
        id: ID!
        url: String!
        name: String!
        description: String
    }

    type Query {
        totalPhotos: Int!
        allPhotos: [Photo!]!
    }

    type Mutation {
        postPhoto(name: String! description: String): Photo!
    }
`

var _id = 0
var photos = []

const resolvers = {
    Query: {
        totalPhotos: () => photos.length,
        allPhotos: () => photos
    },
    Mutation: {
        postPhoto(parent, args) {
            var newPhoto = {
                id: _id++,
                ...args
            }
            photos.push(newPhoto)
            return newPhoto
        }
    },
    Photo: {
        url: parent => `http://yoursite.com/img/${parent.id}.jpg`
    }
}

// 中略

フィールドを指定したミューテーションクエリを実行する.

mutation newPhoto($name: String!, $description: String) {
  postPhoto(name: $name, description: $description) {
    id
    name
    description
  }
}

すると,ちゃんと Photo 型の結果が返ってきた.

{
  "data": {
    "postPhoto": {
      "id": "3",
      "name": "sample photo A",
      "description": "A sample photo for our dataset"
    }
  }
}

ミューテーションクエリを数回実行した後に追加した allPhotos() クエリを実行する.

query listPhotos {
  allPhotos {
    id
    name
    description
    url
  }
}

写真の一覧を取得できる.

{
  "data": {
    "allPhotos": [
      {
        "id": "0",
        "name": "sample photo A",
        "description": "A sample photo for our dataset",
        "url": "http://yoursite.com/img/0.jpg"
      },
      {
        "id": "1",
        "name": "sample photo A",
        "description": "A sample photo for our dataset",
        "url": "http://yoursite.com/img/1.jpg"
      },
      {
        "id": "2",
        "name": "sample photo A",
        "description": "A sample photo for our dataset",
        "url": "http://yoursite.com/img/2.jpg"
      },
      {
        "id": "3",
        "name": "sample photo A",
        "description": "A sample photo for our dataset",
        "url": "http://yoursite.com/img/3.jpg"
      }
    ]
  }
}

残りは以下の項目などを実装していくことになる.

  • enum 型 と input 型を使って使って型定義をモデル化する(デフォルトインプットを指定する)
  • Photo 型 と User 型を連携する

GraphQL サーバの実装を写経する : apollo-server-express

5章「GraphQLサーバーの実装」にはまだ続きがある.Apollo Server を既存のアプリケーションに追加したり,より細かな機能を Express ミドルウェアとして利用したり,様々な用途を考えて apollo-server-express を使ったリファクタリングをする.試す場合は,以下のように apollo-server-express などをインストールしておく.

$ npm remove apollo-server
$ npm install apollo-server-express express
$ npm install graphql-playground-middleware-express

Express を使う場合は,以下のような実装になる.ウェブページを表示したり,GraphQL Playground を表示したり,必要に応じてミドルウェアを追加できる.

const {
    ApolloServer
} = require(`apollo-server-express`)
const express = require(`express`)
const expressPlayground = require(`graphql-playground-middleware-express`).default

// 中略

var app = express()

const server = new ApolloServer({
    typeDefs,
    resolvers
})

server.applyMiddleware({
    app
})

app.get(`/`, (req, res) => res.end(`Welcome to the PhotoShare API`))
app.get(`/playground`, expressPlayground({
    endpoint: `/graphql`
}))

app
    .listen({
            port: 4000
        }, () => console.log(`GraphQL Service running on @ http://localhost:4000${server.graphqlPath}`)
    )

残りは以下の項目などを実装していくことになる.コード量が多く,今回は割愛するけど,より実践的な実装を学べるため,試しておくと良いかと!GitHub の完成形を見るだけでも雰囲気は伝わるはず.

  • typeDefs と resolvers を別ファイルに分割して index.js をリファクタリングする
  • MongoDB を使ってデータを永続保存する
  • GitHub API を使って認証と認可を実装する

GraphQL サーバの実装を写経する : apollo-client

6章「GraphQLクライアントの実装」では,React から GraphQL を扱うために graphql-request と apollo-client を学ぶ.今回は個人的に興味のあった apollo-client を写経した.React 自体はあまり難しい点はなく,apollo-boost (apollo-client などを含む) と react-apollo を使うことにより,実装がシンプルになることを体験できた.以下は実装した User.js の中で GraphQL Server にクエリを実行している部分を抜粋している.

// 中略

const Users = () => 
    <Query query={ROOT_QUERY} fetchPolicy="cache-and-network">
        {({ data, loading, refetch }) => loading ?
            <p>loading users...</p> :
            <UserList count={data.totalUsers} 
                users={data.allUsers} 
                refetch={refetch} />
        }
    </Query>

// 中略

本書を読んでいて参考になったのはキャッシュ実装の仕組みで,REST だとエンドポイントごとにキャッシュできるけど,GraphQL だと固定のエンドポイントになるため,どうキャッシュを実現するの?という話だった.react-apollo を使うと options.fetchPolicy という設定があり,以下の5種類から選べる.また apollo-cache-persist と組み合わせると,キャッシュを localStorage に保存することもできる.このあたりはプロダクションコードを実装するときに改めて検討したいと思う.

  • cache-first
  • cache-and-network
  • network-only
  • cache-only
  • no-cache

www.apollographql.com

とは言え,まだまだ apollo-client のメリットを学べてなく,引き続き調査をしていく.

実践投入

7章「GraphQLの実戦投入にあたって」では,これから本番環境に GraphQL を導入したい人と既に導入している人に最適な内容になっている.僕自身もまだ GraphQL はプロトタイプでしか使ってなく,知らない内容も多かった.

特に「漸進的なマイグレーション」という解説は良かった.どのように既存のアプリケーションを GraphQL に移行するか?という点で,計5種類の戦略が紹介されていた.GraphQL をゲートウェイのように使って,リゾルバから REST API にアクセスするパターンは,並行稼動を前提とした「移行のしやすさ」もあり,現場でも使う機会がありそうだった.

  • REST からリゾルバにデータをフェッチする
  • もしくは GraphQL リクエストを使用する
  • 1つか2つのコンポーネントに GraphQL を組み込む
  • 新しい REST エンドポイントを作成しない
  • 現在の REST エンドポイントをメンテナンスしない

誤植 : 初版第1刷

  • P.vii GrapghQL → GraphQL
  • P.48 List 型の → Lift 型の
  • P.51 https://www.graphqlbin.com/v2/ANgjtr → 既に Server cannot be reached になっている
  • P.53 https://www.graphqlbin.com/v2/yoyPfz → 既に Server cannot be reached になっている
  • P.71 「構成されるでデータになり」→「構成されるデータになり」
  • P.75 DataTime → DateTime
  • P.82 「エラーが帰ってきます」→「エラーが返ってきます」
  • P.97 type Mutation { のインデント誤り
  • P.138「写真共有サービス」→「写真共有アプリケーション」
  • P.149「写真管理サービス」→「写真共有アプリケーション」
  • P.213 Amazon Web Service → Amazon Web Services

なお,誤植ではないけど,P.7の「状態機械」はシンプルに「ステートマシン」で良さそうな気がする.

まとめ

  • 「初めての GraphQL」を読んだ
  • GraphQL 初学者をターゲットに網羅的に学ぶことができる1冊だった
    • 特に「背景 → クエリ → スキーマ → リゾルバ → クライアント → 実戦投入」という流れは素晴らしい!
  • 1周目は全体をザッと読みつつ,2周目で手を動かしながら読み直すのが,1番学習効率が高そう

初めてのGraphQL ―Webサービスを作って学ぶ新世代API

初めてのGraphQL ―Webサービスを作って学ぶ新世代API

  • 作者:Eve Porcello,Alex Banks
  • 出版社/メーカー: オライリージャパン
  • 発売日: 2019/11/13
  • メディア: 単行本(ソフトカバー)

GraphQL Pokémon を使って楽しく学ぶ GraphQL クエリ

GraphQL クエリを学ぶ場合,気軽に使える API があると便利だと思う.よく見るのは GitHub GraphQL API や Star Wars API だけど,個人的に Star Wars の映画を観たことがなく,データの理解が難しいため,良さそうな API を探していた.すると,GitHub で公開されている「GraphQL Pokémon」を発見した.GraphQL で「ポケモン(第一世代)」のポケモンを検索することができる.これは楽しそう!さっそく試してみた.

github.com

クエリ環境

GraphQL Pokémon には GraphiQL コンソールがあり,ブラウザから簡単にクエリを実行できる.

個人的には GraphQL Playground の Mac アプリをよく使っている.インストールしておくと良いと思う.

electronjs.org

f:id:kakku22:20191230121746p:plain

GraphQL スキーマ

GraphQL Pokémon の GraphQL スキーマを以下に載せた.要点をまとめると,ポケモンを表現する Pokemon 型を軸とし,ワザを表現する PokemonAttack 型 と Attack 型 / サイズを表現する PokemonDimension 型 / 進化条件を表現する PokemonEvolutionRequirement 型となる.マスタデータとしてはそこそこ揃っている気がする.そして「ポケモン(第一世代)」を対象とするため,ミュウ (No.151) までデータセットに含まれている.

  • Pokemon
    • PokemonAttack
      • Attack
    • PokemonDimension
    • PokemonEvolutionRequirement
type Attack {
  name: String
  type: String
  damage: Int
}

type Pokemon {
  id: ID!
  number: String
  name: String
  weight: PokemonDimension
  height: PokemonDimension
  classification: String
  types: [String]
  resistant: [String]
  attacks: PokemonAttack
  weaknesses: [String]
  fleeRate: Float
  maxCP: Int
  evolutions: [Pokemon]
  evolutionRequirements: PokemonEvolutionRequirement
  maxHP: Int
  image: String
}

type PokemonAttack {
  fast: [Attack]
  special: [Attack]
}

type PokemonDimension {
  minimum: String
  maximum: String
}

type PokemonEvolutionRequirement {
  amount: Int
  name: String
}

type Query {
  query: Query
  pokemons(first: Int!): [Pokemon]
  pokemon(id: String, name: String): Pokemon
}

クエリ :「ピカチュウ」を検索する

まず,1番簡単なクエリを実行する.pokemon クエリに name: "Pikachu" を指定する.取得するフィールドは number (番号) と name (英語名) と types (タイプ) とした.

query {
  pokemon(name: "Pikachu") {
    number
    name
    types
  }
}

すると「ピカチュウ」を検索できる.ピカチュウは Electric (でんき) タイプであると確認できる.

{
  "data": {
    "pokemon": {
      "number": "025",
      "name": "Pikachu",
      "types": [
        "Electric"
      ]
    }
  }
}

クエリ :「ピカチュウ」の進化を検索する

evolutions (進化) フィールドを指定すると進化するポケモンを Pokemon 型で取得できる.

query {
  pokemon(name: "Pikachu") {
    number
    name
    types
    evolutions {
      number
      name
      types
    }
  }
}

すると「ピカチュウ」の進化は「ライチュウ」であると確認できる.REST API と異なり,1回のクエリで階層データまで取得できるのは GraphQL のメリットと言える.

{
  "data": {
    "pokemon": {
      "number": "025",
      "name": "Pikachu",
      "types": [
        "Electric"
      ],
      "evolutions": [
        {
          "number": "026",
          "name": "Raichu",
          "types": [
            "Electric"
          ]
        }
      ]
    }
  }
}

クエリ :「ヒトカゲ」の進化を検索する

3段階進化をする「ヒトカゲ」を検索する.英語版だと名前が異なるため,今回は name: "Charmander" を指定する.

query {
  pokemon(name: "Charmander") {
    number
    name
    types
    evolutions {
      number
      name
      types
    }
  }
}

すると「ヒトカゲ (Charmander)」の進化は「リザード (Charmeleon)」と「リザードン (Charizard)」であると確認できる.

{
  "data": {
    "pokemon": {
      "number": "004",
      "name": "Charmander",
      "types": [
        "Fire"
      ],
      "evolutions": [
        {
          "number": "005",
          "name": "Charmeleon",
          "types": [
            "Fire"
          ]
        },
        {
          "number": "006",
          "name": "Charizard",
          "types": [
            "Fire",
            "Flying"
          ]
        }
      ]
    }
  }
}

クエリ : フラグメントを使う

進化を検索したときに Pokemon 型のフィールドを2箇所に定義していたため,GraphQL クエリの「フラグメント」を使って共通化する.以下のように Pokemon 型のフラグメント pokemonInfo を定義し,クエリに含めた.さらに今回は取得するフィールドに weaknesses (弱点) を追加した.

fragment pokemonInfo on Pokemon {
  number
  name
  types
  weaknesses
}

query {
  pokemon(name: "Pikachu") {
    ...pokemonInfo
    evolutions {
      ...pokemonInfo
    }
  }
}

「ピカチュウ」と「ライチュウ」は Ground (じめん) タイプが弱点であると確認できる.フラグメントを使うと GraphQL クエリをシンプルに書けるし,可読性も高くなるため,タイミングを見極めて積極的に使っていく.

{
  "data": {
    "pokemon": {
      "number": "025",
      "name": "Pikachu",
      "types": [
        "Electric"
      ],
      "weaknesses": [
        "Ground"
      ],
      "evolutions": [
        {
          "number": "026",
          "name": "Raichu",
          "types": [
            "Electric"
          ],
          "weaknesses": [
            "Ground"
          ]
        }
      ]
    }
  }
}

クエリ :「ピカチュウ」と「ライチュウ」のワザを検索する

attacks (ワザ) フィールドを指定すると,ポケモンのワザを PokemonAttack 型と Attack 型で取得できる.PokemonAttack 型 では fast (ワザ) と special (わざマシン) の2種類のフィールドが定義されている.今回は Pokemon 型に加えて Attack 型も「フラグメント」を使って共通化する.

fragment attackInfo on Attack {
  name
  type
  damage
}

fragment pokemonInfo on Pokemon {
  number
  name
  types
  weaknesses
  attacks {
    fast {
      ...attackInfo
    }
    special {
      ...attackInfo
    }
  }
}

query {
  pokemon(name: "Pikachu") {
    ...pokemonInfo
    evolutions {
      ...pokemonInfo
    }
  }
}

すると「ピカチュウ」と「ライチュウ」のワザを取得できる.ワザの英語名を整理すると以下になると思う(間違っている可能性もある).

  • ピカチュウ
    • fast
      • Quick Attack (でんこうせっか)
      • Thunder Shock (でんきショック)
    • special
      • Discharge (ほうでん)
      • Thunder (かみなり)
      • Thunderbolt (10まんボルト)
  • ライチュウ
    • fast
      • Spark (スパーク)
      • Thunder Shock (でんきショック)
    • special
      • Brick Break (かわらわり)
      • Thunder (かみなり)
      • Thunder Punch (かみなりパンチ)
{
  "data": {
    "pokemon": {
      "number": "025",
      "name": "Pikachu",
      "types": [
        "Electric"
      ],
      "weaknesses": [
        "Ground"
      ],
      "attacks": {
        "fast": [
          {
            "name": "Quick Attack",
            "type": "Normal",
            "damage": 10
          },
          {
            "name": "Thunder Shock",
            "type": "Electric",
            "damage": 5
          }
        ],
        "special": [
          {
            "name": "Discharge",
            "type": "Electric",
            "damage": 35
          },
          {
            "name": "Thunder",
            "type": "Electric",
            "damage": 100
          },
          {
            "name": "Thunderbolt",
            "type": "Electric",
            "damage": 55
          }
        ]
      },
      "evolutions": [
        {
          "number": "026",
          "name": "Raichu",
          "types": [
            "Electric"
          ],
          "weaknesses": [
            "Ground"
          ],
          "attacks": {
            "fast": [
              {
                "name": "Spark",
                "type": "Electric",
                "damage": 7
              },
              {
                "name": "Thunder Shock",
                "type": "Electric",
                "damage": 5
              }
            ],
            "special": [
              {
                "name": "Brick Break",
                "type": "Fighting",
                "damage": 30
              },
              {
                "name": "Thunder",
                "type": "Electric",
                "damage": 100
              },
              {
                "name": "Thunder Punch",
                "type": "Electric",
                "damage": 40
              }
            ]
          }
        }
      ]
    }
  }
}

クエリ :「イーブイ」と進化ポケモンの画像を検索する

GraphQL スキーマを見ると Pokemon 型に image フィールドがある.3種類に進化する可能性のある「イーブイ (Eevee)」を検索して,画像を取得する.

fragment pokemonInfo on Pokemon {
  number
  name
  image
}

query {
  pokemon(name: "Eevee") {
    ...pokemonInfo
    evolutions {
      ...pokemonInfo
    }
  }
}

すると image フィールドに画像 URL が含まれていた.

{
  "data": {
    "pokemon": {
      "number": "133",
      "name": "Eevee",
      "image": "https://img.pokemondb.net/artwork/eevee.jpg",
      "evolutions": [
        {
          "number": "134",
          "name": "Vaporeon",
          "image": "https://img.pokemondb.net/artwork/vaporeon.jpg"
        },
        {
          "number": "135",
          "name": "Jolteon",
          "image": "https://img.pokemondb.net/artwork/jolteon.jpg"
        },
        {
          "number": "136",
          "name": "Flareon",
          "image": "https://img.pokemondb.net/artwork/flareon.jpg"
        }
      ]
    }
  }
}

「GraphQL Pokémon」の GitHub を見ると,ポケモン画像を使った React サンプルアプリも公開されていた.GraphQL / React / Relay / Material-UI を組み合わせた実装例としても参考になる.以下にサンプルアプリのキャプチャを載せておく.

f:id:kakku22:20191230130835p:plain

クエリ : ポケモン一覧を検索する

GraphQL スキーマを見ると,もう1種類 pokemons というクエリも用意されている.柔軟に検索できるのかと予想したけど,実際には first パラメータしかなく,最初から何種類のポケモンを取得する?という用途だった.一覧を作る以外は用途はなさそう.

query {
  pokemons(first: 3) {
    name
  }
}

今回は first: 3 を指定したため,「フシギダネ(Bulbasaur)」と「フシギソウ (Ivysaur)」と「フシギバナ (Venusaur)」を取得できる.ポケモンの英語名を覚える練習に使えそう.英語名だと本当にわからなかった.

{
  "data": {
    "pokemons": [
      {
        "name": "Bulbasaur"
      },
      {
        "name": "Ivysaur"
      },
      {
        "name": "Venusaur"
      }
    ]
  }
}

まとめ

  • GraphQL クエリを学ぶ場合,気軽に使える API があると便利
  • GitHub GraphQL API や Star Wars API 以外にもたくさんある
  • 「GraphQL Pokémon」を使うと GraphQL で「ポケモン(第一世代)」のポケモンを検索することができて楽しく学べる!

Envoy の EDS を REST API で体験する「API Based Dynamic Routing Configuration」を試した

今回は「Try Envoy」の「API Based Dynamic Routing Configuration」を紹介する.前回紹介した「File Based Dynamic Routing Configuration」と関連した内容だけど,今回は API を使った「ディスカバリサービス (xDS)」を学べる.特に Envoy は REST と gRPC をサポートしているけど,今回のコンテンツだと REST を学べる.なお,前回の記事も載せておく.

kakakakakku.hatenablog.com

API Based Dynamic Routing Configuration

手順は以下の「計7種類」ある.今までの Katacoda と同じく文章だと理解しにくい点も多く,自分なりに理解を整理しながら進めた.

  • Step.1 「Introduction」
  • Step.2 「EDS Configuration」
  • Step.3 「Start upstream services」
  • Step.4 「Start EDS」
  • Step.5 「Add endpoint to EDS」
  • Step.6 「Delete Endpoint」
  • Step.7 「Disconnect EDS server」

www.envoyproxy.io

www.katacoda.com

Step.1 「Introduction」

今回は EDS (Endpoint Discovery Service) を REST API を使って実現することにより,エンドポイント情報を取得する.過去記事の内容を復習すると,エンドポイントはクラスタメンバーと表現することもできて,転送先のことを意味する.xDS API を実装する場合,現在だと Java と Go でコントロールプレーンの実装 (gRPC + Protocol Buffers) が Envoy から公開されていて参考になる.ただし,今回は独自実装の API を試す.

Step.2 「EDS Configuration」

envoy.yaml の設定は前回と似ている.今回異なるのは eds_config に api_config_source を設定している点で「File Based」のときは path を設定していた.さらに今回は EDS API として,もう1個 Cluster eds_cluster を追加している.なお,api_type は REST (v2) と REST_LEGACY (v1) があり,既に API v1 は deprecated になっている.API v2 の詳細は以下のドキュメントに載っている.

www.envoyproxy.io

clusters:
- name: targetCluster
  type: EDS
  connect_timeout: 0.25s
  eds_cluster_config:
    service_name: myservice
    eds_config:
      api_config_source:
        api_type: REST
        cluster_names: [eds_cluster]
        refresh_delay: 5s

- name: eds_cluster
  type: STATIC
  connect_timeout: 0.25s
  hosts: [{ socket_address: { address: 172.18.0.4, port_value: 8080 }}]

さっそく Envoy を起動する.ただし,EDS API はまだ存在してなく設定のみとなる.構成図は以下のようになる.

$ docker run --name=api-eds -d \
    -p 9901:9901 \
    -p 80:10000 \
    -v /root/:/etc/envoy \
    envoyproxy/envoy:latest

f:id:kakku22:20191226121518p:plain

Step.3 「Start upstream services」

今回も katacoda/docker-http-server を起動し,個別に接続確認をしておく.

$ docker run -p 8081:8081 -d -e EDS_SERVER_PORT='8081' katacoda/docker-http-server:v4

$ curl http://localhost:8081 -i
HTTP/1.0 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 36
Server: Werkzeug/0.15.4 Python/2.7.16
Date: Thu, 26 Dec 2019 00:00:00 GMT

35c4aef0-e0df-46ac-bd3b-8b12cb3741c9

ただし,コンテナイメージのタグが v4 になっていて,サイズも大きく,何やら Python 環境が入っているけど,詳細は書かれていなかった.あと,環境変数 EDS_SERVER_PORT が追加されている点も異なる.

$ docker images --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}" katacoda/docker-http-server
REPOSITORY                    TAG                 SIZE
katacoda/docker-http-server   v4                  932MB
katacoda/docker-http-server   latest              7.59MB

Envoy のログを確認すると,まだ EDS API に接続できず,エラーになっている.

$ docker logs xxx
[2019-12-26 00:00:00.000][000008][warning][config] [bazel-out/k8-opt/bin/source/common/config/_virtual_includes/http_subscription_lib/common/config/http_subscription_impl.h:101] REST config update failed: fetch failure

構成図は以下のようになる.

f:id:kakku22:20191226182058p:plain

Step.4 「Start EDS」

次にやっと EDS API を起動する.今回は Python で実装されたプロトタイプを使うことになり,コードは GitHub に公開されている.ここで Envoy と EDS API の接続はできたけど,まだエンドポイントが設定されてなく,実際に Envoy にリクエストを送ると no healthy upstream と返ってくる.挙動としては期待値と言える.

$ docker run -p 8080:8080 -d katacoda/eds_server;

$ curl http://localhost
no healthy upstream

構成図は以下のようになる.

f:id:kakku22:20191226182113p:plain

Step.5 「Add endpoint to EDS」

次に EDS API に curl でリクエストを送り,エンドポイントを追加する.今回は既に起動済の katacoda/docker-http-server にルーティングする.

curl -X POST --header 'Content-Type: application/json' --header 'Accept: application/json' -d '{
  "hosts": [
    {
      "ip_address": "172.18.0.3",
      "port": 8081,
      "tags": {
        "az": "us-central1-a",
        "canary": false,
        "load_balancing_weight": 50
      }
    }
  ]
}' http://localhost:8080/edsservice/myservice

Envoy にリクエストを送ると,同じ結果が返ってくる.

$ curl http://localhost
35c4aef0-e0df-46ac-bd3b-8b12cb3741c9

$ curl http://localhost
35c4aef0-e0df-46ac-bd3b-8b12cb3741c9

次に katacoda/docker-http-server を4コンテナ追加し,計5コンテナになる.

$ for i in 8082 8083 8084 8085
  do
      docker run -d -e EDS_SERVER_PORT=$i katacoda/docker-http-server:v4;
      sleep .5
done

追加した4コンテナをエンドポイントとして EDS API に追加する.なお,設定している load_balancing_weight はドキュメントを読むと「合計値をエンドポイント数で割る」と書いてあるため,5コンテナに均等分散するという意味になる.

www.envoyproxy.io

$ curl -X PUT --header 'Content-Type: application/json' --header 'Accept: application/json' -d '{
    "hosts": [
        {
        "ip_address": "172.18.0.3",
        "port": 8081,
        "tags": {
            "az": "us-central1-a",
            "canary": false,
            "load_balancing_weight": 50
        }
        },
        {
        "ip_address": "172.18.0.5",
        "port": 8082,
        "tags": {
            "az": "us-central1-a",
            "canary": false,
            "load_balancing_weight": 50
        }
        },
        {
        "ip_address": "172.18.0.6",
        "port": 8083,
        "tags": {
            "az": "us-central1-a",
            "canary": false,
            "load_balancing_weight": 50
        }
        },
        {
        "ip_address": "172.18.0.7",
        "port": 8084,
        "tags": {
            "az": "us-central1-a",
            "canary": false,
            "load_balancing_weight": 50
        }
        },
        {
        "ip_address": "172.18.0.8",
        "port": 8085,
        "tags": {
            "az": "us-central1-a",
            "canary": false,
            "load_balancing_weight": 50
        }
        }
    ]
    }' http://localhost:8080/edsservice/myservice

実際に Envoy にリクエストを送ると,うまく分散されている.エンドポイント数が少ないからかもしれないけど,たまに2回連続で同じコンテナからレスポンスが返ることもあった.

$ while true; do curl http://localhost; sleep .5; printf '\n'; done
dbc1fe2f-7bfa-44e6-8fff-52a57190de95
28959add-c2df-47cf-8045-c09911a9e913
8d366f05-18d8-4f72-936f-ab282615f6ca
f7f7599d-1be9-480c-96e9-6619b2843499
35c4aef0-e0df-46ac-bd3b-8b12cb3741c9

構成図は以下のようになる.

f:id:kakku22:20191226182130p:plain

Step.6 「Delete Endpoint」

今度は EDS API から5コンテナの情報を消す.すると,Step.4 と同じ no healthy upstream となった.

$ curl -X PUT --header 'Content-Type: application/json' --header 'Accept: application/json' -d '{
  "hosts": [  ]
}' http://localhost:8080/edsservice/myservice

$ curl -v http://localhost
no healthy upstream

Step.7 「Disconnect EDS server」

最後は Step.5 と同じくもう1度 EDS API にエンドポイントを追加してから,EDS API を停止する.

$ docker ps -a | awk '{ print $1,$2 }' | grep katacoda/eds_server  | awk '{print $1 }' | xargs -I {} docker stop {};
$ docker ps -a | awk '{ print $1,$2 }' | grep katacoda/eds_server  | awk '{print $1 }' | xargs -I {} docker rm {}

一度 Envoy 側で EDS API からエンドポイント情報を取得した場合,Envoy 側には影響なしで使えることがわかる.

$ while true; do curl http://localhost; sleep .5; printf '\n'; done
35c4aef0-e0df-46ac-bd3b-8b12cb3741c9
dbc1fe2f-7bfa-44e6-8fff-52a57190de95
8d366f05-18d8-4f72-936f-ab282615f6ca
f7f7599d-1be9-480c-96e9-6619b2843499
28959add-c2df-47cf-8045-c09911a9e913

構成図は以下のようになる.

f:id:kakku22:20191226182144p:plain

まとめ

  • 「Try Envoy」のコンテンツ「API Based Dynamic Routing Configuration」を試した
  • EDS (Endpoint Discovery Service) API を REST で起動し,エンドポイントの dynamic な反映を体験できた
  • katacoda/docker-http-server:v4 の解説がなかったり,gRPC の xDS API の体験がなかったり,もう少し学びたかった点もある

引き続き,進めていくぞ!

Try Envoy 関連