🤱

Serviceをたずねて3000行 - Kubernetesコードリーディングの旅

14 min read

Kubernetes上にワークロードを展開するうえでServiceは欠かすことのできないリソースです。ServiceはPodに対するネットワークトラフィックを抽象化し、クラスタ内部/外部のロードバランサとして主に活躍します。

Serviceの細かい機能や設定に関しては公式のドキュメントに譲るとして、このドキュメントではServiceが作成される流れ[1]とルーティングへの影響を実装コードを読みながら解説します。

https://kubernetes.io/ja/docs/concepts/services-networking/service/

環境

このドキュメントではKubernetes v1.20を元に動作検証とコードリーディングをしています。また省略のため、クラスタの設定を次のとおりに設定してあります。

  • featureGateのEndpointSliceEndpointSliceProxyingは有効
    • v1.20でのデフォルト設定
  • kube-proxyの起動パラメータproxy-modeはiptables
    • 手元の環境におけるデフォルト設定

全体像

Serviceの作成、更新、削除には次のリソースが関係しています。

  • Pod
  • EndpointSlice
  • Service

さらにそれらのリソースを次のコンポーネントが監視しており、変更を検知するとそれぞれのコンポーネントで更新処理が走ることになります。

  • EndpointSlice Controller
    • Serviceの変更
    • EndpointSliceの変更
    • Podの変更
  • kube-proxy
    • Serviceの変更
    • EndpointSliceの変更

まずはEndpointSlice Controllerの挙動から確認し、その後kube-proxyがどのようにルーティングを変更しているのかを確認します。

kube-apiserverがServiceのspecを組み立てる際にService.spec.ClusterIPのアロケートを実施します。(詳細は次のリンク参照)

https://github.com/kubernetes/kubernetes/blob/release-1.20/pkg/registry/core/service/storage/rest.go#L196

IPアドレスのアロケーションはIPv6DualStackfeatureFateが有効にされているかどうかで処理が分岐されます。有効化されていない場合、--service-cluster-ip-rangeフラグで指定したIPv4 CIDRの範囲内のIPアドレスがServiceリソースにアロケートされます。

EndpointSlice Controllerの挙動

EndpointSliceはKubernetes1.16から生まれたリソースで、従来のEndpointの抱えていた性能面の問題を解決するために生まれました。そしてこのEndpointSliceのコントローラがEndpointSlice Controllerです。

EndpointSlice ControllerはService, Pod, EndpointSliceの3つのInformerを所有しており、変更の検知を受けるごとにキューへデータを詰めます。
またEndpointSlice Controllerの、1秒ごとに起動されるメインループの中ではキューからデータを取得しそれぞれのリソースの状態の確認とその結果に合わせた処理が行われます。

https://github.com/kubernetes/kubernetes/blob/release-1.20/pkg/controller/endpointslice/endpointslice_controller.go#L76

メインループ

EndpointSlice Controllerは1秒ごとループ処理を実行します。ここでは便宜上そのループのことを"メインループ"と呼ぶことにします。

https://github.com/kubernetes/kubernetes/blob/release-1.20/pkg/controller/endpointslice/endpointslice_controller.go#L259

メインループでは次のような処理が実行されます。

ループ開始
↓
キューからデータを取得
↓
(データがない場合、ここでループ終了)
↓
キューに詰まっていたデータ(=Service名)からServiceを取得
↓
ServiceのセレクタでPodを取得してEndpointSliceを作成して登録をする
↓
ループ開始に戻る

Service, Podの更新のタイミングでキューにデータが挿入されます。それぞれのパターンの実装を見てみます。

Serviceの更新処理

Serviceが作成/更新された場合、EndpointSlice ControllerはそのServiceのセレクタに一致するPodを探し出しEndpointSliceとして更新します。
後述しますが、kube-proxyはあるServiceへ紐づいているEndpointSlice一覧を取得してくるためEndpointSlice ControllerでPodとセレクタとの紐付けを行います。

具体的な実装は次のonServiceUpdateで実装されています。

https://github.com/kubernetes/kubernetes/blob/release-1.20/pkg/controller/endpointslice/endpointslice_controller.go#L366

処理はシンプルで、更新の対象となったServiceの名前をキューに挿入するだけです。

Podの更新処理

Podが作成/更新された場合、EndpointSlice ControllerはそのPodのセレクタに一致するようなServiceを探しEndpointSliceとして更新します。

具体的な実装は次のaddPodで実装されています。

https://github.com/kubernetes/kubernetes/blob/release-1.20/pkg/controller/endpointslice/endpointslice_controller.go#L447

ローカルに保存されているPodとServiceのキャッシュから該当のPodのセレクタに一致するService一覧を取得し、それらをキューに詰める処理をしています。

EndpointSliceの更新処理

EndpointSliceが単体で更新されることもあります。たとえば別のEndpointSlice Controllerのgoroutinが更新処理を行った場合や手動でEndpointSliceが作成されたときなどに発生します。

EndpointSlice更新処理の具体的な実装は次のonEndpointSliceAddで実装されています。

https://github.com/kubernetes/kubernetes/blob/release-1.20/pkg/controller/endpointslice/endpointslice_controller.go#L392

EndpointSliceに紐づくServiceの名前をキューに詰めるのですが、無限ループを防ぐため「すでに管理しているものではないか」「過去のEndpointSliceではないか」などを検査してからキューに挿入します。(マルチスレッド/マルチプロセスプログラミングは難しい)

EndpointSlice Controllerの挙動のまとめ

EndpointSlice Controllerでは監視しているリソースの更新に合わせて更新用のキューにServiceの名前を追加し、メインループ内でキューの中身を取得してEndpointSliceを更新していく処理が行われていました。さっと見る限り、全体的な設計としてはKubernetesのControllerの模範的な実装になっています。

kube-proxyの挙動

kube-proxyはDaemonSetとして各ノードに配置されるワーカノード側のコンポーネントです。このコンポーネントはワーカノードへ変更を指示します。

kube-proxyのエントリポイントは次のリポジトリです。

https://github.com/kubernetes/kubernetes/blob/release-1.20/cmd/kube-proxy/app/server.go#L306

このRun関数の中でrunLoopが実行されていますが、runLoopの内部処理自体はProxyServerに委譲されています。ProxyServerは環境に依存したオプションとInformerの情報にしたがってワーカノードのルーティングを変更するインタフェースを提供しています。

ProxyServerの初期化処理

ProxyServerの処理の多くはproxy.Providerというインタフェースに委譲されています。このproxy.ProviderインタフェースはProxier[2]と命名/実装されており、Proxierが物理的にノードのルーティングの変更を行い永続化を行います。

今回はproxy-modeをiptablesに設定している環境ですので、次の初期化処理によってProxyServerにiptables.Proxier、つまりiptablesに対して物理的に変更できるインタフェースが注入されることになります。(初期化処理の中でiptables/ipvs/userspaceのいずれかのProxierが注入されています)

https://github.com/kubernetes/kubernetes/blob/release-1.20/cmd/kube-proxy/app/server_others.go#L75

さらに今回はIPv4のみを有効にしている環境[3]ですので、単一のiptables.Proxierが生成されます。

ProxyServerの起動処理

ProxyServerは起動する際にService, EndpointSliceのリソースを監視するInformerを作成し、それぞれの更新処理を登録します。

https://github.com/kubernetes/kubernetes/blob/release-1.20/cmd/kube-proxy/app/server.go#L744
https://github.com/kubernetes/kubernetes/blob/release-1.20/cmd/kube-proxy/app/server.go#L749

更新処理はproxy.ProvidorインタフェースのSync関数で実現されており、iptables.ProxierではsyncProxyRulesで実装されています。

またProxyServer起動時にループ処理もgoroutinで起動します。このgoroutinの処理自体はproxy.ProvidorインタフェースのSyncLoop関数で実現されています。SyncLoop関数の実装はSync関数と同じくsyncProxyRulesで実装されています。
SyncLoop関数の特徴としては少なくとも1時間に一度は更新処理が行われるようにループが実装されています。

https://github.com/kubernetes/kubernetes/blob/release-1.20/pkg/proxy/iptables/proxier.go#L338

syncProxyRulesの実装

https://github.com/kubernetes/kubernetes/blob/release-1.20/pkg/proxy/iptables/proxier.go#L811

冒頭にロックの処理を挟んでから本処理が始まります。
(マルチスレッドで動くため) ロックに失敗した場合でもキャッシュには登録済みであるため、次の更新で漏れなく更新されています。

本処理では次のようになっています。

iptablesローカルキャッシュのリフレッシュ
↓
Kubernetes特有のルーティング作成
↓
ローカルキャッシュに保存されているServiceとそのServiceに紐つくEndpointSliceを吸い上げる
↓
(ServiceTopologyが有効な場合) EndpointSliceのトポロジ情報を吸い上げる
↓
DNATルール書き込み
↓
SNATルール書き込み
↓
iptablesのrestore

具体的にどのようなルールが作成されるのかは後述します。

kube-proxyの挙動まとめ

kube-proxyはService/EndpointSliceの更新イベントを契機とした直接更新とループでの定期更新の2つを元にルーティングの更新していました。
また今回はiptablesを前提とした環境だったため、ルーティングの更新時にキャッシュを元にiptablesのrestoreを行うことでKubernetes内のルーティングを実現させていました。

実際に動かしてみる

KubernetesにPodやServiceを作成した際、iptablesにどのようなルールが反映されるのかを確認してみます。まずDeploymentを作成してEndpointSliceとServiceの状況を確認してみます。

# Podを5つとServiceを作成する
ubuntu@ip-10-82-101-65:~$ kubectl create deployment --image nginx nginx
deployment.apps/nginx created
ubuntu@ip-10-82-101-65:~$ kubectl expose deployment hoge --port 80
service/nginx exposed
ubuntu@ip-10-82-101-65:~$ kubectl scale deployment nginx --replicas 5
deployment.apps/nginx scaled
# EndpointSliceを確認する
ubuntu@ip-10-82-101-65:~$ kubectl get endpointslice
NAME          ADDRESSTYPE   PORTS   ENDPOINTS                                                     AGE
kubernetes    IPv4          6443    10.82.101.65                                                  46m
nginx-k9n6w   IPv4          80      192.168.185.134,192.168.185.135,192.168.185.137 + 2 more...   31m
ubuntu@ip-10-82-101-65:~$ kubectl get endpointslice nginx-k9n6w -o json | jq '.endpoints[].addresses[]'
"192.168.185.134"
"192.168.185.135"
"192.168.185.137"
"192.168.185.136"
"192.168.185.138"
# ServiceのClusterIPを確認する
ubuntu@ip-10-82-101-65:~$ kubectl get svc
NAME         TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)   AGE
kubernetes   ClusterIP   10.96.0.1       <none>        443/TCP   52m
nginx        ClusterIP   10.102.88.247   <none>        80/TCP    37m

このときiptables-saveコマンドをたたくことで次のルールが確認できます。(関連ある部分のみ抜粋、順番はそのまま)

-A KUBE-SEP-7XNKSJZCWZM6JL55 -s 192.168.185.137/32 -m comment --comment "default/nginx" -j KUBE-MARK-MASQ
-A KUBE-SEP-7XNKSJZCWZM6JL55 -p tcp -m comment --comment "default/nginx" -m tcp -j DNAT --to-destination 192.168.185.137:80
-A KUBE-SEP-F5A3WVYW4IHXFQ4F -s 192.168.185.134/32 -m comment --comment "default/nginx" -j KUBE-MARK-MASQ
-A KUBE-SEP-F5A3WVYW4IHXFQ4F -p tcp -m comment --comment "default/nginx" -m tcp -j DNAT --to-destination 192.168.185.134:80
-A KUBE-SEP-KM4DEYTXSCDCAZGW -s 192.168.185.135/32 -m comment --comment "default/nginx" -j KUBE-MARK-MASQ
-A KUBE-SEP-KM4DEYTXSCDCAZGW -p tcp -m comment --comment "default/nginx" -m tcp -j DNAT --to-destination 192.168.185.135:80
-A KUBE-SEP-RBJTNY7OCASJIJNT -s 192.168.185.138/32 -m comment --comment "default/nginx" -j KUBE-MARK-MASQ
-A KUBE-SEP-RBJTNY7OCASJIJNT -p tcp -m comment --comment "default/nginx" -m tcp -j DNAT --to-destination 192.168.185.138:80
-A KUBE-SEP-VFUARJSOMW7LGFGS -s 192.168.185.136/32 -m comment --comment "default/nginx" -j KUBE-MARK-MASQ
-A KUBE-SEP-VFUARJSOMW7LGFGS -p tcp -m comment --comment "default/nginx" -m tcp -j DNAT --to-destination 192.168.185.136:80
-A KUBE-SERVICES ! -s 192.168.0.0/16 -d 10.102.88.247/32 -p tcp -m comment --comment "default/nginx cluster IP" -m tcp --dport 80 -j KUBE-MARK-MASQ
-A KUBE-SERVICES -d 10.102.88.247/32 -p tcp -m comment --comment "default/nginx cluster IP" -m tcp --dport 80 -j KUBE-SVC-2CMXP7HKUVJN7L6M
-A KUBE-SVC-2CMXP7HKUVJN7L6M -m comment --comment "default/nginx" -m statistic --mode random --probability 0.20000000019 -j KUBE-SEP-F5A3WVYW4IHXFQ4F
-A KUBE-SVC-2CMXP7HKUVJN7L6M -m comment --comment "default/nginx" -m statistic --mode random --probability 0.25000000000 -j KUBE-SEP-KM4DEYTXSCDCAZGW
-A KUBE-SVC-2CMXP7HKUVJN7L6M -m comment --comment "default/nginx" -m statistic --mode random --probability 0.33333333349 -j KUBE-SEP-VFUARJSOMW7LGFGS
-A KUBE-SVC-2CMXP7HKUVJN7L6M -m comment --comment "default/nginx" -m statistic --mode random --probability 0.50000000000 -j KUBE-SEP-7XNKSJZCWZM6JL55
-A KUBE-SVC-2CMXP7HKUVJN7L6M -m comment --comment "default/nginx" -j KUBE-SEP-RBJTNY7OCASJIJNT

ここでdefault/nginxService宛にパケットを送信したときのルールの適応順序をパケットの気持ちになって確認してみます。
まず初めにService宛のパケット(=to 10.102.88.247)はKUBE-SERVICESルールに引っかかります。

-A KUBE-SERVICES ! -s 192.168.0.0/16 -d 10.102.88.247/32 -p tcp -m comment --comment "default/nginx cluster IP" -m tcp --dport 80 -j KUBE-MARK-MASQ
-A KUBE-SERVICES -d 10.102.88.247/32 -p tcp -m comment --comment "default/nginx cluster IP" -m tcp --dport 80 -j KUBE-SVC-2CMXP7HKUVJN7L6M

パケットはこのルールで宛先をチェックされ、10.102.88.247/32宛のパケット(=default/nginxService宛)の場合はKUBE-SVC-2CMXP7HKUVJN7L6Mルールに飛ばされます。

KUBE-SVC-2CMXP7HKUVJN7L6Mルールではiptablesのstatisticモジュールで利用できるrandomモードによるバランシングが行われています。iptablesのフィルタリングは上から順番に行われるため、「最初は20%、次は25%、さらに次は33%……」としていくことでパケットがランダムに分配されます。

-A KUBE-SVC-2CMXP7HKUVJN7L6M -m comment --comment "default/nginx" -m statistic --mode random --probability 0.20000000019 -j KUBE-SEP-F5A3WVYW4IHXFQ4F
-A KUBE-SVC-2CMXP7HKUVJN7L6M -m comment --comment "default/nginx" -m statistic --mode random --probability 0.25000000000 -j KUBE-SEP-KM4DEYTXSCDCAZGW
-A KUBE-SVC-2CMXP7HKUVJN7L6M -m comment --comment "default/nginx" -m statistic --mode random --probability 0.33333333349 -j KUBE-SEP-VFUARJSOMW7LGFGS
-A KUBE-SVC-2CMXP7HKUVJN7L6M -m comment --comment "default/nginx" -m statistic --mode random --probability 0.50000000000 -j KUBE-SEP-7XNKSJZCWZM6JL55
-A KUBE-SVC-2CMXP7HKUVJN7L6M -m comment --comment "default/nginx" -j KUBE-SEP-RBJTNY7OCASJIJNT

そしてそのKUBE-SVC-2CMXP7HKUVJN7L6Mルールの宛先はKUBE-SEP-*となっており、これはService宛のパケットをDNATして宛先をPodのIPアドレスに差し替える処理を行っています。

-A KUBE-SEP-7XNKSJZCWZM6JL55 -s 192.168.185.137/32 -m comment --comment "default/nginx" -j KUBE-MARK-MASQ
-A KUBE-SEP-7XNKSJZCWZM6JL55 -p tcp -m comment --comment "default/nginx" -m tcp -j DNAT --to-destination 192.168.185.137:80

これらのルーティング処理によって、default/nginxService宛のパケットはdefault/nginxDeploymentのいずれかのPodのIPアドレスへ送信されるようになりました。

完走した感想

はじめてKubernetesのソースコードを読んでみたのですが、コードリーディングしやすくよく設計されていると思いました。

実装を眺めていて気がついたのですが、kube-proxyもやはりControllerっぽい実装になっていました。(Informer使っていたりIndexer的なキャッシュと永続化層があったりetc)
その一方で処理の委譲やDI[4]を利用した環境依存の注入、キューを一切使わず遅延処理の実行など他のControllerであまり見られない実装[5]を見ることができました。Golangにあまり馴染みのない自分にとってはかなり読み応えがあって楽しかったです。

まったく関係ない(関係ある)のですが、コードリーディングを始めるきっかけとなったKubernetes Internal - connpass にこの場で感謝を。

脚注
  1. LoadBalancer Serviceの実装についてはこの場では話しません、おれはkube-proxyが読みたいのだ! ↩︎

  2. ProxierはおそらくControllerのIndexerと同じ哲学で設計されています。 ↩︎

  3. featureGateのIPv6DualStackを有効にしている場合、iptables.Proxierの内部でメインループとは別のgoroutineが起動します。その内部で別のiptables.Proxierが作成されIPv6の更新が行われるループが生成されます。 ↩︎

  4. Dependency Injection ↩︎

  5. 自分があまり知らないだけで実は一般的なのかも……? ↩︎

Discussion

ログインするとコメントできます