読者です 読者をやめる 読者になる 読者になる

新人SEの学習記録

14年度入社SEの学習記録用に始めたブログです。気づけば社会人3年目に突入。

学習記録:Docker

第4章 Dockerの内部構造と関連ツール

Dockerの内部構造

コンテナ内部のプロセス管理

コンテナ内部で稼働するプロセスには,それぞれに独立したプロセステーブルが割当てられる。
例えば,以下のDockerfile(一部抜粋)

CMD ["/usr/local/bin/init.sh"]

と以下のアプリケーション起動スクリプト

#!/bin/bash

service httpd start
/bin/bash

から作成されたコンテナイメージを,以下のコマンドで起動する。

# docker run -itd -p 8000:80 --name apache01 apache:ver1.0

このコンテナの内部では,まずシェルスクリプト/usr/local/bin/init.shが実行され,その中でhttpdサービスとbashを起動する。
コンテナ内部のbashからプロセスを確認すると,次のようになる。

UID     PID   PPID  C STIME TTY        TIME   CMD
root      1      0  0 05:46   ?    00:00:00   /bin/bash /usr/local/bin/init.sh 
root     16      1  0 05:46   ?    00:00:00   /usr/sbin/httpd 
apache   18      16 0 05:46   ?    00:00:00   /usr/sbin/httpd
apache   19      16 0 05:46   ?    00:00:00   /usr/sbin/httpd
apache   19      16 0 05:46   ?    00:00:00   /usr/sbin/httpd
...
root     26      1  0 05:46   ?    00:00:00   /bin/bash
root     35     26  0 05:46   ?    00:00:00   ps -ef

初めに起動したinit.shは,プロセスIDが"1"になっており,その子プロセスとしてhttpデーモンとbashが起動していることがわかる。
一方,コンテナの外部にあたるホスト上でpsコマンドを実行すると,以下のようになる(一部抜粋)。

UID     PID   PPID  C STIME   TTY        TIME   CMD
...
root   7302      1  1 11:15   ?      00:02:40   /usr/bin/docker -d --selinux-enabled
root  13711   7302  0 14:46   pts/4  00:00:00   /bin/bash /usr/local/bin/init.sh
root  13743  13711  0 14:46   ?      00:00:00   /usr/sbin/httpd
48    13745  13743  0 14:46   ?      00:00:00   /usr/sbin/httpd
48    13746  13743  0 14:46   ?      00:00:00   /usr/sbin/httpd
...
root  13753  13711  0 14:46   pts/4  00:00:00   /bin/bash

これらはコンテナ内部で稼働しているプロセスと同じものだが,プロセスIDが異なっている。特に,init.shはDockerサービスのデーモンプロセスである/usr/bin/dockerの子プロセスとして起動していることがわかる。これは,次のように理解できる。
まず初めに,ホスト上でdockerコマンドを実行してコンテナを起動すると,dockerデーモンはDockerfileのCMD命令で指定されたコマンドをコンテナ内部で実行する。このため,コンテナ内部のプロセスは,ホスト上ではdockerデーモンの子プロセスとして実行されていく形になる。
一方,コンテナの内部にはホストとは別に独立したプロセステーブルが用意されている。このため,コンテナ内部ではdockerデーモンから起動されたコマンドがPID=1の最初のプロセスとなる。今の例ではinit.shがこれにあたり,更にその子プロセスとしてその他のプロセスが実行されていく。init.shではhttpdサービスを起動しているのでhttpdプロセスが起動し,更にその子プロセスとなるhttpdプロセスがフォークされていく。その後bashを起動するが,これはフォアグラウンドで稼働を続けるので,シェルスクリプトの実行はここで一旦中断することになる。
このとき,コンテナ内部のbashをexitコマンドで終了すると,init.shの実行が完了してコンテナ内部ではPID=1のプロセスが消滅することになる。Linuxの仕組み上,PID=1のプロセスが存在しない状態ではコンテナの稼働を継続することはできず,コンテナ内のプロセスは全て強制停止させられ,コンテナは停止状態になる。

ここで,stopコマンドでコンテナを停止するときの動作についても言及したい。stopコマンドを実行すると,コンテナ内部のPID=1のプロセスをTERM(もしくはKILL)シグナルで停止する。先ほどの例ではinit.shプロセスがTERMシグナルを受け取ることになるので,シェルスクリプトが停止したあとその他のプロセスは強制停止させられることになる。
PID=1のプロセスが別のプロセス,例えばhttpdプロセスのような場合,このプロセス内部でTERMシグナルに対するハンドラが用意されている場合,このハンドラによって適切な終了処理が行われることになる。単純なWebサーバであれば強制停止でも問題ないが,DBサーバなどではデータを保護するために適切な終了処理が必須となる場合もある。
例えば,上記の例ではinit.shに以下のようなコマンドを追記することで停止時に独自の終了処理を追加することができる。

#!/bin/bash
service httpd start

cat <<EOF >>~/.bashrc
trap '<任意の終了処理を記述>' TERM
EOF
exec /bin/bash
コンテナイメージ管理

コンテナイメージはヒストリー構造を持っており,同一のベースイメージから複数のイメージを作成した場合,ベースイメージに対する差分のみが保存される。では,実際はどのような仕組みで保存されているのだろうか。
Dockerではコンテナイメージをローカルディスクに保存する方式をプラグイン形式で選択できるようになっており,CentOS7ではdm-thin(Device Mapper Thin-Provisioning)を利用した仕組みが用いられている。

dm-thinはLinuxが標準で提供するDevice Mapperの仕組みを利用したモジュールである。Device Mapperは物理ディスクの上にソフトウェアのモジュールをかぶせることで,追加機能を持った特別なディスクデバイスを構成する。
dm-thinでは,物理ディスクに相当する部分としてデータ用デバイスとメタデータ用デバイスの2種類のデバイスを用意し,これらの上に任意の数の論理デバイスを作成していく。それぞれの論理デバイスは独立したディスク領域として扱われ,実際にデータが書き込まれた分だけ物理ディスクを割り当てる。論理デバイスに書き込みが発生すると,データ用デバイスから一定サイズのブロックが割当てられていく,ブロックと論理デバイスの対応関係は別途メタデータ用デバイスの方に記録される。
そして,dm-thinは論理デバイスのスナップショット機能を提供する。既存の論理デバイスからスナップショットコピーを作成すると,同一のブロックを参照する論理デバイスが用意され,コピー元/先のどちらかに書き込みが発生sいた場合,書き込み部分については新しいブロックを割当てて,もとのブロックの内容をコピーした後に書き込みを行う(Copy on Write)。

Dockerではそれぞれのコンテナイメージに対して個別の論理デバイスを用意する。コンテナイメージの中身はコンテナに割り当てるルートディレクトリそのものなので,用意した論理デバイスをファイルシステムでフォーマットして,イメージに含まれるファイル群をディレクトリ構造ごとまとめて保存する。要は,空のファイルシステムにtar形式のアーカイブファイルを展開して保存するようなものと考えられる。コンテナ起動時には,指定されたコンテナイメージを保存した論理デバイスのスナップショットを作成し,そちらをコンテナのルートファイルシステムとして割り当てる。これにより,1つのイメージから複数のコンテナを起動することが可能になる。

例えば,同じCentOS6のベースイメージからhttpdをインストールしたイメージを作成した場合,まず,ベースイメージの論理デバイスはCentOS6のコンテンツが書き込まれたブロックを参照している。httpdをインストールしたイメージの論理デバイスは,ベースイメージと同じCentOS6のコンテンツが書き込まれたブロックと,httpdの追加ファイルが書き込まれたブロックを参照する…というようになる。

では,Dockerのインストール時にデータ用デバイスとメタデータ用デバイスがどのように準備されているかというと,デフォルト状態ではディスクイメージファイルとして/var/lib/docker/devicemapper/devicemapperに用意されている。

# ls -lh /var/lib/docker/devicemapper/devicemapper
合計 3.3G
-rw-------.  1 root root 100G  73  11:03 data
-rw-------.  1 root root 2.0G  73  12:35 metadata

これらのファイルはデータの書き込みに伴いファイルサイズを拡張していくスパース形式のイメージファイルになっている。dataは見かけ上100Gバイトのサイズを持つが,実際には書き込みが発生した分のサイズしか持っておらず,書き込みが発生するごとにファイルサイズを拡張していく。このスパース形式のディスクイメージファイルは,物理ディスク領域に直接アクセスする場合に比べてアクセス性能は大きく劣る。ディスクアクセスの性能を重視する場合は,LinuxのLVMで構成した論理ボリュームをコンテナの保存領域として使用することもできる。
その場合の手順は以下のようになる。

// 一度dockerを止めて環境の初期化(全コンテナイメージが削除される)
# systemctl stop docker.service
# rm -rf /var/lib/docker/*
// ボリュームグループを作成してコンテナイメージの保存領域として指定する
# pvcreate /dev/sdb
# vgcreate vg_pool /dev/sdb

更に,設定ファイル/etc/sysconfig/docker-storage-setupを編集する。

VG=vg_pool
DATA_SIZE=20G

VGには先程用意したボリュームグループを指定し,DATA_SIZEにはコンテナイメージ保存領域として使用する論理ボリュームのサイズを指定する。この後,以下のコマンド

# docker-storage-setup

を実行すると,指定サイズの論理ボリュームが作成されたあと,Dockerに対して必要な設定が追加される。

複数ホストのネットワーク管理

Dockerのネットワークは前の章で説明したとおり,同一ホストのコンテナ間のネットワーク通信は仮想ブリッジdocker0を通して,複数ホスト間ではポート転送機能を利用して通信を行うことになる。
しかし,Docker用の仮想ネットワークツールであるFlannelを用いると,異なるホスト上の仮想ブリッジを仮想ネットワークで相互接続することが可能になる。

Flannelでは,VXLANによるオーバレイ技術を用いて,物理ネットワークの上に独立した仮想ネットワークを構成する。例えば,192.168.122.0/24のネットワークに接続されているホストLinux複数台ある場合,各ホスト上の仮想ブリッジdocker0はこのネットワークとは独立した仮想ネットワークで相互接続されており,物理ネットワークとは異なるサブネットを使用することができる。
例えば,コンテナ通信用ネットワークには10.1.0.0/16のサブネットが割り当てられているとする。このサブネットを分割して,それぞれの仮想ブリッジに割り当てる。ここでは,ホスト1には10.1.1.0/24を,ホスト2には10.1.2.0/24を割り当てる。
コンテナ内部から送信されたパケットは,仮想ブリッジdocker0からVXLAN機能を持った仮想NIC「flannel.1」に転送されたあと,コンテナ通信用ネットワークを経由して送信先のコンテナに届けられる。docker0とflannel.1の間のパケット転送は,ホストLinuxの標準的なパケットフォワーディングの機能を使用する。
ちなみに,flannel.1とコンテナ通信用ネットワークはFlannelが用意するが,各ホストで稼働するDockerに対しては--bipオプションが必要となる。上の例では,ホスト1では--bip=10.1.1.1/24という設定を追加することで,docker0には10.1.1.1というIPアドレスが設定され,各コンテナには10.1.1.0/24のサブネットからIPアドレスが割り当てられるようになる。この後の手順でKubernetesの環境をセットアップすると,Flannelはこれらの起動オプションを自動的に追加してくれる。