CUBE SUGAR CONTAINER

技術系のこと書きます。

Ubuntu で Linux カーネルのバージョンを変更する

たまに、新しい機能が使いたいなどの理由で、Linux カーネルのバージョンを新しくしたいときがある。 そんなとき、Ubuntu であればビルド済みのパッケージが提供されているため、比較的容易にカーネルを入れ替えることができる。

使った環境は次のとおり。

$ lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description:    Ubuntu 22.04 LTS
Release:    22.04
Codename:   jammy
$ uname -srm
Linux 5.15.0-33-generic aarch64

もくじ

下準備

パッケージをダウンロードするために、あらかじめ wget をインストールしておく。

$ sudo apt-get update
$ sudo apt-get install wget

パッケージをダウンロードする

Ubuntu は Kernel PPA でビルド済みの Linux カーネルを提供している。 以下のメインラインカーネルは、不具合の調査や次期リリースに向けたバージョン選定などを目的としているらしい。

kernel.ubuntu.com

上記から、お目当てのバージョンで、必要なパッケージを選んでダウンロードしてくる。 今回は、現時点 (2022-05) でメインラインカーネルとして提供されている最新バージョンの v5.18 を選んだ。

まずは、アーキテクチャに依存しない linux-headers パッケージはどの環境でも必要になる。

$ wget https://kernel.ubuntu.com/~kernel-ppa/mainline/v5.18/amd64/linux-headers-5.18.0-051800_5.18.0-051800.202205222030_all.deb

そして、次にアーキテクチャに依存したパッケージをそれぞれダウンロードする。 今回は ARM64 環境なので、それに対応するものを。 名前に 64k がつくものは ARM64 環境でページサイズを 64KB に拡張したビルドになっているが、そちらは選んでいない。

$ wget \
  https://kernel.ubuntu.com/~kernel-ppa/mainline/v5.18/arm64/linux-headers-5.18.0-051800-generic_5.18.0-051800.202205222030_arm64.deb \
  https://kernel.ubuntu.com/~kernel-ppa/mainline/v5.18/arm64/linux-image-unsigned-5.18.0-051800-generic_5.18.0-051800.202205222030_arm64.deb \
  https://kernel.ubuntu.com/~kernel-ppa/mainline/v5.18/arm64/linux-modules-5.18.0-051800-generic_5.18.0-051800.202205222030_arm64.deb

インストールする

ダウンロードが終わったら、上記を apt-get なり dpkg を使ってインストールする。

$ sudo apt-get install ./*.deb

インストールできたらシステムを再起動する。

$ sudo shutdown -r now

再起動が終わったら、新しい Linux カーネルでシステムが動作しているはず。

$ uname -srm
Linux 5.18.0-051800-generic aarch64

めでたしめでたし。

Python: 集約特徴量を作るための scikit-learn Transformer 互換クラスの実装例について

ふと、集約特徴量を作るための scikit-learn Transformer 互換な実装を巷であまり見かけないなと思った。 そこで、自作しているものを公開してみる。

使った環境は次のとおり。

$ sw_vers          
ProductName:    macOS
ProductVersion: 12.4
BuildVersion:   21F79
$ python -V                                   
Python 3.9.12
$ pip list | egrep "(pandas|scikit-learn)"
pandas                        1.4.2
scikit-learn                  1.1.1

もくじ

集約特徴量とは

まずは、このエントリで集約特徴量と呼んでいるものについて説明する。 あらかじめ断っておくと、一般的に明確な呼称と定義があるわけではない。 Pandas で言えば、groupby() してから agg() して、それを元のデータフレームあるいは別のデータフレームとマージしたカラムのこと。 これによって、行方向の加工だけでは得られない、予測にとって有益な情報を列方向 (= 別の行) から抽出できる。

たとえば Count Encoding なんかも、集約特徴量の一種と言える。 agg() で計算する統計量が、出現回数 (count) というだけ。 今回は、そういった特定の統計量に特化させるのではなく、汎用に使えるものが欲しいよねという気持ちがある。

どうして必要なのか

まず、集約特徴量を作るのに、特に大した労力は必要ない。 前述したとおり、groupby() してから agg() した上で、データフレームを merge() するだけで得られる。 では、何故わざわざ scikit-learn の Transformer 互換の実装が必要になるのか。 それは、新規のデータの予測に使う場合を考えてのこと。

たとえば、あらかじめ学習データとテストデータが静的な CSV ファイルとして与えられる状況を考えてみる。 この場合、テストデータに集約特徴量を付与するには、両者を単純に連結して計算しちゃうことも考えられる。 統計量を計算する対象が目的変数でなければ、テストデータにも同じデータはあるはずなので、できてしまう。 しかし、このやり方ではまったく新しいデータに特徴量を付与したくなったときに都合が良くない。 なぜなら、まったく新しいデータに適用するには、既存のデータで計算した統計量を何処かに保存しておく必要があるため。 統計量の入ったデータフレームをバラバラと管理するのは汎用性がないので、できればやりたくない。

じゃあ、どこに保存しておくかというと、何らかのインスタンスのメンバに入っていると都合が良さそう。 そして、こういった場合によく用いられるのが scikit-learn の Transformer API を実装したクラス、ということになる。 scikit-learn の Transformer は、学習するだけの fit() メソッドと、適用するだけの transform() メソッドがある。 これによって、fit() で統計量を計算して、transform() でデータフレームとマージ、と処理が分けられる。

下準備

前置きが長くなったけど、ここからは実際にサンプルコードを示していく。 あらかじめ、必要なパッケージをインストールしておく。

$ pip install pandas scikit-learn

実装例

以下に集約特徴量を生成するための scikit-learn Transformer 互換の API を備えたクラスを示す。 使い方については後述する。

from __future__ import annotations

from typing import Callable
from typing import Iterable
from typing import Union

import pandas as pd
from sklearn.base import TransformerMixin
from sklearn.base import BaseEstimator


class AggregationEncoder(TransformerMixin, BaseEstimator):
    """集約特徴量を生成するための scikit-learn Transformer 互換 API を備えたクラス

    pandas の DataFrame が入力と出力になる
    """

    def __init__(
        self,
        group_keys: list[Union[str, Iterable[str]]],
        group_values: list[str],
        agg_methods: list[Union[str, Callable]],
    ):
        """

        :param group_keys: グループ化に使うカラム名
        :param group_values: 集約特徴量を計算するカラム名
        :param agg_methods: 計算する集約特徴量の種類 (統計量)
        """
        self.group_keys = group_keys
        self.group_values = group_values
        self.agg_methods = agg_methods

        # エンコードしたカラム名の一覧
        self.encoded_cols_: list[str] = []
        # Aggregation したデータフレームを記録しておく場所
        self._group_df_agg: list[tuple[Union[str, Iterable[str]], pd.DataFrame]] = []

    def fit(self, input_df: pd.DataFrame) -> TransformerMixin:
        """集約特徴量の生成に使う統計量を学習する

        :param input_df: 集約特徴量を生成するための統計量を求めるデータ
        """
        for agg_key in self.group_keys:
            for agg_method in self.agg_methods:

                # 集約方法の名前を作る
                if isinstance(agg_method, str):
                    # 文字列ならそのまま使う
                    agg_method_name = agg_method
                elif callable(agg_method):
                    # 呼び出し可能オブジェクトは名前を取り出して使う
                    agg_method_name = agg_method.__name__
                else:
                    raise ValueError(
                        "'agg_methods' must be a list of str or callable objects"
                    )

                for agg_value in self.group_values:
                    # 集約特徴量を作るために必要なカラムを求める
                    need_cols = [agg_value]
                    if isinstance(agg_key, str):
                        need_cols += [agg_key]
                    elif isinstance(agg_key, Iterable):
                        need_cols += list(agg_key)
                    else:
                        raise ValueError(
                            "'group_keys' must be a list of str or iterable objects"
                        )
                    # 集約特徴量を作るための計算
                    df_group_by = input_df[need_cols].groupby(agg_key)
                    df_agg = df_group_by[[agg_value]].agg(agg_method)

                    # 追加するカラムの名前を生成する
                    agg_key_name = (
                        agg_key if isinstance(agg_key, str) else "".join(agg_key)
                    )
                    # 名前の生成パターンが気に食わないときはココを変更する
                    col_name = "agg_{0}_{1}_groupby_{2}".format(
                        agg_method_name,
                        agg_value,
                        agg_key_name,
                    )
                    df_agg.columns = [col_name]

                    self.encoded_cols_.append(col_name)
                    self._group_df_agg.append((agg_key, df_agg))
        return self

    def transform(self, input_df: pd.DataFrame) -> pd.DataFrame:
        """集約特徴量を生成する

        :param input_df: 学習済みの集約特徴量を生成する対象となるデータ
        :return: 生成された集約特徴量
        """
        # 副作用が生じないようにコピーする
        new_df = input_df.copy()

        # 生成対象のデータフレームとマージしていく
        for group_key, df_agg in self._group_df_agg:
            new_df = pd.merge(
                new_df, df_agg, how="left", right_index=True, left_on=group_key
            )

        # 元々あったカラムは落とす
        new_df.drop(input_df.columns, axis=1, inplace=True)

        return new_df

    def fit_transform(self, input_df: pd.DataFrame) -> pd.DataFrame:
        """集約特徴量を学習して、同時に生成まで行う

        :param input_df: 集約特徴量を生成する対象のデータ
        :return: 生成された集約特徴量
        """
        self.fit(input_df)
        return self.transform(input_df)

    def get_params(self, deep=True) -> dict[str, object]:
        return {
            "group_keys": self.group_keys,
            "group_values": self.group_values,
            "agg_methods": self.agg_methods,
        }

上記を aggregation.py とか適当な名前で保存しておこう。

使ってみる

上記を利用するサンプルコードをいくつか書いてみよう。 上記のファイルがあるのと同じディレクトリで Python の REPL を立ち上げる。

$ python

基本的な使い方

たとえば、以下のような Pandas のデータフレームがあるとする。

>>> data = {
...     "group": ["A", "A", "A", "B", "B", "C"],
...     "value": [10, 20, 30, 40, 50, 60],
... }
>>> import pandas as pd
>>> df = pd.DataFrame(data)
>>> df
  group  value
0     A     10
1     A     20
2     A     30
3     B     40
4     B     50
5     C     60

上記から AggregationEncoder を使って集約特徴量を計算する準備をする。 まず、グルーピングのキーとして group_keys 引数にリストで group カラムを指定する。 同様に、バリューとして group_values 引数にリストで value カラムを指定する。 計算する統計量としては、agg_methods 引数で出現回数 (count) と平均 (mean) を指定した。

>>> from aggregation import AggregationEncoder
>>> encoder = AggregationEncoder(
...     group_keys=["group"],
...     group_values=["value"],
...     agg_methods=["count", "mean"]
... )

先ほどのデータフレームに対して fit_transform() メソッドを呼び出すと、次のとおり集約特徴量が生成される。 それぞれ、グループの出現回数と平均値になっている。

>>> encoder.fit_transform(df)
   agg_count_value_groupby_group  agg_mean_value_groupby_group
0                              3                          20.0
1                              3                          20.0
2                              3                          20.0
3                              2                          45.0
4                              2                          45.0
5                              1                          60.0

まったく新しいデータに適用する

前述したとおり、scikit-learn の Transformer にしたからには、新規のデータに適用したい。 次はそれを試してみよう。

以下のとおり、新しいデータフレームを用意する。 このデータフレームには、学習したデータには含まれていなかった Z という値も含まれている。

>>> data = {
...     "group": ["A", "B", "C", "Z"],
... }
>>> test_df = pd.DataFrame(data)
>>> test_df
  group
0     A
1     B
2     C
3     Z

先ほど学習させた AggregationEncodertransform() メソッドを呼び出してデータフレームを渡す。 すると、学習データの統計量を元に集約特徴量が生成されることがわかる。 初見の値については NaN になっている。

>>> encoder.transform(test_df)
   agg_count_value_groupby_group  agg_mean_value_groupby_group
0                            3.0                          20.0
1                            2.0                          45.0
2                            1.0                          60.0
3                            NaN                           NaN

特徴量を総当り的に作る

こういった特徴量は、総当り的にばーっと作って、とりあえずモデルに突っ込むというパターンが頻出する。 なので、そういったニーズも満たせるように作ってある。

たとえば、次のようにカテゴリ変数と連続変数が 2 つずつあるようなデータフレームを考える。

>>> import numpy as np
>>> data = {
...     "group1": ["A", "A", "A", "B", "B", "C"],
...     "group2": ["x", "y", "z", "x", "y", "z"],
...     "value1": [10, 20, 30, 40, 50, 60],
...     "value2": [np.nan, 500, 400, 300, 200, 100],
... }
>>> df = pd.DataFrame(data)
>>> df
  group1 group2  value1  value2
0      A      x      10     NaN
1      A      y      20   500.0
2      A      z      30   400.0
3      B      x      40   300.0
4      B      y      50   200.0
5      C      z      60   100.0

この場合は、group_keysgroup_values に計算したいカラムをリストで放り込んでおけばいい。

>>> encoder = AggregationEncoder(
...     group_keys=["group1", "group2"],
...     group_values=["value1", "value2"],
...     agg_methods=["min", "max"]
... )

あとは、「グルーピングのキー x グルーピングのバリュー x 特徴量の種類」の組み合わせで特徴量をばーっと作れる。 今回であれば「2 x 2 x 2 = 8」カラムになる。

>>> encoder.fit_transform(df)
   agg_min_value1_groupby_group1  agg_min_value2_groupby_group1  ...  agg_max_value1_groupby_group2  agg_max_value2_groupby_group2
0                             10                          400.0  ...                             40                          300.0
1                             10                          400.0  ...                             50                          500.0
2                             10                          400.0  ...                             60                          400.0
3                             40                          200.0  ...                             40                          300.0
4                             40                          200.0  ...                             50                          500.0
5                             60                          100.0  ...                             60                          400.0

[6 rows x 8 columns]

生成されるカラム名を取得する

組み合わせが増えると、どんなカラムができるんだっけ?と後から確認したくなる。 なので、生成されるカラムの名前を encoded_cols_ メンバで得られる。

>>> from pprint import pprint
>>> pprint(encoder.encoded_cols_)
['agg_min_value1_groupby_group1',
 'agg_min_value2_groupby_group1',
 'agg_max_value1_groupby_group1',
 'agg_max_value2_groupby_group1',
 'agg_min_value1_groupby_group2',
 'agg_min_value2_groupby_group2',
 'agg_max_value1_groupby_group2',
 'agg_max_value2_groupby_group2']

複数のキーでグルーピングしたい

複数のキーを使ってグルーピングしたい、という場合もあるはず。 そんなときは group_keys の要素にリストで複数のカラムを放り込む。

>>> encoder = AggregationEncoder(
...     group_keys=[["group1", "group2"]],
...     group_values=["value1"],
...     agg_methods=["mean"]
... )

上記であれば group1group2 の両方が一致するものを、同じグループとして扱う。 今回の指定とデータフレームだと、同じグループが存在しないので value1 の値がそのまま出てしまうけど。

>>> encoder.fit_transform(df)
   agg_mean_value1_groupby_group1group2
0                                  10.0
1                                  20.0
2                                  30.0
3                                  40.0
4                                  50.0
5                                  60.0

カスタマイズした統計量を計算する

既存の統計量だけでなく、自分なりの統計量を計算したいというニーズもある。 そんなときは、各グループで層化されたデータフレームを受け取って統計量を計算する関数を用意する。 以下では、たとえば中央値を計算している。 まあ、これは単に "median" を指定すれば良いだけなんだけど。

>>> def median(x):
...     return x.quantile(0.5)
... 

上記の関数を agg_methods に放り込めば良い。

>>> encoder = AggregationEncoder(
...     group_keys=["group1"],
...     group_values=["value1"],
...     agg_methods=[median]
... )

後は何も変わらない。カラム名の中には関数名が入る。

>>> encoder.fit_transform(df)
   agg_median_value1_groupby_group1
0                              20.0
1                              20.0
2                              20.0
3                              45.0
4                              45.0
5                              60.0

まとめ

今回は、集約特徴量を作るための scikit-learn Transoformer 互換クラスの実装例を紹介した。

pivot_root について

今回は、Linux でプロセスのルートファイルシステムの場所を変更する機能の pivot_root について扱う。 プロセスのルートファイルシステムを変更するのは、古典的な chroot を使っても実現できる。 ただ、chroot は隔離したはずのルートファイルシステムから脱出できてしまう事象、いわゆる脱獄が起こりやすい仕様になっている。 そのため、Docker などの一般的な Linux コンテナの実装では pivot_root がデフォルトで使われている。 今回は、そんな pivot_root をコマンドラインツールとしての pivot_root(8) と、システムコールとしての pivot_root(2) で触ってみる。

使った環境は次のとおり。

$ lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description:    Ubuntu 20.04.4 LTS
Release:    20.04
Codename:   focal
$ uname -srm
Linux 5.4.0-109-generic aarch64
$ pivot_root --version
pivot_root from util-linux 2.34
$ gcc --version
gcc (Ubuntu 9.4.0-1ubuntu1~20.04.1) 9.4.0
Copyright (C) 2019 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
$ ldd --version
ldd (Ubuntu GLIBC 2.31-0ubuntu9.7) 2.31
Copyright (C) 2020 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
Written by Roland McGrath and Ulrich Drepper.

もくじ

下準備

下準備として、コマンドラインツールとしての pivot_root(8) が入っている util-linux をインストールしておく。 また、システムコールとしての pivot_root(2) を含む C のソースコードをビルドするために build-essential もインストールする。

$ sudo apt-get -y install util-linux build-essential

pivot_root(8) の動作を試す

まずはコマンドラインツールとしての pivot_root(8) から使ってみよう。

pivot_root を利用するには、変更先となるルートファイルシステムが必要になるので、用意する。 今回は mktemp(1) を使ってテンポラリディレクトリを作って利用する。

$ export ROOTFS=$(mktemp -d)
$ echo $ROOTFS
/tmp/tmp.pMdpBMx0Uu

ルートファイルシステムにはプロセスが利用するプログラムとライブラリの一式が必要になる。 そこで、必要そうなコマンドラインツール本体と、ダイナミックリンクされているライブラリをコピーしておく。 コピーするツールについてはお好みで。

$ COPY_CMDS=ls,cat,rm,head,mkdir,mount,umount,df
$ IFS=","
$ for CMD in ${COPY_CMDS}
do
  cp -avL --parents $(which ${CMD}) ${ROOTFS}
  ldd $(which ${CMD}) | grep -o "/lib.*\.[0-9]\+" | xargs -I {} cp -avL --parents {} ${ROOTFS}
done

上記を実行すると、コマンドラインツールと依存しているライブラリがテンポラリディレクトリ以下にコピーされる。

$ find $ROOTFS
/tmp/tmp.pMdpBMx0Uu
/tmp/tmp.pMdpBMx0Uu/usr
/tmp/tmp.pMdpBMx0Uu/usr/bin
/tmp/tmp.pMdpBMx0Uu/usr/bin/df
/tmp/tmp.pMdpBMx0Uu/usr/bin/cat
/tmp/tmp.pMdpBMx0Uu/usr/bin/mount
/tmp/tmp.pMdpBMx0Uu/usr/bin/ls
/tmp/tmp.pMdpBMx0Uu/usr/bin/head
/tmp/tmp.pMdpBMx0Uu/usr/bin/rm
/tmp/tmp.pMdpBMx0Uu/usr/bin/umount
/tmp/tmp.pMdpBMx0Uu/usr/bin/mkdir
/tmp/tmp.pMdpBMx0Uu/lib
/tmp/tmp.pMdpBMx0Uu/lib/ld-linux-aarch64.so.1
/tmp/tmp.pMdpBMx0Uu/lib/aarch64-linux-gnu
/tmp/tmp.pMdpBMx0Uu/lib/aarch64-linux-gnu/libblkid.so.1
/tmp/tmp.pMdpBMx0Uu/lib/aarch64-linux-gnu/libpcre2-8.so.0
/tmp/tmp.pMdpBMx0Uu/lib/aarch64-linux-gnu/libmount.so.1
/tmp/tmp.pMdpBMx0Uu/lib/aarch64-linux-gnu/libc.so.6
/tmp/tmp.pMdpBMx0Uu/lib/aarch64-linux-gnu/libpthread.so.0
/tmp/tmp.pMdpBMx0Uu/lib/aarch64-linux-gnu/libdl.so.2
/tmp/tmp.pMdpBMx0Uu/lib/aarch64-linux-gnu/libselinux.so.1

続いて、unshare(8) を使って Mount Namespace を新しく用意する 1ROOTFS 変数をそのまま使い続けたいので、sudo(8) するときに -E オプションを指定して環境変数を引き継いでおく。

$ sudo -E unshare --mount

ROOTFS ディレクトリをバインドマウントすることでマウントポイントにしておく。 これは pivot_root で新しいルートファイルシステムにする場所はマウントポイントである必要があるため。

# mount --bind ${ROOTFS} ${ROOTFS}

続いて、古いルートファイルシステムをマウントする場所としてディレクトリを作っておく。 これは pivot_root を実行するときに、新しいルートファイルシステムの他に古いルートファイルシステムをマウントする場所も指定する必要があるため 2

# mkdir -p ${ROOTFS}/.old-root

満を持して pivot_root(8) を実行する。

# pivot_root ${ROOTFS} ${ROOTFS}/.old-root

これで、ルートファイルシステムが先ほどテンポラリディレクトリで作ったディレクトリに切り替わった。

# ls /
lib  usr

ただし、この時点ではまだカレントワーキングディレクトリとして古いルートファイルシステムが見えてしまっている。 なので、カレントワーキングディレクトリをルートファイルシステムに変更しておこう。

# cd /

また、この時点ではまだ先ほど指定した /.old-root に古いルートファイルシステムが残っている。

# ls /.old-root
bin  boot  dev  etc  home  lib  lost+found  media  mnt  opt  proc  root  run  sbin  snap  srv  sys  tmp  usr  var

なので、アンマウントしたい。 そのために、まずは proc ファイルシステムをマウントする。 これは、どうやら umount(8) が proc ファイルシステムを見て動作しているようなので必要になる。

# mkdir /proc
# mount -t proc proc /proc
# df -Th
Filesystem     Type      Size  Used Avail Use% Mounted on
/dev/sda1      ext4      4.7G  2.9G  1.8G  62% /.old-root
udev           devtmpfs  452M     0  452M   0% /.old-root/dev
tmpfs          tmpfs     485M     0  485M   0% /.old-root/dev/shm
tmpfs          tmpfs      97M  1.1M   96M   2% /.old-root/run
tmpfs          tmpfs     5.0M     0  5.0M   0% /.old-root/run/lock
tmpfs          tmpfs      97M     0   97M   0% /.old-root/run/user/1000
tmpfs          tmpfs     485M     0  485M   0% /.old-root/sys/fs/cgroup
/dev/loop0     squashfs  128K  128K     0 100% /.old-root/snap/bare/5
/dev/loop1     squashfs   58M   58M     0 100% /.old-root/snap/core20/1437
/dev/loop2     squashfs   58M   58M     0 100% /.old-root/snap/core20/1408
/dev/loop3     squashfs   62M   62M     0 100% /.old-root/snap/lxd/22761
/dev/loop4     squashfs   62M   62M     0 100% /.old-root/snap/lxd/22530
/dev/loop6     squashfs   39M   39M     0 100% /.old-root/snap/snapd/15541
/dev/loop5     squashfs  896K  896K     0 100% /.old-root/snap/multipass-sshfs/147
/dev/loop7     squashfs   38M   38M     0 100% /.old-root/snap/snapd/15183
/dev/sda15     vfat       98M  290K   98M   1% /.old-root/boot/efi

これで、古いルートファイルシステムがアンマウントできる。

# umount -l /.old-root

これで最低限必要な作業は一通り終わった。 マウント状況を確認すると、だいぶシンプルになっている。

# df -Th
Filesystem     Type  Size  Used Avail Use% Mounted on
/dev/sda1      ext4  4.7G  2.9G  1.8G  62% /
# cat /proc/self/mountinfo
822 713 8:1 /tmp/tmp.IXvzlsgIm8 / rw,relatime - ext4 /dev/sda1 rw
823 822 0:5 / /proc rw,relatime - proc proc rw

あとは、この状態だと /dev がなかったりするのでマウントしたり。

# mkdir /dev
# mount -t devtmpfs devtmpfs /dev

pivot_root(2) の動作を試す

続いてはシステムコールとしての pivot_root(2) を使う。

先ほどと同じように、あらかじめテンポラリディレクトリに必要なコマンドラインツールとライブラリ一式をコピーしておく。 今回は bash もコピーする。

$ export ROOTFS=$(mktemp -d)
$ COPY_CMDS=bash,ls,cat,rm,head,mkdir,mount,umount,df
$ IFS=","
$ for CMD in ${COPY_CMDS}
do
  cp -avL --parents $(which ${CMD}) ${ROOTFS}
  ldd $(which ${CMD}) | grep -o "/lib.*\.[0-9]\+" | xargs -I {} cp -avL --parents {} ${ROOTFS}
done

先ほどはコマンドラインで操作していた、以降の処理はライブラリ関数やシステムコールで実行する。 以下に、そのサンプルコードを示す。 肝心の pivot_root(2) を呼び出しているのは syscall(SYS_pivot_root, argv[1], put_old_path) のところ。 pivot_root(2) は libc のラッパー関数がないので、直接 syscall(2) を使って呼び出す必要がある。

#define _GNU_SOURCE

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <sched.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/mount.h>
#include <sys/syscall.h>

int main(int argc, char *argv[]) {
    // 引数の長さをチェックする
    if (argc < 2) {
        fprintf(stderr, "Please specify the path to change root\n");
        exit(EXIT_FAILURE);
    }

    // unshare(2) で Mount Namespace を作成する
    if (unshare(CLONE_NEWNS) != 0) {
        fprintf(stderr, "Failed to create a new mount namespace: %s\n", strerror(errno));
        exit(EXIT_FAILURE);
    }

    // ルートディレクトリから再帰的にマウントのプロパゲーションを無効にする
    if (mount("none", "/", NULL, MS_REC | MS_PRIVATE, NULL) != 0) {
        fprintf(stderr, "Failed to change root filesystem propagation: %s\n", strerror(errno));
        exit(EXIT_FAILURE);
    }

    // new_root に使うディレクトリを bind mount する
    if (mount(argv[1], argv[1], NULL, MS_BIND, NULL) != 0) {
        fprintf(stderr, "Failed to bind mount new_root directory: %s\n", strerror(errno));
        exit(EXIT_FAILURE);
    }

    // put_old に使うパスを求める
    char put_old_path[256];
    if (sprintf(put_old_path, "%s/.old-root", argv[1]) < 0) {
        fprintf(stderr, "Failed to sprintf: %s\n", strerror(errno));
        exit(EXIT_FAILURE);
    }

    // put_old に使うディレクトリを作る
    if (mkdir(put_old_path, 0777) < 0) {
        fprintf(stderr, "Failed to make directory: %s\n", strerror(errno));
        exit(EXIT_FAILURE);
    }

    // pivot_root(2) したいディレクトリにカレントワーキングディレクトリを変更する
    if (chdir(argv[1]) != 0) {
        fprintf(stderr, "Failed to change directory: %s\n", strerror(errno));
        exit(EXIT_FAILURE);
    }

    // pivot_root(2) を呼び出す
    // libc のラッパー関数がないので syscall(2) で呼び出す
    if (syscall(SYS_pivot_root, argv[1], put_old_path) < 0) {
        fprintf(stderr, "Can not pivot_root: %s\n", strerror(errno));
        exit(EXIT_FAILURE);
    }

    // put_old を umount2(2) で Lazy Detach する
    if (umount2("/.old-root", MNT_DETACH) < 0) {
        fprintf(stderr, "Can not umount: %s\n", strerror(errno));
        exit(EXIT_FAILURE);
    }

    // 古いファイルシステムをマウントしていた場所は不要なので削除しておく
    if (rmdir("/.old-root") < 0) {
        fprintf(stderr, "Failed to remove directory: %s\n", strerror(errno));
        exit(EXIT_FAILURE);
    }

    // execvp(3) でシェルを起動する
    char* const args[] = {"bash", NULL};
    if (execvp(args[0], args) != 0) {
        fprintf(stderr, "Failed to exec \"%s\": %s\n", args[0], strerror(errno));
        exit(EXIT_FAILURE);
    }

    return EXIT_SUCCESS;
}

上記のソースコードをビルドする。

$ gcc --std=c11 --static -Wall pivot_root.c

上記でビルドしたバイナリを、テンポラリディレクトリを引数にして実行する。

$ sudo ./a.out ${ROOTFS}

実行すると、先ほどコマンドラインで実行した状態と同じになる 3

# ls /
lib  usr
# df -Th
Filesystem     Type  Size  Used Avail Use% Mounted on
/dev/sda1      ext4  4.7G  2.9G  1.8G  62% /
# cat /proc/self/mountinfo
822 713 8:1 /tmp/tmp.CtMCVVvCU4 / rw,relatime - ext4 /dev/sda1 rw
714 822 0:5 / /proc rw,nosuid,nodev,noexec,relatime - proc proc rw

いじょう。

まとめ

今回は pivot_root をコマンドラインツールとシステムコールから使って、プロセスのルートファイルシステムを変更してみた。


  1. 古い unshare(8) ではマウントのプロパゲーションが生じるかもしれない。必要なら $ sudo mount --make-private / を実行しておく

  2. すぐにアンマウントするなら、新しいルートファイルシステムと古いルートファイルシステムに同じ場所を指定しても良いらしい

  3. /proc や /dev はマウントしていない

Linux の Network Namespace と Keepalived でルータの冗長化を試す

今回は「Linuxで動かしながら学ぶTCP/IPネットワーク入門」に載せようか悩んで、結局は載せなかった内容のひとつを扱う。 Linux の Network Namespace を使って作った 2 台のルータを、Keepalived (VRRP) を使ってホットスタンバイで冗長化する構成を組んでみる。 つまり、2 台のうち 1 台のルータに障害が起きても、残りの 1 台が役務を引き継いで通信を継続できる状況を再現する。

使った環境は次のとおり。

$ lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description:    Ubuntu 20.04.4 LTS
Release:    20.04
Codename:   focal
$ uname -srm
Linux 5.4.0-107-generic aarch64

もくじ

下準備

あらかじめ、必要なパッケージをインストールしておく。

$ sudo apt-get -y install keepalived iproute2 iputils-ping tcpdump

構築するネットワークについて

これから組んでいくネットワークは次のような論理構成になる。 この中で rt1rt2 がルータで、Keepalived の VRRP を使って冗長化する。 冗長化はマスター・バックアップ構成で、マスターのルータは仮想 IP アドレス (以下 VIP) を自身のネットワークインターフェイスに持つ。 図では IP アドレスの 192.0.2.254198.51.100.254 が VIP を表している。

f:id:momijiame:20220401005508p:plain
ネットワークの論理構成

物理構成もあった方が分かりやすいはずだけど、描くのがちょっと大変そうなので今回は省略する。

ネットワークを構築する

まずは必要な Network Namespace を作成する。 名前は論理構成図と対応していて、ht* がホスト、sw* がスイッチ、rt* がルータを表している。

$ sudo ip netns add ht1
$ sudo ip netns add sw1
$ sudo ip netns add rt1
$ sudo ip netns add rt2
$ sudo ip netns add sw2
$ sudo ip netns add ht2

続いて、veth インターフェイスのペアを作成する。 こちらも、名前は論理構成図と対応する。

$ sudo ip link add ht1-veth0 type veth peer name ht1-sw1
$ sudo ip link add ht2-veth0 type veth peer name ht2-sw2

$ sudo ip link add rt1-veth0 type veth peer name rt1-sw1
$ sudo ip link add rt1-veth1 type veth peer name rt1-sw2

$ sudo ip link add rt2-veth0 type veth peer name rt2-sw1
$ sudo ip link add rt2-veth1 type veth peer name rt2-sw2

作成したネットワークインターフェイスを Network Namespace に所属させていく。

$ sudo ip link set ht1-veth0 netns ht1
$ sudo ip link set ht2-veth0 netns ht2

$ sudo ip link set ht1-sw1 netns sw1
$ sudo ip link set ht2-sw2 netns sw2

$ sudo ip link set rt1-veth0 netns rt1
$ sudo ip link set rt1-veth1 netns rt1

$ sudo ip link set rt1-sw1 netns sw1
$ sudo ip link set rt1-sw2 netns sw2

$ sudo ip link set rt2-veth0 netns rt2
$ sudo ip link set rt2-veth1 netns rt2

$ sudo ip link set rt2-sw1 netns sw1
$ sudo ip link set rt2-sw2 netns sw2

Network Namespace に所属させたインターフェイスの状態を UP にする。

$ sudo ip netns exec ht1 ip link set ht1-veth0 up
$ sudo ip netns exec ht2 ip link set ht2-veth0 up

$ sudo ip netns exec sw1 ip link set ht1-sw1 up
$ sudo ip netns exec sw2 ip link set ht2-sw2 up

$ sudo ip netns exec rt1 ip link set rt1-veth0 up
$ sudo ip netns exec rt1 ip link set rt1-veth1 up

$ sudo ip netns exec sw1 ip link set rt1-sw1 up
$ sudo ip netns exec sw2 ip link set rt1-sw2 up

$ sudo ip netns exec rt2 ip link set rt2-veth0 up
$ sudo ip netns exec rt2 ip link set rt2-veth1 up

$ sudo ip netns exec sw1 ip link set rt2-sw1 up
$ sudo ip netns exec sw2 ip link set rt2-sw2 up

今回は 3 つ以上のネットワークインターフェイスが同じネットワークセグメントに所属するため、ブリッジが必要になる。 Linux Bridge として用意する。

$ sudo ip netns exec sw1 ip link add dev sw1-br0 type bridge
$ sudo ip netns exec sw1 ip link set sw1-br0 up

$ sudo ip netns exec sw2 ip link add dev sw2-br0 type bridge
$ sudo ip netns exec sw2 ip link set sw2-br0 up

ブリッジにネットワークインターフェイスを接続する。

$ sudo ip netns exec sw1 ip link set ht1-sw1 master sw1-br0
$ sudo ip netns exec sw1 ip link set rt1-sw1 master sw1-br0
$ sudo ip netns exec sw1 ip link set rt2-sw1 master sw1-br0

$ sudo ip netns exec sw2 ip link set ht2-sw2 master sw2-br0
$ sudo ip netns exec sw2 ip link set rt1-sw2 master sw2-br0
$ sudo ip netns exec sw2 ip link set rt2-sw2 master sw2-br0

インターフェイスに IP アドレスを付与する。

$ sudo ip netns exec ht1 ip address add 192.0.2.1/24 dev ht1-veth0
$ sudo ip netns exec ht2 ip address add 198.51.100.1/24 dev ht2-veth0

$ sudo ip netns exec rt1 ip address add 192.0.2.251/24 dev rt1-veth0
$ sudo ip netns exec rt1 ip address add 198.51.100.251/24 dev rt1-veth1

$ sudo ip netns exec rt2 ip address add 192.0.2.252/24 dev rt2-veth0
$ sudo ip netns exec rt2 ip address add 198.51.100.252/24 dev rt2-veth1

ルータとして動作するようにカーネルのパラメータを設定する。

$ sudo ip netns exec rt1 sysctl net.ipv4.ip_forward=1
$ sudo ip netns exec rt2 sysctl net.ipv4.ip_forward=1

ホストのデフォルトルートを設定する。 ただし、ここで設定するデフォルトルートの IP アドレスは VIP になる。 つまり、現時点では何処にも設定されていない。 VIP は、この後に設定する Keepalived がマスターになったタイミングで自動的に付与する。

$ sudo ip netns exec ht1 ip route add default via 192.0.2.254
$ sudo ip netns exec ht2 ip route add default via 198.51.100.254

下準備が終わったので、まずはセグメントに閉じた疎通を確認しておく。

$ sudo ip netns exec ht1 ping -c 3 192.0.2.251
PING 192.0.2.251 (192.0.2.251) 56(84) bytes of data.
64 bytes from 192.0.2.251: icmp_seq=1 ttl=64 time=0.141 ms
64 bytes from 192.0.2.251: icmp_seq=2 ttl=64 time=0.181 ms
64 bytes from 192.0.2.251: icmp_seq=3 ttl=64 time=0.179 ms

--- 192.0.2.251 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2072ms
rtt min/avg/max/mdev = 0.141/0.167/0.181/0.018 ms

$ sudo ip netns exec ht1 ping -c 3 192.0.2.252
PING 192.0.2.252 (192.0.2.252) 56(84) bytes of data.
64 bytes from 192.0.2.252: icmp_seq=1 ttl=64 time=0.132 ms
64 bytes from 192.0.2.252: icmp_seq=2 ttl=64 time=0.151 ms
64 bytes from 192.0.2.252: icmp_seq=3 ttl=64 time=0.098 ms

--- 192.0.2.252 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2098ms
rtt min/avg/max/mdev = 0.098/0.127/0.151/0.021 ms

$ sudo ip netns exec ht2 ping -c 3 198.51.100.251
PING 198.51.100.251 (198.51.100.251) 56(84) bytes of data.
64 bytes from 198.51.100.251: icmp_seq=1 ttl=64 time=0.113 ms
64 bytes from 198.51.100.251: icmp_seq=2 ttl=64 time=0.174 ms
64 bytes from 198.51.100.251: icmp_seq=3 ttl=64 time=0.163 ms

--- 198.51.100.251 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2071ms
rtt min/avg/max/mdev = 0.113/0.150/0.174/0.026 ms

$ sudo ip netns exec ht2 ping -c 3 198.51.100.252
PING 198.51.100.252 (198.51.100.252) 56(84) bytes of data.
64 bytes from 198.51.100.252: icmp_seq=1 ttl=64 time=0.132 ms
64 bytes from 198.51.100.252: icmp_seq=2 ttl=64 time=0.162 ms
64 bytes from 198.51.100.252: icmp_seq=3 ttl=64 time=0.167 ms

--- 198.51.100.252 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2088ms
rtt min/avg/max/mdev = 0.132/0.153/0.167/0.015 ms

Keepalived でルータを冗長化する

では、ここからルータを冗長化する作業に入る。 まずは rt1 で動かす Keepalived の設定ファイルを用意する。 Keepalived 自体が Network Namespace に対応しているため、先頭の部分で動作する Network Namespace として rt1 を指定している。 ポイントは vrrp_sync_group を使っているところ。 こうすると、グループ内のいずれかの vrrp_instance に障害が起きたとき、同期して状態を切り替えることができる。

$ cat << 'EOF' > rt1-keepalived.conf
net_namespace rt1

vrrp_sync_group VirtualGroup1 {
   group {
      VirtualInstance1
      VirtualInstance2
   }
}

vrrp_instance VirtualInstance1 {
    state BACKUP
    interface rt1-veth0
    virtual_router_id 1
    priority 20
    advert_int 1
    nopreempt
    authentication {
        auth_type PASS
        auth_pass passwd
    }
    virtual_ipaddress {
        192.0.2.254/24
    }
}

vrrp_instance VirtualInstance2 {
    state BACKUP
    interface rt1-veth1
    virtual_router_id 1
    priority 20
    advert_int 1
    nopreempt
    authentication {
        auth_type PASS
        auth_pass passwd
    }
    virtual_ipaddress {
        198.51.100.254/24
    }
}
EOF

上記の設定ファイルを使って Keepalived を起動する。

$ sudo keepalived -nlP -p /var/run/keepalived1 -f rt1-keepalived.conf
Sun Apr  3 00:07:54 2022: Starting Keepalived v2.0.19 (10/19,2019)
Sun Apr  3 00:07:54 2022: WARNING - keepalived was build for newer Linux 5.4.166, running on Linux 5.4.0-107-generic #121-Ubuntu SMP Thu Mar 24 16:07:22 UTC 2022
Sun Apr  3 00:07:54 2022: Command line: 'keepalived' '-nlP' '-p' '/var/run/keepalived1' '-f' 'rt1-keepalived.conf'
Sun Apr  3 00:07:54 2022: Opening file 'rt1-keepalived.conf'.
Sun Apr  3 00:07:54 2022: Changing syslog ident to Keepalived_rt1
Sun Apr  3 00:07:54 2022: Starting VRRP child process, pid=1897
Sun Apr  3 00:07:54 2022: Registering Kernel netlink reflector
Sun Apr  3 00:07:54 2022: Registering Kernel netlink command channel
Sun Apr  3 00:07:54 2022: Opening file 'rt1-keepalived.conf'.
Sun Apr  3 00:07:54 2022: Registering gratuitous ARP shared channel
Sun Apr  3 00:07:54 2022: (VirtualInstance1) Entering BACKUP STATE (init)
Sun Apr  3 00:07:54 2022: (VirtualInstance2) Entering BACKUP STATE (init)
Sun Apr  3 00:07:57 2022: (VirtualInstance1) Entering MASTER STATE
Sun Apr  3 00:07:57 2022: VRRP_Group(VirtualGroup1) Syncing instances to MASTER state
Sun Apr  3 00:07:57 2022: (VirtualInstance2) Entering MASTER STATE

Entering MASTER STATE という表示が出れば、ルータがマスターになったことを表している。

別のターミナルを開いて rt1 の IP アドレスを確認すると、ちゃんと VIP が付与されている。

$ sudo ip netns exec rt1 ip address show | grep .254/24
    inet 192.0.2.254/24 scope global secondary rt1-veth0
    inet 198.51.100.254/24 scope global secondary rt1-veth1

同様に rt2 で動作させる Keepalived 用の設定ファイルも用意する。

$ cat << 'EOF' > rt2-keepalived.conf
net_namespace rt2

vrrp_sync_group VirtualGroup1 {
   group {
      VirtualInstance1
      VirtualInstance2
   }
}

vrrp_instance VirtualInstance1 {
    state BACKUP
    interface rt2-veth0
    virtual_router_id 1
    priority 10
    advert_int 1
    nopreempt
    authentication {
        auth_type PASS
        auth_pass passwd
    }
    virtual_ipaddress {
        192.0.2.254/24
    }
}

vrrp_instance VirtualInstance2 {
    state BACKUP
    interface rt2-veth1
    virtual_router_id 1
    priority 10
    advert_int 1
    nopreempt
    authentication {
        auth_type PASS
        auth_pass passwd
    }
    virtual_ipaddress {
        198.51.100.254/24
    }
}
EOF

上記の設定ファイルを使って rt2 用の Keepalived を起動する。

$ sudo keepalived -nlP -p /var/run/keepalived2 -f rt2-keepalived.conf
Sun Apr  3 00:47:35 2022: Starting Keepalived v2.0.19 (10/19,2019)
Sun Apr  3 00:47:35 2022: WARNING - keepalived was build for newer Linux 5.4.166, running on Linux 5.4.0-100-generic #113-Ubuntu SMP Thu Feb 3 18:43:29 UTC 2022
Sun Apr  3 00:47:35 2022: Command line: 'keepalived' '-nlP' '-p' '/var/run/keepalived2' '-f' 'rt2-keepalived.conf'
Sun Apr  3 00:47:35 2022: Opening file 'rt2-keepalived.conf'.
Sun Apr  3 00:47:35 2022: Changing syslog ident to Keepalived_rt2
Sun Apr  3 00:47:35 2022: Starting VRRP child process, pid=1300
Sun Apr  3 00:47:35 2022: Registering Kernel netlink reflector
Sun Apr  3 00:47:35 2022: Registering Kernel netlink command channel
Sun Apr  3 00:47:35 2022: Opening file 'rt2-keepalived.conf'.
Sun Apr  3 00:47:35 2022: Registering gratuitous ARP shared channel
Sun Apr  3 00:47:35 2022: (VirtualInstance1) Entering BACKUP STATE (init)
Sun Apr  3 00:47:35 2022: (VirtualInstance2) Entering BACKUP STATE (init)

こちらは Entering BACKUP STATE のまま、つまりバックアップになるので正しい。

tcpdump(1) でブリッジをキャプチャすると VRRP のパケットが流れているのが確認できる。 宛先の IP アドレス 224.0.0.18 は、VRRP ルータが所属するマルチキャストアドレス。

$ sudo ip netns exec sw1 tcpdump -tnl -i sw1-br0 vrrp
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on sw1-br0, link-type EN10MB (Ethernet), capture size 262144 bytes
IP 192.0.2.251 > 224.0.0.18: VRRPv2, Advertisement, vrid 1, prio 20, authtype simple, intvl 1s, length 20
IP 192.0.2.251 > 224.0.0.18: VRRPv2, Advertisement, vrid 1, prio 20, authtype simple, intvl 1s, length 20
IP 192.0.2.251 > 224.0.0.18: VRRPv2, Advertisement, vrid 1, prio 20, authtype simple, intvl 1s, length 20
^C
3 packets captured
3 packets received by filter
0 packets dropped by kernel
$ sudo ip netns exec sw2 tcpdump -tnl -i sw2-br0 vrrp
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on sw2-br0, link-type EN10MB (Ethernet), capture size 262144 bytes
IP 198.51.100.251 > 224.0.0.18: VRRPv2, Advertisement, vrid 1, prio 20, authtype simple, intvl 1s, length 20
IP 198.51.100.251 > 224.0.0.18: VRRPv2, Advertisement, vrid 1, prio 20, authtype simple, intvl 1s, length 20
IP 198.51.100.251 > 224.0.0.18: VRRPv2, Advertisement, vrid 1, prio 20, authtype simple, intvl 1s, length 20
^C
3 packets captured
3 packets received by filter
0 packets dropped by kernel

疎通を確認する

これで、エンドツーエンドで通信する準備が整った。 実際に ht1 から ht2 に向けて Ping を打ってみよう。

$ sudo ip netns exec ht1 ping -c 3 198.51.100.1
PING 198.51.100.1 (198.51.100.1) 56(84) bytes of data.
64 bytes from 198.51.100.1: icmp_seq=1 ttl=63 time=0.166 ms
64 bytes from 198.51.100.1: icmp_seq=2 ttl=63 time=0.192 ms
64 bytes from 198.51.100.1: icmp_seq=3 ttl=63 time=0.214 ms

--- 198.51.100.1 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2087ms
rtt min/avg/max/mdev = 0.166/0.190/0.214/0.019 ms

ちゃんと疎通が確認できた。

試しに障害を起こしてみる

めでたしめでたし、と言いたいところだけど、せっかく冗長化しているのだから障害を起こさないと。

試しに sw1 につながっている rt1 のインターフェイスをダウンさせてみる。 これで VRRP のパケットが sw1 にマルチキャストで流れなくなる。

$ sudo ip netns exec sw1 ip link set rt1-sw1 down

実行すると rt1 がリンクダウンを検知してフォールトステートになる。

$ sudo keepalived -nlP -p /var/run/keepalived1 -f rt1-keepalived.conf

...(省略)...

Sun Apr  3 00:48:37 2022: Netlink reports rt1-veth0 down
Sun Apr  3 00:48:37 2022: (VirtualInstance1) Entering FAULT STATE
Sun Apr  3 00:48:37 2022: (VirtualInstance1) sent 0 priority
Sun Apr  3 00:48:37 2022: VRRP_Group(VirtualGroup1) Syncing instances to FAULT state
Sun Apr  3 00:48:37 2022: (VirtualInstance2) Entering FAULT STATE

代わりに rt2 の方がマスターに昇格した。

$ sudo keepalived -nlP -p /var/run/keepalived2 -f rt2-keepalived.conf

...(省略)...

Sun Apr  3 00:48:37 2022: (VirtualInstance2) Backup received priority 0 advertisement
Sun Apr  3 00:48:39 2022: (VirtualInstance1) Entering MASTER STATE
Sun Apr  3 00:48:39 2022: VRRP_Group(VirtualGroup1) Syncing instances to MASTER state
Sun Apr  3 00:48:39 2022: (VirtualInstance2) Entering MASTER STATE

VIP を確認しても rt2 の方に移動している。

$ sudo ip netns exec rt1 ip address show | grep .254/24
$ sudo ip netns exec rt2 ip address show | grep .254/24
    inet 192.0.2.254/24 scope global secondary rt2-veth0
    inet 198.51.100.254/24 scope global secondary rt2-veth1

試しに障害を復旧させてみよう。 先ほどダウンさせたリンクをアップに戻す。

$ sudo ip netns exec sw1 ip link set rt1-sw1 up

しかし、VIP は元に戻らない。 これは、Keepalived の設定で nopreempt を指定しているため。 明示的にマスターを切り替える動作をしない限り、元の状態には戻らない。 この動作によって、中途半端に壊れているようなときにマスターとバックアップが頻繁にフラップするのを防ぐことができる。

$ sudo ip netns exec rt2 ip address show | grep .254/24
    inet 192.0.2.254/24 scope global secondary rt2-veth0
    inet 198.51.100.254/24 scope global secondary rt2-veth1

背後で ping(8) を打ち続けていると、障害が起こってもマスターが切り替わってルーティングが継続される様子が観察できる。 いじょう。

まとめ

今回は Network Namespace と Keepalived の VRRP を使ってルータの冗長化を試した。

ClusterShell を使って複数のマシンを SSH で並列に操作する

複数のマシンを使って動作検証をしていると、ログインやコマンド入力の操作が煩雑になる。 また、複数のマシンに共通で必要な操作があったりすると手数もかさむ。 今回は、そういった問題を緩和できる ClusterShell について扱う。 ClusterShell を使うと、マシンをグループ化して SSH で並列に操作できる。

今回は、次のようなマシンの構成を扱う。 client には ClusterShell をインストールして、他のマシンを操作する。 masterworker[01] は名前通り異なる役割のマシンを想定して用意した。

  • client
    • 192.168.56.10
  • master
    • 192.168.56.20
  • worker1
    • 192.168.56.31
  • worker2
    • 192.168.56.32

上記のマシンは、あらかじめ Ubuntu 20.04 LTS を使って構築してある。 一応、末尾にはおまけとして Vagrant + VirtualBoxを使って仮想マシンを構築するための設定ファイルを用意した。

$ lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description:    Ubuntu 20.04.3 LTS
Release:    20.04
Codename:   focal
$ uname -srm
Linux 5.4.0-91-generic x86_64

ClusterShell のバージョンは次のとおり。

$ clush --version
clush 1.8.3

もくじ

下準備

client には、まず ClusterShell をインストールする。 sshpass と openssh-client は SSH のために入れておく。

$ sudo apt-get update
$ sudo apt-get -y install sshpass openssh-client clustershell

これで clush コマンドが使えるようになる。

$ clush --version
clush 1.8.3

次に、ホスト名を使って操作したいので /etc/hosts に IP アドレスとの対応関係を書き込んでおく。

$ cat << 'EOF' | sudo tee -a /etc/hosts >/dev/null
192.168.56.10 client
192.168.56.20 master
192.168.56.31 worker1
192.168.56.32 worker2
EOF

次に SSH でログインするための公開鍵を作成する。

$ ssh-keygen -t rsa -P '' -f $HOME/.ssh/id_rsa

client から、その他のホストに公開鍵を使ってログインできるように登録する。 ここの作業は環境構築に使ったツールやイメージなどによって少し変わる。 たとえばパスワード認証が無効になっているイメージだと、この操作では登録できない。 また、Vagrant で作った環境なのでパスワードが vagrant になっている。

$ sshpass -p "vagrant" \
    ssh-copy-id -i $HOME/.ssh/id_rsa.pub -o "StrictHostKeyChecking no" master
$ sshpass -p "vagrant" \
    ssh-copy-id -i $HOME/.ssh/id_rsa.pub -o "StrictHostKeyChecking no" worker1
$ sshpass -p "vagrant" \
    ssh-copy-id -i $HOME/.ssh/id_rsa.pub -o "StrictHostKeyChecking no" worker2

次に、ClusterShell にホスト名とグループの対応関係を登録する。 対応関係は /etc/clustershell/ 以下の設定ファイルで指定する。 設定ファイルは <group-name>: <hostname>,... というフォーマットになっている。 以下では all というグループに、操作対象となるすべてのホストを登録している。 そして、グループ mmaster を、グループ wworker1worker2 を登録している。

$ sudo cp /etc/clustershell/groups.d/local.cfg{,.orig}
$ cat << 'EOF' | sudo tee /etc/clustershell/groups.d/local.cfg >/dev/null
all: master,worker1,worker2
m: master
w: worker1,worker2
EOF

これで ClusterShell を使い始める準備ができた。

個別のホストを指定して操作する

特定のホストを指定してコマンドを実行したいときは -w オプションを使う。 ここでは、それぞれのホストにホスト名を設定した。

$ clush -w master "sudo hostnamectl set-hostname master"
$ clush -w worker1 "sudo hostnamectl set-hostname worker1"
$ clush -w worker2 "sudo hostnamectl set-hostname worker2"

グループを指定して操作する

先ほどの例であれば、別に ssh(1) を直接使って操作しても変わらなかった。 ClusterShell の本領はグループを指定して操作できることにある。 グループを指定するには -g オプションでグループ名を指定すれば良い。 また、-L オプションを指定すると、結果をホスト名のアルファベット順でソートできる。

試しに all グループに対して hostname コマンドを実行してみよう。

$ clush -g all -L hostname
master: master
worker1: worker1
worker2: worker2

上記から、操作対象のすべてのホストに hostname コマンドが実行されたことがわかる。

また、-g all はよく使うので -a オプションがエイリアスとして用意されている。

$ clush -a -L hostname
master: master
worker1: worker1
worker2: worker2

同じように、特定のグループを指定してコマンドを実行してみよう。 以下ではグループ mw を、それぞれ指定している。

$ clush -g m -L hostname
master: master
$ clush -g w -L hostname
worker1: worker1
worker2: worker2

ちゃんとグループに所属しているホストに対してコマンドが実行されていることがわかる。

グループはカンマ区切りで複数指定することもできる。 以下ではグループ mw に対して実行している。

$ clush -g m,w -L hostname
master: master
worker1: worker1
worker2: worker2

複数のホストにファイルをコピーする

ClusterShell では、複数のホストにファイルを scp(1) できる。 ファイルをコピーするには -c オプションでコピーしたいファイルを指定して、コピー先のディレクトリを --dest オプションで指定する。

以下では greet.txt というファイルを、すべてのホストに対して /tmp 以下にコピーしている。

$ echo "Hello, World" > greet.txt
$ clush -g all -c greet.txt --dest /tmp

コピーされたはずのパスを cat(1) すると、ちゃんとファイルがコピーされていることがわかる。

$ clush -g all -L "cat /tmp/greet.txt"
master: Hello, World
worker1: Hello, World
worker2: Hello, World

書き込みに特権が必要なファイルをコピーするときは、少し工夫が必要になる。 具体的には、一度特権が不要なディレクトリにコピーした上で、あらためて特権ユーザでファイルを移動するというもの。 たとえば /etc/hosts をコピーしてみよう。

$ clush -g all -c /etc/hosts --dest /var/tmp
$ clush -g all -L "sudo cp /var/tmp/hosts /etc/hosts"

たとえば master の内容を確認すると、ちゃんとコピーされたことがわかる。

$ clush -w master "cat /etc/hosts" 
master: 127.0.0.1 localhost
master: 127.0.1.1 vagrant
master: 
master: # The following lines are desirable for IPv6 capable hosts
master: ::1     ip6-localhost ip6-loopback
master: fe00::0 ip6-localnet
master: ff00::0 ip6-mcastprefix
master: ff02::1 ip6-allnodes
master: ff02::2 ip6-allrouters
master: 192.168.56.10 client
master: 192.168.56.20 master
master: 192.168.56.31 worker1
master: 192.168.56.32 worker2

まとめ

今回は ClusterShell を使うことで、複数のマシンを SSH で並列に操作する方法を扱った。

おまけ: 環境構築に使った Vagrantfile

今回の環境を作るのに使った Vagrantfile を以下に示す。

# -*- mode: ruby -*-
# vi: set ft=ruby :

# Vagrantfile API/syntax version. Don't touch unless you know what you're doing!
VAGRANTFILE_API_VERSION = "2"

Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|

  machines = {
    "client" => "192.168.56.10",
    "master" => "192.168.56.20",
    "worker1" => "192.168.56.31",
    "worker2" => "192.168.56.32",
  }

  machines.each do |key, value|
    config.vm.define key do |machine|
      machine.vm.box = "bento/ubuntu-20.04"
      machine.vm.network "private_network", ip: value
      machine.vm.provider "virtualbox" do |vb|
        vb.cpus = "2"
        vb.memory = "1024"
      end
    end
  end

end

あとは以下で環境が用意できる。

$ vagrant up
$ vagrant ssh client

いじょう。

chroot について

今回は、Unix の古典的な機能のひとつである chroot について扱う。 chroot を使うと、特定のプロセスにおけるルートディレクトリを、ルートディレクトリ以下にある別のディレクトリに変更できる。 今回扱うのはコマンドラインツールとしての chroot(8) と、システムコールとしての chroot(2) になる。

使った環境は次のとおり。

$ lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description:    Ubuntu 20.04.4 LTS
Release:    20.04
Codename:   focal
$ uname -srm
Linux 5.4.0-104-generic aarch64
$ chroot --version
chroot (GNU coreutils) 8.30
Copyright (C) 2018 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <https://gnu.org/licenses/gpl.html>.
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.

Written by Roland McGrath.
$ gcc --version
gcc (Ubuntu 9.4.0-1ubuntu1~20.04) 9.4.0
Copyright (C) 2019 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
$ ldd --version
ldd (Ubuntu GLIBC 2.31-0ubuntu9.7) 2.31
Copyright (C) 2020 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
Written by Roland McGrath and Ulrich Drepper.

もくじ

下準備

chroot(8) は coreutils パッケージに含まれているのでインストールしておく。 また、chroot(2) を呼び出すコードをビルドするために build-essential をインストールする。

$ sudo apt-get -y install coreutils build-essential

chroot(8) の動作を試す

まずはコマンドラインツールとしての chroot(8) から動作を確認していく。

はじめに、chroot(8) したプロセスでルートディレクトリになるディレクトリを用意する。 ディレクトリは mktemp(1) を使ってテンポラリディレクトリとして作る。

$ ROOTFS=$(mktemp -d)
$ echo ${ROOTFS}
/tmp/tmp.GuMwStXLLO

chroot(8) した上で起動するプログラムとして bash(1) をコピーしておく。 このとき --parents オプションを使ってディレクトリ構造ごとコピーしてやる。

$ cp -avL --parents $(which bash) ${ROOTFS}
/usr -> /tmp/tmp.GuMwStXLLO/usr
/usr/bin -> /tmp/tmp.GuMwStXLLO/usr/bin
'/usr/bin/bash' -> '/tmp/tmp.GuMwStXLLO/usr/bin/bash'

さらに、bash(1) の動作に必要な共有ライブラリをコピーする。 動作に必要な共有ライブラリは ldd(1) の出力から得られる。

$ ldd $(which bash) | grep -o "/lib.*\.[0-9]\+" | xargs -I {} cp -avL --parents {} ${ROOTFS}
/lib -> /tmp/tmp.GuMwStXLLO/lib
/lib/aarch64-linux-gnu -> /tmp/tmp.GuMwStXLLO/lib/aarch64-linux-gnu
'/lib/aarch64-linux-gnu/libtinfo.so.6' -> '/tmp/tmp.GuMwStXLLO/lib/aarch64-linux-gnu/libtinfo.so.6'
'/lib/aarch64-linux-gnu/libdl.so.2' -> '/tmp/tmp.GuMwStXLLO/lib/aarch64-linux-gnu/libdl.so.2'
'/lib/aarch64-linux-gnu/libc.so.6' -> '/tmp/tmp.GuMwStXLLO/lib/aarch64-linux-gnu/libc.so.6'
'/lib/ld-linux-aarch64.so.1' -> '/tmp/tmp.GuMwStXLLO/lib/ld-linux-aarch64.so.1'

上記の作業を、必要なプログラムそれぞれについてやっていく。 手作業でひとつひとつやると大変なのでループを回して処理する。 ここでは例として ls, mkdir, mount をコピーした。

$ CMDS=ls,mkdir,mount
$ IFS=","
$ for CMD in ${CMDS}
> do
>   cp -avL --parents $(which ${CMD}) ${ROOTFS}
>   ldd $(which ${CMD}) | grep -o "/lib.*\.[0-9]\+" | xargs -I {} cp -avL --parents {} ${ROOTFS}
> done
'/usr/bin/ls' -> '/tmp/tmp.GuMwStXLLO/usr/bin/ls'
'/lib/aarch64-linux-gnu/libselinux.so.1' -> '/tmp/tmp.GuMwStXLLO/lib/aarch64-linux-gnu/libselinux.so.1'
'/lib/aarch64-linux-gnu/libc.so.6' -> '/tmp/tmp.GuMwStXLLO/lib/aarch64-linux-gnu/libc.so.6'
'/lib/ld-linux-aarch64.so.1' -> '/tmp/tmp.GuMwStXLLO/lib/ld-linux-aarch64.so.1'
'/lib/aarch64-linux-gnu/libpcre2-8.so.0' -> '/tmp/tmp.GuMwStXLLO/lib/aarch64-linux-gnu/libpcre2-8.so.0'
'/lib/aarch64-linux-gnu/libdl.so.2' -> '/tmp/tmp.GuMwStXLLO/lib/aarch64-linux-gnu/libdl.so.2'
'/lib/aarch64-linux-gnu/libpthread.so.0' -> '/tmp/tmp.GuMwStXLLO/lib/aarch64-linux-gnu/libpthread.so.0'
'/usr/bin/mkdir' -> '/tmp/tmp.GuMwStXLLO/usr/bin/mkdir'
'/lib/aarch64-linux-gnu/libselinux.so.1' -> '/tmp/tmp.GuMwStXLLO/lib/aarch64-linux-gnu/libselinux.so.1'
'/lib/aarch64-linux-gnu/libc.so.6' -> '/tmp/tmp.GuMwStXLLO/lib/aarch64-linux-gnu/libc.so.6'
'/lib/ld-linux-aarch64.so.1' -> '/tmp/tmp.GuMwStXLLO/lib/ld-linux-aarch64.so.1'
'/lib/aarch64-linux-gnu/libpcre2-8.so.0' -> '/tmp/tmp.GuMwStXLLO/lib/aarch64-linux-gnu/libpcre2-8.so.0'
'/lib/aarch64-linux-gnu/libdl.so.2' -> '/tmp/tmp.GuMwStXLLO/lib/aarch64-linux-gnu/libdl.so.2'
'/lib/aarch64-linux-gnu/libpthread.so.0' -> '/tmp/tmp.GuMwStXLLO/lib/aarch64-linux-gnu/libpthread.so.0'
'/usr/bin/mount' -> '/tmp/tmp.GuMwStXLLO/usr/bin/mount'
'/lib/aarch64-linux-gnu/libmount.so.1' -> '/tmp/tmp.GuMwStXLLO/lib/aarch64-linux-gnu/libmount.so.1'
'/lib/aarch64-linux-gnu/libc.so.6' -> '/tmp/tmp.GuMwStXLLO/lib/aarch64-linux-gnu/libc.so.6'
'/lib/ld-linux-aarch64.so.1' -> '/tmp/tmp.GuMwStXLLO/lib/ld-linux-aarch64.so.1'
'/lib/aarch64-linux-gnu/libblkid.so.1' -> '/tmp/tmp.GuMwStXLLO/lib/aarch64-linux-gnu/libblkid.so.1'
'/lib/aarch64-linux-gnu/libselinux.so.1' -> '/tmp/tmp.GuMwStXLLO/lib/aarch64-linux-gnu/libselinux.so.1'
'/lib/aarch64-linux-gnu/libpcre2-8.so.0' -> '/tmp/tmp.GuMwStXLLO/lib/aarch64-linux-gnu/libpcre2-8.so.0'
'/lib/aarch64-linux-gnu/libdl.so.2' -> '/tmp/tmp.GuMwStXLLO/lib/aarch64-linux-gnu/libdl.so.2'
'/lib/aarch64-linux-gnu/libpthread.so.0' -> '/tmp/tmp.GuMwStXLLO/lib/aarch64-linux-gnu/libpthread.so.0'

準備が終わったところで、満を持して chroot(8) する。 第 1 引数は chroot(8) したプロセスでルートディレクトリになるディレクトリ。 第 2 引数は chroot(8) した上で起動するコマンドの場所。

$ sudo chroot ${ROOTFS} $(which bash)

実行すると、先ほどとは異なるシェルとして bash(1) が立ち上がる。 試しにルートディレクトリを ls(1) してみよう。 あきらかに、普段とは表示されるディレクトリの数が異なる。 lib と usr しかない。 とはいえ、これは先ほどコピーしたファイルのあったディレクトリなので心当たりは十分にあるはず。

# ls /
lib  usr

ここで、試しに proc ファイルシステムをマウントしてみよう。 ディレクトリを用意してマウントする。

# mkdir -p /proc
# mount -t proc proc /proc

すると、次のようにちゃんとマウントできる。 なお、chroot(8) ではルートディレクトリを切り替えるだけなので、PID (プロセス識別子) の名前空間はシステムと共有している。

# ls /proc
1     1448  178  240  474  501    623  70  80   98     diskstats    kallsyms     mdstat    schedstat  thread-self
10    1449  179  287  475  522    625  71  81   acpi    driver       kcore    meminfo   scsi       timer_list
104   1458  18    288  476  574   627  72  83   buddyinfo  execdomains  key-users    misc       self       tty
11    15    19    3    477  576   629  73  84   bus     fb       keys     modules   slabinfo   uptime
12    16    2     361  486  6 632  74  841  cgroups     filesystems  kmsg     mounts    softirqs   version
1359  1685  20    374  488  612   646  75  842  cmdline     fs       kpagecgroup  net       stat       version_signature
1376  1686  21    380  489  615   648  76  85   consoles    interrupts   kpagecount   pagetypeinfo  swaps      vmallocinfo
1377  1691  22    392  496  616   666  77  86   cpuinfo     iomem        kpageflags   partitions    sys        vmstat
14    17    23    4    499  621   673  78  9    crypto  ioports      loadavg      pressure  sysrq-trigger  zoneinfo
143   177   24    473  500  622   686  8     95   devices   irq          locks    sched_debug   sysvipc

確認が終わったらシェルを終了しよう。 これで chroot(8) を呼び出した元のプロセスに戻れる。

# exit
exit

Ubuntu 21.10 のルートファイルシステムに chroot(8) してみる

次は、試しに他の GNU/Linux ディストリビューションのルートファイルシステムを展開して chroot(8) してみよう。 今、システムとして使っているのが Ubuntu 20.04 LTS なので、Ubuntu 21.10 を使うことにした。

まずは Ubuntu 21.10 の、ルートファイルシステムをアーカイブしたファイルをダウンロードして展開する。 CPU の命令セットが違うとダウンロードするファイルが異なる点に注意する。

$ ISA=$(uname -m | sed -e "s/x86_64/amd64/" -e "s/aarch64/arm64/")
$ mkdir -p /tmp/ubuntu-impish-${ISA}
$ wget -O - https://cdimage.ubuntu.com/ubuntu-base/releases/21.10/release/ubuntu-base-21.10-base-${ISA}.tar.gz | tar zxvf - -C /tmp/ubuntu-impish-${ISA}

次のように /tmp 以下にファイルが展開された。

$ ls /tmp/ubuntu-impish-${ISA}/
bin  boot  dev  etc  home  lib  media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var

ここで、展開されたディレクトリに対して chroot(8) してみよう。

$ sudo chroot /tmp/ubuntu-impish-${ISA} /usr/bin/bash

これで Ubuntu 21.10 のルートファイルシステムが、プロセスのルートディレクトリになった。 例えば /etc 以下にある lsb-release ファイルを表示すると Ubuntu 21.10 のものになっている。 bash のバージョンも Ubuntu 21.04 LTS の 5.0 系ではなく 5.1 系になっている。

# cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=21.10
DISTRIB_CODENAME=impish
DISTRIB_DESCRIPTION="Ubuntu 21.10"
# bash --version
GNU bash, version 5.1.8(1)-release (aarch64-unknown-linux-gnu)
Copyright (C) 2020 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>

This is free software; you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.

システムは Ubuntu 20.04 LTS なのに、なんだか Ubuntu 21.10 を使っているような気分になる。 一方で、uname(1) から得られるカーネルのバージョンは Ubuntu 20.04 LTS のまま。

# uname -r
5.4.0-104-generic

これは、単に chroot(8) でルートディレクトリを入れ替えているだけなので当たり前。 Linux コンテナ技術は基本的にカーネルを共有するので Docker などを使っていても、この点は変わらない 1

chroot(2) の動作を試す

続いてはシステムコールとしての chroot(2) の動作を試してみる。

以下のサンプルコードでは、第 1 引数で指定されたパスに chroot(2) した上で bash(1) を起動している。

#define _XOPEN_SOURCE

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>


int main(int argc, char *argv[]) {
    // 引数の長さをチェックする
    if (argc < 2) {
        fprintf(stderr, "Please specify the path to change root\n");
        exit(EXIT_FAILURE);
    }

    // chroot(2) したいディレクトリにカレントワーキングディレクトリを変更する
    if (chdir(argv[1]) != 0) {
        fprintf(stderr, "Failed to change directory: %s\n", strerror(errno));
        exit(EXIT_FAILURE);
    }

    // カレントワーキングディレクトリに chroot(2) する
    if (chroot(".") != 0) {
        fprintf(stderr, "Failed to change root: %s\n", strerror(errno));
        exit(EXIT_FAILURE);
    }

    // シェルを起動し直す
     char* const args[] = {"bash", NULL};
    if (execvp(args[0], args) != 0) {
        fprintf(stderr, "Failed to exec \"%s\": %s\n", args[0], strerror(errno));
        exit(EXIT_FAILURE);
    }

    return EXIT_SUCCESS;
}

上記をコンパイルする。

$ gcc --std=c11 --static -Wall chroot.c 
$ file a.out 
a.out: ELF 64-bit LSB executable, ARM aarch64, version 1 (GNU/Linux), statically linked, BuildID[sha1]=362e58fceadfa88e4ef8f7becdb06350922b9930, for GNU/Linux 3.7.0, not stripped

できたバイナリに第 1 引数として Ubuntu 21.10 のディレクトリを指定して実行する。

$ sudo ./a.out /tmp/ubuntu-impish-${ISA}/

すると、次のようにちゃんと Ubuntu 21.10 のルートファイルシステムがルートディレクトリになっている。 つまり chroot(8) を使ったときと同じ結果になった。

# ls /
bin  boot  dev  etc  home  lib  media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var
# cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=21.10
DISTRIB_CODENAME=impish
DISTRIB_DESCRIPTION="Ubuntu 21.10"

ひとしきり確認したら環境から抜ける。

# exit

chroot した環境から脱獄 (jail break) してみる

実は chroot で隔離したファイルシステムは、プロセスに CAP_SYS_CHROOT のケーパビリティがあると脱獄できることが知られている。 これはあくまで chroot(2) の仕様であって、不具合や脆弱性ではないらしい。 では、実際に脱獄できるのか確かめてみよう。

以下にサンプルコードを示す。 このコードをビルドしたバイナリを chroot した環境で実行することで脱獄する。 コードでは "foo" という名前でディレクトリを作って、そこに chroot(2) している。 その上で chdir(2) を何度も呼び出して、その後でまたカレントワーキングディレクトリに対して chroot(2) している。 そして、最後に bash(1) を呼び出している。 やっていることは実にシンプル。

#define _XOPEN_SOURCE

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>


int main(int argc, char *argv[]) {
    // chroot した環境で実行されることを想定している

    // 適当にサブディレクトリを作る
    if (mkdir("foo", 755) != 0) {
        // すでに同名のパスがあるときはエラーを無視する
        if (errno != EEXIST) {
            fprintf(stderr, "Failed to create a new directory: %s\n", strerror(errno));
            exit(EXIT_FAILURE);
    }
    }

    // 作成したサブディレクトリに chroot(2) する
    // chroot(2) は pwd を変更しない
    // rootfs が pwd よりも下のディレクトリになる
    if (chroot("foo") != 0) {
        fprintf(stderr, "Failed to change root: %s\n", strerror(errno));
        exit(EXIT_FAILURE);
    }

    // chdir(2) は rootfs に到達するまで pwd から早退パスで移動できる
    // ただし、現状 rootfs は pwd よりも下にあるので決して到達しない
    // 元々のルートディレクトリまでさかのぼってしまう
    for (int i = 0; i < 1024; i++) {
        if (chdir("..") != 0) {
            fprintf(stderr, "Failed to change directory: %s\n", strerror(errno));
            exit(EXIT_FAILURE);
        }
    }

    // ルートディレクトリまでいってから chroot(2) すると脱獄できる
    if (chroot(".") != 0) {
        fprintf(stderr, "Failed to change root: %s\n", strerror(errno));
        exit(EXIT_FAILURE);
    }

    // 脱獄した上でシェルを起動し直す
    char* const args[] = {"bash", NULL};
    if (execvp(args[0], args) != 0) {
        fprintf(stderr, "Failed to exec \"%s\": %s\n", args[0], strerror(errno));
        exit(EXIT_FAILURE);
    }

    return EXIT_SUCCESS;
}

上記の概念的な説明は man 2 chroot に書かれているし、巷にもいくつか解説が見つかる。

man7.org

ざっくり説明すると、どうやら chroot(2) がプロセスのカレントワーキングディレクトリを変更しないところがポイントらしい。 サブディレクトリに chroot(2) すると、プロセスのルートディレクトリはサブディレクトリになるが、カレントワーキングディレクトリは元のまま変更されない。 つまり、カレントワーキングディレクトリよりもルートディレクトリの方が下位のディレクトリにあるという、なんだか変な状況になる。 そして、カレントワーキングディレクトリから相対パスで chdir(2) する場合、ルートディレクトリに至るまで上位のディレクトリに移動できるらしい。 しかし、ルートディレクトリはカレントワーキングディレクトリよりも下位にあるため、決してそこに至ることはなく本来の隔離される前のルートディレクトリまで到達してしまう。 そこで改めて chroot(2) すると、晴れてプロセスのルートディレクトリが変更されて脱獄成功、ということらしい。

理屈は分かったので、実際に試してみよう。 上記をコンパイルする。

$ gcc --std=c11 --static -Wall jailbreak.c

できたバイナリを、先ほど展開した Ubuntu 21.10 のルートファイルシステムに放り込む。

$ cp a.out /tmp/ubuntu-impish-${ISA}/

上記のディレクトリを指定して chroot(8) する。

$ sudo chroot /tmp/ubuntu-impish-${ISA} /usr/bin/bash

Ubuntu 21.10 のファイルシステムに隔離されたことを確認する。

# cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=21.10
DISTRIB_CODENAME=impish
DISTRIB_DESCRIPTION="Ubuntu 21.10"

ここでおもむろに先ほどコピーしたバイナリを実行してみる。

# /a.out

一見すると変化はないが /etc/lsb-release を確認すると隔離前のファイルシステムに参照できている。 つまり、脱獄できた。

# cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=20.04
DISTRIB_CODENAME=focal
DISTRIB_DESCRIPTION="Ubuntu 20.04.4 LTS"

このような脱獄を防ぐには、根本的には chroot(2) の代わりに pivot_root(2) を使う必要があるようだ。

まとめ

今回は chroot をコマンドラインツールとシステムコールを使って試してみた。


  1. 念の為に補足しておくと、一般的な Linux コンテナ仮想化の実装ではデフォルトで chroot(2) ではなく pivot_root(2) が使われる

Python: Prophet で単変量の時系列予測を試す

Prophet は Meta (旧 Facebook) が中心となって開発している OSS の時系列予測フレームワーク。 目的変数のトレンド、季節性、イベントや外部説明変数を加味した時系列予測を簡単にできることが特徴として挙げられる。 使い所としては、精度はさほど追求しない代わりにとにかく手軽に予測がしたい、といった場面が考えられる。 また、扱うデータセットについても単変量に近いシンプルなものが得意そう。 なお、今回は扱うデータセットの都合からイベントや外部説明変数の追加に関しては扱わない。

使った環境は次のとおり。

$ sw_vers
ProductName:    macOS
ProductVersion: 12.2.1
BuildVersion:   21D62
$ uname -srm
Darwin 21.3.0 arm64
$ python -V
Python 3.9.10
$ pip list | grep -i prophet                       
prophet                  1.0.1

もくじ

下準備

あらかじめ Prophet をインストールしておく。 その他に、データセットの読み込みなどに必要なパッケージもインストールしておく。

$ pip install prophet scikit-learn seaborn pmdarima

flights データセットで試してみる

まずは、航空機の旅客数を扱った有名な flights データセットで試してみる。

その前に、どういったデータかをグラフにプロットして確認しておく。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import pandas as pd
import seaborn as sns
from matplotlib import pyplot as plt


def main():
    # flights データセットを読み込む
    df = sns.load_dataset('flights')

    # カラムが年と月で分かれているのでマージする
    df['year-month'] = pd.to_datetime(df['year'].astype(str) + '-' + df['month'].astype(str),
                                      format='%Y-%b')

    # プロットする
    plt.plot(df['year-month'], df['passengers'])
    plt.show()


if __name__ == '__main__':
    main()

上記に適当な名前をつけて保存したら実行する。

$ python plotflights.py 

すると、次のような折れ線グラフが得られる。

f:id:momijiame:20220309181506p:plain
flightsデータセット

上記からトレンドや季節成分の存在が確認できる。

それでは、次に Prophet を使って予測してみよう。 以下のサンプルコードでは、データを時系列でホールドアウトして、末尾を Prophet で予測している。 Prophet のデフォルトでは、時系列のカラムを ds という名前で、目的変数のカラムを y という名前にすることになっている。 また、実際の値と予測をプロットしたものと、データをトレンドと季節成分に分離したものをグラフとしてプロットしている。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import pandas as pd
import seaborn as sns
from matplotlib import pyplot as plt
from sklearn.metrics import mean_absolute_error
from sklearn.model_selection import train_test_split
from prophet import Prophet


def main():
    # flights データセットを読み込む
    df = sns.load_dataset('flights')

    # カラムが年と月で分かれているのでマージする
    df['year-month'] = pd.to_datetime(df['year'].astype(str) + '-' + df['month'].astype(str),
                                      format='%Y-%b')

    # Prophet が仮定するカラム名に変更する
    # タイムスタンプ: ds
    # 目的変数: y
    rename_mappings = {
        'year-month': 'ds',
        'passengers': 'y',
    }
    df.rename(columns=rename_mappings,
              inplace=True)

    # 不要なカラムを落とす
    df.drop(['year', 'month'],
            axis=1,
            inplace=True)

    # 時系列の順序で学習・検証用データをホールアウトする
    train_df, eval_df = train_test_split(df,
                                         shuffle=False,
                                         random_state=42,
                                         test_size=0.3)

    # 学習用データを使って学習する
    m = Prophet()
    m.fit(train_df)

    # 検証用データを予測する
    forecast = m.predict(eval_df.drop(['y'],
                                      axis=1))

    # 真の値との誤差を MAE で求める
    mae = mean_absolute_error(forecast['yhat'],
                              eval_df['y'])
    print(f'MAE: {mae:.05f}')

    # 実際のデータと予測をプロットする
    fig = plt.figure(figsize=(12, 8))
    ax = fig.add_subplot(1, 1, 1)
    ax.plot(df['ds'], df['y'], color='y')
    m.plot(forecast, ax=ax)
    # トレンドと季節成分をプロットする
    m.plot_components(forecast)

    plt.show()


if __name__ == '__main__':
    main()

上記を実行してみよう。 実際の値と予測値の、ホールドアウトデータでの乖離を MAE で出力している。

$ python predflights.py

...

MAE: 35.21388

実際の値と予測をプロットしたグラフは次のとおり。 青い実線が予測値、薄い青色で示された範囲は 95% 信頼区間らしい。

f:id:momijiame:20220309181947p:plain
flightsデータセットの実測値と予測結果

トレンドはつかめているものの、実際の値よりも振幅は小さくなっていることがわかる。

トレンドと季節成分は次のように分離された。 Prophet は特に指定しない限り、季節成分を自動で検出してくれる。 以下では年次でのトレンドが自動的に検出されたことが確認できる。

f:id:momijiame:20220309182038p:plain
flightsデータセットのトレンドと季節成分

先ほどのモデルでは、振幅が小さいことで実際の値とのズレが大きくなってしまっていた。 これは、季節成分の計算がデフォルトで加算モードになっていたことが理由として考えられる。 つまり、時間が進むごとに目的変数が大きくなると共に振幅も大きくなることが上手く表現できていなかった。 そこで、次は季節成分の計算を乗法モードに変更してみる。 トレンド成分にかけ算で季節成分をのせてやれば、振幅がだんだんと大きくなっていく様子が表現できるはず。

以下のサンプルコードではモデルに seasonality_mode='multiplicative' を指定することで季節成分の計算を乗法モードにしている。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import pandas as pd
import seaborn as sns
from matplotlib import pyplot as plt
from sklearn.metrics import mean_absolute_error
from sklearn.model_selection import train_test_split
from prophet import Prophet


def main():
    df = sns.load_dataset('flights')

    df['year-month'] = pd.to_datetime(df['year'].astype(str) + '-' + df['month'].astype(str),
                                      format='%Y-%b')

    rename_mappings = {
        'year-month': 'ds',
        'passengers': 'y',
    }
    df.rename(columns=rename_mappings,
              inplace=True)

    df.drop(['year', 'month'],
            axis=1,
            inplace=True)

    train_df, eval_df = train_test_split(df,
                                         shuffle=False,
                                         random_state=42,
                                         test_size=0.3)

    # 季節成分の計算を加算モードから乗法モードに変更する
    m = Prophet(seasonality_mode='multiplicative')
    m.fit(train_df)

    forecast = m.predict(eval_df.drop(['y'],
                                      axis=1))

    mae = mean_absolute_error(forecast['yhat'],
                              eval_df['y'])
    print(f'MAE: {mae:.05f}')

    fig = plt.figure(figsize=(12, 8))
    ax = fig.add_subplot(1, 1, 1)
    ax.plot(df['ds'], df['y'], color='y')
    m.plot(forecast, ax=ax)
    m.plot_components(forecast)

    plt.show()


if __name__ == '__main__':
    main()

上記を実行してみよう。 かなり MAE が改善したことが確認できる。

$ python multiflights.py

...

MAE: 22.31301

実際の値と予測値をグラフで確認しても、次のように当てはまりが良くなっている。

f:id:momijiame:20220309182757p:plain
flightsデータセットの実測値と予測結果 (乗法モード)

wineind データセットで試してみる

もうひとつ、ワインの生産量を示すデータセット (wineind) で試してみよう。

先ほどと同じように、まずはデータセットを可視化する。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import pandas as pd
from pmdarima import datasets
from matplotlib import pyplot as plt


def main():
    # wineind データセットを読み込む
    series = datasets.load_wineind(as_series=True)
    df = series.to_frame(name='bottles')

    # プロットする
    df.plot()
    plt.show()


if __name__ == '__main__':
    main()

上記を実行する。

$ python plotwineind.py

得られるグラフは次のとおり。 季節成分は確認できるものの、単調な増加トレンドがあるわけではないようだ。

f:id:momijiame:20220309183422p:plain
wineindデータセット

先ほどと同じように、データをホールドアウトして予測してみよう。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import pandas as pd
from pmdarima import datasets
from matplotlib import pyplot as plt
from sklearn.metrics import mean_absolute_error
from sklearn.model_selection import train_test_split
from prophet import Prophet


def main():
    series = datasets.load_wineind(as_series=True)
    df = series.to_frame(name='bottles')

    df.reset_index(inplace=True)
    df['index'] = pd.to_datetime(df['index'],
                                 format='%b %Y')

    rename_mappings = {
        'index': 'ds',
        'bottles': 'y',
    }
    df.rename(columns=rename_mappings,
              inplace=True)

    train_df, eval_df = train_test_split(df,
                                         shuffle=False,
                                         random_state=42,
                                         test_size=0.3)

    m = Prophet(seasonality_mode='multiplicative')
    m.fit(train_df)

    forecast = m.predict(eval_df.drop(['y'],
                                      axis=1))

    mae = mean_absolute_error(forecast['yhat'],
                              eval_df['y'])
    print(f'MAE: {mae:.05f}')

    fig = plt.figure(figsize=(12, 8))
    ax = fig.add_subplot(1, 1, 1)
    ax.plot(df['ds'], df['y'], color='y')
    m.plot(forecast, ax=ax)
    m.plot_components(forecast)

    plt.show()


if __name__ == '__main__':
    main()

上記を実行する。

$ python predwineind.py

...

MAE: 2443.18595

得られた予測は次のとおり。 今回は、先ほどよりも実際の値と予測が一致していない。 中には実際の値が 95% 信頼区間の外に出てしまっているものもある。

f:id:momijiame:20220309183655p:plain
wineindデータセットの実測値と予測結果

トレンドと季節成分は次のとおり。 今度は単調な下降トレンドと認識されているようだ。 もちろん、これらの結果は学習させる範囲にも大きく依存する。

f:id:momijiame:20220309183724p:plain
wineindデータセットのトレンドと季節成分

まとめ

今回は Prophet を使って時系列の予測を試してみた。 ごくシンプルな時系列データで、なるべく簡単にトレンドや季節成分を加味した予測をしたいときには選択肢の一つとして考えられるかもしれない。