🐔

client-go で DynamicClient を使うと通信量が増える

2024/12/26に公開

はじめに

最近 client-go を弄っていたら DynamicClient を使うとリクエストやレスポンスのサイズが微妙にデカくなることに気付いたのでメモ。

なお、この内容は client-go v0.32.0 をベースに調査している。

普通のクライアントとダイナミッククライアント

client-go には普通のクライアント(kubernetes.Clientset)の他にダイナミッククライアント( dynamic.DynamicClient)と言うクライアントがある。

普通のクライアントがリソースの種類に応じて corev1.Pod とか appsv1.Deployment とかと言ったそれぞれに対応した型でやり取りするところを、コイツは何もかもを unstructured.Unstructured でやり取りする。

通常は普通のクライアントを使う方が型が決まってるので何かと便利なのだが、たまにリソースの種類に限らず統一的に扱いたいとか思った場合にダイナミッククライアントが便利だったりする。

JSON と Protocol Buffers

kubernetes の API と言えば JSON だと思っていたが、最近の client-go ではサーバとのやり取りに JSON ではなくて Protocol Buffers が使われるようになっていた。何で JSON じゃないのかと言えば単純に効率的だからだろう。

JSON は人が見ても比較的分かりやすい[1]形式だが、その理由は各フィールドの名前がデータとして付いてることと、各データが文字列であることによると思う。これは人が見る分にはいいのだが、どうしても容量がデカくなってしまう[2]

一方 Protocol Buffers は人が見ると全く分からない。いや、主語がデカかった。「え?見れば分かるよ?」とか言う人間 Protocol Buffers デコーダな人もいるのかもしれないが、少なくともオレには読めない。読めないが代わりに比較的コンパクトである。さすがに元々文字列のフィールド値は何ともならないが、数値はバイナリだしフィールドの識別も名前じゃなくて数値なのでまぁコンパクトになるよね。

Pod の情報 1 つぐらいならともかく大規模なクラスタとかでうん十うん百[3] のデータをシリアライズした場合、JSON と Protocol Buffers ではサイズに結構な差が出る。

と言う訳で、client-go では最近ようやくそれに気づいて[4] 通信にProtocol Buffers を使うようになったようだ。

ちなみに、etcd への保存は昔からずっと Protocol Buffers だった。

ダイナミッククライアントと Protocol Buffers

Protocol Buffers を使うようになりはしたのだが、実はダイナミッククライアントと Protocol Buffers は相性が悪い。相性が悪い理由はデシリアライズ先の構造体のせいだ。

先に書いたように Protocol Buffers はフィールドの識別に数値を使っているので、デシリアライズ先の構造が予め分かっていないとデシリアライズできない。普通のクライアントであればデシリアライズ先は corev1.Pod とかなのでいいのだが、ダイナミッククライアントでは unstructured.Unstructured でコイツの実体は map[string]any なので格納するのにフィールド名が必要なのだ。

で、何が起こるかと言えば client-go でダイナミッククライアントを使うと Protocol Buffers を使うのを諦めて旧来通り JSON を使ってしまう。

つまり、通信量が増えてしまうのだ。

ホントは使えるようにできんじゃね?

ホントのコト言えば、ダイナミッククライアントでも頑張れば Protocol Buffers は使えるんじゃないかと思う。だって受けるのが map[string]any だとしてもそもそも型定義は手元にあるんだしフィールドを識別する数値をフィールド名に変換すればいいだけだよね?

でもそれをするのは面倒だしダイナミッククライアントのためにそこまでやる?って感じなんだと思う。

確認してみる

デカくなりそうなことは分かったが、実際ホントなのか確認してみる。

Pod一覧比較
package main

import (
    "context"
    "flag"
    "fmt"

    corev1 "k8s.io/api/core/v1"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    utilruntime "k8s.io/apimachinery/pkg/util/runtime"
    "k8s.io/client-go/dynamic"
    "k8s.io/client-go/kubernetes"
    "k8s.io/client-go/tools/clientcmd"
    "k8s.io/klog/v2"
)

func main() {
    klog.InitFlags(nil)
    flag.Parse()

    config, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
        clientcmd.NewDefaultClientConfigLoadingRules(), nil,
    ).ClientConfig()
    utilruntime.Must(err)

    // 普通のクライアントで Pod の一覧を取得
    fmt.Println("普通")
    client := kubernetes.NewForConfigOrDie(config)
    list, err := client.CoreV1().Pods(metav1.NamespaceAll).
        List(context.TODO(), metav1.ListOptions{})
    utilruntime.Must(err)
    for i, pod := range list.Items {
        fmt.Printf("%v:%v/%v\n", i, pod.Namespace, pod.Name)
    }

    // ダイナミッククライアントで Pod の一覧を取得
    fmt.Println("ダイナミック")
    dclient := dynamic.NewForConfigOrDie(config)
    uslist, err := dclient.Resource(corev1.SchemeGroupVersion.WithResource("pods")).
        List(context.TODO(), metav1.ListOptions{})
    utilruntime.Must(err)
    for i, uspod := range uslist.Items {
        fmt.Printf("%v:%v/%v\n", i, uspod.GetNamespace(), uspod.GetName())
    }
}

で、とりあえず動かしてみる。対象は kind を使ってコントロールプレーン 1 ワーカー 3 で作ったばかりのクラスタだ。

$ ./compare -v 10 2> compare.log
普通
0:kube-system/coredns-668d6bf9bc-hnbp4
1:kube-system/coredns-668d6bf9bc-nl9nv
2:kube-system/etcd-kind-control-plane
3:kube-system/kindnet-5b7sq
4:kube-system/kindnet-ms6hh
5:kube-system/kindnet-nlcdp
6:kube-system/kindnet-wgw2c
7:kube-system/kube-apiserver-kind-control-plane
8:kube-system/kube-controller-manager-kind-control-plane
9:kube-system/kube-proxy-psm47
10:kube-system/kube-proxy-q2f5x
11:kube-system/kube-proxy-q2rxw
12:kube-system/kube-proxy-tk9vm
13:kube-system/kube-scheduler-kind-control-plane
14:local-path-storage/local-path-provisioner-58cc7856b6-wp72c
ダイナミック
0:kube-system/coredns-668d6bf9bc-hnbp4
1:kube-system/coredns-668d6bf9bc-nl9nv
2:kube-system/etcd-kind-control-plane
3:kube-system/kindnet-5b7sq
4:kube-system/kindnet-ms6hh
5:kube-system/kindnet-nlcdp
6:kube-system/kindnet-wgw2c
7:kube-system/kube-apiserver-kind-control-plane
8:kube-system/kube-controller-manager-kind-control-plane
9:kube-system/kube-proxy-psm47
10:kube-system/kube-proxy-q2f5x
11:kube-system/kube-proxy-q2rxw
12:kube-system/kube-proxy-tk9vm
13:kube-system/kube-scheduler-kind-control-plane
14:local-path-storage/local-path-provisioner-58cc7856b6-wp72c

それっぽく動いてる。Cilium 好きなので kube-proxy が動いてると新鮮だ。関係ないけど Cilium ってみんな何て呼んでるの?向こうの人は「スィリアム」って言ってるように聞こえるけどどうしても「しりうむ」って言っちゃうんだよね[5]

話が逸れた。klog の仕込みが効いてるのでコマンドラインで -v 10 を指定すると[6]標準エラー出力にログがアホ程出てくる。

さすがに全部載せるのは忍びないので抜粋で。

普通のクライアントの場合

ログ抜粋は以下のような感じ[7]

普通のクライアントのログ抜粋
I1225 23:51:05.206893 3041813 type.go:204] "Request Body" body=""
I1225 23:51:05.206958 3041813 round_trippers.go:473] curl -v -XGET  -H "Accept: application/vnd.kubernetes.protobuf,application/json" -H "User-Agent: compare/v0.0.0 (linux/amd64) kubernetes/$Format" 'https://127.0.0.1:39063/api/v1/pods'
I1225 23:51:05.207497 3041813 round_trippers.go:517] HTTP Trace: Dial to tcp:127.0.0.1:39063 succeed
I1225 23:51:05.216980 3041813 round_trippers.go:560] GET https://127.0.0.1:39063/api/v1/pods 200 OK in 10 milliseconds
I1225 23:51:05.217030 3041813 round_trippers.go:577] HTTP Statistics: DNSLookup 0 ms Dial 0 ms TLSHandshake 5 ms ServerProcessing 3 ms Duration 10 ms
I1225 23:51:05.217041 3041813 round_trippers.go:584] Response Headers:
I1225 23:51:05.217050 3041813 round_trippers.go:587]     Date: Tue, 24 Dec 2024 14:51:05 GMT
I1225 23:51:05.217056 3041813 round_trippers.go:587]     Audit-Id: 8e2812c1-621e-4cc0-b19b-7ac1bca889a9
I1225 23:51:05.217061 3041813 round_trippers.go:587]     Cache-Control: no-cache, private
I1225 23:51:05.217067 3041813 round_trippers.go:587]     Content-Type: application/vnd.kubernetes.protobuf
I1225 23:51:05.217072 3041813 round_trippers.go:587]     X-Kubernetes-Pf-Flowschema-Uid: b04eebb6-9c74-483a-814c-e9c2981daa67
I1225 23:51:05.217076 3041813 round_trippers.go:587]     X-Kubernetes-Pf-Prioritylevel-Uid: 8e295cb1-7c49-480a-9fa2-a8ad04ffff86
I1225 23:51:05.221630 3041813 type.go:204] "Response Body" body=<
        00000000  6b 38 73 00 0a 0d 0a 02  76 31 12 07 50 6f 64 4c  |k8s.....v1..PodL|
        00000010  69 73 74 12 fd de 04 0a  0c 0a 00 12 06 35 36 30  |ist..........560|
・・・
中略
・・・
        00012f80  30 2e 32 72 00 82 01 0c  0a 0a 31 37 32 2e 31 39  |0.2r......172.19|
        00012f90  2e 30 2e 32 1a 00 22 00                           |.0.2..".|
 >

ポイントは、

  1. リクエスト時に -H "Accept: application/vnd.kubernetes.protobuf,application/json" が付いてる。application/vnd.kubernetes.protobuf が先にあるので Protocol Buffers が優先[8]
  2. レスポンスヘッダも Content-Type: application/vnd.kubernetes.protobuf になってる。

と言うところだろうか。

で、肝心のサイズはと言うとレスポンスボディが 0x12f98 なので 77,720 バイトだ。

ダイナミッククライアントの場合

ログ抜粋は以下のような感じ。

ダイナミッククライアントのログ抜粋
I1225 23:51:05.224651 3041813 simple.go:280] "Request Body" body=""
I1225 23:51:05.224694 3041813 round_trippers.go:473] curl -v -XGET  -H "Accept: application/json" -H "User-Agent: compare/v0.0.0 (linux/amd64) kubernetes/$Format" 'https://127.0.0.1:39063/api/v1/pods'
I1225 23:51:05.228783 3041813 round_trippers.go:560] GET https://127.0.0.1:39063/api/v1/pods 200 OK in 4 milliseconds
I1225 23:51:05.228852 3041813 round_trippers.go:577] HTTP Statistics: GetConnection 0 ms ServerProcessing 3 ms Duration 4 ms
I1225 23:51:05.228863 3041813 round_trippers.go:584] Response Headers:
I1225 23:51:05.228871 3041813 round_trippers.go:587]     Date: Tue, 24 Dec 2024 14:51:05 GMT
I1225 23:51:05.228875 3041813 round_trippers.go:587]     Audit-Id: 3030598d-de93-4a81-b943-4430290101d6
I1225 23:51:05.228879 3041813 round_trippers.go:587]     Cache-Control: no-cache, private
I1225 23:51:05.228881 3041813 round_trippers.go:587]     Content-Type: application/json
I1225 23:51:05.228884 3041813 round_trippers.go:587]     X-Kubernetes-Pf-Flowschema-Uid: b04eebb6-9c74-483a-814c-e9c2981daa67
I1225 23:51:05.228887 3041813 round_trippers.go:587]     X-Kubernetes-Pf-Prioritylevel-Uid: 8e295cb1-7c49-480a-9fa2-a8ad04ffff86
I1225 23:51:05.229730 3041813 simple.go:280] "Response Body" body=<
        {"kind":"PodList","apiVersion":"v1","metadata":{"resourceVersion":"560443"},"items":[{"metadata":{"name":"coredns-668d6bf9bc-hnbp4", ・・・中略・・・,"qosClass":"BestEffort"}}]}
 >

こちらもポイントは、

  1. リクエスト時は -H "Accept: application/json" になっている。JSON オンリーだ。
  2. レスポンスヘッダも当然 Content-Type: application/json になってる。

と言うところだろうか。

で、肝心のサイズはと言うとレスポンスボディが普通のテキストで表示されるので分かりづらいが 113,114 バイトだ。何と 4 割 5 分増しだ。JSON の方がデフォルトだと考えても 3 割引だ。

カスタムリソースと Protocol Buffers と CBOR

通信量的にありがたい Protocol Buffers だが、残念ながらカスタムリソースではそもそも使用できない。これは考えてみれば当たり前で、kube-apiserver は起動時にカスタムリソースに関する情報を何も持っていないので Protocol Buffers でシリアライズ・デシリアライズすることができないからだ。

まぁ理論的には CRD を基に直接シリアライズ・デシリアライズすることも可能な気がしないでもないが、なかなか重い処理になりそうな気はするしそこまでやるモチベーションも無いのかもしれない。

じゃあデカいまま諦めましょうとなるかと言うと実はそんなことも無いらしく、CBOR と言うバイナリフォーマットを使おうぜ的な流れになっているようで、現在 CBORServingAndStorage と言うフィーチャーゲートが Alpha の状態だ。フィーチャーゲートの名前からも分るように、コイツを有効にすると通信だけじゃなく etcd への保存にも使われるようになる。

CBOR が使えるようになると原理的にはダイナミッククライアントでも恩恵を受けられると思うので、期待して待っていようと思う。

ちなみに、Protocol Buffers と CBOR の両方が使えたらどうなるのかと言うと、Protocol Buffers が優先になる。CBOR はあくまでも JSON の代替だ。

おわりに

ダイナミッククライアントだとレスポンスが思った以上にデカくなっていたことが分かった。これらはもちろんリクエストでも効くが、リクエストはせいぜい 1 データしか送らないのでそこまで大きな差にはならない気がする[9]

と言う訳で、普通のクライアントでできるのであればむやみやたらとダイナミッククライアントを使わない方が良さそうだ。

それでは、良い client-go ライフを!

脚注
  1. jq や yq で prettify しないと若干(?)厳しいが… ↩︎

  2. ちょっと前にみんな大好きだった XML ほどではないが… ↩︎

  3. 千や万もあったりするのかな? ↩︎

  4. いや、もちろん気付いてなかったわけではなくて様々な条件を判断した結果 JSON にしてたんだろうが… ↩︎

  5. async は「あしんく」だし queue は 「くえうえ」だし opaque は「おぱきゅー」。 ↩︎

  6. コード上で直接 flag.Set("v", "10") してもいいんだが、縁起ものなので flag.Parse() でコマンドライン引数にした。 ↩︎

  7. クリスマスイブに何やってんだオレ… ↩︎

  8. 関係ないけど何でログが curl なのかね?ログの設定も DebugCurlCommand だし。 ↩︎

  9. それとも更新リクエストが大量にあればやっぱり結構効いてくるのかな? ↩︎

Discussion