コンテナ技術の基礎(1)「カーネルの分離 namespace」
●
本連載では、エンタープライズシステムでコンテナ/Kubernetesを活用した業務システムを開発・運用するエンジニアに向けて、知っておくべきKubernetesセキュリティの基礎知識、Microsoftが提供するパブリッククラウド「Azure」を使ったクラウドでのKubernetesセキュリティ対策のポイント、気を付けておきたい注意点などの実践的なノウハウを紹介します。
今回より、コンテナセキュリティの基礎となる、以下のコンテナ技術について3回に分けて説明します。
カーネルの分離 namespace
リソースの分離 cgroup
namespaceとcgroupで下から見るKubernetes
初回となる今回は、カーネルの分離を実現するために利用されているnamespaceを取り上げます。
コンテナはOSを論理的に分割し、隔離された空間でアプリケーションを動作させる技術です。したがって、よく言われるように仮想マシンの場合はハイパーバイザーが仮想的なハードウェアを提供していて、その上で仮想マシンのOSがさらにその上でアプリケーションが動きますが、コンテナの場合はカーネルの機能によってプロセスを隔離しているだけという違いがあります。
コンテナ内でアプリケーションのプロセスだけを動かすことができ、ホストのOSからはプロセスを通常どおり起動するのと違わないため、仮想マシンに比べると圧倒的に速く起動します。
namespaceとは
「OSを論理的に分割し隔離された空間でアプリケーションを動作させる」際に中核となっているのがnamespaceです。ここではUbuntu 20.04でnamespaceの挙動を確認していくことにします。
namespaceには以下の種類があります。
PID プロセス群
Mount マウントポイント
Network ネットワーク関連
UTS ホスト名など
IPC プロセス間通信
User ユーザー/グループ、権限
では、namespaceがどのような動きをするのかを確認していきましょう。主に使うのは以下の4つのコマンドです。
unshareコマンド: Namespaceを作成し指定したコマンドを実行
nsenterコマンド: 既存のNamespaceに関連付けて指定したコマンドを実行
ip netnsコマンド: Network Namespaceの操作
lsnsコマンド: namespaceの一覧を表示
まずは、DockerやKubernetesを動かしていない素の状態を確認します。使うのはlsnsコマンドです。
# lsns
NS TYPE NPROCS PID USER COMMAND
4026531835 cgroup 97 1 root /sbin/init
4026531836 pid 97 1 root /sbin/init
4026531837 user 97 1 root /sbin/init
4026531838 uts 94 1 root /sbin/init
4026531839 ipc 97 1 root /sbin/init
4026531840 mnt 91 1 root /sbin/init
4026531860 mnt 1 15 root kdevtmpfs
4026531992 net 97 1 root /sbin/init
4026532193 mnt 1 367 root /lib/systemd/systemd-udevd
4026532194 uts 1 367 root /lib/systemd/systemd-udevd
4026532195 mnt 1 494 systemd-timesync /lib/systemd/systemd-timesyncd
4026532196 uts 1 494 systemd-timesync /lib/systemd/systemd-timesyncd
4026532197 mnt 1 641 systemd-network /lib/systemd/systemd-networkd
4026532198 mnt 1 643 systemd-resolve /lib/systemd/systemd-resolved
4026532254 mnt 1 675 root /lib/systemd/systemd-logind
4026532255 uts 1 675 root /lib/systemd/systemd-logind
initやsystemdでnamespaceが使われているのが分かります。initで使われているnamespaceを調べる場合は、以下のように/proc/initのPID以下を見ます。
# ll /proc/1/ns
total 0
dr-x--x--x 2 root root 0 Nov 7 01:05 ./
dr-xr-xr-x 9 root root 0 Nov 7 01:05 ../
lrwxrwxrwx 1 root root 0 Nov 7 01:10 cgroup -> 'cgroup:[4026531835]'
lrwxrwxrwx 1 root root 0 Nov 7 01:10 ipc -> 'ipc:[4026531839]'
lrwxrwxrwx 1 root root 0 Nov 7 01:05 mnt -> 'mnt:[4026531840]'
lrwxrwxrwx 1 root root 0 Nov 7 01:10 net -> 'net:[4026531992]'
lrwxrwxrwx 1 root root 0 Nov 7 01:10 pid -> 'pid:[4026531836]'
lrwxrwxrwx 1 root root 0 Nov 7 01:11 pid_for_children -> 'pid:[4026531836]'
lrwxrwxrwx 1 root root 0 Nov 7 01:10 user -> 'user:[4026531837]'
lrwxrwxrwx 1 root root 0 Nov 7 01:10 uts -> 'uts:[4026531838]'
nsenterを使うと、特定のnamespaceの中に入ることができます。以下のような感じで使います。1は対象にするプロセスのPIDを指定していて、ここでは1なのでinitです。--allは/proc/[pid]/ns/*以下のすべてという意味です。種類を指定したい場合は「--net」や「--pid」を指定します。
nsenter --target 1 --all bash
中に入って何かを確認していくのは少し後回しにして、それぞれのNamespaceを一つずつ確認していきましょう。
●
PID プロセス群
基本となるプロセスの分離から確認していきます。PIDを分離してps auxを実行する場合のコマンドラインは以下のようになります。
sudo su -l
mkdir -p /opt/container
cd /opt/container
cp -r /usr/bin /usr/lib /usr/lib64 .
unshare -pf chroot . /bin/bash
mkdir -p /proc /dev/pts
mount -t proc proc /proc
mount -t devpts devpts /dev/pts
ps aux
実行結果は以下です。
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
0 1 0.0 0.0 4332 3696 ? S 07:22 0:00 bash
0 6 0.0 0.0 6120 3080 ? R+ 07:24 0:00 ps aux
unshare -p bashだけの場合は、bash: fork: Cannot allocate memoryというメッセージが表示されます。このエラーの一番簡単な解決策は、-fオプションをつけることです。
chroot .がないとps auxと打つとホストのプロセスが出てきてしまいます。psが/proc/から読み取った情報を表示するものだからです。chroot .を付けることで、/opt/containerがルート扱いになり、ホストのパスが見えなくなります。psも含めてコマンドが使えなくなってしまうので、cp -r /usr/bin /usr/lib /usr/lib64 .でコマンドをコピーしています。
mkdir -p /proc /dev/ptsとmount -t proc proc /procとmount -t devpts devpts /dev/ptsですが、procはプロセスに関するカーネル情報にアクセスする手段を提供する疑似ファイルシステムで、chroot先のために用意しています。devptsも疑似ファイルシステムでこちらはターミナルを提供するためのものです。
このやり方ではホスト側でmountしてしまっているので、ホストに影響が出てしまいます。--mount-procを付けると、mount名前空間を分離しつつ /proc を再マウントしてくれるのでお手軽です。
unshare -pf --mount-proc /bin/bash
ps aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.0 0.1 7208 3620 pts/0 S 02:22 0:00 /bin/bash
root 9 0.0 0.1 8864 3076 pts/0 R+ 02:22 0:00 ps aux
Mount マウントポイント
次にマウントポイントの分離について確認していきましょう。以下のコマンドを実行してみてください。
unshare -m /bin/bash
mount --bind /etc/hosts /root/hosts
mount | grep hosts
結果は以下のようになります。unshareの外では影響を受けないので何も表示されません。
/dev/vda1 on /root/hosts type ext4 (rw,relatime)
これだけではあまり楽しくないので、ルートファイルシステムを入れ替えてみましょう。
ルートファイルシステムの中身を用意する
まずは、ルートファイルシステムの中身を用意します。中身を用意する方法は以下のようにいくつかあります。
mmdebstrapやyum install -y --releasever=7 --installroot="https://news.mynavi.jp/installpath" @base @coreを使って書き出す
alpineなどのイメージのダウンロードしてきて展開する
DockerのImageを展開する
ここではmmdebstrapを使ってDebian GNU/Linuxのイメージの中身を書き出す方法をやってみます。mmdebstrapはdebootstrapを高速化し、rootでなくても実行できるように改良されるなど、多様化したニーズに対応しています。
mmdebstrapで指定できるのはDebian GNU/Linuxのパッケージですので、Ubuntuから実行する場合はキーリストをダウンロードして--aptopt='Dir::Etc::Trusted "/usr/share/keyrings/debian-archive-keyring.gpg"'のように指定する必要があります。
apt-get update
apt-get install mmdebstrap
apt-get install debian-archive-keyring
mkdir ./images
cd ./images
mmdebstrap stretch ./ --aptopt='Dir::Etc::Trusted "/usr/share/keyrings/debian-archive-keyring.gpg"'
中身をみてみましょう。一通りそろっていることが分かります。
# ll
total 84
drwxr-xr-x 21 root root 4096 Nov 7 08:26 ./
drwx------ 5 root root 4096 Nov 7 08:23 ../
drwxr-xr-x 2 root root 4096 Nov 7 08:27 bin/
drwxr-xr-x 2 root root 4096 Jul 10 2020 boot/
drwxr-xr-x 4 root root 4096 Jul 10 2020 dev/
drwxr-xr-x 50 root root 4096 Nov 7 08:27 etc/
drwxr-xr-x 2 root root 4096 Jul 10 2020 home/
drwxr-xr-x 10 root root 4096 Nov 7 08:27 lib/
drwxr-xr-x 2 root root 4096 Nov 7 08:26 lib64/
drwxr-xr-x 2 root root 4096 Nov 7 08:26 media/
drwxr-xr-x 2 root root 4096 Nov 7 08:26 mnt/
drwxr-xr-x 2 root root 4096 Nov 7 08:26 opt/
drwxr-xr-x 2 root root 4096 Jul 10 2020 proc/
drwx------ 2 root root 4096 Nov 7 08:26 root/
drwxr-xr-x 3 root root 4096 Nov 7 08:26 run/
drwxr-xr-x 2 root root 4096 Nov 7 08:27 sbin/
drwxr-xr-x 2 root root 4096 Nov 7 08:26 srv/
drwxr-xr-x 2 root root 4096 Jul 10 2020 sys/
drwxrwxrwt 2 root root 4096 Nov 7 08:27 tmp/
drwxr-xr-x 10 root root 4096 Nov 7 08:26 usr/
drwxr-xr-x 11 root root 4096 Nov 7 08:26 var/
●
unshareとpivot_rootでルートファイルシステムを入れ替える
PIDのnamespaceを確認する中で、ルートを変更する方法としてchrootを使いました。chrootはchrootするCapability(cap_sys_chroot)を奪わないとchrootの外に出ることができてしまいます。
ここではpivot_root を試してみます。pivot_rootを使うとプロセスのルートファイルシステムを入れ替えることができます。pivot_rootは引数に「新しいルートファイルシステム」と「古いルートファイルシステムのマウント先」を指定するのですが、以下の制約があります。
「新しいルートファイルシステム」と「古いルートファイルシステムのマウント先」は「変更前のルートファイルシステム」と同じマウントポイントにあってはいけない
「古いルートファイルシステムのマウント先」は 「新しいルートファイルシステム」の配下になければならない
他のファイルシステムを 「古いルートファイルシステムのマウント先」 にマウントできない
これらの制約を満たしつつ、ファイルシステムを入れ替えるために以下の操作をします。
1の条件を満たすためにmount --bind $NEW_ROOT $NEW_ROOTでマウントポイントを分離する
2の条件を満たすために「古いルートファイルシステムのマウント先」を$NEW_ROOT/.put_oldにする
pivot_root後に「古いルートファイルシステム」を外す
「古いルートファイルシステム」で起動した/bin/shがいなくなってしまったので、「新しいルートファイルシステム」の/bin/shを起動します。コマンドで表すと、以下のようになります。
NEW_ROOT=/root/images
mkdir $NEW_ROOT/.put_old
mkdir $NEW_ROOT/proc
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
"
"
UTS ホスト名
UTSの分離は特にややこしいことはありません。以下のように操作してみると独自のホスト名を持たせることができます。
unshare -u /bin/bash
hostname # 名前空間を切る前のホスト名がそのまま表示されます
hostname container
hostname # container
exit
hostname # 名前空間内の操作の影響は受けていません
IPC プロセス間通信
System V プロセス間通信オブジェクトとPOSIXメッセージキューが対象になっていています。POSIXメッセージキューを試してみましょう。試すためのPythonのコードは以下を使います。
まず、posix_ipcを入れます。
pip install posix_ipc
送信側です。1秒スリープしながらキューへの送信を続けます。
import posix_ipc as ipc
from time import sleep
mq = ipc.MessageQueue('/testq', ipc.O_CREAT)
cnt = 0
while True:
print(cnt)
mes = mq.send(str(cnt))
sleep(1)
cnt+=1
次に受信側です。
import posix_ipc as ipc
mq = ipc.MessageQueue('/testq')
while True:
mes = mq.receive()
print(mes[0])
screenなどを使って以下を同時に動かしてみましょう。
送信1:namespaceなし。
python3 ./send.py
受信1:namespaceなし。送信側から送られてくる数字が順に表示されます。
python3 ./receive.py
受信2:namespaceを作成。何も表示されません。
unshare --ipc
python3 ./receive.py
POSIXメッセージキューがnamespaceによって分離されていることを確認できました。
●
Network ネットワーク関連
Networkのnamespaceを試す場合は、ip netnsを使うほうが分かりやすいです。vethはL2の仮想ネットワークインタフェースで、2つのインタフェースのペアを作成してくれるものでnamespaceをまたがって通信できるようになります。
操作とコマンド実行例は以下のようになります。
ip以外の関連するコマンドとしては、以下があります。IPマスカレードを有効にする方法はiptablesでもかまいません。
testns1とtestns2の2つのnamespaceを作って動きを確認してみます。
# ip netns add testns1 # 名前空間の作成
# ip netns add testns2
# ip netns list # 名前空間の一覧
testns2
testns1
次に、vethを作ってnamespaceにひもづけます。m1とs1のペアを作ってs1をtestns1に、m2とs2のペアを作ってs2をtestns2にひもづけています。
# ip link add name m1 type veth peer name s1 # vethの作成
# ip link add name m2 type veth peer name s2
# ip -d link show s1
3: s1@m1: mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
link/ether 46:56:02:e4:d7:b9 brd ff:ff:ff:ff:ff:ff promiscuity 0 minmtu 68 maxmtu 65535
veth addrgenmode eui64 numtxqueues 1 numrxqueues 1 gso_max_size 65536 gso_max_segs 65535
# ip -d link show m1
4: m1@s1: mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
link/ether d2:32:b1:dc:bf:be brd ff:ff:ff:ff:ff:ff promiscuity 0 minmtu 68 maxmtu 65535
veth addrgenmode eui64 numtxqueues 1 numrxqueues 1 gso_max_size 65536 gso_max_segs 65535
# ip link set dev s1 netns testns1 # s1を名前空間testns1にひもづける
# ip link set dev s2 netns testns2 # s2を名前空間testns1にひもづける
m1とs1がnamespaceをまたいでつながったままになっていることを確認してみましょう。
# ip netns exec testns1 ip link
1: lo: 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
3: s1@if4: mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
link/ether 46:56:02:e4:d7:b9 brd ff:ff:ff:ff:ff:ff link-netnsid 0
# ethtool -S m1
NIC statistics:
peer_ifindex: 3
rx_queue_0_xdp_packets: 0
rx_queue_0_xdp_bytes: 0
rx_queue_0_xdp_drops: 0
IPアドレスを割り当てていきます。
ip netns exec testns1 ip addr add 192.168.20.102/24 dev s1
ip netns exec testns1 ip link set dev s1 up
ip netns exec testns1 ip link set dev lo up
ip netns exec testns1 ip addr show dev s1
ip addr add 192.168.20.101/24 dev m1
ip link set dev m1 up
ip addr show dev m1
ip netns exec testns1 ip route add default via 192.168.20.101 dev s1
ip netns exec testns2 ip addr add 192.168.10.102/24 dev s2
ip netns exec testns2 ip link set dev s2 up
ip netns exec testns2 ip link set dev lo up
ip netns exec testns2 ip addr show dev s2
ip addr add 192.168.10.101/24 dev m2
ip link set dev m2 up
ip addr show dev m2
ip netns exec testns2 ip route add default via 192.168.10.101 dev s2
作成したnamespace testns1からip netns exec testns1 ping 宛先を打って通信可能かを確認します。この段階では、testns1からtestns2のs2のIPには通信できないという結果になるはずです。
OK from testns1 ping to 192.168.20.101
OK from testns1 ping to 192.168.20.102
OK from testns1 ping to 192.168.10.101
NG from testns1 ping to 192.168.10.102
OK from host ping to 192.168.20.101
OK from host ping to 192.168.20.102
OK from host ping to 192.168.10.101
OK from host ping to 192.168.10.102
ホスト側にはtestns1とtestns2の双方の経路情報があります。
# ip route
default via 116.80.89.1 dev ens10 proto static
116.80.89.0/24 dev ens10 proto kernel scope link src 116.80.89.195
192.168.10.0/24 dev m2 proto kernel scope link src 192.168.10.101
192.168.20.0/24 dev m1 proto kernel scope link src 192.168.20.101
そのため、IPフォワードを有効にすると、testns1とtestns2の通信ができるようになります。sysctl -w net.ipv4.i_forward=1を実行してから再度、pingを打つと以下の結果になります。
OK from testns1 ping to 192.168.20.101
OK from testns1 ping to 192.168.20.102
OK from testns1 ping to 192.168.10.101
OK from testns1 ping to 192.168.10.102
インターネットに出るために、IPマスカレードを設定します。これでtestns1からインターネットに出ることができるようになります。
# iptables --table nat --append POSTROUTING --source 192.168.20.102/24 --jump MASQUERADE
# iptables --table nat --list
Chain PREROUTING (policy ACCEPT)
target prot opt source destination
Chain INPUT (policy ACCEPT)
target prot opt source destination
Chain OUTPUT (policy ACCEPT)
target prot opt source destination
Chain POSTROUTING (policy ACCEPT)
target prot opt source destination
MASQUERADE all -- 192.168.20.0/24 anywhere
# ip netns exec testns1 curl "http://142.251.42.131" # Googleから応答が返る
# ip netns exec testns2 curl "http://142.251.42.131" # Googleから応答が返らない
User ユーザー/グループ、権限
Userのnamespaceは、後発のnamespaceで利用できるようになり始めたという段階のものです。試してみる場合のお手軽な使い方は、unshare -U -rを指定する方法です。unshareの外からはunshareの実行ユーザーの操作のように見えるものが、unshareの中からはrootの操作に見えます。
mkdir /home/ubuntu/container
cd /home/ubuntu/container
unshare -U -r /bin/bash
id # uid=0(root) gid=0(root) groups=0(root),65534(nogroup)
touch hoge
ll # -rw-rw-r-- 1 root root 0 Oct 17 07:23 hoge
exit
cd /home/ubuntu/container
ll # -rw-rw-r-- 1 ubuntu ubuntu 0 Oct 17 07:23 hoge
uidとgidを割り当てる場合は、/proc/unshareのPID/uid_mapと/proc/unshareのPID/gid_mapにプロセス内のマッピングルールを記載します。
(名前空間内の最初のID) (名前空間外の最初のID) (名前空間内で使用するIDの個数)
initのuid_mapとgid_mapは、以下のように定義されています。
# cat /proc/1/uid_map
0 0 4294967295
# cat /proc/1/gid_map
0 0 4294967295
●
Capability
Userのnamespaceは後発なので権限を制限する仕組みの中核にいるのはCapabilityです。Capabilityはrootが持っている権限をバラして、rootの持つすべての権限を付与せず必要な権限のみを付与できるようにしたものです。37種類あります。
Capabilityはファイルに対して指定する方法と、スレッドに対して指定する方法があります。
ファイルに指定する方法は従来からあるSet-User-IDやSet-Group-IDに似たイメージで実行ファイルに権限を付与します。Set-User-IDが付与されている実行ファイルの例はpasswdで、この実行ファイルはrootユーザーが実行しなくてもパスワードの変更の際に必要なファイルアクセスができます。
# ls -l /bin/passwd
-rwsr-xr-x. 1 root root 27856 Apr 1 2020 /bin/passwd
ファイルにCapabilityが指定されている例はpingでCAP_NET_RAWが付与されています。
# getcap /bin/ping
/bin/ping = cap_net_raw+ep
一般ユーザーが実行しても/etc/shadowをみれるcatを作ってみましょう。DAC(任意アクセス制御)をバイパスしてアクセスするためのCapabilityはCAP_DAC_READ_SEARCHです。
$ sudo mkdir /ubuntu
$ sudo cp /bin/cat /ubuntu/cat
$ /ubuntu/cat /etc/shadow # /ubuntu/cat: /etc/shadow: Permission denied
$ sudo setcap cap_dac_read_search=+ep /ubuntu/cat
$ /ubuntu/cat /etc/shadow # こっちは成功する
前述したようにファイル Capabilityではそのファイルを実行するユーザーが誰であっても権限を与えた状態で利用することができてしまいますこの状況を回避するためにファイルではなくプロセスにCapabilityを付与できます。systemdにはそのための仕組みがあるので、これを使って確認していきましょう。
作業場所とログの書き出し先を作り、Capabilityの動きを確認するのに使うcatコマンドをコピーします。
$ sudo rm /ubuntu/cat
$ sudo cp /bin/cat /ubuntu/cat
$ sudo chmod a+w /ubuntu/cat.log
systemdから実行するスクリプト/ubuntu/cat.shを用意します。
#!/bin/bash
/ubuntu/cat /etc/shadow > /ubuntu/cat.log
まずはCapabilityの設定をしないで動かすとどうなるのか確認してみます。
/etc/systemd/system/cat.service
[Unit]
Description=cat shadow
[Service]
ExecStart=/ubuntu/cat.sh
User=ubuntu
Group=ubuntu
[Install]
WantedBy=multi-user.target
権限がないので、Permission deniedがsyslogに出力されました。
Nov 28 03:46:32 i-17100000215267 systemd[1]: Started cat shadow.
Nov 28 03:46:32 i-17100000215267 cat.sh[1530]: /ubuntu/cat: /etc/shadow: Permission denied
今度は、AmbientCapabilitiesにCAP_DAC_READ_SEARCHを追加してみます。
/etc/systemd/system/cat.service
[Unit]
Description=cat shadow
[Service]
ExecStart=/ubuntu/cat.sh
User=ubuntu
Group=ubuntu
AmbientCapabilities=CAP_DAC_READ_SEARCH
[Install]
WantedBy=multi-user.target
エラーにならずに成功しました。/ubuntu/cat.logの中身が/etc/shadowと同じものになっていることも確認できます。
Nov 28 03:45:02 i-17100000215267 systemd[1]: Started cat shadow.
Nov 28 03:45:02 i-17100000215267 systemd[1]: cat.service: Succeeded.
○野村総合研究所(NRI) 湯川勇太(ゆかわ ゆうた)
前職で大手家電量販店のECモールの立ち上げとECサイトの刷新プロジェクトを経験。NRI入社後は製造業の商品検索システムを担当した。現在は大手物流企業向けの基幹システムの方式設計や技術検証、トラブル対応を行っている。
本連載では、エンタープライズシステムでコンテナ/Kubernetesを活用した業務システムを開発・運用するエンジニアに向けて、知っておくべきKubernetesセキュリティの基礎知識、Microsoftが提供するパブリッククラウド「Azure」を使ったクラウドでのKubernetesセキュリティ対策のポイント、気を付けておきたい注意点などの実践的なノウハウを紹介します。
カーネルの分離 namespace
リソースの分離 cgroup
namespaceとcgroupで下から見るKubernetes
初回となる今回は、カーネルの分離を実現するために利用されているnamespaceを取り上げます。
コンテナはOSを論理的に分割し、隔離された空間でアプリケーションを動作させる技術です。したがって、よく言われるように仮想マシンの場合はハイパーバイザーが仮想的なハードウェアを提供していて、その上で仮想マシンのOSがさらにその上でアプリケーションが動きますが、コンテナの場合はカーネルの機能によってプロセスを隔離しているだけという違いがあります。
コンテナ内でアプリケーションのプロセスだけを動かすことができ、ホストのOSからはプロセスを通常どおり起動するのと違わないため、仮想マシンに比べると圧倒的に速く起動します。
namespaceとは
「OSを論理的に分割し隔離された空間でアプリケーションを動作させる」際に中核となっているのがnamespaceです。ここではUbuntu 20.04でnamespaceの挙動を確認していくことにします。
namespaceには以下の種類があります。
PID プロセス群
Mount マウントポイント
Network ネットワーク関連
UTS ホスト名など
IPC プロセス間通信
User ユーザー/グループ、権限
では、namespaceがどのような動きをするのかを確認していきましょう。主に使うのは以下の4つのコマンドです。
unshareコマンド: Namespaceを作成し指定したコマンドを実行
nsenterコマンド: 既存のNamespaceに関連付けて指定したコマンドを実行
ip netnsコマンド: Network Namespaceの操作
lsnsコマンド: namespaceの一覧を表示
まずは、DockerやKubernetesを動かしていない素の状態を確認します。使うのはlsnsコマンドです。
# lsns
NS TYPE NPROCS PID USER COMMAND
4026531835 cgroup 97 1 root /sbin/init
4026531836 pid 97 1 root /sbin/init
4026531837 user 97 1 root /sbin/init
4026531838 uts 94 1 root /sbin/init
4026531839 ipc 97 1 root /sbin/init
4026531840 mnt 91 1 root /sbin/init
4026531860 mnt 1 15 root kdevtmpfs
4026531992 net 97 1 root /sbin/init
4026532193 mnt 1 367 root /lib/systemd/systemd-udevd
4026532194 uts 1 367 root /lib/systemd/systemd-udevd
4026532195 mnt 1 494 systemd-timesync /lib/systemd/systemd-timesyncd
4026532196 uts 1 494 systemd-timesync /lib/systemd/systemd-timesyncd
4026532197 mnt 1 641 systemd-network /lib/systemd/systemd-networkd
4026532198 mnt 1 643 systemd-resolve /lib/systemd/systemd-resolved
4026532254 mnt 1 675 root /lib/systemd/systemd-logind
4026532255 uts 1 675 root /lib/systemd/systemd-logind
initやsystemdでnamespaceが使われているのが分かります。initで使われているnamespaceを調べる場合は、以下のように/proc/initのPID以下を見ます。
# ll /proc/1/ns
total 0
dr-x--x--x 2 root root 0 Nov 7 01:05 ./
dr-xr-xr-x 9 root root 0 Nov 7 01:05 ../
lrwxrwxrwx 1 root root 0 Nov 7 01:10 cgroup -> 'cgroup:[4026531835]'
lrwxrwxrwx 1 root root 0 Nov 7 01:10 ipc -> 'ipc:[4026531839]'
lrwxrwxrwx 1 root root 0 Nov 7 01:05 mnt -> 'mnt:[4026531840]'
lrwxrwxrwx 1 root root 0 Nov 7 01:10 net -> 'net:[4026531992]'
lrwxrwxrwx 1 root root 0 Nov 7 01:10 pid -> 'pid:[4026531836]'
lrwxrwxrwx 1 root root 0 Nov 7 01:11 pid_for_children -> 'pid:[4026531836]'
lrwxrwxrwx 1 root root 0 Nov 7 01:10 user -> 'user:[4026531837]'
lrwxrwxrwx 1 root root 0 Nov 7 01:10 uts -> 'uts:[4026531838]'
nsenterを使うと、特定のnamespaceの中に入ることができます。以下のような感じで使います。1は対象にするプロセスのPIDを指定していて、ここでは1なのでinitです。--allは/proc/[pid]/ns/*以下のすべてという意味です。種類を指定したい場合は「--net」や「--pid」を指定します。
nsenter --target 1 --all bash
中に入って何かを確認していくのは少し後回しにして、それぞれのNamespaceを一つずつ確認していきましょう。
●
PID プロセス群
基本となるプロセスの分離から確認していきます。PIDを分離してps auxを実行する場合のコマンドラインは以下のようになります。
sudo su -l
mkdir -p /opt/container
cd /opt/container
cp -r /usr/bin /usr/lib /usr/lib64 .
unshare -pf chroot . /bin/bash
mkdir -p /proc /dev/pts
mount -t proc proc /proc
mount -t devpts devpts /dev/pts
ps aux
実行結果は以下です。
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
0 1 0.0 0.0 4332 3696 ? S 07:22 0:00 bash
0 6 0.0 0.0 6120 3080 ? R+ 07:24 0:00 ps aux
unshare -p bashだけの場合は、bash: fork: Cannot allocate memoryというメッセージが表示されます。このエラーの一番簡単な解決策は、-fオプションをつけることです。
chroot .がないとps auxと打つとホストのプロセスが出てきてしまいます。psが/proc/から読み取った情報を表示するものだからです。chroot .を付けることで、/opt/containerがルート扱いになり、ホストのパスが見えなくなります。psも含めてコマンドが使えなくなってしまうので、cp -r /usr/bin /usr/lib /usr/lib64 .でコマンドをコピーしています。
mkdir -p /proc /dev/ptsとmount -t proc proc /procとmount -t devpts devpts /dev/ptsですが、procはプロセスに関するカーネル情報にアクセスする手段を提供する疑似ファイルシステムで、chroot先のために用意しています。devptsも疑似ファイルシステムでこちらはターミナルを提供するためのものです。
このやり方ではホスト側でmountしてしまっているので、ホストに影響が出てしまいます。--mount-procを付けると、mount名前空間を分離しつつ /proc を再マウントしてくれるのでお手軽です。
unshare -pf --mount-proc /bin/bash
ps aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.0 0.1 7208 3620 pts/0 S 02:22 0:00 /bin/bash
root 9 0.0 0.1 8864 3076 pts/0 R+ 02:22 0:00 ps aux
Mount マウントポイント
次にマウントポイントの分離について確認していきましょう。以下のコマンドを実行してみてください。
unshare -m /bin/bash
mount --bind /etc/hosts /root/hosts
mount | grep hosts
結果は以下のようになります。unshareの外では影響を受けないので何も表示されません。
/dev/vda1 on /root/hosts type ext4 (rw,relatime)
これだけではあまり楽しくないので、ルートファイルシステムを入れ替えてみましょう。
ルートファイルシステムの中身を用意する
まずは、ルートファイルシステムの中身を用意します。中身を用意する方法は以下のようにいくつかあります。
mmdebstrapやyum install -y --releasever=7 --installroot="https://news.mynavi.jp/installpath" @base @coreを使って書き出す
alpineなどのイメージのダウンロードしてきて展開する
DockerのImageを展開する
ここではmmdebstrapを使ってDebian GNU/Linuxのイメージの中身を書き出す方法をやってみます。mmdebstrapはdebootstrapを高速化し、rootでなくても実行できるように改良されるなど、多様化したニーズに対応しています。
mmdebstrapで指定できるのはDebian GNU/Linuxのパッケージですので、Ubuntuから実行する場合はキーリストをダウンロードして--aptopt='Dir::Etc::Trusted "/usr/share/keyrings/debian-archive-keyring.gpg"'のように指定する必要があります。
apt-get update
apt-get install mmdebstrap
apt-get install debian-archive-keyring
mkdir ./images
cd ./images
mmdebstrap stretch ./ --aptopt='Dir::Etc::Trusted "/usr/share/keyrings/debian-archive-keyring.gpg"'
中身をみてみましょう。一通りそろっていることが分かります。
# ll
total 84
drwxr-xr-x 21 root root 4096 Nov 7 08:26 ./
drwx------ 5 root root 4096 Nov 7 08:23 ../
drwxr-xr-x 2 root root 4096 Nov 7 08:27 bin/
drwxr-xr-x 2 root root 4096 Jul 10 2020 boot/
drwxr-xr-x 4 root root 4096 Jul 10 2020 dev/
drwxr-xr-x 50 root root 4096 Nov 7 08:27 etc/
drwxr-xr-x 2 root root 4096 Jul 10 2020 home/
drwxr-xr-x 10 root root 4096 Nov 7 08:27 lib/
drwxr-xr-x 2 root root 4096 Nov 7 08:26 lib64/
drwxr-xr-x 2 root root 4096 Nov 7 08:26 media/
drwxr-xr-x 2 root root 4096 Nov 7 08:26 mnt/
drwxr-xr-x 2 root root 4096 Nov 7 08:26 opt/
drwxr-xr-x 2 root root 4096 Jul 10 2020 proc/
drwx------ 2 root root 4096 Nov 7 08:26 root/
drwxr-xr-x 3 root root 4096 Nov 7 08:26 run/
drwxr-xr-x 2 root root 4096 Nov 7 08:27 sbin/
drwxr-xr-x 2 root root 4096 Nov 7 08:26 srv/
drwxr-xr-x 2 root root 4096 Jul 10 2020 sys/
drwxrwxrwt 2 root root 4096 Nov 7 08:27 tmp/
drwxr-xr-x 10 root root 4096 Nov 7 08:26 usr/
drwxr-xr-x 11 root root 4096 Nov 7 08:26 var/
●
unshareとpivot_rootでルートファイルシステムを入れ替える
PIDのnamespaceを確認する中で、ルートを変更する方法としてchrootを使いました。chrootはchrootするCapability(cap_sys_chroot)を奪わないとchrootの外に出ることができてしまいます。
ここではpivot_root を試してみます。pivot_rootを使うとプロセスのルートファイルシステムを入れ替えることができます。pivot_rootは引数に「新しいルートファイルシステム」と「古いルートファイルシステムのマウント先」を指定するのですが、以下の制約があります。
「新しいルートファイルシステム」と「古いルートファイルシステムのマウント先」は「変更前のルートファイルシステム」と同じマウントポイントにあってはいけない
「古いルートファイルシステムのマウント先」は 「新しいルートファイルシステム」の配下になければならない
他のファイルシステムを 「古いルートファイルシステムのマウント先」 にマウントできない
これらの制約を満たしつつ、ファイルシステムを入れ替えるために以下の操作をします。
1の条件を満たすためにmount --bind $NEW_ROOT $NEW_ROOTでマウントポイントを分離する
2の条件を満たすために「古いルートファイルシステムのマウント先」を$NEW_ROOT/.put_oldにする
pivot_root後に「古いルートファイルシステム」を外す
「古いルートファイルシステム」で起動した/bin/shがいなくなってしまったので、「新しいルートファイルシステム」の/bin/shを起動します。コマンドで表すと、以下のようになります。
NEW_ROOT=/root/images
mkdir $NEW_ROOT/.put_old
mkdir $NEW_ROOT/proc
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
"
"
UTS ホスト名
UTSの分離は特にややこしいことはありません。以下のように操作してみると独自のホスト名を持たせることができます。
unshare -u /bin/bash
hostname # 名前空間を切る前のホスト名がそのまま表示されます
hostname container
hostname # container
exit
hostname # 名前空間内の操作の影響は受けていません
IPC プロセス間通信
System V プロセス間通信オブジェクトとPOSIXメッセージキューが対象になっていています。POSIXメッセージキューを試してみましょう。試すためのPythonのコードは以下を使います。
まず、posix_ipcを入れます。
pip install posix_ipc
送信側です。1秒スリープしながらキューへの送信を続けます。
import posix_ipc as ipc
from time import sleep
mq = ipc.MessageQueue('/testq', ipc.O_CREAT)
cnt = 0
while True:
print(cnt)
mes = mq.send(str(cnt))
sleep(1)
cnt+=1
次に受信側です。
import posix_ipc as ipc
mq = ipc.MessageQueue('/testq')
while True:
mes = mq.receive()
print(mes[0])
screenなどを使って以下を同時に動かしてみましょう。
送信1:namespaceなし。
python3 ./send.py
受信1:namespaceなし。送信側から送られてくる数字が順に表示されます。
python3 ./receive.py
受信2:namespaceを作成。何も表示されません。
unshare --ipc
python3 ./receive.py
POSIXメッセージキューがnamespaceによって分離されていることを確認できました。
●
Network ネットワーク関連
Networkのnamespaceを試す場合は、ip netnsを使うほうが分かりやすいです。vethはL2の仮想ネットワークインタフェースで、2つのインタフェースのペアを作成してくれるものでnamespaceをまたがって通信できるようになります。
操作とコマンド実行例は以下のようになります。
ip以外の関連するコマンドとしては、以下があります。IPマスカレードを有効にする方法はiptablesでもかまいません。
testns1とtestns2の2つのnamespaceを作って動きを確認してみます。
# ip netns add testns1 # 名前空間の作成
# ip netns add testns2
# ip netns list # 名前空間の一覧
testns2
testns1
次に、vethを作ってnamespaceにひもづけます。m1とs1のペアを作ってs1をtestns1に、m2とs2のペアを作ってs2をtestns2にひもづけています。
# ip link add name m1 type veth peer name s1 # vethの作成
# ip link add name m2 type veth peer name s2
# ip -d link show s1
3: s1@m1: mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
link/ether 46:56:02:e4:d7:b9 brd ff:ff:ff:ff:ff:ff promiscuity 0 minmtu 68 maxmtu 65535
veth addrgenmode eui64 numtxqueues 1 numrxqueues 1 gso_max_size 65536 gso_max_segs 65535
# ip -d link show m1
4: m1@s1: mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
link/ether d2:32:b1:dc:bf:be brd ff:ff:ff:ff:ff:ff promiscuity 0 minmtu 68 maxmtu 65535
veth addrgenmode eui64 numtxqueues 1 numrxqueues 1 gso_max_size 65536 gso_max_segs 65535
# ip link set dev s1 netns testns1 # s1を名前空間testns1にひもづける
# ip link set dev s2 netns testns2 # s2を名前空間testns1にひもづける
m1とs1がnamespaceをまたいでつながったままになっていることを確認してみましょう。
# ip netns exec testns1 ip link
1: lo: 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
3: s1@if4: mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
link/ether 46:56:02:e4:d7:b9 brd ff:ff:ff:ff:ff:ff link-netnsid 0
# ethtool -S m1
NIC statistics:
peer_ifindex: 3
rx_queue_0_xdp_packets: 0
rx_queue_0_xdp_bytes: 0
rx_queue_0_xdp_drops: 0
IPアドレスを割り当てていきます。
ip netns exec testns1 ip addr add 192.168.20.102/24 dev s1
ip netns exec testns1 ip link set dev s1 up
ip netns exec testns1 ip link set dev lo up
ip netns exec testns1 ip addr show dev s1
ip addr add 192.168.20.101/24 dev m1
ip link set dev m1 up
ip addr show dev m1
ip netns exec testns1 ip route add default via 192.168.20.101 dev s1
ip netns exec testns2 ip addr add 192.168.10.102/24 dev s2
ip netns exec testns2 ip link set dev s2 up
ip netns exec testns2 ip link set dev lo up
ip netns exec testns2 ip addr show dev s2
ip addr add 192.168.10.101/24 dev m2
ip link set dev m2 up
ip addr show dev m2
ip netns exec testns2 ip route add default via 192.168.10.101 dev s2
作成したnamespace testns1からip netns exec testns1 ping 宛先を打って通信可能かを確認します。この段階では、testns1からtestns2のs2のIPには通信できないという結果になるはずです。
OK from testns1 ping to 192.168.20.101
OK from testns1 ping to 192.168.20.102
OK from testns1 ping to 192.168.10.101
NG from testns1 ping to 192.168.10.102
OK from host ping to 192.168.20.101
OK from host ping to 192.168.20.102
OK from host ping to 192.168.10.101
OK from host ping to 192.168.10.102
ホスト側にはtestns1とtestns2の双方の経路情報があります。
# ip route
default via 116.80.89.1 dev ens10 proto static
116.80.89.0/24 dev ens10 proto kernel scope link src 116.80.89.195
192.168.10.0/24 dev m2 proto kernel scope link src 192.168.10.101
192.168.20.0/24 dev m1 proto kernel scope link src 192.168.20.101
そのため、IPフォワードを有効にすると、testns1とtestns2の通信ができるようになります。sysctl -w net.ipv4.i_forward=1を実行してから再度、pingを打つと以下の結果になります。
OK from testns1 ping to 192.168.20.101
OK from testns1 ping to 192.168.20.102
OK from testns1 ping to 192.168.10.101
OK from testns1 ping to 192.168.10.102
インターネットに出るために、IPマスカレードを設定します。これでtestns1からインターネットに出ることができるようになります。
# iptables --table nat --append POSTROUTING --source 192.168.20.102/24 --jump MASQUERADE
# iptables --table nat --list
Chain PREROUTING (policy ACCEPT)
target prot opt source destination
Chain INPUT (policy ACCEPT)
target prot opt source destination
Chain OUTPUT (policy ACCEPT)
target prot opt source destination
Chain POSTROUTING (policy ACCEPT)
target prot opt source destination
MASQUERADE all -- 192.168.20.0/24 anywhere
# ip netns exec testns1 curl "http://142.251.42.131" # Googleから応答が返る
# ip netns exec testns2 curl "http://142.251.42.131" # Googleから応答が返らない
User ユーザー/グループ、権限
Userのnamespaceは、後発のnamespaceで利用できるようになり始めたという段階のものです。試してみる場合のお手軽な使い方は、unshare -U -rを指定する方法です。unshareの外からはunshareの実行ユーザーの操作のように見えるものが、unshareの中からはrootの操作に見えます。
mkdir /home/ubuntu/container
cd /home/ubuntu/container
unshare -U -r /bin/bash
id # uid=0(root) gid=0(root) groups=0(root),65534(nogroup)
touch hoge
ll # -rw-rw-r-- 1 root root 0 Oct 17 07:23 hoge
exit
cd /home/ubuntu/container
ll # -rw-rw-r-- 1 ubuntu ubuntu 0 Oct 17 07:23 hoge
uidとgidを割り当てる場合は、/proc/unshareのPID/uid_mapと/proc/unshareのPID/gid_mapにプロセス内のマッピングルールを記載します。
(名前空間内の最初のID) (名前空間外の最初のID) (名前空間内で使用するIDの個数)
initのuid_mapとgid_mapは、以下のように定義されています。
# cat /proc/1/uid_map
0 0 4294967295
# cat /proc/1/gid_map
0 0 4294967295
●
Capability
Userのnamespaceは後発なので権限を制限する仕組みの中核にいるのはCapabilityです。Capabilityはrootが持っている権限をバラして、rootの持つすべての権限を付与せず必要な権限のみを付与できるようにしたものです。37種類あります。
Capabilityはファイルに対して指定する方法と、スレッドに対して指定する方法があります。
ファイルに指定する方法は従来からあるSet-User-IDやSet-Group-IDに似たイメージで実行ファイルに権限を付与します。Set-User-IDが付与されている実行ファイルの例はpasswdで、この実行ファイルはrootユーザーが実行しなくてもパスワードの変更の際に必要なファイルアクセスができます。
# ls -l /bin/passwd
-rwsr-xr-x. 1 root root 27856 Apr 1 2020 /bin/passwd
ファイルにCapabilityが指定されている例はpingでCAP_NET_RAWが付与されています。
# getcap /bin/ping
/bin/ping = cap_net_raw+ep
一般ユーザーが実行しても/etc/shadowをみれるcatを作ってみましょう。DAC(任意アクセス制御)をバイパスしてアクセスするためのCapabilityはCAP_DAC_READ_SEARCHです。
$ sudo mkdir /ubuntu
$ sudo cp /bin/cat /ubuntu/cat
$ /ubuntu/cat /etc/shadow # /ubuntu/cat: /etc/shadow: Permission denied
$ sudo setcap cap_dac_read_search=+ep /ubuntu/cat
$ /ubuntu/cat /etc/shadow # こっちは成功する
前述したようにファイル Capabilityではそのファイルを実行するユーザーが誰であっても権限を与えた状態で利用することができてしまいますこの状況を回避するためにファイルではなくプロセスにCapabilityを付与できます。systemdにはそのための仕組みがあるので、これを使って確認していきましょう。
作業場所とログの書き出し先を作り、Capabilityの動きを確認するのに使うcatコマンドをコピーします。
$ sudo rm /ubuntu/cat
$ sudo cp /bin/cat /ubuntu/cat
$ sudo chmod a+w /ubuntu/cat.log
systemdから実行するスクリプト/ubuntu/cat.shを用意します。
#!/bin/bash
/ubuntu/cat /etc/shadow > /ubuntu/cat.log
まずはCapabilityの設定をしないで動かすとどうなるのか確認してみます。
/etc/systemd/system/cat.service
[Unit]
Description=cat shadow
[Service]
ExecStart=/ubuntu/cat.sh
User=ubuntu
Group=ubuntu
[Install]
WantedBy=multi-user.target
権限がないので、Permission deniedがsyslogに出力されました。
Nov 28 03:46:32 i-17100000215267 systemd[1]: Started cat shadow.
Nov 28 03:46:32 i-17100000215267 cat.sh[1530]: /ubuntu/cat: /etc/shadow: Permission denied
今度は、AmbientCapabilitiesにCAP_DAC_READ_SEARCHを追加してみます。
/etc/systemd/system/cat.service
[Unit]
Description=cat shadow
[Service]
ExecStart=/ubuntu/cat.sh
User=ubuntu
Group=ubuntu
AmbientCapabilities=CAP_DAC_READ_SEARCH
[Install]
WantedBy=multi-user.target
エラーにならずに成功しました。/ubuntu/cat.logの中身が/etc/shadowと同じものになっていることも確認できます。
Nov 28 03:45:02 i-17100000215267 systemd[1]: Started cat shadow.
Nov 28 03:45:02 i-17100000215267 systemd[1]: cat.service: Succeeded.
○野村総合研究所(NRI) 湯川勇太(ゆかわ ゆうた)
前職で大手家電量販店のECモールの立ち上げとECサイトの刷新プロジェクトを経験。NRI入社後は製造業の商品検索システムを担当した。現在は大手物流企業向けの基幹システムの方式設計や技術検証、トラブル対応を行っている。