← Blog一覧へ

シェルもマウントもdocker runも、正体を知らずに使っていた

#tech#Linux

武内覚さんの[試して理解]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向けかの対応づけは、いつ誰が決めているのか。オプションなのか、実行環境なのか、コンパイラの判断なのか。

調べると、全部ビルドのときに決まっていて、実行時には何も判断していませんでした。

ビルド時に何が決まるかの図。write(1, msg, 6) を呼ぶhello.cをgcc(x86_64向け)でビルドするとa.outになる。別の経路で、glibcのソースのsysdeps/unix/sysv/linux/x86_64/をビルドしたlibc.so.6の側に、引数はrdi/rsi/rdxで受け取る、rax=1を設定する、syscall命令を実行する、というwriteラッパーの処理が確定している。引数レジスタは積み直さない。x86_64向けにビルドした時点で、ラッパーの処理が確定する。

実行時の流れの図。呼び出し元のa.outがwrite(1, msg, 6)を呼び、rdi=1、rsi=msgの先頭アドレス、rdx=6がレジスタに入る。libc.so.6のwriteラッパーは引数レジスタをそのままに、rax=1だけを設定する。ビルド済みのx86_64アセンブリで、ここでは何も判断しない。syscall命令でユーザーモードからカーネルモードへ移り、カーネルがsys_writeを実行して、戻り値をラッパーが受け取る。実行時は、ビルド時に確定した処理をそのまま実行する。

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依存の機械語なので、どこでビルドしたかが結果に残る。普段の感覚との差では、ここが一番大きい発見でした。

シェルとジョブとセッションは、別の階層の名前だった

シェル、ジョブ、セッション。どれも聞いたことはあるのに、それぞれが何なのかは点で覚えていました。

擬似端末とセッションとジョブの入れ子を表した図。一番外の枠が擬似端末 /dev/pts/0 で、その中にセッションが入る。セッションはログインしてから抜けるまでの単位で、端末が1つ結びつく。セッションの中に3つが並ぶ。セッションリーダーのbashは、bash自身も1つのプロセスグループ。プロセスグループ(ジョブ1)はフォアグラウンドで、cat foo | grep bar のcatとgrepの2プロセスが入る。プロセスグループ(ジョブ2)はバックグラウンドで、sleep 100 & のsleepが入る。

まずシェル。入力されたコマンドラインを解釈してプロセスを生成・実行するプログラムで、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 のようなディレクトリを入り口にする。

マウント前のディレクトリツリー。ルートの下にhome/とmnt/があり、mnt/の下の/mnt/dataにあたるdata/は空のまま置かれている。

マウント後のディレクトリツリー。/dev/sdb1を/mnt/dataにマウントしたことで、同じdata/の下にfoo.txtとbar.txtが現れている。ここから先が/dev/sdb1の中身で、ツリーの他の部分は変わらない。

データが移動するわけではありません。中身は元のストレージにあって、入り口が生えるだけ。その入り口もディスクに書き込まれるものではなく、どのデバイスをどこに繋いだかをカーネルが一覧で覚えているだけでした。/proc/mountsfindmnt で覗けます。再起動すると消えるので、/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ドキュメントにあります。

docker run --memory=512m --cpus=1.5 を書いたことがありますが、あれが設定しているのはcgroupでした。Dockerのドキュメントによれば --cpus="1.5"--cpu-period="100000"--cpu-quota="150000" を指定したのと同じで、cgroupでは cpu.max150000 100000 にあたります。--memorymemory.max で、Kubernetesのlimitsも同じところへ落ちる。コンテナのリソース制限は、cgroupに値を書く作業に名前を付けたものでした。

まとめ

普段の道具の下に何があるかを知ると、設定値を勘で決める場面が減ります。コードも設定もAIが出してくれるようになったぶん、それが妥当かを判断する側に回る場面が増えて、低レイヤーの知識はむしろ以前より要ると思っています。