LXCで学ぶコンテナ入門 -軽量仮想化環境を実現する技術

第54回Linuxカーネルのコンテナ機能 ―cgroup v2から使うCPUの帯域幅制限(2)

前回は、CPUコントローラでの帯域幅制限がどのように行われるのかについて簡単に説明しました。

前回の最後に、説明のために挙げた例がかなり理想的な状況であると書きました。今回は、帯域幅制限の機能を詳しく見ていくために、前回よりはもう少し複雑な状況について説明していきましょう。

未使用クォータの返却

前回説明で示した例では、CPUは割り当てられたスライスをすべて使いきっていました。しかし、複数のCPUがある環境で、タスクが終了したり、I/O待ちなどにより、クォータが割り当たったCPUで実行可能なタスクがなくなった場合はどうなるでしょう?

図1 割り当てられたスライスが使われない場合
実行可能タスクがなくなった場合

図1のように、クォータプールに10msクォータが残っているとします。

  • ①でCPU1でCPUを使用するタスクから要求がありスライスが転送され、クォータの残りが5msとなりました
  • ②で再度CPU1でCPUを使用するタスクから要求がありスライスが転送され、クォータの残りがなくなりました
  • ③でCPU1のタスクは1msクォータを消費した時点で終了しました
  • ④でCPU2でCPUを使用するタスクから要求がありましたが、クォータプールにはもうクォータが残っていません

もし、図1のように一度割り当てられたスライスがCPUに残ったままであれば、cgroup全体としてはクォータを使い切っていないにもかかわらず、他のCPUから要求があった場合でも、その期間内ではタスクが実行できないことになります。

これでは、割り当てられたクォータを使い切れず、CPUが有効に使われません。CPUが空いているにもかかわらず、タスクが無駄に制限にかかり、システムで動作するタスクのパフォーマンスが悪化することになるでしょう。

そこで、上で述べたようなことが起こらないように、一定期間使われないクォータが残っている場合、そのクォータをクォータプールに返却します。

その様子を図2を使って説明していきましょう。

図2 クォータのプールへの返却
クォータのプールへの返却

これまでの例と同様に、cgroupに対しては20ミリ秒の制限値が設定されており、2つのCPUが存在しています。

  • ①でCPU1でCPUを使用するタスクから要求がありスライスが転送され、タスクは5ミリ秒間実行され、転送されたスライスを使いました
  • ②でCPU2でCPUを使用するタスクから要求がありスライスが転送され、タスクは5ミリ秒間実行され、転送されたスライスを使いました
  • ③でCPU1でCPUを使用するタスクから要求がありスライスが転送され、タスクは1ミリ秒間実行されました。この時点でCPU1にはクォータは4ミリ秒分残っています
  • CPU1は一定期間残っているクォータを使わなかったため、④で残っているクォータをクォータプールに返却します。このとき、返却するクォータは残っているクォータから1ミリ秒をのぞいた分、つまり3ミリ秒です
  • ⑤でCPU2でCPUを使用するタスクから要求がありスライスが転送され、タスクは5ミリ秒間実行され、転送されたスライスを使いました
  • ⑥でCPU2でCPUを使用するタスクからさらに要求があり、クォータプールに残っている3ミリ秒がCPU2に転送され、タスクは3ミリ秒実行され、転送されたスライスを使いました
  • ⑦でCPU2でCPUを使用するタスクから要求がありました。しかし、クォータプールにはもうスライスが残っていませんので、この期間ではタスクは実行できません

③のように、タスクがCPUに残っているクォータを全部使わないまま、実行可能なタスクがない状態が一定期間続いた場合は、クォータプールにクォータを返却します。クォータを返却することにより、図1で示したように、cgroup全体では制限に達していないにも関わらず、タスクが実行できずに、割り当てられたクォータを無駄に捨ててしまうことが防げます。

ここで、1ミリ秒だけ残すのは、グローバルロックでの競合を防ぐためとのことです[1]

そして、この1ミリ秒残ったクォータについては、cpu.maxで指定された期間を過ぎても有効なままです。つまり次の期間で使えます。

もし、期間が来たからといって、残っている1ミリ秒を捨ててしまうと、大量にCPUを搭載したホスト上でマルチスレッド化されたプロセスを動かす場合に、パフォーマンスが悪化するためです。たとえ1ミリ秒といえども、各CPUごとにクォータが残ったまま大量に放棄されるので、やはり与えられたクォータを十分に活かせないまま無駄に捨てることになります。

Memo前回と今回の記事で参考にしているIndeedエンジニアリング・ブログの記事は、この1ミリ秒が無駄に捨てられてしまい、パフォーマンスが悪化したため、その原因をつきとめ、1ミリ秒を捨てないようにして解決した、というお話です[2]
機能が元々意図していた仕様は、CPUに残った1ミリ秒は期間内だけで有効でした。ですので、期間が終わると捨てられる実装がされているはずでした。しかし、実際にはバグで1ミリ秒が捨てられておらず、現在と同じように期間を超えて1ミリ秒はCPUに割り当てられたままになっていました。そのバグを修正し、1ミリ秒をきちんと捨てるように修正したとたんに、大規模環境でCPUが有効利用されずに、期待に反する制限がかかってしまう問題が出たようです。
1ミリ秒がきちんと捨てられないバグを修正したコミット(正しい動きに修正する意図があるコミット)以来、CPUごとに1ミリ秒が期間とともに捨てられ、パフォーマンスが悪化したようです。ブログ中では88コアのマシンを例に解説されています。これくらいCPUコア数が多いと、1ミリ秒といえどもCPUごとに1ミリ秒捨てられるため、大量にCPUの使用制限が発生してしまう可能性があります。実際にパフォーマンスが劣化したようです。

この1ミリ秒を返さない代わりに、1ミリ秒分のクォータについては次の期間で使えるようにすることで、前の期間に他のCPUで使えなくて無駄にした分を穴埋めするという感じでしょうか。そもそもこの1ミリ秒は、もともとcgroupとCPUに割り当たったクォータであり、余計に割り当てているわけではありません。CPUをまたいでは使えませんが、マルチスレッドのプロセスであれば、次の期間にそのCPU上でタスクが実行される可能性は高いでしょうから、トータルで見るとパフォーマンスが劣化しなくなるようです。

この無効にならずに、次の期間でも使用できるクォータに対して、バースト(burst)という言葉が使われます。

ここまでで説明した1ミリ秒は、sysctlなどでは変更できません。カーネルのコード上でmin_cfs_rq_runtimeという変数で定義されています。

kernel/sched/fair.c
/* a cfs_rq won't donate quota below this amount */
static const u64 min_cfs_rq_runtime = 1 * NSEC_PER_MSEC;

この1ミリ秒をのぞいた分を返却する処理は常に行われるわけではありません。次の場合は返却されません[3]

  • 返却分をクォータプールに返しても、クォータプールの残りがスライス(sysctlパラメーターのsched_cfs_bandwidth_slice_usより小さい場合。返却後のクォータプールにスライスより小さいクォータしか残っていない場合、スライスが割り当てできないためだと思われます
  • 期間が終了する間際は返却されません。返却しても使う時間が残っていないためと思われます

以上で説明したように、CPUに割り当てられたあとに使わなかったクォータは、クォータプールに返却することで、無駄に使われないクォータが発生することを防げるようになっています。また、返却されない1ミリ秒のクォータについては、次の期間以降に使えるため、CPUを有効に使えます。この返却されないクォータは、割り当てられたCPUでのみ使えます。他のCPUに転送することはできません。

突発的にCPUを使用するタスクへのCPU時間の割り当て

ここからは少し話題を変えて、CPUを使用する期間と使用しない期間がはっきりと分かれるタスクに対する制限値の設定を考えましょう。

常にある程度一定のCPU時間を使用するタスクの場合、図3のように、タスクに合った期間と制限値を設定することで、性能の悪化を防げます。ここで、赤は制限値を、緑は実際にタスクが実行されている時間を示しています。赤の範囲内に緑が収まるように設定しています。

図3 一定のCPU時間を使用するタスクに対する制限値の設定
一定のCPU時間を使用するタスク

このように、多少の使用時間に差はあるものの、常時一定で負荷がかかっているタスクの場合は、タスクの実行を邪魔しない程度の制限値に調整できます。長い目(長い時間)で見ると、他のタスクに対して、不当に長い時間CPUを専有することはないでしょう。

ところが図4のような、普段はほとんどCPUを使用しないけれども、CPUの使用が突発的に増えるタスクがあった場合はどうでしょうか? 例えば何らかの入出力を待つことが多いタスクです。

図4 突発的にCPUの使用が増えるタスク
突発的にCPUの使用が増えるタスク

このようなタスクの実行を邪魔しないように図4の1)のように設定することが考えれます。この図で表した時間内では問題ないように見えます。しかし、実際は制限値が多めに設定されているので、長い目で見たとき、他の時間帯で全体的に他のタスクより多くCPU時間を使ってしまう可能性が考えられます。ホスト上のタスクに公平にCPUを割り当てたいのに、特定の時間帯に、特定のタスクを実行するだけのために、CPUの割り当てが不公平になる可能性があります。ホストの管理者として、割り当てが不公平になることは避けたい場合があるでしょう。

では、図4の2)のように、他のタスクとのバランスを取って、タスクの実行には不十分な制限値を設定するとどうでしょうか。2)の緑の✕部分は制限にかかってCPUを使用できず、後の期間に回された分です。

このような場合、他のタスクはバランスよくCPUが割り当たるため問題がないかもしれませんが、このタスクはレイテンシが悪化します。もし、レイテンシがタスクにとって重要な場合は問題です。

制限値だけでなく期間も長くして、割り当たるCPU時間の割合を増やすことは考えられます。しかし、期間を伸ばすと、制限にかかった場合に次の期間を待つことになります。そうなるとより長く待つことになり、やはりレイテンシは悪くなる可能性があり、問題の解決にはなりません。

しかも、普段は全くCPUを使っていないにも関わらず、CPUを使い始めたときだけ制限がかかってしまうのは、長い時間で見たときに公平にCPUを割り当てているとは言えません。

バースト機能

先に説明したとおり、CPUに割り当たったクォータは、使われない場合クォータプールに返却されます。そして、返却する際にCPUに割り当たったクォータのうち、1ミリ秒だけはCPUに残り、使用せずに残った1ミリ秒のクォータは期間を超えて残りました。このバースト機能により、cgroupに割り当たったクォータが有効利用され、タスクのパフォーマンス悪化を防ぐ一定の効果が出ました。

ただ、この機能はCPUを超えてバーストされることはありません。そして上限は1ミリ秒です。この動きに合ったワークロードの場合は効果があるでしょうし、効果がないワークロードもあるでしょう。

図4の2)の場合、他のタスクとの公平性から制限値を設定し、CPUを使用した300ミリ秒以降は制限にかかり、CPUの使用が制限されました。しかし、300ミリ秒まではCPUを使用しておらず、全体的に見ると、例えば図3のように常時CPUを使っているタスクに比べると、CPUをあまり使っていないと言えるでしょう。公平性のために制限値を設定したのに、自身は公平な扱いを受けていません。

そこで、図4の2)の0〜300ミリ秒間使わなかったクォータを貯めておいて、あとで制限に引っかかり、CPUを使えなかった期間に繰り越せるようにする機能が実装されました。この機能が、バースト機能です。

先に説明した1ミリ秒のバーストとは違い、CPUに割り当てる前のクォータプールで貯めておけるため、特定のCPUにひもづくことはなく、単純に前の期間に使わなかったクォータを、次の期間にcgroup内で自由に使えます。

制限なくバーストできると、システム全体で見たときにバーストしたタスクがCPUを使いすぎる可能性があるため、このバーストできる時間には制限があります。バーストできる時間はcpu.maxで設定した制限値より小さい時間を設定します。

図5 バースト機能を使ったレイテンシの改善
バースト機能

図5の3)のように、0〜300ミリ秒の間に使わなかったクォータを、バーストできる上限まで貯めておき、あとで制限値に引っかかり実行できないときに回せます。図5の2)のようにバースト機能を使わないときに比べてレイテンシが改善しています。

表1 バースト機能を設定する際に使用するファイル
パラメータ名 機能 操作
cpu.max.burst バーストできる(貯めておける)上限値。デフォルトは0で、バースト機能を使わない場合と同じ動き。cpu.maxで設定した制限値より大きな値は設定できない。単位はマイクロ秒 読み書き

このバースト機能はcgroup v1でも利用できます。cgroup v1の場合はcpu.cfs_burst_usファイルを使用します。

設定の様子を見てみましょう。

$ sudo mkdir /sys/fs/cgroup/test01
(cgroupの作成)
$ echo "50000 100000" | sudo tee /sys/fs/cgroup/test01/cpu.max
(制限値として50000を、期間として100000を設定)
50000 100000
$ echo "50000" | sudo tee /sys/fs/cgroup/test01/cpu.max.burst 
50000 (バースト上限として50000を設定)
$ cat /sys/fs/cgroup/test01/cpu.max.burst
50000 (設定されている)
$ echo "60000" | sudo tee /sys/fs/cgroup/test01/cpu.max.burst 
60000
tee: /sys/fs/cgroup/test01/cpu.max.burst: Invalid argument
(制限値より大きな値を設定しようとするとエラーになる)

上の例では、期間として100ミリ秒、制限値として50ミリ秒を設定し、最初バーストの上限値として50ミリ秒を設定しました。その後、制限値より大きな値である60ミリ秒を設定しようとしたところ、エラーになりました。

もちろん、CPU資源は有限ですので、CPUの使用率が高いシステムでバースト機能を使うと、システム全体でCPU資源が不足する可能性はあります。しかし、CPUに余裕があり、システム上にcgroupが多数存在する場合は問題が起こる可能性は限定的であることが示されているようです[4]

まとめ

今回は、CPUコントローラの帯域幅制限で、CPUを有効に使用するための仕組みを紹介しました。

ひとつは、CPUに割り当てたものの、使われなかったクォータを返却する仕組みです。その際に、返却されないクォータがあり、そこでもCPUを有効に利用するための実装がされていることを紹介しました。

もうひとつは、突発的にCPUを使用するタスクに対応するためのバースト機能について紹介しました。使わなかったクォータを貯めておいて、制限にかかったときに使用する仕組みでした。

今回で、いったんCPUコントローラの話題は終わりです。

お知らせ:技術書典

筆者は2022年から、この連載をもとにした、Linuxコンテナで使われるカーネル技術を紹介する同人本を作り、技術書を対象にした同人誌即売会である技術書典に出展しています。

2023年11月11日(土)から11月26日(日)まで開催される技術書典15にもlxc-jpというサークルから出展し、本連載をもとに書いた3冊の本を販売します。

11月12日(日)のオフライン開催にも出展予定ですので、来られる方はぜひブースにお越しください。そして、⁠連載読んでます」と声をかけていただいたり、連載の感想などをお聞かせくだされば、筆者がとても喜びます(^^)。

おすすめ記事

記事・ニュース一覧