kakakakakku blog

Weekly Tech Blog: Keep on Learning!

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

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

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

目次

  • 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 オペレーション(querymutationsubscription)を学ぶ.本書で使う「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 (リゾルバ実装) となる.そして,最後に typeDefsresolvers を指定した 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() とする. パラメータとしては namedescription を定義する.登録後は 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 の完成形を見るだけでも雰囲気は伝わるはず.

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

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

6章「GraphQLクライアントの実装」では,React から GraphQL を扱うために graphql-requestapollo-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

https://www.apollographql.com/docs/react/api/react-apollo/www.apollographql.com

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

実践投入

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

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

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

誤植 : 初版第1刷

  • P.vii GrapghQLGraphQL
  • 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 DataTimeDateTime
  • P.82 「エラーが帰ってきます」→「エラーが返ってきます」
  • P.97 type Mutation { のインデント誤り
  • P.138「写真共有サービス」→「写真共有アプリケーション」
  • P.149「写真管理サービス」→「写真共有アプリケーション」
  • P.213 Amazon Web ServiceAmazon Web Services

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

まとめ

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

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

GraphQL クエリを学ぶ場合,気軽に使える API があると便利だと思う.よく見るのは GitHub GraphQL APIStar 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 APIStar 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 は RESTgRPC をサポートしているけど,今回のコンテンツだと 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_configapi_config_source を設定している点で「File Based」のときは path を設定していた.さらに今回は EDS API として,もう1個 Cluster eds_cluster を追加している.なお,api_typeREST (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 関連

JetBrains エディタで API リクエストをコード化できる「HTTP client」

実装した API の動作確認とテストをするときに,今までは curlPostman を主に使っていたけど,最近は JetBrains エディタで使える「HTTP client」も併用している.今日は API リクエストをファイルに記述しコード化できる「HTTP client」の概要を紹介する.JetBrains のドキュメントは基本的に英語だけど pleiades.io なら日本語で読める.

今回は検証環境として Sinatra を使った API を実装し,HTTP client から http://localhost:4567 にリクエストを送る.現在 RubyMineGoLand のライセンスを購入しているため,今回は RubyMine を使って記事をまとめたけど,他の JetBrains エディタでも基本的に使える(IntelliJ IDEA だと Ultimate 限定).

リクエストファイル

まず「リクエストファイル」を作る.RunyMine で「New → HTTP Request」と選択することもできるし,新規ファイルの拡張子を .http もしくは .rest にして作ることもできる.最もシンプルなリクエストファイルは以下となる.

GET http://localhost:4567/ping

今回はファイル名を api.http とした.ファイルを作成するとエディタは以下のような UI になる.

f:id:kakku22:20191224233117p:plain

「Run All Requests in Files」ボタンもしくは GET の左にある「Run ▶」ボタンを押すとリクエストファイルを実行できる.実行すると「ヘッダー」「レスポンス」を確認できる.

GET http://localhost:4567/ping

HTTP/1.1 200 OK
Content-Type: text/html;charset=utf-8
Content-Length: 4
X-Xss-Protection: 1; mode=block
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
Server: WEBrick/1.4.2 (Ruby/2.5.1/2018-03-29)
Date: Sat, 24 Dec 2019 14:00:00 GMT
Connection: Keep-Alive

pong

Response code: 200 (OK); Time: 52ms; Content length: 4 bytes

テンプレート機能

リクエストファイルで ⌘ + J と入力すると,以下の「計6種類」あるテンプレートから書き出せる.名前の略称は謎だけど,gt : GETpt : POSTr : Request など,傾向はありそう.

  • fptr : POST Request with file
  • gtr : GET Request
  • gtrp : GET Request with parameters
  • mptr : POST Request with multipart
  • ptr : POST Request
  • ptrp : POST Request with parameters

f:id:kakku22:20191224233705p:plain

例えば gtr を選択すると,以下のようにシンプルな GET リクエストを記述できる.

GET http://localhost:80/api/item
Accept: application/json

###

例えば ptr を選択すると,以下のようにシンプルな POST リクエストを記述できる.

POST http://localhost:80/api/item
Content-Type: application/json

{}

###

セパレータ機能

テンプレート機能を使うと自動的に末尾が ### になり,なんだろう?と気になると思う.これは「セパレータ」と言って,リクエストファイルの中に複数のリクエストを記述できることを意味している.以下は GETPOST の2種類のリクエストを記述している.

GET http://localhost:4567/ping

###

POST http://localhost:4567/users
Content-Type: application/json

{
  "name": "kakakakakku"
}

###

「Run All Requests in Files」ボタンを押すとまとめて実行できるし,「Run ▶」ボタンを押すと個別に実行できる.以下のように結果もまとめて確認できる.

f:id:kakku22:20191225001900p:plain

Examples 機能

リクエストファイルのメニューバーにある「Examples」ボタンを押すと HTTP client に同梱されたリクエストファイルの具体的な記述例を確認できる.現在は「計4種類」あり,実行することもできる.

  • Get Requests
  • Post Requests
  • Requests with Authorization
  • Requests with Tests

個人的には「テンプレート機能」よりも使う場面が多いと思う.変数を使ったり,認証をしたり,応答ハンドラスクリプトを書いたり(後述),すぐに使える記述例をコピーできる.

f:id:kakku22:20191225002111p:plain

Convert from cURL 機能

リクエストファイルのメニューバーにある「Convert from cURL」ボタンを押すと,curl コマンドをリクエストファイルの記述に変換できる.既存スクリプトを HTTP client に移行しやすくなる便利な機能だと思う.

f:id:kakku22:20191225003550p:plain

変数機能

HTTP client には「動的変数」「環境変数」がある.「動的変数」は計3種類あり,自動的に値を設定してくれる.要件に合う場合に使える.

  • $uuid : UUID を返す
  • $timestamp : UNIX Timestamp を返す
  • $randomInt : 0 - 1000 の範囲から乱数を返す

実際にリクエストファイルを記述すると以下のようになる.

POST http://localhost:4567/logging
Content-Type: application/json

{
  "uuid": "{{$uuid}}",
  "randomInt": "{{$randomInt}}",
  "timestamp": "{{$timestamp}}"
}

###

「環境変数」は環境ごとに任意の値を設定できる.まず,環境設定を記述する http-client.env.json もしくは rest-client.env.json を作成する.今回は dev 環境と prd 環境に name 変数を定義するファイルを作成する.

{
  "dev": {
    "name": "kakakakakku-dev"
  },

  "prd": {
    "name": "kakakakakku-prd"
  }
}

リクエストファイルには {{name}} と記述する.

POST http://localhost:4567/users
Content-Type: application/json

{
  "name": "{{name}}"
}

###

すると「Run All Requests in Files」ボタンを押して実行するときに環境を選択できるようになり,該当する環境変数が設定される.

f:id:kakku22:20191225010103p:plain

応答ハンドラスクリプト機能

「応答ハンドラスクリプト機能」を使うと,リクエストファイルを実行した後にレスポンスを検証し,テストコードを記述できるようになる.ハンドラスクリプト自体は JavaScript を使う.例えば,以下はレスポンスコード 200404 に対して response.status === 200 という条件で検証している.

GET https://httpbin.org/status/200

> {%
    client.test("Request executed successfully", function() {
        client.assert(response.status === 200, "Response status is not 200");
    });
%}

###

GET https://httpbin.org/status/404

> {%
    client.test("Request executed successfully", function() {
        client.assert(response.status === 200, "Response status is not 200");
    });
%}

実行すると,期待した通りに2個目のテストは落ちる.もしかしたら簡単な TDD もできそう!

f:id:kakku22:20191225010956p:plain

ログ機能

作業ディレクトリの .idea/httpRequests/ 直下に HTTP client のログが「最大50件」保存されている.さらにリクエストファイルのメニューバーにある「Open Log」ボタンを押すと,ログを確認しながら「Run ▶」ボタンで個別に再実行もできる.

機能は他にもある

今回紹介しなかった機能もある.例えば以下など.

  • リクエストファイルにコメントを書く
  • 認証のために Authorization ヘッダーを送信する(Basic 認証 / Digest認証)
  • リダイレクトに対応する

リクエストファイルの具体的な解説は以下のドキュメントにある.

リクエストファイルの構文仕様は GitHub に載っている.

github.com

まとめ

JetBrains エディタで使える「HTTP client」は API リクエストをファイルに記述できる.今回紹介した多くの機能を使ってリッチな API の動作確認とテストを実現できるし,何よりもチーム開発においては「リクエストファイルをコード化してリポジトリで管理できる」という点に価値がある.最近は個人 GitHub リポジトリに api.http を置くようにしている.

とは言え,メンバー全員を JetBrains エディタに統一するのは本質的ではなく,例えば VS Code など他のエディタでもリクエストファイルを認識できると良さそう.調べてみると,リクエストファイルをサポートする拡張機能「REST Client」はあるけど,構文が微妙に違う気がする.

「HTTP client」 を使って API リクエストをコード化しよう!

Envoy のディスカバリサービス (xDS) を学べる「File Based Dynamic Routing Configuration」を試した

今回は「Try Envoy」「File Based Dynamic Routing Configuration」を紹介する.今までの内容は envoy.yaml に static な設定をしていたけど,設定を dynamic に反映できる Envoy の「ディスカバリサービス (xDS)」を学べる.また Envoy は xDS として「File Based(ファイル)」「API Based(REST / gRPC)」をサポートしている.今回は「File Based」を試す.

File Based Dynamic Routing Configuration

手順は以下の「計8種類」ある.それでもなお「Estimated Time: 10 minutes」と書いてあって,雑すぎるでしょ!

  • Step.1 「Envoy Dynamic Configuration」
  • Step.2 「Cluster ID」
  • Step.3 「EDS Configuration」
  • Step.4 「EDS Configuration」
  • Step.5 「Start Envoy」
  • Step.6 「Apply Changes」
  • Step.7 「CDS Configuration」
  • Step.8 「CDS Apply Changes」

www.envoyproxy.io

www.katacoda.com

Step.1 「Envoy Dynamic Configuration」

Envoy には,今まで使ってきた static な設定以外に dynamic な設定があり,「ディスカバリサービス (xDS)」と言う.xDS と言われている通り,様々な種類がある.今回のコンテンツでは「EDS / CDS / LDS」を試す.

  • EDS (Endpoint Discovery Service)
  • CDS (Cluster Discovery Service)
  • RDS (Route Discovery Service)
  • LDS (Listener Discovery Service)
  • SDS (Secret Discovery Service)

ドキュメントを読むと,コンテンツに載っている xDS 以外にも種類があった.例えば,以下など.

  • VHDS (Virtual Host Discovery Service)
  • SRDS (Scoped Route Discovery Service)
  • RTDS (RunTime Discovery Service)

www.envoyproxy.io

Step.2 「Cluster ID」

最初に envoy.yamlnode を設定する.この設定は今までのコンテンツには出てこなかった.xDS を使うときに Envoy 自体を識別する ID となり,今回は適当に id_1 とした.

node:
  id: id_1
  cluster: test

Step.3 「EDS Configuration」

今までは envoy.yamlclusters に転送するホストを static に設定していたけど,今回は EDS (Endpoint Discovery Service) を使うため eds_configeds.conf を指定する.ファイル自体は Step.4 で作成する.

clusters:
- name: targetCluster
  connect_timeout: 0.25s
  lb_policy: ROUND_ROBIN
  type: EDS
  eds_cluster_config:
    service_name: localservices
    eds_config:
      path: '/etc/envoy/eds.conf'

今回は「File Based」なので path を指定した.もし api_config_source を指定すると「API Based」になる.

www.envoyproxy.io

Step.4 「EDS Configuration」

次に eds.conf を作成する.まず,エンドポイントを1個にして,IP アドレスを 172.18.0.3 にする.

{
  "version_info": "0",
  "resources": [{
    "@type": "type.googleapis.com/envoy.api.v2.ClusterLoadAssignment",
    "cluster_name": "localservices",
    "endpoints": [{
      "lb_endpoints": [{
        "endpoint": {
          "address": {
            "socket_address": {
              "address": "172.18.0.3",
              "port_value": 80
            }
          }
        }
      }]
    }]
  }]
}

Step.5 「Start Envoy」

実際に Envoy と katacoda/docker-http-server を起動し,以下の構成図のようになる.

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

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

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

f:id:kakku22:20191217065539p:plain

eds.conf にエンドポイントを1個しか設定していないため,何度リクエストを送っても,特定のコンテナに転送される.

$ curl localhost
<h1>This request was processed by host: c9d6a2229f32</h1>

$ curl localhost
<h1>This request was processed by host: c9d6a2229f32</h1>

$ curl localhost
<h1>This request was processed by host: c9d6a2229f32</h1>

Step.6 「Apply Changes」

eds.conf を修正し,エンドポイントを2個にする.以下の構成図のようになる.

{
  "version_info": "0",
  "resources": [{
    "@type": "type.googleapis.com/envoy.api.v2.ClusterLoadAssignment",
    "cluster_name": "localservices",
    "endpoints": [{
      "lb_endpoints": [{
        "endpoint": {
          "address": {
            "socket_address": {
              "address": "172.18.0.3",
              "port_value": 80
            }
          }
        }
      },
        {
        "endpoint": {
          "address": {
            "socket_address": {
              "address": "172.18.0.4",
              "port_value": 80
            }
          }
        }
      }]
    }]
  }]
}

f:id:kakku22:20191217065554p:plain

修正した eds.conf を Envoy に自動的に反映するために mv コマンドを使ってファイルを差し替える.ドキュメントにも記載がある通り,Envoy はファイルの mv を監視している.

# Envoy will only watch the file path for moves.
$ mv eds.conf tmp; mv tmp eds.conf

直後に Envoy にリクエストを送ると,Envoy コンテナを再起動せずに eds.conf を反映できた.

$ curl localhost
<h1>This request was processed by host: c9d6a2229f32</h1>

$ curl localhost
<h1>This request was processed by host: edd664e9d604</h1>

$ curl localhost
<h1>This request was processed by host: c9d6a2229f32</h1>

$ curl localhost
<h1>This request was processed by host: edd664e9d604</h1>

Step.7 「CDS Configuration」

最後 2 Steps は CDS (Cluster Discovery Service)LDS (Listener Discovery Service) を試す.まず,CDS の設定となる cds.conf を作成する.既に作成した EDS と連携している.

{
  "version_info": "0",
  "resources": [{
    "@type": "type.googleapis.com/envoy.api.v2.Cluster",
    "name": "targetCluster",
    "connect_timeout": "0.25s",
    "lb_policy": "ROUND_ROBIN",
    "type": "EDS",
    "eds_cluster_config": {
      "service_name": "localservices",
      "eds_config": {
        "path": "/etc/envoy/eds.conf"
      }
    }
  }]
}

次に LDS の設定をする lds.conf を作成する.今まで envoy.yaml に設定していた filters を LDS に移したイメージとなる.

{
  "version_info": "0",
  "resources": [{
    "@type": "type.googleapis.com/envoy.api.v2.Listener",
    "name": "listener_0",
    "address": {
      "socket_address": {
        "address": "0.0.0.0",
        "port_value": 10000
      }
    },
    "filter_chains": [{
      "filters": [{
        "name": "envoy.http_connection_manager",
        "config": {
          "stat_prefix": "ingress_http",
          "codec_type": "AUTO",
          "route_config": {
            "name": "local_route",
            "virtual_hosts": [{
              "name": "local_service",
              "domains": [
                "*"
              ],
              "routes": [{
                "match": {
                  "prefix": "/"
                },
                "route": {
                  "cluster": "targetCluster"
                }
              }]
            }]
          },
          "http_filters": [{
            "name": "envoy.router"
          }]
        }
      }]
    }]
  }]
}

最後に新しく envoy1.yaml を作成し,cds.conflds.conf を設定している.全てを dynamic_resources の中に設定したため,とてもシンプルになった.おおおー!

node:
  id: id_1
  cluster: test

dynamic_resources:
  cds_config:
    path: "/etc/envoy/cds.conf"
  lds_config:
    path: "/etc/envoy/lds.conf"

既に Envoy を起動しているため,今回は 81 Port で新しい Envoy を起動する.特に挙動は変わらないけど,以下の構成図のようになる.

docker run --name=proxy-eds-cds-lds-filebased -d \
    -p 9902:9901 \
    -p 81:10000 \
    -v /root/:/etc/envoy \
    -v /root/envoy1.yaml:/etc/envoy/envoy.yaml \
    envoyproxy/envoy:latest

$ curl localhost:81
<h1>This request was processed by host: c9d6a2229f32</h1>

$ curl localhost:81
<h1>This request was processed by host: edd664e9d604</h1>

$ curl localhost:81
<h1>This request was processed by host: c9d6a2229f32</h1>

$ curl localhost:81
<h1>This request was processed by host: edd664e9d604</h1>

f:id:kakku22:20191217070613p:plain

Step.8 「CDS Apply Changes」

最後は cds.conflds.conf を修正して Envoy に反映する.まず,新しく cds.conf に Cluster newTargetCluster を追加する.

{
  "version_info": "0",
  "resources": [{
      "@type": "type.googleapis.com/envoy.api.v2.Cluster",
      "name": "targetCluster",
      "connect_timeout": "0.25s",
      "lb_policy": "ROUND_ROBIN",
      "type": "EDS",
      "eds_cluster_config": {
        "service_name": "localservices",
        "eds_config": {
          "path": "/etc/envoy/eds.conf"
        }
      }
    },
    {
      "@type": "type.googleapis.com/envoy.api.v2.Cluster",
      "name": "newTargetCluster",
      "connect_timeout": "0.25s",
      "lb_policy": "ROUND_ROBIN",
      "type": "EDS",
      "eds_cluster_config": {
        "service_name": "localservices",
        "eds_config": {
          "path": "/etc/envoy/eds1.conf"
        }
      }
    }
  ]
}

Cluster newTargetCluster から転送されるエンドポイントも新しく eds1.conf として設定する.

{
  "version_info": "0",
  "resources": [{
    "@type": "type.googleapis.com/envoy.api.v2.ClusterLoadAssignment",
    "cluster_name": "localservices",
    "endpoints": [{
      "lb_endpoints": [{
          "endpoint": {
            "address": {
              "socket_address": {
                "address": "172.18.0.6",
                "port_value": 80
              }
            }
          }
        },
        {
          "endpoint": {
            "address": {
              "socket_address": {
                "address": "172.18.0.7",
                "port_value": 80
              }
            }
          }
        }
      ]
    }]
  }]
}

lds.conf は Cluster を newTargetCluster に変更しておく.

"route": {
  "cluster": "newTargetCluster"
}

修正した cds.conflds.confmv コマンドを使って差し替える.すると,自動的に新しく起動した katacoda/docker-http-server に接続できるようになった.

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

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

$ mv cds.conf tmp; mv tmp cds.conf; mv lds.conf tmp; mv tmp lds.conf

$ curl localhost:81
<h1>This request was processed by host: beac7d8bc5d3</h1>

$ curl localhost:81
<h1>This request was processed by host: 8d90f60e8a19</h1>

$ curl localhost:81
<h1>This request was processed by host: beac7d8bc5d3</h1>

$ curl localhost:81
<h1>This request was processed by host: 8d90f60e8a19</h1>

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

f:id:kakku22:20191217071552p:plain

まとめ

  • 「Try Envoy」のコンテンツ「File Based Dynamic Routing Configuration」を試した
  • Envoy でサポートされている「ディスカバリサービス (xDS)」の一部を学べた
    • EDS (Endpoint Discovery Service)
    • CDS (Cluster Discovery Service)
    • LDS (Listener Discovery Service)
  • 次は 「API Based」な xDS を試すために「API Based Dynamic Routing Configuration」をまとめる!

プルリクエスト

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

github.com

Try Envoy 関連