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