Diary

Diary

日々学んだことをアウトプットする場として初めてみました

k8s の cpu limit をつけるとアプリが 10 倍遅くなった

k8s の cpu limit をつけることで、実行時間が見事に遅くなってしまったのでその成果発表です。
参考記事

[目次]

背景

gin で動いてる Go のサーバーで、json のレスポンスを返すときに c.JSON を使っているのですが、ec2 で動いてる eks の node 上にデプロイされると応答が極端に遅くなるエンドポイントがありました。

トレース情報を見てみると、構造体を json に変換して返すだけの部分で 10 秒ほどかかっていました。
(22 MB ほどのデータ量であり、c.JSON メソッドはローカルで動かすと 600 ms 程で終わるのですが、k8s にデプロイされた状態だと 10 s 以上かかってた!)

結論からいうと、CPU limit を全 pod に入れており、それが原因で遅くなったのではないかと推測しています。

調べたこと

まずは pod の cpu, memory を調べてみました。

pod には以下の値が設定されており、リクエスト時のリソースも limit を超えてなさそうだったので一旦スルーしました。
(これが良くなかった。なんで limit まで使ってるように見えないんだろ。)

resources:
 requests:
   cpu: "16m"
   memory: "640Mi"
 limits:
   cpu: "128m"
   memory: "896Mi"

c.JSON は内部的に Go 標準の json ライブラリ(encoding/json)を使っているのですが、シンプルにこれが遅いのではないかとも思い、サードパーティjson ライブラリも導入してみました。
easyjson を導入すると、応答時間は 1/10 ほどになり、かなり改善が見込まれてしまいました。
(希望が見えたが故に、進む方向を間違えかけた時もありました。)


次に、pprof などを開発環境に差し込み、各メソッドの時間・CPU・メモリなどを調べてみました。

上で 10s 以上 json の加工・レスポンスにかかっていた部分も、pprof の出力では 300ms ほどしか表示されておらず、何らかの理由でリソースがうまいこと割り当てられてないんじゃないかと想像しました。


そこで cpu limit によるスケジューラの原理を知り、request, limit を変化させて実験させてみました。

一番右の列は『pprof での出力で確認した時間 / 実際に JSON の返却にかかった値』を表示しており、実際にコンピューティングリソースが割り当てられた時間の割合だと考えています。 おおよそ limit に比例して割合が伸びており『limit により CPU の割り当てが小さくなり、アイドル時間が不当に増えた』という仮説は正しそうです。

limit limit/1cpu (%) pprof での出力で確認した時間 / 実際に JSON の返却にかかった値 (%)
128m 12.8 10.6
256m 25.6 25.2
512m 51.2 48.3
1 100 95

最終的に limit を 1 core にすることで『pprof での出力が 1.32s』『実際にかかった時間が 1.39s』となり、謎だった差分がほぼなくなりました!

また、今回は cpu limit 128m で設定していたため 1000/128 = 7.8125 倍ほどレスポンスが遅くなってしまっていた可能性があります。

外部 api 呼び出しとか io バウンドな処理がメインなエンドポイントではそんな問題にならなく、重い計算とか cpu バウンドな処理にはモロに効いてきそうかなと思ってます。

k8s が CPU を割り当てる方法

内部ではデフォルトで CFS スケジューラが --cpu-cfs-quota-period duration などの値を参照して pod に割り当てるコンピューティングリソースのスケジュールをしてるみたいですが、そんなことは知りません。
もっと概念的でわかりやすい説明が欲しいです。

そもそも 100m cpu ってなんでしょうか?
1 cpu ならまだしも小数点ってナンダヨ

その答えは cgroup にありそうですが、概念的には以下の図で説明されます。

出典: スロットリング解除: クラウドにおける CPU の制限の修正

cpu core の稼働のうち、どれだけの時間その pod が稼働できるかの割合が limit で決まっています(デフォルトでは 100ms を基準として設定される)。

つまり、limit が 100m core cpu の pod は 100ms のうち 100/1000 の 10ms しか動かないことになります!
つまりのつまり、アプリケーションは10倍遅くなります!

マルチコアの場合

CPU がマルチコアの場合も、基本の考えは同じです。 limit に応じて、どのくらいの割合で pod が専有できるかが決まります。

全てのコアに分配される可能性があるため、シングルスレッドで動くようなものに対しては、無駄なコアが生じてしまう可能性があります(スロットルの発生)。 そのため、1cpu を越さない範囲なのであれば、なるべく 1プロセスが動く cpu は1つに絞った方が効率が良いことになります。

Go のコンテナで動かしてるんですが

この辺の Go に関する情報は Go100Tips にも書いてあるので、気になる方はぜひ読んでみてください。

Go言語 100Tips ありがちなミスを把握し、実装を最適化する (impress top gear)

新品価格
¥3,960から
(2023/10/29 20:56時点)

Go では GOMAXPROCS の環境変数や runtime.GOMAXPROCS で稼働しうる CPU の上限を設定できますが、残念ながら現在のところ CFS を元に自動で値が変わることはありません( https://github.com/golang/go/issues/33803 )。

そのため、automaxprocs のようなライブラリを導入し、pod を適切な CPU 数で動かしてあげる必要が出てきます。

今回遅くなったアプリに入れたところ、導入前は GOMAXPROCS が 4 だった(node が乗ってる ec2 は r5.xlarge であり、その論理コア数 4 と一緒)のが導入後は 1 になりました。