Serviceをたずねて3000行 - Kubernetesコードリーディングの旅
Kubernetes上にワークロードを展開するうえでServiceは欠かすことのできないリソースです。ServiceはPodに対するネットワークトラフィックを抽象化し、クラスタ内部/外部のロードバランサとして主に活躍します。
Serviceの細かい機能や設定に関しては公式のドキュメントに譲るとして、このドキュメントではServiceが作成される流れ[1]とルーティングへの影響を実装コードを読みながら解説します。
環境
このドキュメントではKubernetes v1.20を元に動作検証とコードリーディングをしています。また省略のため、クラスタの設定を次のとおりに設定してあります。
- featureGateの
EndpointSlice
とEndpointSliceProxying
は有効- v1.20でのデフォルト設定
- kube-proxyの起動パラメータ
proxy-mode
はiptables- 手元の環境におけるデフォルト設定
全体像
Serviceの作成、更新、削除には次のリソースが関係しています。
- Pod
- EndpointSlice
- Service
さらにそれらのリソースを次のコンポーネントが監視しており、変更を検知するとそれぞれのコンポーネントで更新処理が走ることになります。
- EndpointSlice Controller
- Serviceの変更
- EndpointSliceの変更
- Podの変更
- kube-proxy
- Serviceの変更
- EndpointSliceの変更
まずはEndpointSlice Controllerの挙動から確認し、その後kube-proxyがどのようにルーティングを変更しているのかを確認します。
EndpointSlice Controllerの挙動
EndpointSliceはKubernetes1.16から生まれたリソースで、従来のEndpointの抱えていた性能面の問題を解決するために生まれました。そしてこのEndpointSliceのコントローラがEndpointSlice Controllerです。
EndpointSlice ControllerはService, Pod, EndpointSliceの3つのInformerを所有しており、変更の検知を受けるごとにキューへデータを詰めます。
またEndpointSlice Controllerの、1秒ごとに起動されるメインループの中ではキューからデータを取得しそれぞれのリソースの状態の確認とその結果に合わせた処理が行われます。
メインループ
EndpointSlice Controllerは1秒ごとループ処理を実行します。ここでは便宜上そのループのことを"メインループ"と呼ぶことにします。
メインループでは次のような処理が実行されます。
ループ開始
↓
キューからデータを取得
↓
(データがない場合、ここでループ終了)
↓
キューに詰まっていたデータ(=Service名)からServiceを取得
↓
ServiceのセレクタでPodを取得してEndpointSliceを作成して登録をする
↓
ループ開始に戻る
Service, Podの更新のタイミングでキューにデータが挿入されます。それぞれのパターンの実装を見てみます。
Serviceの更新処理
Serviceが作成/更新された場合、EndpointSlice ControllerはそのServiceのセレクタに一致するPodを探し出しEndpointSliceとして更新します。
後述しますが、kube-proxyはあるServiceへ紐づいているEndpointSlice一覧を取得してくるためEndpointSlice ControllerでPodとセレクタとの紐付けを行います。
具体的な実装は次のonServiceUpdate
で実装されています。
処理はシンプルで、更新の対象となったServiceの名前をキューに挿入するだけです。
Podの更新処理
Podが作成/更新された場合、EndpointSlice ControllerはそのPodのセレクタに一致するようなServiceを探しEndpointSliceとして更新します。
具体的な実装は次のaddPod
で実装されています。
ローカルに保存されているPodとServiceのキャッシュから該当のPodのセレクタに一致するService一覧を取得し、それらをキューに詰める処理をしています。
EndpointSliceの更新処理
EndpointSliceが単体で更新されることもあります。たとえば別のEndpointSlice Controllerのgoroutinが更新処理を行った場合や手動でEndpointSliceが作成されたときなどに発生します。
EndpointSlice更新処理の具体的な実装は次のonEndpointSliceAdd
で実装されています。
EndpointSliceに紐づくServiceの名前をキューに詰めるのですが、無限ループを防ぐため「すでに管理しているものではないか」「過去のEndpointSliceではないか」などを検査してからキューに挿入します。(マルチスレッド/マルチプロセスプログラミングは難しい)
EndpointSlice Controllerの挙動のまとめ
EndpointSlice Controllerでは監視しているリソースの更新に合わせて更新用のキューにServiceの名前を追加し、メインループ内でキューの中身を取得してEndpointSliceを更新していく処理が行われていました。さっと見る限り、全体的な設計としてはKubernetesのControllerの模範的な実装になっています。
kube-proxyの挙動
kube-proxyはDaemonSetとして各ノードに配置されるワーカノード側のコンポーネントです。このコンポーネントはワーカノードへ変更を指示します。
kube-proxyのエントリポイントは次のリポジトリです。
この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が注入されています)
さらに今回はIPv4のみを有効にしている環境[3]ですので、単一のiptables.Proxierが生成されます。
ProxyServerの起動処理
ProxyServerは起動する際にService, EndpointSliceのリソースを監視するInformerを作成し、それぞれの更新処理を登録します。
更新処理はproxy.ProvidorインタフェースのSync
関数で実現されており、iptables.ProxierではsyncProxyRules
で実装されています。
またProxyServer起動時にループ処理もgoroutinで起動します。このgoroutinの処理自体はproxy.ProvidorインタフェースのSyncLoop
関数で実現されています。SyncLoop
関数の実装はSync
関数と同じくsyncProxyRules
で実装されています。
SyncLoop
関数の特徴としては少なくとも1時間に一度は更新処理が行われるようにループが実装されています。
syncProxyRulesの実装
冒頭にロックの処理を挟んでから本処理が始まります。
(マルチスレッドで動くため) ロックに失敗した場合でもキャッシュには登録済みであるため、次の更新で漏れなく更新されています。
本処理では次のようになっています。
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/nginx
Service宛にパケットを送信したときのルールの適応順序をパケットの気持ちになって確認してみます。
まず初めに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/nginx
Service宛)の場合は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/nginx
Service宛のパケットはdefault/nginx
DeploymentのいずれかのPodのIPアドレスへ送信されるようになりました。
完走した感想
はじめてKubernetesのソースコードを読んでみたのですが、コードリーディングしやすくよく設計されていると思いました。
実装を眺めていて気がついたのですが、kube-proxyもやはりControllerっぽい実装になっていました。(Informer使っていたりIndexer的なキャッシュと永続化層があったりetc)
その一方で処理の委譲やDI[4]を利用した環境依存の注入、キューを一切使わず遅延処理の実行など他のControllerであまり見られない実装[5]を見ることができました。Golangにあまり馴染みのない自分にとってはかなり読み応えがあって楽しかったです。
まったく関係ない(関係ある)のですが、コードリーディングを始めるきっかけとなったKubernetes Internal - connpass にこの場で感謝を。
-
LoadBalancer Serviceの実装についてはこの場では話しません、おれはkube-proxyが読みたいのだ! ↩︎
-
ProxierはおそらくControllerのIndexerと同じ哲学で設計されています。 ↩︎
-
featureGateの
IPv6DualStack
を有効にしている場合、iptables.Proxierの内部でメインループとは別のgoroutineが起動します。その内部で別のiptables.Proxierが作成されIPv6の更新が行われるループが生成されます。 ↩︎ -
Dependency Injection ↩︎
-
自分があまり知らないだけで実は一般的なのかも……? ↩︎
Discussion