CentOSでのルートのoverlay化

# はじめに
## 概要
最近のLinuxにはoverlayfsという仕組みがあり、対象のファイル・ディレクトリ構造を、ベースとなるread-onlyな領域と、変更差分を保存する領域とに分離して管理することができます。
しかし、ルートディレクトリはシステム起動に直に関わる部分であるため、CentOSの標準ではoverlayfsをルートとする手段が提供されていません。
そこで、CentOS標準のdracutを拡張し、ルートディレクトリをoverlay化する方法を試してみました。

話としては、ArchLinuxですが[パッケージインストールからの変更をoverlayで管理する](/suracuto/items/58ab360d137c142fd093)と似たようなものになるかと思います。

なお念のためですが、**自身の環境で試す場合には自己責任でお願いします**。

## 環境
実行環境としてはCentOS7.4を使用しました。

# 予備知識
## overlayfs
overlayfsとは、[はじめに](#はじめに)で触れた通り、「ベースとなるread-onlyな領域と、変更差分を保存する領域とに分離して管理する」ファイルシステムです。
※色々眺めていると、Dockerを使用する文脈で登場することが多いでしょうか。

何が嬉しいかと言うと、例えば次のようなコトを実現できるからです

* 変更差分を別に管理できるため、一気に以前の状態にロールバックするのが容易
* 複数サーバで似たようなファイルシステムを用意する時に、共通部分をNFS共有なんかで括りだして管理をまとめることができる  
  しかも変更部分をノード毎に分離できるため、共通部分への影響を考えなくていい

前者は、よくネットカフェのPCにあるような「再起動したら元の状態に復元」を容易に実現できることが期待できますし、後者は多数の類似サーバをディスクレス管理するのに生かせそうです。

ただし、ext系やxfsのように何らかのデバイス上に直接ファイルシステムを構築する方式とは違い、何らかベースになるようなファイルシステムを用意して、それをマージして使う方式であることに注意が必要です。が、逆に言えば標準的なファイル操作でベース部分を操作すれば色々できるということで、ファイルシステム固有の特殊な手順というのを意識する必要がありません。これも良いところです。具体的な話は[実践! OverlayFS](/awakia/items/af9b46c322905cdce1d7)が参考になるかと思います。

なお、overlayfs の機能は Linux kernel 3.18 でマージされた、となっていますが、CentOS7 の kernel 3.10系 にもバックポートされているようで、CentOS7.4 ならば最小インストール状態でも使うことができます。

## initramfsとdracut
### initramfsとは
Linux kernel は、各種デバイスドライバ、ファイルシステム等様々な機能をモジュールとして分離しています。このモジュールの実体は個々の .ko ファイルですから、

* ファイルシステムを認識するにはモジュールファイルが必要
* モジュールファイルをロードするには先にファイルシステムを認識する必要がある

という鶏と卵状態になってしまいます。

そのため、Linux が起動する際、ブートローダである grub等は、kernel と共に初期処理用のファイル一式 ( カーネルモジュール含む ) を詰め込んだイメージをロードし、主に本命のルートファイルシステムをマウントするまでの処理を実現します。このイメージ(或いは初期処理用に展開されたファイルシステム)のことをinitramfsと言っています。
この初期処理でルートファイルシステムが / でない仮の場所にマウントされた後は、switch_rootコマンド或いはsystemd(systemctl)の持つswitch-rootの機能により、ルートファイルシステムの切り替わりが、initramfsが展開された初期ルートファイルシステムから本命のルートファイルシステムへと行われ、更なる初期化処理や各種サービスの起動へと移っていきます。

### dracutとは
dracutとは、上述のinitramfsによる初期化処理用のフレームワークであり、また、initramfs作成ツールであるdracutコマンドを含んでいます。
dracutはモジュール構成をとっており、初期化において必要な機能 ( 例えば lvm だとか、iscsiだとか… ) を個々のモジュールとして管理し、initramfsに組み込みます。

また、dracutはinitramfs上での処理を各ステージに分けており、個々のステージからモジュール毎に組み込まれたフックを呼び出すことでLinux起動時の処理を実現します。ステージの概要については[dracut.modules(7)のBOOT PROCESS STAGES](http://man7.org/linux/man-pages/man7/dracut.modules.7.html#BOOT_PROCESS_STAGES)をご参照ください。フックの実装は当該サーバの構成をハードコーディングするような形で行うことも可能ですが、grub等ブートローダから渡される起動時オプションをパラメータとして活用するように柔軟に行うことも可能です。一般には後者の方が好ましいと思います。

ちなみに、フックを含めモジュールの実体はシェルスクリプトです。initramfs作成時のモジュール組み込みを制御する`module-setup.sh`と、initramfsの中に組み込まれ、フックの処理を実現する各種スクリプトからなります。なお、フックや、必要なカーネルモジュール・ツール等は`module-setup.sh`の中で、initramfsに組み込むよう指示します。

# ルートのoverlay化
## 実現方針
ルートをoverlay化するに際し、initramfsを使う処理のどのステージでoverlayfsマウントするかを検討します。

ext系やxfs、nfsといった、直接マウントすることで即ファイルシステムとして使える場合であれば、mountフックでマウントする処理を書けば済む話です。しかし、overlayfsの場合はベースとなるファイルシステムを何かしら先にマウントしなければなりません。それを一つ一つパラメータとして与えるのも面倒なので、**overlayで差分を管理する領域の情報のみを追加する**ようにしました。パラメータ名は ov.XX としています。

どうなるかというと、

* 標準 … カーネルパラメータ root=XX で、XX を新ルートディレクトリにマウント、スイッチ
* overlay … カーネルパラメータ root=XX で仮の新ルートとしてマウント、ov.dev=YY で、YYをupper,work を含む領域としてマウント、仮の新ルートをlowerとして overlay を新ルートディレクトリにマウント、スイッチ

というように起動時の挙動を変えます。なので、ステージとしてはpre-pivot ( ルートディレクトリをマウントした後、スイッチする前のステージ ) でけりをつけることになります。

## モジュールの実装
### ファイル構成
今回モジュール名は`ovroot`とし、`/usr/lib/dracut/modules.d/99ovroot`に以下のファイルを生成しました。

* `module-setup.sh`  
  initramfsへ(dracutの)モジュールを組み込む際の設定を記述する
* `ovroot.sh`  
  フックの実体。`module-setup.sh`の中で、pre-pivotステージのフックとして組み込むよう指定する。

なお、ノリで付けたサブディレクトリ名の99は優先順位めいたものかと思うのですが、詳細は把握しておりません。少なくとも同一ステージでの各モジュールでのフックの処理順序は別途指定しますので、違うのではないかと思います。

### モジュール組み込み
以下のような`module-setup.sh`を用意しました。

```bash:module-setup.sh
#!/bin/bash
# -*- mode: shell-script; indent-tabs-mode: nil; sh-basic-offset: 4; -*-
# ex: ts=8 sw=4 sts=4 et filetype=sh
 
check() {
    modprobe -n overlay >/dev/null 2>&1 && return 255
    return 1
}
 
depends() {
    echo bash
    return 0
}
 
installkernel() {
    hostonly='' instmods overlay
    return 0
}
 
install() {
    inst_multiple find rsync
    inst_hook pre-pivot 99 "$moddir/ovroot.sh"
}
```

このファイルは、シェルスクリプトではあるのですが単独で動作するわけではなく、ライブラリとしてdracutからsourceされ使われます。ここではモジュールの設定に従い幾つかの関数を定義するのですが、今回は`check`,`depends`,`installkernel`,`install`の4関数を定義しました。

* check  
  返り値により、モジュールの組み込み可否を決定します。0ならば無条件組み込み、1ならば無条件除外、255ならば明示的に指定された場合のみ組み込み、となります。  
  一応今回は、kernelがoverlayに対応しているかどうかだけをチェックしています。他にも、必要なツールが揃っているかなどをチェックして組み込むかどうかを切り替えることができます。
* depends  
  依存関係にあるdracutの他モジュールを、標準出力への出力により指定します。今回フックとして組み込むスクリプトはbash依存の書き方をしているので、標準のbashモジュールも同時に組み込まれるようにしています。
* instkernel  
  initramfsに組み込むkernelモジュールを指定する関数です。dracutの用意している`instmods`関数によりoverlayカーネルモジュールを組み込みます。
* install  
  モジュールのインストール処理本体です。必要になるツールを`inst`や`inst_multiple`関数で組み込みます。今回はfind,rsyncを指定しています。また、フックを`inst_hook`関数で組み込みます。今回は、pre-pivotステージの最後(優先度99)ということで、もう一つの`ovroot.sh`を指定しています。

### フックの実体
以下のような`ovroot.sh`を用意しました。

```bash:ovroot.sh
#!/bin/bash
# -*- mode: shell-script; indent-tabs-mode: nil; sh-basic-offset: 4; -*-
# ex: ts=8 sw=4 sts=4 et filetype=sh
 
# setup or manipulate overlay'ed root
 
OV_BASE=/run/ov
OV_ROOT=$OV_BASE/root
 
my_try() {
    [ -n "$MY_DIVED_INTO_SUBSHELL" ] && return 0
    local fd
    exec {fd}>&1
    MY_EXCEPTION=$(
        set -e
        exec {fd2}>&$fd
        eval "exec $fd>&1 >&$fd2 $fd2>&-"
        MY_DIVED_INTO_SUBSHELL=1 MY_EFD=$fd ${FUNCNAME[1]} "$@"
    )
    MY_TRY_RET=$?
    exec {fd}>&-
    return 1
}
 
# mount the additional device for overlay
ov_mount_dev() {
    my_try
    if [ $? != 0 ]; then
        # some recovery operation; see $MY_EXCEPTION for excepctions happened
        return $MY_TRY_RET
    fi
    mkdir "$OV_BASE"
    mount -t "${OVTYPE:-xfs}" -o "$OVOPT" "$OVDEV" "$OV_BASE"
    [ -d "$OV_ROOT" ] || mkdir -m 700 "$OV_ROOT"
    for d in "$OV_ROOT"/{lower,upper{,/etc},work}; do
        [ -d $d ] || mkdir $d
    done
    mount --bind "$NEWROOT" "$OV_ROOT/lower"
}
 
remount_rw_lower() {
    [ "$ROOT_RO" != 1 || "$OVRW" = 1 ] && return 0
    mount -o remount,rw "$OV_ROOT/lower"
    OVRW=1
}
 
remount_ro_lower() {
    [[ "$ROOT_RO" != 1 || "$OVRW" = 1 ]] || return 0
    mount -o remount,ro "$OV_ROOT/lower"
}
 
ov_clear() {
    rm -rf * .{[^.],??*}
}
 
ov_mount() {
    if [ ! -e etc/fstab ]; then
        sed -E 's/^([[:space:]]*[^#[:space:]]+[[:space:]]+\/[[:space:]])/#\1/' ../lower/etc/fstab > etc/fstab
    fi
    modprobe overlay
    umount "$NEWROOT"
    mount -t overlay ov -o "lowerdir=$OV_ROOT/lower,upperdir=$OV_ROOT/upper,workdir=$OV_ROOT/work" "$NEWROOT"
}
 
ov_sync() {
    remount_rw_lower
    rsync -av ./ ../lower/ \
    && find ../lower -type c \
        | xargs -d \\n -L 100 stat -c '%t,%T %n' \
        | sed -ne 's/^0,0 //p' \
        | xargs -d \\n -L rm -f
}
 
ov_main() {
    my_try
    if [ $? != 0 ]; then
        # some recovery operation; see $MY_EXCEPTION for exceptions happened
        return $MY_TRY_RET
    fi
 
    cd "$OV_ROOT/upper"
 
    OV_MOUNT=1
    if [ -f .ov-clear ]; then
        ov_clear
    elif [[ -f .ov-sync || -f .ov-synconly ]]; then
        [ -f .ov-synconly ] && OV_MOUNT=0
        [ -f .ov-updatefstab ] || rm -f etc/fstab
        rm -f .ov-*
        ov_sync && ov_clear
    fi
 
    remount_ro_lower
 
    [ "$OV_MOUNT" = 1 ] && ov_mount
}
 
# get parameters
OVDEV=$(getarg ov.dev)
[ -n "$OVDEV" ] || return
OVTYPE=$(getarg ov.type)
OVOPT=$(getarg ov.opt)
getarg ro && ROOT_RO=1
 
ov_mount_dev
[ $? = 0 ] || return
ov_main
```

こちらも、dracutからはsourceされることで実行されます。なので、`getarg`等のdracut側で用意している関数を使うことができます。
※ただ関数仕様がちゃんと書いてないのが辛い…

基本的には、`ov_mount`関数で、overlayのupper,work用領域のマウント、`ov_main`関数でoverlayのマウントを行っています。

なお、overlay化処理を調整する起動時パラメータは以下の3つです。

* ov.dev  
  overlayのupper,work部分を保持するマウント対象(デバイス名等)を指定します。指定の仕方は、通常の `root=XX` と同じで、デバイスファイル名や`UUID=xxxxx`といった値を使えます。もし指定がない場合、overlay化の処理は一切行いません。
* ov.type  
  ov.devで指定された対象のファイルシステムタイプを指定します。無指定時はデフォルトでxfsと見做します。
* ov.opt  
  追加のマウントオプションを、もし必要であれば指定します。

ところで、今回の実装では、特定のファイルの存在をスイッチにすることで、幾つかの処理を行うように調整しています。

* `/.ov-clear`  
  overlayマウント前に、差分(upper領域)をクリアします。つまりoverlay化した時点、或いは最後に同期を取った時点まで、ファイルシステムの状態がロールバックされることになります。
* `/.ov-sync`,`/.ov-synconly`  
  差分(upper領域)の内容を、lower領域である、元のルートに反映(同期)します。`/.ov-synconly`の場合、反映だけしてoverlayマウントはしません ( 素のルートをそのまま使います )。モジュールと一緒にrsyncを組み込んだのはこのためです。なお、削除されたファイルは、メジャー番号・マイナー番号が共に0のキャラクタデバイスとして見えますので、find,statを組み合わせて削除しています。
* `/.ov-updatefstab`  
  上述の同期の際、fstabファイルも同期対象とします。詳細は後述しますが、fstabはoverlay化のために書き換えを行いますので、デフォルトでは同期対象から外しているのです。

なお、fstabファイルでのルートディレクトリのエントリはoverlay化していない状態のものですので、そのままだと矛盾が生じます。だからといって手動でコメントアウトするようだと、overlayを一時的にでも取り止めたい場合にちょっと面倒になります。( read-onlyからread-writeに自動再マウントしてくれなくなる )
そのため、overlay化とともにfstabを一時的に書き替えて、ルートディレクトリエントリを一時コメントアウトしています。


## 試験動作
### initramfsの作成
前項の通り、`/usr/lib/dracut/modules.d`以下にファイルを用意したら、次のコマンドにより新しいinitramfsを作成します。

```shell-session:initramfs作成
# mv /boot/initramfs-$(uname -r).img{,.org}
# dracut --add ovroot /boot/initramfs-$(uname -r).img
```

そのままだと既存のファイルを上書きできないので、一旦現行のinitramfsは退避しておきます。( 何かあった時に戻せるように、という意味もあります )

ちなみに、作成されたinitramfsはgzip圧縮されたcpioアーカイブですので、展開することで内容を確認することができます。以下から、組み込んだ`ovroot.sh`が、dracutのpre-pivotフックとして、initramfs内の`/lib/dracut/hooks/pre-pivot/`に保存されていることが分かります。

```shell-session:initramfs内容確認
# mkdir /tmp/initramfs
# cd /tmp/initramfs
# gzip -cd /boot/initramfs-$(uname -r).img | cpio -id
# ls -l lib/dracut/hooks/pre-pivot/
total 16
-rwxr-xr-x 1 root root 8679 Apr  2 18:08 85-write-ifcfg.sh
-rwxr-xr-x 1 root root 2498 Apr  2 18:08 99-ovroot.sh
```

### 起動オプションの調整
続いては起動オプションの調整です。CentOS7のgrub2の場合、`/etc/default/grub`に設定を書いたのち、`grub2-mkconfig`で反映させます。( 昔のgrub1の時のように、直接grub.cfgを書き換えることはしません )

```text:/etc/default/grub
#GRUB_CMDLINE_LINUX="crashkernel=auto rd.lvm.lv=vg1/root rd.lvm.lv=vg2/swap rhgb quiet"
GRUB_CMDLINE_LINUX="crashkernel=auto rd.lvm.lv=vg1/root rd.lvm.lv=vg2/swap rd.lvm.lv=vg2/ov ov.dev=/dev/mapper/vg2-ov rhgb quiet"
```

今回の環境は、もともとLVM構成で、vg1ボリュームグループのrootロジカルボリュームをルートにしていました。そこに vg2ボリュームグループのovロジカルボリュームをoverlay用の領域として指定します。( 予めロジカルボリュームを作って、xfsでフォーマットしておきます )
これは、`ov.dev=/dev/mapper/vg2-ov` というパラメータ指定の部分が該当します。
なお、LVMの場合、別途ロジカルボリュームを認識するためのパラメータが必要です。( これはoverlayに関わらず、です )
インストール時に生成されている`rd.lvm.lv=XX/XX`を真似て、`rd.lvm.lv=vg2/ov` というパラメータも追加しておきます。

余談ですが、起動時のdracutの動きを追いたい場合は、`rd.shell rd.debug log_buf_len=1M`あたりを追加しておくと、起動後に`journalctl`でログを拾うことができます。
※`rd.debug`だけでも良いかも知れませんが試していません。

`grub2-mkconfig`で書き換える設定ファイルですが、今回はUEFI起動するようにセットアップされている環境だったため`/boot/efi/EFI/centos/grub.cfg`でした。Legacy BIOS起動の場合は`/boot/grub2/grub.cfg`になると思います。

```shell-session:grub設定反映
# grub2-mkconfig -o /boot/efi/EFI/centos/grub.cfg
```

### overlay化確認
設定を済ませたら再起動します。

上がってきたところでマウント状況を見てみると次のようになっています。( 一部抜粋 )

```shell-session:マウント状況確認
$ df -h
Filesystem                 Size  Used Avail Use% Mounted on
ov                          18G   38M   18G   1% /
devtmpfs                   1.9G     0  1.9G   0% /dev
tmpfs                      1.9G     0  1.9G   0% /dev/shm
tmpfs                      1.9G  8.5M  1.9G   1% /run
tmpfs                      1.9G     0  1.9G   0% /sys/fs/cgroup
/dev/mapper/vg2-ov          18G   38M   18G   1% /run/ov
/dev/vda2                 1014M  155M  860M  16% /boot
/dev/vda1                  200M  9.8M  191M   5% /boot/efi
# grep overlay /proc/mounts 
ov / overlay rw,relatime,lowerdir=/run/ov/root/lower,upperdir=/run/ov/root/upper,workdir=/run/ov/root/work 0 0
```

overlayfsという、直接にはデバイス等と紐づかないファイルシステムであるため、今回のdracut/ovrootモジュール内で指定した`ov`という名前で現れます。

なお、overlayのベースとなるファイルシステムは`/run/ov`にマウントした状態になっているため、そこから差分を確認することができます。
※ただし、lower,upper,workの各領域に直接書き込み削除をするのは動作保証外です。操作する場合はoverlayマウントしてない時にしてください。

次のように、色々見てみると、既にupper領域に幾つか差分が保存されていることが分かります。

```shell-session:overalyのベースを覗き見る
# ls -ld /run/ov/root/upper/*/*
-rw-r--r--  1 root root 629 Apr  2 17:31 /run/ov/root/upper/etc/fstab
-r--r--r--. 1 root root  33 Apr  1 14:34 /run/ov/root/upper/etc/machine-id
drwxr-xr-x. 2 root root  28 Apr  1 14:34 /run/ov/root/upper/etc/tuned
drwxr-xr-x. 7 root root  90 Apr  1 15:17 /run/ov/root/upper/var/lib
drwxr-xr-x. 4 root root 134 Apr  2 17:31 /run/ov/root/upper/var/log
drwxr-xr-x. 3 root root  21 Apr  1 14:34 /run/ov/root/upper/var/spool
drwxrwxrwt. 2 root root   6 Apr  2 17:31 /run/ov/root/upper/var/tmp
# sha1sum /var/log/boot.log /run/ov/root/{lower,upper}/var/log/boot.log
1b190f9dd15dd07a048e6562c6fe6fecfd5516b6  /var/log/boot.log
680c420a2cc8bed3ba6d660485be921f1e20db38  /run/ov/root/lower/var/log/boot.log
1b190f9dd15dd07a048e6562c6fe6fecfd5516b6  /run/ov/root/upper/var/log/boot.log
# diff /run/ov/root/lower/etc/fstab /etc/fstab
9c9
< /dev/mapper/vg1-root /                          xfs     defaults        0 0
---
> #/dev/mapper/vg1-root /                          xfs     defaults        0 0
```

その中でも、`/var/log/boot.log`で試しに内容を比較してみると、lower にあるファイルを上書きする形でupperにある差分が実際のファイルとして見えていることが分かります。
fstabeについても、実装したdracut/ovrootモジュールの作用により、ルート部分がコメントアウトされていることが分かります。

なお、`/.ov-XX`ファイルによる制御も試したのですが、ここでは詳細は割愛します。実際の挙動としては、次のようになっています。

* overlay化されている状態で `/.ov-XX` ( `/.ov-clear`等 ) をtouchする
* 差分としてupper領域にファイルが生成される
* その後再起動すると、次回起動時のdracut/ovrootモジュールにより、upper領域にできた`/.ov-XX`ファイルに従って処理が行われる ( 処理が済んだら`/.ov-XX`ファイルは削除される )


# 終わりに
というわけでまとまりがありませんが、試してみた時の一連の話を載せました。今回は試していませんが、NFS上のoverlayも、パラメータを調整すればそのままいけるんじゃないかと思います。
dracutについては、なかなかチュートリアルに使えるドキュメントが見つからず手探りだったのですが、分かってしまうと比較的容易に扱えるんではないかと思います。この記事が参考になれば幸いです。