今更ながら「コンテナ技術入門」をやってみた (その3)

はじめに

前回はコンテナを構成する要素技術の Namespace、cgroup、Capability について学んだが、今回はファイルシステム、ネットワークについて深ぼっていく

kazuki217.hatenablog.com

ファイルシステムの分離

chroot と pivot_root はプロセスのルートファイルシステムを隔離する目的で使用する
ファイルシステムを隔離すると、ホスト OS ファイルシステムへのアクセス制限を設けることができ、よりセキュアな状態となる

chroot と pivot_root は操作対象が異なる

まずは現在のプロセスのルートディレクトリを確認してみると / であることがわかる

$ ls -l /proc/$$/root
lrwxrwxrwx 1 xxxxx xxxxx 0 Mar 18 07:11 /proc/2111/root -> /

ルートファイルシステム/dev/root とわかる

$ awk '{ if ($5 == "/") print $0 }' /proc/$$/mountinfo
26 1 253:1 / / rw,relatime shared:1 - ext4 /dev/root rw,discard,errors=remount-ro,commit=30

chroot でルートディレクトリを変更しファイルシステムを隔離してみる

変更先のディレクトリにバイナリとライブラリをコピーしておく

$ ROOTFS=$(mktemp -d)
$ cp -a /bin /lib $ROOTFS
$ ls -l /bin /lib
lrwxrwxrwx 1 root root 7 Jan 26 00:42 /bin -> usr/bin
lrwxrwxrwx 1 root root 7 Jan 26 00:42 /lib -> usr/lib

/bin/libusr/binusr/lib を参照しているので /usr もコピーしておく

$ cp -a /usr $ROOTFS
$ ls -l $ROOTFS
lrwxrwxrwx  1 xxxxx xxxxx    7 Jan 26 00:42 bin -> usr/bin
lrwxrwxrwx  1 xxxxx xxxxx    7 Jan 26 00:42 lib -> usr/lib
drwxr-xr-x 11 xxxxx xxxxx 4096 Jan 26 00:42 usr

chroot でルートディレクトリを変更すると、/etc/passwd が参照できなくなっていることを確認できる

$ sudo chroot $ROOTFS /bin/sh

$ cat /etc/passwd
cat: /etc/passwd: No such file or directory

有名な話だが、chroot でルートディレクトリを変更しファイルシステムを隔離しても Capability CAP_SYS_CHROOT が有効なプロセスであれば以下のプログラムを実行することで抜け出せてしまう
筆者は C 言語が読み書きできないが、おそらく以下のようなことを実施していると推測する
コマンド chroot はカレントディレクトリの移動も一緒に行われるが、システムコール chroot はカレントディレクトリは変わらないらしい
新たに作成した /$ROOTFS/.dummy ディレクトリにシステムコール chroot を行うと、コマンド chroot で切り替わったルートディレクトリは無効となり、かつカレントディレクトリは移動せず /$ROOTFS のままなので、ルートディレクト/$ROOTFS/.dummy でありながらカレントディレクトリは /$ROOTFS という状態となる
この状態で chdir を繰り返すと / まで移動ができ cat /etc/passwd が実行できてしまう

http://www.gcd.org/blog/2007/09/132/

$ cat <<EOF > escape-chroot.c
#include <stdio.h>
#include <sys/stat.h>
#include <sys/unistd.h>
#include <errno.h>
#include <string.h>
int main(int argc, char *argv[]) {
  int i;
  mkdir(".dummy", 0755);
  chroot(".dummy");
  for (i = 0; i < 256; i++) {
    chdir("..");
  }
  if (chroot(".") < 0) {
    fprintf(stderr, "chroot failed: %s\n", strerror(errno));
    return 1;
  }
  argv++;
  execvp(argv[0], argv);
  fprintf(stderr, "%s failed: %s\n", argv[0], strerror(errno));
  return 0;
}
EOF

$ gcc -Wall -o escape-chroot escape-chroot.c

実際に試してみると cat /etc/passwd が実行できてしまった

$ ROOTFS=$(mktemp -d)
$ cp -a /bin /lib /usr escape-chroot $ROOTFS
$ sudo chroot $ROOTFS /bin/sh

# cat /etc/passwd
cat: /etc/passwd: No such file or directory

# ./escape-chroot cat /etc/passwd
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
[...]

この抜け出しには Capability CAP_SYS_CHROOT を無効にすることで対処できる

$ sudo capsh --chroot=$ROOTFS --drop=cap_sys_chroot --

# ./escape-chroot cat /etc/passwd
chroot failed: Operation not permitted

続いて pivot_root でプロセスのルートファイルシステムを入れ替えてみる

pivot_root は現在のルートファイルシステムと新しいルートファイルシステムを別の場所にマウントし、ファイルシステムの隔離を行うもので chroot では行えていた抜け出しに対処できる

なお、pivot_root を利用するには以下の条件を満たす必要がある

  1. 新しいファイルシステム (new_root) と元のファイルシステムの移動先 (put_old) はディレクトリではなくてはならない
  2. new_root と put_old は現在のルートディレクトリと同じマウントポイントにあってはならない
  3. put_old は new_root 配下になければならない
  4. new_root はマウントポイントへのパスでなければならないが、ルートは利用できない
  5. new_root の親マウントおよび現在のルートディレクトリの親マウントの伝搬タイプは MS_SHARED であってはならない
  6. 現在のルートディレクトリはマウントポイントでなくてはならない

pivot_root(2) - Linux manual page

条件の 1,2,3 に対応するため、先ほど作成したファイルシステム配下に .put_old を作成する
またコンテナから /proc をマウントするために proc ディレクトリを作成する

$ NEW_ROOT=$ROOTFS
$ mkdir $NEW_ROOT/{.put_old,proc}

以下のコマンドを実行することでルートファイルシステム$ROOTFS に入れ替えることが可能

$ unshare -mpfr /bin/sh -c "
  mount --bind $NEW_ROOT $NEW_ROOT && \
  mount -t proc /proc $NEW_ROOT/proc && \
  pivot_root $NEW_ROOT $NEW_ROOT/.put_old && \
  umount -l /.put_old && \
  cd / && \
  exec /bin/sh
"

ルートファイルシステムが入れ替わったため、/etc/passwd が存在せずエラーとなっている
もちろん脱出プログラムを実行しても参照できない

# ls -l /
total 20
lrwxrwxrwx   1     0     0     7 Jan 25 15:42 bin -> usr/bin
-rwxrwxr-x   1     0     0 70624 Mar 17 22:17 escape-chroot
lrwxrwxrwx   1     0     0     7 Jan 25 15:42 lib -> usr/lib
dr-xr-xr-x 151 65534 65534     0 Mar 17 22:19 proc
drwxr-xr-x  11     0     0  4096 Jan 25 15:42 usr

# cat /etc/passwd
cat: /etc/passwd: No such file or directory

# ./escape-chroot cat /etc/passwd
cat: /etc/passwd: No such file or directory

何をしているかを細かく見ていくと、まずは Namespace を隔離して /bin/sh を実行している

$ unshare -mpfr /bin/sh -c

条件の 4 つ目である new_root のパスをマウントポイントとするため、$NEWROOT をバインドマウントする

  mount --bind $NEW_ROOT $NEW_ROOT

/proc をマウントする
これをマウントしておかないとプロセス情報はもちろん /proc/mounts などにアクセスできなくなってしまう

  mount -t proc /proc $NEW_ROOT/proc

ルートファイルシステム$NEW_ROOT に移して、古いルートファイルシステム.put_old に変更している

  pivot_root $NEW_ROOT $NEW_ROOT/.put_old

古いルートファイルシステムは不要なので、アンマウントしている

  umount -l /.put_old

新しいルートファイルシステム/ に移動する

  cd /

プロセスを /bin/sh に入れ替えている

  exec /bin/sh

古いルートシステムをアンマウントしないと、現在のルートディレクトリを確認することもできる

$ unshare -mpfr /bin/sh -c "
  mount --bind $NEW_ROOT $NEW_ROOT && \
  mount -t proc /proc $NEW_ROOT/proc && \
  pivot_root $NEW_ROOT $NEW_ROOT/.put_old && \
  cd / && \
  exec /bin/sh
"

# ls -l .put_old
total 64
drwxr-xr-x   3 65534 65534  4096 Mar 17 22:06 Users
lrwxrwxrwx   1 65534 65534     7 Jan 25 15:42 bin -> usr/bin
drwxr-xr-x   5 65534 65534  4096 Jan 25 15:48 boot
drwxr-xr-x  19 65534 65534  4060 Mar 17 22:06 dev
drwxr-xr-x  66 65534 65534  4096 Mar 17 22:16 etc
drwxr-xr-x   3 65534 65534  4096 Mar 17 22:06 home
lrwxrwxrwx   1 65534 65534     7 Jan 25 15:42 lib -> usr/lib
drwx------   2 65534 65534 16384 Jan 25 15:47 lost+found
drwxr-xr-x   2 65534 65534  4096 Jan 25 15:42 media
drwxr-xr-x   3 65534 65534  4096 Mar 17 22:06 mnt
drwxr-xr-x   4 65534 65534  4096 Mar 17 22:06 opt
dr-xr-xr-x 151 65534 65534     0 Mar 17 22:06 proc
drwx------   3 65534 65534  4096 Feb  2 08:18 root
drwxr-xr-x  19 65534 65534   620 Mar 17 22:06 run
lrwxrwxrwx   1 65534 65534     8 Jan 25 15:42 sbin -> usr/sbin
drwxr-xr-x   2 65534 65534  4096 Jan 25 15:42 srv
dr-xr-xr-x  13 65534 65534     0 Mar 17 22:06 sys
drwxrwxrwt  11 65534 65534  4096 Mar 17 22:17 tmp
drwxr-xr-x  11 65534 65534  4096 Jan 25 15:42 usr
drwxr-xr-x  12 65534 65534  4096 Feb  2 08:16 var

OverlayFS

オーバーレイとは読み取り専用の下層レイヤと読み書き可能な上位レイヤを重ね合わせたもので、下層レイヤは lowerdir、上位レイヤは upperdir と呼ばれる
lowerdir に対する書き込みが発生すると、該当ファイルを upperdir にコピーして書き込みを行うため、lowerdir には変更が反映されない
このような仕組みを Copy on Write (CoW) と呼び、複数のコンテナでファイルシステムを共有することができるため、ディスクスペースの節約やファイルシステムの作成に伴うコンテナの起動時間短縮に繋がっている

lowerdir、upperdir 以外に lowerdir と upperdir を重ね合わせた merged、作業用として利用される work が存在する

実際にやってみる

まずは docker を利用してファイルシステムを用意する

$ OVERLAY=$(mktemp -d)
$ mkdir $OVERLAY/{lower,upper,work,merged}
$ CID=$(sudo docker container create alpine:latest)
$ sudo docker export $CID | tar -x -C $OVERLAY/lower
$ sudo docker rm $CID

早速 OverlayFS としてマウントしてみる

$ sudo mount \
  -t overlay \
  -o lowerdir=$OVERLAY/lower,upperdir=$OVERLAY/upper,workdir=$OVERLAY/work \
  overlay \
  $OVERLAY/merged

$ mount | grep overlay
overlay on /tmp/tmp.ifO7w8f5ym/merged type overlay (rw,relatime,lowerdir=/tmp/tmp.ifO7w8f5ym/lower,upperdir=/tmp/tmp.ifO7w8f5ym/upper,workdir=/tmp/tmp.ifO7w8f5ym/work,nouserxattr)

ディレクトリを確認してみると lower には alpine のファイルシステムが存在し、upper には何も存在せず、当然ながら mergedlower と同じになっている

$ ls -l $OVERLAY/lower
total 68
drwxr-xr-x  2 xxxxx xxxxx 4096 Jan 27 02:55 bin
drwxr-xr-x  4 xxxxx xxxxx 4096 Mar 18 07:21 dev
drwxr-xr-x 19 xxxxx xxxxx 4096 Mar 18 07:21 etc
drwxr-xr-x  2 xxxxx xxxxx 4096 Jan 27 02:55 home
drwxr-xr-x  7 xxxxx xxxxx 4096 Jan 27 02:55 lib
drwxr-xr-x  5 xxxxx xxxxx 4096 Jan 27 02:55 media
drwxr-xr-x  2 xxxxx xxxxx 4096 Jan 27 02:55 mnt
drwxr-xr-x  2 xxxxx xxxxx 4096 Jan 27 02:55 opt
dr-xr-xr-x  2 xxxxx xxxxx 4096 Jan 27 02:55 proc
drwx------  2 xxxxx xxxxx 4096 Jan 27 02:55 root
drwxr-xr-x  2 xxxxx xxxxx 4096 Jan 27 02:55 run
drwxr-xr-x  2 xxxxx xxxxx 4096 Jan 27 02:55 sbin
drwxr-xr-x  2 xxxxx xxxxx 4096 Jan 27 02:55 srv
drwxr-xr-x  2 xxxxx xxxxx 4096 Jan 27 02:55 sys
drwxrwxr-x  2 xxxxx xxxxx 4096 Jan 27 02:55 tmp
drwxr-xr-x  7 xxxxx xxxxx 4096 Jan 27 02:55 usr
drwxr-xr-x 12 xxxxx xxxxx 4096 Jan 27 02:55 var

$ ls -l $OVERLAY/upper
total 0

$ ls -l $OVERLAY/merged
total 68
drwxr-xr-x  2 xxxxx xxxxx 4096 Jan 27 02:55 bin
drwxr-xr-x  4 xxxxx xxxxx 4096 Mar 18 07:21 dev
drwxr-xr-x 19 xxxxx xxxxx 4096 Mar 18 07:21 etc
drwxr-xr-x  2 xxxxx xxxxx 4096 Jan 27 02:55 home
drwxr-xr-x  7 xxxxx xxxxx 4096 Jan 27 02:55 lib
drwxr-xr-x  5 xxxxx xxxxx 4096 Jan 27 02:55 media
drwxr-xr-x  2 xxxxx xxxxx 4096 Jan 27 02:55 mnt
drwxr-xr-x  2 xxxxx xxxxx 4096 Jan 27 02:55 opt
dr-xr-xr-x  2 xxxxx xxxxx 4096 Jan 27 02:55 proc
drwx------  2 xxxxx xxxxx 4096 Jan 27 02:55 root
drwxr-xr-x  2 xxxxx xxxxx 4096 Jan 27 02:55 run
drwxr-xr-x  2 xxxxx xxxxx 4096 Jan 27 02:55 sbin
drwxr-xr-x  2 xxxxx xxxxx 4096 Jan 27 02:55 srv
drwxr-xr-x  2 xxxxx xxxxx 4096 Jan 27 02:55 sys
drwxrwxr-x  2 xxxxx xxxxx 4096 Jan 27 02:55 tmp
drwxr-xr-x  7 xxxxx xxxxx 4096 Jan 27 02:55 usr
drwxr-xr-x 12 xxxxx xxxxx 4096 Jan 27 02:55 var

ここで merged にファイルを作成すると upper にも反映されるが、lower には反映されておらず CoW の振る舞いが確認できる

$ touch $OVERLAY/merged/newfile.txt
$ ls -l $OVERLAY/upper/newfile.txt
-rw-rw-r-- 1 xxxxx xxxxx 0 Mar 18 07:23 /tmp/tmp.brzNgmW39G/upper/newfile.txt

$ ls -l $OVERLAY/lower/newfile.txt
ls: cannot access '/tmp/tmp.brzNgmW39G/lower/newfile.txt': No such file or directory

次に以下のような Dockerfile をイメージして実際にコンテナを作成してみる

# Layer1
FROM alpine:latest

# Layer2
RUN apk add --no-cache curl

ENTRYPOINT ["curl"]

まずは Layer1 で alpine:latest のファイルシステムを用意する

$ LAYER1=$(mktemp -d)
$ mkdir $LAYER1/lower
$ CID=$(sudo docker container create alpine:latest)
$ sudo docker export $CID | tar -x -C $LAYER1/lower
$ sudo docker rm $CID

用意した Layer1 を lowerdir として upperdir に curl をインストールする

$ LAYER2=$(mktemp -d)
$ mkdir $LAYER2/{upper,work,merged}
$ sudo mount \
    -t overlay \
    -o lowerdir=$LAYER1/lower,upperdir=$LAYER2/upper,workdir=$LAYER2/work \
    overlay \
    $LAYER2/merged
$ sudo mount --bind /etc/resolv.conf $LAYER2/merged/etc/resolv.conf
$ unshare -r chroot $LAYER2/merged apk add --no-cache curl
$ sudo umount -R $LAYER2/merged
$ rm -rf $LAYER2/{work,merged}

Layer2 には curl があるが、Layer1 には curl はインストールされていない

$ ls -l $LAYER2/upper/usr/bin/curl
-rwxr-xr-x 1 xxxxx xxxxx 264088 Dec  7 16:32 /tmp/tmp.rxwcLRL0f8/upper/usr/bin/curl

$ ls -l $LAYER1/lower/usr/bin/curl
ls: cannot access '/tmp/tmp.3aXImDPDNv/lower/usr/bin/curl': No such file or directory

最後に Layer1 と Layer2 を重ね合わせたものを lowerdir としてコンテナを起動し、Layer2 でインストールした curl を使って httpbin.org にリクエストする
uuid が返却されていることを確認できる

$ CONTAINER=$(mktemp -d)
$ mkdir $CONTAINER/{upper,work,merged}
$ ROOTFS=$CONTAINER/merged
$ sudo mount \
    -t overlay \
    -o lowerdir=$LAYER2/upper:$LAYER1/lower,upperdir=$CONTAINER/upper,workdir=$CONTAINER/work \
    overlay \
    $ROOTFS
$ sudo mount --bind /etc/resolv.conf $ROOTFS/etc/resolv.conf
$ ARGS="http://httpbin.org/uuid"
$ unshare \
    -uipr \
    --mount-proc \
    --fork \
    chroot $ROOTFS /bin/sh -c "mount -t proc proc /proc && exec curl $ARGS"
{
  "uuid": "ee4dc3f5-afc0-4b98-9077-bc4a430a676c"
}

Network Namespace

通常であればネットワークインターフェースやルーティングテーブル、ソケットなどのネットワークリソースは OS 全体で共有され、コンテナごとにこれらを独立して扱えるようにするためには Namespace の 1 つである Network Namespace で隔離する

隔離された Network Namespace には専用のループバックインターフェースが提供される

$ unshare -nr ip link
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN mode DEFAULT group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00

ネットワークインターフェースは 1 つの Network Namespace にしか存在できないため、Root Namespace (ホスト OS の PID 1 の Namespace) が持つネットワークインターフェースは、隔離された Namespace では利用できない
なので、隔離した Network Namespace で外部と通信するにはネットワークインターフェースを適切に割り当てる必要がある

Docker では veth (Virtual Ethernet Device) ペアを利用し、1 つを Root Namespace に作成したネットワーク Bridge に、もう 1 つを Network Namespace に割り当て、Namespace 間で通信を行う

実際に Network Namespace を利用してプライベートネットワークを作成してみる

Network Namespace 間で通信をおこなうために、ネットワーク Bridge (br0) を作成する

$ sudo ip link add name br0 type bridge
$ sudo ip addr add 192.168.77.1/24 broadcast 192.168.77.255 label br0 dev br0
$ sudo ip link set dev br0 up

$ ip link
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP mode DEFAULT group default qlen 1000
    link/ether 52:55:55:8c:b5:0a brd ff:ff:ff:ff:ff:ff
    altname enp0s2
[...]
4: br0: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc noqueue state DOWN mode DEFAULT group default qlen 1000
    link/ether fa:e1:72:58:f5:9e brd ff:ff:ff:ff:ff:ff

Network Namespace (ns0) を作成する

$ sudo ip netns add ns0

$ ip netns list
ns0

veth ペア (veth0veth0_peer) を作成し、片方を Root Namespace の br0 に、もう片方を ns0 に割り当てる

$ sudo ip link add veth0 type veth peer name veth0_peer
$ sudo ip link set dev veth0 master br0
$ sudo ip link set dev veth0 up
$ sudo ip link set dev veth0_peer netns ns0

$ ip link
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP mode DEFAULT group default qlen 1000
    link/ether 52:55:55:8c:b5:0a brd ff:ff:ff:ff:ff:ff
    altname enp0s2
[...]
4: br0: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc noqueue state DOWN mode DEFAULT group default qlen 1000
    link/ether fa:e1:72:58:f5:9e brd ff:ff:ff:ff:ff:ff
6: veth0@if5: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc noqueue master br0 state LOWERLAYERDOWN mode DEFAULT group default qlen 1000
    link/ether de:4a:b0:c1:31:b6 brd ff:ff:ff:ff:ff:ff link-netns ns0

$ sudo ip netns exec ns0 ip link
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN mode DEFAULT group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
5: veth0_peer@if6: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
    link/ether 12:e8:37:f0:a5:77 brd ff:ff:ff:ff:ff:ff link-netnsid 0

ns0 の veth にアドレスを割り当て、デフォルトゲートウェイbr0 (192.168.77.1) に設定する

$ sudo ip netns exec ns0 ip link set dev veth0_peer name eth0
$ sudo ip netns exec ns0 ip addr add 192.168.77.2/24 dev eth0
$ sudo ip netns exec ns0 ip link set dev eth0 up
$ sudo ip netns exec ns0 ip route add default via 192.168.77.1

$ sudo ip netns exec ns0 ip route list
default via 192.168.77.1 dev eth0 
192.168.77.0/24 dev eth0 proto kernel scope link src 192.168.77.2 

ns0 を参照するコンテナを作成し、br0 (192.168.77.1)ping で疎通確認する

$ sudo ip netns exec ns0 ping -c 3 192.168.77.1
PING 192.168.77.1 (192.168.77.1) 56(84) bytes of data.
64 bytes from 192.168.77.1: icmp_seq=1 ttl=64 time=0.434 ms
64 bytes from 192.168.77.1: icmp_seq=2 ttl=64 time=0.224 ms
64 bytes from 192.168.77.1: icmp_seq=3 ttl=64 time=0.229 ms

--- 192.168.77.1 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2045ms
rtt min/avg/max/mdev = 0.224/0.295/0.434/0.097 ms

最後に

前回、今回とコンテナ要素技術について学んだ
知らないことばかりで、ゼロベースから調べながら進めるととても時間がかかり、正直とても大変だったがやはり有用だったと思う

これを機に低レイヤについて更に学習していこうと思う