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

第64回LinuxカーネルのPSI機能(1)

今回から、LinuxカーネルのPSI機能について紹介していきます。

この連載では、Linuxカーネルに実装されているコンテナ関連の機能を紹介してきました。今回から紹介するPSI機能自体は、コンテナ専用の機能ではありません。しかし、cgroup v2と連携でき、cgroupfs内に関連するファイルが出現しますので、コンテナ環境でも役に立つ機能でしょう。

今回は、このPSI機能の概要がつかめるように説明していきます。

今回の実行例は、特に断りのない限りUbuntu 26.04上で実行しました。

PSI

PSIは"Pressure Stall Information"の略で、CPU、メモリ、I/Oという主要なリソースにかかる負荷の増大を、その兆候が現れはじめた段階で把握するための仕組みです。4.20カーネルで導入されました。

PSIで得られる情報は、システムが次のような状態になり、タスクがリソースを待機している時間の割合です。

  • リソースが不足し、タスクの実行が停止したり遅延したりしている状態
  • タスクがリソース待ちの状態になっており、実行が停止したり遅延したりしている状態

このような状態をストール(stall)と呼び、ストールしている割合や時間をPSIとして取得します。

それぞれのリソースでは、表1のような状態がストールに相当するでしょう。

表1 各リソースでストールの原因となる状態
リソース 各リソースでストールの原因となる状態
CPU 実行可能状態のタスクがCPU時間を待っている状態
メモリ メモリ割り当てに失敗したり、スワップインを待ったりしている状態など
I/O ストレージからのI/O完了を待っている状態

PSIを使うと、リソース不足に関する情報を得られます。この情報をカーネルから得られる他の情報やユーザー空間のツールと組み合わせれば、リソース不足を細かく検出したり予測したりして、問題にすばやく対応できるようになるでしょう。

また、cgroup v2と連携することで、コンテナ環境や、ワークロードごとにcgroupが分かれているような場合に、より細かくリソースを監視できます。

PSI機能は、4.20カーネルで導入されたあとも、表2のように機能が追加されています。各バージョンで追加された機能は、関連する機能を説明する際に説明します。

表2 PSI機能の進化
カーネルバージョン 変更点
4.20 PSI導入(システム全体+cgroupごと)
5.2 しきい値超過モニタリング機能追加
5.13 cgroupごとのcpuにfull導入(副作用でシステム全体にもfullが表示)
5.13 しきい値超過モニタリングがCAP_SYS_RESOURCEを持つユーザに許可されるようになった
5.14 cgroupごとのPSI機能を起動パラメータで制御可能になった
5.19 システム全体のCPUのPSIで、fullを常に0にする修正
6.1 割り込み処理(IRQ/SOFTIRQ)の計測を追加、cgroupごとのPSI機能をオン・オフするファイルを追加
6.4 しきい値超過モニタリングが非特権ユーザにも許可されるようになった

PSIの算出

PSIはファイルから取得します。PSIの値が格納されたファイルの内容は次のようなフォーマットです。

some avg10=0.00 avg60=0.00 avg300=0.00 total=0
full avg10=0.00 avg60=0.00 avg300=0.00 total=0

このように、somefullという2行があります。

この2項目はそれぞれ次の内容を表しています。

  • some: 対象となるリソースにおいて、少なくとも1つのタスクがリソース不足のためストールした時間の割合
  • full: 対象となるリソースにおいて、アイドル状態ではないすべてのタスクが同時にリソース不足のためストールした時間の割合

PSIファイルを見ると、somefullについてそれぞれいくつかの値があります。これらの値は、それぞれ単位時間の中でどれだけタスクがストールしていたかを表します。

avg10は10秒、avg60は60秒、avg300は300秒の間で何%ストールしていたかを表しています。totalは合計のストール時間で、単位はマイクロ秒です。

これらの値は2秒ごとに集計されますので、それより短い周期でこれらのファイルを読む意味はありません[1]

この10秒、60秒、300秒のそれぞれに表示される値の計算方法を見てみましょう。

図1は、システム上で2つのタスクのみが並行して実行されている様子を表しています。このうち、"Task A"は60秒間ストールすることなく実行されましたが、"Task B"はリソース不足のため30秒ストールしました。

このとき、"Task B"だけがストールしたので、someが50%になります。"Task A"はストールすることなく実行されていますので、fullは0%です。

図1 一部のタスクだけがストールした場合のPSIの値
一部のタスクだけがストールした場合のPSIの値

図2は、"Task B"が図1と同様に30秒間ストールし、そのうち30秒のうち10秒間は"Task A"もストールした様子を表しています。つまり、60秒の間にすべてのタスクが10秒間ストールしたということです。

このとき、someは図1と同様に50%となります。fullは"Task A"と"Task B"両方がストールした時間の割合ですので、16.66%(10/60)となります。

図2 すべてのタスクがストールした場合のPSIの値
すべてのタスクがストールした場合のPSIの値

図1が示すのは、一部のタスクについて実行時間が延びる可能性がある状態です。それに対して、図2のようにfullの値が0でない場合は、システム全体でリソース不足が起こっており、システム全体のスループットが低下していることを示します。

複数CPUの場合の算出モデル

PSIの考え方は、ここまで説明したとおりそれほど複雑ではありません。ただし、CPUが複数ある場合の計算は少しイメージしづらいでしょう。そこで、カーネルがどのようなモデルにもとづいて計算しているのかを紹介します。

このモデルについては、カーネルソースのkernel/sched/psi.cの冒頭にコメントがあります。

threads = min(アイドル状態ではないタスク数, CPU数)
   SOME = min(遅延しているタスクの数 / threads, 1)
   FULL = (threads - min(生産的なタスクの数, threads)) / threads

「生産的なタスク」とは、

  • I/Oの場合、実行中のタスク
  • メモリの場合、回収処理中ではない実行中のタスク
  • CPUの場合、CPU上にある(=実際にCPUで実行状態である)タスク

を意味します。

たとえば、256個のCPU上で257個の数値計算タスクを動かす場合は、次のようになります。⁠遅延しているタスクの数」の考え方が少しわかりづらいかもしれません。ある瞬間を切る取ると、常に257個のタスクのうち1個だけが待機している状態になるため、1になります。

threads = min(257, 256)
   SOME = min(1 / 256, 1)             = 0.4%
   FULL = (256 - min(256, 256)) / 256 = 0%

4つのタスクのうち1つがメモリ待ちで遅延している場合は、次のようになります。

threads = min(4, 4)
   SOME = min(1 / 4, 1)               = 25%
   FULL = (4 - min(3, 4)) / 4         = 25%

実際の実装についても、psi.cのコメントで説明がありますので、興味のある方はご覧になってみてください。

割り込み処理のPSI

割り込み処理のPSIは、他のリソースの値とは少し意味合いが違います。

irqファイルから得られるPSIは、割り込み処理(IRQ/SOFTIRQ)に関連する値です。割り込み処理は、ハードウェアなどからの通知に応じてカーネルが実行する処理で、その間、そのCPU上で実行中だったタスクは停止します。

割り込み処理のPSIは、割り込み処理がなければタスクが実行に使えたはずの時間のうち、実際には割り込み処理によって奪われた時間の割合を示します。このように導出する値のため、someに相当する概念は定義されておらず、irqファイルにはfullの行のみが存在します。

図3の場合、60秒のうち、10秒が割り込み処理に使われているので、16.66%(10/60)となります。

図3 割り込み処理のPSIの算出
割り込み処理のPSIの算出

システム全体のPSIの取得

システム全体のPSIは、/proc/pressure/ディレクトリー以下のファイルから取得できます。

$ ls /proc/pressure/
cpu  io  memory
$ cat /proc/pressure/cpu 
some avg10=5.48 avg60=3.00 avg300=0.75 total=2314283
full avg10=0.00 avg60=0.00 avg300=0.00 total=0
$ cat /proc/pressure/memory 
some avg10=0.00 avg60=0.00 avg300=0.00 total=481
full avg10=0.00 avg60=0.00 avg300=0.00 total=481
$ cat /proc/pressure/io
some avg10=0.66 avg60=0.52 avg300=0.14 total=522892
full avg10=0.40 avg60=0.29 avg300=0.07 total=330247

この例のように、CPU、メモリ、I/Oの情報を取得できます。

このうち、システム全体のCPUに対するfullの情報は、5.13カーネル以降で報告されるようになりました。5.13より前のカーネルでは、cpuファイルにはfullの行はありません。

cpuファイルにfullが表示されるようになったのは、5.13カーネルでcgroupごとにCPUのPSIとしてfullを計測するようになったためです。そのため、本来はcgroup内でのみCPUのfullが計測されるべきでしたが、副作用としてシステム全体のCPUでもfullが計測され、cpuファイルにfullが表示されるようになってしまいました。

しかし、システム全体で全タスクが同時にCPUを待つという状態は存在しないはずです。システム全体のCPUに対するfullは定義されていません。何を表す値なのかもわからず、表示自体に意味がないため、混乱を招くだけでした。

そのため、5.19カーネルで、cpuファイルのfullにはすべて0を表示するように修正されました。行そのものを削除しなかったのは、一度表示してしまった行を急に消すと、ファイルを読んでいるプログラムの誤動作を引き起こす可能性があるためです。後方互換性を考慮したわけです。

したがって、cpuファイルのfull行は5.19カーネル以降ではすべての項目で0が表示されます。5.13〜5.18カーネルでは、0ではない無意味な値が入っていますので、これらの値は使わないようにしましょう[2]

6.1カーネル以降でCONFIG_IRQ_TIME_ACCOUNTINGが有効になっている場合は、これらのファイルの他にirqというファイルが追加されます。次に示すのは、7.1.3カーネルでCONFIG_IRQ_TIME_ACCOUNTINGを有効にし、ソースからビルドしたカーネル環境での実行例です。

$ uname -r
7.1.3-plamo64 (7.1カーネル環境)
$ ls /proc/pressure/
cpu  io  irq  memory (irqが存在)

irqファイルにはfullの行しかありません。

$ cat /proc/pressure/irq 
full avg10=0.00 avg60=0.00 avg300=0.00 total=6928825

PSIの動き

ここからは、実際に/proc/pressure/以下のファイルを読み、システム全体のPSIの動きを追ってみましょう。

なおシステム全体のPSIは、システムのスペック、システム上で動いているプロセスなど、さまざまな要因によって変化します。このため、⁠このような負荷をかけたらこのような値が得られる」というような実験はできません。

システムの負荷を上げると、PSIがそれに応じて変化する様子を見てもらうための実験です。得られた値はあくまで一例ですので、値ではなく動きのほうに注目してご覧いただければと思います。

メモリ

まずはメモリに負荷をかけてみましょう。連載でこれまでも使ったstress-ngコマンドでメモリを確保します。

テスト環境は4GBのメモリを持つVMです。次のように--vm 1を指定し、負荷をかけるワーカーを1つだけ起動します。あわせて--vm-hang 0を指定して、一度確保したメモリを確保しつづけるようにします。

$ stress-ng --vm 1 --vm-bytes 3700M --vm-hang 0 --timeout 120s

結果を見てみましょう(図4)

図4 メモリのPSI(確保したメモリを解放しない場合)
メモリのPSI(確保したメモリを解放しない場合)

まずこのシステムは、stress-ng以外がほとんど動いていないため、somefullはほぼ同じ値になりました。

負荷をかけると同時にavg10の値が上がり、その後は急激に下がって0に近づきます。avg60avg300の値はゆっくり立ち上がり、大きくはなりません。avg10の値が大きい状態が長く続かないため、長期間の値が大きくならないのは当然です。

このような結果になる理由は、--vm-hang 0を指定し、確保したメモリを保持しつづけているからです。最初にメモリを確保した時点では負荷がかかりますが、その後は負荷がかかりません。このため、avg10の値は急激に下がっていきます。最初に負荷がかかってストールした時間がavg60avg300に影響するため、これらの値はゆっくり下がっていきます。

次にstress-ngコマンドに--vm-hang 10を指定し、確保したメモリを10秒後に解放して、再度確保します。

$ stress-ng --vm 1 --vm-bytes 3700M --vm-hang 10 --timeout 120s

結果を見てみましょう(図5)

図5 メモリのPSI(解放と再確保を繰り返した場合)
メモリのPSI(解放と再確保を繰り返した場合)

こちらも--vm 1を指定していますので、somefullの値はほぼ同じになりました。

--vm-hang 10を指定したため、定期的にメモリを確保し、そのたびにメモリ負荷が上がります。avg10の値に定期的に山があることがわかります。avg60avg300の値は長い時間でならされますので、値の上下は少ないです。定期的に負荷がかかるので、--vm-hang 0のときの結果と違い、avg60avg300の値は下がりません。

CPU

次はCPUです。

CPUの実験にもstress-ngコマンドを使用します。stress-ngでは--cpuで指定した数だけ、CPUに負荷をかけるワーカーを起動します。

そこでまずは、CPU数と同じワーカー数を指定してCPUに負荷をかけてみます。その後、ワーカー数をCPU数より多くしていき、PSIの値がどう変わるかを見てみましょう。

今回使用している環境はCPU数が2です。

$ grep processor /proc/cpuinfo 
processor       : 0
processor       : 1

そこで、--cpu 2として、ワーカー数2で実行します。

$ stress-ng --cpu 2 --timeout 60s

結果を見てみましょう(図6)

図6 CPUのPSI(CPU数と同じワーカー数の場合)
CPUのPSI(CPU数と同じワーカー数の場合)

このように実行すると、CPU使用率はいずれも100%近くに張り付きます。しかし、タスクはほぼストールせずに動作しますので、PSIの値は小さいままです。

次は実験環境のCPU数より1つ多いワーカー数となる--cpu 3を指定して実行しました(図7)

図7 CPUのPSI(CPU数より1つ多いワーカー数の場合)
CPUのPSI(CPU数より1つ多いワーカー数の場合)

図7のように、avg10の値がほぼ50%付近で頭打ちになっていることがわかります。

この実験を先に説明したモデルに代入してみましょう。

threads = min(3, 2)                   = 2
   SOME = min(1 / 2, 1)               = 50%

このように、実行例とモデルが一致していることがわかります。

次に実験環境のCPU数よりかなり多い--cpu 6を指定して実行しました(図8)

図8 CPUのPSI(CPU数よりかなり多いワーカー数の場合)
CPUのPSI(CPU数よりかなり多いワーカー数の場合)

図8のように、avg10の値が100%付近まで上がっていることがわかります。

threads = min(6, 2)                   = 2
   SOME = min(4 / 2, 1)               = 100%

モデルに代入すると、確かに100%になります。

なお先に説明したとおり、CPUのfullは常に0ですので、グラフにはプロットしていません。

I/O

I/Oについては、fioコマンドで実験しました。実行したコマンドは次のとおりです。

$ fio -filename=/data/testfile -direct=1 -rw=randwrite -bs=4k -numjobs=1 -time_based -runtime=30 -size=100M -name=psi_test_1

このうち、同時にワークロードを実行するジョブ数である-numjobsの値を1、2、4で順に変化させました。

まずは-numjobs=1を指定したときの結果です(図9)

図9 I/OのPSI(ジョブ数1の場合)
I/OのPSI(ジョブ数1の場合)

図9のように、単独のタスクでもavg10の値は50%以上上がっており、この環境ではストールする(I/O完了を待つ)時間がそれなりに発生していることがわかります。somefullの値はほぼ同じです。このホスト上ではfioコマンド以外のタスクを実行していませんので、納得できる結果でしょう。

次に-numjobs=2を指定したときの結果です(図10)

図10 I/OのPSI(ジョブ数2の場合)
I/OのPSI(ジョブ数2の場合)

図10のように、PSIの値については-numjobs=1のときと大きく変わりません。ただし、-numjobs=1のときはsomefullの値がほぼ同じでしたが、-numjobs=2になると、少し乖離していることがわかります。

2つのタスクは、それぞれストールはしているものの、常に同じタイミングでストールしているわけではないことがわかります。

次に-numjobs=4を指定したときの結果です(図11)

図11 I/OのPSI(ジョブ数4の場合)
I/OのPSI(ジョブ数4の場合)

図11でavg10の値を見ると、some-numjobs=1-numjobs=2のときよりは大きくなっています。タスクの数が増えたため負荷が上がり、ストールすることが多くなったことがわかります。一方でfullは、-numjobs=1-numjobs=2のときより下がっていることがわかります。これは、単独ではストールするものの、全タスクが同時にストールする時間が減っていることを示します。

この結果から、この環境で-numjobs=4程度まで負荷を上げた場合、次のような状況であるといえるでしょう。

  • タスク数が増えたことで、同時に発行されるI/Oの数が増え、個々のタスクがストールする機会は増えた(=someの上昇)
  • しかし、タスクごとにI/O発行・完了のタイミングがずれるため、⁠全タスクが寸分違わず同時にストールする」という状況は起きにくくなった(=fullの低下)
  • つまりストールは発生しているが、あるタスクがストールしている間に別のタスクは処理が進んでおり、全体としては並行して処理が進んでいる

割り込み処理

最後に割り込み処理のPSIの動きを見ておきましょう。

ここまでの実験で使ってきたUbuntu 26.04では、CONFIG_IRQ_TIME_ACCOUNTINGが有効になっていないため、irqファイルがありません。そこで、割り込み処理の実験では、筆者がメンテナを務めているPlamo LinuxをインストールしたVM環境を使いました。このVM上で、CONFIG_IRQ_TIME_ACCOUNTINGを有効にしてビルドした7.1.3カーネルを動かしています。

割り込み負荷は、stress-ngコマンドで発生させました。次のように--timerオプションを使用し、高頻度のタイマーイベントを発生させ、タイマー割り込みを生成しました。--timer-freq 100000で、タイマーを1秒間に100,000回発生させました。

$ stress-ng --timer 1 --timer-freq 100000 --timeout 60s

結果は図12のようになりました。

図12 割り込み処理のPSI
割り込み処理のPSI

stress-ngコマンドの実行直後から値が上がり、実行停止とともに下がっていく様子がわかります。

まとめ

今回は、PSI機能の実装がカーネルでどのように進んだのかを紹介しました。そして、PSIの算出方法を説明したうえで、実際にホストに負荷をかけてPSIを取得しました。

先にも書いたとおり、PSI機能では、cgroupで制限値を設定した場合のように「制限に達したら必ずこうなる」といった測定はできません。そこで、PSIの動きがイメージできるように単一のリソースに負荷をかけ、PSIが変化する様子を見てもらいました。

実際にシステムを運用する環境では、今回の実験のように単一のリソースに負荷がかかっているようなケースは珍しいはずです。複数のリソースに負荷がかかり、あるリソースの負荷が他のリソースにも影響を与えるような場合がほとんどです。そのような場合にも、それぞれのPSIを取得することで、リソース不足の様子を細かく検出して対応したり、リソース不足を予測したりできるでしょう。

次回はcgroup v2とPSIの連携について紹介する予定です。

参考文献

おすすめ記事

記事・ニュース一覧