CUBE SUGAR CONTAINER

技術系のこと書きます。

qrencode と viu を使ってターミナルで QR コードを作って表示する

情報共有などのために、ささっと QR コードを作って読み込ませたいときがある。 そんなときは qrencode と viu を使うとターミナル上で完結して楽そうだ。

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

$ sw_vers   
ProductName:    macOS
ProductVersion: 12.4
BuildVersion:   21F79
$ qrencode -V                         
qrencode version 4.1.1
Copyright (C) 2006-2017 Kentaro Fukuchi
$ viu -V
viu 1.4.0

もくじ

下準備

まずは qrencode と viu をインストールしておく。

$ brew install qrencode viu 

ターミナルで QR コードを作る

qrencode を使うと、次のように引数で指定した文字列を埋め込んだ QR コードが作れる。 -o オプションで保存先を指定する。

$ qrencode -o greet.png "Hello, World"

できあがる QR コードはこんな感じ。

ターミナルで QR コードを表示する

ターミナルで QR コードを作ったなら、表示までターミナルで完結したい気持ちもある。 そんなときは viu で開くと良い。

$ viu greet.png

こんな感じで表示できる。

viu を使って QR コードの画像を開く

目視ではかなりざらついてるように見えるけど、デバイスに読み込ませてみるとちゃんと読める。 QR コードの堅牢性おそるべし。 もし読めないときは viu コマンドの -w オプションとかを使って大き目に表示させると良い。

ワンライナーで QR コードを作って表示する

ちなみに qrencode も viu も標準入出力を読み書きできる。 そのため、両者を次のように組み合わせることも可能だ。

$ qrencode -o - "Hello, World" | viu -

これで、ファイルを介すことなく QR コードをターミナル上に表示できる。

めでたしめでたし。

子育てにかかる費用を公的データで調べる

一般的に、子どもを一人育てるのには 2,000 万円かかるとか言われている。 しかし、何も子どもが生まれた瞬間に 2,000 万円が必要になるわけではない。 もちろん、2,000 万円を均等に割った額が毎年かかるわけでもない。 そもそも、こういった数字はあくまで代表的な家庭における目安に過ぎないはず。 たとえば、子どもがどういった就学をするのかだったり、世帯収入にも依存していると考えられる。 そこで、今回は子育てにかかる費用に対する自分の解像度を上げるために、公的な統計資料を当たってみることにした。

もくじ

インターネットによる子育て費用に関する調査

まず、第1子が誕生してから中学校を卒業するまでの費用に関しては、平成21年度に内閣府が実施した調査が参考になる。 この資料は教育費に限らず、子育てにかかる全般的な費用について扱っている。

www8.cao.go.jp

上記資料の「第3章 調査結果」に掲載されているグラフを以下に引用する。 このグラフからは、それぞれの就学区分ごとに第1子の子育てをするのに年間でいくらかかっているか分かる。

出典: 平成21年度インターネットによる子育て費用に関する調査 全体版 第3章 調査結果 42 ページ 図表 1-1.第1子一人当たりの年間子育て費用額(対象者全体平均)【第1子の就学区分別】

未就園児は 84 万円、保育園・幼稚園児は 121 万円、小学生は 115 万円、中学生は 155 万円が平均で必要となるようだ。 なお、この資料では就学先が私立か国公立かを区別していない。 つまり、調査対象とした全体の平均である点に留意が必要となる。

また、上記のグラフが掲載されている次のページには、各年齢ごとに分解したグラフが掲載されている。 グラフからは、年齢が上がる毎に学校外教育費 (塾や習い事) がじわじわと増加していく様子や、学校教育費が中学校から一気に増加する様子が伺える。

世帯年収ごとの子育て費用額の比較も興味深い。 以下は「保育所・幼稚園児」について、世帯年収ごとの年間子育て費用額を比較したグラフになっている。 このグラフからは、子どもが小さいうちは世帯年収ごとの子育て費用額の差は「子どものための預貯金・保険」や「レジャー・旅行費」に大きく出ることが伺える。

出典: 平成21年度インターネットによる子育て費用に関する調査 全体版 【参考資料】 37ページ 図表 4-3.世帯年収別にみた「保育所・幼稚園児」第1子一人当たりの年間子育て費用額(対象者全体平均)

一方で、以下は「中学生」について、世帯年収ごとの年間子育て費用額を比較したグラフになっている。 こちらのグラフでは、特に高い世帯年収において、子育て費用額の差が「学校教育費」や「学校外活動費」に大きく出ている。 つまり、子どもの年齢によって資金の余裕が振り分けられる先が少し変わるようだ。 これらも、自分たちの世帯年収に合わせて考える上で参考になるだろう。

出典: 平成21年度インターネットによる子育て費用に関する調査 全体版 【参考資料】 38ページ 図表 4-5.世帯年収別にみた「中学生」第1子一人当たりの年間子育て費用額(対象者全体平均)

ちなみに、「【参考資料】」に掲載されているグラフには子育てにかかる費用の構造が図示されており、こちらも面白い。

出典: 平成21年度インターネットによる子育て費用に関する調査 全体版 【参考資料】110 ページ掲載図表

子供の学習費調査

高校までの学習費に焦点を絞った資料としては、文部科学省が平成30年度に実施した調査が参考になる。 この資料では幼稚園から高校までの学習費を、国公立と私立で区別して調査している。

www.mext.go.jp

上記資料の「調査の結果 > 結果の概要 > 平成30年度 > 2.調査結果の概要」に掲載されているグラフを以下に引用する。

出典: 平成30年度 子供の学習費調査 2.調査結果の概要 図1−2 学校種別にみた学習費総額

上記から、国公立と私立で学習費に大きな差が生じることが確認できる。 私立中学の場合は公立の約 3 倍、私立高校でも公立の約 2 倍かかる。 場合によっては私立中学に進学を希望することもあるだろうし、私立高校であればより現実的な可能性として捉えておく必要がありそうだ。

教育費に関する調査結果

先ほどの文科省の調査は高校までが範囲だった。 高専・専修学校や大学など、高校以降の教育費については日本政策金融公庫の調査が詳しい。 この調査では「教育費負担の実態調査結果」を毎年公表している。

www.jfc.go.jp

上記で令和 3 年度の調査結果として掲載されているグラフを以下に引用する。 このグラフには、進学先別の年間の在学費用が載っている。

令和3年度 教育費負担の実態調査結果 6ページ 図−3 在学先別にみた1年間の在学費用(子供1人当たりの費用)

上記から、高専・専修学校や大学などへ進学すると、最低でも 100 万円程度は年間でかかることがわかる。 なお、上記は大学の費用を国公立と私立で区別していない。

大学に関して、国公立と私立の文理で区別したグラフが以下になる。

出典: 令和3年度 教育費負担の実態調査結果 6ページ 図−4 国公立・私立別にみた大学の在学費用(子供1人当たりの費用)

大学の中でも、国公立と私立の文理で費用の差が激しい。 国公立は平均で 103 万円だが、私立の文系は 152 万円、理系では 183 万円となっている。 なお、最近だと理系は大学院への進学が一般的になってきている点も心に留めておく必要がありそうだ。

また、上記の資料には自宅外通学に関する資料も載っている。 私自身は関東圏に居住していることもあってさほど可能性は高くないものの、それ以外の場合には参考になりそうだ。 とはいえ、関東圏に住んでいる場合であっても、海外へ留学する可能性は考慮する必要があるのだろうか・・・?

国民生活白書

最初に登場した「インターネットによる子育て費用に関する調査」は、子育てにかかる費用を広範に扱った調査だったが、対象は第1子に限定されていた。 では、第2子以降にかかる費用はどうなるのだろうか。 この点に関しては、内閣府が平成17年度に実施した国民生活白書の調査が参考になりそうだった。

warp.da.ndl.go.jp

平成17年版「子育て世代の意識と生活」の「第3章 子育てにかかる費用と時間 > 二人目・三人目の子どもにかける費用は逓減」に記述がある。

warp.da.ndl.go.jp

ここまで、一人の子どもを育てるための費用を見てきたが、更に子どもを育てた場合に子育て費用はどれくらい増加するのだろうか。一人の場合と同様に、「基本的経費」、「教育費」、「住宅関係費」に分けて、子どもを二人持つ世帯の「子どもを育てる費用」から子どもを一人持つ世帯の「子どもを育てる費用」を差し引いて、22年間分を足したものを「二人目の子ども9を育てる」費用として推計した。ここでいう「子どもを育てる費用」は、前項と同じく、付注3−1−1に掲げた費目における子どもを育てるための追加的な費用である。 その結果、一人の子どもを育てる費用の1,302万円に対して、二人目の子どもを育てる費用10は1,052万円と、20%程度節約されていることが分かった(第3−1−14図)。内訳ごとに節約の程度を見ると、二人目の基本的経費は一人目の80.0%、教育費は83.6%、住宅関係費は63.0%となっている。同様に三人目の子どもにかかる費用を推計すると、22年間で769万円となり、二人目と比べて更に27%程度節約されており、子どもの増加にともない、子どもにかかる費用は逓減していくことがうかがわれる。

上記より、第2子の子育てにかかる費用は第1子の約 8 割まで減少 (節約) し、第3子に至っては第1子の約 6 割まで減少することがわかる。 もし、すでに第1子がいる場合には、その実績を元に第2子以降でどれくらいかかりそうかを計算する上で目安にできそうだ。 ただし、この結果は単純に「減っている」のではなく、諸々の事情から「減らしている」ことも考慮する必要があるだろう。

いじょう。

まとめ

子育てにかかる費用が分からなかったので、公的データを調べてわかったことについて自分用にまとめた。

NVMe ストレージのデータを nvme-cli(1) で完全に消去する

ストレージ機器を破棄または譲渡するときには、漏えいを防ぐためにあらかじめデータを消去しておく必要がある。 このとき、データの消去は後から読み取りが難しいように実施しなければいけない。 後から読み取りが難しい形でデータを消去することは Secure Erase と呼んだりするようだ。 今回は NVMe ストレージを Secure Erase する方法について扱う。

まず、最近のマザーボードの UEFI には、ストレージのデータを Secure Erase するためのツールが付属していることがある。 もし、ツールが付属している場合には、それを利用するのが手っ取り早い。 では、付属していない場合にどうするか、というのが今回の話になる。 Unix 系の OS で NVMe ストレージを使っている場合には、nvme-cli(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.13.0-44-generic x86_64
$ nvme version
nvme version 1.9

下準備

Ubuntu の場合には apt を使ってインストールできる。

$ sudo apt-get -y install nvme-cli

データを消去する

まず、nvme list コマンドで消去したいデバイスを確認する。

$ sudo nvme list
Node             SN                   Model                                    Namespace Usage                      Format           FW Rev  
---------------- -------------------- ---------------------------------------- --------- -------------------------- ---------------- --------
/dev/nvme0n1     XXXXXXXXXXXX         XXXXXXXXXXXXX                            1           1.00  TB /   1.00  TB    512   B +  0 B   XXXXXXX 

あとは、nvme format コマンドに確認したデバイスを指定して実行するだけ。 このとき、オプションとして -s 2 を指定するのが望ましい。

$ nvme format /dev/nvme0n1 -s 2

先ほどの形式だとデバイスを名前空間ブロックデバイス (/dev/nvmeXnY みたいな形式) として指定していた。 それ以外にもキャラクタデバイス (/device/nvmeX みたいな形式) も受け付けられる。 先ほどと同じように namespace を指定するときは -n オプションを指定する。

$ nvme format /dev/nvme0 -s 2 -n 1

実行すると、ストレージのデータが消去される。 なお、デバイスがシステムの起動ディスクになっていても問題ない。

-s オプションについて

先ほど指定した -s オプションについて補足する。 このオプションは、データの消去をどのように実施するかを表している。 詳しくは man ページに記載されている。

$ man 1 nvme-format

manpages.ubuntu.com

オプションに指定する値の説明を、上記から引用する。 基本的には 2 を指定することで Secure Erase できる。

           │Value │ Definition                       │
           ├──────┼──────────────────────────────────┤
           │0     │ No secure erase operation        │
           │      │ requested                        │
           ├──────┼──────────────────────────────────┤
           │1     │ User Data Erase: All user data   │
           │      │ shall be erased, contents of the │
           │      │ user data after the erase is     │
           │      │ indeterminate (e.g., the user    │
           │      │ data may be zero filled, one     │
           │      │ filled, etc). The controller may │
           │      │ perform a cryptographic erase    │
           │      │ when a User Data Erase is        │
           │      │ requested if all user data is    │
           │      │ encrypted.                       │
           ├──────┼──────────────────────────────────┤
           │2     │ Cryptographic Erase: All user    │
           │      │ data shall be erased             │
           │      │ cryptographically. This is       │
           │      │ accomplished by deleting the     │
           │      │ encryption key.                  │
           ├──────┼──────────────────────────────────┤
           │3–7   │ Reserved                         │
           └──────┴──────────────────────────────────┘

なお、ソースコードを読む限り1、これらはいずれも NVMe デバイスに発行するコマンドの引数として表現されているようだ。 言いかえると、製品のファームウェアのレベルで Secure Erase の機能が実装されていることを前提としている。 そのため、製品によってはサポートされていない可能性もある。

いじょう。


  1. 内部的には libnvme というライブラリが使われていて、ユーザ空間からは ioctl(2) でデバイスを操作する

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 を使ってルータの冗長化を試した。