Webエンジニアが自作コンテナを作って理解したDockerの正体
普段、Webアプリケーションの開発やデプロイで当たり前のように使っている docker run や docker build。
しかし、「コンテナの中で何が起きているのか?」と聞かれると、ブラックボックスに感じているWebエンジニアも多いのではないでしょうか。
「Dockerは重い仮想マシン(VM)ではなく、ただのプロセスである」
この言葉を頭では理解していても、実際に実感するのは難しいものです。
そこで本記事では、Linuxの標準機能(chroot、unshare、cgroups、OverlayFS)だけを使って、シェルスクリプト数行で自作コンテナを作成します。
この記事を読み終える頃には、普段使っているDockerコマンドの裏側でLinuxカーネルが何をしているのかが地続きで理解できるようになります。
1. コンテナの全体像:Dockerは「ただ制限されたプロセス」
まず結論から言うと、Dockerという独立したOSやハイパーバイザが存在するわけではありません。
Dockerの正体は、Linuxカーネルが持つ隔離・制限機能を組み合わせて、普通のプロセスを「あたかも独立した環境で動いているかのように誤認させている仕組み」です。
具体的には、以下の4つのLinuxカーネル機能がDockerの骨組みを作っています。
-
chroot(またはpivot_root):ファイルシステムの隔離(見せかけるルートディレクトリを変更) -
Namespaces(unshare):リソースの隔離(プロセスID、ネットワーク、ホスト名などを分離) -
cgroups:リソースの制限(CPUやメモリの使用上限を設定) -
OverlayFS:イメージのレイヤー管理(読み取り専用層と書き込み層の重ね合わせ)
これらを1つずつ手動で実行し、最後に1つのシェルスクリプトに組み上げてみましょう。
※ 以下の実験は Linux 環境(Ubuntu 22.04 LTS / 24.04 LTS 等)で
sudo権限を持って実行してください。
2. STEP 1:chroot でファイルシステムを閉じ込める
コンテナがホストOSのファイル(/etc/shadow や他のユーザーのデータなど)を見られないようにする最初の仕組みが chroot(change root)です。
原理
プロセスに対して「ここがあなたのルートディレクトリ(/)ですよ」と教え込み、指定したディレクトリより外側の親ディレクトリへアクセスできないようにします。
実験
まずはコンテナのルートファイルシステム(rootfs)となるディレクトリを用意します。今回は軽量な Alpine Linux のファイルシステムをダウンロードして使います。
# 作業用ディレクトリを作成
mkdir -p ~/my-container/rootfs
cd ~/my-container
# Alpine Linux の mini rootfs を取得して展開
curl -sSL https://dl-cdn.alpinelinux.org/alpine/v3.19/releases/x86_64/alpine-minirootfs-3.19.1-x86_64.tar.gz | tar -xz -C ./rootfs
これで ./rootfs の中に /bin, /etc, /lib などのLinuxディレクトリ構造が整いました。
次に、chroot を使ってこのディレクトリをルートとしてシェルの /bin/sh を起動してみます。
sudo chroot ./rootfs /bin/sh
プロンプトが切り替わったら、ファイル構造を確認してみましょう。
/ # ls /
bin dev etc home lib media mnt opt proc root run sbin srv sys tmp usr var
/ # exit
ホストOS(Ubuntu等)のファイルは見えず、Alpine Linux のディレクトリ構造だけが見える状態になりました!これがコンテナのファイル隔離の第一歩です。
3. STEP 2:unshare(Namespaces)でプロセスやホスト名を隔離する
chroot だけでは不十分です。実は chroot 内で ps aux コマンドを実行したりホスト名を確認したりすると、ホストOS側の全プロセスやホスト名がそのまま見えてしまいます。
これを解決するのが Linux Namespaces(名前空間) です。
原理
Namespacesは、Linuxの各種オブジェクト(PID、ネットワーク、マウントポイント、ホスト名など)の参照テーブルをプロセス単位で独立させる機能です。
主なNamespaceの種類:
-
PID Namespace:プロセスIDの隔離(コンテナ内のメインプロセスを
PID 1に見せる) - UTS Namespace:ホスト名・ドメイン名の隔離
- MNT Namespace:マウントポイントの隔離
- NET Namespace:ネットワークインターフェース・ルーティングテーブルの隔離
実験
Linux標準の unshare コマンドを使うと、新しいNamespaceを生成してプロセスを実行できます。
# PID名前空間、UTS(ホスト名)名前空間、Mount名前空間を隔離してchrootを実行
sudo unshare --pid --uts --mount --fork chroot ./rootfs /bin/sh
コンテナ内でホスト名を変更し、プロセスの状態を確認してみます。
# コンテナ内でホスト名を変更してみる
hostname my-mini-container
# プロセス用仮想ファイルシステム proc をマウントしてpsを実行
mount -t proc proc /proc
ps aux
出力結果:
PID USER TIME COMMAND
1 root 0:00 /bin/sh
3 root 0:00 ps aux
ホストOS側には数百のプロセスが走っているにもかかわらず、コンテナ内からは自分が PID 1 として動いているように見えています。
(コンテナを抜けた後、ホストOS側のホスト名が変わっていないことも確認できます)
4. STEP 3:cgroups でリソースの暴走を防ぐ
環境を隔離できても、コンテナ内のプロセスがホストのメモリを全消費してクラッシュさせてしまっては困ります。
リソース使用量の上限を設けるのが cgroups (Control Groups) です。
原理
Linux kernelは /sys/fs/cgroup/ という仮想ファイルシステムを通じてcgroupを管理しています。ここにディレクトリを作成し、制限値を書き込むことでプロセスグループのCPUやメモリを制限できます。
実験(cgroup v2 の例)
コンテナのメモリ使用量を 100MB に制限してみます。
# 1. 新しい cgroup グループ(ディレクトリ)を作成
sudo mkdir -p /sys/fs/cgroup/my-container
# 2. メモリ上限値を 100MB (104,857,600 バイト) に設定
echo "104857600" | sudo tee /sys/fs/cgroup/my-container/memory.max
# 3. 現在のシェル(PID)をこの cgroup に登録
echo $$ | sudo tee /sys/fs/cgroup/my-container/cgroup.procs
これで、このシェルおよびここから派生する子プロセス(コンテナ内プロセス)は、合計で100MB以上のメモリを消費できなくなりました。100MBを超えるとOOM Killer(Out of Memory)が発動し、プロセスが安全に停止します。
5. STEP 4:OverlayFS でイメージのレイヤー構造を再現する
docker pull や docker build をした際によく見る「レイヤー(Layer)」。
なぜDockerはイメージを重ね合わせ、高速に起動できるのでしょうか?その秘密が OverlayFS です。
原理
OverlayFSは、複数のディレクトリを1つのディレクトリツリーとして重ね合わせて見せる特殊なファイルシステムです。
-
lowerdir:読み取り専用層(ベースイメージ:Docker Image) -
upperdir:書き込み可能層(コンテナ固有の変更差分) -
workdir:OverlayFSが内部処理に使う作業用領域 -
merged:上記を重ね合わせてユーザーに見せる領域(Docker Containerの見た目)
[ merged ] <--- コンテナから見えているファイルシステム
├── upperdir (書き込み可能: コンテナ内での変更分)
└── lowerdir (読み取り専用: ベースイメージ)
実験
cd ~/my-container
# 必要なディレクトリを作成
mkdir -p lower upper work merged
# lower (ベースイメージ側) にファイルを作成
echo "Hello from Base Image" > lower/base.txt
# OverlayFS をマウント
sudo mount -t overlay overlay -o lowerdir=lower,upperdir=upper,workdir=work merged
# merged の中身を確認
ls merged/
# -> base.txt が見えている
# merged の中で新しいファイルを作成してみる
echo "Hello from Container" > merged/container.txt
# 実体のディレクトリを確認する
ls lower/
# -> base.txt のみ(ベースは変更されない!)
ls upper/
# -> container.txt (差分だけが upper に保存されている!)
コンテナ内でファイルを削除したり書き換えても、元となる lowerdir(イメージ)は一切傷つきません。差分はすべて upperdir に蓄積されます。
これが docker run が一瞬で起動し、いくら破棄しても元のイメージが無傷である理由です。
6. STEP 5:自作コンテナ起動スクリプト my-docker.sh
ここまでに学んだ chroot, unshare, cgroups, OverlayFS をすべてガッチャンコして、自分だけのミニDockerを作ってみましょう!
以下のスクリプトを my-docker.sh として保存します。
#!/bin/bash
set -e
# 1. ディレクトリ構成のセットアップ
BASE_DIR="$(pwd)"
ROOTFS="$BASE_DIR/rootfs"
UPPER="$BASE_DIR/upper"
WORK="$BASE_DIR/work"
CONTAINER_ROOT="$BASE_DIR/merged"
mkdir -p "$UPPER" "$WORK" "$CONTAINER_ROOT"
# 後処理用のクリーンアップ関数
cleanup() {
echo "Cleaning up..."
sudo umount "$CONTAINER_ROOT/proc" 2>/dev/null || true
sudo umount "$CONTAINER_ROOT" 2>/dev/null || true
echo "Done."
}
trap cleanup EXIT
# 2. OverlayFS でイメージ層と書き込み層を合体
echo "[1/3] Mounting OverlayFS..."
sudo mount -t overlay overlay -o lowerdir="$ROOTFS",upperdir="$UPPER",workdir="$WORK" "$CONTAINER_ROOT"
# 3. cgroups でメモリ制限 (100MB)
echo "[2/3] Setting up cgroups (Memory limit: 100MB)..."
CGROUP_DIR="/sys/fs/cgroup/my-container"
sudo mkdir -p "$CGROUP_DIR"
echo "104857600" | sudo tee "$CGROUP_DIR/memory.max" > /dev/null
# 4. コンテナ起動 (unshare + chroot)
echo "[3/3] Starting Container..."
echo "----------------------------------------"
# 隔離空間内で proc をマウントしてシェルを実行
sudo unshare --pid --uts --mount --fork \
bash -c "
# 自身を cgroup に登録
echo \$$ | sudo tee $CGROUP_DIR/cgroup.procs > /dev/null
# コンテナのルートに移動して proc をマウント
chroot $CONTAINER_ROOT /bin/sh -c '
hostname my-custom-container
mount -t proc proc /proc
/bin/sh
'
"
実行してみる
chmod +x my-docker.sh
./my-docker.sh
実行すると、わずか数十行のスクリプトによって、メモリ制限が効いており、ファイル変更の差分管理ができ、プロセスとホスト名が隔離された完全なミニコンテナが立ち上がります!
7. まとめ:Docker(runc)の正体と対比表
私たちが普段使っている Docker の内部アーキテクチャは、以下の階層構造になっています。
[ Docker CLI ] (例: docker run)
│
[ Docker Daemon (dockerd) ] (API受取、イメージ管理)
│
[ containerd ] (コンテナのライフサイクル管理)
│
[ runc ] <--- ★低レイヤコンテナランタイム(本記事で手動で行った処理を実装)
│
[ Linux Kernel ] (Namespaces / cgroups / chroot / OverlayFS)
今回作成した my-docker.sh は、まさに runc が裏で行っている処理そのもの です。
実際の runc(Go言語で記述)も、CgoやLinuxシステムコール(syscall.Unshare, syscall.Chroot など)を使ってカーネルに指示を出しているに過ぎません。
最後に、Dockerの概念とLinuxカーネル機能の対比表を整理しておきます。
| Dockerの概念・機能 | 裏で動いているLinuxの仕組み | 役割 |
|---|---|---|
docker run (隔離環境) |
unshare (Namespaces: PID, UTS, MNT, NET etc.) |
プロセス、ネットワーク、ホスト名等の空間分離 |
| ルートファイルシステム |
chroot / pivot_root
|
プロセスが見られるファイルツリーの決定 |
docker pull / レイヤー |
OverlayFS |
読み取り専用層と書き込み層の重ね合わせ |
**-m 512m / --cpus**
|
cgroups (Control Groups) |
CPU・メモリ等の利用上限設定 |
-p 8080:80 |
Network Namespace + veth + iptables
|
仮想NICのペア作成とポートフォワーディング |
「Dockerは魔法の技術」ではなく、「Linuxカーネルの堅牢な隔離機能をきれいに使いやすくラップしたオーケストレータツール」であることが実感できたのではないでしょうか。
この裏側の仕組みを知っておくと、コンテナのセキュリティ設計、パフォーマンスチューニング、あるいはトラブルシューティング時の解像度が劇的に上がります。ぜひお手元のLinux環境で遊んでみてください!
