Webアプリケーション開発では、手元の開発環境や本番環境など、たいていどこかでコンテナを使います。自分もなんとなく使っていますが、コンテナが動く仕組みについて十分に説明できないことに気づきました。そこで、仕組みを一から追ってみることにしました。
イメージの取得からコンテナが動作するまでに動くプログラム(全体像)
この記事で扱う範囲
扱うのは、docker pullでイメージを取得してから、docker runでコンテナのプロセスが動き出すまでの範囲です。そのあいだに、どのプロセスが何をしているかを調べてみました。
1章と2章はdocker pull、3章と4章はdocker runの実行時に起きたことです。
コンテナを動かしているLinuxカーネルの機能については、詳細な挙動には言及せず、一般的な概念を書くにとどめます。
登場するプログラムとその関係
docker pullとdocker runの実行時に動くプログラムと、それぞれの関係を図にしました。各プログラムが何かは、次章以降の最初に出てくる場面で説明します。

動作環境
- Mac(Apple M4、macOS 26.7.1)。CPUはarm64です
- Docker Desktop 4.56.0、Docker Engine 29.1.3
- イメージの保存をcontainerdに任せ、取得にもcontainerdのライブラリを使う仕組みを使っています。Dockerはこれをcontainerd image storeと呼び、Docker Desktopでは標準で有効です。設定画面での名前は「Use containerd for pulling and storing images」です。この仕組みを使っていない環境では、記事中に登場するディレクトリと異なります
- 例としてalpine:3.20とpostgres:16の2つのコンテナを使用します。
コンテナの動作環境
Linuxのコンテナは、Linuxのカーネル上で動きます。そのため、MacのDocker Desktopは、Macの中に小さなLinuxのVMを立ち上げ、そのVMのカーネルでコンテナを動かします。
本記事の実行例は、このVMの中で実験したものです。
1. イメージを取得する
docker pullを実行すると、Dockerはレジストリからイメージを取得します。レジストリとは、コンテナイメージを保管・配布するサーバーです。代表的なものはDocker Hubで、alpine:3.20のように名前だけを指定すると、Dockerは自動的にDocker Hubからイメージを取ってきます。
イメージの中身を見てみる
イメージの形式を規定しているのは、OCI(Open Container Initiative)という団体のImage Specという仕様です。Dockerは、Docker独自の形式(Image Manifest Version 2, Schema 2)も扱えますが、この記事ではOCIの形式で説明します。この記事で例に使うalpine:3.20とpostgres:16も、OCIの形式です。
OCIのイメージは、次の4種類のファイルでできています。(イメージによってはindexがない場合もあります。)
| ファイル | 何が書かれているか |
|---|---|
| index | プラットフォームごとのmanifestの一覧です。複数のプラットフォーム向けに配るイメージで使われます。 |
| manifest | 1つのプラットフォーム向けのイメージに使うファイルの一覧です。configが1つと、layerが重なり順に複数並びます。 |
| config | コンテナを起動するときの設定です。実行するコマンドや環境変数などが入ります。 |
| layer(レイヤー) | ファイルシステムの差分をまとめたtarで、gzipやzstdで圧縮されていることもあります。下層のlayerから順に重ねることで、コンテナから見えるファイルシステムになります。 |
レイヤーは、Dockerfileでイメージを作る際に作成されます。RUNやCOPYなどの命令を実行するたびに1つずつ生成され、各レイヤーは差分情報だけを持ちます。
4種類のファイルの関係を図示すると、こうなります。

イメージを構成するファイルの参照には、digest(ファイルの中身から計算したハッシュ値で、通常はsha256)が使われています。indexには各manifestのdigestが、manifestにはconfigとlayerのdigestが書かれています。
digestと一緒に、リンク先が何のファイルかを表す名前も書かれており、これをメディアタイプと呼びます。
実際のイメージを見てみましょう。私が取得したindexのdigestは
sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc
でした。
indexの中身は、次のコマンドで見られます。
docker buildx imagetools inspect --raw alpine:3.20
alpine:3.20のindexには、8つのプラットフォームのmanifestが並んでいました。arm64のmanifestを指す部分は次のとおりです。
... { "schemaVersion": 2, "mediaType": "application/vnd.oci.image.index.v1+json", "manifests": [ // ほかのプラットフォームは省略 { "mediaType": "application/vnd.oci.image.manifest.v1+json", "digest": "sha256:45e09956dc667c5eff3583c9d94830261fb1ca0be10a0a7db36266edf5de9e1d", "size": 1026, "platform": { "architecture": "arm64", "os": "linux", "variant": "v8" }, // annotationsは省略 }, ], } ...
digestに書かれているのがarm64のmanifestを指すdigestです。mediaTypeには指す先がmanifestであること、platformにはそのmanifestがarm64向けであることが書かれていました。
次にmanifestを取得してみましょう。
docker buildx imagetools inspect --raw alpine@sha256:45e09956dc667c5eff3583c9d94830261fb1ca0be10a0a7db36266edf5de9e1d
manifestには次のように書かれています。
... { "schemaVersion": 2, "mediaType": "application/vnd.oci.image.manifest.v1+json", "config": { "mediaType": "application/vnd.oci.image.config.v1+json", "digest": "sha256:ab3fe4defd29ba6231229a4d41440ac8bde8218e85870e53876277faa24b35c4", "size": 628, }, "layers": [ { "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", "digest": "sha256:3f26bc2dec0b515f1c2818f6e13a8f1da1f88179a008445d4e587233386bff78", "size": 4092319, }, ], // annotationsは省略 } ...
configのdigestにconfigファイルの、layersのdigestにlayerファイルのdigestがそれぞれ書かれていました。alpine:3.20に含まれるlayerは1つでした。
indexとmanifestに出てきたmediaTypeをまとめると、次のようになっていました。
| ファイル | メディアタイプ |
|---|---|
| index | application/vnd.oci.image.index.v1+json |
| manifest | application/vnd.oci.image.manifest.v1+json |
| config | application/vnd.oci.image.config.v1+json |
| layer | application/vnd.oci.image.layer.v1.tar+gzip |
また、ここまで見てきたmanifestとは別に、それと対になるattestation manifestという補足情報を表すmanifestも含まれていました。
そのうちSBOMには、イメージに入っている、またはビルドに使ったソフトウェアの一覧が記録されています。provenanceにはイメージがどうビルドされたかが記録されています。
これらはplatformがunknown/unknownになっています。また、annotationのvnd.docker.reference.typeがattestation-manifestになっていることからも識別可能です。
dockerdがレジストリからイメージを取得する
docker pullの実行時、dockerコマンド(docker CLI)は自分でイメージを取得するわけではなく、Docker Engine本体のデーモンであるdockerdのAPIを呼び出します。
デーモン(daemon)・・・裏で動き続けて要求を待つプロセス。dockerdも、次に登場するcontainerdも、名前の最後にデーモンを表す「d」が付いています。
docker CLIからの要求を受けたdockerdは、レジストリと通信してファイルを取得します。取得したファイルはcontainerdに送られ、containerdがディスクに保存します。containerdはイメージの保管と、コンテナの作成・起動・停止を行うデーモンです。
このときdockerdは、containerdが提供するクライアント用ライブラリを使って、containerdと通信します。
取得したファイルの保存先
取得したファイルは、containerdがレジストリから受け取った形のままディスクに保存します。gzipで圧縮されているレイヤーも、この時点では展開しません。保存時のファイル名には、そのファイルのdigestからsha256:を除いた16進数の文字列が使われます。
indexには各manifestのdigestが、manifestにはconfigと各レイヤーのdigestが記録されています。そのため、indexのdigestが同じなら、そこから参照されるmanifest、config、レイヤーもすべて同じ内容です。
自分のプラットフォームのmanifestに関わるファイルだけが取得される
実際にpullしてみると、containerdが保存していたのは、indexと、arm64向けのmanifest、config、layerのみでした。標準では、indexの中から自分のプラットフォームのmanifestだけを選んで取得するため、ほかの7つのプラットフォーム用のmanifestは保存されません。
また、内部的には、arm64のmanifestと対になるattestation manifestと、その先のSBOMとprovenanceも一緒に取得していました。

ここまでで、イメージのindex、manifest、config、layerが、containerdによってディスクに保存されました。ここではまだ、layerは圧縮されたtarのままであることに留意してください。
2. レイヤーを展開する
1章で取得したレイヤーファイルは、まだgzipで圧縮されたtarのままです。ディレクトリに展開する必要があります。
展開先のディレクトリを管理するのは、containerdの中のsnapshotterというコンポーネントです。snapshotterには複数の方式がありますが、この記事の動作環境ではoverlayfsという方式が使われていました。
展開先のディレクトリ
展開はpullを実行した時点で行われ、io.containerd.snapshotter.v1.overlayfs/snapshotsの下に、レイヤーごとに1つずつ展開先のディレクトリが作られます。
postgres:16では、1つ目のレイヤーのディレクトリにOSの基本のディレクトリ(bin、boot、dev、etcなど)が入っていました。2つ目以降のディレクトリに入っているのは差分ファイルです。具体的には、そのレイヤーで追加や更新したファイルと、ファイルを削除したことを表す特殊なファイルが展開されます。この特殊なファイルについては、この章の後半で紹介します。
どのプログラムがレイヤーを展開するか
レイヤーを展開する役割を担うのはcontainerdですが、内部ではsnapshotterとdiffという2つのコンポーネントが処理を分担します。
まず、保存用ディレクトリの準備をするのはsnapshotterです。diffはtarアーカイブを受け取り、ファイルシステムの差分をディレクトリに反映します。その後、snapshotterが完了時のコミットを行います。
これらの処理を呼び出すのは、dockerd内で使われているcontainerdライブラリです。
なお、ディレクトリのマウントの指定もsnapshotterが行います。ただしoverlayFS(後述)で2層目以降のレイヤーを展開するケースではマウントを実行せず、準備したディレクトリへ直接解凍が行われます。
レイヤーでファイルを削除したときの扱い
レイヤーは差分なので、前のレイヤーにあったファイルの削除も取り扱います。消したファイルはレイヤー上でどう取り扱われるのかを、次のDockerfileで実際にイメージを作って確かめました。
FROM alpine:3.20 RUN echo secret > /secret.txt RUN rm /secret.txt
echoのRUNでできたレイヤーにはsecret.txtが入りました。後のレイヤーで削除しても、前のレイヤーのファイルは削除されません。Dockerfileで削除したファイルも、イメージからは取り出せるということです。
一方、rmのRUNでできたレイヤーには、secret.txtを消したことを表す特殊なファイルが入っていました。このファイルはwhiteoutと呼ばれます。レイヤーのtarアーカイブの中では、削除したファイルの名前の前に.wh.を付けたファイル名で記録されます。今回は.wh.secret.txtとして記録されていました。
レイヤーを展開する際には、containerdは、whiteoutをまた別の形の特殊なファイルに書き換えます。デバイス番号が0, 0のキャラクタデバイスです。キャラクタデバイスとは本来、端末などの装置をファイルとして読み書きできるようにする特別なファイルですが、デバイス番号の0, 0はどの装置にも割り当てられない番号なので、このファイルはどの装置も指しません。
少し話を先取りすると、次の第3章では、OverlayFSと呼ばれる、複数のディレクトリを重ねて1つに見せるLinuxカーネルの機能が登場します。OverlayFSは、上の層にこの0, 0のキャラクタデバイスを見つけると、下の層のディレクトリにある同じ名前のファイルを非表示にします。こうしてコンテナのファイルシステム上でファイルの削除が反映されます。
さて、話の流れを元に戻します。

ここまでで、各レイヤーが1つずつ別のディレクトリに展開されました。ただ、ディレクトリはレイヤーごとに分かれたままです。
3. レイヤーを重ねてファイルシステムを作る
ここからはdocker runについての話になります。
2章で展開したレイヤーは、レイヤーごとに別のディレクトリに入っています。しかしコンテナからは、これが1つのファイルシステムに見えなければいけません。そこで、先ほど出てきたOverlayFSを使い、ディレクトリを重ねて1つに見せます。
ここで重ねるのは、次の3種類のディレクトリです。
- 2章で、コンテナイメージのレイヤーを展開したディレクトリ
- コンテナごとの-initの層のディレクトリ
- コンテナが書き込む層のディレクトリ
このうち、一番目を除いた2つは、イメージには含まれないコンテナごとのディレクトリです。
dockerdがディレクトリを重ねる
まずsnapshotterがコンテナの書き込み先となるディレクトリを作成し、その後dockerdにマウントの指示をします。
それを受けてdockerがOverlayFSを使って3種類のディレクトリをマウントします。マウント先は/var/lib/docker/rootfs/overlayfs/<コンテナID>です。これはcontainerdの保存先ではなく、dockerdの保存先である/var/lib/dockerの下でした。
コンテナの作成完了時点では、このマウントは解除されています。コンテナを起動すると、マウントされます。
ディレクトリの重なり方
2章のイメージから作ったコンテナのマウントは、次のようになっていました。<snapshots>は、2章で見た展開先のio.containerd.snapshotter.v1.overlayfs/snapshotsです。
lowerdir=<snapshots>/8211/fs コンテナごとの-initの層
<snapshots>/8195/fs RUN rm /secret.txt のレイヤー
<snapshots>/8194/fs RUN echo secret > /secret.txt のレイヤー
<snapshots>/8111/fs alpine:3.20のレイヤー
upperdir=<snapshots>/8212/fs コンテナが書き込む層
workdir=<snapshots>/8212/workディレクトリが重なる順は、下から、イメージのレイヤー、コンテナごとの-initの層、コンテナが書き込む層です。
-initの層を見てみると、/etc/hosts、/etc/resolv.conf、/etc/hostname、/.dockerenvなどの空のファイルと、/dev配下の空のディレクトリなどが入っていました。
このうち/etc/hosts、/etc/resolv.conf、/etc/hostnameの3つは、起動時に別のファイルをマウントするための場所です。Dockerはコンテナごとにこれらの設定ファイルを用意し、コンテナの起動時に、それぞれ対応するパスにマウントします。その結果、コンテナからは、空のファイルではなく、Dockerが用意したコンテナごとの設定ファイルの内容が見えるようになります
また、同じイメージからもう1つコンテナを作ってみると、別のディレクトリになったのは-initの層と書き込む層だけで、イメージのレイヤーは同じディレクトリを使っていました。同じイメージから作ったコンテナどうしは、イメージのレイヤーを共有しているということです。
OverlayFSの挙動を確かめる
OverlayFSは、lowerdirに指定されているディレクトリを読み取り専用で重ねる一方、書き込みについてはupperdirに指定したディレクトリに反映します。
本当にそうなるのかを、動いているコンテナで、ファイルを読み込み/書き込み/削除をして確かめました。
- /etc/os-releaseを読み込み: upperdirには何も増えない
- /etc/motdに1行追加: upperdirにetc/motdのコピーができ、そこに変更が入った
- /etc/issueを削除: upperdirに、2章で見た削除ファイルの形でetc/issueが置かれた
書き込み時も削除時も、lowerdirにあるコンテナイメージのレイヤーのetc/motdとetc/issueは元のままでした。同じイメージから作った別のコンテナからも、元のまま見えました。この結果から、書き込みや削除はupperdirに行われることが検証できました。

ここまでで、コンテナのルートになるファイルシステムができあがりました。しかし、まだ、その上で動くプロセスはありません。
4. プロセスを隔離して起動する
ファイルシステムができたので、最後はコンテナのプロセスを起動します。
起動の順序
ここで新しく出てくるのが、runcとcontainerd-shimです。2つの関係を、次の図にまとめました。

runc
runcは、OCIの仕様に沿って、Linuxの上でコンテナを作成・起動するコマンドラインツールです。Dockerが標準の設定でコンテナを動かす時は、このruncが使われます。1章で見たイメージの形式はOCIのImage Specで決まっていましたが、コンテナの動かし方もOCIのRuntime Specで決まっており、runcはそれにしたがって実装されています。
runcに渡す設定は、bundleと呼ばれるディレクトリにまとめられます。その中のconfig.jsonには、実行するプログラムなどの設定と、コンテナのルートファイルシステムの場所を示すroot.pathが書かれています。
root.pathには絶対パスも指定でき、今回の環境では、3章でdockerdがマウントした、bundleの外にあるディレクトリを指していました。つまり、3章で用意したファイルシステムを、4章のruncに引き渡す役目を果たすのが、このconfig.jsonです。
containerd-shim
containerdはruncを直接呼び出すのではなく、containerd-shimというプロセスを介して呼び出します。Dockerの標準設定では、コンテナごとに1つのcontainerd-shimが動きます。
containerd-shimには複数の種類があり、runcを使うときにはcontainerd-shim-runc-v2が使われます。containerdはこのプロセスが待ち受けるソケットを通して、コンテナの作成・起動・停止を指示します。
このように間にcontainerd-shimを挟むことで、OCIのRuntime Specに沿った別のランタイムが組み込みやすくなります。また、containerd-shimは、後述するようにコンテナのプロセスの親となり、コンテナの実行中も動き続けます。
実際に動いた順序とプロセスの親子関係
docker runの実行中に動くプロセスをカーネルのトレースで記録しました。すると、containerdがcontainerd-shimを起動し、containerd-shimがruncを呼んで、runcがコンテナのプロセスを作る様子を見ることができました。
詳しく見ると、containerd-shimは2段階で起動していました。最初のcontainerd-shimは、指示を待ち受ける別のcontainerd-shimを起動して終了します。その後、containerdとの通信やruncの呼び出しは、待ち受け用のcontainerd-shimが担当します。
このcontainerd-shimは、runcをrunc createとrunc startの2回に分けて呼び出します。
- runc create:コンテナのプロセスを作成する
- runc start:作成したプロセスに、コンテナのコマンドの実行を開始させる
作成されたプロセスは、同じプロセスID(PID)のまま、実行するプログラムをコンテナのコマンドに切り替えていました。それぞれのruncコマンドは、処理が完了すると終了します。
runcが終了すると、コンテナのプロセスは親プロセスが終了した状態になります。親が先に終了して残された子プロセスは、孤児プロセス(orphan process)と呼ばれます。孤児プロセスは、OSが決めた特別なプロセスに引き取られます。Linuxでは通常pid 1のプロセスがその役目です。
ところが、containerd-shimは、subreaperというLinuxの機能を使用し自分の子孫の孤児プロセスを引き取る設定になっています。そのためruncが終わって孤児になったコンテナのプロセスは、pid 1ではなく、containerd-shimの子に付け替えられます。
runcがプロセスに設定する隔離と制限
runcは、Linuxカーネルの機能を組み合わせてプロセスを隔離し、制限します。どう制限するかを決めるのはdockerdです。どのnamespaceを分けるか、どのcapabilitiesを渡すか、どのseccompのプロファイルを使うかをdockerdが組み立ててcontainerdに渡し、containerdがそれをbundleのconfig.jsonに書き出します。runcは、そのとおりにカーネルの機能を使います。
| 機能 | 何をするか | 実行例で見たこと |
|---|---|---|
| namespace | プロセスから見えるものを分ける | pid、net、mnt、uts、ipc、cgroupの6つがVMと分かれていた。userとtimeは分かれていなかった |
| cgroups | 使える量を制限する | --memory、--pids-limit、--cpusで指定した量が、上限として設定された。付けなければ、上限は設定されなかった |
| capabilities | rootの権限を細かく分け、一部だけ渡す | コンテナに渡された権限は、VMのpid 1より少なかった |
| seccomp | 呼べるシステムコールを絞る | コンテナはSeccomp: 2で、フィルタが掛かっていた |
コンテナの中から見てみると、3章で3種類のディレクトリを重ねて作ったファイルシステムがルートディレクトリ(/)に見えていました。

こうして、コンテナのプロセスが隔離された状態で動き出しました。その親は、containerd-shimです。
まとめ
docker pullとdocker runの裏側では、次の順に処理が進みます。
- dockerdがレジストリから、自分のプラットフォームのmanifestとそのリンク先のファイルを取得し、containerdに渡す。containerdは、受け取ったファイルをディスクに保存する
- 展開先のディレクトリをsnapshotterが作り、containerdがレイヤーを1つずつ展開する。
- dockerdが、展開したレイヤーのディレクトリと、コンテナごとのディレクトリを、OverlayFSで重ねてマウントする
- containerd-shimがruncを呼び、runcがnamespaceやcgroupsなどでプロセスを隔離して起動する。runcは終了し、コンテナのプロセスはcontainerd-shimの子になる
プログラムごとに見ると、役割は次のように分かれていました。
| プログラム | この記事で見た役割 |
|---|---|
| dockerd | レジストリからファイルを取得する。ディレクトリを重ねてマウントする。プロセスの隔離と制限をどう設定するかを決める |
| containerd | 取得したファイルを保存する。レイヤーを展開する。containerd-shimを起動する。config.jsonを書き出す |
| snapshotter | 展開先のディレクトリと、コンテナが書き込む層のディレクトリを作る |
| containerd-shim | runcを呼ぶ。動いているコンテナのプロセスの親になる |
| runc | カーネルの機能でプロセスを隔離し、コンテナのプロセスを作る |
コンテナの正体は、ディレクトリを重ねて作ったファイルシステムの上で、カーネルの機能で隔離されて動く、普通のプロセスだということがわかりました。

書いた人:金子




















