フラミナル

考え方や調べたことを書き殴ります。IT技術系記事多め

要素技術を触って学ぶ「コンテナ技術入門」を実際にやってみた

Containers

こちらの記事をご存知でしょうか?

コンテナってLinuxのNamespaceやcgroupを使ってやってるのよねまでは知りつつも、その裏側までは知りませんでした。そこでこの記事で紹介されているハンズオンを実際にやっていきたいと思います。

  • かかった時間:20分程度
  • 前提知識:Linux操作

ハンズオン

Vagrant+Virtual boxで環境準備

【事前準備】

vagrantとvirtual boxをインストールしてください。 ただしバージョンによって動作しないことがあります。 私の環境ではvagrant 2.2.6virtualbox 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

コンテナの作成

ここではcgroupNamespaceを使い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にメッセージを送る)

f:id:lirlia:20200408104433p:plain


続いて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というコンテナツールはこの辺を楽するためのラッパーなんだなとわかったと思います。