kubernetesにおけるHTTP/2のロードバランシング

# はじめに

現状（2019/7/14）、gRPCを使ったkubernetes上のアプリケーションは、Envoy => application という構成で通信するようになっていることが多いです。なぜこのような構成になっているのか自分で調べたことをまとめました。自分もまだまだ詳しくないので、フィードバックを頂けると嬉しいです。

# ロードバランサーの概要

## ロードバランサーの役割

ここでのロードバランサーはネットワーク経由でのタスクの割り当てを、複数のバックエンドサーバーに分散させる仕組み（ロードバランシング）を提供します。
それだけでなく、利用可能なバックエンドサーバーのIPアドレスを管理することで、クライアントはぞれぞれについて知る必要がなくなり、ロードバランサーのDNS名やIPアドレス、ポートを知っていれば通信が可能になります。（サービスディスカバリー）さらに、リクエストに応答可能かを確認し、利用可能なバックエンドサーバーにのみ通信を送ることで、不具合のあるバックエンドサーバーにリクエストが送られないようにすることができます（ヘルスチェッキング）。

そして、ロードバランサーには主にL4とL7という二つのカテゴリーがあります。
LはOSI参照モデルにおけるLayerを意味し、L4はトランスポート層、L7はアプリケーション層を示します。
それぞれのロードバランサーによってどのデータの単位で通信を分散させるかが変わってきます。

## L4ロードバランサー

L4ロードバランサーはTCP/UDPのレベルでつまり、パケット単位でロードバランシングをします。
従って、L4ロードバランサーはL7におけるアプリケーションデータの中身には関知しません。

![スクリーンショット 2019-07-12 22.07.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/68018/baaf4119-929e-84a5-0e15-5ca5419cca08.png)

また、TCPの場合はコネクションが続く限りは同じバックエンドサーバーにパケットが送られます。
なぜなら、そうしないとSYN, SYN/ACK, ACKパケットを交換し、シーケンス番号を増分しながら通信ができないからです。

しかし、このようなL4ロードバランサーの特性が欠点になることがあります。
例えば、gRPCのようなHTTP/2による通信では、一つのTCPコネクションでHTTPレベルでのリクエスト/レスポンスを多重化することができます。
これは、バイナリフォーマットでHTTPのメソッド、ヘッダ、ボディなどの構成要素がHTTPのレイヤーにおいて表現される（バイナリフレーミング）ためです。
そして、一つのTCPコネクションにおいて、ストリームという双方向の論理的なフレームのまとまりを使って、アプリケーションデータの通信の状態を管理したり、優先順位をつけたりします。
そうすると、一つのコネクション上に三つのストリームが多重化されている場合でも、一つのバックエンドサーバーに全てが送られることになります。
これは、keep-aliveなどのTCPコネクションの使い回しの場合にも同じことが起こります。

gRPCやHTTP/2については、[こちらの記事](https://qiita.com/shoichiimamura/items/075fcbf22b5f5e48f9ec)で詳しく説明しました。

## L7ロードバランサー

上記の問題に対処するには、L7ロードバランサーが有効です。
L7ロードバランサーは一つのTCPコネクションにアプリケーションデータが多重化されている場合でも、アプリケーションデータ単位でバックエンドサーバーにTCPコネクションを接続し直し、通信を分散させることができます。

![スクリーンショット 2019-07-12 22.08.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/68018/15a1ca9c-861f-336d-4c5c-d15fd49b1ba3.png)


## 参考
- https://blog.envoyproxy.io/introduction-to-modern-network-load-balancing-and-proxying-a57f6ff80236
- https://www.atmarkit.co.jp/ait/articles/0302/05/news001.html

# kubernetesにおけるロードバランシング
## Serviceの仕組み

kubernetesのPodはデプロイなどで入れ替わるものであり、それぞれのPodはIPアドレスを持っています。
そのため、直接Podと通信するクライアントは動的に変わるPodのIPアドレス一覧を常に監視、更新する必要に迫られます。
ServiceはそのようなPodへのアクセス方法を抽象化する責務を担っています。
具体的には、クラスタIPというクラスタ内で一意の仮想IPアドレスを持ち、クライアントがこのIPアドレスにリクエストすると、予め指定されたラベルをもつPodの集合の一つにルーティングします。
仮想IPアドレスとは、実際のNetwork Interfaceに接続されたIPアドレスではないことを意味します。

そして、このServiceの仕組みを実現するために、kube-proxyがServiceのクラスタIPへのトラフィックをPodのIPアドレスの一覧のいずれかにルーティングします。
kube-proxyはノードごとに存在するkubernetesのコンポーネントの一種（Node Component）です。
kube-proxyのproxy方法はバージョンによって変わりますが、現在（2019/7/14）ではiptables proxy modeが広く利用されています。

ServiceやPodsに割り当てられたIPアドレスをkubernetesのコントロールプレーンの一つのEndpointsContollerがEndpointsオブジェクトとして管理しています。

iptables proxy mode の場合、kube-proxyはAPI経由でそれらを問い合わせて、Serviceの追加、削除や正常なPodのIPアドレスを確認し、ノードのiptablesに対してルールを追加および削除します。
そして、ServiceのClusterIPと正常なPodのIPアドレスをマッピングします。ClusterIPにリクエストされると、ランダムにPodのIPアドレスを選択し、ルーティングします。

![スクリーンショット 2019-07-12 22.07.36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/68018/5cbb6b69-a853-aea7-c7ca-df475f533af7.png)

[こちらの記事](https://cloud.google.com/kubernetes-engine/docs/concepts/network-overview)の画像を参照しています。

実際に確認してみます。

適当なPodを三つ作成します。
それぞれのPodにIPアドレスが割り当てられていることが確認できます。

```bash
$ kubectl create hello-minikube --image=k8s.gcr.io/echoserver:1.10
deployment.apps/hello-minikube created

$ kubectl scale --replicas=3 deployment hello-minikube
deployment.extensions/hello-minikube scaled

$ kubectl get pods -o wide
NAME                              READY   STATUS    RESTARTS   AGE   IP           NODE       NOMINATED NODE   READINESS GATES
hello-minikube-856979d68c-5lc8n   1/1     Running   0          86m   172.17.0.8   minikube   <none>           <none>
hello-minikube-856979d68c-692mk   1/1     Running   0          86m   172.17.0.7   minikube   <none>           <none>
hello-minikube-856979d68c-kzjb5   1/1     Running   0          86m   172.17.0.9   minikube   <none>           <none>
```

Serviceを作成すると、先ほどのPodのIPアドレスがEndpointsに登録されています。

```bash
$ kubectl expose deployment hello-minikube --port=80
service/hello-minikube exposed

$ kubectl describe service hello-minikube
Name:              hello-minikube
Namespace:         default
Labels:            run=hello-minikube
Annotations:       <none>
Selector:          run=hello-minikube
Type:              ClusterIP
IP:                10.104.86.8
Port:              <unset>  80/TCP
TargetPort:        80/TCP
Endpoints:         172.17.0.7:80,172.17.0.8:80,172.17.0.9:80
Session Affinity:  None
Events:            <none>
```

次に、kube-proxyのPodに入り、iptablesの設定を確認します。

```bash
$ kubectl --namespace kube-system get pods 
NAME                                        READY   STATUS    RESTARTS   AGE
...
kube-proxy-lc84b                            1/1     Running   0          137m

$ kubectl --namespace kube-system exec -it kube-proxy-lc84b -- /bin/sh
# iptables -t nat -L KUBE-SERVICES // serviceに関するルールの一覧を見ると、hello-minikubeのクラスタIP（10.104.86.8）に関するルールが含まれている
Chain KUBE-SERVICES (2 references)
target                     prot opt source               destination
...
KUBE-SVC-OJVYVGJ6XUZQDPN7  tcp  --  anywhere             10.104.86.8          /* default/hello-minikube: cluster IP */ tcp dpt:http

# iptables -t nat -L KUBE-SVC-OJVYVGJ6XUZQDPN7 // そのルールでは、33.3%の確率でKUBE-SEP-EQE5Q6T32QGIM6VXというルールを使用する設定になっている
Chain KUBE-SVC-OJVYVGJ6XUZQDPN7 (1 references)
target                     prot opt source               destination
KUBE-SEP-EQE5Q6T32QGIM6VX  all  --  anywhere             anywhere             statistic mode random probability 0.33332999982
KUBE-SEP-KGLS4AVE3VFMR2HX  all  --  anywhere             anywhere             statistic mode random probability 0.50000000000
KUBE-SEP-FFO3E7UZY73I7HTC  all  --  anywhere             anywhere

# iptables -t nat -L KUBE-SEP-EQE5Q6T32QGIM6VX // そのルールは送信先のIPアドレスをPodのIPアドレス（172.17.0.7）の80番ポートへ変換する設定（DNAT）になっている
Chain KUBE-SEP-EQE5Q6T32QGIM6VX (1 references)
target     prot opt source               destination
...
DNAT       tcp  --  anywhere             anywhere             tcp to:172.17.0.7:80
```

このように、kubernetesのServiceはiptablesでNATを用い、クラスタIPをランダムに特定のPodのIPアドレスに書き換えることで、ロードバランスを実現しています。
したがって、kubernetesのServiceはL4ロードバランサーであることがわかります。
これは、上述のようにHTTP/2における一つのTCPコネクションに多重化されたリクエストを分散できない問題に直面します。
この問題を解消するために、EonvyなどのL7ロードバランサーを使います。

## EnvoyをHeadless Serviceと使う

EnvoyはL4/L7の両方に対応したロードバランサーです。HTTP/2やgRPCのロードバランシングにも対応しています。
高いパフォーマンスが出せるようにC++で実装されています。またEnvoyはサイドカーパターンでも利用されます。
c.f https://www.envoyproxy.io/

クライアントサイドでのロードバランシングや他のロードバランサーとの比較は[BugsnagでEnvoyを導入した記事](https://www.bugsnag.com/blog/envoy)に分かりやすくまとめられています。

kubernetes上でEnvoyを使って、HTTP/2のロードバランスを行うにはHeadless Serviceと併用することで、Envoyから直接、PodにL7ロードバランスすることができます。

Healess Serviceは、kube-proxyの管理の対象外になり、iptablesによるロードバランスはされません。
そして、対象とするPodのラベルが指定されている場合は、Headless ServiceのDNS名を問い合わせると、APIサーバー経由で、それらのPodのIPアドレス一覧が返ってきます。

EnvoyではこのHeadless ServiceのDNS名を使ってService Discoveryをし、直接Podへロードバランスします。
実際に、Headless Serviceを作って、Podの中からServiceを名前解決するとPodのIPアドレス一覧が返ってきます。

```bash
# dig hello-minikube.default.svc.cluster.local +short
172.17.0.7
172.17.0.8
172.17.0.9
```

## 参考
- https://kubernetes.io/docs/concepts/overview/components
- https://kubernetes.io/docs/concepts/services-networking/service
- https://cloud.google.com/kubernetes-engine/docs/concepts/network-overview
- https://supergiant.io/blog/understanding-kubernetes-kube-proxy/