めもめも

このブログに記載の内容は個人の見解であり、必ずしも所属組織の立場、戦略、意見を代表するものではありません。

RHEL7におけるDockerのディスクイメージ管理方式

変更履歴
2014/04/20 公開
2014/04/27 構成情報ファイルの説明追加
2014/06/15 dm-thinprovisiongのデバイスメタデータファイル変更

背景

先だって、「Linuxコンテナ(LXC)の基礎をまとめ直す」というコラムに、「来るべきDockerの波に向けて、まずは、コンテナの基礎を理解しましょう!」的な話を書きました。この中で、比較的に原始的なコンテナ利用法として、「RHEL6.2のlibvirtからLinuxコンテナを利用」という記事を紹介しています。

この記事では、busyboxを使った簡易httpサーバのコンテナを起動していますが、この手順に従うと(気づく人は)容易に気づくのが、コンテナに見せるファイルシステムの準備がいかに面倒か、という事実です。コンテナから見えるルートファイルシステムは、基本的には、ホスト上の特定のディレクトリにchrootした状態に過ぎません。したがって、この特定のディレクトリの下には、アプリケーションの実行に必要なあらゆるファイル(動的ライブラリなど)を突っ込んでおく必要があります。(先の記事では、この手順が面倒なので、動的ライブラリが一切不要なbusyboxでお茶を濁していたりします。)

また、苦労して作成したファイルシステムをどうメンテナンスするかも悩ましくなります。まさか、tarballに固めてバックアップですか?

実はDockerでは、このあたりの問題を解決、というか、うまくやりくりできるように、「イメージリポジトリ」や「スナップショット」などの独自の概念を取り入れています。概念的には面白そうなのですが、インフラエンジニアであれば、当然(!)、イメージの実体がどこに格納されていて、どういう技術でスナップショットが作成されているのか気になりますよね。(というか、それを知らずに使っていたら、トラブルでイメージが破損した時にどうやって対応してよいのか、最悪、どうやって個別ファイルのサルベージに取り組んでいいのか分かりませんよね。。。。)

なお、Dockerのディスクイメージの実装には、いくつかの方式があります。ここでは、あくまで、RHEL7での実装を前提とします。RHEL6/CentOS6なども同じ方式です。

参考資料
これから始める「DockerでかんたんLAMP環境 for CentOS」
thin-provisioning.txt
Resizing Docker containers with the Device Mapper plugin
LVM thin povisioning

環境準備

RHEL7Betaを最小構成でインストールしたら、EPELからdocker-ioを導入して、dockerサービスを起動します。「bridge-utils, net-tools」は必須ではないですが、無いと不便なので入れています。

# yum -y install bridge-utils net-tools
# yum -y install http://download.fedoraproject.org/pub/epel/beta/7/x86_64/epel-release-7-0.1.noarch.rpm
# yum -y install docker-io
# systemctl enable docker.service
# systemctl start docker.service

ここで、サービスを起動したら間髪入れずに、次のコマンドを叩きます。

# ps -efww
...
root      2237  2203 22 17:10 ?        00:00:00 mkfs.ext4 -E discard,lazy_itable_init=0,lazy_journal_init=0 /dev/mapper/docker-253:0-15516-base
...
# lsblk
NAME                        MAJ:MIN RM  SIZE RO TYPE MOUNTPOINT
...
loop0                         7:0    0  100G  0 loop 
└docker-253:0-15516-pool   253:2    0  100G  0 dm   
  └docker-253:0-15516-base 253:3    0   10G  0 dm   
loop1                         7:1    0    2G  0 loop 
└docker-253:0-15516-pool   253:2    0  100G  0 dm   
  └docker-253:0-15516-base 253:3    0   10G  0 dm   

なにやら謎の10GBのデバイス「/dev/mapper/docker-253:0-15516-base」がext4でフォーマットされています。実は、このデバイスは、Dockerが扱う全てのイメージのベースとなるデバイスです。Dockerで新規のイメージを作成すると、この後で説明する、Thin-Provisioningの機能により、このベースイメージのスナップショットを作成して、それを新規イメージとして利用します。そのため、Dockerのイメージはすべて、10GBに揃えられているわけです。

それでは、ここで、CentOS6のイメージをダウンロードして、ローカルリポジトリに登録します。

# docker pull centos

ここでもダウンロードが始まったら、別の端末から、おもむろに次のコマンドを叩きます。

# lsblk
NAME                        MAJ:MIN RM  SIZE RO TYPE MOUNTPOINT
...
loop0                         7:0    0  100G  0 loop 
└docker-253:0-15516-pool   253:2    0  100G  0 dm   
  ├docker-253:0-15516-base 253:3    0   10G  0 dm   
  └docker-253:0-15516-539c0211cd76cdeaedbecf9f023ef774612e331137ce7ebe4ae1b61088e7edbe
                            253:4    0   10G  0 dm   /var/lib/docker/devicemapper/mnt/539c0211cd76cdeaedbecf9f023ef774612e331137ce7
loop1                         7:1    0    2G  0 loop 
└docker-253:0-15516-pool   253:2    0  100G  0 dm   
  ├docker-253:0-15516-base 253:3    0   10G  0 dm   
  └docker-253:0-15516-539c0211cd76cdeaedbecf9f023ef774612e331137ce7ebe4ae1b61088e7edbe
                            253:4    0   10G  0 dm   /var/lib/docker/devicemapper/mnt/539c0211cd76cdeaedbecf9f023ef774612e331137ce7
# df
ファイルシス          1K-ブロック    使用   使用可 使用% マウント位置
...
/dev/dm-4                10190100   75716  9573712    1% /var/lib/docker/devicemapper/mnt/539c0211cd76cdeaedbecf9f023ef774612e331137ce7ebe4ae1b61088e7edbe
/dev/dm-5                10190100   42864  9606564    1% /var/lib/docker/devicemapper/mnt/0b443ba0395813ef287c27f5ff953121a69ab23c467ffbefcefaec3f255e0693

またもや謎の10GBのデバイスができて、さらにマウントされています。これが、先のベースイメージから作成したスナップショットで、これをマウントしてダウンロードしたCentOS6のイメージを展開しているわけです。

ダウンロードが完了すると、lsblkコマンドでは見えなくなりますが、その実体はちゃんと残っています。この実体を見つけるために、Device MapperによるThin-Provisioning機能を少し勉強することにしましょう。

Device MapperによるThin-Provisioning機能

ものすごーく、簡単に言うと、データ用のデバイスとメタデータ用の(小さな)デバイスを用意して、これを1つのストレージプールとして扱う機能です。このプールから任意のサイズの論理デバイスを切り出して、使用することが可能になります。

この時、切り出した論理デバイスに対して、その論理サイズ分のブロックを事前に確保することはしません。デバイスの使用量が増えるにしたがって、順次、プールのデバイスから領域を割り当てていきます。そのため、例えば、データ用デバイスが100GBであっても、(実使用量が100GBを超えない限り)、論理サイズ10GBのデバイスを10個以上切り出すことが可能です。また、スナップショット機能を利用して、既存のデバイスをコピーして、新規デバイスを作成することも可能です。論理的には完全に独立したコピーとなるので、スナップショットであることを忘れて、別々のデバイスとして利用することが可能です。

この機能は、Device Mapperのカーネルモジュールとして提供されています。

# lsmod | grep dm_thin
dm_thin_pool           55788  2 
dm_persistent_data     61832  1 dm_thin_pool
dm_bio_prison          15501  1 dm_thin_pool
dm_mod                102999  15 dm_log,dm_persistent_data,dm_mirror,dm_bufio,dm_thin_pool

ネイティブに取り扱う際は、dmsetupコマンドを駆使する必要がありますが、最近のLVMは、これのWrapper機能を持っているので、LVMのコマンドを通して利用することが可能です。RHEL6でLVM経由でThin-Provisioningを使用する際は、こちらのドキュメントを参照ください。

ただし!

Dockerでは、どういうわけだか、LVMを使わずに、ネイティブな方法でThin-Provisiongのデバイスを利用しています。そのため、Dockerがこっそり用意するThin-Provisiongデバイスを覗きこむためには、dmsetupコマンドを駆使する必要があります。そこで、ちょっとだけ練習をしてみます。

まず、Dockerが使っているThin-Provisioningデバイスがあるとややこしいので、Dockerを停止しておきます。

# systemctl stop docker

1GBのデータデバイス用と10MBのメタデータデバイス用のイメージファイルを用意して、ループバックデバイスに紐付けます。

# fallocate -l $((1024*1024*1024)) /root/data_device.img
# fallocate -l $((1024*1024*10)) /root/metadata_device.img
# losetup -f /root/data_device.img
# losetup -f /root/metadata_device.img
# losetup 
NAME       SIZELIMIT OFFSET AUTOCLEAR RO BACK-FILE
/dev/loop0         0      0         0  0 /root/data_device.img
/dev/loop1         0      0         0  0 /root/metadata_device.img

ここで次のコマンドを実行すると、用意したデバイスを使って、Thin-Provisioning用のプール「mypool」を作成して、さらにそこから、論理デバイスを切り出します。

# dmsetup create mypool --table "0 $((1024*1024*1024/512)) thin-pool /dev/loop1 /dev/loop0 512 8192"
# dmsetup message /dev/mapper/mypool 0 "create_thin 0"

最後の「0」はこの論理デバイスにユニークな「デバイスID」になります。この時点では、論理デバイスのサイズは確定していません。この後、サイズとデバイスIDを指定して論理デバイスを有効化すると、実際に使用できるようになります。この時、デバイス名を指定すると、デバイスファイル「/dev/mapper/<デバイス名>」が割り当てられます。

# dmsetup create mydevice --table "0 $((1024*1024*100/512)) thin /dev/mapper/mypool 0"

次のようにlsblkコマンドで、デバイスの様子が確認できます。

# lsblk
NAME          MAJ:MIN RM  SIZE RO TYPE MOUNTPOINT
...
loop0           7:0    0    1G  0 loop 
└mypool      253:2    0    1G  0 dm   
  └mydevice  253:3    0  100M  0 dm   
loop1           7:1    0   10M  0 loop 
└mypool      253:2    0    1G  0 dm   
  └mydevice  253:3    0  100M  0 dm   

できた論理デバイスは、普通にフォーマットして使うことができます。

# mkfs.ext4 /dev/mapper/mydevice 
# mount /dev/mapper/mydevice /mnt
# date > /mnt/test.txt

使い終わったら無効化しておきます。

# umount /mnt
# dmsetup remove mydevice

もちろん、再度、有効化して利用することも可能です。

# dmsetup create mydevice --table "0 $((1024*1024*100/512)) thin /dev/mapper/mypool 0"
# mount /dev/mapper/mydevice /mnt

という感じで、Thin-Provisioningデバイスの使い方が分かった所で、定義したプールを消して後片付けしておきましょう。

# umount /mnt
# dmsetup remove mydevice
# dmsetup remove mypool
# losetup -d /dev/loop0
# losetup -d /dev/loop1

先ほど停止したDockerサービスも再開しておきます。

# systemctl start docker

Dockerが利用するThin-Provisioningデバイス

それでは、Dockerが内部的に使用しているThin-Provisioningデバイスを確認してみましょう。まず、10秒スリープするだけのコンテナを起動します。

# docker run centos sleep 10

この時、別の端末でデバイスの様子を確認すると、次のようになっています。

# losetup 
NAME       SIZELIMIT OFFSET AUTOCLEAR RO BACK-FILE
/dev/loop0         0      0         1  0 /var/lib/docker/devicemapper/devicemapper/data
/dev/loop1         0      0         1  0 /var/lib/docker/devicemapper/devicemapper/metadata

# lsblk 
...
loop0                       7:0    0  100G  0 loop 
└─docker-253:0-15516-pool 253:2    0  100G  0 dm   
  └─docker-253:0-15516-91711c50926a5ea1379c9b4a129f7883a18dbf4318d8e4a2b23a043deb1e8869
                          253:4    0   10G  0 dm   /var/lib/docker/devicemapper/mnt/91711c50926a5ea1379c9b4a12
loop1                       7:1    0    2G  0 loop 
└─docker-253:0-15516-pool 253:2    0  100G  0 dm   
  └─docker-253:0-15516-91711c50926a5ea1379c9b4a129f7883a18dbf4318d8e4a2b23a043deb1e8869
                          253:4    0   10G  0 dm   /var/lib/docker/devicemapper/mnt/91711c50926a5ea1379c9b4a12

# df
ファイルシス          1K-ブロック    使用   使用可 使用% マウント位置
...
/dev/dm-4                10190100  356596  9292832    4% /var/lib/docker/devicemapper/mnt/91711c50926a5ea1379c9b4a129f7883a18dbf4318d8e4a2b23a043deb1e8869

先に練習でThin-Provisioningデバイスを作成した時の様子を思い出すと、何がおきているかはすぐに分かります。まず、下記にある「data」「metadata」がプール用のデータデバイスとメタデータデバイスのイメージファイルです。

# ls -lh /var/lib/docker/devicemapper/devicemapper/
合計 963M
-rw-------. 1 root root 100G  4月 20 19:02 data
-rw-------. 1 root root 1.5K  4月 20 19:02 json
-rw-------. 1 root root 2.0G  4月 20 19:02 metadata

このプール内にある論理デバイスは、作成時にデバイス番号が割り振られていますが、その情報は、上記のjsonファイルに記録されています。これは、Dockerが独自に記録しています。ちょいと、整形して出力すると次のようになります。

# cat /var/lib/docker/devicemapper/devicemapper/json | python -mjson.tool
{
    "Devices": {
        "": {
            "device_id": 0,
            "initialized": true,
            "size": 10737418240,
            "transaction_id": 1
        },
        "0b443ba0395813ef287c27f5ff953121a69ab23c467ffbefcefaec3f255e0693": {
            "device_id": 8,
            "initialized": true,
            "size": 10737418240,
            "transaction_id": 13
        },
...
        "f6f690c42f33b7f55f9886f06552ea694b873cea4f898f97816337fdc6e4bdca-init": {
            "device_id": 11,
            "initialized": true,
            "size": 10737418240,
            "transaction_id": 16
        }
    }
}

謎のUUIDっぽいものは、この後で見るDockerとしてのイメージのIDです。それぞれについて、Thin-PorvisioningデバイスとしてのIDと論理サイズなどが記録されています。

2014/06/15 追記
Docker1.0では、上記のjsonファイルは、イメージごとに分割して、「/var/lib/docker/devicemapper/metadata/」というファイルに保存されるように変更されています。

では、DockerとしてのイメージIDは、どのように管理されているのでしょうか? dockerコマンドで確認すると先頭の12バイトだけ表示されます。

# docker images
REPOSITORY          TAG                 IMAGE ID            CREATED             VIRTUAL SIZE
centos              centos6             0b443ba03958        3 days ago          297.6 MB
centos              latest              0b443ba03958        3 days ago          297.6 MB
centos              6.4                 539c0211cd76        12 months ago       300.6 MB

リポジトリ「centos」に属する3つのイメージがありますが、これらの元の情報は、実は次のJsonファイルです。こちらからは、ID全体が分かります。

# cat /var/lib/docker/repositories-devicemapper | python -mjson.tool
{
    "Repositories": {
        "centos": {
            "6.4": "539c0211cd76cdeaedbecf9f023ef774612e331137ce7ebe4ae1b61088e7edbe",
            "centos6": "0b443ba0395813ef287c27f5ff953121a69ab23c467ffbefcefaec3f255e0693",
            "latest": "0b443ba0395813ef287c27f5ff953121a69ab23c467ffbefcefaec3f255e0693"
        }
    }
}

ではここで、既存のイメージを修正した独自イメージを作成してみます。次はCentOSのコンテナを起動して、「/root/file.txt」を作成した後に60秒間スリープします。

# docker run centos /bin/bash -c 'echo "Hello, World!" > /root/file.txt; sleep 60'

起動中のコンテナが使用しているイメージは、コンテナが終了すると削除されるので、スリープしている間にイメージのスナップショットを取って保存します。

# docker ps
CONTAINER ID        IMAGE               COMMAND                CREATED             STATUS              PORTS               NAMES
d11a82703373        centos:centos6      "/bin/bash -c 'echo    6 seconds ago       Up 5 seconds                            dreamy_mcclintock   

# docker commit d11a82703373 myrepo:hello_world
99672cda56ab113015005706fe952bab5898f1d5ec58bc0863d54b58f613cba7

# docker images
REPOSITORY          TAG                 IMAGE ID            CREATED             VIRTUAL SIZE
myrepo              hello_world         99672cda56ab        2 minutes ago       297.6 MB
centos              centos6             0b443ba03958        3 days ago          297.6 MB
centos              latest              0b443ba03958        3 days ago          297.6 MB
centos              6.4                 539c0211cd76        12 months ago       300.6 MB

無事に保存できました。それでは、このイメージの中身をdmsetupコマンドでダイレクトに覗いてみましょう。まず、イメージIDを確認します。

# cat /var/lib/docker/repositories-devicemapper | python -mjson.tool
{
    "Repositories": {
        "centos": {
            "6.4": "539c0211cd76cdeaedbecf9f023ef774612e331137ce7ebe4ae1b61088e7edbe",
            "centos6": "0b443ba0395813ef287c27f5ff953121a69ab23c467ffbefcefaec3f255e0693",
            "latest": "0b443ba0395813ef287c27f5ff953121a69ab23c467ffbefcefaec3f255e0693"
        },
        "myrepo": {
            "hello_world": "99672cda56ab113015005706fe952bab5898f1d5ec58bc0863d54b58f613cba7"
        }
    }
}

このイメージIDから、Thin-ProvisioningデバイスのIDを確認します。

# cat /var/lib/docker/devicemapper/devicemapper/json | python -mjson.tool
...
        "99672cda56ab113015005706fe952bab5898f1d5ec58bc0863d54b58f613cba7": {
            "device_id": 23,
            "initialized": true,
            "size": 10737418240,
            "transaction_id": 28
        },
...

ここで確認したデバイスIDとサイズの情報を変数にセットしておきましょう。先のlsblkコマンドの結果から、プールの名称は「docker-253:0-15516-pool」とわかるので、これも変数にセットしておきます。

# device_id=23
# size=10737418240
# pool=docker-253:0-15516-pool

これらの情報を利用して、この論理デバイスを手動で有効化してマウントしてみましょう。

# dmsetup create myvol --table "0 $(($size / 512)) thin /dev/mapper/$pool $device_id"
# mount /dev/mapper/myvol /mnt
# ls /mnt
id  lost+found  rootfs

マウントしたファイルシステムの「rootfs」がコンテナから見えるルートファイルシステムです。次のように、先ほど作成したファイルがちゃんと入っています。

# cat /mnt/rootfs/root/file.txt 
Hello, World!

最後にファイルシステムをアンマウントして、論理デバイスを無効化しておきます。

# umount /mnt
# dmsetup remove myvol

考察

これで、Dockerが扱うディスクイメージは、すべてがまとめて、巨大なイメージファイル「/var/lib/docker/devicemapper/devicemapper/(data|metadata)」から作ったプールに詰め込まれていることが分かりました。デフォルトで、

・プールサイズは100GB
・(すべてのイメージの元になる)ベースイメージは10GB

と決め打ちされているので、個々のイメージはすべて10GBに固定されており、かつプールが一杯になるとそれ以上コンテナが作成できなくなるという問題があります。しかも最初のプール用のファイルがスパースファイルになっているので、性能上の問題もありそうです。本番利用する際は、プール用のデバイスは、「/dev/sdb」などの物理デバイスをアサインして利用する方がよいかも知れません。このあたりのカスタマイズについては、こちらの記事を参考にしてください。

補足情報

Dockerのイメージには、コンテナを起動する際の構成情報が付随しています。これら構成情報は、イメージの実体とは別に、「/var/lib/docker/graph//json」に記録されています。

次は「dockerfile/nginx」の構成情報ですが、コンテナ起動時に最初に実行するコマンドである「Entrypoint」などの情報が含まれていることが分かります。("container_config"の方が実行時に影響するの構成情報になるようです。)

# docker images dockerfile/nginx
REPOSITORY          TAG                 IMAGE ID            CREATED             VIRTUAL SIZE
dockerfile/nginx    latest              2ba101254ac8        25 hours ago        393.6 MB
[root@rhel7rc2 ~]# image_id=2ba101254ac8 && cat /var/lib/docker/graph/$image_id*/json | python -mjson.tool
{
    "Size": 0,
    "architecture": "amd64",
    "config": {
        "AttachStderr": false,
        "AttachStdin": false,
        "AttachStdout": false,
        "Cmd": null,
        "CpuShares": 0,
        "Dns": null,
        "Domainname": "",
        "Entrypoint": [
            "nginx"
        ],
        "Env": [
            "HOME=/root",
            "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
        ],
        "ExposedPorts": {
            "80/tcp": {}
        },
        "Hostname": "100ddd371955",
        "Image": "8b8e289e3b40ffc21a4709ccc6b01a28d28c87da0137f1e497c69ed30088be45",
        "Memory": 0,
        "MemorySwap": 0,
        "NetworkDisabled": false,
        "OnBuild": [],
        "OpenStdin": false,
        "PortSpecs": null,
        "StdinOnce": false,
        "Tty": false,
        "User": "",
        "Volumes": {
            "/data": {},
            "/etc/nginx/sites-enabled": {},
            "/var/log/nginx": {}
        },
        "VolumesFrom": "",
        "WorkingDir": "/etc/nginx"
    },
    "container": "15c6538c6f265962cccc27b0e7c7fc94a6fbdd6ec2deb41d3edc0f30e4c685f2",
    "container_config": {
        "AttachStderr": false,
        "AttachStdin": false,
        "AttachStdout": false,
        "Cmd": [
            "/bin/sh",
            "-c",
            "#(nop) EXPOSE [80]"
        ],
        "CpuShares": 0,
        "Dns": null,
        "Domainname": "",
        "Entrypoint": [
            "nginx"
        ],
        "Env": [
            "HOME=/root",
            "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
        ],
        "ExposedPorts": {
            "80/tcp": {}
        },
        "Hostname": "100ddd371955",
        "Image": "8b8e289e3b40ffc21a4709ccc6b01a28d28c87da0137f1e497c69ed30088be45",
        "Memory": 0,
        "MemorySwap": 0,
        "NetworkDisabled": false,
        "OnBuild": [],
        "OpenStdin": false,
        "PortSpecs": null,
        "StdinOnce": false,
        "Tty": false,
        "User": "",
        "Volumes": {
            "/data": {},
            "/etc/nginx/sites-enabled": {},
            "/var/log/nginx": {}
        },
        "VolumesFrom": "",
        "WorkingDir": "/etc/nginx"
    },
    "created": "2014-04-26T02:15:11.344952515Z",
    "docker_version": "0.8.1",
    "id": "2ba101254ac858f7926d2ee09ebdef9447acde04c6f0f118a62859825cd5523c",
    "os": "linux",
    "parent": "8b8e289e3b40ffc21a4709ccc6b01a28d28c87da0137f1e497c69ed30088be45"
}