2 ポイント 投稿者 GN⁺ 2023-11-09 | 1件のコメント | WhatsAppで共有
  • コンテナにCPU制限をかけても、Goランタイムはデフォルトではそれを認識しないため、ホスト全体のコア数を基準にスレッドを作成し、レイテンシを増やす可能性がある
  • GoのGCはほとんどの場合アプリケーションと並行して動作するが、Sweep TerminationとMark Terminationでは、すべてのゴルーチンを停止する**stop-the-world(STW)**区間が必要になる
  • LinuxのCFSはコア数を1秒あたりのCPU時間に分割して配分し、--cpus=4はコンテナに毎秒4秒分のCPU時間を与える方式である
  • 16コアのホストで4コア制限のコンテナを実行すると、Goが16個のOSスレッドにゴルーチンを載せられるため、CPU quotaを使い切った後にSTWが長くなる可能性がある
  • GOMAXPROCSをコンテナのCPU制限に合わせると、例ではGCサイクルが2.5ms未満から1ms未満に短縮され、STWは約26μsまで減少した

コンテナのCPU制限とGoランタイムの不一致

  • コンテナでGoアプリケーションを実行する際、CPU制限はホストCPUをすべて消費しないようにするための仕組みである
  • 問題は、GoランタイムがコンテナのCPU制限をデフォルトでは認識しない点にある
  • この不一致により、ランタイムは実際のquotaより多くのCPUを使えると判断し、高いレイテンシにつながる可能性がある

Go GCでSTWが発生する箇所

  • Goのガベージコレクタは、ほとんどの時間アプリケーションと並行実行される
  • ただしGCの過程には、すべてのゴルーチンを停止しなければならない区間が2つある
    • Mark Phaseの前にwrite barrierを適用するために停止する段階がSweep Terminationである
    • Mark Phaseの後にwrite barrierを取り除くために再び停止する段階がMark Terminationである
  • STW区間は通常、数十マイクロ秒程度である
  • 例のアプリケーションはメモリを大量に割り当てるシンプルなWebアプリケーションで、ソースコードはgo-cfs-blogにある
  • コンテナは4 CPU制限で実行される
docker run --cpus=4 -p 8080:8080 $(ko build -L main.go)
  • runtime/traceパッケージでtraceを収集し、go tool traceで分析できる
  • この実行ではGCサイクルは2.5ms未満だったが、そのうちほぼ10%がSTW区間だった
  • レイテンシに敏感なアプリケーションでは、この程度の割合でも問題になり得る

DockerのCPU制限とLinux CFSの動作方式

  • Dockerの--cpus CPU制限はhard limitである
  • --cpu-sharesも設定できるが、ホストがCPU制約を受けている場合にのみ強制される
    • ホストに余裕があれば、コンテナは割り当てられたCPUコアより多く使用できる
    • ホストが制約状態になると、アプリケーションは制限を受ける
  • LinuxのCompletely Fair Scheduler(CFS)はLinux 2.6.23で導入され、Linux 6.6以前までデフォルトスケジューラだった
  • CFSはproportional share schedulerであり、プロセスのweightを使用可能なCPUコア数に比例させる
    • 4個のCPUコアを使用できるプロセスのweightは4である
    • 2個のCPUコアを使用できるプロセスのweightは2である
  • CFSはCPU時間を細かく分けて配分する
    • 4コアシステムは毎秒4秒分のCPU時間を配分できる
    • コンテナにCPUコア数を割り当てることは、Linuxスケジューラにn CPU分の時間を要求するのと同じである
    • --cpus=4は、コンテナが毎秒4秒分のCPU時間を受け取ることを意味する

なぜSTWが長くなるのか

  • Goランタイムは起動時にCPUコアごとに1つのOSスレッドを作成する
  • 16コアのマシンでは、CGroupのCPU制限に関係なく16個のOSスレッドを作成できる
  • ランタイムはこれらのOSスレッド上にゴルーチンをスケジュールする
  • コンテナのCPU制限が4コアであっても、Goは16個のOSスレッド全体にゴルーチンを載せることができる
  • この状態では、ランタイムは毎秒16秒分のCPU時間を使えると期待することになる
  • STW時間が長くなるのは、Linuxスケジューラが再び実行してくれるのを待っているスレッド上のゴルーチンまで、すべて停止しなければならないためである
  • コンテナがCPU quotaをすでに使い切った後は、それらのスレッドはスケジュールされない

GOMAXPROCSをCPU quotaに合わせる

  • GoではGOMAXPROCS環境変数で、ランタイムが使用するCPUスレッド数を制限できる
  • CPU quotaが4のコンテナでは、GOMAXPROCS=4もあわせて指定する
docker run --cpus=4 -e GOMAXPROCS=4 -p 8080:8080 $(ko build -L main.go)
  • 同じアプリケーションと同じ負荷でGOMAXPROCSをCPU quotaに合わせると、GC時間が短くなった
  • traceではGCサイクルが1ms未満に短縮され、STW区間は26μsだった
  • これはGOMAXPROCS制限がない場合のSTW時間と比べて約1/10の水準である
  • GOMAXPROCSは、コンテナが使用できるCPUコア数に設定すべきである
    • fractional CPUを割り当てる場合は切り捨てる
    • 1 CPU未満を割り当てる場合は切り上げる
    • 計算式はGOMAXPROCS=max(1, floor(CPUs))である
  • Uberのautomaxprocsは、コンテナのcgroupsからこの値を自動計算するオープンソースライブラリである
  • Goランタイムでこれを標準サポートするためのGitHub Issueが開かれている

コンテナ化されたGoサービスで確認すべき点

  • CPU制限を設定するだけでは不十分で、Goランタイムがその制限を反映するようにGOMAXPROCSも合わせる必要がある
  • 自分で計算するのが難しければ、automaxprocsのようなライブラリでcgroupsベースの値を自動設定できる
  • レイテンシに敏感なGoサービスでは、GC traceでSTW時間を確認し、CPU quotaとランタイム設定が食い違っていないか点検すべきである

1件のコメント

 
GN⁺ 2023-11-09
Hacker Newsのコメント
  • 複数の言語で共通して見られる問題は、アプリケーションが /proc/cpuinfo を見てマシンのコア数を検出するという点である。
    しかし Docker コンテナや他のコンテナ技術の中では、このファイルはコンテナホストとまったく同じように見え、コンテナに実際にいくつ割り当てられているかに関係なく、すべてのコアを列挙する。
    しばらくの間、Docker がジョブに割り当てられた「Docker CPU」だけを列挙する偽の /proc/cpuinfo を作れないかと考えていたが、考え直してみると、いくつもの理由でうまくいかなさそうだ。

    • quota ベースの制限を使う場合、コンテナはホストのすべての CPU コアを使える。
      制限されるのは、それらのコアを どれだけ長く使えるか である。
      例外もあり、ドキュメントはこちら: https://kubernetes.io/docs/tasks/administer-cluster/cpu-mana...
    • nproc だけを使い、他のコンテナでも bundle install -j $(nproc) のように使われているのを見た。
      これは CPU 割り当て を尊重するので、求めていた機能を提供する。
      任意のアプリケーションが可能なときに nproc を使うのかは分からない。
      「現在のプロセスで利用可能な処理ユニット数を出力する。これはオンラインのプロセッサ数より少ないことがある。この情報を取得できない場合は、インストールされているプロセッサ数を出力する」
      https://www.gnu.org/software/coreutils/manual/html_node/npro...
      https://www.flamingspork.com/blog/2020/11/25/why-you-should-...
    • Go はそうしていない。
      Go は起動時点で CPU マスク の数を見て、その後は再び見ない。
      Kubernetes ではプロセス実行中に見える CPU が変わることがあるため、問題になる。
    • 偽の /proc/cpuinfo はすでに存在する: https://github.com/lxc/lxcfs
      lxcfs は cgroup の値を推論して /proc をエミュレートする FUSE ファイルシステムで、アプリケーションやライブラリがコンテナ内で動いていることを意識しなくても済むようにする。
      たとえば /proc/uptime はホストではなくコンテナの稼働時間を反映すべきであり、/proc/cpuinfo は cpu.max と cpuset.cpus の組み合わせのうち、より低い上限を CPU 数として反映する。
      CPU 数の推論は sched_getaffinity システムコールでも可能で、この方式は /proc/cpuinfo に依存しない。
      そのため、使っているライブラリによっては厄介なことになる可能性がある。
    • これを見ると、コンテナは 雑な抽象化 であり、VMware は機会を逃したという結論になる。
  • この説明は微妙に誤っている。
    Docker の観点では、CFS cgroup 拡張には調整可能なノブがいくつかある: cfs_quota_us, cfs_period_us(一般的なデフォルトは 1 秒ではなく 100ms)、そして shares である。
    shares を設定すると重み付き比例スケジューリングが適用されるが、競合があるときにしか意味がない。
    最初の 2 つの値は厳格な quota を強制する。
    Docker の --cpu フラグの代わりに --cpu-shares を使って、たいてい役に立たない quota 強制を避けるほうがよい。
    Linux の文書によれば、cpu.shares は同じ階層内の各グループの重みであり、cpu.cfs_period_us は帯域幅判定のためのスケジューラ周期で、デフォルト値は 100000us または 100ms である。
    cpu.cfs_quota_us は各 cfs_period_us の間に現在のグループが実行できる最大時間であり、この値はシステム全体の CPU にまたがる合算時間なので、CPU 2 個をフルに使わせるには cfs_period_us の 2 倍に設定する必要がある。

    • 「Docker の --cpu フラグを使わず代わりに…」という表現は、これといった条件もなく強すぎる。
      決して「たいてい役に立たない」とは言えない。
      shares と quota は異なるユースケース向けなので、自分のユースケースを理解し、それに応じて選べばよい。
    • 1 つ注意点として、--cpu を使うとアプリケーションがそれを検出できるということがある。
      おそらく cpuset を使うためだと思われる。
      quota を使うと検出できないため、必要以上に多くのスレッドが作られる可能性が高い。
    • ブログの筆者です。フィードバックありがとう。
      この部分をもっと明確にしてみます。
      症状はこのように現れると考えていますが、表現はもっとはっきりさせる必要がありそうです。
    • Kubernetes を使う人は、このような設定を直接調整したり変更したりはしない。
      アプリケーションが正しく動作しなければならない。
  • CPU limits の代わりに CPU reservations を使えば、このような調整は不要: https://home.robusta.dev/blog/stop-using-cpu-limits
    CPU reservations も実質的には limits だが、暗黙の上限であり、同時に保証として宣言されるもの
    なので Go ランタイムには利用可能なすべての CPU を使わせておき、CPU 競合が発生したら Linux スケジューラが宣言された reservations に従って制限すればよい

    • limits を設定する理由は、ある Pod が別の Pod に影響することを恐れているからではない
      保証されていない 余剰 CPU を使える状態に慣れたくないから
      ノードに他の Pod が増えていくと、ついさっきまで問題なく動いていた Pod が突然遅くなることがある
      limits を使えば同じ動作をシミュレートでき、適切なキャパシティプランニングで備えられる
      唯一の方法ではないが、最も単純な方法ではある
    • 128 コア構成でいくつか動かしているが、CPU limits は request よりかなり高く設定しつつ、何かが暴走しないように依然として設定している
      この議論には興味があるが、リンク先の記事は CPU をすべての Pod に保証するには limit が必要だと人々が考えている、という点しか扱っていないように見える
    • Kubernetes コミュニティでは、この議論を隔週でやっているような感じ
      この記事自体が間違っているわけではなく、全体としてはコンテンツマーケティング寄りだが、断定が広すぎて limits を設定する妥当な理由をいくつも無視している
      同じところの記事には単純に間違っているものもある: https://home.robusta.dev/blog/containers-dont-use-chroot
      わずかな利益のためにバースト容量をすべて使い切るワークロードもあるし、決められた時間内に終わればよい cronjob より HTTP サーバーのバースト容量を優先すべき場合もある
      開発者がアプリの要求量が増えたのに requests を更新せず、余剰 CPU 時間が突然足りなくなって障害が発生したこともあった
    • reservations は limits ではなく、最低保証 CPU 使用量の制約 である
      理論上は最低保証されるリソースだが、同じホストで忙しいコンテナ同士が一緒に動くと、テールレイテンシと平均レイテンシが異常に増加することがある
      4 コアの EC2 インスタンスでは、CPU 使用率 50% のときと 90% のときでレイテンシはかなり違う
      reservations でも同様で、各コンテナが自分の reservation を保証されていても、同じホスト上の他の多忙なプロセスのせいで相対的な CPU 使用率が非常に高くなる
    • 興味深いが、これはメモリには当てはまらないのでは?
      OOMKiller に殺される可能性がある
      CPU とメモリの limits の両方がないと Guaranteed QoS クラスを得られないので、ある時点で Pod が退避させられる可能性もある
  • コンテナと cgroup を使っていて、CFS スケジューラ には何度もやられた
    新しいスケジューラが何なのか気になる
    ここで本番クラスターに使ったことがある人はいる?
    もうほぼ 20 年もコアを無駄にしている: https://people.ece.ubc.ca/sasha/papers/eurosys16-final29.pdf

    • ここでの問題はスケジューラではない
      コンテナにリソース制限がかかっているのに、コンテナ内プロセスである Go が利用可能な並列性の量を計算するとき、その制限に使われている OS 機能を確認しないことが問題
    • https://kernelnewbies.org/Linux_6.6#New_task_scheduler:_EEVD...
  • GOMAXPROCS に加えて、最近の Go リリースには GOMEMLIMIT もある
    https://github.com/KimMachineGun/automemlimit を使えば、https://github.com/uber-go/automaxprocs と同様にこの上限を自動設定できる

  • 去年、前職でプラットフォームエンジニアとしてオンプレミス Kubernetes クラスターと CI/CD パイプライン基盤を管理していたときにこれを見つけた
    実 CPU と割り当て CPU の不一致が、とくに CPU スロットリング のような問題を引き起こすのは見ていたが、クラスター内のすべての Go デプロイに効くスケーラブルな解決策を見つけるのは難しかった
    何百ものプロジェクトの全開発者に autoprocs 依存関係を入れさせるのは選択肢ではなかった
    代案として、すべての CPU request/limit を整数にそろえ、Kubernetes マニフェストでその値を GOMAXPROCS 環境変数に入れる方法も煩雑で現実的ではなかった
    結局、多数のスレッドを使う一部のアプリケーションにだけ GOMAXPROCS 変数を適用して改善を得たが、プロジェクトごとに CPU 要求量の変動が大きいマイクロサービスアーキテクチャの全デプロイに適用可能な解決策はまだ見つけられていない

    • ここに唯一の正解はない
      GOMAXPROCS を制限すると、プロセスにトラフィックが集中し、キューイングが単純なときに 深刻なレイテンシ問題 が起きることがある
      平均的にプロセスがどれだけの時間を使うかという考えとは関係なく、ハードウェアが返す値で GOMAXPROCS を設定するのが実際には最善
    • すべての Pod コンテナに GOMAXPROCS を注入 する mutating webhook を定義できる
  • Docker や Go に詳しくない立場からすると、この挙動が意図されたものなのか気になる
    Go チームは CGroups limit を認識するようにできるのか?
    他のランタイムも同じように動くのか?

    • .NET もこの問題に対処しなければならなかったはずで、Java も問題があった、あるいは今もあると記憶している
      それとも containerd のようなランタイムのことを言っていたのか?
    • JVM でも同じ問題があった
      Scala でのことだった
  • 一時停止をより短くする GC 手法もある
    たとえば、一時停止中にやるべき処理を並行して実行したうえで、安全点で再度繰り返す方式だ
    狙いは、並行処理のおかげで安全点での作業が「やることがない」という単純な確認に変わることだ
    作業を二重にすると、GC のスループットは悪化する可能性がある

  • この記事ではコンテナについて述べているが、問題は Go が期待より少ない CPU 時間にしかアクセスできないときには常に起こるように思える
    CPU を使う別のプロセスがあるシステムで Go を実行しても、同じことが起きるのではないか?
    さらに、Go プログラムを 2 つ同時に実行するだけでもそうならないだろうか?