UZABASE Tech Blog

〜迷ったら挑戦する道を選ぶ〜 株式会社ユーザベースの技術チームブログです。

【Clojure】Ductで始めるWebAPI開発

こんにちは!!SPEEDA開発チームの岡村です!!

私たちの開発チームでは、先日チームメンバの野口が書いたこちらの記事に書かれているように、チームメンバーの入れ替えが頻繁に行われます。

かく言う私も一ヶ月前に行われたチームシャッフルで、ClojureでDuctを使って開発を行うチームに移動しました。(Clojureほぼ未経験)

私がClojure初心者だからこそ、これからClojureとDuctを始めて見ようかなと思っている方に対し、お伝えできることがあると思い、今回はDuctでWebAPIを作成する方法をご紹介しようと思います。

Ductとは

Ductはライフサイクル管理を行うライブラリ、Integrantをベースとしたサーバサイドのフレームワークであり、アプリケーション構造は、Integrantのコンフィグレーションマップによって定義されます。

DuctとIntegrantの関係に関しては、弊社の中村がこちらの記事でも一度ご紹介しています。

作成物

CRUDの題材として定番な、TODO管理を下記リクエストで行えるAPIを作成していきます。

リクエストメソッド パス 内容
GET /todos 一覧を取得する
GET /todos/:id 指定したデータを取得する
POST /todos データを登録する
PUT /todos/:id 指定したデータを更新する
DELETE /todos/:id 指定したデータを削除する

プロジェクト作成

DuctプロジェクトはLeiningenを使って作成することができ、プロファイルを指定すると、
それに合わせプロジェクト構成や設定ファイルを自動で生成してくれます。

$ lein new duct todo +api +ataraxy +postgres

今回はAPIを作成し、ルーティングライブラリにAtaraxy、TodoデータをPostgresで管理するため、
+api +ataraxy +postgresと指定しました。

Todoを管理するデータベースも作成しておきましょう。
今回はDockerコンテナで作成しました。

docker run --name todo-db -p 5432:5432 -e POSTGRES_DB=todo-db -e POSTGRES_USER=username01 -e POSTGRES_PASSWORD=password01 -d postgres:11.5 

システムの起動

DuctはREPLを利用して開発を進めていくため、先ほど作成したプロジェクトに移動し、REPLを起動しましょう!

$ lein repl
nREPL server started on port 50077 on host 127.0.0.1 - nrepl://127.0.0.1:50077
REPL-y 0.4.3, nREPL 0.6.0
Clojure 1.10.0
OpenJDK 64-Bit Server VM 11.0.2+9
    Docs: (doc function-name-here)
          (find-doc "part-of-name-here")
  Source: (source function-name-here)
 Javadoc: (javadoc java-object-or-class-here)
    Exit: Control+D or (exit) or (quit)
 Results: Stored in vars *1, *2, *3, an exception in *e

REPLが起動したら、(dev)と入力し開発環境をロードします。

user=> (dev)
:loaded

その後、(go)とコマンドを入力するとシステムを起動することができるのですが、
DBへの接続情報をまだ記述していないため、下記の様なエラーが発生し、起動することができません。

dev=> (go)
:duct.server.http.jetty/stopping-server
Execution error (PSQLException) at org.postgresql.core.v3.ConnectionFactoryImpl/doAuthentication (ConnectionFactoryImpl.java:534).
サーバはパスワード・ベースの認証を要求しましたが、パスワードが渡されませんでした。
dev=> 

システムを起動させるため、DB接続情報を記述しましょう!
/todo/dev/resources配下にある、dev.ednファイルを以下の様に変更します。
このファイルはファイル名の通り、開発環境での設定を記述するファイルです。

{:duct.database/sql
 {:connection-uri "jdbc:postgresql://localhost:5432/todo-db?user=username01&password=password01"}
 }

Ductではednファイルにコンフィグレーションマップを書いていくことで、
アプリケーションの構造を表現することができます。

ここではduct.database/sqlというコンポーネントの初期化時に、データベースの接続情報が渡される用に定義しています。

書き換えた後に、再度システムを起動させてみましょう。

dev=> (go)
:duct.server.http.jetty/starting-server {:port 3000}
:initiated

上記の様に表示されていれば、システムが起動されています。 curlでリクエストを送ってみましょう。

$ curl http://localhost:3000
{"error":"not-found"}

errorが帰ってきますが、起動していることがわかります。

マイグレーション

ductのmoduleでマイグレーションツールとして提供されている、
migrator.ragtime を利用して、システムの起動時にテーブルの作成と、テストデータの投入が行われるようにしましょう。

先ほど編集したdev.ednを下記のように編集します。

{:duct.database/sql
 {:connection-uri "jdbc:postgresql://localhost:5432/todo-db?user=username01&password=password01"}

 :duct.migrator/ragtime
 {:migrations [#ig/ref :todo.migration/create-todos]}

 [:duct.migrator.ragtime/sql :todo.migration/create-todos]
 {:up ["CREATE TABLE todos (id SERIAL  PRIMARY KEY, title TEXT)"
       "INSERT INTO todos (title) values('test1')"
       "INSERT INTO todos (title) values('test2')"]
  :down ["DROP TABLE todos"]}
 }

システムを再起動させてみましょう。
REPLで(reset)と入力すると、再起動することができます。

dev=> (reset)
:reloading ()
:duct.migrator.ragtime/applying :todo.migration/create-todos#2cd2c3f9
:resumed

これでデータベースにテーブルが作成され、データが投入されました。

一覧取得処理の実装

まずはTodo一覧を取得する処理を実装していきましょう。
/todo/resources/todo/に存在する、config.ednを下記のように編集します。

{:duct.profile/base
 {:duct.core/project-ns todo

  :duct.router/ataraxy
  {:routes
   {[:get "/todos"] [:todo.handler.todos/list]}
   }

  ;; Handlers
  :todo.handler.todos/list {}

  }

 :duct.profile/dev   #duct/include "dev"
 :duct.profile/local #duct/include "local"
 :duct.profile/prod  {}

 :duct.module/logging {}
 :duct.module.web/api
 {}
 :duct.module/sql
 {}}

:duct.router/ataraxyコンポーネントに、 {[:get "/todos"] [:todo.handler.todos/list]}という記述を追加しました。
これは、/todosにGETリクエストが来た時、:todo.handler.todos/listコンポーネントで処理をするということを表しています。

:todo.handler.todos/list {}という記述も追加しました。これは:todo.handler.todos/listコンポーネントの初期化時に空のマップを渡すということを示しています。

ハンドラーの実装

続いてハンドラーを作成していきます。

/todo/src/todo/handlerに、todos.cljというファイルを作成し、以下を記述します。

(ns todo.handler.todos
  (:require [ataraxy.response :as response]
            [integrant.core :as ig]))

(defmethod ig/init-key ::list [_ _]
  (fn [_]
    [::response/ok {:message "OK!!!"}]))

Integrantでマルチメソッドとして定義されている、ig/init-keyを実装することにより、コンポーネントを作成することができます。

::listというのは、todo.handler.todos/listと同じ意味になります。
これにより、先ほどconfig.ednで書いた設定と処理を紐づけることができます。

戻り値は、Ataraxyを利用しているのでベクタで記述することができます。
一先ずリクエストが来たら :message "OK!!!"というマップを返すようにしました。

一旦システムを再起動し、実際にリクエストをしてみましょう!

dev> (reset)
:reloading (todo.handler.todos)
;;=> :resumed
$ curl  http://localhost:3000/todos
{"message":"OK!!!"}

レスポンスが帰ってくることを確認できました!

それでは、実際にデータベースからTodoリストを取得し、返すように実装を変えていきます。

/todo/resources/todo/config.ednを下記のように変更します。

{:duct.profile/base
 {:duct.core/project-ns todo

  :duct.router/ataraxy
  {:routes
   {[:get "/todos"] [:todo.handler.todos/list]}
   }

  ;;Handlers
  :todo.handler.todos/list {:db #ig/ref :duct.database/sql}

  }

 :duct.profile/dev   #duct/include "dev"
 :duct.profile/local #duct/include "local"
 :duct.profile/prod  {}

 :duct.module/logging {}
 :duct.module.web/api
 {}
 :duct.module/sql
 {}}

:todo.handler.todos/list {}と記述していた箇所を、
:todo.handler.todos/list {:db #ig/ref :duct.database/sql}に変更しました。

#ig/ref関数を利用し、:todo.handler.todos/listコンポーネントは、:duct.database/sqlコンポーネントに依存しているということを示しています。

:duct.database/sqlは、最初にdev.ednで記述したコンポーネントのことです。

これにより、:todo.handler.todos/listコンポーネントの初期化時に、
初期化済の:duct.database/sqlコンポーネントを受け取ります。

次に先ほど作成した、todos.cljを以下のように変更します。

(ns todo.handler.todos
  (:require [ataraxy.response :as response]
            [integrant.core :as ig]
            [todo.boundary.todos :as todos]))

(defmethod ig/init-key ::list [_ {:keys [db]}]
  (fn [_]
    (let [todos (todos/get-todos db)]
      [::response/ok todos])))

第二引数に、先ほどconfig.ednで定義したデータベースの接続情報を受け取り、
後で作成する、データベース接続を行うBoundaryにDB接続情報を渡して結果を返すようにしました。

init-keyは、第一引数にコンポーネントのキー(:todo.handler.todos/list)が、

第二引数にはコンフィグマップのコンポーネントに対応する以下のようなバリューが渡ってきます。

{:db #duct.database.sql.Boundary
 {:spec
  {:datasource #object[net.ttddyy.dsproxy.support.ProxyDataSource 0x11e3a8d net.ttddyy.dsproxy.support.ProxyDataSource@11e3a8d]}}}

バリューは:todo.handler.todos/list {:db #ig/ref :duct.database/sql}{:db #ig/ref :duct.database/sql}のことです。
#ig/refと記述しているため、初期化された:duct.database/sqlコンポーネントが渡ってきています。

データアクセス層の実装

続いて、実際にデータベースへの接続を行う層として、Boundaryを作成していきましょう!

/todo/src/todo/boundaryにtodos.cljというファイルを作成し、以下を記述します。

(ns todo.boundary.todos
  (:require
   [clojure.java.jdbc :as jdbc]))

(defprotocol Todos
  (get-todos [db]))

(extend-protocol Todos
  duct.database.sql.Boundary

  (get-todos [{:keys [spec]}]
    (jdbc/query spec ["SELECT * FROM todos"]))
  )

DBへの接続情報を受け取り、clojure.java.jdbcを利用してデータベースにアクセスしています。

システムを再起動し、リクエストを送ってみましょう!

curl http://localhost:3000/todos
[{"id":1,"title":"test1"},{"id":2,"title":"test2"}]

データベースから値を取得することができました!

登録処理の実装

次にデータベースへの登録処理を実装していきましょう。
流れは一覧取得処理を実装した時とほとんど同じです。

config.ednに/todosにPOSTでリクエストが送られてきた時の定義を記述します。

{:duct.profile/base
 {:duct.core/project-ns todo

  :duct.router/ataraxy
  {:routes
   {[:get "/todos"] [:todo.handler.todos/list]
    [:post "/todos" {body :body-params}] [:todo.handler.todos/create body]}
   }

  :todo.handler.todos/list {:db #ig/ref :duct.database/sql}
  :todo.handler.todos/create {:db #ig/ref :duct.database/sql}

  }

 :duct.profile/dev   #duct/include "dev"
 :duct.profile/local #duct/include "local"
 :duct.profile/prod  {}

 :duct.module/logging {}
 :duct.module.web/api
 {}
 :duct.module/sql
 {}}

先ほどと違うところは、HTTPリクエストからパラメータを取得しているというところです。
HTTPリクエストの情報はマップで渡ってきており、そこから登録に必要な:body-paramsの値を取り出しています。

ハンドラーの実装

次にhandlerを下記のように変更します。

(ns todo.handler.todos
  (:require [ataraxy.response :as response]
            [integrant.core :as ig]
            [todo.boundary.todos :as todos]))

(defmethod ig/init-key ::list [_ {:keys [db]}]
  (fn [_]
    (let [todos (todos/get-todos db)]
      [::response/ok todos])))

(defmethod ig/init-key ::create [_ {:keys [db]}]
  (fn [{[_ params] :ataraxy/result}]
    (let [result (todos/create-todo db params)
          id (:id (first result))]
      [::response/created (str "/todos/" id)])))

無名関数の引数が一覧検索の時と異なっています。
この無名関数の引数には下記のようなHTTPリクエスト情報のマップが渡ってきます。

{:ssl-client-cert nil, 
 :protocol HTTP/1.1, 
 :remote-addr 0:0:0:0:0:0:0:1, 
 :params {}, 
 :body-params {:title test3}, 
 :route-params {}, 
 :headers {user-agent curl/7.54.0, host localhost:3000, accept */*, content-length 18, content-type application/json}, 
 :server-port 3000, 
 :muuntaja/request #FormatAndCharset{:format application/json, :charset utf-8, :raw-format application/json}, 
 :ataraxy/result [:todo.handler.todos/create {:title test3}], 
 :content-length 18, :form-params {}, 
 :query-params {}, 
 :content-type application/json, 
 :character-encoding UTF-8, 
 :uri /todoss 
 :server-name localhost, 
 :query-string nil, 
 :muuntaja/response #FormatAndCharset{:format application/json, :charset utf-8, :raw-format */*}, :body #object[org.eclipse.jetty.server.HttpInputOverHTTP 0x74981d49 HttpInputOverHTTP@74981d49], 
:scheme :http, 
:request-method :post}

そのため:ataraxy/resultを分配束縛させています。

データアクセス層の実装

続いてBoundaryを編集しましょう!

(ns todo.boundary.todos
  (:require
   [clojure.java.jdbc :as jdbc]))

(defprotocol Todos
  (get-todos [db])
  (create-todo [db params]))

(extend-protocol Todos
  duct.database.sql.Boundary

  (get-todos [{:keys [spec]}]
    (jdbc/query spec ["SELECT * FROM todos"]))

  (create-todo [{:keys [spec]} params]
    (jdbc/insert! spec :todos {:title (:title params)}))

  )

jdbc/queryを使っても、もちろん実装できるのですが、jdbc/insert!を利用してみました。

これで登録処理の実装は完了です!

システムを再起動してリクエストを送ってみましょう!

curl -i -X POST http://localhost:3000/todos -d '{"title": "test3"}'  --header "Content-Type: application/json" 
HTTP/1.1 201 Created
Date: Thu, 29 Aug 2019 05:14:01 GMT
Location: http://localhost:3000/todos/3
Content-Type: application/octet-stream
Content-Length: 0
Server: Jetty(9.2.21.v20170120)

登録処理を行うことができました!

更新、削除、一件取得処理の実装

更新、削除、一件取得処理もこれまでと同様にして実装することができます!
そのため説明は割愛させていただきますが、最終的に実装は下記のようになります。

todo/resources/todo/config.edn

{:duct.profile/base
 {:duct.core/project-ns todo

  :duct.router/ataraxy
  {:routes
   {[:get "/todos"] [:todo.handler.todos/list]
    [:get "/todos/" id] [:todo.handler.todos/fetch ^int id]
    [:post "/todos" {body :body-params}] [:todo.handler.todos/create body]
    [:put "/todos/" id {body :body-params}] [:todo.handler.todos/update ^int id body]
    [:delete "/todos/" id] [:todo.handler.todos/delete ^int id]
    }}

  ;; Handler
  :todo.handler.todos/list
  {:db #ig/ref :duct.database/sql}
  :todo.handler.todos/fetch
  {:db #ig/ref :duct.database/sql}
  :todo.handler.todos/create
  {:db #ig/ref :duct.database/sql}
  :todo.handler.todos/update
  {:db #ig/ref :duct.database/sql}
  :todo.handler.todos/delete
  {:db #ig/ref :duct.database/sql}
  }

 :duct.profile/dev   #duct/include "dev"
 :duct.profile/local #duct/include "local"
 :duct.profile/prod  {}

 :duct.module/logging {}
 :duct.module.web/api
 {}
 :duct.module/sql
 {}}

todo/src/todo/handler/todos.clj

(ns todo.handler.todos
  (:require [ataraxy.response :as response]
            [integrant.core :as ig]
            [todo.boundary.todos :as todos]))

(defmethod ig/init-key ::list [_ {:keys [db]}]
  (fn [_]
    (let [todos (todos/get-todos db)]
      [::response/ok todos])))

(defmethod ig/init-key ::fetch [_ {:keys [db]}]
  (fn [{[_ id] :ataraxy/result}]
    (let [todo (todos/fetch-todo db id)]
      [::response/ok todo])))

(defmethod ig/init-key ::create [_ {:keys [db]}]
  (fn [{[_ params] :ataraxy/result}]
    (let [result (todos/create-todo db params)
          id (:id (first result))]
      [::response/created (str "/todos/" id)])))

(defmethod ig/init-key ::update [_ {:keys [db]}]
  (fn [{[_ id params] :ataraxy/result}]
    (todos/update-todo db id params)
    [::response/no-content]))

(defmethod ig/init-key ::delete [_ {:keys [db]}]
  (fn [{[_ id] :ataraxy/result}]
    (todos/delete-todo db id)
    [::response/no-content]))

todo/src/todo/boundary/todos.clj

(ns todo.boundary.todos
  (:require
   [clojure.java.jdbc :as jdbc]))

(defprotocol Todos
  (get-todos [db])
  (fetch-todo [db id])
  (create-todo [db params])
  (update-todo [db id params])
  (delete-todo [db id]))

(extend-protocol Todos
  duct.database.sql.Boundary

  (get-todos [{:keys [spec]}]
    (jdbc/query spec ["SELECT * FROM todos"]))

  (fetch-todo [{:keys [spec]} id]
    (jdbc/query spec [(format "SELECT * FROM todos WHERE id = '%s'" id)]))

  (create-todo [{:keys [spec]} params]
    (jdbc/insert! spec :todos {:title (:title params)}))

  (update-todo [{:keys [spec]} id params]
    (jdbc/update! spec :todos {:title (:title params)} ["id = ?" id]))

  (delete-todo [{:keys [spec]} id]
    (jdbc/delete! spec :todos ["id = ?" id]))

  )

まとめ

Integrantの「アプリケーションの構造と実装を、明確に分けて開発を行っていける」という機能を利用して作成されたDuctは、ednファイルで各コンポーネント間の依存関係を明示でき、「何が何に依存しているのかednファイルを見ればわかる」といったとこや、提供されているモジュールを利用することにより、実装を手軽に行えるということに魅力を感じました。

Rustで非同期リクエストでハマったこと

こんにちは!SPEEDAプロダクト開発チームの成です。

去年の秋ごろからRustを触り始め、徐々にRustの魅力に惚れられました。 最初は日々コンパイラーにボコボコにされていましたが、 The Book *1 を読みながら、Rustを少しずつ理解していくと、段々コンパイラーと仲良くなってきて、Rustを書くのも楽しくなりました。

小さいな作業効率化のツールから、Rest Api Server、色んな処理を並列化するBatchなどをRustで作ってきました。最近プロダクトのマイクロサービスの極一部もRustで作っており、幸せ感満喫です!!

本日は、HTTPリクエストを並列化するときハマったことをお話したいと思います。 今日の内容はある程度Rustの知識が必要ですが、初めての方は上の The Book の日本語版 から参考できます。

Rustは独特なメモリ管理仕組みを持ち、安全かつRuntimeで発生したら世界が止まるFullGC的なものも一切ないし、高速な並列処理などが特徴です。めちゃくちゃカッコイイ言語と思いますので、本当に広がってほしいです。なので、初めての方々も是非書いてみてください!

はい、宣伝は以上です。本題に入ります。

今日はサンプルコードを介してハマった点を説明させていただきます。

  • 環境

OS rustバージョン
macOS 10.14.6 rustc 1.39.0-nightly
  • 使ったライブラリ

[dependencies]
reqwest = "0.9.20"
tokio-threadpool = "0.1.15"
futures01 = "0.1.28"
futures-preview = { version = "0.3.0-alpha.18", features = ["io-compat"] }
failure = "0.1.5"

今回は tokio-threadpoolfuturesの01系 を使って並列処理を実現します。
HTTPリクエストのライブラリは reqwest を使います。
記事の最後にはRust公式の新しい非同期シンタックス async / await を試したことを共有したいので、 futuresの03系 も入れました。

  • 同期リクエストの実現

1000回リクエストを順番に実行されるサンプルコードです。

use failure::Fail;
use reqwest::{Client, Url};
use std::thread;
use std::thread::ThreadId;
use std::time::{Duration, SystemTime};

pub type ResultWithError<T> = std::result::Result<T, ErrorWrapper>;

#[derive(Fail, Debug)]
pub enum ErrorWrapper {
    #[fail(display = "http request error: {:?}", error)]
    HttpRequestError { error: reqwest::Error },
}

impl From<reqwest::Error> for ErrorWrapper {
    fn from(error: reqwest::Error) -> Self {
        ErrorWrapper::HttpRequestError { error }
    }
}

fn main() {
    request_with_main_thread();
}

fn request_with_main_thread() {
    output1("START SINGLE THREAD");
    let start_at = SystemTime::now();

    let client = Client::new();

    (0..1000)
        .collect::<Vec<i32>>()
        .iter()
        .for_each(|_| match send_request(&client) {
            Ok((thread_id, text)) => output2(thread_id, text.as_str()),
            Err(error) => output1(format!("{:?}", error).as_str()),
        });

    let spent_time = start_at.elapsed().unwrap_or(Duration::new(0, 0));
    output1(format!("END: {}", spent_time.as_millis()).as_str())
}

fn send_request(client: &Client) -> ResultWithError<(ThreadId, String)> {
    let mut response = client
        .get(Url::parse("http://localhost:9000/timestamp").unwrap())
        .send()?;
    Ok((thread::current().id(), response.text()?))
}

fn output2(thread_id: ThreadId, text: &str) {
    println!("[{:?}] => {}", thread_id, text);
}

fn output1(text: &str) {
    output2(thread::current().id(), text);
}
  • main() 関数以前のコードはエラーハンドリングに関するものなので、説明は割愛します。

  • send_request() 関数ではローカルで別で立ち上がっているRestApiServerにリクエストを投げてシステム時間を返してくれます。

  • 実行結果
[ThreadId(1)] => START SINGLE THREAD
[ThreadId(1)] => 2019-09-01T18:50:22.706987Z
[ThreadId(1)] => 2019-09-01T18:50:22.710136Z
[ThreadId(1)] => 2019-09-01T18:50:22.713299Z
......
[ThreadId(1)] => 2019-09-01T18:50:27.398560Z
[ThreadId(1)] => 2019-09-01T18:50:27.404358Z
[ThreadId(1)] => 2019-09-01T18:50:27.407224Z
[ThreadId(1)] => END: 4711

当たり前ですが、リクエスト処理は全部メインスレッド ThreadId(1) で順番に実行されてます。1000回リクエスは5秒近くかかりました。

  • 非同期リクエストの実現

1000回リクエストを10スレッドに捌けて実行されるサンプルコードです。

use failure::Fail;
use futures01::{future, Future};
use reqwest::r#async::Client as AsyncClient;
use reqwest::Url;
use std::thread;
use std::thread::ThreadId;
use std::time::{Duration, SystemTime};
use tokio_threadpool::{Builder, SpawnHandle};

fn main() {
    request_with_multi_thread();
}

fn request_with_multi_thread() {
    output1("START MULTI THREAD");
    let start_at = SystemTime::now();

    let client = AsyncClient::new();
    let thread_pool_size = 10;
    let thread_pool = Builder::new().pool_size(thread_pool_size).build();

    let mut handles = Vec::<SpawnHandle<(ThreadId, String), ErrorWrapper>>::new();
    while handles.iter().count() <= 1000 {
        let cloned_client = client.clone();
        handles.push(thread_pool.spawn_handle(future::lazy(move || {
            send_request_for_future(&cloned_client)
        })));
    }

    handles.iter_mut().for_each(|handle| match handle.wait() {
        Ok((thread_id, text)) => output2(thread_id, text.as_str()),
        Err(error) => output1(format!("{:?}", error).as_str()),
    });

    thread_pool.shutdown_now();

    let spent_time = start_at.elapsed().unwrap_or(Duration::new(0, 0));
    output1(format!("END: {}", spent_time.as_millis()).as_str())
}

fn send_request_for_future(
    client: &AsyncClient,
) -> impl Future<Item = (ThreadId, String), Error = ErrorWrapper> {
    client
        .get(Url::parse("http://localhost:9000/timestamp").unwrap())
        .send()
        .and_then(|mut response| response.text())
        .map(|text| (thread::current().id(), text))
        .from_err()
}

fn output2(thread_id: ThreadId, text: &str) {
    println!("[{:?}] => {}", thread_id, text);
}

fn output1(text: &str) {
    output2(thread::current().id(), text);
}
  • エラーハンドリングに関するコードは省略しました。

  • このサンプルでは 10スレッド で 1000回リクエストを捌けようとしてます。

  • タスク Future(send_request_for_futureの戻り値) をキューに溜まって、スレッドプール tokio_threadpool にアイドルなスレッドがあれば、キューからタスクを取り出して実行する。
    (行:22~28)

  • その結果達 SpawnHandle をメインスレッドでまとめる。
    (行:30~33)。

  • 実行結果
[ThreadId(1)] => START MULTI THREAD
[ThreadId(10)] => 2019-09-01T19:02:53.924756Z
[ThreadId(6)] => 2019-09-01T19:02:53.930478Z
[ThreadId(11)] => 2019-09-01T19:02:53.929056Z
[ThreadId(11)] => 2019-09-01T19:02:53.934684Z
[ThreadId(13)] => 2019-09-01T19:02:53.932217Z
[ThreadId(7)] => 2019-09-01T19:02:53.918978Z
[ThreadId(7)] => 2019-09-01T19:02:53.921761Z
......
[ThreadId(10)] => 2019-09-01T19:02:54.454774Z
[ThreadId(14)] => 2019-09-01T19:02:54.456831Z
[ThreadId(15)] => 2019-09-01T19:02:54.458395Z
[ThreadId(15)] => 2019-09-01T19:02:54.459974Z
[ThreadId(1)] => END: 593

リクエスト処理は全部メインスレッド以外のスレッドで行われて、かかった時間もほぼ直列の1/8です。

  • ハマったこと

1. HttpClientが違います。

reqwest::Client をFutureの中で使うと下記のエラーが発生します。

[ThreadId(1)] => HttpRequestError { error: Error(BlockingClientInFutureContext, "http://localhost:9000/timestamp") }

Futureの中では reqwest::r#async::Client を使わないといけないです。

2. reqwest::r#async::Client をマルチスレッド間で共有しないと、大量リクエストが発生したらリソースが枯渇してしまいます。
        let cloned_client = client.clone();
        handles.push(thread_pool.spawn_handle(future::lazy(move || {
            send_request_for_future(&cloned_client)
        })));

この部分を下記のように変えたら

        handles.push(thread_pool.spawn_handle(future::lazy(move || {
            send_request_for_future(&AsyncClient::new())
        })));

下記のエラーが大量に出てきます。(リクエストが全部失敗ではないですが)

[ThreadId(1)] => HttpRequestError { error: Error(Hyper(Error(Io, Os { code: 54, kind: ConnectionReset, message: "Connection reset by peer" })), "http://localhost:9000/timestamp") }

リクエスト先のサーバからコネクションを勝手に切断したためエラーになったと思います。
reqwest::r#async::Client のソースを見てみると、確かにClientには connection pool を持っています。
並列で数多くClientを生成すると、余計にリクエスト先のサーバとコネクションを確立し過ぎで、途中で切断されることになります。

/// An asynchronous `Client` to make Requests with.
///
/// The Client has various configuration values to tweak, but the defaults
/// are set to what is usually the most commonly desired value. To configure a
/// `Client`, use `Client::builder()`.
///
/// The `Client` holds a connection pool internally, so it is advised that
/// you create one and **reuse** it.
#[derive(Clone)]
pub struct Client {
    inner: Arc<ClientRef>,
}
3. Future.wait() を呼び出すときは、そのFutureは 別のスレッドで実行される保証 があるかどうかを考えないといけないです。

最初 send_request_for_future() は下記のように実装していました。

fn send_request_for_future(client: &AsyncClient) -> ResultWithError<(ThreadId, String)> {
    Ok((
        thread::current().id(),
        client
            .get(Url::parse("http://localhost:9000/timestamp").unwrap())
            .send()
            .wait()?
            .text()
            .wait()?,
    ))
}

reqwest::r#async::Client は使っていますが、Futureを返せずに実際の処理結果を返そうとしていました。.send().text() はそれぞれFutureを返すので、実際の処理結果を取り出すために .wait()? を2回呼び出しました。その結果、実行した直後に [ThreadId(1)] => START MULTI THREAD だけが出力され、その後は一切処理が進まなかったです。
原因は send_request_for_future() はthread_pool中のスレッドから実行されますが、そこで呼び出している2回の .wait()? は更に他のスレッドに実行してくれないと、処理が進まないです。thread_pool中のスレッドが全部 send_request_for_future() の実行に使ってしまったら、処理全体が進まなくなります。もしthread_pool_sizeがリクエスト回数より多い場合(この例だと let thread_pool_size = 1001; )はすべてのリクエストが正常に終わります。
send_request_for_future() がFutureを返すし、メインスレッドで SpawnHandle.wait() を呼び出すように実装したら、処理全体が正常に終えました。

  • まとめ

ほぼどの技術のハマりも同じだと思います。本質を理解したら当たり前のように見えますが、理解する前には詳しい人に聞いたり、ググったりしてバタバタしても中々解決しないとイライラしてしまいますね。今回のハマリポイント、特に3点目は色々ググっても類似のサンプルソースがなかったので、困りました。もし同じ問題に困っている方がいらっしゃったら、この記事が参考にできるならば嬉しいです。
ここで使っているサンプルソースは下記のプロジェクトからcloneできます。

https://github.com/dimmy82/rust-concurrency-request

  • (おまけ)async / await

今年の7月ぐらい、新しい非同期シンタックス async / await がリリースされて、ちょっと触ってみたので共有させていただきます。もし間違っているところがあればご指摘ください。

use failure::Fail;
use futures::executor::block_on;
use reqwest::{Client, Url};
use std::thread;
use std::thread::ThreadId;
use std::time::{Duration, SystemTime};

fn main() {
    block_on(request_with_async_await()); // this is not a parallel request example
}

async fn request_with_async_await() {
    output1("START ASYNC AWAIT");
    let start_at = SystemTime::now();

    let client = Client::new();
    let future1 = send_request_async(&client);
    let future2 = send_request_async(&client);
    match future1.await {
        Ok((thread_id, text)) => output2(thread_id, text.as_str()),
        Err(error) => output1(format!("{:?}", error).as_str()),
    };
    match future2.await {
        Ok((thread_id, text)) => output2(thread_id, text.as_str()),
        Err(error) => output1(format!("{:?}", error).as_str()),
    };

    let spent_time = start_at.elapsed().unwrap_or(Duration::new(0, 0));
    output1(format!("END: {}", spent_time.as_millis()).as_str())
}

async fn send_request_async(client: &Client) -> ResultWithError<(ThreadId, String)> {
    let mut response = client
        .get(Url::parse("http://localhost:9000/timestamp").unwrap())
        .send()?;
    Ok((thread::current().id(), response.text()?))
}

fn output2(thread_id: ThreadId, text: &str) {
    println!("[{:?}] => {}", thread_id, text);
}

fn output1(text: &str) {
    output2(thread::current().id(), text);
}

結論から言うと、上記サンプルのように

let future1 = /* async 関数を呼び出す */;
let future2 = /* async 関数を呼び出す */;
future1.await;
future2.await;

という書き方は非同期にならず、同期処理になります。 async 関数は await で呼び出されたタイミングで実行される からです。

  • 実行結果
[ThreadId(1)] => START ASYNC AWAIT
[ThreadId(1)] => 2019-09-01T21:23:12.791994Z
[ThreadId(1)] => 2019-09-01T21:23:12.794254Z
[ThreadId(1)] => END: 8

async 関数は処理結果(今回の例だと ResultWithError<(ThreadId, String)> )を返すように実装しますが、実際future1の型は impl core::future::Future<Output=ResultWithError<(ThreadId, String)>> になります。なので、もし非同期処理にしたければ、future1, future2を thread_pool.spawn_handle 的な関数に渡して実行してもらうじゃないかなと思います。
ただ、ここの core::future::Future は最近stdに追加されたFuture Traitですが、 futures01::Future とは全然違うものです。なので、使っているthread_poolのバージョンはまだ対応していないようです。
今後 core::future::Future に関する使い方が分かったらまた更新します。

以上、よろしくお願いいたします。

*1:The Book: Rustの創始者を含むコニュニティの方々が執筆するRust仕様の本です。去年までは日本語版が古くて、google先生に助けもらいながら無理やり英語版を読んでましたが、最近翻訳の皆さんのおかげて英語版とほぼ同期する 日本語版 が出てきましたので、Rustの勉強ならばそっちが一番おすすめです。

SPEEDA開発チームをブーストするふりかえりのカルチャー

こんにちは!SPEEDA開発チームの岩見です。
この記事では私たちSPEEDA開発チームの中でも特徴的な文化のひとつとなっている、ふりかえりについてご紹介します。

以下のような方々のお役に立つことを願っています。

  • 自分たちのチームでもふりかえりをやってみたい方
  • ふりかえりはやってるけどなんだかマンネリ化している方
  • 色々なふりかえりの運用、実例が知りたい方

SPEEDA開発チームにおけるふりかえり

私たちSPEEDA開発チームでは、プロジェクト単位の少人数のチームに分かれ、それぞれがエクストリームプログラミング(通称XP)をベースとしたアジャイル開発を行なっています。
私たちは短い開発サイクル(イテレーション)を継続的に繰り返す中で、プロダクトとチームでの開発をインクリメンタルに改善、洗練させていくことを目指しています。
こうした中で、私たちはイテレーション(多くの場合1週間が1イテレーションに相当します)の終わりに、ふりかえりを必ず実施しています。

ふりかえりの中では、イテレーションの開発を振り返って、次のイテレーションでの自分たちの開発を改善する為のアクションを一つ設定します。
アクションの数を絞っているのは、数が多すぎても一つ一つのアクションに対する精度が落ちてしまうリスクがあるためです。
短いイテレーションの中でより本質的なアクションを実施する為に、ふりかえりの時間の中で問題の洗い出し、深掘りを集中的に行うようにしています。

こうして決まったアクションをチームで実行しながら、私たちはプロダクトとチームでの開発プロセスを日々進化させています。

SPEEDA開発チームのふりかえりの特徴

私たちがふりかえりのためのミーティングを行う際には、一つの決め事が存在します。
それは、自分たちの所属しているチームの外側から、一人のメンバーにファシリテーターとして参加してもらうことです。
例えばイテレーションのふりかえりであれば、自分たちのプロジェクトチーム以外のエンジニアにファシリテーターとして参加してもらいます。
このエンジニアの中には、Webアプリケーションエンジニア、テストエンジニア、データサイエンティストといった多様なロールのメンバーが含まれています。
また、ファシリテーターの選出には、偏りや重複が出ないように、シフトを組んで回す形をとっています。
そのため、チーム内のエンジニアは、おおよそ1ヶ月に一度、どこかのチームのふりかえりをファシリテーションすることになっています。

SPEEDA開発チームにおけるファシリテーターの役割

ではファシリテーターは何をしているのか、というと、以下のような役割を担っています。

議論しやすい場の設定

ユーザベースの六本木オフィスにはラウンジと呼ばれる社内ミーティング用の広めのスペースがあります。
ファシリテーターはそこにホワイトボードと人数分の椅子をセットし、参加者が議論ができるようなスペースを作ります。
その際には、椅子の配置をメンバーの顔が全員見えるように、かつホワイトボードが見やすいように、ホワイトボードを囲むような形で椅子を設営します。

議論しやすい場作りは、ふりかえりの最中にも行われます。
あるメンバーがあまり議論に参加できていなければ、ファシリテーターがそのメンバーに発言を促したり、議論が脇道にそれてきた際には、議論の進行をメンバー全員で確認したり、と言ったことを行います。
こうした場作りによって、参加者がより議論をしやすい場面を作るのが、ファシリテーターの役割の一つです。

アクティビティの選定を含めた全体の進行

ファシリテーターは、ふりかえりたいテーマに合わせてアクティビティの選定や、タイムスケジュールの設定、タイムキープを行います。
SPEEDA開発チームのふりかえりでは、KPTとよばれるメジャーな手法以外にも様々なアクティビティが用いられています。
例えばイテレーションで起こったことを満遍なくふりかえりたい場合にはタイムライン*1、各メンバーが思っていることや感情に着目してみたいときにはチームレーダー*2や喜怒哀*3、と言った具合に選出を変えていきます。

ファシリテーターの好みとセンスに応じてどのような手法を用いるかが決められるので、参加者は毎回新鮮な気持ちでふりかえりに臨むことができています。

アクティビティは、アジャイル・レトロスペクティブズFun Retrospectivesを参考にして選んだりしています。
特にアジャイル・レトロスペクティブズは、私たち開発チームのメンバーの教科書のようなものになっており、私もファシリテーションを行う際に読み返したりしています。

ファシリテーターを置くメリット

チームの外側からファシリテーターを招くことで、以下のようなメリットを実感しています。

ファシリテーターが他のチームへの理解を深めることができる

SPEEDA開発チームでは、定期的にプロジェクトチーム間での入れ替えを行なっています。
これは、プロジェクトに関する知識、技術を特定個人に偏らせないようにするための施策です。

ファシリテーターとして他のチームのふりかえりに参加することで、他のチームの現在の状況を高い解像度で知ることができます。
結果、いつでもチームメンバーの入れ替えを行うことができるようになっている状況を作り出す一因となっています。

ファシリテーターが問題を構造的・俯瞰的にとらえる能力が高まる

ファシリテーターには、チームで議論されている問題を俯瞰してとらえることが求められます。
議論の進行に合わせて、参加者に様々なアクションを促し、より良い解決策に導く必要があるためです。
ファシリテーションは問題を構造的、俯瞰的にとらえる絶好の機会となっています。

チームのメンバーが議論に集中することができる

これはチームメンバーの視点から見たメリットなのですが、ファシリテーターを別途置くことで、参加者が残り時間などに気を取られず、議論に集中することができるようになります。
結果、短い時間の中でも、腰を据えた議論ができるようになり、より効果的なアクションが打てるようになります。

さいごに

私たちはふりかえりを定期的に実施していますが、同時に「ふりかえりに甘えてはいけない」ということもチーム内で話しています。
これは、ふりかえりの場を設定しているからと言って、チームの問題や改善策について議論する場をそこだけに限定してはいけない、という意図からきているものです。
私達のチームでは直接的な対話を重視して仕事をしており、何かチームで議論、改善するべき事項が出た際には、そのタイミングで議論をするようにしています。
この文化とふりかえりの両輪で、チームとしてより良い姿を目指して日々開発を行なっています。

私たちが目指すチームの姿は、CTO林の以下の記事で紹介しています。
journal.uzabase.com
興味を持たれた方は、是非以下までご連絡ください!
Uzabase, Inc. - Jobs: SPEEDA ソフトウェアエンジニア(サーバー/フロント) - Apply online


それでは良いふりかえりライフを!

*1:時系列に沿って、チームで起こったことや、チームメンバーが感じたことを付箋に書き、貼り出すアクティビティです。一定期間内での時間、因果関係に沿ったデータを収集するのに適しています。

*2:チームで大切にしている価値観(XP Valuesを用いたことがあります。)をレーダー上で表し、各チームメンバーがどのように評価するかを5点満点などでプロットします。その後、プロットされた点数を元に、何故そう思ったかをディスカッションしていきます。

*3:その名の通り、「喜ばしいこと」「怒りを覚えたこと」「哀しかったこと」という軸でデータを収集します。チームメンバーの感情面に着目するアクティビティです。

appiumを使ってモバイルアプリのテストを自動化する

こんにちは!SPEEDA プロダクト開発チームの板倉です。
前回は、テスト環境(appium + gauge + kotlin)のセットアップについて書きました。

appiumを使ってモバイルアプリのテストを自動化する ~環境構築まで~ - UZABASE Tech Blog

今回は、実際にアプリのテストをどう書くのかについて書いていこうと思います。

テスト対象のアプリ

今回はAndroid StudioでBasic Activityを選択してプロジェクトを作成したアプリを例にテストを書いていきます。

f:id:diskit:20190829081840p:plain

f:id:diskit:20190826075829p:plain:w300

今回はプロジェクト作成時に作られたコードをそのまま使用しています。

テストを書く

Spec, Scenario, Step

まずは、画面表示までScenarioを書いていこうと思います。
アプリを起動すると何が表示されるのかをStepに記述します。

# ホーム画面

## ホーム画面を表示する
* ヘッダにタイトル"example"が表示される
* ヘッダにアクションボタンが表示される
* コンテンツにメッセージ"Hello World!"が表示される
* フローティングアクションボタンが表示される

GaugeのSpecの書き方として、
# は Spec
## はScenario
* はStep
となります。

Stepの実装を書いていく

Stepに記述した文章はアノテーションに記述します。
ダブルクオートで囲んだ部分はパラメータになりますので、メソッドに引数として定義します。
1つ目のStepを例に実装すると以下のような形になります。

@Step("ヘッダにタイトル<title>が表示される")
fun ヘッダにタイトルが表示される(title: String) {
}

メソッドの中に操作や検証を記述していきます。
操作や検証をするにはセレクタを使用して要素を取得して行います。
Specに記載した内容を実装すると以下のようになります。

@Step("ヘッダにタイトル<title>が表示される")
fun ヘッダにタイトルが表示される(title: String) {
  val header = driver.findElementById("toolbar")
  header.findElement(By.className("android.widget.TextView")).text shouldEqual title
}


@Step("ヘッダにアクションボタンが表示される")
fun ヘッダにアクションボタンが表示される() {
    val header = driver.findElementById("toolbar")
    header.findElement(By.className("android.widget.ImageView")).isDisplayed shouldBe true
}

@Step("コンテンツにメッセージ<message>が表示される")
fun コンテンツにメッセージが表示される(message: String) {
    // xpathは避けたほうがいいです。
    // そのままのコードを使ったので、開発する際はidを設定するなど工夫が必要だと思います。
    val content = driver.findElement(By.xpath("//android.widget.FrameLayout/android.view.ViewGroup/android.view.ViewGroup"))
    content.findElement(By.className("android.widget.TextView")).text shouldEqual message
}

@Step("フローティングアクションボタンが表示される")
fun フローティングアクションボタンが表示される() {
    val button = driver.findElementById("fab")
    button.isDisplayed shouldBe true
}

この実装をする上で、ヘッダのタイトルのセレクタを記述する際に困りました。
そういったときに使えるツールを紹介します。

Appium Desktop

AppiumはGUIツールが公開されています。
このツールはインスペクターだけでなく、Appiumサーバーの起動も行えます。

github.com

アプリのダウンロードはこちら

確認してみる

今回困ったヘッダのタイトルの要素を見てみると、

f:id:diskit:20190829080347p:plain

左側に表示しているアプリ上でヘッダのタイトルをクリックすると、中央のAppSourceに表示されたソースから要素がハイライトされ、右側には要素の情報を見ることができます。
今回はツールバーをid指定で取得し、その要素からclassNameを指定して取得するセレクタを使用してタイトルの要素を取得することにしました。

さいごに

AppiumからSelenium Driver(AndroidDriver, IOSDriverなど)が提供されているので、思ってた以上に簡単にテストを書くことができました。
今回試したのはアプリに表示されている要素の取得と検証のみだったので、ネイティブ特有の機能を使う場合どのような実装が必要なのかは調べてみようと思います。

appiumを使ってモバイルアプリのテストを自動化する ~環境構築まで~

こんにちは!SPEEDA プロダクト開発チームの板倉です。
前回書いた時はNewsPicksの開発をしていましたが、今はSPEEDAの開発をしてます。

SPEEDAのプロダクトチームでは、以下の記事のようにE2EをGaugeとKotlinを使って書くことが多いです。

Gauge Test Automation Toolとアジャイル開発 - UZABASE Tech Blog

GaugeのConceptを用いてテストシナリオをより仕様書のように記述する - UZABASE Tech Blog

GaugeのParameterを使いこなす - UZABASE Tech Blog

今回は、NewsPicksでアプリ開発をやっていたこともあり、モバイルアプリではE2Eをどうやるのかなということで調べてみました。

環境構築

appiumのインストールとテストプロジェクトの準備について説明します。
※ アプリ開発に関する環境構築については割愛します。

appium

npmを使ってインストールします。

npm install -g appium

プロジェクト

MavenやGradleプロジェクトを作成したのち、以下の依存関係を追加します。

Maven Repository: com.thoughtworks.gauge » gauge-java
Maven Repository: io.appium » java-client

作成したプロジェクトでコマンドを実行してgaugeプロジェクトに必要なファイルを展開します。

gauge init java

実行後のプロジェクトは以下のようなディレクトリ構造になります。

<PROJECT_ROOT>
├── build.gradle
├── env
│   └── default
│       ├── default.properties
│       └── java.properties
├── gradle
│   └── wrapper
│       ├── gradle-wrapper.jar
│       └── gradle-wrapper.properties
├── gradle.properties
├── gradlew
├── gradlew.bat
├── libs
├── manifest.json
├── settings.gradle
├── specs
│   └── example.spec
└── src
    ├── main
    │   ├── java
    │   ├── kotlin
    │   └── resources
    └── test
        ├── java
        │   └── StepImplementation.java
        ├── kotlin
        └── resources

あとはIDEの機能を使って test/java/StepImplementation.java をkotlinに変換し、test/kotlin 以下に移動します。
これでプロジェクトのセットアップは完了です。

appiumのセットアップ

appiumを使ってアプリを操作する際は、SeleniumのRemoteDriverを通して行うことになります。
DriverにCapabilitiesを指定する必要があり、Androidでは最低限以下の2項目を設定することでテストを行うことができます。

キー 設定内容
deviceName テストを実行する端末の名前 Android Emulator
app テストをするアプリ(apk)の絶対パス /Users/user/workspace/project/test-app.apk

こちらにもあるようにAndroidではdeviceNameが設定しても無視されるようです(2019.7.31時点)。

On Android this capability is currently ignored, though it remains required.

Desired Capabilities - Appium

コードで書くとこんな感じです。

fun <T: WebElement> driver(): AppiumDriver<T> {
  val app = File("example.apk")
  val capabilities = DesiredCapabilities().apply {
    setCapability("deviceName", "Android Emulator")
    setCapability("app", app.absolutePath)
  }
  return AndroidDriver(capabilities)
}

このコードだけでも実行すると appium server が実行され、指定した端末にアプリをインストールし起動するまでが行われます!
※ エミュレータは起動しておく必要があります。

次回は

今回は環境構築まで書いてみました。
次回は実際にアプリをテストするコードを書いてみたいと思います!

さいごに

プロダクトチームは一緒にSPEEDAを開発する仲間を募集しています!
プロダクトチームってどんな感じなの?という疑問に対しては、以下の記事が参考になるかと思います!

journal.uzabase.com

少しでも興味のある方はご連絡お待ちしております!!!

使う技術は自分たちで決める。XPを極め技術を追い求めるエンジニア募集! - 株式会社ユーザベースのWeb エンジニア中途の求人 - Wantedly

「ここではすべてが流れている!」SPEEDA の開発チームに入って驚いた 3 つのこと

7 月から SPEEDA 開発チームに参加しました、野口です!

SPEEDA 開発チームでは、XP のプラクティスを大きく取り入れて日々の開発を進めています。
私は入社前から XP やスクラムのようなアジャイル開発手法とその考え方には慣れ親しんでいたのですが、SPEEDA 開発チームに参加してみて、ユニークだなと感じたことがいくつもありました。

この記事では、SPEEDA 開発チームで私が特にユニークだと感じた 3 つのことについて紹介します。

おことわり

この記事の目的は、SPEEDA 開発チームのユニークな文化を紹介することです。
これらの取り組みには、SPEEDA 開発チームの現在のフェーズに固有の部分も少なからずあると思っており、これを読まれた方の組織やチームで、ここに書かれているようなことをただちに実践することを勧めるものではありません。

私自身、チームに入ってからしばらく経ちますが、まだ十分には消化しきれていない部分もあり、これからも実践しながら考え続けていきたいと思っています :)

その 1: チームのメンバーを「安定させない」

SPEEDA 開発チームは、開発を担う機能エリアごとに、数名程度の小さなチームに分かれています。(以後、この「小さなチーム」のことを単に「チーム」と呼ぶことにします)

日々の開発(スタンドアップや見積もり、ふりかえり等を含む)はこのチームごとに行うのですが、このチームのメンバーは、数ヶ月に一度入れ替えを行っています。実際、私が入社してからも一度入れ替えがあり、7 名いたメンバーのうち 2 名が交代しました。 *1

個人的には、チームのメンバーがお互いの個性やチームの文化・慣習を知り、チームとして安定してパフォーマンスを出せるようになるには時間がかかるため、頻繁な入れ替えは避ける方がよいと考えていました。

では、なぜあえてチームメンバーを安定させず、メンバーが「流れる」ようにするのでしょうか?
今のところ、私は以下の理由があると考えています。

  • やる理由: 製品全体についての知識、および文化を共同所有するため。
    • 個々のチームに知識を閉じず、開発チーム全体で製品全体を共同的に理解し、開発・保守・運用できるようになることが目的。また、文化についても同様。
  • できる理由: 「さまざまなものが流れる」文化があるため。
    • チームメンバーの入れ替えに限らず、SPEEDA の開発チームでは多くのものを安定させず、常に流れる状態に置いている。「流れる」ことが定常状態であるため、メンバーの入れ替えもそのうちの一つにすぎず、チーム・メンバーのいずれも入れ替えにすばやく適応できる。

よく見ると、「やる理由」と「できる理由」は表裏一体になっています。メンバーを入れ替えることによって知識や文化が撹拌され、それによってさらに入れ替えがやりやすくなる、という正のフィードバックの関係です。

その 2: 属人化を防ぐためにドキュメントを「残さない」

SPEEDA 開発チームでは、ドキュメントを用いることがかなり稀です。

アジャイルソフトウェア開発宣言では「包括的なドキュメントよりも個人と対話を」価値とする、と言われていますが、これほどまで対話(特に口頭での会話)に重きを置くソフトウェア開発組織はかなり珍しいのではないかと思います。

アジャイルソフトウェア開発宣言でも「ドキュメントは不要」とまでは言っていない、とはよく言われることで、個人的にも、安定して開発を進めるためには必要十分な量のドキュメントを書いた方がよいと考えていました。

では、なぜドキュメントを「最少限」と言い切れるほどのレベルにまで減らすのでしょうか?
現在の私の理解では、以下の理由があると考えています。

  • やる理由: ドキュメントを減らせば、会話せざるを得ないため。
    • やや本末転倒にも見えるが、知識が「流れる」ために必要な仕掛けと言える。
  • できる理由: 知識は撹拌されており、また常に会話する文化があるため。
    • 「その 1」で紹介したように、SPEEDA の製品についての知識や文化は常に流動し、撹拌されている。また、常時ペアプログラミングを行なっていてペア間の会話は絶えないほか、他ペアや他チームからの割り込みも奨励されており、「質問されたらすぐに答える」ことが徹底されている。

おそらくお気付きのように、ここにも正のフィードバックがあります。ドキュメントを減らせば会話が増え、会話が増えれば、さらにドキュメントを減らせるようになります。
よく「属人化を防ぐためにドキュメントを残す」と言われますが、私たちのチームでは、いわば知識が「チーム全体に属人化(属チーム化?)」したような状態といえます。

その 3: 担当者を「明確にしない」

SPEEDA 開発チームでは、「担当者を明確にしない」ことがよくあります。

たとえば、チームでのふりかえりを行なった際は次週に取り組むアクションを決めますが、私が初めてチームでのふりかえりに参加したとき、アクションの担当者を決めずにふりかえりが終わったことに驚きました。「ちゃんと決めておかないと、みんな忘れてしまったらどうするの?」と思ったのですね。

しかしこれは杞憂でした。当たり前といえば当たり前ですが、誰かが覚えているのですね。
アクションは次週にきちんと行われました。

とはいえ、一般論としてはアクションの担当者を決める方が確実です。私が初めて会ったチームのふりかえりのファシリテーターだったら、「誰か担当者になってくれますか?」と聞くと思います。

他にも、SPEEDA 開発チームでは、普通なら担当者を決めるであろう場面で、あえて決めない、とすることが多くあります。

これはなぜでしょうか?
私の考えでは、以下が理由です。

  • やる理由: 「活動の最小単位」をチームとし、何らかのタスクが個人に帰属することを防ぐため。
    • 何かを個人のタスクとしてしまうと、そのプロセスについての知識と、責任が個人に紐づいてしまう。そうならないよう、万事を個人ではなくチームの関心事にとどめるために、担当者を決めない。
  • できる理由: いつでも活動の最小単位をチームとしており、そうすることに慣れているから。
    • つまり、普段からそうする訓練をしているから。

「やる理由」についてはともかく、「できる理由」については「やっていたらできるようになった」という理由しか思いつきませんでした :)
チーム全体でこれを意識していると、不思議と補い合うようになります。失敗することも時にはありますが、その都度会話して対応します(ただし、できる限り「担当者を決める」以外の方法で)。

「その 1」と「その 2」では「知識」や「文化」が流れていましたが、ここでは「担当」が流れています。*2

そうやってどこへ行きたいのか、そしてこれから

ここまで紹介してきたように、SPEEDA の開発チームでは多くのものが「流れて」います。

私の個人的な感覚では、「ほとんど全てが流れている」と言ってもいいように思います。他にも、今回は紹介しなかったペアプログラミングのやり方や、ミーティングの進め方、製品のデザイン等々、多くの場面が「流れ」の中にあるように感じます。

では、「流れ」それ自体が目的なのでしょうか?
おそらく、Yes でもあり No でもある、と言えます。

ソフトウェア開発という営み自体が絶え間ない流れの中にあり、SPEEDA はソフトウェアサービスなので、その開発チームにあって流れは重要です。そのため、「流れ」はそれ自体として目的の一つになりえます。

一方で、「流れ」は究極の目的ではないはずです。
ユーザベースには「経済情報で、世界を変える」というミッションがあり、それを支える SPEEDA プロダクトチーム(開発チームもここに属する)のミッションは「技術力で、ビジネスをリードする」です。だとすれば、この「流れ」の文化はそれを支えるものであるはずだし、そうあるべきです。

たとえば、以下のインタビュー記事で CTO の林が掲げている「最高の開発チームをつくる」は、「流れ」によって支えられる大きな目標の一つなのでしょう。

journal.uzabase.com

私はまだチームに参加して 2 ヶ月足らずですが、これからもチームで仕事をしていく中でこの流れの文化の究極の意義を見つけ出し、発展させていくことを楽しみにしています!

*1:ちなみに入れ替えを行うこと自体は半ばルール化されていますが、誰が入れ替わるかはメンバー自身の意思によって決まります

*2:もしかしたら、チームの中を「漂っている」と言った方がより的確かもしれません。もっとも「チーム」という単位で見れば、担当は「定まっている」とも言えますが、そのチームのメンバーでさえ入れ替わるので、やはり流れている、漂っているという表現の方が的確に思えます

Istioでマイクロサービスのテスタビリティを向上させる

SPEEDAの開発チームの石橋です。

最近ではマイクロサービスでプロダクトを開発することが多くなってきていると思います。 そういった状況の中でマイクロサービスのテスト、特に異常系のテストをするコストがやや高いという話を何度か耳にしました。 本記事ではIstioのFault Injectionで「エラーが発生する」、「処理に時間がかかる」などの異常系のテストを容易に実現する方法を紹介します。

異常系のテストをする際の課題

サービスAとサービスBがあるとします。サービスAからサービスBにリクエストした際、サービスBがエラーになる可能性があります。 そうした場合、そのエラーが他のマイクロサービスに伝播して、障害がシステム全体へと波及しないよう適切に処理する必要があります。

適切な処理ができていることをテストするためは、「サービスAが正しいリクエストをしてもサービスBが必ずエラーを返す」状態を作らなければなりません。 このような状況を作るためにはいくつか方法が考えられます。

  • 必ずエラーを返すようにサービスBのコードを修正してデプロイする。
  • 必ずエラーを返すモックサーバーを用意し、サービスAの向き先をサービスBからモックサーバーに変更する。

これらの方法はやや面倒だと思います。特に前者はローカルで一時的に修正して確認する分にはよいかもしれませんが、正常系と混在している自動テストに組み込むことは困難です。 IstioのFault Injectionはこの課題を解決します。設定ファイルを適用するだけで「サービスAが正しいリクエストをしてもサービスBが必ずエラーを返す」状態を作ることができます。

環境構築

本記事ではバージョンが1.2.2のIstioを使用して動作確認しています。

まずはKubernetesに必要なリソースを作成していきます。Gatewayはdefault、ServiceとDeploymentとDestinationRuleとVirtualServiceはexample-nsというnamespaceに作成していきます。VirtualServiceはDestinationRuleの後に作成してください。*1 kubectl apply -f ファイル名を実行すれば、それぞれのリソースが作られます。

※それぞれのリソースの概要や設定ファイルの詳細な書き方については割愛します。

Gateway
Service
Deployment
DestinationRule
VirtualService

また、外部からアクセスするためのエンドポイントを環境変数で定義しておきます。後で何度もアクセスするためです。

export INGRESS_HOST=$(kubectl -n istio-system get service istio-ingressgateway -o jsonpath='{.status.loadBalancer.ingress[0].ip}')
export INGRESS_PORT=$(kubectl -n istio-system get service istio-ingressgateway -o jsonpath='{.spec.ports[?(@.name=="http2")].port}')
export GATEWAY=$INGRESS_HOST:$INGRESS_PORT

Fault Injection

まずはFault Injectionする前に上の環境構築で作ったPodに対してアクセスし、正常に処理されることを確認します。

$ for i in {1..10}; do sleep 1; curl -s -H 'Host: example-svc.example-ns.svc.cluster.local' $GATEWAY -o /dev/null -w '%{http_code}\n' ; done
200
200
200
200
200
200
200
200
200
200

10回リクエストし、HTTPのステータスコード200が10回返ってきているので正常に処理されたことがわかります。

Injecting HTTP Aborts

ここからが本題です。exampleというアプリケーションがHTTPのステータスコード500を返すようにします。virtual-service.yamlの内容を以下のように変更します。

もとのvirtual-service.yamlの最後にHTTPのステータスコード500を必ず返す設定を追加しました。

$ diff virtual-service.yaml 500-virtual-service.yaml -u
--- virtual-service.yaml    2019-07-30 17:21:55.000000000 +0900
+++ 500-virtual-service.yaml    2019-07-30 18:14:22.000000000 +0900
@@ -17,3 +17,7 @@
         port:
           number: 80
         subset: v1
+    fault:
+      abort:
+        httpStatus: 500     
+        percent: 100

500-virtual-service.yamlを適用し、上のcURLを再度実行します。

$ for i in {1..10}; do sleep 1; curl -s -H 'Host: example-svc.example-ns.svc.cluster.local' $GATEWAY -o /dev/null -w '%{http_code}\n' ; done
500
500
500
500
500
500
500
500
500
500

意図通り、すべてHTTPのステータスコード500が返ってきています。もちろんpercentを変更すれば、任意の割合でHTTPのステータスコード500を返すことができます。

Injecting HTTP Delays

次はレスポンスが返ってくるのに時間がかかるパターンです。500-virtual-service.yamlの内容のhttpStatus:fixedDelay:に変更しています。ここでは10sにしているので、レスポンスが10秒遅延します。

システム全体が遅くならないために、呼び出し側のマイクロサービスがタイムアウトなどを適切に行えているかをテストする際に有用です。

最後は少し応用的な例です。ヘッダーにx-e2e-case: errorが付いていれば、HTTPのステータスコード500が返ってきます。付いていなければ、exampleというアプリケーション本来のレスポンスが返ってきます。

1つ目のcURLはヘッダーにx-e2e-case: errorを付けています。2つ目のcURLはヘッダーにx-e2e-case: errorを付けていません。 それぞれのcURLを実行すると、期待通りのステータスコードが返ってきます。

$ curl -s -H 'x-e2e-case: error' -H 'Host: example-svc.example-ns.svc.cluster.local' $GATEWAY -o /dev/null -w '%{http_code}\n'
500
$ curl -s -H 'Host: example-svc.example-ns.svc.cluster.local' $GATEWAY -o /dev/null -w '%{http_code}\n'
200

また、VirtualServiceのmatchは強力で、ヘッダー以外にもURI、ポート番号、クエリパラメータなどでマッチさせることができます。*2

E2Eテストを実行する環境用のVirtualServiceを作り、そこに特定の条件でエラーや遅延が発生する設定を書いておけば、異常系のケースを正しくハンドリングできていることを確認するテストも容易に行えると思います。

まとめ

マイクロサービス化によって様々な恩恵を受けることができる一方で複雑度が上がるとことがあります。複雑度が上がったものの1つがネットワークです。 マイクロサービスではネットワーク越しの通信が当たり前になるので、通信が失敗することを前提として実装するべきです。そして、その実装をテストしやすくするのがFault Injectionです。 Istioの場合だと、VirtualServiceでFault Injectionの設定ができます。

参考

Clojure 1.10.x時代の新しいデバッグツール

こんにちは!こんにちは!SPEEDA開発チーム(通称PDT)に所属しているあやぴーid:ayato0211です。

弊チームでは新しいモノを開発するときに、よくClojureという言語が採用されています。だいたい言語別でシェア2位といったところでしょうか。1位はみんな大好き(?)Kotlinで、こちらはサーバーサイドでの利用が主になっていて、次にE2Eのテストコードを書くときに良く利用されています。

今日はそんな弊チームでよく使われているClojureについて、Clojure 1.10.x時代の新しいデバッグツールをお伝えできれば良いなと思い、この記事を書こうと思った次第です。

大きなデータ構造を簡単に把握したい

Clojureは素晴らしい言語です。Clojureはとても素晴らしい言語です。大事なことなので、2回言いました。それはさておき、Clojureでアプリケーションを書いていると、どうしても大きなデータ構造が出てくる瞬間があります。例えば、Ringのリクエストマップや、Ductのコンフィグマップなどが該当します。それ以外にもマップを引数に取る関数を書くことは往々にしてあるかと思います。

これらの大きなデータ構造というのは、実際に中身を確認するのが非常に面倒です。まず思いつくのは、伝統的なプリントデバッグだと思います。このとき、よくあるのはそのマップがどんなキーを持っているのか分からないので、①とりあえずマップ全体を出力してみて、②注目したい目的のキーをなんとか探し出し、③目的のキーと対応する値だけを出力するように修正して④再度確認を行うという①~④の繰り返しをすることです。

この伝統的な方法はデータ構造が大きければ大きいほど目当ての値を見つけるのが難しいです。僕はよく出力した後に迷子になっています。できればもっとインタラクティブに出力したデータ構造を把握しやすい方法が欲しいところです。そういうツールを今回は紹介したいと思います。

Cognitect's REBLを使う

インタラクティブにデータ構造を把握するためのツールとして、オススメしたいのがCognitectが開発しているREBLというツールです。

REBL、つまりRead Eval Browse Loopということで、その名前の通りただ標準出力に吐く代わりに値をブラウジング(走査)できるというわけです。REBLを使うために必要なのはClojureのバージョンが1.10.0以上であることです。Clojure 1.10.0で新しく追加されたtap>を使うと、REBL上に任意のデータを送ることができ、簡単にブラウジングすることができます(より厳密に言えばDatafyなどの話も出てくるんですが一旦脇に置いておく)。

このREBLをどうやって既存プロジェクトで利用できるようにするのか、Ductの簡単なアプリに組み込む例を見せながら解説したいと思います。

REBLのダウンロードとインストール

REBLは以下のページからダウンロードすることができます。

ライセンス条項をよく読んでほしいのですが、個人の趣味として利用する場合にはあまり問題ありませんが、仕事で利用する場合はDatomicのアクティブな有償ユーザーもしくは、REBLのパトロンでなければなりません。このことに注意してダウンロードしてください。

zipファイルをダウンロードしたら適当な場所に展開しておきます。今回は/home/{{username}}/opt/REBL-0.9.172/REBL-0.9.172.jarとなるようにしました。これでREBLのインストールは完了です。

プロジェクトの準備

次にREBLを使う対象のプロジェクトを準備します。今回はDuctのAPIを簡単に作成して、ついでにexampleハンドラーも生やしてしまいます。以下のコマンドでプロジェクトを作成します。

$ lein new duct demo --template-version 0.12.1 -- +api +ataraxy +example
$ cd demo
$ lein duct setup

普段、意識して--template-versionを指定することはありませんが、この例と全く同じ状況を作りたければ、このように指定することで同じ雛形のバージョンを利用できます。

REBLを利用できるようにする

まずは/home/{{username}}/.lein/profiles.cljに以下の情報を追記します。REBLは開発時に使うもので、開発環境(Javaのバージョン)によって必要となる依存関係が微妙に異なるので、開発者のマシン毎に以下の設定はあると良いでしょう(REBLのREADMEに書いてあるUsageを参照)。僕の手元の環境が今はJava 11になっているのでJava FX関連の依存関係が入っています。また先程インストールしたREBLのJarのパスを:resource-pathsに記述します。

{
 ;; 以下の部分を書き加える
 :tools/rebl
 {:dependencies [[org.clojure/core.async "0.4.500"]
                 [org.openjfx/javafx-fxml "11.0.1"]
                 [org.openjfx/javafx-controls "11.0.1"]
                 [org.openjfx/javafx-swing "11.0.1"]
                 [org.openjfx/javafx-base "11.0.1"]
                 [org.openjfx/javafx-web "11.0.1"]]
  :resource-paths ["/home/{{username}}/opt/REBL-0.9.172/REBL-0.9.172.jar"]}}

次に先程用意したプロジェクトのプロジェクトルート以下にあるprofiles.cljを以下のように書き換えます。profiles.clj.gitignoreされるので、個人用の設定を書く場所として適しています。

;; Local profile overrides
{:profiles/dev
 [:tools/rebl]}

最後にdev/src/local.cljを以下のように記述します。このように書くと、Ductのプロジェクトで最初に(dev)とするとこのファイルが自動的にロードされ、REBLの画面を立ち上げる関数(start-rebl)が使えるようになります。開発しているプロジェクトでチーム全員の合意がとれる場合は、dev/src/dev.cljなどに直接REBLを起動させるための関数を書いても良いかもしれません。

;; Local REPL configuration
(when-not (try
            (require 'cognitect.rebl)
            (catch Exception _))
  (defn start-rebl []
    ((resolve 'cognitect.rebl/ui))
    (add-tap clojure.pprint/pprint)
    (println "REBL started")))

ここまででREBLを利用する下準備が整いました。

REBLにデータを送信する

まずは普段どおりにREPLからシステムを起動をして、REBLもあわせて起動させておきます。

user> (dev)
;;=> :loaded
dev> (go)
:duct.server.http.jetty/starting-server {:port 3000}
;;=> :initiated
dev> (start-rebl)
REBL started
;;=> nil

このまま例えばターミナルなどからcurl localhost:3000/exampleとしても、REBLの方には何も出力されません。なので、src/demo/handler/example.clj:demo.handler/exampleコンポーネントを次のように少し書き換えます。

(defmethod ig/init-key :demo.handler/example [_ options]
  (fn [{[_] :ataraxy/result :as req}]
    (tap> req) ;; <- 追記
    [::response/ok {:example "data"}]))

リクエストマップをtap>に渡すようにしました。こうして、REPLなどからシステムを(reset)などを実行して再起動します。そして、先程と同様にcurl localhost:3000/exampleとターミナルから実行してみます。するとREBLの「tap」タブに次のようにリクエストマップが出力されていると思います。

f:id:ayato0211:20190702182751p:plain

この「tap」タブの画面下部にある「Browse」ボタンを押すと「browse」タブに表示が切り替わります。「nav->」入力欄に例えば0 :headersと入力してEnterを押してみましょう。すると次のような画面になっているはずです。nav->のところはget-inと同じ要領で書けると思えば問題ないと思います。

f:id:ayato0211:20190702182849p:plain

今までであれば出力したデータを見ることしかできませんでしたが、REBLを使えばこのように一度出力した値をREBL上で走査することができます。これにより今までより大きなデータ構造などを把握しやすくなり、デバッグなどもやりやすくなるはずです。

REBLのより具体的な使い方は実際に触るか、次の動画を見てもらえると良いかなと思います。

まとめ

今までプリントデバッグでprintlnpprintなどと書いていたところを、tap>と書くだけでREBLにデータの情報を送ることができ、REBL上でインタラクティブにデータを見ることができるようになりました。これにより、Clojureで開発されたアプリケーションをよりデバッグしやすくなったりするのではないでしょうか。

余談

今回は説明していませんが、Clojure 1.10.0で追加されたdatafyなどをうまく活用すると、REBL上で走査できる対象を広げることができます。実際、ネームスペース(clojure.lang.Namespace)などはDatafiableを実装しているため、REBL上でブラウジングすることができるようになっています。REBL左上のパネルに(the-ns 'dev)など入力して評価してみると分かると思います。このあたりの話もいずれ書けたら良いなーと思ったり思わなかったり。

Kubernetes + Istioでblue-green deploymentを実現する

こんにちは、SPEEDAのSREチームの阿南です。最近Kubernetes界隈が盛り上がっていますね。ここ一年で、各企業での利用事例やKubernetesを解説している書籍等もかなり増え、活用の仕方も徐々に確立されて来ているのではないでしょうか。一方Istioについては、色々できそうということはわかったんだけど、利用事例も少ないためどう使えばいいかわからない、本番運用しているという企業もまだあまり聞かないし導入に踏み切れない、という方も多いかと思います。弊社ではまだ2つのnamespaceのみですが、Istioで運用を開始しております。今回は、Istioで実現できることはなんとなく知っているが、具体的な設定方法がよくわからない方向けにblue-green deployの設定を参考に説明してみたいと思います。

Istioの通信について知りたい方はこちら

環境

GKE 1.9.7-gke.11 ,Istio1.0.2 を利用しています。

IstioのRouting Rule

IstioのRouting Ruleには大きく4つのリソースがあります。

resource 説明
Gateway HTTP/TCPのリクエストを受付. どのHost or Portのアクセスを許可するかのルールを記載。 Ingress / Egress のルールを適用できる
VirtualService ルールにマッチしたリクエストをKubernetesのサービスにルーティングする. weightやheaderの情報を利用した振り分けを定義でき、Istioの設定の要とも言える
DestinationRule trafficの振り分けルールを設定. (例: round robin, least connection 等)
ServiceEntry 外部サービスを登録. 例えば、Kubernetes(Istio)の外側にあるDBの接続先等

この4つが適用された通信のイメージを手書きして見ました。まず、クラスター外部から内部への通信です。

f:id:tanan55:20181118130152p:plain

Gatewayを通ったリクエストがVirtualServiceのルールに基づいて各サービスにルーティングされます。その際に、DestinationRuleに基づき最終的にリクエストが送られるpodが決まります。(DestinationRuleでsubsetが設定されていない場合は、RoundRobinで均等に分散される)

続いて、クラスター内部から外部への通信です。

f:id:tanan55:20181118132321p:plain

コンテナから外向きに通信が発生した後、ServiceEntryのルールに基づきRequestを許可するかどうか判断します。ルール次第で、Egressgatewayを通る場合とそうでない場合の2種類があります。個人的には、まだEgressgatewayを使ってないので、外部のエンドポイントを登録する際はServiceEntryに毎回登録しています。

blue-green deploymentの設定

まずは、Kubernetes のPodをサービスとして稼働させるためのリソースを作成します。

  • namespace

gist.github.com

Namespaceを作成する際に istio-injection: enabled を設定しておきます。この設定で自動的にistio-proxyがサイドカーコンテナとして起動するようになります。

  • deployment

gist.github.com

Deploymentでは、 version: blueversion: green の2つnginxを稼働させます。Istioの設定を行う際に、このlabelを元にBlue-Greenができるように設定します。

  • service

gist.github.com

serviceでは、port: 40001で待ち受け、app: nginxtargetPort: 80番へフォワードします。

続いて、Istioのリソースです。Istioのリソースについては、istioをインストールした際にCRDに登録されていますので、kubectl コマンドを利用して設定の反映が可能です。

  • gateway

gist.github.com

gatewayには許容するHostを指定します。正規表現も可能です。 今回は sample.hoge.com を登録します。

  • virtualservice

gist.github.com

gatewayのルールにマッチした通信が、virtualserviceのルールに基づいてkubernetesのServiceへルーティングされます。ポイントとして、headerに x-version: blue とあれば、subset: blue のdestinationruleが適用されるようにしています。このほかの通信については全てgreenにアクセスされます。この設定を利用してblue-greenの切り替え前にblue環境のテストに利用が可能です。

  • destinationrule

gist.github.com

subsetがblueであれば、version: blue のラベルを付与し、subsetがgreenであれば、 version: green のラベルを付与します。これによって、どのPodにアクセスが振り分けられるかが決まります。以上で、設定が完了です。設定を確認するには下記のコマンドを実行します。

$ kubectl get gateway
$ kubectl get virtualservice
$ kubectl get destinationrule

今回構築した環境のイメージは下記の通りです。

f:id:tanan55:20181118230108p:plain

では、実際にアクセスしてみます。今回は、nginxコンテナに出力されたログを見ることにより、どちらのPodにアクセスされたかを確認します。 下記のコマンドでコンテナのログをtailしておきます。

$ kubectl logs -f -n sample-ns $(kubectl get pod -n sample-ns | grep blue | cut -d' ' -f1) -c nginx
$ kubectl logs -f -n sample-ns $(kubectl get pod -n sample-ns | grep green | cut -d' ' -f1) -c nginx

まずは、headerにx-versionを記載せずアクセスします。

$ kubectl get svc istio-ingressgateway -n istio-system
// EXTERNAL-IP を確認
$ curl -XGET -H 'Host: sample.hoge.com' http://<EXTERNAL-IP>/

f:id:tanan55:20181119223402p:plain

最初にアクセスした際は、全てのリクエストがblueのPodにしかリクエストが送られません。

ここで、headerにx-version: green を付けてアクセスします。

$ curl -XGET -H 'Host: sample.hoge.com' -H 'x-version: green' http://<EXTERNAL-IP>/

f:id:tanan55:20181119223549p:plain

特別なheaderをつけることで、特定のリクエストのみを新規にリリースしたgreenに送ることができます。greenが正常なことを確認できたら、blueとgreenを切り替えます。切り替えの方法は、先ほど設定したvirtualserviceのsubsetのblue-greenを逆転させるだけです。

gist.github.com

上記のyamlを適用後、アクセスすると、blueとgreenが入れ替わり、Blue-Green Deploymentが出来ます。

Istioを使い始めてこれはいい!と思ったポイントとしては、本番環境と全く同じ環境でテストができるということです。検証環境と本番環境で差分があってリリースがうまく行かないというのはよくあることだと思いますが、Istioを使うことで、テストができている状態のPodにアクセスを切り替えることが簡単に実現できます。さらにこれを応用して、特定の割合を振り分けたり、社内ユーザのみに新しい環境にアクセスしたりといったことをPodに変更を加えることなく実現できるようになります。 今回は紹介していませんが、gateway や istio-proxy で telemetryを収集でき、grafana / jaeger 等でリクエストのトレースやレスポンス速度を簡単に可視化できるのでこの辺りをフルスタックで揃えているIstioには最初感動しました。ただし、ハマりどころの多さ(本当に多い)や安定性といった面ではまだまだ成熟していない(バグ結構ある)ので、まずは影響の少ないnamespaceから、かつ、メイン機能となるルーティング周りからIstioを使い始めて、運用ノウハウを貯めていくのがいいかなと個人的には思っています。

仲間募集!!

ユーザベースのSPEEDA SREチームは、 「No Challenge, No SRE, No SPEEDA」 を掲げて業務に取り組んでいます。

「挑戦しなければ、SREではないし、SREがなければ、SPEEDAもない」という意識の元、ユーザベースのミッションである 「経済情報で、世界をかえる」 の実現に向けて、日々邁進しています。

IstioやKubernetes以外にも様々なことにチャレンジしてますので少しでも興味を持ってくださった方はこちらまで!

Istioを使いこなすために知っておくこと

こんにちは、SPEEDAのSREチームの阿南です。最近Kubernetes界隈が盛り上がっていますね。ここ一年で、各企業での利用事例やKubernetesを解説している書籍等もかなり増え、活用の仕方も徐々に確立されて来ているのではないでしょうか。一方Istioについては、色々できそうということはわかったんだけど、利用事例も少ないためどう使えばいいかわからない、本番運用しているという企業もまだあまり聞かないし導入に踏み切れない、という方も多いかと思います。弊社ではまだ2つのnamespaceのみですが、Istioで運用を開始しております。今回は、Istioで実現できることはなんとなく知っているが、内部の通信の仕組みやどのようなコンポーネントがあるのかを追っていきたいと思います。その上でBlue-Green Deploymentを実現するための設定方法についても次回記事で触れていきます。

この記事で扱うこと

  • Istioを構成するコンポーネントについて
  • Istioの通信の流れ

Istioを構成するコンポーネント

Istioのコンポーネントについては、IstioのDocumentationに下記の図が紹介されています。

f:id:tanan55:20181125110511p:plain

図の上側はdata planeで、サービスのPodにサイドカーコンテナ(Envoy) が起動し、Podへのリクエストを中継しています。 図の下側はcontrol planeで、Pilot, Mixer, Citadel が稼働します。Pilotは各サイドカーコンテナにConfigを反映し、Mixerはtelemetry収集やPolicy check(アクセスコントロールや流量の制御等)を担います。Citadelは証明書の管理を担当します。

では、実際にIstioをデプロイしてみて確認してみます。

f:id:tanan55:20181008191459p:plain

デプロイの方法については省略しますが、コマンド一つで大量のPodが起動してきますので、一見面食らいますが、順に整理していきます。(grafana, prometheus, servicegraph, tracingについては実際のサービス通信とは関係が薄いため、省略します)

pod 役割
istio-citadel 証明書の発行、管理を実施
istio-egressgateway Istioの内部から外部へ通信するためのgateway
istio-galley ユーザが定義したIstio設定のvalidationを実施
istio-ingressgateway Istioの外部から内部へ通信するためのgateway
istio-pilot ユーザが定義したIstio設定を反映
istio-policy Mixerの一部. istio-proxyに来たrequestをチェック
istio-telemetry Mixerの一部. 各podからテレメトリを収集
istio-sidecar-injector Podが起動する際に、リクエストをhookしてサイドカーを auto Injectする

上記のコンポーネントでIstioの外からどのようにサービスのPodまでリクエストが到達しているのかを図で表現してみました。

f:id:tanan55:20181125114804p:plain

上図の番号順に処理内容を解説します。

  • リクエストが来る前にEnvoyをサイドカーコンテナとしてInjectしておく。(⓪)
  • ingress-gatewayへリクエストが到達。ここで、hostやpathベースで、どのサービスPodにリクエストをforwardするか決める。(①)
  • istio-proxyへリクエストが到達。実際には、サービスPodへのリクエストをPreroutingしている。例えば、サービスコンテナが80番で稼働していた場合、80番に来たリクエストを15001(Envoyポート)に流す。(②)
  • istio-proxyからistio-policyにアクセスし、Policyのチェックを実施。例えば、流量の制御(最大同時アクセス数100等)で条件にひっかかった場合は、サービスのコンテナに到達する前にその時点でリクエストが返される。(③)
  • Policyでチェックを通ったリクエストがサービスコンテナに到達。(④)

この辺りは、実際に通信の流れを理解するまでに時間がかかりました(今も勉強中です)。ご自身で確認したい方は、実際にistio-proxyのコンテナでshellを起動して、tcpdump, netstat等を取ってみると理解が進むと思います。istio-proxyにはデフォルトでこの辺りのコマンドがインストールされているので、スムーズに調査ができます。
今回、実際に通信をみていった結果、想像以上に色々なことをしてるなという印象でした。特に、PolicyチェックのためにMixerにアクセスしている点は結構重要で、istio-policyのPodがダウンするとサービスの通信もダウンしてしまいます。本番運用する際は、この辺りのConfigurationも注意して見ていきましょう。ちなみに、Istioは必要なコンポーネントに絞ってインストールが可能なので、最初はistio-policyなしで運用するのも十分ありだと思います。

今後Istioがどの程度、使われ広まっていくかはまだわかりませんが、Service Meshに必要な概念や機能を勉強するには良いソフトウェアだと思いますので、皆さんもぜひ一度触れてみてください。次回の記事では、通信の流れやコンポーネントを理解した上で、Istioを使ったBlue-Green Deploymentの方法をみていきます。

Kubernetes + Istioでblue-green deploymentを実現する

仲間募集!!

ユーザベースのSPEEDA SREチームは、 「No Challenge, No SRE, No SPEEDA」 を掲げて業務に取り組んでいます。

「挑戦しなければ、SREではないし、SREがなければ、SPEEDAもない」という意識の元、ユーザベースのミッションである 「経済情報で、世界をかえる」 の実現に向けて、日々邁進しています。

少しでも興味を持ってくださった方はこちらまで!