Help us understand the problem. What is going on with this article?

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

More than 3 years have 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のサポートとか、コンテナの蓋を最後に閉じる部分は他の処理に比べて一体性が必要になる(順序の変更が出来ないし柔軟性もあまりない)ので、「コンテナエンジンコマンド」として作る蓋然性はありますね。 

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away