kubernetes(今回はGKE内)でgRPCの通信を場合にぶち当たる問題として、ロードバランシングの問題があります。
gRPCの通信は永続化されるので、そのままの状態で使うとバックエンドにあるサービスがスケールしても分散されないということになります。
具体例
上記のような構成でhoge-gateway(4pod)からhoge-app(10pod)に向けてコネクションプーリングが1で通信をする場合、hoge-appが最大4podしか使われない状態になります。
下記がその状態です。
GKE Container - CPU usage for hoge-app
GKE Container - CPU usage for hoge-gateway
解決方法
これを解決する手段としてgRPCのclientLoadbalancingを使う方法がありますが、clientに依存する方法はあまりスマートとは言えません。
そこで登場するのが envoy です。
envoyはCNCFにも参加しているサービスでL7プロキシです。
基本的にはアプリのサイドカーとして使うマイクロサービス向けのミドルウェアとなっています。様々な言語や通信のアプリ間に便利な機能を追加できる便利な存在です。
C++11で書かれていてモダンだったり、CoreはL3/L4 network proxyでできていたり、HTTP L7のフィルターも可能です。
その他バッファリングやサーキットブレーカー、ルーティングなどの機能があります。
当然gRPCもサポートしていて、HTTP/2の全ての通信に対応しています。
//またMongoDBやDynamoDBのプロキシフィルターとしても動かせる。
//サービスディスカバリの機能もあり、非同期DNS分析かRESTベースが可能。
つまり、アプリの改修なしにうまいことバランシングできるというのです。
今回はサービスディスカバリにはkubernetesのheadless servicesを利用します。headless serviceはServiceに対してDNSリクエストをすると稼働しているPodのIPアドレス一覧を返す仕組みです。この仕組をenvoyと組み合わせることでgRPCをうまくバランシングさせようという試みです。
envoyの導入
先程の構成にenvoyをサイドカーで組み合わせると下記のようになります。
サイドカーで導入できるメリットは、元のアプリをまったくいじることなく導入できる所です。
まずはhoge-appのアプリにenvoyをサイドカーします。下記がサイドカーするためのDevploymentの例です。
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
name: hoge-app
spec:
replicas: 10
template:
metadata:
labels:
app: hoge-app
spec:
volumes:
- name: envoy
configMap:
name: hoge-app-envoy
containers:
- name: envoy
image: envoyproxy/envoy:latest
command:
- "/usr/local/bin/envoy"
args:
- "--config-path /etc/envoy/envoy.json"
resources:
limits:
memory: 512Mi
ports:
- containerPort: 15001
name: app
- containerPort: 8001
name: envoy-admin
volumeMounts:
- name: envoy
mountPath: /etc/envoy
- name: hoge-app
image: asia.gcr.io/hoge/hoge-app:latest
ports:
- containerPort: 50051
もともとのアプリと並列でport:15001でenvoyが動くような形です。envoyのconfファイルはkubernetesのconfigmapでマウントします。次がenvoyのconfigmapです。
apiVersion: v1
kind: ConfigMap
metadata:
name: hoge-app-envoy
data:
# Adding new entries here will make them appear as files in the deployment.
# Please update k8s.io/k8s.io/README.md when you update this file
envoy.json: |
{
"listeners": [
{
"address": "tcp://0.0.0.0:15001",
"filters": [
{
"type": "read",
"name": "http_connection_manager",
"config": {
"codec_type": "auto",
"stat_prefix": "ingress_http",
"route_config": {
"virtual_hosts": [
{
"name": "service",
"domains": ["*"],
"routes": [
{
"timeout_ms": 0,
"prefix": "/",
"cluster": "local_service"
}
]
}
]
},
"filters": [
{
"type": "decoder",
"name": "router",
"config": {}
}
]
}
}
]
}
],
"admin": {
"access_log_path": "/dev/stdout",
"address": "tcp://127.0.0.1:8001"
},
"cluster_manager": {
"clusters": [
{
"name": "local_service",
"service_name": "hoge-app.default.svc.cluster.local",
"connect_timeout_ms": 250,
"type": "static",
"lb_type": "round_robin",
"features": "http2",
"hosts": [
{
"url": "tcp://127.0.0.1:50051"
}
]
}
]
}
}
port:15001でingressの通信を受けて、それを元のhoge-appにproxyしてます。
hoge-app側にenvoyを挟むメリットは今の時点ではあまりないのですが、今後マイクロサービスが増えた場合などに便利なので挟んでおくと良さそうです。
次にhoge-gateway側にも同様の作業を行います。
deploymentに関してはhoge-appとほぼ同じなので説明は省略します。configmapをhoge-appと別のものにするところだけ注意して下さい。
envoyのconfigmapがhoge-appと異なるものになります。下記がその例です。
apiVersion: v1
kind: ConfigMap
metadata:
name: hoge-gateway-envoy
data:
# Adding new entries here will make them appear as files in the deployment.
# Please update k8s.io/k8s.io/README.md when you update this file
envoy.json: |
{
"listeners": [
{
"address": "tcp://0.0.0.0:15001",
"filters": [
{
"type": "read",
"name": "http_connection_manager",
"config": {
"codec_type": "auto",
"stat_prefix": "ingress_http",
"route_config": {
"virtual_hosts": [
{
"name": "service",
"domains": ["*"],
"routes": [
{
"timeout_ms": 0,
"prefix": "/",
"cluster": "local_service"
}
]
}
]
},
"filters": [
{
"type": "decoder",
"name": "router",
"config": {}
}
]
}
}
]
},
{
"address": "tcp://127.0.0.1:9001",
"filters": [
{
"type": "read",
"name": "http_connection_manager",
"config": {
"codec_type": "auto",
"access_log": [
{
"path": "/dev/stdout"
}
],
"stat_prefix": "egress_http",
"route_config": {
"virtual_hosts": [
{
"name": "hoge-app",
"domains": ["*"],
"routes": [
{
"timeout_ms": 0,
"prefix": "/",
"headers": [
{"name": "content-type", "value": "application/grpc"}
],
"cluster": "hoge-app"
}
]
}
]
},
"filters": [
{
"type": "decoder",
"name": "router",
"config": {}
}
]
}
}
]
}
],
"admin": {
"access_log_path": "/dev/stdout",
"address": "tcp://127.0.0.1:8001"
},
"cluster_manager": {
"clusters": [
{
"name": "local_service",
"service_name": "hoge-gateway.default.svc.cluster.local",
"connect_timeout_ms": 250,
"type": "static",
"lb_type": "round_robin",
"hosts": [
{
"url": "tcp://127.0.0.1:8080"
}
]
},
{
"name": "hoge-app",
"features": "http2",
"connect_timeout_ms": 250,
"type": "strict_dns",
"lb_type": "round_robin",
"hosts": [{"url": "tcp://hoge-app-service:15001"}]
}
]
}
}
port:15001でingressの通信をうけるところは同じですが、tcp://127.0.0.1:9001でegressの通信を受けている所がポイントです。9001で受けたegressの通信はhoge-appクラスタに向かいます。hoge-appクラスタはcluster_managerに示しているようにtcp://hoge-app-service:15001に向かって通信するようになります。しかし、これだけでは結局kubernetesのL4LBを使っている事になるのでバランシングされません。
そこで、このtcp://hoge-app-service:15001に通信した時にkubernetesのheadless serviceを利用するように設定する必要があります。
kubernetesのheadless serviceはDNSリクエストを行うと、そのサービスが持つPodのアドレス一覧を返す仕組みです。これを利用することでenvoy側でラウンドロビンさせることができるようになります。
具体的にはhoge-app-serviceの設定を下記のようにします。
apiVersion: v1
kind: Service
metadata:
name: hoge-app-service
spec:
# type: NodePort
clusterIP: None
selector:
app: hoge-app
ports:
- name: grpc
port: 15001
targetPort: 15001
protocol: TCP
port:15001に変更しているのと、spec.typeを削除し、spec.clusterIP: Noneを追加しています。これでこのserviceはheadless serviceになります。
kubectl get servicesしてTYPEがClusterIPになっていることが確認できると思います。
hoge-gateway-serviceのPortも15001に変更すれば準備完了です。
結果
実際に通信してみると下記のように綺麗に分散されるのがわかります。
GKE Container - CPU usage for hoge-app
GKE Container - CPU usage for hoge-gateway
また、Podの数を5台から10台に途中で切り替えても綺麗に分散されます。
まとめ
世間はistio、istioと盛り上がってますが、まだまだ実用段階ではない事を考えるとこのenvoyProxyは今GKEでマイクロサービスを構築するには必須のツールとなりそうですね!!
参考
https://cloud.google.com/community/tutorials/envoy-flask-google-container-engine
https://github.com/bakins/kubernetes-envoy-example