シェルもマウントもdocker runも、正体を知らずに使っていた
武内覚さんの[試して理解]Linuxのしくみ 増補改訂版を読みました。読み終えて残ったのは新しい知識よりも、毎日使っているものの正体を知らないまま使っていた、という感触でした。
自分は普段アプリケーション開発をしていて、扱う言語はPHPが多いです。Linuxコマンドを叩く機会はありますが、カーネルの側は疎いままでした。図解の多い本ですが、それでも一度で頭に入ることはなく、数回読み返してやっと理解できつつあります。読み返す中で曖昧さが解けたところを並べます。
ハードウェアを触れないのは、行儀の問題ではなかった
プロセスがハードウェアを直接操作すると、複数のプロセスが同じデバイスを勝手に触って想定外の動作になる。だからカーネルが代わりに操作する。理屈は納得できるものの、カーネル頑張りすぎだろう、とも思いました。
曖昧だったのは、その「勝手に触れない」がどう成立しているかです。CPUにはカーネルモードとユーザーモードがあり、プロセスが動くユーザーモードでは特権命令が実行できず、デバイスにも直接アクセスできない。行儀よく振る舞うことを期待しているのではなく、ハードウェアの仕組みとして禁止されている。
では、どうやって依頼するのか。その手段がシステムコールでした。発行するとCPUがカーネルモードに切り替わり、カーネルが処理を代行して、終わるとユーザーモードに戻ります。
libcとコンパイラとカーネルを混ぜて覚えていた
厄介なのは、システムコールの発行手順がCPUのアーキテクチャごとに違うことです。引数やシステムコール番号をどのレジスタに置くか、どの命令で発行するかが変わる。x86_64なら番号を rax に置いて syscall、arm64なら w8 に置いて svc #0 で、アーキテクチャごとの対応は syscall(2) に一覧があります。しかもこの手順はC言語では書けず、アセンブリが必要になります。
そこを引き受けているのがlibcで、Linuxではglibcが代表的です。libcがシステムコールのラッパー関数を提供していて、その中身がアーキテクチャ固有のアセンブリになっている。C言語のプログラムからは write() のような関数を呼ぶだけで済みます。
ここで疑問が出ました。どのCPU向けかの対応づけは、いつ誰が決めているのか。オプションなのか、実行環境なのか、コンパイラの判断なのか。
調べると、全部ビルドのときに決まっていて、実行時には何も判断していませんでした。


libcはアーキテクチャごとに別々にビルドされていて、glibcのソースには sysdeps/unix/sysv/linux/x86_64/ のようなディレクトリが並んでいます。システムコール番号もアーキテクチャごとに違い、write はx86_64では1番、arm64では64番。同じ hello.c をarm64向けにビルドすると、変わるのはコンパイラの出力とlibcの中身だけで、Cのソースは1文字も変わりません。
誤解していたのは担当分けでした。Linux自体はOSのカーネルで、ラッパー関数を提供しているのはカーネルではなくlibc。カーネルが提供するのはシステムコールのインタフェースまで。かといってlibcが機械語への変換をしているわけでもなく、そこはコンパイラの仕事。3者を混ぜて覚えていました。
ビルド済みバイナリは持ち運べるのか
ここまで読んで、Cでアプリケーションを作る場合はそのサーバーでビルドしたものを置くのだろうか、という疑問が出ました。調べたところ、ビルドする場所はどこでもよく、そのサーバーと同じCPU向けにビルドされていればいい。apt install で入るものも、どこか別の場所でビルドされた成果物です。ただし動的リンクしたバイナリは、実行側のglibcがビルド側より古いと動きません。ディストリビューションを跨いで配るときはここが効きます。
逆に、CPUが違うバイナリは起動すらしません。命令そのものがx86_64向けの機械語として焼き込まれているので、arm64のCPUには読めない。
x86_64のマシンからarm64向けにビルドすることもできて、クロスコンパイルと呼ばれます。arm64向けの機械語を出すコンパイラとarm64向けのlibcを用意して、gcc の代わりにそちらを使うだけ。Debian系なら apt install gcc-aarch64-linux-gnu で両方入ります。docker buildx build --platform でイメージのアーキテクチャを指定するのも、突き詰めれば同じ話です。オプションで決まるのかと思ったのは、この意味では半分合っていました。実行時に判断しているのではなく、ビルドのときに何向けにするかを選んでいる。
面倒なのは、できたバイナリがビルドしたマシンでは動かないことです。動作確認にはarm64の環境が別に要る。さきほどのライブラリの条件も重なるので、結局は本番と同じ環境でビルドするのが無難でした。CIやコンテナでビルドするのはこのためです。
PHPしか書いていないと、この心配は要りません。ソースを置けば実行時に解釈されるので、デプロイはファイルを置くだけで済む。Cは成果物がCPU依存の機械語なので、どこでビルドしたかが結果に残る。普段の感覚との差では、ここが一番大きい発見でした。
シェルとジョブとセッションは、別の階層の名前だった
シェル、ジョブ、セッション。どれも聞いたことはあるのに、それぞれが何なのかは点で覚えていました。

まずシェル。入力されたコマンドラインを解釈してプロセスを生成・実行するプログラムで、bashやzshはその実装のひとつ。カーネルの一部でも特権的な存在でもなく、ps に出てくる普通のプロセスです。
次にジョブ。シェルがひとまとまりとして扱うコマンドの単位で、cat foo | grep bar のようにパイプで繋いだ列は、プロセスが2つでもジョブとしては1つ。カーネル側での呼び名はプロセスグループです。
一番外側がセッション。ログインからログアウトまでを表す単位で、中に複数のジョブが入る。ここに端末が1つ結びついていて、これを制御端末と呼びます。ターミナルエミュレータやSSHで使っているのはカーネルに作らせた擬似端末で、/dev/pts/0 のようなデバイスとして見えます。自分はセッションが擬似端末そのものだと思っていたのですが、そうではなく、セッションに端末が結びついている関係でした。
この階層が分かると、Ctrl+Cの挙動も説明できます。端末はフォアグラウンドのプロセスグループをひとつだけ覚えていて、Ctrl+Cで発生したシグナルをそこへ送る。だから cat foo | grep bar を止めると、cat だけでなくパイプライン全体にSIGINTが届く。バックグラウンドジョブが死なないのは、フォアグラウンドではないから届かないだけでした。
ターミナルを閉じると実行中のコマンドが道連れになるのも同じ構造です。端末が閉じられるとSIGHUPがセッションリーダーであるシェルに届き、bashは自分が抱えているジョブにもSIGHUPを送ってから終了する。nohupやdisownは、このSIGHUPを避けるための仕組みでした。
ワーカー数を論理CPU数より多くしていた理由
論理CPUはカーネルから見えるCPUの数で、4コアで各コアが2スレッドなら8つ。ある瞬間に1つの論理CPU上で動いているプロセスは1つだけで、同時に動いて見えるのは、タイムスライスごとに切り替えているからです。
ここで例えたくなるのが厨房です。コンロの口が2つある厨房に鍋を4つ持ち込んでも、同時に火にかけられるのは2つまで。4皿を少しずつ進めたいなら一定時間ごとに鍋を載せ替えることになり、これがタイムスライスとコンテキストスイッチにあたります。このとき4皿が出揃う時間は、2つずつ順番に作った場合と変わりません。口数が増えていないからです。むしろ載せ替えの手間の分だけ伸びて、1皿目が出てくるのも遅くなる。
ただ、これはCPUを使い続ける処理に限った話でした。ディスクやネットワークの応答を待っている間、プロセスはスリープ状態になり論理CPUを使わない。火にかける必要のない鍋は口を塞がない、ということになります。DBやAPIの応答待ちが大半を占めるWebアプリケーションで、ワーカー数を論理CPU数より多く設定するのはこれが理由か、と繋がりました。そこでの上限はCPUではなくメモリのはずです。
OPcacheが置いている先は共有メモリだった
プロセスは互いのメモリを覗けないので、やり取りには専用の仕組みが要ります。そのひとつが共有メモリで、ページテーブルを複数のプロセスから同じ物理メモリへ向けることで実現します。
PHPだけ書いていると馴染みがない、と最初は思ったのですが、これは違いました。OPcacheがコンパイル済みのopcodeを置いている先がこれです。php-fpmのワーカーが何十プロセスあってもopcodeを一組持つだけで済むのはこのためで、opcache.memory_consumption はこの共有メモリのサイズをメガバイトで決める設定でした。毎日使っている設定値の正体がここだったとは思いませんでした。
マウントはコピーでも同期でもなかった
マウントが何をしているのかも曖昧でした。実際にやっているのは、ストレージの中身を見るための入り口をディレクトリツリーに生やすことです。
WindowsでUSBメモリを挿すと E: として現れる、あれに相当します。Linuxにはドライブレターがなく / を根とする1本のツリーしかないので、代わりに /mnt/data のようなディレクトリを入り口にする。


データが移動するわけではありません。中身は元のストレージにあって、入り口が生えるだけ。その入り口もディスクに書き込まれるものではなく、どのデバイスをどこに繋いだかをカーネルが一覧で覚えているだけでした。/proc/mounts や findmnt で覗けます。再起動すると消えるので、/etc/fstab に書いて起動のたびに繋ぎ直している。
アンマウントで消えるのはこの一覧の1行だけで、ストレージには何も触りません。だからデータはそのまま残ります。マウントポイントに元々あったファイルが見えなくなるのも、消えたのではなく入り口の下に隠れているだけでした。
シンボリックリンクとの違いも整理できました。あちらは「あっちを見てね」と書いた札で、実体は行き先のパスを書いた文字列だけ。ln -s /mnt/data link で作った札を ls -l で見るとサイズが9バイトで、/mnt/data の文字数と同じです。symlink(7) の言うとおり、中身はそのパスの文字そのものでした。
たどった先は同じツリーの中の別の場所なので、札を作ってもツリーの中身は増えません。まだマウントしていないディスクへ札を張っても、指す先が無いのでリンク切れになるだけ。ただし札はたどるたびに解決し直されるので、あとからマウントすれば、1バイトも書き換えないまま通るようになります。ディスクをツリーに登場させられるのは、マウントのほうだけです。
docker runに書いていた数字はcgroupだった
コンテナも、カーネルにコンテナという名前の機能があるわけではなく、既存の仕組みの組み合わせでした。見える範囲を区切るのがnamespaceで、使える量を区切るのがcgroup。だからホストで ps を打てば、コンテナの中のプロセスも普通に並びます。特別なプロセスではなく、見える範囲を狭められた普通のプロセスでした。
cgroupはファイルシステムを通して操作します。/sys/fs/cgroup の下にディレクトリを作るとそれが1つのグループになり、cgroup.procs へPIDを書き込むとそのプロセスがグループに入る。制限値も同じで、ファイルに数値を書けば効きます。書き方はカーネルのcgroup v2ドキュメントにあります。
- CPUは
cpu.maxに「使ってよい時間 期間」をマイクロ秒で書く。50000 100000なら、0.1秒ごとに0.05秒まで、論理CPU 0.5個分に制限される。 - メモリは
memory.maxが上限。超えるとそのグループの中でOOM killerが動く。 - I/Oは
io.maxで、デバイスごとに帯域やIOPSの上限を決める。
docker run --memory=512m --cpus=1.5 を書いたことがありますが、あれが設定しているのはcgroupでした。Dockerのドキュメントによれば --cpus="1.5" は --cpu-period="100000" と --cpu-quota="150000" を指定したのと同じで、cgroupでは cpu.max の 150000 100000 にあたります。--memory は memory.max で、Kubernetesのlimitsも同じところへ落ちる。コンテナのリソース制限は、cgroupに値を書く作業に名前を付けたものでした。
まとめ
- プロセスがハードウェアを直接触れないのは、ユーザーモードでは特権命令が実行できないから。依頼の手段がシステムコールで、発行する命令はx86_64なら
syscall、arm64ならsvc #0 - どのCPU向けかはプログラムとlibcをビルドした時点で決まり、実行時には何も判断していない。
writeの番号がx86_64で1番、arm64で64番と違っても、Cのソースは1文字も変わらない。ラッパー関数を提供するのはカーネルではなくlibc - CPUが違うバイナリは起動しない。クロスコンパイルで別アーキテクチャ向けに作れるが、動作確認にはそのアーキテクチャの環境が別に要る
- シェル、ジョブ、セッションは端末を頂点にした入れ子の別々の層。Ctrl+Cがパイプライン全体に効くのも、ターミナルを閉じるとジョブが道連れになるのも、シグナルの宛先がプロセスグループとセッションだから
- I/O待ちのプロセスはスリープして論理CPUを使わない。待ちが主ならワーカー数は論理CPU数を超えてよく、そこでの上限はメモリ
- マウントはコピーでも同期でもなく、ツリーに入り口を生やす操作。入り口の実体はカーネルが持つ一覧の1行で、アンマウントで消えるのもその1行だけ
- php-fpmのワーカーが何十プロセスあってもopcodeを一組しか持たないのは、置き場所が共有メモリだから。そのサイズが
opcache.memory_consumption - コンテナのリソース制限はcgroupのファイルに値を書く作業で、
--cpus=1.5はcpu.maxの150000 100000になる
普段の道具の下に何があるかを知ると、設定値を勘で決める場面が減ります。コードも設定もAIが出してくれるようになったぶん、それが妥当かを判断する側に回る場面が増えて、低レイヤーの知識はむしろ以前より要ると思っています。