こちらの記事をご存知でしょうか?
コンテナってLinuxのNamespaceやcgroupを使ってやってるのよねまでは知りつつも、その裏側までは知りませんでした。そこでこの記事で紹介されているハンズオンを実際にやっていきたいと思います。
- かかった時間:20分程度
- 前提知識:Linux操作
ハンズオン
Vagrant+Virtual boxで環境準備
【事前準備】
vagrantとvirtual boxをインストールしてください。 ただしバージョンによって動作しないことがあります。 私の環境では
vagrant 2.2.6
とvirtualbox 5.2.38 r136252 (Qt5.6.3)
を使っています
$ mkdir docker-test ; cd docker-test $ vim Vagrantfile $ vagrant up $ vagrant ssh
※Vagrantfileは記事で紹介されているものを使ってください
コンテナの作成(事前準備)
# mktempでユニークなtempファイル作成 vagrant@ubuntu-bionic:~$ ROOTFS=$(mktemp -d) # bashが動作するコンテナイメージを作成します vagrant@ubuntu-bionic:~$ CID=$(sudo docker container create bash) # 確認 vagrant@ubuntu-bionic:~$ echo $CID f4818f8cf25f640d0228d4aa071561f27a208980ef49adf59266ae04c0e0fc11
CID
にはコマンドの実行結果が格納されています。ここではコンテナ ID が STDOUT (標準出力)に表示されています。ここで作ったイメージは/var/lib/docker
配下に格納されています。
# docker イメージを先ほど作ったディレクトリに展開し格納 vagrant@ubuntu-bionic:~$ sudo docker container export $CID | tar -x -C $ROOTFS # 確認 vagrant@ubuntu-bionic:~$ ls $ROOTFS bin etc lib mnt proc run srv tmp var dev home media opt root sbin sys usr # bashのシンボリックリンクを作成 vagrant@ubuntu-bionic:~$ ls $ROOTFS/bin arch dd fsync linux32 mountpoint reformime su ash df getopt linux64 mpstat rev sync base64 dmesg grep ln mv rm tar bbconfig dnsdomainname gunzip login netstat rmdir touch busybox dumpkmap gzip ls nice run-parts true cat echo hostname lzop pidof sed umount chgrp ed ionice makemime ping setpriv uname chmod egrep iostat mkdir ping6 setserial usleep chown false ipcalc mknod pipe_progress sh watch conspy fatattr kbd_mode mktemp printenv sleep zcat cp fdflush kill more ps stat date fgrep link mount pwd stty vagrant@ubuntu-bionic:~$ ln -s /usr/local/bin/bash $ROOTFS/bin/bash vagrant@ubuntu-bionic:~$ ls $ROOTFS/bin/bash /tmp/tmp.KqU3bIG1sk/bin/bash # 作ったコンテナを削除 vagrant@ubuntu-bionic:~$ sudo docker container rm $CID
cgroupの作成
$ UUID=$(uuidgen) $ sudo cgcreate -t $(id -un):$(id -gn) -a $(id -un):$(id -gn) -g cpu,memory:$UUID $ cgset -r memory.limit_in_bytes=10000000 $UUID $ cgset -r cpu.cfs_period_us=1000000 $UUID $ cgset -r cpu.cfs_quota_us=300000 $UUID
まずはUUIDを作成します。(ただのユニークなIDです)
vagrant@ubuntu-bionic:~$ UUID=$(uuidgen) vagrant@ubuntu-bionic:~$ echo $UUID 0c23703c-d4e4-4cec-bd57-7654b606d715
つづいてcgroup
を作成します。
vagrant@ubuntu-bionic:~$ sudo cgcreate -t $(id -un):$(id -gn) -a $(id -un):$(id -gn) -g cpu,memory:$UUID vagrant@ubuntu-bionic:~$ echo "$(id -un):$(id -gn) -a $(id -un):$(id -gn)" vagrant:vagrant -a vagrant:vagrant
そして作成したcgroup
に対してCPUやメモリの制限を行なっていきます。
vagrant@ubuntu-bionic:~$ cgset -r memory.limit_in_bytes=10000000 $UUID vagrant@ubuntu-bionic:~$ cgset -r cpu.cfs_period_us=1000000 $UUID vagrant@ubuntu-bionic:~$ cgset -r cpu.cfs_quota_us=300000 $UUID
コンテナの作成
ここではcgroup
とNamespace
を使いCPU/メモリといったリソースを制限し、カーネルリソースを隔離し、必要なファイルシステムのマウントなどを行なっていきます。
vagrant@ubuntu-bionic:~$ CMD="/bin/sh" vagrant@ubuntu-bionic:~$ cgexec -g cpu,memory:$UUID \ > unshare -muinpfr /bin/sh -c " > mount -t proc proc $ROOTFS/proc && > touch $ROOTFS$(tty); mount --bind $(tty) $ROOTFS$(tty) && > touch $ROOTFS/dev/pts/ptmx; mount --bind /dev/pts/ptmx $ROOTFS/dev/pts/ptmx && > ln -sf /dev/pts/ptmx $ROOTFS/dev/ptmx && > touch $ROOTFS/dev/null && mount --bind /dev/null $ROOTFS/dev/null && > /bin/hostname $UUID && > exec capsh --chroot=$ROOTFS --drop=cap_sys_chroot -- -c 'exec $CMD' > "
それでは一行づつ意味を確認していきます。
これはCMD変数に格納しているだけですね。
vagrant@ubuntu-bionic:~$ CMD="/bin/sh"
続いてcgexec
コマンドを使ってUUIDで定義されたcgroupにて\
以下のコマンドを実行しています。このcgroupには先ほど設定したメモリとCPUの制限がなされていますのでそれを使うように明示します。
vagrant@ubuntu-bionic:~$ cgexec -g cpu,memory:$UUID \
つづいてunshare
です。このコマンドは親プロセスからいくつかのNamespace(名前空間)を共有せずにプログラムを実行するためのコマンドです。
> unshare -muinpfr /bin/sh -c "〜〜"
ここで指定しているオプションを見てみましょう。
オプション | 意味 |
---|---|
m | マウントの名前空間 |
u | UTSの名前空間 ※hostnameなど |
i | System V IPCの名前空間 |
n | ネットワークの名前空間 |
p | PIDの名前空間 |
f | 指定されたプログラムを起動前にフォークする |
r | 現在のユーザー(unshareコマンドを実行したユーザー)を作成したプログラムのrootユーザとしてマッピングする |
さて、オプションをみたところで何をしているか?についてですが、このコマンド(unshare -muinpfr /bin/sh -c "〜〜"
)では/bin/sh -C "〜〜"
で指定したプログラムをunshare
を用いて別のNamespaceで実行しようとしています。
/bin/sh -c 〜〜
は〜〜を新しいshプロセスの上で実行するという意味ですね。
では続いてそのshellで実行される内容をみていきましょう。
> mount -t proc proc $ROOTFS/proc && > touch $ROOTFS$(tty); mount --bind $(tty) $ROOTFS$(tty) && > touch $ROOTFS/dev/pts/ptmx; mount --bind /dev/pts/ptmx $ROOTFS/dev/pts/ptmx && > ln -sf /dev/pts/ptmx $ROOTFS/dev/ptmx && > touch $ROOTFS/dev/null && mount --bind /dev/null $ROOTFS/dev/null && > /bin/hostname $UUID && > exec capsh --chroot=$ROOTFS --drop=cap_sys_chroot -- -c 'exec $CMD'
一行目からです。
> mount -t proc proc $ROOTFS/proc &&
proc
と呼ばれるプロセスが稼働するための仮想ファイルシステムです。ホストOS上の/procと先ほど作ったコンテナ用の/procをマッピングしています。最後の&&
はこのコマンドが成功したら次のコマンドを実行するという意味です。
例:echo "hello" && echo "world"
の場合
vagrant@ubuntu-bionic:~$ echo "hello" && echo "world" hello world
続いてtouchです。
> touch $ROOTFS$(tty); mount --bind $(tty) $ROOTFS$(tty) &&
ここでは現在使っているtty
を作成しコンテナ用のtty
と紐づけています。
vagrant@ubuntu-bionic:~$ echo $(tty) /dev/pts/0
※ttyで表示されている/dev/pts/0
とは私たちがvagrant ssh
をしてリモートログインをした際に使われている擬似端末です
vagrant@ubuntu-bionic:~$ echo "hello" >> /dev/pts/0 hello
こんな風にすると自分の画面にhello
と出力できます。また擬似端末はアクセスごとに作成されますので、1台のマシンに複数の端末でログインすると新しい/dev/pts/1
が作成されます。
なので別の端末にこんないたずらもできます。(pts/0
からpts/1
にメッセージを送る)
続いてptmx
でも同じようにします。
> touch $ROOTFS/dev/pts/ptmx; mount --bind /dev/pts/ptmx $ROOTFS/dev/pts/ptmx &&
ptmxの詳しい説明はこちらをどうぞ。
なんでこんなことをしているかというと、これがないとログインしても操作できないからです。
続いて先ほどマウントしたptmx
のシンボリックリンクを作成します。
> ln -sf /dev/pts/ptmx $ROOTFS/dev/ptmx &&
つづいて/dev/null
の作成です
> touch $ROOTFS/dev/null && mount --bind /dev/null $ROOTFS/dev/null &&
ホスト名を設定します。
> /bin/hostname $UUID &&
ここではexec
コマンドを使ってcapsh
をexecしています。
> exec capsh --chroot=$ROOTFS --drop=cap_sys_chroot -- -c 'exec $CMD'
execとは現在のプロセスを指定したものに変更するというもので、forkと対となるプロセスの作成方法です。(forkの場合は既存のプロセスをコピーしてプロセスが生成される)
ターミナルを新しく開いてexec sleep 10
と実行してみましょう。これを行うとSSHが終了します。
vagrant@ubuntu-bionic:~$ exec sleep 10 Connection to 127.0.0.1 closed.
これは私たちが使っていたbashプロセス
がexecによってsleep 10 プロセス
に置き換わったのです。そしてsleep 10
が終了するとこのプロセスは終了しますのでSSH自体も終了したわけです。
続いてその中身です。
> exec capsh --chroot=$ROOTFS --drop=cap_sys_chroot -- -c 'exec $CMD'
capsh
コマンドは引数で指定したコマンドに対して様々な条件を付加して実行するラッパーツールです。
--chroot
はLinuxの機能であるchrootそのままでルートディレクトリの位置を変更します--drop=cap_sys_chroot
はchrootを使うために必要なオプションです(CAP_SYS_CHROOT ケーパビリティを持つプロセス) のみが chroot (2) を呼び出すことができる)
例えば$ROOTFS/testDir
を作ってみましょう。そしてこれをcapsh
コマンド経由でls /testDir
を実行してみます。
vagrant@ubuntu-bionic:~$ touch $ROOTFS/testDir
もちろん通常の/
には存在していませんが、chrootによって/
の位置を$ROOTFS
に変更された状態で実行されるls
はルートディレクトリが/$ROOTFS
となっていますのできちんと表示されます。
vagrant@ubuntu-bionic:~$ ls /testDir ls: cannot access '/testDir': No such file or directory vagrant@ubuntu-bionic:~$ sudo capsh --chroot=$ROOTFS --drop=cap_sys_chroot -- -c "ls /testDir" /testDir
さてということで、このコマンド群でやっていることを振り返ってみます。
vagrant@ubuntu-bionic:~$ cgexec -g cpu,memory:$UUID \ > unshare -muinpfr /bin/sh -c " > mount -t proc proc $ROOTFS/proc && > touch $ROOTFS$(tty); mount --bind $(tty) $ROOTFS$(tty) && > touch $ROOTFS/dev/pts/ptmx; mount --bind /dev/pts/ptmx $ROOTFS/dev/pts/ptmx && > ln -sf /dev/pts/ptmx $ROOTFS/dev/ptmx && > touch $ROOTFS/dev/null && mount --bind /dev/null $ROOTFS/dev/null && > /bin/hostname $UUID && > exec capsh --chroot=$ROOTFS --drop=cap_sys_chroot -- -c 'exec $CMD' > "
cgexec
で指定したcgroupの設定に対して、unshare
で新しいシェルを呼び出し、その中でNamespaceの制限を行い、各種デバイスファイルの作成・マウントを実施し、ホスト名を規定しました。またそのシェルはexec capsh
によってただのシェルからcapsh
コマンドで指定された-c 'exec $CMD'
プロセスに変貌しました。
$CMD
は最初に指定した/bin/sh
ですね。
要するにいろんな制限をかけたshプロセスを起動しただけです。
作成したコンテナの確認
このコマンドを実行すると以下のようにシェルが表示されます。
/ # hostname 0c23703c-d4e4-4cec-bd57-7654b606d715 / # ls bin home mnt root srv tmp dev lib opt run sys usr etc media proc sbin testDir var
先ほど作ったtestDir
もありますね。
/ # / # uname -n 0c23703c-d4e4-4cec-bd57-7654b606d715 / # id uid=0(root) gid=0(root) groups=0(root) / # ps aux PID USER TIME COMMAND 1 root 0:00 /bin/sh 17 root 0:00 ps aux / # mount proc on /proc type proc (rw,relatime) devpts on /dev/pts/0 type devpts (rw,nosuid,noexec,relatime,gid=5,mode=620,ptmxmode=000) devpts on /dev/pts/ptmx type devpts (rw,nosuid,noexec,relatime,gid=5,mode=620,ptmxmode=000) udev on /dev/null type devtmpfs (rw,nosuid,relatime,size=491552k,nr_inodes=122888,mode=755) / # / # ip link 1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN qlen 1000 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 / # / # yes > /dev/null
実行したシェルとは別のシェルからps -f
を叩くときちんとコンテナの中のyes
が実行されていることがわかりますね。
vagrant@ubuntu-bionic:~$ ps f PID TTY STAT TIME COMMAND 9976 pts/1 Ss 0:00 -bash 10144 pts/1 R+ 0:00 \_ ps f 9218 pts/0 Ss 0:00 -bash 10116 pts/0 S 0:00 \_ unshare -muinpfr /bin/sh -c mount - 10117 pts/0 S 0:00 \_ /bin/sh 10139 pts/0 R+ 0:24 \_ yes
そして使っているリソースを確認するとcgroup
で指定した30%程度のCPU使用率に収まっていることがわかりますね。
vagrant@ubuntu-bionic:~$ top -p $(ps e |grep yes | grep -v grep | awk '{print $1}') top - 06:20:09 up 6:52, 2 users, load average: 0.04, 0.07, 0.02 Tasks: 1 total, 1 running, 0 sleeping, 0 stopped, 0 zombie %Cpu(s): 13.3 us, 0.5 sy, 0.0 ni, 86.2 id, 0.0 wa, 0.0 hi, 0.0 si KiB Mem : 1008624 total, 100124 free, 170772 used, 737728 buff/ KiB Swap: 0 total, 0 free, 0 used. 697052 avail PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ 10139 vagrant 20 0 1572 4 0 R 29.7 0.0 1:59.09
後片付け
記事内のコマンドを叩いて掃除をしましょう。
/ # exit vagrant@ubuntu-bionic:~$ sudo cgdelete -r -g cpu,memory:$UUID vagrant@ubuntu-bionic:~$ rm -rf $ROOTFS
まとめ
このようにコンテナの技術は紐解いていくとLinuxが搭載している機能を使って実現されていることがわかります。そして、Dockerというコンテナツールはこの辺を楽するためのラッパーなんだなとわかったと思います。