届いた荷物は、誰が捌くのさね?
안녕하신게라!パナソニック コネクト株式会社クラウドソリューション部の加賀です。
前回「TCP/UDPとポートの基本の"キ"」では、宛先の窓口(ポート)まで、荷物に合った配送方法(TCP/UDP)でデータを届けるところまでを解説しました。パケットは無事、目的のLinuxサーバのネットワークカード(NIC)に到着です。
時計が合い(NTP)、相手が本物だと確かめ(TLS)、住所を特定し(DNS)、宛先の窓口まで荷物を届けた(TCP/UDP)。ここまではすべて「サーバに届くまで」の話でした。では、届いた荷物(例えば「このページの画像をくれ」というHTTPリクエスト)は、サーバの中で誰が、どうやって捌いているのでしょうか。
ネットワーク(NTP・TLS・DNS・TCP/UDP)が「データを運ぶ」ための仕組みだったのに対し1、運ばれてきたデータを実際に処理する力、つまりCPUで計算し、メモリに展開して動かす「計算する能力」そのものを、クラウドの世界では「コンピュート(Compute)」と呼びます。AWSのEC2やLambda、AzureのVirtual MachinesやAzure Functions、Google CloudのCompute EngineやCloud Run Functionsがいずれも「コンピュート」に分類されるのはこのためで、データを保存するストレージ、データを運ぶネットワークと並ぶ、クラウドインフラを支える代表的な三本柱のひとつです。
普段はEC2やコンテナといった「箱」の単位で意識するコンピュートですが、その正体をいちばん底まで掘り下げていくと、最後に行き着くのが本記事の主役、プロセスとメモリです。
サーバの中で仕事をするこの二人を、料理にたとえながら見ていきましょう。
レシピ本は、ひとりでに料理を作らない
サーバの中身に踏み込む前に、まず「プログラム」と「プロセス」の違いを整理しておきましょう。
プログラムは、ハードディスクやSSDの中に保存された「ただのファイル(実行ファイル)」です。料理でいえば棚に並んだレシピ本。手順が書かれてはいるけど、それ自体は何もしません。
一方のプロセスは、そのプログラムがOSによってメモリ上に読み込まれ、CPUを使って実際に動いている「状態」を指します2。レシピ本を開いた料理人(シェフ)が、手を動かして調理している実作業そのものです。
Webサーバの代名詞であるNginxやApacheも、インストールしただけならまだ棚のレシピ本、ただのプログラムにすぎません。起動コマンドを叩いて初めてOS上にプロセスとして生まれ3、前回登場したポート80番や443番で通信を待ち構え、リクエストを捌く仕事を始めます。
「置いてある」だけのファイルが「動いている」実体に変わる、この境目こそがプログラムとプロセスの分かれ目です。
メモリという「まな板」と、仮想メモリの優しいウソ
料理人が調理を始めるには、食材を広げて切るための作業スペースが要ります。プロセスにとってのそれがメモリ(RAM)、いわばまな板です4。プロセスは処理に必要なデータやプログラムの一部をこのまな板の上に広げ、CPUで計算しながら捌いていきます。
ここで問題になるのが、1台のサーバでは何百ものプロセスが同時に立ち上がっている、という事実です(後ほど触れるように、実際にCPU上で走っているのは一握りで、残りは出番待ちですが、走っていないプロセスもメモリ上には場所を確保しています)。もし全員が同じ1枚のまな板を共有したら、隣のシェフが広げた食材を勝手に切り刻んでしまう事故が起きかねません。
そこで現代のOS(LinuxやWindows)は、仮想メモリ(Virtual Memory)という巧妙な仕掛けを使います。OSは各プロセスに対して「君専用の広大なまな板を用意したよ」と伝えます。プロセスAもプロセスBも、それぞれ「自分がメモリを丸ごと独占している」と信じ込んで仕事をします。
しかし、これはOSがついた優しいウソです。実際には物理メモリ(本物のまな板)は限られており、OSが裏でそれを細かく区切り(ページング)、原則としてどのプロセスの領域も重ならないようパズルを解いて配置しています(共有ライブラリのように、あえて同じ物理領域を複数プロセスで使い回すケースは例外です)。プロセスが使う「見かけ上の住所(仮想アドレス)」を「本物の住所(物理アドレス)」へ翻訳する。CPU内のアドレス変換専用回路であるMMU(Memory Management Unit)が変換し、OSは変換の対応表(ページテーブル)を用意・管理する、という分担で高速に動いています。しかも一度引いた変換結果はCPU内の小さなキャッシュ(TLB)に覚えておくため、次からは対応表を最初からたどらずに済み、これが高速化の肝になっています5。
この仕組みのおかげで、プロセスAがバグで暴走しても、プロセスBのまな板を破壊すること(メモリの不正アクセス)はできません。これをメモリ保護と呼びます。仮想メモリは、単に「物理メモリより広く使える」だけの技術ではなく、プロセス同士を隔離して守る安全装置でもあるわけです。
【コラム】スワップ(Swap)の甘い罠とスラッシング地獄
物理メモリ(本物のまな板)には限りがあり、いずれ使い切ります。OSはまな板が溢れそうになると(あるいは vm.swappiness の設定次第では、その前から先んじて)、今すぐ使わない食材だけを一時的に「冷蔵庫(ハードディスクやSSD)」へ退避させます。これがスワップ(Swap)です。退避した食材を再び使うときは、プロセスが一度そのページに触れて「無い」と気づき(ページフォルト)、OSが冷蔵庫から取り出して物理メモリへ戻します。
スワップのおかげで物理メモリを超える大きさのプロセスも動かせますが、ディスクへの読み書きはメモリに比べて桁違いに遅い。頻繁にスワップが起きると、OSは「食材を冷蔵庫から出してはしまい」を延々と繰り返し、肝心の調理が進まなくなります。これをスラッシング(Thrashing)と呼び、サーバの応答時間(レスポンスタイム)が跳ね上がり、サーバ自体が文鎮化する典型的な原因です。「Swapがあるから安心」ではなく「Swapが発生している時点で悲鳴が上がっている」と捉えるのが、インフラ運用のセオリーです。
【コラム】OOM Killer 〜メモリ枯渇の最終処刑人〜
まな板(物理メモリ)も限界、冷蔵庫(スワップ)も限界。サーバ全体のメモリが文字どおり完全に枯渇(Out of Memory)すると、どうなるのか。
Linuxはシステム全体のクラッシュという最悪の事態を避けるため、最終手段を発動します。それがOOM Killerです。この機能は「メモリを大量に食っているプロセス」を淡々とスコアリングし、点数の高いものを問答無用で強制終了(Kill)してメモリを取り戻します。
やっかいなのは、その犠牲になりやすいのが、往々にして一番メモリを使っている「本番データベース(MySQLなど)」や「メインのJavaアプリケーション」だという点です。「あれ、DBが突然死したぞ?」と慌てて dmesg や /var/log/messages(環境によっては journalctl -k)を漁ると、そこにはOOM Killerがプロセスを仕留めた無情な記録が残っている……というのは、インフラエンジニアなら一度は味わうトラウマです。クラウド時代でも、メモリの設計と監視が甘ければ普通に処されます。
なお厳密には、OOM Killerは「メモリを回収してもこれ以上割り当てられない」とカーネルが判断した時点で発動します。さらにコンテナ環境では、ホスト全体ではなく、メモリ上限(cgroup)を超えた“そのコンテナだけ”が仕留められることもあります。この「区切って上限を設ける」発想は、次回の隔離の話にそのまま繋がります。
【コラム】free の「空きが少ない」に慌てない
free コマンドを叩いて used が大きく、free(空き)がわずかしか残っていないと、「メモリが足りない!」と青ざめがちです。しかしその多くは、OSが一度読んだファイルを「また使うかも」とメモリ上にキャッシュ(buff/cache)しているだけで、アプリが本当に必要とすれば即座に明け渡されます。空いているまな板を「よく使う食材の一時置き場」として遊ばせずに活用している、いわば空いた場所を無駄なく使っている気の利いた状態です。
そのため本当に見るべきは free(空き)列ではなく、キャッシュ分を差し引いた**「今すぐ使える量」を表す available 列**です。ここさえ枯渇していなければ、used が大きくても慌てる必要はありません。
限られたCPUをぶん回す、コンテキストスイッチ
まな板(メモリ)の話が続きましたが、実際に食材を切る作業にあたるのがCPUの計算です。そして、シェフ(プロセス)が包丁を握って手を動かせる持ち場がCPUのコア、いわば調理台です(なお、1人のシェフ=1プロセスが、同じまな板を共有する複数の手=スレッドを持つこともできますが、本記事ではプロセス単位で話を進めます)。
ここでも数の問題が立ちはだかります。サーバの中でプロセス(シェフ)は数百人も控えているのに、CPUがたった4コアしかなければ、調理台に立って同時に手を動かせるシェフは4人だけ6。それなのに、なぜ何百ものプロセスが同時に動いているように見えるのでしょうか。
答えは「OSが猛烈な速さでシェフを入れ替えているから」です。これをコンテキストスイッチと呼びます。
CPUは数ミリ秒ほどの持ち時間(この長さは固定ではなく、実行を待つプロセスの数や負荷に応じて伸び縮みします)ごとに「はいプロセスA、時間切れ。次はプロセスB」と担当を切り替えます(切り替えそのものにかかる時間は、さらに短いマイクロ秒の世界です)。このときシェフA(プロセスA)は、「どこまで切ったか」「いま包丁をどう握っていたか」といった作業の途中経過(コンテキスト)を一度メモリに退避します。入れ替わったシェフBは自分の途中経過を復元し、中断していた作業を再開します。
しかも、切り替えの引き金は「時間切れ」だけではありません。オーブンの焼き上がりを待つように、プロセスがディスクやネットワークの応答待ち(I/O待ち)に入った瞬間、シェフは自ら包丁を置いて調理台を次の人へ譲ります。実サーバでは、むしろこの「待ちによる自発的な譲り合い」のほうが高頻度で起きています。
この入れ替えがあまりに高速なため、人間の目には何百ものプロセスが並行して動いているように映るわけです。結構な力技ですよね。前回のTCP/UDPで「ひとつの住所に届く荷物を、宛名(ポート)で担当窓口へ振り分ける」話をしましたが、今度は「限られたCPUを、時間で分け合う」。有限の資源をやりくりする発想は、ネットワークもコンピュートも変わりません。
おわりに
今回は、届いた荷物をサーバの中で捌く主役、プロセスとメモリの基本を追いかけました。
プロセスは、ディスクに置かれただけのプログラムがメモリ上で実際に動き出した動的な実体でした。仮想メモリは各プロセスに専用のまな板を与えて互いを隔離し、安全に共存させる仕組みです。物理メモリが足りなくなればスワップでしのぎ、それも尽きればOOM Killerがプロセスを仕留めてシステムを守る。そしてコンテキストスイッチが、限られたCPUコアの上で何百ものプロセスを同時に動いているように見せていました。
時計が合い(NTP)、相手が本物だと確かめ(TLS)、住所が分かり(DNS)、宛先の窓口まで荷物が届き(TCP/UDP)、サーバの中でプロセスがそれを捌く。そして出来上がった料理(レスポンス)は、再び荷物となって前回の配送網(TCP/UDP)に乗り、注文主のブラウザへと帰っていきます。
プロセスやメモリもまた「動いて当たり前」と思われがちな縁の下のインフラです。「どのプロセスがCPUやメモリを食っているか」は top や ps7、サーバ全体の混み具合(実行待ちの行列の長さ)は uptime のロードアベレージ、メモリとスワップの逼迫は free や vmstat、OOM Killerの発動跡は dmesg(または journalctl -k)といった道具で覗けます。だからこそ、この基本の"キ"を押さえておくと、サーバが重い・急に落ちたというときの切り分けの初動が変わってきます。
さて、1台のサーバには多様なプロセスが同居していますが、仮想メモリで隔離されていても、「ファイルシステム(見えているディレクトリ)」「ネットワーク(ポート番号)」「依存するライブラリ(PythonやNode.jsのバージョン)」までは、どうしてもOS全体で共有してしまっています。そのため「プロセスAはPython 3.8が必要なのに、プロセスBはPython 3.10でないと動かない」といった依存関係の衝突が起こりがちです。クラウドっぽく別のサーバを用意しても良いのですが、その準備だけでなく運用までをも考えると少々気が重くなります。
メモリだけでなく、プロセスが触れるファイルシステムや持っている権限まで、同じサーバの中でまるごと隔離できないものか。コンテナという現代の魔法が生まれるずっと前から、先人たちはこの「権限と隔離」の課題と格闘してきました。
次回はその源流をたどる「隔離、基本の"キ"(root・su・sudo・chrootと権限分離)」へ続きます。
お断り
記事内容は個人の見解であり、所属組織の立場や戦略・意見を代表するものではありません。
あくまでエンジニアとしての経験や考えを発信していますので、ご了承ください。
-
とはいえ、DNSの名前解決やTLSのハンドシェイク、TCP/IPの処理そのものも、突き詰めればどこかのサーバ(やルータなどの機器)のプロセスとメモリが実行しています。「ネットワーク=運ぶ/コンピュート=捌く」はあくまで役割で切り分けた見方であって、あらゆる処理は最終的にコンピュートの上で動いている。これが、この連載の底に流れるもう一つのテーマだったりします。 ↩
-
この「状態」は一枚岩ではなく、実際には「実行中」「実行可能(順番待ち)」「待機(I/O待ちなどでブロック中)」などに細かく分かれます。さらに、仕事を終えたのに親プロセスが後片付け(終了ステータスの回収)をサボると、亡骸だけが残るゾンビプロセスになることもあります。 ↩
-
生まれたプロセスにはOSが一意の背番号(PID=プロセスID)を振ります。プロセスは必ず別の親プロセスから枝分かれ(fork)して生まれるため、全体は1本の家系図(プロセスツリー)を成し、その根っこにいるのがPID 1のinit(現代のLinuxではsystemd)です。親が先に死んで“みなしご”になったプロセスはPID 1が引き取り、終了後の後片付け(前述のゾンビの回収)も肩代わりします。 ↩
-
このまな板は一枚の平面ではなく、用途ごとに区画が分かれています。レシピの手順そのもの(プログラムのコード)、決まった置き場が要る調味料(データ領域)、調理中に増えたり減ったりする作業スペース(ヒープ)、そして「どこまでやったか」を書き留める付箋(スタック)といった具合です。 ↩
-
もし対応表をたどっても目当てのページが物理メモリ上に無ければ、CPUは「ページフォルト」という合図を上げ、OSが必要なページをディスク(後述のスワップなど)から物理メモリへ読み込みます。仮想メモリが物理メモリより広く振る舞えるのは、この「使う瞬間に初めて実体を用意する」仕組み(デマンドページング)のおかげです。 ↩
-
正確には、Intelのハイパースレッディングに代表されるSMT(同時マルチスレッディング)で1つの物理コアが2スレッド分の論理CPUに見えることがあり、4コアなら論理8スレッドとして扱われます。とはいえ1つの調理台(コア)に2人のシェフが相乗りし、一方が仕込み待ちで手が空いた隙にもう一方が包丁を握るといった具合で、コア数を超えて同時進行が無限に増えるわけではありません。 ↩
-
topやpsを眺めるときの定番の混乱ポイントが、仮想サイズ(VSZ)と実メモリ(RSS)の違いです。VSZは「プロセスが確保している“見かけ上の”アドレス空間の広さ」で、しばしば実態より大きく見えます。実際に物理メモリを消費している量は RSS のほうなので、メモリ逼迫を疑うときは RSS を見るのが基本です。 ↩