Edited at

原理原則で理解するDocker

More than 1 year has passed since last update.


はじめに

この記事は、オールアバウト Advent Calendar 2016の23日目のエントリーです。

私、@tajima_tasoからは【原理原則で理解するDocker】と題して書かせて頂きます。

Dockerってそもそも何?ってところから、内部実装部分についても触れています。


Dockerとは何か?

まず、Dockerとは何なのか?について軽くおさらいしましょう。

今更聞けないという方も、なんとなくイメージが掴めたら嬉しいです。


何故、今Dockerなのか?

Docker

ソフトウェア開発を行う環境にいる方々の中で、この言葉を耳にする機会が増えてきているのではないでしょうか?

実際の業務において導入まではしていないとしても、何となく盛り上がっている技術用語であることは肌感覚としてあると思います。

実は、Docker実現のベースとなっているLinuxコンテナという技術自体はずっと前から存在していました。

Linuxコンテナ技術実現の核となる機能は主に、namespaceとcgroupsというものなのですが、中でもプロセス空間の分離が可能になったPID Namespaceの実装は2008年のLinuxカーネル2.6.24からです。

PID namespaces in the 2.6.24 kernel

すなわちDockerのようなことをやろうと思えば、2008年からやるチャンスはあったわけですし、実際コンテナ技術を使ったソフトウェアは開発されていました。

下記はDocker以前からあるコンテナ技術の一例です。

Solaris Zone

Oracle Solarisコンテナ:Oracle Solaris 10 新機能 機能編(1):Solarisゾーン

FreeBSD Jail

https://ja.wikipedia.org/wiki/FreeBSD_jail

RHEL OpenVZ

OpenVZ

それにもかかわらず、コンテナ技術が新しいソリューションでしかもそれが≒Dockerのように思われてしまうほど注目を集めているのは、何故なのでしょうか?

私は、以下の流れが要因だと思います。


  1. 開発するソフトウェア、そしてその開発環境が個人、中小、大企業レベルを問わず十分に複雑なものになってきた。

  2. 1の問題を統合的に解決するソリューションとして、Dockerが時代のニーズにあわせたカタチでタイミングよく登場した。

  3. GoogleやMicrosoftをはじめとするソフトウェア開発に関わる世界中の会社や団体、個人が広くその有用性を認め始め、関連するOS、ツール(CoreOSKubernetesRancher)が多く作られ始めた。

経験則による個人的見解も含まれていますが、概ね認識は間違っていないと思います。

今までのコンテナ技術がどちらかといえば複雑なシステムのソフトウェア開発で利用されてきたことを考えると、コンテナ技術の普及はそれが特別なものではなく、多くの人に必要とされる世の中になってきたことを意味しているのではないでしょうか?

現代のソフトウェア開発は、アプリケーションのコードだけで完結するというものはなく、それに付随するライブラリ、データベース、キャッシュデータストア、OSレベルで提供されるライブラリなどが密結合で複雑に絡みあっています。

加えて、旧来のようにウォーターフォール型でしっかりと開発して納品したらそこで終了という時代ではなくなっています。

ソフトウェア開発者に必要とされているのは、必要となる機能を継続的に追加、変更、修正し、商用環境へ確実にデプロイする仕組みです。場合によってはアプリケーションのコードだけでなく、共有ライブラリのアップデートも主にセキュリティ面から必要とされます。

このようにソフトウェア開発は大中小の規模にかかわらず、複雑化してきており、今後も更に複雑になっていくことでしょう。


Dockerは何を解決するのか?

アプリケーションのコードを書くのは、まだまだソフトウェア開発者の仕事です。

現状においても、継続的にアプリケーションのソースコードを管理するソリューションとして、Gitなどのバージョン管理ツールを利用している開発者は多いと思います。

これによってアプリケーションコードにおいては、デグレなどのミスは回避できるようになってきました。

ですが、それだけでは先程述べた現状の問題を解決することはできません。

それを解決するソリューションとして期待されているのがDockerなのです。

Dockerはアプリケーションとその実行環境を統合的に管理する為のソリューションです。

開発環境におけるOSレベルのライブラリ、ミドルウェアのバージョン、環境設定は、常に本番環境と同じものにすることが可能です。

言ってみれば、デプロイ時は開発環時のOSレベルの環境をまるごと、本番環境へデプロイするようなものです。

これは、

開発環境で動いたけど、本番環境で動かない

すなわち、本番環境へのデプロイ時の最大の不安要素が無くなるということを意味します。


仮想サーバとの違い

似たようなものとして、仮想サーバというものを連想する方もいると思います。では、仮想サーバとDockerはどう違いのでしょうか?

サーバの仮想化にはホストOS型とハイパーバイザ型(近年はパフォーマンス面の理由からハイパーバイザ型が主流)がありますが、いずれにしてもゲストOSを管理するソフトウェアによって、物理サーバ上に複数のOSを稼働させることができる方式です。

ゲストOSからのハードウェアリソースへのアクセス要求(CPU、メモリ、ディスク、ネットワーク)は、ハイパーバイザによって一旦制御されてしまうため、少なからぬオーバヘッドが生じてしまいます。準仮想化方式の導入によってゲストOS自体を変更してハードウェアへのアクセスを最適化する技術などもありますが、そのぶん管理コストは高くなります。

このようにサーバの仮想化方式では、ハイパーバイザに加え、複数のOSを統合的に管理していく必要があります。このへんの管理コストは高く、ある程度運用した環境でOSレベルの設定をバージョンアップしようものなら、一大イベントとなってしまいます。

これに対してDockerはあくまでOS上で動く、プロセスの1つに過ぎません。Linux上で稼働する関連したプロセスをグルーピングし、それぞれは独立したユーザー空間で稼働します。

このように分離された実行環境のことをコンテナと呼びます。

コンテナ内部では他の実行環境と異なるディレクトリツリー、プロセステーブル、ネットワーク設定が適用されます。

Linuxのコンソール上で、ps auxと打って下さい。

たくさんのプロセスが一覧で表示されると思いますが、Dockerはあくまでその中の1つに過ぎないのです。

仮想化サーバの例でいうと、Dockerはハイパーバイザのようなもので、コンテナサービスを提供するデーモンとなるのですが、あくまでプロセスの1つです。

言ってしまえば、/usr/sbin/sshdや/usr/sbin/httpdと同じようなものです。

sshdもhttpdもフォークして子プロセスを生成しますが、Dockerもフォークした子プロセスの中でコンテナを実現しているのです。

たとえばApache(httpd)の設定変更を思い浮かべて下さい。

$ sudo vi /etc/httpd/conf.d/*conf

# 何か変更
$ systemctl restart httpd.service

というようにApacheに関する設定を修正して、プロセスを再起動すれば新しいApacheの設定がされた状態でApacheが稼働します。

完全にイコールではありませんが、イメージとしてはDockerによるコンテナの環境構築や本番環境へのデプロイはこれと同じことです。

DockerはDockerfileという設定ファイルから、全ての設定が適用されたコンテナイメージを1から(あるいは中間イメージから)ビルドする仕組みを持っています。

この設定ファイルのみを変更してコンテナイメージを作成するような運用を行えば、何度でもどこにでも同じ実行環境を構築することができます。更にこれをGitなどのバージョン管理システムと併用すれば、環境のバージョン管理も手軽に行えます。

このように、仮想サーバの設定変更とは違い、ミドルウェアにおける設定ファイル変更からの再起動のような手軽さで実行環境の適用が可能になるのです。


Dockerを使ってみる

実際にdockerを使ってみます。Linux上にDockerをインストールずみの状態で、Dockerデーモンが起動中とします。私は今回CentOS7.2を使用しています。

$ ps aux | grep docker

root 941 0.0 1.8 668340 37824 ? Ssl 12月18 1:11 /usr/bin/docker-current daemon --exec-opt native.cgroupdriver=systemd --selinux-enabled --log-driver=journald
# dockerが起動している

この状態であれば、dockerコマンドを使用して、Dockerデーモンが提供するサービスを利用することができます。

まずは、Docker HubからCentOSのリポジトリをダウンロードします。

$ sudo docker pull centos

#リポジトリのダウンロード処理
$ sudo docker images
#ダウンロードしたリポジトリ一覧表示

タグ名、centos6.7のイメージからコンテナを起動してみます。

$ sudo docker run -it --name centos6 centos:centos6.7 /bin/bash

すると、先程dockerコマンドを実行していたLinuxのOS環境とはまったく違うLinux環境にログインしたような状態になります。

それでは、コンテナを起動したOS環境と、コンテナ環境の内部で同じシステム確認用コマンドを打って違いをざっと確認してみましょう。

なお、このエントリの中では、コンテナを起動したOS環境をホスト環境と呼びコンテナをコンテナ環境と呼ぶことにします。

# ホスト環境

$ cat /etc/redhat-release
CentOS Linux release 7.2.1511 (Core)

$ ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
inet6 ::1/128 scope host
valid_lft forever preferred_lft forever
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP qlen 1000
link/ether 9c:a3:ba:30:d3:bd brd ff:ff:ff:ff:ff:ff
inet 133.242.236.36/24 brd 133.242.236.255 scope global eth0
valid_lft forever preferred_lft forever
inet6 fe80::9ea3:baff:fe30:d3bd/64 scope link
valid_lft forever preferred_lft forever
3: docker0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP
link/ether 02:42:e4:d2:82:68 brd ff:ff:ff:ff:ff:ff
inet 172.17.0.1/16 scope global docker0
valid_lft forever preferred_lft forever
inet6 fe80::42:e4ff:fed2:8268/64 scope link
valid_lft forever preferred_lft forever

$ ps aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.0 0.1 43244 3636 ? Ss 12月18 0:21 /usr/lib/systemd/systemd --switched-root --system --deserialize 21
root 1 0.0 0.1 43244 3636 ? Ss 12月18 0:21 /usr/lib/systemd/systemd --switched-root --system --deserialize 21
root 2 0.0 0.0 0 0 ? S 12月18 0:00 [kthreadd]
root 3 0.0 0.0 0 0 ? S 12月18 0:00 [ksoftirqd/0]
root 7 0.0 0.0 0 0 ? S 12月18 0:00 [migration/0]
root 8 0.0 0.0 0 0 ? S 12月18 0:00 [rcu_bh]
root 9 0.0 0.0 0 0 ? S 12月18 0:00 [rcuob/0]
root 10 0.0 0.0 0 0 ? S 12月18 0:00 [rcuob/1]
# 省略 133行ほど

# コンテナ環境

$ cat /etc/redhat-release
CentOS release 6.7 (Final)

$ ifconfig
eth0 Link encap:Ethernet HWaddr 02:42:AC:11:00:02
inet addr:172.17.0.2 Bcast:0.0.0.0 Mask:255.255.0.0
inet6 addr: fe80::42:acff:fe11:2/64 Scope:Link
UP BROADCAST RUNNING MULTICAST MTU:1500 Metric:1
RX packets:5 errors:0 dropped:0 overruns:0 frame:0
TX packets:8 errors:0 dropped:0 overruns:0 carrier:0
collisions:0 txqueuelen:0
RX bytes:438 (438.0 b) TX bytes:648 (648.0 b)

lo Link encap:Local Loopback
inet addr:127.0.0.1 Mask:255.0.0.0
inet6 addr: ::1/128 Scope:Host
UP LOOPBACK RUNNING MTU:65536 Metric:1
RX packets:0 errors:0 dropped:0 overruns:0 frame:0
TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
collisions:0 txqueuelen:0
RX bytes:0 (0.0 b) TX bytes:0 (0.0 b)

$ ps aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.0 0.0 11492 1780 ? Ss 13:08 0:00 /bin/bash
root 21 0.0 0.0 13372 1044 ? R+ 13:17 0:00 ps aux

ディレクトリツリーの違いはぱっと見違いを比較しにくいので割愛していますが、もちろんコンテナ環境とホスト環境では全く異なるファイルシステムツリーが見えているはずです。ネットワークの設定についてはインターフェース名が同じあっても、IPアドレス、MACアドレスが違います。

また、ホスト環境のネットワーク設定に注目して下さい。docker0という仮想ブリッジがありますが、これはDocker導入時に設定されます。この仮想ブリッジを通して、コンテナ内のプロセスはコンテナ外部のプロセスと通信することができます。コンテナとその外の世界との通信はDocker独自のネットワーク実装はもちろん、Linuxが提供するネットワーク機能も併用して柔軟に対応できるようになっています。

ここで理解しておけなければならないのは、OSとしてはあくまでホスト環境上のCentOS7.2のカーネルで構築されているので、コンテナ環境で表示されたCentOS6.7のカーネルは厳密にはこの環境上、どこにもないということです。

なので、もしCentOS6.7のコンテナの環境で何らかのバイナリファイルを実行する場合、それはCentOS6.7上でコンパイルされたバイナリファイルをCentOS7.2上で動かすことになります。

ですが、基本的にLinuxのバイナリファイルは設計思想上、後方互換性を保って作られているので 動作します。これもLinuxがDockerを実現できた大きな理由の1つでしょう。

コンテナは手軽に複数立ち上げることもできます。

先程コンテナを立ち上げたホスト環境でもう1つCentOS6.7のコンテナを名前を変えて新たに起動してみましょう。

$ sudo docker run -it --name centos6_2 centos:centos6.7 /bin/bash

コンテナ内で環境情報を確認してみます。


$ cat /etc/redhat-release
CentOS release 6.7 (Final)

$ ifconfig
eth0 Link encap:Ethernet HWaddr 02:42:AC:11:00:03
inet addr:172.17.0.3 Bcast:0.0.0.0 Mask:255.255.0.0
inet6 addr: fe80::42:acff:fe11:3/64 Scope:Link
UP BROADCAST RUNNING MULTICAST MTU:1500 Metric:1
RX packets:5 errors:0 dropped:0 overruns:0 frame:0
TX packets:8 errors:0 dropped:0 overruns:0 carrier:0
collisions:0 txqueuelen:0
RX bytes:438 (438.0 b) TX bytes:648 (648.0 b)

lo Link encap:Local Loopback
inet addr:127.0.0.1 Mask:255.0.0.0
inet6 addr: ::1/128 Scope:Host
UP LOOPBACK RUNNING MTU:65536 Metric:1
RX packets:0 errors:0 dropped:0 overruns:0 frame:0
TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
collisions:0 txqueuelen:0
RX bytes:0 (0.0 b) TX bytes:0 (0.0 b)

ネットワークのインターフェイス名は同じにもかかわらず、割り当てられているIPアドレスが先程のコンテナとは異なります。またループバックアドレスに関して同じ情報を表示していたとしても、やはりそれらはコンテナ間で異なるものを指しています。

コンテナ同士の場合、初期状態では稼働中のプロセスの違いがわかりにくいので、後に起動したコンテナで下記のプログラムをバックグラウンドで走らせてみます。

なお、実行ファイルを作成する場合は、gccなどのコンパイラがなければ、システム内にインストールしておく必要があります。

#include<stdlib.h>


int main (void) {
while (1){
sleep(3);
}
}

$ ./a.out &

$ ps aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.0 0.0 11492 1776 ? Ss 00:36 0:00 /bin/bash
root 59 0.0 0.0 3972 316 ? S 01:13 0:00 ./a.out
root 61 0.0 0.0 13368 1044 ? R+ 01:14 0:00 ps aux

最初に起動したコンテナ環境内で、psコマンドを打ってみます。

$ ps aux

USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.0 0.0 11492 1780 ? Ss Dec20 0:00 /bin/bash
root 32 0.0 0.0 13372 1044 ? R+ 01:52 0:00 ps aux

./a.outが存在しないので、この環境上では別のプロセステーブルを参照していることがわかります。

更にここでホスト環境上でpsコマンドを実行してみましょう。

$ ps aflx

# 一部省略してます。
4 0 941 1 20 0 1170188 38544 wait Ssl ? 2:19 /usr/bin/docker-current daemon --exec-opt native.cgroupdriver=systemd --selinux-enabled --log-driver=journald
4 0 12885 941 20 0 11492 1652 n_tty_ Ss+ pts/2 0:00 \_ /bin/bash
4 0 23531 941 20 0 11492 1796 n_tty_ Ss+ pts/3 0:00 \_ /bin/bash
0 0 25255 23531 20 0 3972 312 hrtime S pts/3 0:00 \_ ./a.out

dockerデーモン(PID941)から子プロセスとしてbashが2つフォークされています。

ホスト環境上では、それぞれPID12885とPID23531のプロセスとして確認できますが、これらは、それぞれのコンテナ環境のPID1として表示されていたものと同じものです。

このようにコンテナでは、仮想サーバと違い、プロセスとしてOSのような実行環境が実現されていることがわかりました。

ホスト環境でpsコマンドを走らせた時に133個ものプロセスが確認できたのに対して、コンテナでは2つしか走っていないことからもその軽さがわかると思います。

コンテナはあくまでプロセスとして実現されており、OSとして動かす為に必要な処理が走らない為、高速に動作させることができます。

もちろんそこにhttpdやmysqldなどのデーモンをいれて利用していくことにはなりますが、それでも仮想サーバと比べると圧倒的に少ないオーバーヘッドで動作することができます。

ミドルウェアレベルの手軽さで、OSのような実行環境そのものを管理することができるのです。


Docker実現の仕組み

それでは、Docker(コンテナ)を実現する為の方法として二点ほどその機能を深掘りしていきましょう。


nameapace

namespaceはLinuxカーネルが提供するOSが管理するシステムリソースを分離する為の機能です。

現状は6種類のリソースをnamespaceによって分離することが可能です。

具体的にはDockerの実現の例でみたように、フォークしたプロセスに対して独立したシステムリソースを割り当てることができます。


mount namespace

ファイルシステムのディレクトリ構造を分離します。

コンテナごとにわりあてた論理デバイスにルートファイルシステムを割り当てることで実現しています。

各コンテナ間では同じファイルパスでも異なる実体のファイルやディレクトリにアクセスします。


UTS namespace

unameで取得されるようなシステム情報を分離します。


IPC namespace

Systen V IPCとPOSIXメッセージキューを分離します。

コンテナ間でプロセス間通信に利用される、セマフォ、メッセージキュー、共有メモリといった機構を分離します。


PID namespace

PIDを分離します。

各コンテナ間では異なるプロセステーブルを参照するため、同じPIDでも異なるプロセスを指します。


network namespace

ネットワークインタフェースを分離します。

各コンテナ内では異なるネットワークインタフェースを持つことになるため、IPアドレスをはじめとする各アドレス情報を分離できます。


user namespace

ユーザー情報を分離します。

各コンテナ間では同じUID、GIDでも別のユーザーを指します。

rootですらもコンテナが異なればそれぞれ違うユーザーを指します。

ここで、namespaceに関するある実験をしてみます。

その実験の為のコードをsetns(2) - Linux manual pageより引用します。


namespace.c

 #define _GNU_SOURCE

#include <fcntl.h>
#include <sched.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>

#define errExit(msg) do { perror(msg); exit(EXIT_FAILURE); \
} while (0)

int
main(int argc, char *argv[])
{
int fd;

if (argc < 3) {
fprintf(stderr, "%s /proc/PID/ns/FILE cmd args...\n", argv[0]);
exit(EXIT_FAILURE);
}

fd = open(argv[1], O_RDONLY); /* Get file descriptor for namespace */
if (fd == -1)
errExit("open");

if (setns(fd, 0) == -1) /* Join that namespace */
errExit("setns");

execvp(argv[2], &argv[2]); /* Execute a command in namespace */
errExit("execvp");
}


これをコンパイルして、namespaceという実行ファイルにします。

setfsはシステムコールで、既存のnamespaceを参照するファイルディスクリプタを指定すると、呼び出したスレッドをそのnamespaceに関連付けます。

ちなみに、ファイルディスクリプタとは何かについては、以前Linuxのファイルディスクリプタをハックするで書かせて頂いたので、よければこちらもどうぞ。

さて、既存のnamespaceを参照するファイルディスクリプタはどうやって取得すればいいのかというと、/proc/PID/ns/というディレクトリの中の6種類のnamespaceに対応するファイルをオープンして取得します。

ためしにDockerから起動したコンテナのnamespaceを確認してみます。2つ起動しましたが、一方のPIDはさきほどのpsコマンドから12885であることがわかります。こちらのプロセスに注目してみます。

$ ll /proc/12885/ns

lrwxrwxrwx 1 root root 0 12月 22 23:54 ipc -> ipc:[4026532157]
lrwxrwxrwx 1 root root 0 12月 22 23:54 mnt -> mnt:[4026532155]
lrwxrwxrwx 1 root root 0 12月 20 22:08 net -> net:[4026532160]
lrwxrwxrwx 1 root root 0 12月 22 23:54 pid -> pid:[4026532158]
lrwxrwxrwx 1 root root 0 12月 22 23:54 user -> user:[4026531837]
lrwxrwxrwx 1 root root 0 12月 22 23:54 uts -> uts:[4026532156]

さきほど紹介した6種類のnamespaceが表示されていることがわかります。

このコマンドを打つ前にホスト環境の/etc/redhat-releaseの内容を確認してみます。

$ cat /etc/redhat-release

CentOS Linux release 7.2.1511 (Core)

次に、さきほどコンパイルして生成した実行ファイルnamespaceを使って、既存のコンテナのmount namespaceに新たに起動したプロセスを関連付けてみましょう。

$ ./namespace /proc/12885/ns/mnt /bin/bash

この状態で/etc/redhat-releaseを確認してみます。

$ cat /etc/redhat-release

CentOS release 6.7 (Final)

起動したコンテナと同じファイルを参照することができています。

プロセスをnamespaceに関連づける方法としては、cloneシステムコールに各namespace用のフラグ(CLONE_NEWNSや、CLONE_NEWIPCなど)を指定してフォークしたプロセスに関連づける方法や、unshareシステムコールにフラグを指定して、子プロセスを作成せずに現在のプロセスに新しいnamespaceを関連付ける方法などがあります。

※以前書いたエントリ

Linuxのforkシステムコールをハックする

clone(2) - Linux manual page

unshare(2) - Linux manual page


cgroups

cgroupsはLinuxカーネルが提供する、グルーピングした特定のプロセス群にハードウェアとそれに関係する側面から制限をかける機能です。

具体的には各コンテナに対するCPU時間の割り当て優先度や、メモリ使用量、データ転送に伴う帯域などを調整できます。

コンテナの文脈で説明すると、制御されるプロセス群はnamespaceでグルーピングされたプロセスということになります。

ためしに以下のディレクトリの中身をみると、どういったリソースを制御可能かわかります。

$ ll /sys/fs/cgroup/

drwxr-xr-x 4 root root 0 12月 20 21:57 blkio
lrwxrwxrwx 1 root root 11 12月 18 17:33 cpu -> cpu,cpuacct
drwxr-xr-x 4 root root 0 12月 20 21:57 cpu,cpuacct
lrwxrwxrwx 1 root root 11 12月 18 17:33 cpuacct -> cpu,cpuacct
drwxr-xr-x 3 root root 0 12月 18 17:33 cpuset
drwxr-xr-x 4 root root 0 12月 20 21:57 devices
drwxr-xr-x 3 root root 0 12月 18 17:33 freezer
drwxr-xr-x 3 root root 0 12月 18 17:33 hugetlb
drwxr-xr-x 4 root root 0 12月 20 21:57 memory
drwxr-xr-x 3 root root 0 12月 18 17:33 net_cls
drwxr-xr-x 3 root root 0 12月 18 17:33 perf_event
drwxr-xr-x 4 root root 0 12月 18 17:33 systemd

今回はdevicesに注目してみます。

devicesは所属するグループ群に対して、デバイスへのアクセスを制御する機能です。

私の環境ではcgroupsはsystemdで管理されているので、現在稼働中のコンテナにおけるdevicesの管理状況は下記ディレクトリ内で確認できます。

$ ll /sys/fs/cgroup/devices/system.slice/docker-f4458636b4067b2136686ba4c96af94d7ba66ad8c0ed702c82859a145a48672b.scope

-rw-r--r-- 1 root root 0 12月 21 09:36 cgroup.clone_children
--w--w--w- 1 root root 0 12月 21 09:36 cgroup.event_control
-rw-r--r-- 1 root root 0 12月 21 09:36 cgroup.procs
--w------- 1 root root 0 12月 21 09:36 devices.allow
--w------- 1 root root 0 12月 21 09:36 devices.deny
-r--r--r-- 1 root root 0 12月 21 09:36 devices.list
-rw-r--r-- 1 root root 0 12月 21 09:36 notify_on_release
-rw-r--r-- 1 root root 0 12月 21 09:36 tasks

というようなファイルが格納されています。

私が現在稼働させているコンテナのプロセスIDを確認してみます。

root       941  0.0  1.2 1170188 26560 ?       Ssl  12月18   3:02 /usr/bin/docker-current daemon --exec-opt native.cgroupdriver=systemd --selinux-enabled --log-driver

root 23531 0.0 0.0 11492 1132 pts/3 Ss 12月21 0:00 \_ /bin/bash
root 25255 0.0 0.0 3972 312 pts/3 S 12月21 0:01 \_ ./a.out
root 24371 0.0 0.0 3972 312 pts/3 S+ 02:09 0:00 \_ ./a.out

23531、25255、24371のPIDのプロセスが起動しているコンテナのプロセスグループに所属していることがわかります。

ここでcgroup.procsの中身を見てみると、

$ cat cgroup.procs

23531
24371
25255

さきほどpsコマンドで確認して3つのプロセスが表示されたので、このプロセス達に対して何らかのデバイスのアクセス制御をしていることがわかります。

どうやって制限をかけているかは、名前からなんとなくわかると思いますが

devices.allow、devices.denyのファイルでそれぞれ許可するデバイス、禁止するデバイスを管理します。

なお、この2つは書き込み専用なので、読み込み専用のdevices.listを使って現在許可されているデバイスを確認します。

$ cat devices.list

c *:* m
b *:* m
c 5:1 rwm
c 4:0 rwm
c 4:1 rwm
c 136:* rwm
c 5:2 rwm
c 10:200 rwm
c 1:3 rwm
c 1:5 rwm
c 1:7 rwm
c 5:0 rwm
c 1:9 rwm
c 1:8 rwm

各行の最初の一文字目はデバイスの種類を表しています(cはキャラクタデバイス、bはブロックデバイスなど)。

5:1などは/dev配下におけるメジャー番号とマイナー番号を表しており最後のrwは読み込み書き込み、mはデバイスファイルの新規作成が可能であることを意味していおり、*はワイルドカードを表しています。

なんとなく察しがつくかもしれませんが、Dockerにおいては最初に全てのデバイスへのアクセスを不可にしてから、段階的にアクセスを許可するというホワイトリスト方式をとっています。

Webサーバソフトウェア、Apacheのアクセス設定をしたことがある方は設定ファイル(.htaccssなど)の設定方法と似ていると感じるかもしれません。

Linuxのcgroupsを利用する時にdevices.listがいつもデフォルトでこう設定してあるわけではなく、あくまでDockerがこのようなアクセス制御を行っているということがポイントです。

では、今回のデバイスへの実験はこのコンテナに対して更にデバイスへのアクセスを厳しくしてみましょう。

devices.list内で許可対象になっていたc 1:8は/dev/randomを意味します。

なので、コンテナ側で下記コマンドを打つと、問題なく実行されます。

$ cat /dev/random

# ランダムな文字が表示され続ける。

続いてホスト環境側で、下記コマンドを実行します。devices.denyに追記で書き込みを行っているだけです。

$ /sys/fs/cgroup/devices/system.slice/docker-f4458636b4067b2136686ba4c96af94d7ba66ad8c0ed702c82859a145a48672b.scope

$ echo 'c 1:8 rwm' >> devices.deny

devices.listを確認するとc 1:8 rwmが消えています。

$ cat devices.list

c *:* m
b *:* m
c 5:1 rwm
c 4:0 rwm
c 4:1 rwm
c 136:* rwm
c 5:2 rwm
c 10:200 rwm
c 1:3 rwm
c 1:5 rwm
c 1:7 rwm
c 5:0 rwm
c 1:9 rwm

そして再び、コンテナ側で同じコマンドを実行します。

$ cat /dev/random

cat: /dev/random: Operation not permitted

このように/dev/randomにアクセスすることはできなくなりました。

原理的にコンテナ方式による実行環境の制御はセキュリティ的な観点からは、仮想サーバより脆弱性を秘めています。

仮想サーバはOSやハイパーバイザに何らかの脆弱性がなければ、他の仮想環境に影響を与えることはまずないですが、コンテナ間の関係はあくまでただのプロセスなので、他のコンテナ(プロセス)へ影響を与える可能性は仮想環境よりは間違いなく高いです。

そういったセキュリティ上のリスクへの対策はDocker自身やDockerの為の基盤システムなどでカバーしようという試みが行われていますが、これもその対策の1つと言えます。


今後の展望

なお、Dockerはnamespaceやcgroups含め、Linuxカーネルが提供するコンテナ実現関連のAPIを抽象化するインタフェースとしてlibcontainerをDocker0.9より内部で実装してます。

これによって、Dockerは特定の環境に依存しにくい安定的なシステムに近づいたので、導入する側としては決断がしやすくなったと思います。

また、Docker専用のOS(RancherOS、CoreOS)の登場や、横断的なDocker管理を可能とするKubernetesなどのオーケストレーションツールの機能も充実してきたので、Immutable Infrastructure(不変のインフラ)として、幅広い分野に利用されていくことが期待されます。

新しい情報があれば随時、シェアしていきたいと思います👍


検証環境

OS: CentOS Linux release 7.2.1511 (Core)

Linuxカーネル: 3.10.0-327.36.3.el7.x86_64


参考文献

Docker実践入門 ――Linuxコンテナ技術の基礎から応用まで

「仮想化」実装の基礎知識

Docker 0.9: Introducing Execution Drivers And Libcontainer

Container Specification - v1

Linuxカーネルのソースコード: 3.10

Linux Kernel Updates Vol.2014.08