CUBE SUGAR CONTAINER

技術系のこと書きます。

Linux: util-linux を gdb でデバッグする

util-linux に含まれるコマンドの振る舞いを動的に解析したい場面があったので、手順を書き残しておく。

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

$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=20.04
DISTRIB_CODENAME=focal
DISTRIB_DESCRIPTION="Ubuntu 20.04.3 LTS"
$ uname -rm
5.4.0-91-generic aarch64

下準備

まずは、システムにインストールされている util-linux のバージョンを調べておく。 今回使った環境では 2.34 らしい。

$ dpkg -l | grep util-linux
ii  util-linux                     2.34-0.1ubuntu9.1                     arm64        miscellaneous system utilities

ただし、システムに入っているものはリリース版なので、シンボルなどが削除されてしまっている。

$ file $(which unshare)
/usr/bin/unshare: ELF 64-bit LSB shared object, ARM aarch64, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux-aarch64.so.1, BuildID[sha1]=3c2afa819eb8040c947103fe980e2d88acc92c2b, for GNU/Linux 3.7.0, stripped

そこで、自分でソースコードを落としてきてビルドする。 まずはビルドに必要なパッケージ一式を入れる。 また、後ほど使うデバッガとして gdb も入れておく。

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

続いては肝心の util-linux の tarball をダウンロードする。

$ wget -O - https://mirrors.edge.kernel.org/pub/linux/utils/util-linux/v2.34/util-linux-2.34.tar.gz | tar zxvf -

ビルドする。

$ cd util-linux-2.34/
$ ./configure && make

これで util-linux に含まれている諸々がビルドできた。

$ ls
ABOUT-NLS         addpart          chrt           ctrlaltdel  fsck        ipcs          libmount.la      lscpu       mkfs.minix  readprofile   sfdisk       tools
AUTHORS           agetty           col            delpart     fsck.minix  isosize       libsmartcols     lsipc       mkswap      rename        stamp-h1     umount
COPYING           autogen.sh       colcrt         disk-utils  fsfreeze    kill          libsmartcols.la  lslocks     mount       renice        sulogin      unshare
ChangeLog         bash-completion  colrm          dmesg       fstrim      last          libtcolors.la    lslogins    mountpoint  resizepart    swaplabel    utmpdump
Documentation     blkdiscard       column         eject       getopt      ldattach      libtool          lsmem       namei       rev           swapoff      uuidd
Makefile          blkid            config         fallocate   hardlink    lib           libuuid          lsns        nologin     rfkill        swapon       uuidgen
Makefile.am       blkzone          config.h       fdformat    hexdump     libblkid      libuuid.la       m4          nsenter     rtcwake       switch_root  uuidparse
Makefile.in       blockdev         config.h.in    fdisk       hwclock     libblkid.la   logger           mcookie     partx       schedutils    sys-utils    wall
NEWS              cal              config.log     fincore     include     libcommon.la  login-utils      mesg        pivot_root  script        taskset      wdctl
README            chcpu            config.status  findfs      ionice      libfdisk      look             misc-utils  po          scriptreplay  term-utils   whereis
README.licensing  chmem            configure      findmnt     ipcmk       libfdisk.la   losetup          mkfs        prlimit     setarch       tests        wipefs
aclocal.m4        choom            configure.ac   flock       ipcrm       libmount      lsblk            mkfs.bfs    raw         setsid        text-utils   zramctl

中身を見ると、ちゃんとシンボルなどが残っている。

$ file unshare
unshare: ELF 64-bit LSB shared object, ARM aarch64, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux-aarch64.so.1, BuildID[sha1]=a70bad2506d9972f359c141b679d33fdfe03e58d, for GNU/Linux 3.7.0, with debug_info, not stripped

動作することも確認しておこう。 試しに unshare(1) を実行する。 以下では、PID Namespace を新たに作っている。

$ sudo ./unshare --fork --pid --mount-proc bash

新たに起動したシェルで確認すると、ちゃんと PID が 1 からリセットされている。

# ps aux
USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root           1  0.0  0.3   8592  3528 pts/0    S    16:54   0:00 bash
root           8  0.0  0.2  10188  2816 pts/0    R+   16:54   0:00 ps aux

動作確認が終わったら終了しておこう。

# exit

gdb でデバッグする

動作確認ができたので、続いては gdb を使って動的解析する。 まずは gdb 経由で unshare(1) を起動する。

$ sudo gdb ./unshare

ブレークポイントとして、とりあえず適当にメイン関数を設定する。 ここはまあご自由に。

(gdb) break main
Breakpoint 1 at 0x2940: file sys-utils/unshare.c, line 288.

ちなみに、デバッグ途中で fork(2) する場合には、親プロセスと子プロセスのどちらを追跡するか設定しておく必要がある。 ここでは子プロセスを追跡する場合。

(gdb) set follow-fork-mode child

設定が終わったらコマンドライン引数を渡して実行する。 設定したブレークポイントまで処理が進むはず。

gdb) run --fork --pid --mount-proc bash
Starting program: /home/ubuntu/util-linux-2.34/unshare --fork --pid --mount-proc bash

Breakpoint 1, main (argc=5, argv=0xfffffffff638) at sys-utils/unshare.c:288
288    {

次のように、ちゃんとメイン関数でブレークしている。

(gdb) l
283    
284        exit(EXIT_SUCCESS);
285    }
286    
287    int main(int argc, char *argv[])
288    {
289        enum {
290            OPT_MOUNTPROC = CHAR_MAX + 1,
291            OPT_PROPAGATION,
292            OPT_SETGROUPS,

あとは通常の gdb のやり方で調べていけば良い。

いじょう。

PostgreSQL のテーブルに CSV でデータを読み込む

今回は PostgreSQL のテーブルに CSV ファイル経由でデータを読み込む方法について。 ちょくちょくやり方を調べている気がするのでメモしておく。

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

$ sw_vers
ProductName:    macOS
ProductVersion: 12.1
BuildVersion:   21C52
$ uname -rm
21.2.0 arm64
$ psql --version           
psql (PostgreSQL) 14.1

もくじ

下準備

まずは PostgreSQL をインストールする。 ついでに、CSV をダウンロードするために wget も入れる。

$ brew install postgresql wget

PostgreSQL のサービスを開始する。

$ brew services start postgresql
$ brew services list
Name       Status  User    File
postgresql started amedama ~/Library/LaunchAgents/homebrew.mxcl.postgresql.plist

今回は Seaborn に同梱されている Taxis データセットを使う。 あらかじめ CSV ファイルをダウンロードしておく。

$ wget https://raw.githubusercontent.com/mwaskom/seaborn-data/master/taxis.csv -P /tmp
$ head -n 5 /tmp/taxis.csv
pickup,dropoff,passengers,distance,fare,tip,tolls,total,color,payment,pickup_zone,dropoff_zone,pickup_borough,dropoff_borough
2019-03-23 20:21:09,2019-03-23 20:27:24,1,1.6,7.0,2.15,0.0,12.95,yellow,credit card,Lenox Hill West,UN/Turtle Bay South,Manhattan,Manhattan
2019-03-04 16:11:55,2019-03-04 16:19:00,1,0.79,5.0,0.0,0.0,9.3,yellow,cash,Upper West Side South,Upper West Side South,Manhattan,Manhattan
2019-03-27 17:53:01,2019-03-27 18:00:25,1,1.37,7.5,2.36,0.0,14.16,yellow,credit card,Alphabet City,West Village,Manhattan,Manhattan
2019-03-10 01:23:59,2019-03-10 01:49:51,1,7.7,27.0,6.15,0.0,36.95,yellow,credit card,Hudson Sq,Yorkville West,Manhattan,Manhattan

続いて、上記のデータに適合するテーブルを用意する。 データベースは、デフォルトで postgres が作られている。

$ cat << 'EOF' | psql postgres
CREATE TABLE IF NOT EXISTS taxis (
  pickup TIMESTAMP,
  dropoff TIMESTAMP,
  passengers INT,
  distance FLOAT,
  fare FLOAT,
  tip FLOAT,
  tolls FLOAT,
  total FLOAT,
  color TEXT,
  payment TEXT,
  pickup_zone TEXT,
  dropoff_zone TEXT,
  pickup_borough TEXT,
  dropoff_borough TEXT
);
EOF
CREATE TABLE

CSV ファイルを読み込む

CSV ファイルのインポートには COPY を使う。 これは PostgreSQL 独自の文なので、使い方はドキュメントを参照する。

www.postgresql.jp

実際に、COPY を使って taxis テーブルに /tmp/taxis.csv ファイルを読み込む。 CSV ファイルは先頭行がヘッダになっているので、読み飛ばす設定を WITH 以下に入れる。

$ cat << 'EOF' | psql postgres
COPY taxis
FROM '/tmp/taxis.csv'
WITH (
  FORMAT csv,
  HEADER true
)
EOF

以下のとおり、確認するとファイルの内容が読み込まれていることがわかる。

$ cat << 'EOF' | psql postgres                               
\pset pager off
SELECT * FROM taxis LIMIT 5
EOF
Pager usage is off.
       pickup        |       dropoff       | passengers | distance | fare | tip  | tolls | total | color  |   payment   |      pickup_zone      |     dropoff_zone      | pickup_borough | dropoff_borough 
---------------------+---------------------+------------+----------+------+------+-------+-------+--------+-------------+-----------------------+-----------------------+----------------+-----------------
 2019-03-23 20:21:09 | 2019-03-23 20:27:24 |          1 |      1.6 |    7 | 2.15 |     0 | 12.95 | yellow | credit card | Lenox Hill West       | UN/Turtle Bay South   | Manhattan      | Manhattan
 2019-03-04 16:11:55 | 2019-03-04 16:19:00 |          1 |     0.79 |    5 |    0 |     0 |   9.3 | yellow | cash        | Upper West Side South | Upper West Side South | Manhattan      | Manhattan
 2019-03-27 17:53:01 | 2019-03-27 18:00:25 |          1 |     1.37 |  7.5 | 2.36 |     0 | 14.16 | yellow | credit card | Alphabet City         | West Village          | Manhattan      | Manhattan
 2019-03-10 01:23:59 | 2019-03-10 01:49:51 |          1 |      7.7 |   27 | 6.15 |     0 | 36.95 | yellow | credit card | Hudson Sq             | Yorkville West        | Manhattan      | Manhattan
 2019-03-30 13:27:42 | 2019-03-30 13:37:14 |          3 |     2.16 |    9 |  1.1 |     0 |  13.4 | yellow | credit card | Midtown East          | Yorkville West        | Manhattan      | Manhattan
(5 rows)

いじょう。

Network Namespace 内の Linux Bridge では STP が動作しないらしい

どうやら、今のところ Network Namespace 1 内では Linux Bridge の STP (Spanning Tree Protocol) がサポートされていないようだ。 今回は、以下のような実験を通して、それを実際に確かめてみる。

  1. 単一の Linux Bridge 内にループを作ってストームを引き起こす
  2. Linux Bridge の STP 機能を有効にしてストームが止まるか確かめる

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

$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=20.04
DISTRIB_CODENAME=focal
DISTRIB_DESCRIPTION="Ubuntu 20.04.3 LTS"
$ uname -rm
5.4.0-91-generic aarch64
$ ip -V
ip utility, iproute2-ss200127
$ brctl -V
bridge-utils, 1.6

もくじ

下準備

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

$ sudo apt-get -y install iproute2 tcpdump bridge-utils watch

Network Namespace を使わずに STP が機能することを確かめる

まずは、システムをそのまま使って Linux Bridge の STP が機能することを確かめておく。

はじめに Linux Bridge を br0 という名前で追加する。

$ sudo ip link add dev br0 type bridge

veth (Virtual Ethernet) ペアを br-veth0br-veth1 という名前で用意する。

$ sudo ip link add br-veth0 type veth peer name br-veth1

一応、ドキュメンテーションアドレスの MAC アドレスを付与しておく。

$ sudo ip link set dev br-veth0 address 00:00:5E:00:53:01
$ sudo ip link set dev br-veth1 address 00:00:5E:00:53:02

インターフェイスの状態を UP にする。

$ sudo ip link set br-veth0 up
$ sudo ip link set br-veth1 up

そして、両方のインターフェイスを先ほど作った br0 に所属させる。 これでループができる。

$ sudo ip link set br-veth0 master br0
$ sudo ip link set br-veth1 master br0

ただし、この時点ではブリッジの状態が DOWN のままなので、何も流れていない。

$ ip -s link show br0
3: br0: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
    link/ether 00:00:5e:00:53:02 brd ff:ff:ff:ff:ff:ff
    RX: bytes  packets  errors  dropped overrun mcast   
    0          0        0       0       0       0       
    TX: bytes  packets  errors  dropped carrier collsns 
    0          0        0       0       0       0       

おもむろにブリッジを UP にしてみよう。

$ sudo ip link set br0 up

すると、そのうちにすごい勢いでパケット数のカウンタが増えだすはず。 ストームが生じている。

$ ip -s link show br0
3: br0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default qlen 1000
    link/ether 00:00:5e:00:53:02 brd ff:ff:ff:ff:ff:ff
    RX: bytes  packets  errors  dropped overrun mcast   
    2734413272 38328896 0       0       0       38328896 
    TX: bytes  packets  errors  dropped carrier collsns 
    2909638    33833    0       0       0       0       

watch コマンドとか使って確認するとわかりやすい。

$ watch -n 1 ip -s link show br0

tcpdump コマンドを使って確認すると IPv6 の NDP (Neighbor Discovery Protocol) が原因になっているはず。 中身は NA (Neighbor Advertisement) だったり RS (Router Solicitation) だったり、最初に流れるパケット次第。

$ sudo tcpdump -tnel -i br0 -c 5
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on br0, link-type EN10MB (Ethernet), capture size 262144 bytes
00:00:5e:00:53:02 > 33:33:00:00:00:01, ethertype IPv6 (0x86dd), length 86: fe80::200:5eff:fe00:5302 > ff02::1: ICMP6, neighbor advertisement, tgt is fe80::200:5eff:fe00:5302, length 32
00:00:5e:00:53:02 > 33:33:00:00:00:01, ethertype IPv6 (0x86dd), length 86: fe80::200:5eff:fe00:5302 > ff02::1: ICMP6, neighbor advertisement, tgt is fe80::200:5eff:fe00:5302, length 32
00:00:5e:00:53:02 > 33:33:00:00:00:01, ethertype IPv6 (0x86dd), length 86: fe80::200:5eff:fe00:5302 > ff02::1: ICMP6, neighbor advertisement, tgt is fe80::200:5eff:fe00:5302, length 32
00:00:5e:00:53:02 > 33:33:00:00:00:01, ethertype IPv6 (0x86dd), length 86: fe80::200:5eff:fe00:5302 > ff02::1: ICMP6, neighbor advertisement, tgt is fe80::200:5eff:fe00:5302, length 32
00:00:5e:00:53:02 > 33:33:00:00:00:01, ethertype IPv6 (0x86dd), length 86: fe80::200:5eff:fe00:5302 > ff02::1: ICMP6, neighbor advertisement, tgt is fe80::200:5eff:fe00:5302, length 32
5 packets captured
11083 packets received by filter
11051 packets dropped by kernel

ここで、ブリッジの STP を有効にしてみよう。

$ sudo brctl stp br0 on
$ brctl show br0
bridge name bridge id       STP enabled interfaces
br0     8000.00005e005302  yes     br-veth0
                            br-veth1

上記では、STP によってループがちゃんと遮断されてストームがピタっと止まるはず。

確認が終わったら一旦ブリッジは削除しておく。

$ sudo ip link delete br0

Network Namespace で STP が機能しないことを確かめる

では、続いて Network Namespace 内に作った Linux Bridge でも確かめてみよう。

まずは、bridge という名前で Network Namespace を作成する。

$ sudo ip netns add bridge

その中に、先ほどと同じ br0 という名前で Linux Bridge を作る。

$ sudo ip netns exec bridge ip link add dev br0 type bridge

veth インターフェイスは、先ほど作ったものをそのまま流用する。

$ sudo ip link set br-veth0 netns bridge
$ sudo ip link set br-veth1 netns bridge

インターフェイスの状態を UP にする。

$ sudo ip netns exec bridge ip link set br-veth0 up
$ sudo ip netns exec bridge ip link set br-veth1 up

両方のインターフェイスをブリッジに接続する。

$ sudo ip netns exec bridge ip link set br-veth0 master br0
$ sudo ip netns exec bridge ip link set br-veth1 master br0

先ほどと同じように、この時点ではブリッジの状態が DOWN なのでフレームは流れない。

$ sudo ip netns exec bridge ip -s link show br0
2: br0: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
    link/ether 00:00:5e:00:53:01 brd ff:ff:ff:ff:ff:ff
    RX: bytes  packets  errors  dropped overrun mcast   
    0          0        0       0       0       0       
    TX: bytes  packets  errors  dropped carrier collsns 
    0          0        0       0       0       0  

それでは、ブリッジを UP にしよう。

$ sudo ip netns exec bridge ip link set br0 up

すると、ブリッジのカウンタがもりもりと増えだす。 ここまでは想定内。

$ sudo ip netns exec bridge ip -s link show br0
2: br0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default qlen 1000
    link/ether 00:00:5e:00:53:01 brd ff:ff:ff:ff:ff:ff
    RX: bytes  packets  errors  dropped overrun mcast   
    2430362024 33834240 0       0       0       33834240 
    TX: bytes  packets  errors  dropped carrier collsns 
    2629020    30570    0       0       0       0

では、ブリッジの STP を有効にしてみよう。 表示の上では、ちゃんと STP enabledyes になっている。

$ sudo ip netns exec bridge brctl stp br0 on
$ sudo ip netns exec bridge brctl show br0
bridge name bridge id       STP enabled interfaces
br0     8000.00005e005301  yes     br-veth0
                            br-veth1

しかし、残念ながらストームは止まらず、ブリッジのカウンタは増え続けている。

$ sudo ip netns exec bridge ip -s link show br0
2: br0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default qlen 1000
    link/ether 00:00:5e:00:53:01 brd ff:ff:ff:ff:ff:ff
    RX: bytes  packets  errors  dropped overrun mcast   
    17477322936 241253904 0       0       0       241253904 
    TX: bytes  packets  errors  dropped carrier collsns 
    20065998   233325   0       0       0       0
$ sudo ip netns exec bridge ip -s link show br0
2: br0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default qlen 1000
    link/ether 00:00:5e:00:53:01 brd ff:ff:ff:ff:ff:ff
    RX: bytes  packets  errors  dropped overrun mcast   
    23684383864 327386610 0       0       0       327386610 
    TX: bytes  packets  errors  dropped carrier collsns 
    27466110   319373   0       0       0       0

STP が機能していないようだ。

まとめ

残念ながら、今の Linux カーネルでは Network Namespace 内の Linux Bridge では STP が使えないらしい。 もちろん、これはあくまで「現時点では」という話であって、将来的に使えるようになる可能性はあるはずだけど。

参考

lists.linuxfoundation.org


  1. すべてのプロセスはいずれかの Network Namespace に所属するため、厳密に言うとシステムが所属している以外の (=非ルートな) Network Namespace ということになる

MariaDB のテーブルに CSV でデータを読み込む

今回は MariaDB のテーブルに CSV ファイル経由でデータを読み込む方法について。 ちょくちょくやり方を調べている気がするのでメモしておく。

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

$ sw_vers
ProductName:    macOS
ProductVersion: 12.1
BuildVersion:   21C52
$ uname -rm
21.2.0 arm64
$ mysql --version      
mysql  Ver 15.1 Distrib 10.6.4-MariaDB, for osx10.17 (arm64) using readline 5.1

もくじ

下準備

あらかじめ Homebrew を使って MariaDB をインストールする。 サンプルデータをダウンロードするために wget も入れておく。

$ brew install mariadb wget

インストールできたら MariaDB のサービスを開始する。 本来は mysql_secure_installation を実行して作業した方が望ましいけど、今回は省略する。

$ brew services start mariadb
$ brew services list          
Name    Status  User    File
mariadb started amedama ~/Library/LaunchAgents/homebrew.mxcl.mariadb.plist

サンプルデータとして、Seaborn が利用している taxis データセットを使う。 これはタクシーの利用情報に関するデータセットになっている。

$ wget https://raw.githubusercontent.com/mwaskom/seaborn-data/master/taxis.csv

あらかじめ example という名前でデータベースを作っておく。

$ mysql -vvv -e "CREATE DATABASE IF NOT EXISTS example"
--------------
CREATE DATABASE IF NOT EXISTS example
--------------

Query OK, 1 row affected (0.000 sec)

Bye

そして、taxis データセットに適合する形でテーブルを用意する。

$ cat << 'EOF' | mysql -vvv -D example
CREATE TABLE IF NOT EXISTS taxis (
  pickup DATETIME,
  dropoff DATETIME,
  passengers INT,
  distance FLOAT,
  fare FLOAT,
  tip FLOAT,
  tolls FLOAT,
  total FLOAT,
  color TEXT,
  payment TEXT,
  pickup_zone TEXT,
  dropoff_zone TEXT,
  pickup_borough TEXT,
  dropoff_borough TEXT
);
EOF
--------------
CREATE TABLE IF NOT EXISTS taxis (
  pickup DATETIME,
  dropoff DATETIME,
  passengers INT,
  distance FLOAT,
  fare FLOAT,
  tip FLOAT,
  tolls FLOAT,
  total FLOAT,
  color TEXT,
  payment TEXT,
  pickup_zone TEXT,
  dropoff_zone TEXT,
  pickup_borough TEXT,
  dropoff_borough TEXT
)
--------------

Query OK, 0 rows affected (0.097 sec)

Bye

CSV ファイルからデータを読み込む

さて、ここからが今回の本題になる。 CSV ファイルからデータを読み込むときは LOAD DATA LOCAL INFILE でファイルパスを指定する。 また、フィールドの区切り文字などを同時に指定している。 そして、サンプルファイルは最初にヘッダの行が含まれるので IGNORE 1 LINES で最初の 1 行を読み飛ばしている。

$ cat << 'EOF' | mysql -vvv -D example
LOAD DATA LOCAL INFILE "taxis.csv"
INTO TABLE taxis
FIELDS TERMINATED BY ','
OPTIONALLY ENCLOSED BY '"'
LINES TERMINATED BY '\n'
IGNORE 1 LINES;
EOF
--------------
LOAD DATA LOCAL INFILE "taxis.csv"
INTO TABLE taxis
FIELDS TERMINATED BY ','
OPTIONALLY ENCLOSED BY '"'
LINES TERMINATED BY '\n'
IGNORE 1 LINES
--------------

Query OK, 6433 rows affected (0.054 sec)
Records: 6433  Deleted: 0  Skipped: 0  Warnings: 0

Bye

確認すると、ちゃんとテーブルにデータが読み込まれていることがわかる。

$ mysql -vvv -D example -e "SELECT * FROM taxis LIMIT 5"
--------------
SELECT * FROM taxis LIMIT 5
--------------

+---------------------+---------------------+------------+----------+------+------+-------+-------+--------+-------------+-----------------------+-----------------------+----------------+-----------------+
| pickup              | dropoff             | passengers | distance | fare | tip  | tolls | total | color  | payment     | pickup_zone           | dropoff_zone          | pickup_borough | dropoff_borough |
+---------------------+---------------------+------------+----------+------+------+-------+-------+--------+-------------+-----------------------+-----------------------+----------------+-----------------+
| 2019-03-23 20:21:09 | 2019-03-23 20:27:24 |          1 |      1.6 |    7 | 2.15 |     0 | 12.95 | yellow | credit card | Lenox Hill West       | UN/Turtle Bay South   | Manhattan      | Manhattan       |
| 2019-03-04 16:11:55 | 2019-03-04 16:19:00 |          1 |     0.79 |    5 |    0 |     0 |   9.3 | yellow | cash        | Upper West Side South | Upper West Side South | Manhattan      | Manhattan       |
| 2019-03-27 17:53:01 | 2019-03-27 18:00:25 |          1 |     1.37 |  7.5 | 2.36 |     0 | 14.16 | yellow | credit card | Alphabet City         | West Village          | Manhattan      | Manhattan       |
| 2019-03-10 01:23:59 | 2019-03-10 01:49:51 |          1 |      7.7 |   27 | 6.15 |     0 | 36.95 | yellow | credit card | Hudson Sq             | Yorkville West        | Manhattan      | Manhattan       |
| 2019-03-30 13:27:42 | 2019-03-30 13:37:14 |          3 |     2.16 |    9 |  1.1 |     0 |  13.4 | yellow | credit card | Midtown East          | Yorkville West        | Manhattan      | Manhattan       |
+---------------------+---------------------+------------+----------+------+------+-------+-------+--------+-------------+-----------------------+-----------------------+----------------+-----------------+
5 rows in set (0.001 sec)

Bye

いじょう。

Linux の Mount Namespace について

Linux のコンテナ仮想化を構成する要素の一つに、カーネルの Namespace (名前空間) という機能がある。 これは、プロセスが動作する際のリソースを Namespace という単位で隔離して扱うための仕組み。 以下のとおり、隔離する対象によって Namespace は色々とある。

man7.org

今回は、その中でもマウント情報を隔離するための Mount Namespace を扱ってみる。 具体的には、unshare(1) と unshare(2) を使ってマウント情報が隔離される様子を観察してみる。 unshare(2) というのは Namespace を操作するためのシステムコールで、unshare(1) は同名のシステムコールを利用したコマンドラインツールになっている。

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

$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=20.04
DISTRIB_CODENAME=focal
DISTRIB_DESCRIPTION="Ubuntu 20.04.3 LTS"
$ uname -rm
5.4.0-91-generic aarch64
$ unshare --version
unshare from util-linux 2.34
$ gcc --version
gcc (Ubuntu 9.3.0-17ubuntu1~20.04) 9.3.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.

もくじ

下準備

下準備として、mount(1) と unshare(1) を使うために util-linux をインストールする。 同様に、unshare(2) を使ったコードをビルドするために build-essential をインストールする。

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

unshare(1) を使って Mount Namespace を操作する

まずは unshare(1) を使って Mount Namespace を試してみよう。 現在のプロセスの Mount Namespace の識別子は procfs から取得できる。 具体的には /proc/self/ns/mnt を読めば良い。

$ file /proc/self/ns/mnt
/proc/self/ns/mnt: symbolic link to mnt:[4026531840]

上記で右端にあるブラケットに囲まれた数字は、現在のプロセス、つまりシェルが所属する Mount Namespace を表している。

今回は /tmp/mntpoint というディレクトリを例にして説明していこう。 このディレクトリに tmpfs をマウントして、マウントされたのが見えるか否かで動作確認していく。 まずはマウント先としてディレクトリを作っておこう。

$ mkdir -p /tmp/mntpoint

作ったディレクトリに動作確認用のファイルを適当に作っておく。

$ echo "Hello, World" > /tmp/mntpoint/greet.txt
$ ls /tmp/mntpoint
greet.txt
$ cat /tmp/mntpoint/greet.txt
Hello, World

続いては肝心の unshare(1) を使って Mount Namespace を作る。 このとき --mount オプションを付与することで Mount Namespace を作ることを指定しよう。 末尾には bash を指定してシェルを起動する。

$ sudo unshare --mount bash

上記を実行したら、先ほどと同じように /proc/self/ns/mnt にアクセスしてみよう。 すると、識別子が変わっていることがわかる。 つまり、先ほどとは異なる Mount Namespace にプロセスが所属している。

# file /proc/self/ns/mnt
/proc/self/ns/mnt: symbolic link to mnt:[4026532128]

この時点では、まだ /tmp/mntpoint 以下には何もしていないのでファイルが見える。

# ls /tmp/mntpoint/
greet.txt

ここでおもむろに tmpfs を上記のディレクトリにマウントしてみよう。

# mount -t tmpfs tmpfs /tmp/mntpoint

すると、当然だけどマウントされてファイルは見えなくなる。

# mount | grep /tmp/mntpoint
tmpfs on /tmp/mntpoint type tmpfs (rw,relatime)
# ls /tmp/mntpoint/| wc -l
0

マウントできたことが確認できたら元のシェルに戻ろう。

# exit

元のシェルに戻ると Mount Namespace も元に戻っていることがわかる。

$ file /proc/self/ns/mnt
/proc/self/ns/mnt: symbolic link to mnt:[4026531840]

確認すると /tmp/mntpoint には何もマウントされておらず、ファイルがそのままある。

$ mount | grep /tmp/mntpoint
$ ls /tmp/mntpoint/
greet.txt
$ cat /tmp/mntpoint/greet.txt 
Hello, World

これこそ Mount Namespace の効果を示している。 つまり、異なる Namespace 上で実施したマウントの操作は、別の Namespace には影響を与えない。

unshare(2) を使って Mount Namespace を操作する

続いては、システムコールを直接使って Mount Namespace を操作してみよう。

早速だけど以下にサンプルコードを示す。 unshare(2) で Mount Namespace を作るには引数に CLONE_NEWNS を指定して呼び出せば良い。 その上で、mount(2) を使って /tmp/mntpoint に tmpfs をマウントしている。 最終的にやるのは execvp(3) を使って bash を起動すること。

#define _GNU_SOURCE

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

int main(int argc, char *argv[]) {
    // 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, "Can not change root filesystem propagation: %s\n", strerror(errno));
        exit(EXIT_FAILURE);
    }

    // tmpfs をマウントする
    char mount_dst[] = "/tmp/mntpoint";
    if (mount("tmpfs", mount_dst, "tmpfs", MS_NOSUID | MS_NODEV, NULL) != 0) {
        fprintf(stderr, "Failed to mount %s: %s\n", mount_dst, 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 -Wall example.c 

できたバイナリを実行・・・する前に、一旦 Mount Namespace の識別子を確認しておこう。

$ file /proc/self/ns/mnt
/proc/self/ns/mnt: symbolic link to mnt:[4026531840]

改めて、できたバイナリをスーパーユーザの権限で実行する。

$ sudo ./a.out

するとシェルが起動するので、また Mount Namespace の識別子を確認する。 ちゃんと先ほどと異なっている。

# file /proc/self/ns/mnt
/proc/self/ns/mnt: symbolic link to mnt:[4026532128]

ソースコードの中でマウントしているので /tmp/mntpoint に tmpfs がマウントされてファイルが見えなくなっている。 さっきとはマウントするときのオプションがちょっと違ってるけど、まあそこは良いか。

# mount | grep /tmp/mntpoint
tmpfs on /tmp/mntpoint type tmpfs (rw,nosuid,nodev,relatime)
# ls /tmp/mntpoint/ | wc -l
0

ひとしきり満足したら元のシェルに戻ろう。

# exit

もちろん、別の Mount Namespace での出来事なので、元のシェルにはマウントされていない。

$ mount | grep /tmp/mntpoint
$ ls /tmp/mntpoint/ | wc -l
1

マウントのプロパゲーションについて

ところで、先ほどのソースコードに次のような処理が入っていることを不思議に感じたと思う。

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

これはマウントのプロパゲーションという機能を抑制するために入れている。 というのも、プロパゲーションの設定によっては Mount Namespace が別れていても、操作が別の Namespace まで伝搬してしまうのだ。

Linux カーネルのデフォルトの設定では伝搬しない (PRIVATE) ようになっている。 しかし、systemd(1) は起動時に伝搬する (SHARED) ように設定を書きかえてしまう。 この振る舞いは以下のドキュメントにも記述がある。

man7.org

実際に確認してみよう。 /proc/self/mountinfo を見ると、マウントポイントごとにプロパゲーションのフラグが確認できる。 以下はルートファイルシステムの設定。

$ cat /proc/self/mountinfo | grep ' / / '
32 1 8:1 / / rw,relatime shared:1 - ext4 /dev/sda1 rw

上記で中ほどにある shared:1 というのが、伝搬する (SHARED) 設定になっていることを示している。 これを伝搬しない (PRIVATE) に設定するには、次のように mount(1) に --make-private オプションをつけて実行する。

$ sudo mount --make-private /

もう一度 /proc/self/mountinfo を見ると、shared:1 だった部分が - になった。

$ cat /proc/self/mountinfo | grep ' / / '
32 1 8:1 / / rw,relatime - ext4 /dev/sda1 rw

この状況であればマウントの操作は伝搬しないので、先ほどの処理は不要になる。 試しに、処理を除外したソースコードを用意して試してみよう。

#define _GNU_SOURCE

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

int main(int argc, char *argv[]) {
    // Mount Namespace を作成する
    if (unshare(CLONE_NEWNS) != 0) {
        fprintf(stderr, "Failed to create a new mount namespace: %s\n", strerror(errno));
        exit(EXIT_FAILURE);
    }

    // mount(2) で tmpfs をマウントする
    char mount_dst[] = "/tmp/mntpoint";
    if (mount("tmpfs", mount_dst, "tmpfs", MS_NOSUID | MS_NODEV, NULL) != 0) {
        fprintf(stderr, "Failed to mount %s: %s\n", mount_dst, 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 -Wall example.c
$ sudo ./a.out

起動したシェルでは、もちろん tmpfs のマウントが確認できる。

# mount | grep /tmp/mntpoint
tmpfs on /tmp/mntpoint type tmpfs (rw,nosuid,nodev,relatime)
# ls /tmp/mntpoint | wc -l
0

シェルを抜けて戻ると、マウントは見えなくなっている。 ちゃんと伝搬しないことが確認できた。

# exit
$ mount | grep /tmp/mntpoint
$ ls /tmp/mntpoint/
greet.txt

ちなみに、mount(1) に --make-shared を指定すると、プロパゲーションの振る舞いを元に戻せる。

$ sudo mount --make-shared /
$ cat /proc/self/mountinfo | grep ' / / '
32 1 8:1 / / rw,relatime shared:327 - ext4 /dev/sda1 rw

この状況だと、マウントの操作が Namespace を越えて伝搬してしまう。 実際に試してみよう。 先ほどコンパイルしたバイナリを実行する。

$ sudo ./a.out

起動したシェルでは tmpfs のマウントが確認できる。 ここまでは変わらない。

# mount | grep /tmp/mntpoint
tmpfs on /tmp/mntpoint type tmpfs (rw,nosuid,nodev,relatime)
# ls /tmp/mntpoint | wc -l
0

しかし、シェルから抜けた後で確認しても、マウントが維持されている。 これはつまり、マウントの操作が Namespace を越えて伝搬してしまった、ということ。

# exit
$ mount | grep /tmp/mntpoint
tmpfs on /tmp/mntpoint type tmpfs (rw,nosuid,nodev,relatime)
$ ls /tmp/mntpoint/ | wc -l
0

ところで、unshare(1) を使うときはプロパゲーションについて特に意識する必要がなかったことを思い出してほしい。 これは、unshare(1) が最初に示したソースコードと同等の処理を実行しているため。 つまり、デフォルトでルートディレクトリのプロパゲーションを明示的に無効にしている。

まとめ

今回は Mount Namespace を使ってマウントの情報をプロセスごとに隔離してみた。 ただし、プロパゲーションの設定によっては操作が伝搬してしまう点に注意する必要がある。

Apple Silicon 版の Mac で Miniforge を使ってサードパーティ製のパッケージをインストールする

これを書いている現在 (2021-11)、Apple Silicon 版の Mac を使って Python の開発環境を整えようとすると、なかなかしんどい。 しんどさの主な要因は、サードパーティ製のパッケージが Apple Silicon をまだサポートしていない場合が多い点にある。

たとえば、Python で機械学習をしようと思ったら誰でも一度は使ったことがあるはずの scikit-learn もそのひとつ。 現在の最新バージョン (1.0.1) でも、Apple Silicon の環境では pip を使った正攻法ではインストールできない。 ただし、以下のドキュメントを読むとわかるとおり、Apple Silicon の環境でインストールする唯一の方法として Miniforge を使うことが挙げられている。

scikit-learn.org

Miniforge は、ざっくり言うと利用するリポジトリがデフォルトで conda-forge 1 になっている Miniconda と考えれば良い。 また、公式のリポジトリには An emphasis on supporting various CPU architectures (x86_64, ppc64le, and aarch64 including Apple M1). という文言がある。

github.com

話が少し脱線するけど、Anaconda 社の運営する公式リポジトリは、現在では大規模な商用利用が有償になっている。 もし、まだ気づいていない人がいたら注意しよう。 その逃げ道としても、Miniforge は有力な選択肢と考えられる 2

前述した conda-forge のリポジトリでは、コミュニティが Apple Silicon の環境向けにビルドした多数のバイナリパッケージを提供している。 そのため、正攻法でインストールしにくいパッケージもすんなりインストールできることが多い。

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

$ sw_vers 
ProductName:    macOS
ProductVersion: 12.0.1
BuildVersion:   21A559
$ uname -m
arm64
$ sysctl machdep.cpu.brand_string
machdep.cpu.brand_string: Apple M1
$ pyenv --version
pyenv 2.2.2
$ conda -V     
conda 4.10.3

もくじ

下準備

今回は、個人的に使い慣れた Pyenv から Miniforge をインストールする手順にした。 とはいえ、別に公式からインストーラを落としてきて入れる方法でももちろん構わない。 むしろ、普段から Pyenv を使っていないのであれば、そちらの方がハマりにくいと思う。

Pyenv は Homebrew を使ってインストールする。

$ brew install pyenv

シェルは zsh を使っているので、~/.zshrc に Pyenv の設定を追加する。

$ echo $SHELL     
/bin/zsh
$ cat << 'EOF' >> ~/.zshrc
if which pyenv >/dev/null 2>&1; then
  export PYENV_ROOT="$HOME/.pyenv"
  export PATH="$PYENV_ROOT/bin:$PATH"
  eval "$(pyenv init --path)"
  eval "$(pyenv init -)"
fi
EOF
$ source ~/.zshrc

Miniforge をインストールする

続いて、Pyenv から Miniforge をインストールする。 miniforge3 を指定すれば、Python3 版の最新の安定版が入るはず。

$ pyenv install miniforge3

インストールしたら、Pyenv で利用する Python 実行環境として miniforge3 を追加する。

$ cat << 'EOF' >> ~/.pyenv/version
system
miniforge3
EOF

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

$ conda -V
conda 4.10.3

自動で base 環境をアクティベートしないように設定を投入しておく。

$ conda config --set auto_activate_base false

Miniforge を使って仮想環境を作る

仮想環境を作る手順は、一般的な Conda のやり方と変わらない。 まずは conda create で仮想環境を作る。

$ conda create -y -n venv python=3.9

これで base 以外の環境が作られる。

$ conda env list  
# conda environments:
#
base                  *  /Users/amedama/.pyenv/versions/miniforge3
venv                     /Users/amedama/.pyenv/versions/miniforge3/envs/venv

Conda のスクリプト類にパスを通すために conda init を実行して設定を読み込む。

$ conda init zsh
$ source ~/.zshrc

これで conda activate で仮想環境を切り替えられるようになる。

$ conda activate venv
(venv) $ 
(venv) $ python -V                         
Python 3.9.7

作った直後なので最低限のパッケージだけが入っている。

(venv) $ pip list                                             
Package    Version
---------- -------
pip        21.3.1
setuptools 59.2.0
wheel      0.37.0

Miniforge でサードパーティ製のパッケージをインストールする

ここからが今回の本題で、前述した scikit-learn を conda install でインストールしてみよう。

(venv) $ conda install -y scikit-learn

次のように、あっさりインストールできる。 ちなみに一緒に入った NumPy なんかも、現状では正攻法でインストールしようとするとつまずきやすい 3

(venv) $ pip list 
Package       Version
------------- -------
joblib        1.1.0
numpy         1.21.4
pip           21.3.1
scikit-learn  1.0.1
scipy         1.7.3
setuptools    59.2.0
threadpoolctl 3.0.0
wheel         0.37.0

もちろん、ちゃんと例外にならずインポートできる。

(venv) $ python -c "import sklearn"

LightGBM なんかも、このとおり。

(venv) $ conda install -y lightgbm
(venv) $ python -c "import lightgbm as lgb"

まとめ

将来的にはサードパーティ製のパッケージが徐々に Apple Silicon を公式でサポートしていって、Miniforge を使う必要性は薄れていくと思う。 とはいえ、それが一体いつなのかは分からない。 ひとまず、問題ないと感じられる日が来るまでは Apple Silicon 環境では Miniforge を使っておこうと思う。


  1. Anaconda 社ではなく、コミュニティが運営している Conda 形式のパッケージを配布するリポジトリ

  2. 大規模に利用していた企業が有償になったからといってコミュニティにフリーライドし始めるのが正しいのか、という話は別にあるとして

  3. まだ Wheel 形式のバイナリが提供されていないため、ソースコード配布物 (sdist) からビルドすることになる

Multipass を使って Apple Silicon 版の Mac で Ubuntu の仮想マシンを扱う

Apple Silicon (M1) の載った Mac mini を購入してからというもの、ローカルで仮想マシンを手軽に立ち上げる方法を模索している。 Intel 版の Mac であれば Vagrant + VirtualBox を使っていたけど、残念ながら VirtualBox は ISA が x86 / amd64 のシステムでしか動作しない。 Docker for Mac も使っているけど、コンテナではなく仮想マシンが欲しいという状況も往々にしてある。 そんな折、Multipass を使うとデフォルトではゲスト OS が Ubuntu に限られる 1 ものの、Vagrant 並に仮想マシンを手軽に扱えることがわかった。 今回は、そんな Multipass の使い方について書いてみる。

multipass.run

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

$ sw_vers
ProductName:    macOS
ProductVersion: 12.0.1
BuildVersion:   21A559
$ uname -m
arm64
$ sysctl machdep.cpu.brand_string
machdep.cpu.brand_string: Apple M1
$ multipass version                        
multipass   1.8.1+mac
multipassd  1.8.1+mac

もくじ

Multipass とは

Multipass は、Ubuntu の開発をしている Canonical 社が提供しているツール。 Windows, Linux, macOS それぞれのプラットフォームで、同じ操作感で Ubuntu の仮想マシンを扱うことができる。 操作は基本的に CUI (Character User Interface) で実施する。

バックエンドのハイパーバイザとして、各 OS のネイティブなフレームワークを利用できる点が特徴的。 たとえば、デフォルトだと Windows であれば Hyper-V 2、macOS だとHyperKit 3、Linux は QEMU/KVM が使える。

下準備

Multipass は Homebrew を使ってインストールできる。

$ brew install --cask multipass

インストールすると multipass コマンドが使えるようになる。

$ multipass version                        
multipass   1.8.1+mac
multipassd  1.8.1+mac

仮想マシンを起動する

仮想マシンを起動するには multipass launch コマンドを使う。 --name オプションを使うと仮想マシンに名前をつけられる。 ここでは focal という名前をつけた。 また、末尾には Ubuntu のバージョン番号を指定する。 バージョン番号を省略した場合には、最新の LTS (Long Term Support) リリースが使われるらしい。

$ multipass launch --name focal 20.04

初回の起動時にはイメージのダウンロードや検証が入るので、少し時間がかかる。

なお、指定できるバージョン番号 (ディストリビューション) は multipass find コマンドで確認できる。

$ multipass find
Image                       Aliases           Version          Description
18.04                       bionic            20211109         Ubuntu 18.04 LTS
20.04                       focal,lts         20211108         Ubuntu 20.04 LTS
21.04                       hirsute           20211110         Ubuntu 21.04
21.10                       impish            20211103         Ubuntu 21.10
anbox-cloud-appliance                         latest           Anbox Cloud Appliance
minikube                                      latest           minikube is local Kubernetes

起動が終わると multipass ls コマンド (または multipass list) に仮想マシンが表示されるようになる。

$ multipass ls                       
Name                    State             IPv4             Image
focal                   Running           192.168.64.2     Ubuntu 20.04 LTS

仮想マシンのより詳しい情報は multipass info コマンドで確認できる。 デフォルトでは 1GB のメモリと 5GB のディスクが割り当てられるらしい。

$ multipass info focal
Name:           focal
State:          Running
IPv4:           192.168.64.2
Release:        Ubuntu 20.04.3 LTS
Image hash:     a83b747df657 (Ubuntu 20.04 LTS)
Load:           0.62 0.22 0.08
Disk usage:     1.2G out of 4.7G
Memory usage:   135.6M out of 974.8M
Mounts:         --

仮想マシンに割り当てる CPU やメモリ、ディスクの容量などは起動時にカスタマイズできる。 具体的には multipass launch コマンドで --cpus--mem--disk といったオプションを指定すれば良い。 詳細は multipass launch --help から確認できる。

仮想マシンでコマンドを実行する

起動した仮想マシンでは、multipass exec コマンドを使ってコマンドが実行できる。 仮想マシンの名前と、ハイフンを 2 つはさんで実行したいコマンドを指定しよう。

$ multipass exec focal -- lsb_release --description
Description:    Ubuntu 20.04.3 LTS
$ multipass exec focal -- uname -m                 
aarch64

仮想マシンの ISA も、ちゃんと aarch64 になっている。

また、multipass shell コマンドを使えばシェルにログインできる。

$ multipass shell focal                            
Welcome to Ubuntu 20.04.3 LTS (GNU/Linux 5.4.0-90-generic aarch64)

... (snip) ...

ubuntu@focal:~$ 

通常はこちらを使って操作することになりそうだ。

仮想マシンを停止・再開・削除する

仮想マシンを停止するには multipass stop コマンドを使う。 ちなみに -t オプションを使えば分単位で停止するタイミングを遅らせることもできる。

$ multipass stop focal
$ multipass ls           
Name                    State             IPv4             Image
focal                   Stopped           --               Ubuntu 20.04 LTS

同様に、再開するときは multipass start コマンドを使う。

$ multipass start focal
$ multipass ls
Name                    State             IPv4             Image
focal                   Running           192.168.64.2     Ubuntu 20.04 LTS

仮想マシンを削除するときは multipass delete コマンドを使う。 ただし、この時点では完全に削除されてはいない。

$ multipass delete focal
$ multipass ls          
Name                    State             IPv4             Image
focal                   Deleted           --               Not Available

Deleted な状態であれば、まだ multipass recover コマンドで復旧できる。

$ multipass recover focal
$ multipass ls           
Name                    State             IPv4             Image
focal                   Stopped           --               Ubuntu 20.04 LTS
$ multipass start focal
$ multipass exec focal -- lsb_release --description
Description:    Ubuntu 20.04.3 LTS

仮想マシンを完全に削除するには、multipass delete した後に multipass purge する。 これで Deleted な状態の仮想マシンが完全に削除される。

$ multipass delete focal
$ multipass purge
$ multipass ls   
No instances found.

次の検証に移る前に、削除してしまった仮想マシンを作り直しておこう。

$ multipass launch --name focal 20.04

ホスト OS のストレージをゲスト OS でマウントして使う

Multipass にはホスト OS のストレージをゲスト OS でマウントする機能がある。 この機能は両者でファイルを共有するときに有用。

機能を使うときは multipass mount コマンドを使う。 このコマンドは次のような書式になっている。

$ multipass mount <source> <target>

ここで、<target><vm-name><vm-name>:<path> の指定ができる。 :<path> を省略すると <source> と同じパスでマウントしようとする。 試しにカレントディレクトリをマウントしてみよう。 動作確認用にファイルを作っておく。

$ echo "Hello, World" > greet.txt
$ ls
greet.txt

カレントディレクトリを指定して仮想マシンにマウントする。 ・・・が、これは指定するパスにもよるけど次のようなエラーになる恐れがある。

$ multipass mount $(pwd) focal:/mnt
mount failed: source "/Users/amedama/Documents/temporary/multipass" is not readable

上記のエラーは macOS の設定に起因している。 というのも、デフォルトでは Multipass に $HOME 以下の任意のディレクトリはアクセスが許可されていない。

このエラーを解消するには「システム環境設定 > セキュリティとプライバシー > プライバシー > フルディスクアクセス」を選択して、multipassd にチェックを入れる必要がある。 もちろん、これはセキュリティ保護に関係ないパスを使っていれば問題にはならない。

操作した上で、改めてマウントを試みると今度はエラーにならず成功する。

$ multipass mount $(pwd) focal:/mnt

マウントすると、次のようにちゃんと仮想マシンからホスト OS のディレクトリが見られるようになっている。

$ multipass exec focal -- ls /mnt
greet.txt
$ multipass exec focal -- cat /mnt/greet.txt
Hello, World

ゲスト OS がマウントしている情報は multipass info コマンドから確認できる。

$ multipass info focal                      
Name:           focal
State:          Running
IPv4:           192.168.64.3
Release:        Ubuntu 20.04.3 LTS
Image hash:     a83b747df657 (Ubuntu 20.04 LTS)
Load:           0.07 0.05 0.03
Disk usage:     1.2G out of 4.7G
Memory usage:   150.5M out of 974.8M
Mounts:         /Users/amedama/Documents/temporary/multipass => /mnt
                    UID map: 501:default
                    GID map: 20:default

マウントを解除したいときは multipass umount コマンドを使えば良い。

$ multipass umount focal

Multipass の設定を読み書きする

Multipass の設定は multipass get コマンドと multipass set コマンドで読み書きできる。 設定項目自体は multipass help get コマンドから確認できる。

たとえばバックエンドのハイパーバイザは local.driver という設定項目になっている。 Apple Silicon の Mac では、デフォルトで QEMU が使われているようだ。

$ multipass get local.driver
qemu

設定は multipass set コマンドを使って <key>=<value> 形式で書きかえられる。 試しに local.driverhyperkit に切り替えてみようとすると、無効なドライバだとエラーになってしまった。

$ multipass set local.driver=hyperkit
Invalid setting 'local.driver=hyperkit': Invalid driver

ちなみに Intel 版の Mac であれば、デフォルトでは以下のように hyperkit が使われていた。

$ sw_vers                        
ProductName:    macOS
ProductVersion: 12.0.1
BuildVersion:   21A559
$ uname -m
x86_64
$ multipass version   
multipass   1.8.1+mac
multipassd  1.8.1+mac
$ multipass get local.driver
hyperkit

試しに上記 Intel 版の環境を使って、バックエンドを VirtualBox に変更してみよう。

$ brew install --cask virtualbox
$ multipass set local.driver=virtualbox

設定を変更すると Multipass のデーモンが再起動する。 再起動が終わらないうちに操作しようとすると、次のようなエラーになる。

launch failed: cannot connect to the multipass socket                           
Please ensure multipassd is running and '/var/run/multipass_socket' is accessible

少し待つと、ちゃんと使えるようになる。 バックエンドを切りかえた上で、試しに Ubuntu 18.04 LTS を起動してみよう。

$ multipass launch --name bionic 18.04

VBoxManage list vms コマンドで確認すると、ちゃんと VirtualBox 上で仮想マシンが立ち上がっているようだ。 ちなみに sudo をつけていることからわかるようにスーパーユーザで実行されている。

$ sudo VBoxManage list vms
"bionic" {afb0cdcb-fbd2-4d01-9310-4abd63fa2b9d}

ゲスト OS 上で IP アドレスを確認すると 、10.0.2.15/24 と、たしかに VirtualBox っぽいアドレスが振られている。

$ multipass exec bionic -- ip address show | grep inet
    inet 127.0.0.1/8 scope host lo
    inet6 ::1/128 scope host 
    inet 10.0.2.15/24 brd 10.0.2.255 scope global dynamic enp0s3
    inet6 fe80::5054:ff:fe0e:1caa/64 scope link 

バックエンドを元に戻したいときは、また HyperKit を使うように local.driver を設定すれば良い。

$ sudo multipass set local.driver=hyperkit

まとめ

Multipass を使うことで、Apple Silicon 版の Mac であっても、デフォルトでゲスト OS は Ubuntu に限られるものの簡単に仮想マシンを扱えることがわかった。 最近は、仮想マシンを立ち上げたいときが大体 Ubuntu を動かしたいときとほぼイコールだったので、ひとまず自分的にはこれで十分かもしれない。 IaaS のインスタンスを立ち上げて使うほどでもないくらいの簡単な検証がローカルでやりやすくなるのは嬉しい。


  1. 自分でイメージを作ることもできるらしい。

  2. ただし、利用できる Windows のエディションやバージョンには制約があるようだ。

  3. 裏側で Hypervisor.framework が動作する。なお、Apple Silicon ではデフォルトで QEMU が使われるようだ。