はじめに
Ubuntu系OSのinitについて知りたくなり、実装を調べました。調査に使ったディストリビューションは、Linux Mint 21です。このディストリビューションは、Ubuntu 22.04をベースにしています。この記事は、以下のカーネルバージョンを前提とします。
fyoshida@fyoshida-desktop:~$ uname -a
Linux fyoshida-desktop 5.15.0-48-generic #54-Ubuntu SMP Fri Aug 26 13:26:29 UTC 2022 x86_64 x86_64 x86_64 GNU/Linux
initramfs内のinitを調べる
initramfsイメージの展開
まず、initramfsイメージを展開しましょう。展開には、unmkinitramfsを使います。使い方は、ここ を参照ください。
Usage: unmkinitramfs [-v] initramfs-file directory
Ubuntuでは、/bootにinitramfsイメージが置かれています。適当なディレクトリを作成し、そこで展開します。
mkdir -p lab/initrd
cd lab/initrd
cp /boot/initrd.img-5.15.0-48-generic .
mkdir initrdfiles
cd initrdfiles
unmkinitramfs ../initrd.img-5.15.0-48-generic .
これで、initramfsイメージを展開できました。展開されたディレクトリには、mainというディレクトリが含まれるので、mainに移動します。
fyoshida@fyoshida-desktop:~/lab/initrd/initrdfiles/main$ ls -l
合計 32
lrwxrwxrwx 1 fyoshida fyoshida 7 9月 23 21:27 bin -> usr/bin
drwxr-xr-x 3 fyoshida fyoshida 4096 9月 23 21:27 conf
drwxr-xr-x 12 fyoshida fyoshida 4096 9月 23 21:27 etc
-rwxr-xr-x 1 fyoshida fyoshida 7284 2月 9 2022 init
lrwxrwxrwx 1 fyoshida fyoshida 7 9月 23 21:27 lib -> usr/lib
lrwxrwxrwx 1 fyoshida fyoshida 9 9月 23 21:27 lib32 -> usr/lib32
lrwxrwxrwx 1 fyoshida fyoshida 9 9月 23 21:27 lib64 -> usr/lib64
lrwxrwxrwx 1 fyoshida fyoshida 10 9月 23 21:27 libx32 -> usr/libx32
drwxr-xr-x 2 fyoshida fyoshida 4096 9月 21 20:12 run
lrwxrwxrwx 1 fyoshida fyoshida 8 9月 23 21:27 sbin -> usr/sbin
drwxr-xr-x 12 fyoshida fyoshida 4096 9月 23 21:27 scripts
drwxr-xr-x 11 fyoshida fyoshida 4096 9月 23 21:27 usr
drwxr-xr-x 4 fyoshida fyoshida 4096 9月 23 21:27 var
initスクリプト
/initが実行されることの確認
mainディレクトリ直下にinitがあります。initramfs展開後、Linuxカーネルはinitを実行します。
このことは、以下のLinuxコードとカーネルパラメータから判断できます。ramdisk_execute_commandの値はカーネルパラメータで指定のない限り、デフォルト値のままです。
なお、Ubuntu系ディストリビューションでカーネルのソースコードを取得する方法については、UbuntuのWiki を参考にしてください。
static char *ramdisk_execute_command = "/init";
// 略
static int __ref kernel_init(void *unused)
{
// 略
if (ramdisk_execute_command) {
ret = run_init_process(ramdisk_execute_command);
if (!ret)
return 0;
pr_err("Failed to execute %s (error %d)\n",
ramdisk_execute_command, ret);
}
// 略
また、Linux Mint 21では、カーネルパラメータで特にinitのパスを指定していないことからも、デフォルト値の"/init"の実行を試みることがわかります。
カーネルパラメータは、/proc/cmdlineを見るとわかります。これは後で出てくるので、頭の片隅においてください。
fyoshida@fyoshida-desktop:~/lab/initrd/initrdfiles/main$ cat /proc/cmdline
BOOT_IMAGE=/boot/vmlinuz-5.15.0-48-generic root=UUID=a76a3b85-0ff3-498c-b3ea-ea985e7c4c8d ro quiet splash
/initの実装を見る
何はともあれ、mainに置かれているinitの中身を見ましょう。これは、/initとして実行されるシェルスクリプトです。このシェルスクリプトは、システムが動作するために必要なディレクトリを次々にマウントし、最後にrun-initをexecします。initなので、このスクリプトが実行されたときのプロセスIDは1です。
mount -t sysfs -o nodev,noexec,nosuid sysfs /sys
mount -t proc -o nodev,noexec,nosuid proc /proc
mount -t devtmpfs -o nosuid,mode=0755 udev /dev
mkdir /dev/pts
mount -t devpts -o noexec,nosuid,gid=5,mode=0620 devpts /dev/pts || true
# initは明示的にカーネル起動時のcmdline引数で渡された場合に上書き設定される
export init=/sbin/init
# 略
export rootmnt=/root
# drop_capsは明示的にカーネル起動時のcmdline引数で渡された場合に設定される
export drop_caps=
# 略
. /scripts/local
# 略
mount_top
mount_premount
mountroot
# 略
# Move /run to the root
mount -n -o move /run ${rootmnt}/run
# 略
# Move virtual filesystems over to the real filesystem
mount -n -o move /sys ${rootmnt}/sys
mount -n -o move /proc ${rootmnt}/proc
# 略
exec run-init ${drop_caps} "${rootmnt}" "${init}" "$@" <"${rootmnt}/dev/console" >"${rootmnt}/dev/console" 2>&1
run-initとは何者でしょうか。調べてみると、ELFファイルです。また、シェルのexecコマンドを使ってrun-initを起動しているので、このinitスクリプトがrun-initに「変身」する形で実行されます。よって、run-init実行時のプロセスIDは1であることに注意してください。
fyoshida@fyoshida-desktop:~/lab/initrd/initrdfiles/main$ find . -name run-init
./usr/bin/run-init
fyoshida@fyoshida-desktop:~/lab/initrd/initrdfiles/main$ file usr/bin/run-init
usr/bin/run-init: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=d834167e42f8a96b40ea856be5fb85a400897021, for GNU/Linux 3.2.0, stripped
run-init
run-initについて調べたところ、ask ubuntuのページ を見つけました。これにより、ソースコードのありかがわかります。
run-init is a binary executable, it lives in /usr/lib/klibc/bin/run-init and in your initramfs and is provided by the klibc-utils package in Ubuntu. It isn't a script, so you cannot take a look inside directly, you can check out its source code via running apt source klibc-utils or browsing the upstream repository at https://git.kernel.org/cgit/libs/klibc/klibc.git.
ソースコードを入手し、中身を確認します。run-initの実質的な処理は、run_init()関数で行われることがわかります。
この関数は、主に以下2点を実施します。
- 第1引数に指定されたディレクトリを「/」としてMOVE mountします。第1引数は文字列"/root"を指しています。
- MOVE mount後、第6引数が示すパスにある実行ファイルをexecv()で実行します。今回、第6引数は文字列"/sbin/init"を指しています。よって、/sbin/initのプロセスIDは1です。
/sbin/initは何を指しているのでしょうか。自分のLinux Mint環境で/sbin/initを確認した結果を示します。この結果から、/sbin/initの実体はsystemdだとわかります。ここでようやくsystemdがプロセスID1で実行されることがわかります。
fyoshida@fyoshida-desktop:~$ ls -l /sbin/init
lrwxrwxrwx 1 root root 20 9月 10 03:47 /sbin/init -> /lib/systemd/systemd
では、run-init()のコードを見ましょう。
const char *run_init(const char *realroot, const char *console,
const char *drop_caps, bool dry_run,
bool persist_initramfs, const char *init, char **initargs)
{
struct stat rst, cst, ist;
struct statfs sfs;
int confd;
/* First, change to the new root directory */
if (chdir(realroot))
return "chdir to new root";
/* 略 */
if (!dry_run) {
/* 略 */
/* Overmount the root */
if (mount(".", "/", NULL, MS_MOVE, NULL))
return "overmounting root";
}
/* chroot, chdir */
if (chroot(".") || chdir("/"))
return "chroot";
/* 略 */
/* Open /dev/console */
if ((confd = open(console, O_RDWR)) < 0)
return "opening console";
if (!dry_run) {
dup2(confd, 0);
dup2(confd, 1);
dup2(confd, 2);
}
close(confd);
if (!dry_run) {
/* Spawn init */
execv(init, initargs);
return init; /* Failed to spawn init */
} else {
/* 略 */
}
}
run-init()が呼ばれた時点では、init(run-init)のパラメータ(ルートディレクトリ、カレントディレクトリ)と各マウントポイントから見えるファイルシステムは以下図のとおりです。
chdir(realroot)を実行し、自身のカレントディレクトリを"/root"にします。"cd /root"と同じです。
mount()を実行し、"/root"からマウントされているルートファイルシステムを、"/"にムーブマウントします。これにより、ルートファイルシステムのマウントポイントが"/root"から"/"に移ります。
ムーブマウントの詳細については、mountのmanページのMS_MOVEの説明を参照ください。
最後にchroot(".") とchdir("/")を実行し、init(run-init)のルートディレクトリとカレントディレクトリを"/"にします。
この状態から、新たにマウントしたルートファイルシステムにある「/sbin/init」を実行します。
ここまでで/sbin/initを実行するまでの流れが見えてきましたが、initスクリプトはいつどうやって/rootにルートファイルシステムをマウントするのでしょうか。
/rootへのマウント
mountrootシェル関数
initスクリプトに戻ります。以下のシェル変数とmountrootという「それっぽい」シェル関数を手がかりにスクリプトを調べると、知りたいことがわかりそうです。
export rootmnt=/root
# 略
# Parse command line options
# shellcheck disable=SC2013
for x in $(cat /proc/cmdline); do
case $x in
# 略
root=*)
# カーネルパラメータのうち、root=で指定された値をROOTに記録する。
# これは、後に出てきます。
# カーネルパラメータは、すでに引用したとおり、私の環境では
# root=UUID=a76a3b85-0ff3-498c-b3ea-ea985e7c4c8dです。
# よって、ROOTは"UUID=a76a3b85-0ff3-498c-b3ea-ea985e7c4c8d"です。
ROOT=${x#root=}
if [ -z "${BOOT}" ] && [ "$ROOT" = "/dev/nfs" ]; then
BOOT=nfs
fi
;;
# 略
esac
done
# 略
# Default to BOOT=local if no boot script defined.
# 今回、BOOTを書き換えるコマンド引数はないことから、BOOTの値はlocalになる。
if [ -z "${BOOT}" ]; then
BOOT=local
fi
# 略
# Always load local and nfs (since these might be needed for /etc or
# /usr, irrespective of the boot script used to mount the rootfs).
. /scripts/local
. /scripts/nfs
. /scripts/${BOOT}
# 略
mountroot
mountrootは、/scripts/localにも/scripts/nfsにも実装されています。どちらの実装が使われるのでしょうか。
今回の場合、/scripts/local側の実装が使われます。
シェルスクリプトを読み込ませた際、同名のシェル関数があるとそれを上書きします。よって、/scripts/nfsを読み込ませた時点では、/scripts/nfs側のシェル関数実装が使われることとなります。
しかし、さらに/scripts/${BOOT}にも同名のシェル関数が実装されていた場合、それで上書きされます。今回、${BOOT}には"local"がセットされています。よって、最終的に/scripts/localのmountroot実装が使われます。
/scripts/localにあるmountrootのシェル関数実装を読んでみます。
mountrootシェル関数が呼び出すlocal_mount_root
mountrootシェル関数の実質的な処理は、local_mount_rootシェル関数で行っています。
local_mount_rootシェル関数の前半部分を見ます。このシェル関数では、まず、local_device_setupシェル関数を呼び出し、以下2点を取得します。
- ルートデバイスのデバイスファイル名 (DEVにセットされる)
- ルートファイルシステムのファイルシステム種別 (FSTYPEにセットされる)
その後、獲得したこれらシェル変数を使って、ルートファイルシステムをrootmntが指すディレクトリ(/root)にマウントします。
local_mount_root()
{
# 略
local_device_setup "${ROOT}" "root file system"
ROOT="${DEV}"
# 略
checkfs "${ROOT}" root "${FSTYPE}"
# Mount root
# shellcheck disable=SC2086
mount ${roflag} ${FSTYPE:+-t "${FSTYPE}"} ${ROOTFLAGS} "${ROOT}" "${rootmnt?}"
mountroot_status="$?"
# 略
}
では、local_device_setupは、ルートデバイスのデバイスファイル名とそのファイルシステム種別をどう取得しているのでしょうか。
local_device_setupシェル関数によるルートファイルシステム種別の取得
local_device_setupシェル関数の前半です。
local_device_setup()
{
local dev_id="$1"
# wait-for-rootのタイムアウト時間に関する処理などは、略
case "$dev_id" in
UUID=*|LABEL=*|PARTUUID=*|/dev/*)
# wait-for-rootが出力するファイルシステム種別がFSTYPEにセットされる
FSTYPE=$( wait-for-root "$dev_id" "$slumber" )
;;
#略
local_device_setupの第一引数には、${ROOT}が渡されます。すでに述べたとおり、${ROOT}には"UUID=..."がセットされているため、wait-for-rootコマンドを実行するルートに入ります。
wait-for-rootコマンドは、コマンド引数で指定したルートデバイスのデバイスファイル生成を待ち、生成後そのファイルシステム種別を表示するコマンドです。wait-for-rootという名前は、この挙動に由来すると思われます。
話が細かくなるので、wait-for-rootコマンドの実装については、Appendixの「wait-for-rootコマンドの実装」で扱います。
さらに後半を見ます。
if ! real_dev=$(resolve_device "${dev_id}") ||
! get_fstype "${real_dev}" >/dev/null; then
# 見つかるのに時間がかかっている場合の特殊な処理。略
fi
# 見つからなかった場合の処理について、略
fi
DEV="${real_dev}"
resolve_deviceはscripts/functionに実装されているシェル関数です。このシェル関数は、ルートデバイスのデバイスファイルを示す文字列、つまり第一引数で渡された"UUID=..."をデバイスファイルの絶対パスに変換して、それをシェル変数DEVにセットします。
この関数では、blkid -l -t rootdevstr -o device
コマンドを用いて変換します。
私の環境で実行した場合、以下のように/dev/sda3に変換されます。
fyoshida@fyoshida-desktop:~/lab/initrd/initrdfiles/main$ cat /proc/cmdline
BOOT_IMAGE=/boot/vmlinuz-5.15.0-48-generic root=UUID=a76a3b85-0ff3-498c-b3ea-ea985e7c4c8d ro quiet splash
fyoshida@fyoshida-desktop:~/lab/initrd/initrdfiles/main$ blkid -l -t "UUID=a76a3b85-0ff3-498c-b3ea-ea985e7c4c8d" -o device
/dev/sda3
わざわざ変換する理由は、mountコマンドがサポートできない形式でルートデバイスを指定する場合があるためだと推測します。
事実、resolve_deviceには、先に紹介したコマンドで変換に失敗した場合の変換処理も実装されています。
まとめ
少なくともUbuntu22.04では、以下の流れでsystemdがinitプロセスとして起動されます。
- カーネルパラメータで特に指定のない限り、""/init"が実行される。最初に起動されるプロセスなので、プロセスIDは1である。
- /initはシェルスクリプトで、/rootにルートファイルシステムをマウントしたあとで、run-initを実行する。run-initはexecコマンドを用いて実行されるため、そのプロセスIDは1である。
- run-initではmoveマウントなどを経て、"/"ディレクトリにルートファイルシステムがマウントされる。その後、exec関数経由で/sbin/initが実行される。
- /sbin/initは/lib/systemd/systemdへのシンボリックリンクである。そのため、最終的にsystemdがinitとして実行される。exec関数を経由した実行のため、そのプロセスIDは1である。
まとめると数行で終わりますが、ここに至るまでに様々な実装を読み、知ることができました。ソース、書籍、Webを調べるだけでも、諸々を知ることができます。これらは、公私を問わないノウハウとして蓄積されますし、何より楽しいです。
今年も残り少ないですが、今年も来年もHappy Hacking!
Appendix
wait-for-rootコマンドの実装
ここでは、wait-for-rootの実装を確認します。wait-for-rootコマンドのソースコードは、initramfs-toolsのsrc/wait-for-root.c にあります。
initramfs-toolsのソースコードは、apt source initramfs-tools
で取得できます。
なお、ルートデバイスは"UUID=..."で指定されるという前提で話を進めます。
デバイスファイル名の取得
wait-for-rootコマンドは、受け取った「ルートデバイスを指すUUID」から「UUID文字列を含むデバイスファイルの絶対パス」を生成します。
int
main (int argc,
char *argv[])
{
// 略
devpath = argv[1];
if (! strncmp (devpath, "UUID=", 5)) {
strcpy (path, "/dev/disk/by-uuid/");
strcat (path, devpath + 5);
} else if (! strncmp (devpath, "LABEL=", 6)) {
やっていることは単純で、UUID文字列の前に"/dev/disk/by-uuid/"という文字列をつけるだけです。
実際に/dev/disk/by-uuidを確認すると、/dev/sda3を指すシンボリックリンクがあり、その名前がUUID文字列となっています。
fyoshida@fyoshida-desktop:~$ ls /dev/disk/by-uuid/
0630-BB43 a76a3b85-0ff3-498c-b3ea-ea985e7c4c8d
fyoshida@fyoshida-desktop:~$ ls -l /dev/disk/by-uuid/
合計 0
lrwxrwxrwx 1 root root 10 9月 24 09:18 0630-BB43 -> ../../sda2
lrwxrwxrwx 1 root root 10 9月 24 09:18 a76a3b85-0ff3-498c-b3ea-ea985e7c4c8d -> ../../sda3
ファイルシステム種別の取得
次に、netlink経由でudevdからイベントを受け取れるよう準備をします。netlinkとudevdの関係については、このQiitaの記事もしくはここ(英語ですが、よくまとまっています)を参照ください。
/* Connect to the udev monitor first; if we stat() first, the
* event might happen between the stat() and the time we actually
* get hooked up.
*/
udev = udev_new ();
udev_monitor = udev_monitor_new_from_netlink (udev, "udev");
udev_monitor_filter_add_match_subsystem_devtype (udev_monitor, "block", NULL);
udev_monitor_enable_receiving (udev_monitor);
udev_monitor_set_receive_buffer_size(udev_monitor, 128*1024*1024);
"udev_"というプレフィックスがつく関数群は、libudevのものです。libudevのソースコードは、apt source libudev-dev
で取得できます。
libudevの基本的な解説は、ここ を参照ください。また、呼び出している関数群の仕様については、以下のmanを参照ください。
関数 | manへのリンク |
---|---|
udev_new | https://manpages.debian.org/bullseye/libudev-dev/udev_new.3.en.html |
udev_monitor_new_from_netlink | https://manpages.debian.org/bullseye/libudev-dev/udev_monitor_new_from_netlink.3.en.html |
udev_monitor_filter_add_match_subsystem_devtype | https://manpages.debian.org/bullseye/libudev-dev/udev_monitor_filter_update.3.en.html |
udev_monitor_enable_receiving | https://manpages.debian.org/bullseye/libudev-dev/udev_monitor_receive_device.3.en.html |
udev_monitor_set_receive_buffer_size | https://manpages.debian.org/bullseye/libudev-dev/udev_monitor_receive_device.3.en.html |
さらに、先に生成した「UUIDを用いたデバイスファイルの絶対パス」が存在するか、stat()で確認します。存在する場合、libudevを使ってルートファイルシステムのファイルシステム種別を取得し、それを標準出力に出力します。引用したソースコードが、これに該当します。
/* Check to see whether the device exists already on the filesystem.
* If this is true, we don't need to wait for it can obtain the
* filesystem type by looking up the udevdb record by major/minor.
*/
if (stat (path, &devstat) == 0 && S_ISBLK (devstat.st_mode))
{
udev_device = udev_device_new_from_devnum (udev, 'b', devstat.st_rdev);
if (udev_device) {
type = udev_device_get_property_value (udev_device, "ID_FS_TYPE");
if (type) {
printf ("%s\n", type);
udev_device_unref (udev_device);
goto exit;
}
ここでポイントとなるのは、先にudevdからイベントを受け取れる状態にした上で、デバイスファイルの存在を確認する点です。
仮に、デバイスファイルの存在を確認した(処理1)後に、udevdからイベントを受け取れるよう準備をする(処理2)場合、処理1と処理2の間にデバイスファイルが生成された場合にそれを検出できません。
よって、udevdからイベントを受け取れるよう準備をするのが先となります。
なお、デバイスファイルが存在しない場合、udevdからデバイスファイル生成のイベントを受け取るまで待ち、受け取った後は上記同様の処理を行います。ソースコードの引用は省略します。