consul connect, L7 traffic management, nomad consul connect を試す(2)

前回の続きから。

ここから行なっていく手順も前回の consul connect を構築した状態から再開です。

今回は、 consul 1.6 から追加された L7 traffic management と、 nomad 0.10 の consul connect integration を試すものになる。

consul connect は envoy の他に組み込みプロキシなども使用できるから、この先以降は現時点では envoy 限定の機能となってくる。 実現している機能を考えれば、これは確かに consul 内で実装するのは相当な手間だろうとは思う。

Consul L7 traffic management

概ねこの辺のガイドなどを参考にしている。

www.consul.io

www.consul.io

最初にいろいろ眺めていても全然理解できなかったのだけど、読んだり試したりしていてなんとなくどういうことをするものかがわかってきた。

L7 traffic management は config entry という機能を使って、サイドカーへ適用するする設定を与えることで実現していく。

config entry は Kind と Name 属性と Kind ごとに個別の情報を持つ、JSONまたはHCLファイルで、 Kindが設定の種類、 Nameが設定を適用するサービス名となる(全体の設定を示す、Globalもある)

service-defaults

まずは、Kind=service-defaults で、サービスごと proxy の設定を L4(TCP) から L7(http) に切り替える config を 各サービスごとに設定する。

# service_default.hcl
Kind = "service-defaults"
Name = "service-a"
Protocol = "http"
# service_default2.hcl
Kind = "service-defaults"
Name = "service-b"
Protocol = "http"

このファイルを、 config consul write コマンドで投入する。

consul config write l7/service_default.hcl
consul config write l7/service_default2.hcl

service-router

次が、L7の振り分けを設定する Kind, "service-router" の設定となる。

service-router を設定すると、 今まで単一のサービスにしか接続できなかった upstream のコネクションが、 URLやHTTPヘッダなどの内容に応じて、複数のサービスにルーティングできるようになる。

これの恩恵が受けやすいのが gRPCだろう。

gRPCは、コネクションを サーバーアドレス+ポートで生成して、PRC処理ごとにコネクションを使い回す。 その際に service-router を使うことで、サイドカーへの単一のコネクションだけで複数のサービスに接続可能にできる。 これが、L4レベルプロキシだとサービスごとに異なるポートで異なるコネクションが必要となってしまう。

今回のサンプルはこれを模したルーティングを行なってみる。

(この方法が正しいのかはわからないが) service-a の sidecar の upstream の接続先を自分自身に向けて再設定する。

{
    "ID": "service-a",
    "Name": "service-a",
    "Address": "127.0.0.1",
    "Port": 3000,
    "Meta": {
      "version": "v1"
    },
    "Check": {
      "DeregisterCriticalServiceAfter": "90m",
      "HTTP": "http://127.0.0.1:3000/health",
      "Interval": "10s"
    },
    "Connect": {
        "SidecarService":{
            "Proxy":{
                "upstreams": [
                  {
                    "destination_name": "service-a",
                    "local_bind_port": 9000
                  }
                ]
            }
        }
    }
  }
curl -X PUT http://localhost:8500/v1/agent/service/register -d @service_a_in_l7.json

次に、以下の内容で service-router を作成し config を適用する。

# service_a_router.hcl
kind = "service-router"
name = "service-a"
routes = [
  {
    match {
      http {
        path_prefix = "/hello_b"
      }
    }
    destination {
      service = "service-b"
    }
  },
  # NOTE: a default catch-all will send unmatched traffic to "service-a"
]
consul config write l7/service_a_router.hcl

この内容は、service-a のサイドカーに来た "/hello_b" へのリクエストは、service-b に向け、それ以外はデフォルト(service-a)に向けるという意味になる。

この状態で、service-a にアクセスしてみる。

curl localhost:3000/hello_a
{"message_b":"service_b(1) up at Tue, 17 Dec 2019 04:32:58 GMT","message_a":"service_a up at Tue, 17 Dec 2019 04:32:57 GMT"}

無事に、service-a に向かう upstream 出会っても、特定のパスなら service-bに向けれたことが確認できた。 gRPCでも同様に動くだろう。

この時、service-a の envoy は次のように設定されている。 抜粋で示す。

    {
     "version_info": "00000009",
     "listener": {
      "name": "service-a:127.0.0.1:9000",
      "address": {
       "socket_address": {
        "address": "127.0.0.1",
        "port_value": 9000
       }
      },
      "filter_chains": [
       {
        "filters": [
         {
          "name": "envoy.http_connection_manager",
          "config": {
           "tracing": {
            "random_sampling": {},
            "operation_name": "EGRESS"
           },
           "http_filters": [
            {
             "name": "envoy.router"
            }
           ],
           "rds": {
            "route_config_name": "service-a",
            "config_source": {
             "ads": {}
            }
           },
           "stat_prefix": "upstream_service-a_http"
          }
         }
        ]
       }
      ]
     },
     "last_updated": "2019-12-17T05:11:49.438Z"
    },

  {
   "@type": "type.googleapis.com/envoy.admin.v2alpha.RoutesConfigDump",
   "dynamic_route_configs": [
    {
     "version_info": "00000009",
     "route_config": {
      "name": "service-a",
      "virtual_hosts": [
       {
        "name": "service-a",
        "domains": [
         "*"
        ],
        "routes": [
         {
          "match": {
           "prefix": "/hello_b"
          },
          "route": {
           "cluster": "service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul"
          }
         },
         {
          "match": {
           "prefix": "/"
          },
          "route": {
           "cluster": "service-a.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul"
          }
         }
        ]
       }
      ],
      "validate_clusters": true
     },
     "last_updated": "2019-12-17T05:11:49.440Z"
    }
   ]
  }

まずは、リスナーについて HTTPモードでの接続を示す、 http-connection-managerが使われるようになった。 これは proxyの設定を http に変更したためだ(ただし、 service-routerの設定を行うまでは tcpのままだったので、必要となるまではなるべくTCPのままでやろうとするのだろう)

このリスナーには rds(envoyの動的なルーティング設定) が設定されていて、その設定先である route_config には、service-router で設定した通りのルートが設定されているのが見て取れる。

つまりは、 consul config の適用により、Kindに合わせて envoy の設定を xDSで変更していくのが、L7 traffic management の裏側だ。

service-splitter, service-resolver

service-router がリスナー側(入口側)の設定なら、 service-splitter, service-resolver はクラスター側(出口側, upstream側) の振り分け設定だ。 service-b の2つのサービスへの振り分けは均等だったが、これらを使うことで配分を変えることができる。

実は service-b2つのサービスにはそれぞれ、 versionという meta情報を付与していて、それぞれ v1, v2 という内容を設定してある。 (前回の記事の service-bの設定ファイルを見ると書いてある)

meta情報など様々な条件を使って同一のサービスであっても振り分け先ごとにグルーピングできる、これを subset と呼ぶようになっていて、 service-resolver は subset の設定を行う kind だ。

#service_b_resolver.hcl
kind           = "service-resolver"
name           = "service-b"
default_subset = "v1"
subsets = {
  "v1" = {
    filter = "Service.Meta.version == v1"
  }
  "v2" = {
    filter = "Service.Meta.version == v2"
  }
}

これは サービスの meta情報の値によって、v1, v2 という subset を設定している。

https://www.consul.io/docs/agent/config-entries/service-resolver.html を見ると、別サービスへの振り分けなどもできるようだし、色々な使い道がありそう。

service-splitter はサブセットごとに振り分ける量を設定するものになる。

#service_b_splitter.hcl

kind = "service-splitter"
name = "service-b"
splits = [
  {
    weight         = 90
    service_subset = "v1"
  },
  {
    weight         = 10
    service_subset = "v2"
  },
]

これは、90:10 で v1 と v2 の振り分け量を設定していることになる。

これらの config を設定してみる。

consul config write l7/service_b_resolver.hcl
consul config write l7/service_b_splitter.hcl

テストしてみる。

$ curl localhost:3000/hello_a
{"message_b":"service_b(1) up at Tue, 17 Dec 2019 04:32:58 GMT","message_a":"service_a up at Tue, 17 Dec 2019 04:32:57 GMT"}
$ curl localhost:3000/hello_a
{"message_b":"service_b(1) up at Tue, 17 Dec 2019 04:32:58 GMT","message_a":"service_a up at Tue, 17 Dec 2019 04:32:57 GMT"}
$ curl localhost:3000/hello_a
{"message_b":"service_b(1) up at Tue, 17 Dec 2019 04:32:58 GMT","message_a":"service_a up at Tue, 17 Dec 2019 04:32:57 GMT"}
$ curl localhost:3000/hello_a
{"message_b":"service_b(1) up at Tue, 17 Dec 2019 04:32:58 GMT","message_a":"service_a up at Tue, 17 Dec 2019 04:32:57 GMT"}
$ curl localhost:3000/hello_a
{"message_b":"service_b(1) up at Tue, 17 Dec 2019 04:32:58 GMT","message_a":"service_a up at Tue, 17 Dec 2019 04:32:57 GMT"}
$ curl localhost:3000/hello_a
{"message_b":"service_b(1) up at Tue, 17 Dec 2019 04:32:58 GMT","message_a":"service_a up at Tue, 17 Dec 2019 04:32:57 GMT"}
$ curl localhost:3000/hello_a
{"message_b":"service_b(1) up at Tue, 17 Dec 2019 04:32:58 GMT","message_a":"service_a up at Tue, 17 Dec 2019 04:32:57 GMT"}
$ curl localhost:3000/hello_a
{"message_b":"service_b(1) up at Tue, 17 Dec 2019 04:32:58 GMT","message_a":"service_a up at Tue, 17 Dec 2019 04:32:57 GMT"}
$ curl localhost:3000/hello_a
{"message_b":"service_b(2) up at Tue, 17 Dec 2019 04:32:58 GMT","message_a":"service_a up at Tue, 17 Dec 2019 04:32:57 GMT"}
$ curl localhost:3000/hello_a
{"message_b":"service_b(1) up at Tue, 17 Dec 2019 04:32:58 GMT","message_a":"service_a up at Tue, 17 Dec 2019 04:32:57 GMT"}

明らかに、service-b_2 にはつながりづらくなった。

さて、同じく envoy の設定を覗いてみよう。

     "route_config": {
      "name": "service-b",
      "virtual_hosts": [
       {
        "name": "service-b",
        "domains": [
         "*"
        ],
        "routes": [
         {
          "match": {
           "prefix": "/"
          },
          "route": {
           "weighted_clusters": {
            "clusters": [
             {
              "name": "v1.service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul",
              "weight": 8500
             },
             {
              "name": "v2.service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul",
              "weight": 1500
             }
            ],
            "total_weight": 10000
           }
          }
         }
        ]
       }

service-b のルート設定に、 weighted_clusters という設定が増えていて、service-splitter で設定した重みが設定されていることがわかる。

振り分け先のクラスタも、v1, v2 で分かれている。 subset は、envoy上では単純なクラスタ定義となっていることがわかる。

splitter によりconsul上で1つのサービスも、envoy上では分かれている。これはとても参考になる実装だと思う。

というわけで、L7 traffic management を試した。 envoy の xDS コントロールプレーンを実装した自分にとって、consul の xDS 実装は非常に参考になる部分が多い。

簡易的な isito として使える。

nomad

続いて、 nomadnomad の consul connect 連携を試す。

これを使うと、docker container を1つずつあげて、手動で consul に登録する必要はないし、 サイドカーの立ち上げも不要になる。
さらに、 host ネットワークではなく、 nomad が管理しているコンテナネットワーク内でコンテナ間通信を行うようにもできる。

今までの手順で起動した Docker コンテナは全て落とし、 consul からもサービスを全て削除する。

consul services deregister --id=service-a
consul services deregister --id=service-a-sidecar-proxy
consul services deregister --id=service-b
consul services deregister --id=service-b_2
consul services deregister --id=service-b-sidecar-proxy
consul services deregister --id=service-b_2-sidecar-proxy

docker stop service_a service_b service_b_2 sidecar_a sidecar_b sidecar_b_2

これから行う手順は概ね、次のガイドに従って行ったものになる。

www.nomadproject.io

さらりと、 consul へのパスが通っていることや、 nomad は root 権限で起動することなどハマりポイントが書いてあるので注意が必要だ。

CNI (Container Network Interface, https://github.com/containernetworking/cni ) plugin のインストールが必要となり、 またカーネル設定の一部変更も必要となる。 nomad は CNI経由で iptable を操作して、サイドカーと docker コンテナを localhost で通信可能にしている。

( CNI についてはいまいち理解不足だが、 k8s のネットワーキングでも使っているコンテナ間のネットワーク設定のための仕様であり、 network namespace をいい感じに作って、コンテナにアタッチするみたいなものだろうか)

# CNI plugin
curl -L -o cni-plugins.tgz https://github.com/containernetworking/plugins/releases/download/v0.8.1/cni-plugins-linux-amd64-v0.8.1.tgz
sudo mkdir -p /opt/cni/bin
sudo tar -C /opt/cni/bin -xzf cni-plugins.tgz

以下の設定ファイルを、"/etc/sysctl.d/99-sysctl.conf" などに作って、 sudo sysctl -p

net.bridge.bridge-nf-call-arptables = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.bridge.bridge-nf-call-iptables = 1

この状態で、 nomadルート権限で起動する。

nomad では docker コンテナを、 ジョブ定義ファイルを作って投入する。k8sでいう所の、deploymentSet だ。

今までに作ってきた、service-a, service-b 2つのジョブ定義ファイルを作ってみる。

#service_a.job
job "service_a_job" {
  datacenters = ["dc1"]
  group "service-a" {
    count = 1
    network {
      mode = "bridge"
      # this setting host network 3000 forward to bridge network 3000.
      port "http" {
        static = 3000
        to     = 3000
      }
    }
    service {
      name = "service-a"
      port = "3000"
      connect {
        sidecar_service {
            proxy {
                upstreams {
                    destination_name = "service-a"
                    local_bind_port = 9901
                }
          }
        }
      }
    }
    task "service-a" {
      driver = "docker"
      env {
          #. Note that dashes (-) are converted to underscores (_) in environment variable
        SIDECAR_URL = "http://${NOMAD_UPSTREAM_ADDR_service_a}"
      }
      config {
        image = "<your_registory>/service_a"
      }
      resources {
            memory = 100
      }
    }
  }
}

nomad 0.10 から、最上位のgroup 節 に network 節が書けるようになった。 以前では、nomad にはコンテナネットワークを管理する機能はなく、事前に自分で docker network や vxlan を構成しておく必要があったが、 今回からは nomad 独自のコンテナネットワークが暗黙的にあるようだ。 このネットワークに外部からブリッジするポートとして静的に3000ポートを指定している。

また、service 節に connect 節も書けるようになった。 consul connet と同様の upstream の設定を行なっている。 前述の L7 traffic manager とも併用可能なので、自分自身に upstream を向けている。
また、service節では他に、 network で設定した 3000版ポートを、service-a に流すように port を設定している。

task 節の env でコンテナの環境変数を与えている。 http://${NOMAD_UPSTREAM_ADDR_service_a} のように、nomad が管理している属性値を実行時に与えることができる。ちなみに ${NOMAD_UPSTREAM_ADDR_service_a} は実際には、 localhost:9901 であり、 nomad が CNI を使って独自のコンテナネットワークを行なっていることがわかった。

というわけで、同様に service-b のジョブ定義ファイルも作成する。

# service-b.job
job "service_b_job" {
  datacenters = ["dc1"]
  group "service-b" {
    count = 2
    network {
      mode = "bridge"
    }
    service {
      name = "service-b"
      port = "3001"
      connect {
        sidecar_service {}
      }
    }

    task "service-b" {
      driver = "docker"
      env {
        APP_ID = "${NOMAD_ALLOC_ID}"
      }
      config {
        image = "<your-registory>/service_b"
      }
      resources {
          memory = 100
      }
    }
  }
}

ちなみに、docker イメージは Docker レジストリにpush しておく必要があり、ローカルの docker image は使用できない。 ただし、一度 tar に出力することでtar からロードも可能となっている。 その辺の方法は、github の全量のソースを参照してほしい。

こららのファイルを nomad に適用すると、 noamd は (今回はサーバーは1つしかないが) nomad エージェントを導入している各サーバーから、 docker コンテナを起動するサーバーを探して割り当てる。

nomad job run nomad/service_a.job
nomad job run nomad/service_b.job

consul を見てみると、 nomad アイコンのついた、service-a, service-bとそのサイドカーがサービスとして登録されていることがわかる。 このように、nomad は consul に 自身が起動したコンテナの情報を登録し、ヘルスチェックなどを移譲している。

f:id:kencharos:20191217200537p:plain

nomad を見てみると、サービスを3つ起動していることがわかる。

f:id:kencharos:20191217200932p:plain

service-a ジョブの内容を見てみると、サイドカーコンテナも一緒に起動し、 3000ポートが service-aに割り当ててあることがわかり、

f:id:kencharos:20191217201123p:plain

service-bには ポートが割り当てられてない(外部からアクセスできない) ことがわかる。

f:id:kencharos:20191217201331p:plain

サイドカーのポートは公開されているように見えるが、サイドカーへのアクセスは mTLSのため、クライアント証明書が必要なので、実質的に外部からアクセスできない。

では、疎通できるか試してみる。

$ curl localhost:3000/hello_a
{"message_b":"service_b(a7885ab8-cef6-5aed-7c25-b7c5978bcd3f) up at Tue, 17 Dec 2019 11:42:00 GMT","message_a":"service_a up at Tue, 17 Dec 2019 11:43:11 GMT"}
$ curl localhost:3000/hello_a
{"message_b":"service_b(89984200-a56b-5696-2ee3-c5fa19512145) up at Tue, 17 Dec 2019 11:42:00 GMT","message_a":"service_a up at Tue, 17 Dec 2019 11:43:11 GMT"}

service_b のIDには、nomad のコンテナごとにユニークに振られる allocation id を設定していて、ちゃんと分散してアクセスしていることがわかる。

(なお、consul からの続きでこの手順を試してたらうまく動かなかなったので、一度 consulのtmpファイルを破棄してから再起動し、 サイド L7 traffic manager の config を再設定した。 そのため、nomad の手順を実行する際に、サービスだけでなく、L7 traffic manager の設定も全て消したほうがよかったと思われる。)

というわけで、 nomad と consul connect を使うことで、サイドカーの設定を設定だけで追加でき、独自ネットワークでの動作やポート管理からも解放された。

簡易的な k8s, istio として使えそうだ。

ちなみに、 docker ps した結果は次のようになった。

CONTAINER ID        IMAGE                                      COMMAND                  CREATED             STATUS              PORTS                    NAMES
a915b780f123        service_a                                  "docker-entrypoint.s…"   8 minutes ago       Up 8 minutes                                 service-a-c4cd69c1-50ac-981f-2748-2b53a176d106
cf072b8f461c        envoyproxy/envoy:v1.11.2                   "/docker-entrypoint.…"   8 minutes ago       Up 8 minutes                                 connect-proxy-service-a-c4cd69c1-50ac-981f-2748-2b53a176d106
9d51d11c526f        gcr.io/google_containers/pause-amd64:3.0   "/pause"                 8 minutes ago       Up 8 minutes                                 nomad_init_c4cd69c1-50ac-981f-2748-2b53a176d106
662baf7eab4a        service_b                                  "docker-entrypoint.s…"   10 minutes ago      Up 10 minutes                                service-b-89984200-a56b-5696-2ee3-c5fa19512145
265d8defce94        service_b                                  "docker-entrypoint.s…"   10 minutes ago      Up 10 minutes                                service-b-a7885ab8-cef6-5aed-7c25-b7c5978bcd3f
53481e6f6282        envoyproxy/envoy:v1.11.2                   "/docker-entrypoint.…"   10 minutes ago      Up 10 minutes                                connect-proxy-service-b-a7885ab8-cef6-5aed-7c25-b7c5978bcd3f
befc5e30e23d        envoyproxy/envoy:v1.11.2                   "/docker-entrypoint.…"   10 minutes ago      Up 10 minutes                                connect-proxy-service-b-89984200-a56b-5696-2ee3-c5fa19512145
f798256ae7e7        gcr.io/google_containers/pause-amd64:3.0   "/pause"                 10 minutes ago      Up 10 minutes                                nomad_init_89984200-a56b-5696-2ee3-c5fa19512145
565e75715f36        gcr.io/google_containers/pause-amd64:3.0   "/pause"                 10 minutes ago      Up 10 minutes                                nomad_init_a7885ab8-cef6-5aed-7c25-b7c5978bcd3f

自分のイメージと サイドカーenvoy 以外に、謎の pause-amd64 とかいうコンテナも立っていたり、CNI経由のためか コンテナがどのネットワークにも属してないなど、 nomad のシンプルさが若干失われたような気がした。

まとめ

L7 traffic management はいい感じだった。 mesh gateway を使うことで、マルチクラウドやオンプレ、k8sなんかともいい感じに連携できそうだ。 適用したconfigの内容が UI とかで参照できるともっといいんだけどね。

nomad の consul connect は可能性は感じるが今の所まだ荒削りだなと思った。 CNIによるネットワーキングは便利だが、果たしてそれは nomad に求めていたことなのかと思ったり、 ルート権限いるの? とか、envoy をもう少し自由にいじりたいとか、まだ洗練される余地があるような気がしている。

とはいえ、オンプレでも使えるサービスメッシュ、コンテナクラスタとしては魅力的なので、今後もアップデートを見守っていきたいと思う。

consul connect, L7 traffic manager, nomad consul connect を試す(1)

consul には connect という consul 管理下のサービス間のmTLSによる接続を管理する connect という機能がある。 サービス間接続には 組み込み Proxy や envoy を使い、いわゆるサービスメッシュのようなことができる。

www.hashicorp.com

consul connect は L4レベルのプロキシであり、また nomad には対応してなかった。 そのため、connect を使うことを断念し envoy を自前でどうにかするようなことをしていたのだけど、 ちょっと前に consul, nomad それぞれのバージョンアップで consul 1.6 には L7レベルのルールを設定可能な L7 traffic manager が追加され、 nomad 0.10 には CNI (container network interface) を使用した consul connect 統合が追加された。
(consul 1.6 には もう1つ 別ネットワークのconsulへのプロキシとして動く mesh gateway も追加されたが今回は割愛)

勉強がてらこれらの機能を使ってみる。試したソースはこちらにある。

https://github.com/kencharos/consul-connect-nomad

consul, nomad それぞれ 簡易化のために 1ノードでサーバーとエージェント両方を構成する設定が含まれているので、 それを起動してから各種手順を実行していく。
nomad は途中で Linuxでしか動かない機能を使用していくので、 Vagrantfile も用意してある。

consul connect

まずは基本の consul connect から。概ね 以下のチュートリアルに沿った内容となっている。

learn.hashicorp.com

service-a と service-b という2つのWebアプリケーションを用意し、service-a から service-bのエンドポイントを呼び出すというような形をとる。

どちらも単純な node.js, express のアプリケーションで作る。 service-a には環境変数として service-b のURLが渡るような作りになっている。

# service-a
const port = process.env.PORT || 3000;
const sidecarUrl = process.env.SIDECAR_URL || "http://localhost:3001";
const up = new Date().toUTCString()

app.get('/hello_a', (req, res) => {
    console.log(sidecarUrl)
    fetch(sidecarUrl + "/hello_b")
        .then(r => r.json())
        .then(data => res.send(Object.assign(data, {message_a:"service_a up at " + up})))
        .catch(e => {console.log(e); res.sendStatus(500); })
});
#service-b
const port = process.env.PORT || 3001;
const id = process.env.APP_ID || "1";
const up = new Date().toUTCString()

app.get('/hello_b', (req, res) => {
    res.send({"message_b":`service_b(${id}) up at ${up}`})
});

これらをDockerイメージにしておき、hostネットワーク上で起動する。 service-a は1つ、service-bは connect経由で負荷分散させたいので2つ起動する。 service-aに与える接続先URLは後から起動するサイドカープロキシのURLになっている。

docker run --rm --name service_a -e "SIDECAR_URL=http://localhost:9000" --network host -d service_a
docker run --rm --name service_b -d --network host  service_b
docker run --rm --name service_b_2 -e "PORT=3002" -e "APP_ID=2" -d --network host  service_b

これらのプロセスを consul に登録することで、consul上でサービスとして登録でき、ヘルスチェックの設定や、conusl を複数サーバーで動かす場合は各サーバーのconsul で動いているサービスの情報が収集され、全サーバーで稼働しているサービスの状態が把握できるようになる。

例えば service-a は次のようなファイルを作って、consul cli で登録する。

{
    "ID": "service-a",
    "Name": "service-a",
    "Address": "127.0.0.1",
    "Port": 3000,
    "Meta": {
      "version": "v1"
    },
    "Check": {
      "DeregisterCriticalServiceAfter": "90m",
      "HTTP": "http://127.0.0.1:3000/health",
      "Interval": "10s"
    },
    "Connect": {
        "SidecarService":{
            "Proxy":{
                "upstreams": [{
                    "destination_name": "service-b",
                    "local_bind_port": 9000
                }]
            }
        }
    }
  }

"service-a" のホストやポート、ヘルスチェックの他に、 connect という節がある(connect を使わない場合は書かない)。
これは "service-a" のサイドカーproxyには 9000ポートで "service-b" 行きのリスナーを作ってという指示になる。

2コンテナで上げた "service-b" についても同様にコンテナごとにファイルを作る。

{
    "ID": "service-b",
    "Name": "service-b",
    "Address": "127.0.0.1",
    "Port": 3001,
    "Meta": {
      "version": "v1"
    },
    "Check": {
      "DeregisterCriticalServiceAfter": "90m",
      "HTTP": "http://127.0.0.1:3001/health",
      "Interval": "10s"
    },
    "connect": {
        "sidecar_service":{
        }
  }
}
{
    "ID": "service-b_2",
    "Name": "service-b",
    "Address": "127.0.0.1",
    "Port": 3002,
    "Meta": {
      "version": "v2"
    },
    "Check": {
      "DeregisterCriticalServiceAfter": "90m",
      "HTTP": "http://127.0.0.1:3002/health",
      "Interval": "10s"
    },
    "connect": {
        "sidecar_service":{
        }
  }
}

"service-b" は IDは異なるが、Nameは同じサービスという風にしてある。また、connect 節でサイドカーproxyは設定しているが、 外部に出て行く通信がないため、特に他サービスへの設定はない。

これを consul cliREST API で登録する。

curl -X PUT http://localhost:8500/v1/agent/service/register -d @service_a.json
curl -X PUT http://localhost:8500/v1/agent/service/register -d @service_b.json
curl -X PUT http://localhost:8500/v1/agent/service/register -d @service_b2.json

とりあえず、3つのサービスを登録した直後の consul の状態は次のようになる。

f:id:kencharos:20191216235732p:plain
service-a,b の登録後

サービス登録時にヘルスチェックの設定もしたのでサービス1つにつき2つのチェックが設定されるので、 "service-a" のヘルスチェックは2, "service-b" のヘルスチェックはサービスが2つあるので4 となっている。

各サービスに sidecar-service というサービスがあることがわかる。 これがconsul connect で管理されるサイドカーproxyなのだが、自分でやる場合は別途、サイドカーproxyを起動して consul に登録しないといけない(面倒)。

というわけで、今回は envoy を使ってサイドカーを登録する。

envoy を所定の定義ファイルを与えて起動さえできれば、 consul connect コマンドで登録可能ではあるが、 色々と煩雑なのでconsulコマンドと envoy をdocker イメージにまとめる手段が上記のチュートリアルに紹介されている。

次のようなDockerfile を作って、 consul-envoy という名前の docker イメージを作っておく。

# https://learn.hashicorp.com/consul/developer-mesh/connect-envoy
# docker build -t consul-envoy .
# use with --init option
FROM consul:latest
FROM envoyproxy/envoy:v1.11.2
COPY --from=0 /bin/consul /bin/consul
ENTRYPOINT ["consul", "connect", "envoy"]

このイメージをサービス数分起動して行く。起動する際に consul connect envoy コマンド https://www.consul.io/docs/commands/connect/envoy.html の引数でどのサービスのサイドカーなのか、admin port を使うかなどを指定するのがポイントとなる。

先に起動した3つのサービスそれぞれに、envoy サイドカーを起動して割り当てる。

docker run --init --rm -d --network host --name sidecar_a consul-envoy -sidecar-for service-a -admin-bind 0.0.0.0:19000 
docker run --init --rm -d --network host --name sidecar_b consul-envoy -sidecar-for service-b -admin-bind 0.0.0.0:19001 
docker run --init --rm -d --network host --name sidecar_b_2 consul-envoy -sidecar-for service-b_2 -admin-bind 0.0.0.0:19002

これで、サイドカーも無事起動し、consul のヘルスチェックも全て通るようになる。

f:id:kencharos:20191217001145p:plain

service-a サイドカーの情報を見てみると、どのサービスのサイドカーなのかや、upstream(接続先のサービス)のポートなどが記載されている。

f:id:kencharos:20191217001447p:plain

service-a を起動した時にすでにSIDECAR_URL に localhost:9000 を割り当てていたので、これですでに疎通はできるはずなのでやってみよう。

$ curl localhost:3000/hello_a
{"message_b":"service_b(1) up at Mon, 16 Dec 2019 14:35:49 GMT","message_a":"service_a up at Mon, 16 Dec 2019 14:35:48 GMT"}

$ curl localhost:3000/hello_a
{"message_b":"service_b(2) up at Mon, 16 Dec 2019 14:42:29 GMT","message_a":"service_a up at Mon, 16 Dec 2019 14:35:48 GMT"}

$ curl localhost:3000/hello_a
{"message_b":"service_b(1) up at Mon, 16 Dec 2019 14:35:49 GMT","message_a":"service_a up at Mon, 16 Dec 2019 14:35:48 GMT"}

$ curl localhost:3000/hello_a
{"message_b":"service_b(2) up at Mon, 16 Dec 2019 14:42:29 GMT","message_a":"service_a up at Mon, 16 Dec 2019 14:35:48 GMT"}

"service_b" からの応答メッセージには、2つ起動したservice_bどちらからかの応答かがわかるように, 1か2 のIDが振ってある。 9000番ポート経由のservice_b への通信に対して、 service-bと service-b_2 への通信が均等に割り振られているので、 サイドカーenvoyが適切にリクエストを振り分けていることがわかる。

また、次のように consul intension で サービス間通信の許可・拒否を登録可能だ。 次のようにすると、 service-bへの通信は遮断される。

$ consul intention create -deny service-a service-b
Created: service-a => service-b (deny)

$ curl localhost:3000/hello_a
Internal Server Error

これらの制御はどのようになっているかが気になったので、envoyの管理コンソールを見てみる。 管理コンソールはenvoy コンテナを起動する際に 1900x ポートで起動するように設定してある。

envoy の コンフィグダンプの全量は gist https://gist.github.com/kencharos/459aacbf41e5d7cb6fdc45ac29ca73a8 を見てもらうとして、 大事な部分を抜粋する。

まずは service-a のリスナー1

"listener": {
      "name": "public_listener:127.0.0.1:21000",
      "address": {
       "socket_address": {
        "address": "127.0.0.1",
        "port_value": 21000
       }
      },
      "filter_chains": [
       {
        "tls_context": {
         "common_tls_context": {
           "割愛"
         },
         "require_client_certificate": true
        },
        "filters": [
         {
          "name": "envoy.ext_authz",
          "config": {
           "stat_prefix": "connect_authz",
           "grpc_service": {
            "envoy_grpc": {
             "cluster_name": "local_agent"
            },
            "initial_metadata": [
             {
              "key": "x-consul-token"
             }
            ]
           }
          }
         },
         {
          "name": "envoy.tcp_proxy",
          "config": {
           "stat_prefix": "public_listener_tcp",
           "cluster": "local_app"
          }
         }
        ]
       }
      ]
     }

envoy 自体は 21000ポートで起動し、中身は割愛するが クライアント認証ありのTLS接続で受け付けるようになっている。 connect プロキシ間の通信は全て mTLSとなっていて、証明書は consul がルートCAとなって発行するようになっている。

また、ext_authz フィルタを設定し、接続を受け付けたらlocal_agent に転送するようになっている。 local_agent は 8502番ポートで、 consul のgRPC ポートになっていて、ここで intention などのサービス間接続の許可・拒否を行なっていると思われる。 これは、 SPIFFE や OPA と近いアプローチだと思う。

そのあとは、 tcp_proxy フィルタで、 local_app へ転送している。 local_app は 最初に起動した service-a のdocker イメージとなっているので、 このサイドカープロキシは、21000で受け付け -> consul へ認証移譲 -> 本来のサービスへ転送 という処理をしている。

このプロキシにはもう1つリスナがある。

"listener": {
      "name": "service-b:127.0.0.1:9000",
      "address": {
       "socket_address": {
        "address": "127.0.0.1",
        "port_value": 9000
       }
      },
      "filter_chains": [
       {
        "filters": [
         {
          "name": "envoy.tcp_proxy",
          "config": {
           "stat_prefix": "upstream_service-b_tcp",
           "cluster": "service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul"
          }
         }
        ]
       }
      ]
     },

9000番ポートで立っていることからわかる通り、これは service-a を登録する際に設定した service-bへの接続用のリスナとなっている。 このリスナが向けている "service-b...." という cluster も、envoy の admin 機能から実際のアドレスがわかるようになっていて、 次のような内容が設定されている。

service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21001::cx_active::0
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21001::cx_connect_fail::0
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21001::cx_total::5
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21001::rq_active::0
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21001::rq_error::0
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21001::rq_success::0
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21001::rq_timeout::0
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21001::rq_total::5
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21001::hostname::
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21001::health_flags::healthy
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21001::weight::1
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21001::region::
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21001::zone::
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21001::sub_zone::
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21001::canary::false
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21001::priority::0
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21001::success_rate::-1
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21001::local_origin_success_rate::-1
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21002::cx_active::0
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21002::cx_connect_fail::0
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21002::cx_total::4
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21002::rq_active::0
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21002::rq_error::0
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21002::rq_success::0
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21002::rq_timeout::0
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21002::rq_total::4
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21002::hostname::
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21002::health_flags::healthy
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21002::weight::1
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21002::region::
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21002::zone::
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21002::sub_zone::
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21002::canary::false
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21002::priority::0
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21002::success_rate::-1
service-b.default.dc1.internal.06e91f3c-fffd-1989-629f-d8b3edd7db5c.consul::127.0.0.1:21002::local_origin_success_rate::-1

このクラスタには2つのエンドポイント、"localhost:21001", "localhost:21002" が設定されている。

2100x ポートは、service-b 向けに起動した2つのサイドカーのリスナーのポートとなっていて、 service-a のものと同様に、これらのリスナーもconsul への認証とservice-b への転送を行うようになっている。

また、どちらも weight::1 が設定されているのでこのクラスタへのアクセスは2つのエンドポイント へ均等に割り振られる。

よって全体のフローは、

service-a から 9000ポートにアクセス -> service-aサイドカーが 21001 or 201002 へ振り分け -> service-b-sidecar(21001, 21002) -> consul へ認証移譲 -> service_b へ転送

ということになる。 壮大ではあるが、consul がクラスタ化して複数サーバーにサービスが分散したとしても、 service-a は とりあえず 9000 ポートに接続すれば、どのサーバーに動いている service-b であっても接続できるようになる。

また、サービスの数や位置が変わったとしても、その内容は即座に変更が各 envoy に伝わる。 これは envoy の xDS という仕組みで実現されていて、 istio などの別のサービスメッシュでも同じように使われている。

というわけで、サーバーやサービスの構成が変更されても柔軟に追従できそうな仕組みが connect であることがわかり、 それを裏で支えているのが envoy と xDS プロトコルであることもわかった。

とはいっても次の点が少々面倒

  • connect は L4レベルなので接続先のサービスが増えると管理しないといけないポートが増える
  • サービスの起動と consul への登録さらにサイドカーの起動と登録も必要

この辺を楽にしていく仕組みが、L7 traffic manager と nomad である。

が、長くなったのでその2つについてはまた後で書きます。

余談だが、 consul から起動する envoy には追加で設定をあたえるオプションもある。 分散トレーシングやログフォーマットなども追加できるようになっているので、頑張ればより高度なサイドカーとしても使えるかもしれない。

https://www.consul.io/docs/connect/proxies/envoy.html#advanced-configuration

サイドカーライブラリ Dapr の分散トレーシングを試す

ちょっと前にこんなニュースがありました。

www.publickey1.jp

公式はこの辺かな?

dapr.io

github.com

MicrosoftOSS で、しかも golang で作ったという異色のライブラリです。

また最近は envoy を使ったサービスメッシュについて色々と調べていたこともあり、似たような問題を解決するものであるといこともあり、興味を持ちました。

サンプルやコンセプトページを見ているとなんとなく雰囲気がつかめてきます。

github.com

github.com

  • Isito のように、各サービスにサイドカーとして起動するDapr インスタンスサイドカーインスタンスを管理するDaprサーバーから構成されている。
    • サイドカー経由でサービスにアクセスすることでアドレス解決を任せたり、プロトコル変換ができる。
      • 例えば、HTTPしかないサービスをgRPC経由で呼び出したりとか、Kafka経由で非同期イベント経由で呼び出したりとか、サービス間の連携を後から設定できる(Bindings)
  • RedisやKafkaなどのストレージと連携して、サービス間でステートをやり取りする機能(WebAPIや言語別のSDK)がある
    • そんな大したものではなく、Daprサーバーに向けてオブジェクトを登録・取得する簡単なAPIがあるという感じ
  • セキュリティ、秘密管理、分散トレーシングなどの非機能要件に関する部分をある程度Dapr側でやってくれる

envoy, Istio などと比較して違いがあるのは最初にあげたBindingsでしょう。 とりあえずWebAPIとして作っておいて、DaprがよしなにgRPCとかイベント連携にしてくれるとか、自前で実行方法に合わせた連携処理を書かなくて済むというのは便利です。

と色々と気になる機能はありますが、今回はの主題は分散トレーシングについてです。

サイドカーで分散トレーシング

どうして分散トレーシングが気になるかと言えば、 envoy などのサイドカーパターンで分散トレーシングを行うには Context Propagation というトレース情報の伝播が必要だからです。

www.envoyproxy.io

サービスを跨ぐ複数の通信をトレーシングで一連の処理だと認識するには、 各通信で一位となるID(トレースIDとか呼ばれる)を受け渡していく必要があります。

分散トレーシングライブラリはサーバー側(通信の入り口)でヘッダなどを見てトレースIDがあるかを調べ、あるならそれを使い、無いなら新しくトレースIDを作ります。
同一処理内で外部API通信などを行うクライアント側(通信の出口)でも通信のヘッダにサーバー側で取得したトレースIDを乗せることで、一連のトレースであることを認識させていきます。

これが伝播と呼ばれるもので、実装するのがとても面倒です。

Zipkin Brave, OpenTelemetry, Spring Cloud Sleuth など様々なライブラリやフレームワークがあり、現状はOpenTelemetryにまとまるかもしれないという希望もありつつ、まだまだ群雄割拠な状態です。

envoy のようなサイドカーの分散トレーシングがやってくれるのは、トレースサーバーへの情報送信だったり、ZipkinやJaeger,StackDriverなど複数トレースサーバーへの差異の吸収だったりします。

トレースIDの伝播は、そもそもサービス内から実行するクライアント実行の通信が一連のトレースかどうかを判断する術をサイドカーが持たないので、サービス側の責務になります。

ということは伝播のための仕組みやライブラリをサービス側から完全に消し、透過的な分散トレーシングを実現することは困難というのが現時点での私の結論でした。

となると、Daprのトレーシングは果たしてどうかというのが気になってしまいます。

Dapr で分散トレーシングに言及しているのはこの辺り。

github.com

まだサンプル集には実装例はありませんでしたが、ドキュメントを見ていると、

  • OpenTelemetry使うから、Dapr側で ZipkinとかJargerとかにトレース情報送る
  • X-Correlation-ID というのがトレースID的なもの
  • X-Correlation-ID がヘッダにあればそのまま使うし、無いなら新しく作るとある

最後の文面が出てきた時点であまり期待はできなくなったのですけど、折角ですし試してみました。

Dapr で分散トレーシング

試したコードはこちらです。

github.com

Daprはk8sでもオンプレミスでも動くようです。(オンプレミスで分散で動かすガイドはまだ見当りませんが)

またローカルなどでテスト用途で動かすための standalone モードがありますので、今回はそちらで試します。

github.com

に従い、Daprを導入すると dapr cli が手に入り、Docker上 で daprサーバーと ステート管理のための redis が動くようになります。

55bf9b9c9e52        daprio/dapr         "./placement"            4 days ago          Up 4 days           0.0.0.0:50005->50005/tcp           dapr_placement
ad75cf783790        redis               "docker-entrypoint.s…"   4 days ago          Up 4 days           0.0.0.0:6379->6379/tcp             dapr_redis

あとは、サービスのコードを作成したら、 dapr run コマンドでサービスとサイドカーを一緒に起動するという感じです。

まずは適当にservice2 という名前でアプリを作ってみます。

// app.js
const express = require('express');
const bodyParser = require('body-parser');
const app = express();
app.use(bodyParser.json());

const port = 3001;

app.post('/apply', (req, res) => {
    console.log(req.headers)
    res.send({message:"hello! " + req.body.name})
});

app.listen(port, () => console.log(`Node App service2 listening on port ${port}!`));

JSONを受け取り、リクエストヘッダをプリントして、返すというだけの簡単なものです。

また準備としてzipkinを起動し、Daprに zipkinにトレースを送るように設定します。

zipkin起動

docker run -d -p 9411:9411 openzipkin/zipkin

dapr サイドカーに trace と zipkin の設定を行います。

設定は CRD形式というk8sでの設定ファイルの書式です。

tracing.yml、これはトレースの送信方法など汎用的な設定です

apiVersion: dapr.io/v1alpha1
kind: Configuration
metadata:
  name: tracing
spec:
  tracing:
    enabled: true
    expandParams: true
    includeBody: true

zipkin.yaml , zipkinにトレースを送るための設定。トレース送信先の実装に応じて変更するものです。

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: zipkin
spec:
  type: exporters.zipkin
  metadata:
  - name: enabled
    value: "true"
  - name: exporterAddress
    value: "http://10.200.10.1:9411/api/v2/spans"

これらを サービスごとに components というディレクトリを作って置いておきます。

(components ディレクトリは、 dapr run コマンド実行時に自動で作れらる場合もあり、デフォルトでいくつかのファイルが勝手にできます。サイドカーの設定などを規定する場所となっています)

では、サイドカーと一緒にサービスを dapr run コマンドで起動します。

$ dapr run --app-id service2 --app-port 3001 --port 3501 --config ./components/tracing.yaml  node app.js

ℹ️  Starting Dapr with id service2. HTTP Port: 3501. gRPC Port: 55788
== DAPR == time="2019-11-26T01:41:06+09:00" level=info msg="starting Dapr Runtime -- version 0.2.0 -- commit c75b111"
== DAPR == time="2019-11-26T01:41:06+09:00" level=info msg="log level set to: info"
== DAPR == time="2019-11-26T01:41:06+09:00" level=info msg="standalone mode configured"
== DAPR == time="2019-11-26T01:41:06+09:00" level=info msg="dapr id: service2"
== DAPR == time="2019-11-26T01:41:06+09:00" level=info msg="loaded component statestore (state.redis)"
== DAPR == time="2019-11-26T01:41:06+09:00" level=info msg="loaded component zipkin (exporters.zipkin)"
== DAPR == time="2019-11-26T01:41:06+09:00" level=info msg="loaded component messagebus (pubsub.redis)"
== DAPR == time="2019-11-26T01:41:06+09:00" level=info msg="loaded component tracing ()"
== DAPR == time="2019-11-26T01:41:06+09:00" level=info msg="application protocol: http. waiting on port 3001"
✅  You're up and running! Both Dapr and your app logs will appear here.

== APP == Node App service2 listening on port 3001!
== DAPR == time="2019-11-26T01:41:07+09:00" level=info msg="application discovered on port 3001"
== DAPR == 2019-11-26 01:41:07.169239 I | redis: connecting to localhost:6379
== DAPR == 2019-11-26 01:41:07.216321 I | redis: connected to localhost:6379 (localAddr: [::1]:55814, remAddr: [::1]:6379)
== DAPR == time="2019-11-26T01:41:07+09:00" level=info msg="actor runtime started. actor idle timeout: 1h0m0s. actor scan interval: 30s"
== DAPR == time="2019-11-26T01:41:07+09:00" level=info msg="actors: starting connection attempt to placement service at localhost:50005"
== DAPR == time="2019-11-26T01:41:07+09:00" level=info msg="http server is running on port 3501"
== DAPR == time="2019-11-26T01:41:07+09:00" level=info msg="gRPC server is running on port 55788"
== DAPR == time="2019-11-26T01:41:07+09:00" level=info msg="local service entry announced"
== DAPR == time="2019-11-26T01:41:07+09:00" level=info msg="dapr initialized. Status: Running. Init Elapsed 651.0255980000001ms"
== DAPR == time="2019-11-26T01:41:07+09:00" level=info msg="actors: established connection to placement service at localhost:50005"
== DAPR == time="2019-11-26T01:41:07+09:00" level=info msg="actors: placement order received: lock"
== DAPR == time="2019-11-26T01:41:07+09:00" level=info msg="actors: placement order received: update"
== DAPR == time="2019-11-26T01:41:07+09:00" level=info msg="actors: placement tables updated"
== DAPR == time="2019-11-26T01:41:07+09:00" level=info msg="actors: placement order received: unlock"

3501 ポートで dapr インスタンスが起動し、 3001 ポートで起動したservice2 に接続しているようです。

docker 上の dapr インスタンスと、ホスト上の node アプリケーションが合わさり、ログも混ざって出てる(== APP == の行)のが、なかなか面白いですね。

service2 は、 localhost:3001/apply で待ち受けしていますが、 これをdapr サイドカーは、localhost:3501/v1.0/invoke/service2/method/apply と、 //invoke/<app-id>/method/ というURLで待ち受けるようになります。

最初は面食らいますが、URLを全体でユニークにするためなのかな?

では呼び出してみます。

$ curl -X POST http://localhost:3501/v1.0/invoke/service2/method/apply -d '{"name":"test"}'
{"message":"hello! test"}

ログは次のような感じ。

== APP == {
== APP ==   'user-agent': 'curl/7.54.0',
== APP ==   host: '127.0.0.1:3001',
== APP ==   'content-type': 'application/json',
== APP ==   'content-length': '15',
== APP ==   accept: '*/*',
== APP ==   'x-correlation-id': '86d482e659eea6f4;fcc76f86bce3a9614b9a372a36ab242f;1'
== APP == }

x-correlation-id が出ているので、zipkinを確認してみます。
ちなみに、x-correlation-id の ;より前は spanIDで、後ろがトレースIDです。

トレースが出ています。dapr サイドカーがいい感じにzipkinにトレースを送ってくれたようです。

f:id:kencharos:20191126015418p:plain

2サービス間のトレーシング

では本題となる、2サービス間のトレーシングを試してみます。

前述のService2 を呼び出すService1 を別のサービスとして作成し、こちらも dapr サイドカーと一緒に起動します。

ポイントは Service2 のURLです。ソースにもある通り、Service1(自分)のサイドカーのURL(localhost:3500) + /v1.0/invoke/service2 としていて、 Serivce2のサイドカーに直接向けていません。Service2のアドレス解決は、Daprがやってくれます。

// app.js
const express = require('express');
const bodyParser = require('body-parser');
const fetch = require("node-fetch")
const app = express();
app.use(bodyParser.json());

const port = 3000;
// dapr サイドカー経由のURL
const service2URL = "http://localhost:3500/v1.0/invoke/service2/method/apply"

app.get('/hello', (req, res) => {
    console.log(req.headers)
    fetch(service2URL, {
        method: "POST",
        body: JSON.stringify({name:"service1"}),
        headers: {
            "content-type":"application/json"
            // リクエストヘッダのトレースIDをクライアントのリクエストヘッダに伝播する
           // , "x-correlation-id" : req.headers["x-correlation-id"]
            }
    }).then(r => r.json())
      .then(data => res.send(data))
});

app.listen(port, () => console.log(`Node App service1 listening on port ${port}!`));

ある程度答えは見えていたので恣意的ですが、とりあえずは x-correlation-id ヘッダの連携は今はしないようにコメントアウトしています。

dapr サイドカーを3500ポートで起動します。

dapr run --app-id service1 --app-port 3000 --port 3500 --config ./components/tracing.yaml  node app.js

余談ですが、 dapr list で起動している dapr インスタンスも見れます。

$ dapr list
  APP ID    HTTP PORT  GRPC PORT  APP PORT  COMMAND      AGE  CREATED              PID
  service2  3501       56104      3001      node app.js  12m  2019-11-26 01:51.24  53879
  service1  3500       56938      3000      node app.js  11s  2019-11-26 02:03.23  55314

さて、service1 を呼び出してみます。

$ curl http://localhost:3500/v1.0/invoke/service1/method/hello
{"message":"hello! service1"}

service1 のログ

== APP == {
== APP ==   'user-agent': 'curl/7.54.0',
== APP ==   host: '127.0.0.1:3000',
== APP ==   'content-type': 'application/json',
== APP ==   accept: '*/*',
== APP ==   'x-correlation-id': 'e757cdd5120e5471;a8ef06433d0fbf475e9d4e71e6caddd2;1'
== APP == }

service2 のログ

== APP == {
== APP ==   'user-agent': 'node-fetch/1.0 (+https://github.com/bitinn/node-fetch)',
== APP ==   host: '127.0.0.1:3001',
== APP ==   'content-type': 'application/json',
== APP ==   'content-length': '19',
== APP ==   accept: '*/*',
== APP ==   'accept-encoding': 'gzip,deflate',
== APP ==   'x-correlation-id': 'ad8782c8425119f8;c49be5e2c42af227a984a36677c246d3;1',
== APP ==   connection: 'close'
== APP == }

見事に x-correlation-id が一致してないです。zipkinもおかしい。

f:id:kencharos:20191126020826p:plain

ぱっと見、service1,service2 のスパンのあるトレースがあるので、成功? とも思ったけど中身がおかしい 。

gRPCの内部の通信のようなもの? を拾っているようだ。

f:id:kencharos:20191126021031p:plain

で、service1 の app.js の コメントアウトしてある x-correlation-id の伝播を設定して、service1 を再起動します。 この場合は次のように、同一のトレースIDになりました。

$ curl http://localhost:3500/v1.0/invoke/service1/method/hello
{"message":"hello! service1"}

service1

== APP == {
== APP ==   'user-agent': 'curl/7.54.0',
== APP ==   host: '127.0.0.1:3000',
== APP ==   'content-type': 'application/json',
== APP ==   accept: '*/*',
== APP ==   'x-correlation-id': '327d36a56fb4f0b7;fe6ad7b82c9e24156d73da4f24646a81;1'
== APP == }

service2

== APP == {
== APP ==   'user-agent': 'node-fetch/1.0 (+https://github.com/bitinn/node-fetch)',
== APP ==   host: '127.0.0.1:3001',
== APP ==   'content-type': 'application/json',
== APP ==   'content-length': '19',
== APP ==   'x-correlation-id': '3c614f073b853834;fe6ad7b82c9e24156d73da4f24646a81;1',
== APP ==   accept: '*/*',
== APP ==   'accept-encoding': 'gzip,deflate',
== APP ==   connection: 'close'
== APP == }

zipkin も次の通り。

f:id:kencharos:20191126021934p:plain

前述の分断されたトレースが1つにまとまったように見える。

なんでservice1のspanが4つ(自分のサイドカーに向けた通信だから?) とか、service2 の gRPC 呼び出しは何だ(サイドカー内部の通信はgRPC?)? とか疑問は残りますが、 dapr でも トレース情報の伝播は必須だということがわかりました。

まとめ

Dapr でもサービス内でヘッダ情報の伝播は必須だということがわかりました。

伝播に必須なのは x-correlation-id だけなので、楽といえば楽なんですけど。
実際、伝播や instrumentation のライブラリを作るのもそこまで難しくないでしょうし、OpenTelemetry でも用意されるのかな?

とはいえ、透過的な分散トレーシングは夢ではあるので、もしお前の呼び出し方はおかしいとか、こうすれば行けるとか、こんなAPIがあるよとかご指摘があれば是非ともお願いところです。

分散トレーシング以外でDapr に感じることは、 envoy 直接使うよりも設定は楽だし、dapr 前提でマイクロサービスを設計すると色々楽ができる部分がありそうなので将来性を感じました。

今後も注視していきたいと思います。

JJUG CCC 2019 Spring で登壇してきました

5/18 に行われた JJUG CCC 2019 Spring に「初めてのgRPC」 という内容で発表してきました。

www.java-users.jp

発表資料はこちらです。

speakerdeck.com

全体の資料もこちらでまとまっています。

github.com

今回は、登壇者兼ボランティアスタッフとして申し込みしました。 ボランティアスタッフは2回目だし慣れてるだろうから、部屋の進行と発表同時にやっちゃえということで、 スタッフからの注意事項の説明の後、そのまま発表するというちょっと面白い流れになりました。

会社の人からもいい感じのツッコミをいただきました。

技術イベントの登壇はいつかやってみたいことの一つでした、なので夢が一つ叶いました。

なかなか大変でしたけど、発表したテーマに興味を持って頂けた方が何名かいらっしゃったので、発表してみて良かったです。

発表テーマ gRPC について

gRPC については2,3 年ほど前から採用事例を聞くようになってきました。 また最近だと、国内・海外のマイクロサービスを主導している会社で gRPC + サービスメッシュの採用事例を聞くようになってきていたので gRPC について調べ出しました。

その過程で、4種類ある RPC のプログラムの書き方が結構違ったり、そもそも gRPC Java に関する情報があまりない( Go が多い) ということに気づきました。 ちょうどいいタイミングで CCC の CFP 応募が始まったので、良い機会だということで申し込んでみました。

幸いにも、同じタイミングで 社内でも gRPC を検討するタイミングがあったので、 チーム内勉強会を開くノリで発表資料をまとめられたのは良かったかなと思います。

スライドが70枚と多く、後半は飛ばし気味になりましたが時間内で言いたいことは言えたので良かったです。 わかりづらい点などありましたら、コメントや Twitter で質問いただけると幸いです。

gRPC は個人的に筋の良い技術だと思っていますが、ライブラリやミドルウェアを含めた運用の知見がまだ足りてないです。 さらに今までの Web アプリケーションフレームワークの手法が使えないです。 ( とは言え、Webアプリケーションフレームワークの仕事の大半は、不確実なHTTP ボディの解析とURLのルーティングで、gRPC はそのどちらも割と高いレベルで解消しているのだけど) なので、まだまだ発展の余地はあるし、色々貢献できそうだと思いました。 社内でも草の根的に gRPC やってるチームがあるということもあとで聞きましたので、やっておいて損はないと思います。

またパgRPC についてフォーマンスよりも気に入っているのは、IDL としての Protocol Bufferes の良さです。 DDD の文脈では公開されたインターフェースという、そのサービスが提供する API をサービス自らが提供するパターンがあります。 proto ファイルはまさに公開されたインターフェースのフォーマットとして最適だと思います。

今後チームで gRPC をやってみて、実際どうだったかをお知らせする機会があればやってみようと思います。

いただいた質問について

  • 分散トレースはできるの? Netty 以外も使えるの?

  • Protocol Bufferes の message を ビジネスロジックに持ち込まないなら、DTOを自分で作ってコピーしないといけないのでは?

  • Reactive-gRPC を使うと、RxJava や Spring WebFlux とかと接続できるの?

    • WebFlux との接続は確認しました。そのため gRPC-gateway を使わなくても、Spring WebFlux->Reactive gRPC で REST-gRPC変換はできなくもないです。
    • R2DBC が登場したら、 gRPC とDB をシームレスでリアクティブに扱えるので面白いなと思っています。
    • 色々な Reactive なもの(外部のWebApi とか、別の gRPC の呼び出しとか)を逐次・並列に接続したい場合に、Reactive-gRPC は良い選択肢だと思います。
  • 何か困ったことは?

    • 時刻型と十進数型は ProtocolBufferes には無いので、自作する必要があること。
    • 自作した場合、Javaの LocalDate や BigDecimal とのマッピングコードが必要なので、どうやって実現するかは検討の余地がある。
      • go だとこの場合 インターフェースで後からメソッドが追加できるから楽なんだろうなとか思った。
      • Java だとどうしようもないが、 Kotlinだと拡張メソッドで Protocol Buffersの型とJavaの型それぞれに相互変換のメソッドを追加できるので、Kotlin やるならありかなと思います。
  • Kotlin でできるの?

    • できます。gRPC-Kotlin プラグインもありますが、普通に生成した gRPC Service, Stub のJavaコードもKotlin から使えます。
    • gRPC-Kotlin のコードは コルーチンを使うためか、MDC,分散トーレシングのためにCoroutien Context の上書きが必須だったので、もう少し見守ろうと思いました。(サンプルコードを参照)

所属について

2019年のはじめに転職しました。 今回、試用期間が空けたこともあり、所属について隠すことはやめました。

懇親会 LT でもほぼ同時期に入試された方が、働いてみてどうだったかを語っていました。 私もチームは違いますが、大規模なサービスの開発に携われるというのはプレッシャーもありますが、とても面白いです。 エンジニアリングに集中できる環境があり、チームメンバーみんなが生き生きとしているので働いて楽しいなと感じています。

DevRel チームの方々はフットワークも軽く、知識もあり、今回の発表にあたり色々とアドバイスいただけました。 発表内容については、機密や誹謗中傷がないかをチェックされるだけで、 内容については発表者の意思を尊重いただけたので感謝しかありません。 内容についての拙い点はわかりづらい点は全て私のスキルによるものです。

JJUG CCC について

ボランティアスタッフとして

今回は人員が2倍くらいに増えました。 色々な人が参加していて楽しかったし、一人当たりの負荷も前回に比べて減りました。 また総会には参加できませんでしが、運営の方式も変わりますので CCC はより良いものになっていくと思います。 みんなもやりましょう。

参加者として

自分の発表以外は、割と自由にその辺をうろついていました。 その辺で久しぶりの方々と身の回りの話や、今後の技術動向などについて話をしていることが多かったかな。 セッション聞くのも面白いですけど、ここでしか会えない人たちと話すのも楽しいし、刺激になる。

特にアンカンファレンスは、今回は Java Champion や JDK ソムリエ、他に Java 界隈の強い人ばかりの豪華メンバーでした。

貴重な実体験や知識を聞くことができました。

まとめ

皆さまお疲れ様でした。

こんなに貴重な技術イベントは他にはないと思う。

次も頑張るぞ。

Microservices Patterns を読んで(2)

Chris Richardson 氏の Microservices Patterns を読んだ。前回からの続きです。

Microservices Patterns: With examples in Java

Microservices Patterns: With examples in Java

前回はこちら。

kencharos.hatenablog.com

全13章の後半、8章からです。

8章 外部API

マイクロサービス群を外部公開する場合に、API Gateway を適用することが書かれている。 API Gateway に求めらる機能は、ルーティングのほか、認証、プロトコル変換(REST->gRCPとか)、前述の API Composition など。

認証の例として、外部からの Basic 認証に対して、API Gateway が内部の OAtuh サーバーと接続して認証を肩代わりしたり、 JWT トークンを Cookie に付け替えたり、リフレッシュトークンのリフレッシュなどを行い、外部クライアントの負荷を減らす案などが出ていた。

API Gateway を実現する手段として、 AWSAPI Gateway, Spring Cloud Gateway, Apollo GraphQL (node.js), NetFlix Falcor などがある。 API Gateway のカスタマイズ性を重視するかどうかでどれを選ぶかが変わってくる。 また、アクセスが集中する部品なので性能面での注意も必要となる。例えば Spring Cloud Gateway は、Reactor と WebFlux を使って大量アクセスが可能になっている。

API Gateway に各サービスを連携する際の主管をどうするかが興味深かった。 API Gateway を管理するチームと、各サービスを担当するチームが異なっていると調整コストが発生する。 Netflix では、API Gateway を階層化し、API Gateway チームが基本的な Gateway 機能を提供し、そこに各チームが作成した API Gateway をぶら下げる。 各チームが外部公開に特化した API を作るこのパターンは、 BFF (Backend For Frontend) と呼ばれる。

9章 マイクロサービスのテスト 1

テストに関する章その1 。

筆者の経験から、マイクロサービスの開発を滞りなく行うには 自動化されたテストとビルドパイプラインが必須。 一方で、今まで見てきたプロジェクトでは自動化テストをちゃんとやっている現場が割となかったため、 まずはテストの基本について書くことにしたとのこと。

以下に挙げるテストの4分類に関する記述が主な内容となる。

  • End-to-End テスト : システム全体の受入テスト。全ての要素を本番同様の構成にする。
  • Component テスト : サービス単体の受入テスト。テスト対象以外のサービスはスタブを用いる。
  • Integration テスト : API呼び出しやインフラに依存する部分のテスト。スタブ、CDC を用いる。
  • Unit テスト : クラス単体のテスト。依存性はモックを使う。

上記の4つはピラミッドの構成を取り、上に行くほどテストの構成が複雑で実行時間が長く、脆くなる。 各テストの性質に合わせて使用する技術やテストの量を調整して行く。

Unit テストでは、テストダブル (モックやフィクスチャなどで依存性を置き換えること)の手法が紹介されている。

Integration テストでは、コンシューマー駆動契約テスト, CDC (Consumer Driven Contract) が紹介されている。 CDCはサービスのAPIを呼ぶ側のコンシューマーと、APIを提供する側のプロデューサーとで、 両者のサービスを実際にデプロイしてテストを行う代わりに、 コンシューマーがAPIの入出力に対する期待値を定義し、その期待値を満たしているかをお互いにチェックする。

期待値を DSL として定義すると、コンシューマー側では DSL からモックサーバーを立てるのでモックサーバーに向けてテストを行う。 プロデューサー側では DSL からAPIにアクセスを行うテストを生成するので、プロデューサーが期待した通りの入出力を行うかテストする。 こうすると API 呼び出しのテストにおいて、他のサービスを実際にデプロイすることがないのでテストの効率化が期待できる。

また、CDC はビルドパイプラインに載せることが重要。他のサービスが CDC の DSL を更新したらそれを自動的にテストできるようにしておく。 そうするとことで、早期に API の整合性が合わなくなったことを検知できる。

CDC をサポートするライブラリは、Spring Cloud Contract や Pact がある。

10章 マイクロサービスのテスト 2

主に Integration テストの記述方法の紹介。

Spring Cloud Contract や、 Evantuate Tram のテストライブラリを使うと、 RESTやメッセージングなどの連携部分を CDC でテストできる。 DB の Integration テストについては、 JPA のプロパティの変更の検証で代替するか、 Docker を使うかといった感じ。

Component テストについては、サービスに依存する DB や kafka, 他のサービスについては、インメモリ型のライブラリや Docker, サービスに見立てた HTTPサーバを立てて、テストを行う。 Component テストの記述は受入テストであることを踏まえて、シナリオ形式で書くことが望ましく、 Cucumber や Gherkin といったライブラリが紹介されている。

End-to-End テストも同様に Cucumber を使う。

(ただし、個人的な意見でいえば、 BDD はどうなんだろうというスタンス)

11 章 本番稼働に向けた開発

マイクロサービスにおける非機能要件をまとめた章。

マイクロサービスを問題なく運用して行くためには、セキュリティ、設定、監視が重要なファクターと言える。

セキュリティについては、API Gateway で認証や認可を一元的を行う、監査ログの発行、TLSによる暗号化などが要素となる。

設定については設定ファイルなどをアプリケーションに埋め込むのではなく外部から設定可能なようにすることが重要となる。

環境変数などを使って外部から設定を上書きできるようにする push-based 型や、 コンフィグレーションサーバーを立てて各サービスの設定を一元管理し、各サービスが設定を起動時に取りに行く pull-base 型 などがある。 また、サービスを再起動せずとも設定のリロードが可能なような仕組みもあると望ましい。

監視については、ヘルスチェックAPIの提供、 kibanaなどを使ったログサーバーによるログの一元管理、分散トレーシング、 メトリクスの定期的な収集、監査ログなどがある。

メトリクスは CPU使用率などのマシン状態だけでなく、業務的なメトリクスの記録(一定以上の金額の取引があった回数とか)も行うと良い。 micromater はカスタムメトリクスを記録し、 Prometheus などのメトリクス収集ツールと連携する。

監査ログの収集は収集漏れを防ぐために、AOPドメインイベントと連携すると良い。

上記のように、マイクロサービスに求められる非機能要件は膨大であるため、こういった機能を基盤として提供できるようにしたライブラリを microservice chassis と呼ぶ。 Spring Boot, Spring Boot Actuator, Spring Cloud などが該当する。

ただし、 microservice chassis は言語やバージョンがある程度固定化されてしまう欠点がある。 そこで最近注目されているのが、マイクロサービスの実行基盤側で上記の非機能要件を提供する service mesh である。 service mesh は sidecar として自分たちの作ったサービスの横にプロキシとして存在し、サービスへの in/out 両方の通信を仲介することで、 上記の非機能要件を付与する。 また、 sidecar の一元管理を行うための control plane という機能も一緒に登場する。 ( sidecar は control plane の対比として、 data plane とも呼ばれる)

service mesh を提供するライブラリとして、 istio, envoy, linkerd, consul connect, NGINX Controller などがある。

12章 マイクロサービスのデプロイ

アプリケーションのデプロイに関する章。

アプリケーションのデプロイは、 TomcatWebLogic などアプリケーションサーバーに同居させる形式から、 クラウドの登場により マシンイメージの展開や SaaSなどが生まれ、 最近は Docker によるコンテナ仮想化、k8sによるコンテナクラスタAWS Lambda などのサーバーレスといった様々な変遷があった。

コンテナを使う方がより早く柔軟である。 Docker や k8s を使ったコンテナイメージの作成や、k8s での負荷分散、ローリングデプロイなどについて一通り触れられている。 ただし、これらの内容はそれだけで一冊の本になる内容なので、入門的な内容を軽く触れる感じとなる。

最後に少しだけ、サービスの一部を AWS Lambda に切り出してデプロイする例が載っている。

13章 マイクロサービスのリファクタリング

既存のモノリシックなシステムをどうやってマイクロサービスに変えて行くかという章。

Big Bang Rewrite (既存システムを無視して0から作り直すこと) を絶対に避ける。基本的にうまくいかないし、コストがかかりすぎる。 また、デプロイメントパイプラインも自動化テストも無いのに作り直すのは生存できないというような強い言葉も書かれている。

Strangler Application パターンという徐々にモノリスを分解してマイクロサービスに変えていき、最終的にモノリスを無くすような戦略の方がうまくいく。 初めは設定ファイルを外出しするような小さな修正だけでも十分。

クラウドベンダーや SaaS ベンダーがサービスをクラウドに載せ替えてコスト削減のようなことを言ってきても、検討しないで飛び乗るような真似をしてはいけない。徐々に移行しながら適切な移行先の基盤を検討する方が良い。

ではどうやってリファクタリングをしていくかだが、3つの方法がある。

  1. 新規機能をマイクロサービスにする
  2. フロントエンドとバックエンドの分離
  3. モノリスの一部をマイクロサービスとして切り出す

いずれの方法であってもまずは前段に API Gateway を導入し、モノリスと新規開発部分を振り分け可能にしておくことが必須となる。

場合によってはモノリスとマイクロサービス間でデータの取得や同期が必要となる。 その際にモノリス側の修正が最低限で済むような工夫が必要となる。

例えば、サービスを分割する際は分割後不要となる項目であっても読み取り専用で残し、 マイクロサービス側で反映を行なったりする。 その反映もイベント連携としたり、モノリスの修正が一切できないような状況なら、DBのトランザクションログからデータ反映するようなツールを作る。

認証についても、既存の認証機能を API Gateway で上手く吸収し、マイクロサービス側へ連携させるようにするなどと言った工夫がある。

また、マイクロサービス側がモノリス側のデータ構造に引っ張られすぎないように、 腐敗防止層(ACL: Anti corruption Layer) といった防御のためのサービスを作ることも検討する。

まとめ

9章のテストちゃんとやってる現場が少ないという発言は胸に来ますね。 13章にも名言がたくさんあり、著者の経験を物語っているなと感じました。

英語で400ページを超える分量の本ですが、 DDDやマイクロサービスについては以前から調べていたのでどうにか読むことができました。

マイクロサービスに関する知見を幅広く集めて整理した本書は、サーバーサイドに関わる人であれば読んでみて損はないかなと思います。 サンプルコードの多くは Java や Spring なので、その辺の知見がないと少々難しいかもしれないですが。

マイクロサービスという言葉が出てきたのは2014年頃だったと思います。 その頃はマイクロサービスは今後はやっていくのかは怪しいなという思いでした。

とはいえ、増大して行くモバイルデバイスとインターネットに対し、Web を戦場とするサービス提供者にとっては、 いかに早くサービスを改善・拡大できるかが重要だったと思います。 そこで先人が色々な試行錯誤を重ねた結果として、サービスを分散させても上手く行くアイデアがようやくまとまってきた段階に来たんだなと感じます。 また、それを支えるのはクラウドサービスの普及と発展なのは間違いのないことです。

今なら、マイクロサービスに挑戦しても無謀ではないと本書を読んで思いました。

Microservices Patterns を読んで(1)

Chris Richardson 氏の Microservices Patterns を読んだ。

Microservices Patterns: With examples in Java

Microservices Patterns: With examples in Java

マイクロサービスという言葉が出て来て数年経ちます。

私もマイクロサービス的な複数のサービス間でデータのやり取りを頻繁にするようなシステムを構築したことがあります。 その際にデータの整合性は最重要ではなかったのでトランザクション的なものは使いませんでしが、 お金を扱うようなシステムをマイクロサービスにした場合ちゃんとしたトランザクションはどうするのかは気になっていました。

本書にはその疑問に対する現実的な回答が載っています。

著者は CloudFoundry の創設者で、また以下のマイクロサービスのパターンを集めたサイトの管理者でもあります。 microservices.io

また本書のサンプルコードに度々登場するマイクロサービスのための evantuate-tram フレームワークとその開発を行なっている evantuate の代表でもあります。

eventuate.io

というわけで著者の知見を盛り込んだ本書は、マイクロサービスを導入するために必要な、設計・実装・インフラといったあらゆる知識を包括的に学べる一冊だと思いました。

なお、本書で掲載されているソースコードは以下で公開されているようです。

Eventuate example applications

本書は全部で 13 章から成っています。それぞれの章の内容を簡単にまとめてきます。 3週間くらいかけて読んだので一部曖昧だったり、私見もあるかもしれないけどそこはご容赦を。

1 章 モノリシックヘルを脱出する

Uber Eats のような、注文・レストラン・配達 を提供するシステムを題材にしている。 複数の機能を単一の Spring Boot アプリケーションでデプロイしている。 Jenkins による CI もあるし、ヘキサゴナルアーキテクチャもあるしで、決してレガシーではないが モノリシックゆえに理解しづらく、機能追加が難しく、新しいライブラリは言語が投入できず、スケールもしないという欠点を抱えているため、 これをマイクロサービスに移行したいというところから始まる。

マイクロサービスは上記の欠点を克服し、開発スピードの向上が期待できるとしつつも、一方で分散構成ゆえの困難も抱えることになる。

残りの章はその困難に対する対策となる。

2章 マイクロサービスアーキテクチャの定義

マイクロサービスの設計に関する章。

マイクロサービスの一つ一つはヘキサゴナルアーキテクチャで作り、サービス間のデータは 定義した API 経由でしかやり取りしない。 また、サービスごとに DB を持つ。

マイクロサービス全体の設計は次の3つを行うことになる。

  1. サービスの分割
  2. 各サービスの機能の定義
  3. サービスのAPIの定義とサービス間の関連を定義

サービスの分割の手法として、 business capability と DDD の2つが挙げられている。

前者は業務の価値を生む出す機能を抽出し、それをグルーピングしていくような感じだろうか。 ユースケース分析とか要求分析に近い感じだろうか。

後者は DDD の流儀に従って粗めのドメインモデルを作り、境界づけられたコンテキストを作ることでサービスを分割する。 またこの章だけではなく、随所に DDD の手法を適用していくことになる。

どちらの手法でも大事なのはビジネスの観点に従って分割をすることで、技術的な理由による分割を行なってはいけないということ。 また疎結合な分割を行うこと。適切でないサービス分割は、マイクロサービスアーキテクチャではなく、ただの分割したモノリス (Distributed Monolith) になってしまう。

3章 プロセス間通信

マイクロサービス間の通信手段に関する章。

同期通信の手段として、 REST, gRPC の2つがある。 REST は汎用的に使える一方でマイクロサービスの文脈では通信効率の悪さや、API と HTTP へのマッピングの負荷が大きいという点が述べられている。 gRPC は通信効率の良さやスキーマがあること、多言語対応などがマイクロサービスに適しているが、HTTP/2 が必須となる。

またどちらであっても、同期通信は通信相手が必ずいないといけないので、可用性を下げる要因となる。 そのために、Circuit Braker や Service Discovery を導入するとよい。

非同期的手段として、kafka などの非同期メッセージングがある。 マイクロサービスの可用性を向上にするには、サービス間のデータの送受信を非同期メッセージにするのが望ましい。 (これは主に更新系操作の話で、同期通信ももちろん使っていいはず)

例えば、サービス間で非同期でデータを送りその結果を応答して欲しいなら、送信用のチャネルと返信用のチャネルを用意して 送信用のチャネルにデータを流す際に id を格納して、その id を付与したデータを返信用チャネルに流してもらう。

この際にデータの整合性を保つために Transactional Message というものが必要となる。 簡単に言うと DB の更新とそれに対応するメッセージの送信が必ずアトミックになると言うものだ。

evantuate tram はこういったメッセージ送受信を簡単に行うためのフレームワークで、 Transactional Message は 直接メッセージを送る代わりに、 DB の OUTBOX (送信箱)テーブルに送信メッセージ内容を書き込む。 そして、非同期で OUTBOX テーブルの内容をポーリングするか、DBのトランザクションログ を tailing して OUTBOX の内容をkafka に送る。

単純だけど、これはいいアイデアだ。 アプリケーションコード内にメッセージ送信を行うコードを書くと、DBトランザクション失敗時のメッセージ送信の扱いは面倒になりがちだ。

他にメッセージングの送信は最低一回保証なので同一メッセージが再度届くことも考慮した設計にする必要があることも述べられている。

なお、トランザクションログを監視して kafka に投げるライブラリは他に debezium も紹介されている。 ぜひ試してみたい。

4章 Saga によるトランザクション

本書の一番のポイントは多分ここ。実際マイクロサービスをやるにあたって最も気になるのはトランザクションだろう。 2フェーズコミットはスケーラビリティを大きく損なうため、Saga というパターンを使う。

Saga は非同期メッセージングをベースにしたサービス間の協調の仕組みだ。 トランザクションの ACID の4つの特性のうち、 ACD だけを保証する。 Isolation についてはいくつかのテクニックである程度は保てるが、基本的には結果整合性になる。

Saga は 以下のような手続きで実現する。

  • Transactional Message を使って、まずは自分のデータを処理中(pending) にして他のサービスのメッセージを投げる。
  • 他のサービスはメッセージを受け取って自身のデータを更新し、応答メッセージを投げる。
  • 応答メッセージを受け取ったらその内容に従って、ステータスを完了や失敗に更新する。

また失敗時に更新したデータを取り消す(または打ち消す)ために、補償トランザクションを用意しておく。

Saga は f 型と Orchestration 型の2つがある。

前者は送信したメッセージの扱いと返信を投げた先のサービスに任せる感じで単純な反面、トランザクションの内容が複雑だと全体の把握が難しくなる。
後者は主となるサービスが全てのメッセージの送受信の流れを決める感じで、複雑なトランザクションの制御に向く。 基本的に Orchestration で行うのがよく、evantuate tram は Orchestration を DSL として定義可能な API を持つ。

両者のソースコードは前述のサンプルコードにあるので両方を見て比較すると良いと思う。 Choreography 型は投げたメッセージについてどういった処理を行い、どういう返信メッセージをするのかを確認するためには、そのサービスのソースを見るしかないので、実際わかりづらい。

Isolation を保つにはトランザクション中のデータのステータス管理が肝になる。ステータス管理を適切に行うためにステートマシンを定義すると良い。

他に、Isolation を確保するためのテクニックとして https://dl.acm.org/citation.cfm?id=284472.284478 という 複数データベース環境におけるACIDに関する論文からいくつかが紹介されている。

5章 マイクロサービスのビジネスロジック

DDD の手法を適用してビジネスロジックを構成するクラスを作る。 主な登場人物は、 Service, Repository, Entity, ValueObject の他、マイクロサービス固有の SagaOrchestrator (SagaのDSL), MessageConsumer, MessageHandler などがある。

適切なサービス分割を行うために、 Aggregation(集約)を定義するのが重要。 他の集約への参照は、オブジェクトではなく ID を保持することが望ましい。

こうすることで、集約一つが適切なトランザクションの単位となる。 また、NoSQL を使う場合でも集約を決めることは重要となる。 それは、集約一つを NoSQL のドキュメントとすることで、トランザクションが提供されない NoSQL でもデータの整合性が保てるためだ。

また集約の処理結果を、DomainEvent というサービスで起きた変化を伝えるデータにして、さらにその DomainEvent をメッセージ送信することで、 他のサービスへの通知を行うことができる。

DomainEvent は様々な用途で使用できる。 Choreography 型 Saga の起因となるのも DomainEvent となるし、他に後述の EventSourcing や CQRS, AuditLog などでも使う。

また適切な DomainEvent の定義の仕方として、 Event storming について少しだけ触れらている。

6章 Event Sourcing

集約の更新と同時にイベントを投げるというのは、2つのデータを管理することになるし、もしイベントを投げることを失念してしまったら 見つかりにくいバグの原因になりかねない。 それならいっそのこと、イベントの蓄積だけを行おうというのが、 Event Sourcing の基本的な考え方。

銀行の入出金の例で言えば、従来の方法は入出金があるたびに現在の残高を更新していくが、 Event Sourcing では入出金を一件ずつ記録しておき 残高の算出が必要になった時点で全ての入出金のイベントを取得して残高の算出を行う。

Event Sourcing を行えば管理対象は イベントデータだけだから前述の懸念点は無くなる。 一方で従来のやり方とは大きく異なるやり方なので、学習コストがかかることに注意が必要。

他に、サマリ処理を軽減するスナップショットや重複メッセージの扱い、仕様変更後のマイグレーションといった実装上の懸念点について述べられている。

7章 クエリ

サービスを跨ったデータの取得方法に関する章。

一つ目は API Composition で、一つのサービスが他のサービスの API をコールしてデータをまとめるというもの。 単純だし、最も一般的に使われる。 一方で複雑な場合だと、 インメモリ join や多数の通信によって計算量や時間が増える。 後者は並列化などによって緩和可能だが、前者の解決には CQRS パターンによるデータ複製及びクエリに適したストレージを使うことが挙げられる。

CQRS (Command Query Responsibility Segregation) は クエリ専用のデータモデルやデータストレージ (ElasticSearch など) を用いる方法。 また Command 側はこれまでに出てきた DDD の手法で手厚く作り、 ドメインイベント経由で Query 側にデータを提供する。 Query 側はイベント経由でクエリ用のデータを作り公開する。

DB に限らず適した製品を選べたり、複数サービスに跨るデータを集約できることがポイントになる。 DB以外の製品として出てきたのは、地理情報の検索が可能な Elastic Search など。

また、 Event Sourcing をCommand 側で使った場合はイベントを集約した内容を Query として生成することで、 集約にかかる負荷を抑えたり、Queryの要求に応じて柔軟にQueryモデルの再生成が可能といったメリットがある。

CQRS を適用する上での欠点は、複雑性が増すこと。

一旦まとめ

長くなったので主に設計・実装に関わる前半が終わったこの辺で一回区切ります。

個人的に気になっていた、 Saga について実行可能なサンプルコードもある状態で解説されているのがとても良かったです。 OUTBOX を使用したトランザクションメッセージは個人的には目から鱗のアイデアだと思いつつも、 Saga パターンと補償トランザクションは真面目に書くと結構辛いなというのが正直な感想ではあります。

とはいえ、これより優れたアイデアというものも中々出てこないですし、 Saga を簡単に書き下す言語やライブラリの登場が今後あるかもしれないです。 個人的には、Saga を書くのに Scala + Akka は結構良いのではと思っていたりします。

あとはドメインイベントは必須だとして、 Event Sourcing をどうするか。 Event Sourcing を使うと CQRS も多分必須になるし。 なんとなく、 Event Sourcing がハマりそうな分野(会計とか)はあるものの、これを適用するのは勇気がいるなと思います。

とはいえ本書は全てのパターンを使うことを求めてはいないし、各パターンの利点・欠点もちゃんと述べている。 そのため、自分たちが作るものに合わせて最適なパターンを決めていけばいいし、 特定のサービスだけ Event Sourcing + CQRS にするというのももちろんありでしょう。

続きを書きました。 kencharos.hatenablog.com

2度目の転職をします

ブログの方は久しぶりです。 Qiita にはそこそこ書いてましたが、QIita に書いてもあまり個人に評判とかが反映されないなという思いが強まったので、 今後はブログに色々書いていこうかなと思います。 更新も一年ぶり、、頻度あげます、、はい、、

ちょっと前の記事をさかのぼれば 5年前の転職の記事が出てきてしまうのであれですが、 2度目の転職をすることになりました。

どこに行くのはここでは書かないですが、しばらくしたら Twitter で何かしら所属については発言する気がします。 ここでは、転職活動の振りかえりを書きます。

何をしていたか

ITコンサル的な会社にいました。 技術提案、PMO、一次受け開発などをするところで、主に開発やってました。 ここ一年は クラウドネイティブなシステム作るみたいなお仕事をしていて、 IaaS をいかに使わずに 開発と運用を回すかみたいなことに取り組んでました。 ほぼすべての技術構成を自分で決めて検証して、チームメンバで一緒に作る毎日は中々に楽しかったです。

他には社内で勉強会やら研修資料やらを作ったり。 Git や Java8 関数型プログラミング の研修資料を作り、それを新人研修のカリキュラムに組み込んだのは、 開発環境をモダン化していく上でよい仕事をしたなと自分でも思います。

どうして転職しようと思ったか

色々と考えましたが、まとめると転職で望むことや条件は次の3つになりました。

  1. サービス開発をやりたい
  2. 収入を増やす
  3. グローバルな環境(英語)

サービス開発をやりたい

今の職でも前職の SIer でも開発はやりますが、自社の事業としてのサービスを作っているわけではなく、基本的には受託です。 そうするとサービス開発の一部にしか携われないなと。 幸いにもアーキテクチャを作るような重要部分を任せていただくことは多かったです。 一方で企画段階だったり、あるいはサービスロンチ後にログや利用動向などをみてサービスの改善を図っていったりだとか、 改善をしていくことを前提とした設計だとかに携わることはあまりありませんでした。

サービス開発は作って終わりではなく、作った後も継続して改善が必要だと思っています。 どちらもやってこそ見えてくる境地があるだろうし、単純に自社サービスをやって得られるデータを扱う事には純粋な興味がある。 あとは大規模サービスはどうやって大量のトラフィックやログをさばいているの?とかも気になるしやってみたい。 事例公開はあるにはあるけど、本当のところは外からじゃわからない。

そういった思いがあり、事業会社でサービス開発に携わりたいと思うようになりました。

収入を増やす

幸いにも業界の平均よりも多めの給料はもらっていたと思います。 実際、転職活動中、今より収入を増やしてくれる会社はほとんどありませんでした。

とはいえ、収入が十分かどうかは家族構成や環境なんかで大きく変わりますし、 何かあった時の備えや貯蓄・投資もしたいし、ちょっと贅沢もしたいものです。 定年まで右肩上がりで給料が上がって、年金もばっちりもらえるなんてのは幻想だと思っているし、 今のうちに稼げるだけ稼いでおきたい。

そんなわけでもう少し収入を増やせないか、あるいは副業などの自由な働き方ができる会社がないかを考えるようになりました。

グローバルな環境(英語)

開発者としての地位と幅を上げていくためには、積極的に海外の情報を仕入れたり海外の人との交流が必要だなとひしひしと感じるようになりました。 仕事でも仕事外でも英語ができないと単純に辛かったり、歯がゆい思いをすることが増えました。 例えば、目の前に Java チャンピオンがいるし聞きたいことがあるのに、上手く話せなかったみたいなことがありました。

今の30,40代は、ギリギリで日本語だけで仕事ができる世代だと思っていて、 今後は英語ができないといい仕事にありつけないんじゃないかと思っています。

そんなわけで、英語を学べる・使える環境に身を置きたいなと考えるようになりました。 自分で英会話やればいいじゃんというのもその通りなんですけど、仕事で使わざるを得ないならやらざるを得ないだろうと。

上の3つの条件のうち最低でも2つを満たせるところに行こうと考えました。

転職活動でしたこと

大体一年くらい活動をしていました。

やったことは次の通りです。

職務経歴書(レジュメ)を書く

2度目の転職なので職務経歴書は元々ありましたが、 今までの経歴を振り返って最新化しました。 ただただやったことの羅列にならないように、 自分が主体的に行ったことのアピールを盛り込んだり、 重要でないことは削ったりとメリハリをつけるようにしました。

どうやって書いていいかわからないというのであれば、 転職ドラフトとか Moffers とかに申し込んでレジュメを書いてみたらいいと思います。 第三者にレビューしてもらえるので結構有用でした。

あと、前述した3つの条件にかすらない企業に当たってもしょうがないので、 自分の転職する条件は最初に書きました。 はっきり書いてある分、企業側の人も重視して見てくれたと思います。

企業との接点を増やす

転職系のサービス(転職ドラフト、moffers、Paiza、findy, folkwell、Bizleach、Linkedin)などはあらかた登録しました。

特に理由はないのですが、エージェントは登録しませんでした。 エージェントとのやり取りが面倒だから、レジュメを登録して企業側からのアクションを待ちたいみたいな心理が働いたのかもしれないです。 (事実結構忙しかった)

色々なサービスに登録したせいか、スタートアップ、ベンチャー、大企業、外資など色々なところから声をかけていただけたなと思います。 何社かの人にも実際に会いに行きました。

転職目的ではなく自身の習慣としてですが、Twitter ではいろんなエンジニアの方をフォローしているので、自然と採用に関する情報も入ってきました。 採用説明会を開くなどの情報が入ってきて気になる会社があれば、申し込んだりもしていました。

他には勉強会の参加などもしていました。 勉強会の参加履歴から企業側が興味を持ち、声をかけてくれたこともありました。 ただし、採用目的で勉強会に参加してもつらいだけなので、そこはあわよくば程度に考えましょう。

Qiita を書いたりもしていたので、企業側からは技術習得に熱心な人みたいな印象を持たれることが多かったように思います。 仕事での仕事外でもアウトプットをしていることは採用の評価に大きく影響すると感じます。

レーニン

外資系やベンチャーではコーディングテストが出ることが多いです。 CS 知識を問うものも多く、最低限以下の内容は押さえておかないとスタートラインにも立てないです。

  • データ構造(連結リストとかスタックとか木とか)
  • 探索、ソートのアルゴリズムと計算量
  • 幅優先検索とか深さ優先検索とかDPとか
  • 再帰

他にも Linux やネットワーク、HTTP の基礎的な知識を問われることもあるし、非常に高度なアルゴリズムを適用しないと解けない問題などもあります。 私自身は活動を始める前から Paiza をやったり、アルゴリズムの本を読んだりしていました。

活動を終えてからしりましたが、外資向けのコーディング試験対策としては、

leetcode.com

などが有名なようでした。 いずれにせよ、普段書いているプログラムとはちょっと趣向が違う課題が多いので、 対策はしておいた方がよいと思います。 どこまで対策するかは、その会社がどこまでの技術レベルを求めるかによるので一概には言えないですが、 凄腕の技術者が多い会社であればそれだけ採用試験は難しいとみていいと思います。

面談・面接

大体の場合、仕事で何をしてきたかが問われますので、ちゃんと自分がやってきたことをアピールできるように、 仕事で自分が主体的に取り組み工夫してきたことを自分の言葉で話せるようにしておきます。 10年以上この業界で働いていますので、自分のスキルをちゃんと自分で語れないようでは、採用したいとは思ってはくれないでしょう。

転職をしたらどうなりたいか・何をしたいかも大体聞かれます。 1年後、5年後のなりたい自分とキャリアプランを考えておくといいと思います。

また、前述の3つの条件については早めに話すようにしました。 なので給与レンジなどはカジュアル面談の時点で聞くようにしていました。 オファーの段階になって希望年収を下回るというのも時間の無駄です。

それに、企業側の人もお金の話をしても嫌な顔はしませんでした。 むしろ外資系の場合は先に、幾らぐらい欲しいの? と聞かれました。 特にエージェントを介さない場合、年収交渉は自分でやるしかないので、 お金の話はきちんとしたほうがいいです。

結果

何社かの人はカジュアル面談に伺い、お話を聞かせていただきました。 3つの条件以外にスキル的なアンマッチもあったりして、採用プロセスに進もうと思ったところは正直そんなになかったです。 これは正直なところ、忙しかったから受ける会社は厳選したいという思いもありました。

数カ月にわたって7,8度の面接を行って落ちたところもあり、その時は正直だいぶへこみました。

その後、たまたま Twitter を見ていたら某企業が採用説明やる情報を見つけて、 採用説明会に行き、興味があったので、申込、コーディングテスト、面接2回で内定と半月くらいであっさり決まりました。

面接の帰りに近くの店で餃子食べてたら、電話かかってきて内定の連絡だった時は正直笑いました。 自分の上げた3つの条件をすべて満たせるところだったので結果的には良かったと思います。

今後について

すごい人ばかりいるところなので、「俺より強い奴に会いに行く」という気持ちです。 入社は年初となりますので実際に入ってみてどうだったかは、試用期間が過ぎる頃にまたレポートしようと思っています。

転職はゴールではなくスタートなのでこれからも頑張っていかないといけないですが、今後も設計とプログラミングの間で生きていく所存です。 今後ともよろしくお願いします。