箱に詰めれば、世界のどこでも同じに動く
안녕하신게라!パナソニック コネクト株式会社クラウドソリューション部の加賀です。
前回「権限と隔離の基本の"キ"」では、su・sudo・chroot から Namespace と cgroups まで、Linuxが積み重ねてきた隔離の歴史をたどりました。「見え方を隔離する」「使用量を制限する」という、独立した箱を作るための部品はカーネルにすべて揃った、というのが前回の到着点です。
ただ、部品が揃うことと、誰でも使えることの間には距離がありました。unshare や /sys/fs/cgroup を手で組み合わせるのは職人芸で、しかも chroot の無人島キャンプと同じように、中で動かす一式(実行ファイルと依存ライブラリ)は自分で担いで持ち込まなければならない。せっかくマジックミラーの部屋をOSが用意できても、ユーザが荷造りを毎回手作業でやっていては日常使いの道具になりません。
そこに現れたのが Docker です。今回は、Dockerがこの部品をどう組み立てているのか(仕組み)と、それでいったい何が変わったのか(革新性)を見ていきます。
docker run と打った瞬間、何が起きているのか
まず押さえておきたいのは、Dockerは隔離を発明していないということです。
プロセスを閉じ込める仕事そのものは、前回登場したカーネルの機能が担っています。
Dockerがやっているのは、その手続きを段取りよく代行することです。
docker run -d nginx と打ったとき、裏では概ねこんなバケツリレーが起きています。
-
dockerコマンド(CLI)が、入力をAPIリクエストに変換してdockerd(Dockerのデーモン)へ渡す -
dockerdが、必要なイメージをローカルに持っているか確認し、なければレジストリから取得する -
containerdが、イメージを展開してコンテナの生涯管理(起動・停止・監視)を引き受ける -
runcが、実際に手を動かす。Namespaceを作り(clone/unshare)、cgroupsに上限を書き込み、pivot_rootでイメージの中身をそのプロセスの/に据えて、指定のコマンドを起動する - 以降、コンテナの中のプロセスはホストから見れば単なる1プロセスとして走り続ける
最後の5番が、コンテナの理解でいちばん大事なところです。VMのような「仮想のマシン」はどこにも無く、あるのはマジックミラーの部屋(Namespace)で視界を塞がれ、cgroupsで食事量を制限された、ごく普通のプロセスだけ。だから、ホスト側から ps で普通に見えます。
$ docker run -d --name demo alpine sleep 300
$ docker exec demo ps -ef
PID USER TIME COMMAND
1 root 0:00 sleep 300 # ← 中から見れば、自分がPID 1
$ ps -ef | grep "sleep 300"
root 31245 31220 0 10:12 ? 00:00:00 sleep 300 # ← 外から見れば、ただのプロセス
$ docker rm -f demo # 確かめたら、箱は片付けておく
同じ1つのプロセスが、中から見れば PID 1、外から見れば PID 31245。この二重の見え方がLinuxの隔離機能だと判れば、コンテナに対するモヤモヤしたイメージは晴れましたでしょうか1。
【コラム】コンテナの中のrootは、ホストのrootなのか?
「外から見ればホストのただの1プロセス」と分かると、途端に気になってくることがあります。docker run したコンテナの中で id を叩くと、たいてい uid=0(root) と返ってきます。前回さんざん「rootは神様、居座らせるな」という話をしましたが、これはホストのrootなのでしょうか。
初期設定のままだと、答えは残念ながらほぼYesです。見えるものはNamespaceで隔離されていても、UID 0 は UID 0。だからホストのディレクトリを -v /:/host のように渡してしまえば、中のrootでホストのファイルを好きに書き換えられます。--privileged を付けた箱なら、もはや隔離はほとんど無いに等しい。「コンテナだから安全」ではないのです。
同じ理屈で気をつけたいのが -v /var/run/docker.sock:/var/run/docker.sock です。このソケットに触れるということは「好きなコンテナを --privileged で起動できる」と同義で、実質ホストのrootを渡しているのと変わりません。CIのコンテナの中からDockerを叩く構成でよく見かける形なので、頭の片隅に置いておいてください。
そこで使うのが、前回の表にも出てきた User Namespace です。「中のUID 0 を、ホストのUID 100000 に対応させる」という写像を作れば、中では神様でも外に出れば無力な一般ユーザになります。
Dockerの userns-remap、デーモン自体を一般ユーザで動かす Rootless モード、あるいは後述の Dockerfile に USER app と書いて中でもrootを使わない。どれも前回の「必要な分だけ借りて、すぐ返す」の延長線上にあるお作法です。
なぜVMではなく、コンテナなのか
「完全に独立した環境がほしいなら、昔からあるVMwareやVirtualBoxのような仮想マシン(VM)でいいのでは?」。インフラを学び始めると必ず抱く疑問です。ここには、はっきりとしたアーキテクチャの違いがあります。
仮想マシン(VM)は、物理サーバの上に仮想化ソフト(ハイパーバイザ)を敷き、その上にゲストOS(別のLinuxやWindows)を丸ごとインストールします。1軒の家の敷地に、柱も屋根もまったく別の家をもう1軒建てるようなもの。起動に数十秒から数分かかり、何もしていなくてもメモリをGB単位で消費します。堅牢な代わりに、重い。
コンテナには、そのゲストOSがありません。先ほど見たとおり、実体はホストのカーネルの上で動く裸のプロセスです。1軒の家を、前回のマジックミラー(Namespace)と、部屋ごとに決めた広さ・電気の上限(cgroups)で個室に区切っただけ。だから起動はミリ秒から数百ミリ秒で終わり、余分なOSを抱えないぶんオーバーヘッドもほぼありません。
軽い代わりに、カーネルは全コンテナで共有している、つまりVMほど完全な分離ではないという違いも押さえておきましょう。カーネルに脆弱性があれば全部屋が影響を受けますし、ホストと種類の違うOSは動かせません。だからこそ、外部から持ち込まれた信頼できないコードを走らせる用途では、いまでもVMや、VMとコンテナの中間にあたる技術(システムコールを自前で受け止める gVisor、1コンテナを軽量VMで包む Kata Containers や Firecracker など)が選ばれます。
【コラム】WindowsでLinuxコンテナが動く不思議(WSL2)
「コンテナはホストOSのカーネルを共有するはず。なのに、なぜWindowsのDocker DesktopでLinuxのコンテナが動くの?」
もっともな疑問です。結論から言うと、そのままでは動きません(WindowsとLinuxはカーネルが別物だからです)。WindowsでDockerを動かすとき、裏では WSL2(Windows Subsystem for Linux 2) が、本物のLinuxカーネルをVMとして起動しています。Dockerはその「裏で動いているLinuxカーネル」を共有して、Linuxコンテナを走らせているわけです。
「コンテナはカーネルを共有する」という原則は、ちゃんと守られています。逆に言えば、WindowsのDockerが妙にメモリを食うのも、裏で本物のVMが動いているからです。
Dockerが起こした、本当の革命
さて、ここまでの話だと「Dockerはカーネルの機能を呼ぶ便利ツール」で終わってしまいます。
実際、コンテナを動かす技術そのもの(LXCなど)はDockerより前から存在していました。
では、なぜ Docker はこれほど世界を席巻できたのか。答えは箱の作り方ではなく、箱の中身をイメージというひとつのパッケージにまとめ、世界中で配れるようにしたことにあります。
前回やり残した「大荷物を毎回手作業で用意したくない」という課題への答えです。
Dockerfileで、環境構築を手順書にする
Dockerでは、Dockerfile というテキストファイルに「土台にするイメージ」「入れるライブラリ」「置くソースコード」を手順として書きます。
FROM python:3.13-slim # 土台となる一式(ユーザランド)を持ち込む
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt # 依存ライブラリを詰め込む
COPY . .
CMD ["python", "main.py"] # 箱を起動したときに走るプロセス
$ docker build -t myapp:1.0 . # 荷造りリストどおりに詰めて、イメージにする
$ docker run -d -p 8080:8000 myapp:1.0 # 中の8000番を、ホストの8080番へ繋ぐ
これは構造を見せるための例なので、そのままビルドするなら main.py と requirements.txt が必要です。手元で動かして確かめたいだけなら、CMD を ["python", "-m", "http.server", "8000"] に変えると、依存なしで8000番が開きます。
前回、無人島キャンプの荷造りのように ldd で調べたライブラリを1つずつ運び込んでいた、あの作業を思い出してください。それがたった数行の宣言に置き換わりました。環境構築という属人的な職人芸が、レビューもできてGitで履歴も追える、ただのコードになったわけです。
アプリとDBのように箱が複数になっても、発想は変わりません。compose.yaml に「どの箱を、どの設定で、どう繋ぐか」をまとめて書き、docker compose up で一括して立ち上げます。1つの箱の中身を手順書にしたのと同じやり方が、箱の組み合わせにも広がっただけです。
【コラム】UbuntuのイメージをRocky Linuxのホストで動かせるのはなぜ?
FROM で「土台となる一式」を持ち込む、と書きました。ここで湧く疑問がこれです。「カーネルを共有するなら、なぜRHEL系のサーバでUbuntuのコンテナが動くの?」。
答えは、コンテナイメージにカーネルは入っていないからです。ubuntu や alpine のイメージに詰まっているのは、bash や ls といったコマンド群、共有ライブラリ、ディレクトリ構造といったユーザランド(カーネルより上の一式)だけ。前回 chroot の無人島へ担いでいった、あの大荷物がそのままイメージになったと思ってください。
だから中で cat /etc/os-release すると "Ubuntu" と答えますが、uname -r で返ってくるカーネルのバージョンはホストのものです。ディストリビューションの違いとは、要はユーザランドの品揃えの違いなのだと分かる、良い実験でもあります。
とはいえ「ユーザランドさえ持ち込めば何でも動く」わけではありません。中のライブラリ(glibcなど)は、ホストのカーネルが持っていないシステムコールを要求することがあります。古いカーネルのサーバに最新のイメージを持っていくと、起動した瞬間に見慣れないエラーで落ちる、というのはこれが原因です。カーネルを共有するというのは、その世代に縛られるという意味でもあるわけです。
イメージはレイヤの積み重ね
もうひとつDockerが賢いのは、イメージを1枚の塊ではなくレイヤ(層)の積み重ねとして持つところです。Dockerfile の命令1行がおおよそ1レイヤになり、それを下から順に重ねたものがイメージになります。厳密には、ファイルを書き換える RUN・COPY・ADD が中身のある層を作り、WORKDIR や CMD は設定だけを記録する層になります(次の出力で 0B の行が並ぶのは、そのためです)。
$ docker image history myapp:1.0 # ※列と表記は抜粋・簡略化
# 上が新しい層。Dockerfileの並びとは上下が逆さまに出る(下から読むと手順書の順番)
IMAGE CREATED BY SIZE
a1b2c3d4e5f6 CMD ["python" "main.py"] 0B
<missing> COPY . . 12kB
<missing> RUN pip install -r requirements.txt 48MB
<missing> COPY requirements.txt . 214B
<missing> WORKDIR /app 0B
<missing> python:3.13-slim のレイヤ群 125MB
この構造が、3つの嬉しさを生みます。
-
ビルドが速い
変更がなければレイヤのキャッシュが再利用されます。このDockerfileのように、COPY requirements.txt .→RUN pip install→COPY . .の順、つまり「変更頻度の低い依存ライブラリのインストール」を先に、「毎回変わるソースコードのコピー」を後に書くのがコツです。逆にRUN pip installより前にCOPY . .を置くと、ソースを1文字直すだけでその層から下(新しい側)が作り直しになり、毎回pip installからやり直しになる、という定番の落とし穴があります。 -
容量と転送量が減る
同じ土台(例:python:3.13-slim)を使うイメージが10個あっても、共通レイヤはホスト上に1つあれば済みます。配布時も差分のレイヤだけ落ちてきます。 -
起動が軽い
レイヤはすべて読み取り専用で、起動時に書き込み可能な薄い層を1枚上に重ねるだけ(copy-on-write)。だから同じイメージから何十個も箱を立てても、ディスク上でその都度コピーしません2。
この構造が分かると、イメージを小さく保つ定番の手も腹落ちします。マルチステージビルドです。Dockerfile に FROM を2つ書き、コンパイラやSDKを積んだ重い土台で成果物を作ってから、COPY --from=builder で出来上がった物だけを軽い土台へ移す。ビルド道具はイメージに残らないので、容量も、持ち込む脆弱性の数も減ります。
【コラム】箱は使い捨て、データは外に出す
いま出てきた「書き込み可能な薄い層」には、地味に重い性質があります。コンテナが書き込んだ内容はここに溜まるので、コンテナを削除すると一緒に消えるのです。うっかり中でDBを動かしていると、docker rm ひとつでデータが蒸発します。
そのため、コンテナは「いつ捨てても、同じイメージから作り直せば同じ状態になるもの(使い捨て)」として扱い、消えては困るものは外に出すのが作法です。データはボリュームやマウントで外部へ(docker run -v mydata:/var/lib/mysql)、設定は環境変数で外から注入(-e)、そしてログはファイルに書かず標準出力へ吐く。
最後のログの話、以前「UNIX哲学に学ぶ「組み合わせる」思考法」で触れた「プロセスは標準出力に流し、その先は外側が決める」の考え方そのままです。だからコンテナのログは docker logs で拾えますし、その先を監視サービスへ流すのも外側の仕事になります。
50年前の作法が、現代の環境でも効いているのが面白いところです。
なお、外へ出したデータで最初に転ぶのは、たいてい権限です。ホストのディレクトリをそのままマウントすると、中のプロセスのUIDとホスト側のファイル所有者が食い違い、Permission denied を食らいます。前回の脚注で触れた「Linuxが見ているのは名前ではなくUIDという数字」が、そのまま噛みついてくる場面です。docker run -u $(id -u):$(id -g) でUIDを合わせる、Dockerfile の USER と揃える、あるいは所有者をDockerに任せられる名前付きボリュームを使う。開発中のソースを丸ごとマウントする構成で必ず出会うので、覚えておいて損はありません。
レジストリで、詰めた箱を世界に配る
荷造りを終えたイメージは、Docker Hub や Amazon ECR などのレジストリ(イメージの倉庫)に置いて共有できます。逆に docker run nginx と打てば、誰かが用意した完成済みのNginx環境が、手元に数秒で降ってきて起動します。
便利さの裏返しで、これは他人が詰めた荷物を、中身を確かめずに自分のサーバで開ける行為でもあります。以前「AIの悪用とサプライチェーン攻撃:「s1ngularity」の事例紹介」で触れたように、配布物そのものに悪意を仕込む攻撃は現実に起きています。公式イメージや検証済みイメージを選ぶ、nginx:latest ではなくバージョンやダイジェスト(nginx@sha256:...)で固定する、docker scout や Trivy で脆弱性を見てから使う。このあたりを習慣にしておくと、あとで胃の痛い思いをしません。(バージョン固定は忘れがちなので自戒も込めて)
そして、この「手順書」+「レイヤ」+「倉庫」が合わさった結果、システム開発に長年つきまとってきた「私の環境では動いたんですけどね(It works on my machine)」という言い訳が消えました。開発者のPCで詰めた荷物を、そのまま本番サーバへ運んで開けるだけ。
まったく同じユーザランド、まったく同じライブラリが動くからです。
「動く世界」をまるごと箱に封じ、誰にでも渡せるようにした。これがDocker最大の功績です3。
おわりに
今回は、コンテナを日常の道具に変えたDockerを、仕組みと功績の両面から見てきました。
docker run の裏では、dockerd から containerd、runc へとバケツリレーが起き、最後はカーネルのNamespaceとcgroupsでただのプロセスが隔離されているだけでした。
隔離そのものは前回のLinuxの機能で、Dockerが変えたのはむしろ中身の運び方。Dockerfile で環境構築を手順書にし、イメージをレイヤに分けて使い回し、レジストリで世界に配る。この3点セットが「私の環境では動いた」を終わらせました。
蓋を開けてみればカーネルの機能とファイルの配り方の組み合わせなので、覗く道具もごく普通です。docker ps で箱の生死を、docker logs で中のプロセスの言い分を、docker stats でcgroupsの効き具合を、docker exec で中に入って調査する。ホスト側から ps や lsns で見れば、箱の中のプロセスもホストの1プロセスとして見えています。この内と外を行き来できて、箱の成り立ちを知っているかどうかで、「なぜかコンテナが起動しない」「なぜかデータが書けない/消えた」「なぜかビルドが毎回遅い」といった場面での切り分けの初動が、まるで違ってきます。これもまた、押さえておきたい基本の"キ"です。
さて、NTPで時計を合わせ、TLSで相手を確かめ、DNSで住所を引き、TCP/UDPで荷物を届け、プロセスとメモリでそれを捌き、権限と隔離でプロセスを守り、コンテナという箱に詰めて動きを揃える。ここまでで、1台のサーバの上でアプリが安全に動く道具はひとまず揃いました。
しかし本番のシステムでは、「Webの箱を100個」「DBの箱を3個」と大量のコンテナが同時に走ります。そうなると次の問題が出てきます。届いたリクエストを、どの箱に渡せばいいのか。 そして箱が1つ静かに死んだとき、誰がそれに気づいて避けてくれるのか。
その交通整理を担う主役の話、次回「ロードバランサの基本の"キ"(振り分けと負荷分散)」へ続きます。
お断り
記事内容は個人の見解であり、所属組織の立場や戦略・意見を代表するものではありません。
あくまでエンジニアとしての経験や考えを発信していますので、ご了承ください。
-
「なぜ
dockerdとcontainerdとrunc、こんなに層が多いのか」と思うかもしれません。runcはコンテナを作ったら役目を終えて消える設計で、常駐して面倒を見るのはcontainerd側(実際にはcontainerd-shimという小さな見張り役)です。この分業のおかげで、dockerdを再起動してもコンテナは動き続けられるわけです(見張り役だけ残っていれば、上の層は入れ替わっても構わない)。 ↩ -
この重ね合わせを担うのが
overlayfsなどのユニオンファイルシステムです。「下の層は触らず、変更点だけ上の層に書く」という仕組みで、docker diffを叩くとコンテナが起動後に何を書き換えたかが一覧で見えます。 ↩ -
いまでは、この箱の作り方・運び方そのものが OCI(Open Container Initiative) として標準化され、Docker以外の実装(
containerd、Podman、CRI-Oなど)でも同じイメージが動きます。かつてKubernetesがDocker経由での実行(dockershim)をやめると発表したとき、「Dockerイメージが使えなくなる」と界隈がざわつきましたが、実際にはイメージがOCI準拠である以上、docker buildで詰めた箱はそのまま動きます。
実行する場所も同じで、自前のサーバに置く代わりに Amazon ECS や Google Cloud Run のようなサービスへ預ければ、動かす面倒は基盤側が引き受けてくれます。それでも渡すものは、docker buildで詰めた同じ荷物です。
「Docker=コンテナ」ではなく「Dockerはコンテナを扱う道具のひとつ」である、と捉えておくと、この先の技術選定で迷いません。 ↩