Edited at

MINCSによるLinuxコンテナ実装の裏側

More than 1 year has passed since last update.


これは何か

MINCSのコンテナ実装の基本設計を解説する記事です。MINCSの概要については以前の記事を参照してください。

この記事では実際にMINCSのコードを見ながら、シェルスクリプトあるいはコマンドラインから最小限のコンテナ環境を作る方法を説明しています。


MINCSの基本設計

MINCS(mincコマンド)はPosixシェルスクリプト1としてコンテナを実装することを目的としていたため、基本的には特殊なコマンドを使わず、Linuxの基本コマンド(coreutilsやutil-linux)を利用して実装しています。


Linuxコンテナの基礎

Linuxコンテナとは何でしょうか? 個人的には、広義の(狭義の?)Linuxコンテナとはnamespaceを利用した空間分離のことを指すと考えています。このnamespaceは基本的にはプロセスから見えるリソースを別空間に分離する機能だと思ってください(ただしリソースによって分離の仕方に違いがあります)。


MINCSで扱うLinuxコンテナの定義

ざっくりというと、MINCSではコンテナを「pid名前空間とmount名前空間を分離し、別のrootfs(ルートファイルシステム)にchrootした状態で実行されるisolatedプロセス群」として定義しています。他の名前空間はお飾りです。偉い人には分からんのです。オプショナルな機能として扱っています。実際、ユーザの目に見える最も大きな違いはこの2つです。厳密に言うと、mount名前空間が異なる上で、色んなマウントポイントをマウントし直すので、結果としてデバイスの見え方なんかも違ったりする、という違いがあります。

「別のrootfs」と言いましたが、mincでデフォルトで使われるrootfsはホストのrootfsです。ただし、ホストのrootfsの上にoverlayfsで別のレイヤを被せて、コンテナ内部で行った変更がホスト側の別のプロセスから見えることを防いでいます。

つまり、MINCSではコンテナを徹底して単なるプロセスの実行環境の分離として扱っています。これはfork->execのリソース分離シーケンスを一層推し進めたものと考えることが出来るでしょう。


mincのコンテナ作成シーケンス

mincでは以下に示す3段階の処理を行い、コンテナを作成します。


  1. unshareによる、名前空間の(分岐という意味での)フォーク

  2. mount/umountによる、新しいrootfsに対するoverlayや様々なマウントポイントの再整備

  3. chroot(or pivot_root)による新しいrootfsへの移行と対象コマンドの実行

ざっくりとプロセスの実行と比較するなら、1はfork、2は新しい実行ファイルのロード、3はその実行ファイルをexecすることに例えられるでしょう。

ではそれぞれの段階でどのような処理をしているか、その時何が起きるのかを見ていきましょう2


名前空間のフォーク

まずmincでは、netnsの分離やcgroupsの設定などの処理をしてから、unshareでリソースの分離を行います。


libexec/minc-exec

unshare -iumpf $LIBEXEC/`basename $0` "$@"


-iumpfは順に、IPC名前空間、UTS名前空間、mount名前空間、pid名前空間を分離し、指定したプログラムをforkして起動する、というオプションです。PID名前空間を新しく作る場合、プロセスを新しく作らないと新しい名前空間に入らないということのようです。

unshareコマンドを実行すると、cloneシステムコールで指定したnamespaceの分離がなされ、新しい名前空間上で子プロセスが実行されます。この時子プロセスの名前空間上では面白い現象が起きます。

- mount名前空間の分離を指示しても、unshare直後はプロセスのforkと同じく、別の実体ではあるものの元の名前空間と同じマウント状態が維持されます。

- pid名前空間の分離を指示すると、自身のPIDは1になりますが、前述のように親の/procのマウント状態が維持されるため、/proc/以下に親プロセスのPIDなどが見えてしまいます3

mincではこの性質を逆に利用し、unshareによるコンテナ内の子プロセス起動後に、以下のコードを使ってそのプロセスのホストからみたPIDを自ら取得し、保持するようにしています。


libexec/minc-exec

  # At this point, we still have original namespace procfs

export MINC_PID=`cut -f 4 -d" " /proc/self/stat`

/proc/self(つまりこのプロセス)のPIDを、親の名前空間のprocfsから取得しているわけですね。これはしばらく先の処理で以下のようにtmpdirのpidファイルに保存しています。


libexec/minc-exec

echo $MINC_PID > $MINC_TMPDIR/pid


このPIDファイルはホスト側からも見えるので、コンテナに対して外部からシグナルを送るのに利用できます。


新しいrootfsの再構築

unshareでmount名前空間を分離したので、そのまま改造して使うことも可能ですが、mincではdockerみたく違うディストロのrootfsを使ったり、現在のrootfsにoverlayfsで保護したいので、新しいrootfsを現在のrootfsの一時的なサブディレクトリとして用意して構築を行います。mincではこのサブディレクトリは外部から与えられる事もあるので、unshareを実行する前にmktempで作っています。(なのでホストからその様子が確認できます)


minc

    export MINC_TMPDIR=`mktemp -d /tmp/minc$$-XXXXXX`



libexec/minc-coat

RD=$TMPDIR/root

UD=$TMPDIR/storage
WD=$TMPDIR/work
...
mount -t overlay -o upperdir=$UD,lowerdir=$BASEDIR,workdir=$WD overlayfs $RD 2>/dev/null

変数$BASEDIRが指しているのがrootfsの元になるディレクトリです。

さらにunshareでUTS名前空間を分離していたので、コンテナのホスト名を設定します(デフォルトではホストのホスト名を利用します4)。


libexec/minc-exec

    hostname $MINC_UTSNAME


続いて、procfsやsysfs、/devなどを、このサブディレクトリに追加していきます。


libexec/minc-exec

    # Use fake devfs

mount -t tmpfs tmpfs $RD/dev
# Prepare pts
mkdir $RD/dev/pts
if [ "$MINC_OPT_PTY" ]; then
# This just a quick hack...
touch $RD`tty`; bindmount `tty`
touch $RD/dev/pts/ptmx; bindmount /dev/pts/ptmx
else
mount devpts -t devpts -onoexec,nosuid,gid=5,mode=0620,newinstance,ptmxmode=0666 $RD/dev/pts
fi
ln -s /dev/pts/ptmx $RD/dev/ptmx
touch $RD/dev/console; bindmount /dev/console
touch $RD/dev/null; bindmount /dev/null
touch $RD/dev/zero; bindmount /dev/zero
touch $RD/dev/random; bindmount /dev/random
touch $RD/dev/urandom; bindmount /dev/urandom
test -d /dev/mqueue && mkdir $RD/dev/mqueue && bindmount /dev/mqueue

bindmount関数は指定されたパスを新しいrootfsの同じパスにバインドマウントします。単純に/dev以下にdevtmpfsをマウントしてもいいのです5が、余計なデバイスまで見えてしまうのを防ぐため、適当に厳選したデバイスをバインドマウントしています。また、ttyについては、devptsファイルシステムをマウントしていますが、これだと現在使っているttyが見えないという事態が発生します。mincでは--ptyオプションを使うと現在のttyをバインドマウントするようになります(結果としてttyコマンドが正常に動くようになります)。

次にprocfsとsysfsをバインドマウントします。/procについては概ねprocfsを再マウントするだけですが、いくつかのクリティカルなものについては、(1)大元の/procをroマウントして、(2)新しいrootfsの/procにバインドマウントする、という方法を使い、対象のファイルがread onlyになるようにしています。

sysfsについては単に現在の/sysをバインドマウントしています。


libexec/minc-exec

  mount -t proc -o ro,nosuid,nodev,noexec proc /proc #(1)

mount -t proc -o rw,nosuid,nodev,noexec,relatime proc $RD/proc
bindmount /proc/sys #(2)
bindmount /proc/sysrq-trigger #(2)
bindmount /proc/irq #(2)
bindmount /proc/bus #(2)
[ "$MINC_NOPRIV" ] || bindmount /sys


chroot/pivot_rootによるrootfsの変更

コンテナ作成の最後の処理はrootfsの変更ですが、mincではchrootとpivot_rootの両方のパスを用意しています。


chrootによるrootfsの変更

chrootでは既知の仕様により、jailbreakされる可能性があるため、capshが使えるならcapshでコンテナ内部からはchroot出来ないようにしています。


libexec/minc-exec

    # in this case, cap_sys_chroot should be dropped if possible.

if which capsh > /dev/null 2>&1; then
MINC_DROPCAPS="$MINC_DROPCAPS,cap_sys_chroot"
fi
leash $RD "$@"

leash()関数は、capshコマンドでchrootあるいはchrootでrootfsを変更し、sh -cを使って指定されたコマンドを実行します。指定されたコマンドにはexecを使うことで、そのコマンドがpid=1のプロセスとなるようにしています6


libexec/minc-leash

  RUN="$MINC_DEBUG_PREFIX capsh --chroot=$RD $OPT --drop=$MINC_DROPCAPS -- "

else
RUN="$MINC_DEBUG_PREFIX chroot $OPT $RD sh"
...
exec $RUN -c "exec $*"


pivot_rootによるrootfsの変更

chrootメソッドではcapshが無いとjailbreakし放題なので、pivot_rootを使ってrootfsを変更することもできます。


libexec/minc-exec

    # To unmount all unused mountpoints, use pivot_root to change root

cd $RD
mkdir -p .orig
pivot_root . .orig
PATH=/bin:/sbin:/usr/bin:/usr/sbin
mount --make-rprivate /.orig
umount -l /.orig
RD=.
leash $RD "$@"

以前はpivot_rootを2回使っていましたが、「/.origだけprivateにした上でumount --lazyを使えば不要なものだけ外せるんや!」みたいな話をどこかで聞いて修正しました。

pivot_rootでrootfsは変更されるのですが、man pivot_rootにあるように、exec chrootしないと最終的に元のプロセスが元のrootfsを参照してしまうのでumountされません。このためpivot_rootをするパスでも、最終的にはleashを呼び出しています。


chrootとpivot_root

今のところデフォルトではpivot_rootを使っていますが、chroot+capshの方がコンテナとしては正統です。なぜなら、pivot_rootを実行した後のコマンド(mount, umount, chroot, capshなど)は、基本的には全て新しいrootfs上にあるコマンドを実行することになるからです。

このため、近いうちにデフォルトを変更することになるでしょう。

chroot
pivot_root

jailbreak方法
あり。ただしcapshで回避可能
今のところ無し

コンテナ依存性
なし
あり。コンテナ内のmountやchrootを呼び出している

この辺り、コンテナエンジンとして一つのバイナリに出来る他の実装方法であれば、必要なコマンドや処理を自身に内包できるので問題ないですが、シェルスクリプトだけで実装されているMINCSでは実現できません。

コンテナエンジンに本当に必要だったのは、unshareじゃなくenhanced pivot_rootやったんや!というのが今回のオチです7






  1. ちなみに、当然unshareなどlinuxオンリーのツールを使っているので、厳密にはPOSIX互換ではないです。飽くまでbashismではないという意味。 



  2. ちなみに実際には--debugオプションをつけたり、minc-execの各段階のあとに、bashって書けば何が起きてるのかは調べられるよ:wink: 



  3. ただし、unshareに--mount-procを指定すると、名前空間の分離後にprocfsが再マウントされ、正しいpid名前空間が見えるようになります。 



  4. Dockerだとコンテナのハッシュ値ですが、mincでは特にハッシュ値とかは使わないので、ホストの名前そのままになっています。 



  5. 実際、--usedevオプションを使うとdevtmpfsをマウントするだけになります。 



  6. お察しの通り、この方法だと引数で渡した空白やエスケープを含む文字列がきちんと処理されないのですが、実質的にあまり問題にならないと思うので放置しています。あまり複雑な処理を渡す場合はスクリプトを書いてください。 



  7. いや、実際chroot+capshだけでなく、seccompとかselinuxのサポートとか、コンテナの蓋を最後に閉じる部分は他の処理に比べて一体性が必要になる(順序の変更が出来ないし柔軟性もあまりない)ので、「コンテナエンジンコマンド」として作る蓋然性はありますね。