目次
Dockerは毎日使っているのに、「コンテナの中でpsを打つとPID 1に自分のアプリがいるのはなぜか」「メモリ制限を超えると何が起きるのか」「イメージのレイヤーとは物理的に何なのか」と聞かれると答えに詰まる、という方は多いのではないでしょうか。仕組みを知らなくても動かせるのがDockerの良さですが、トラブルの原因を切り分けるときには、その「中身」の理解が効いてきます。
この記事では、コンテナが「特別なプロセス」にすぎないことを、Linuxカーネルの3つの機能——namespaces(見える範囲の隔離)・cgroups(使える資源の制限)・OverlayFS(ファイルシステムの重ね合わせ)——から説明し、Dockerの裏で動くrunc/containerdの関係、そしてunshareコマンドでDockerなしのコンテナを手作りする体験までを扱います。DockerとVMの違いや基本操作はコンテナと仮想マシンの違いとDocker入門で解説済みなので、本記事はその一段下の階層に集中します。
namespaces|コンテナは「隔離された普通のプロセス」
ホストからはただのプロセスとして見える
仮想マシンはハイパーバイザーの上でゲストOSのカーネルが丸ごと動きますが、コンテナには専用のカーネルはありません。コンテナ内のプロセスはホストのカーネル上で直接動く、ごく普通のLinuxプロセスです。違いは「見える範囲」と「使える資源」がカーネルの機能で制限されている点だけです。実際にホスト側で確認してみましょう。
# コンテナを起動
docker run -d --name web nginx:alpine
# ホストのpsにnginxのワーカーがそのまま見える(PIDはホスト視点)
ps -ef | grep "nginx: worker"
# コンテナ内から見るとPIDは1から始まる
docker exec web ps -o pid,comm
# PID COMMAND
# 1 nginx
# 30 nginx
同じプロセスが、ホストからは例えばPID 48213、コンテナ内からはPID 1に見えています。これがnamespacesの効果です。「コンテナが軽い」「起動が一瞬」なのは、OSを起動しているわけではなくプロセスを1つ起動しているだけだからです。
主な名前空間と役割
namespacesは「このプロセスからは世界がこう見える」を種類ごとに分ける仕組みです。Dockerがコンテナ起動時に作る主なものを整理します。
| 名前空間 | 隔離するもの | 効果 |
|---|---|---|
| PID | プロセスID | コンテナ内のPIDが1から始まり、他コンテナやホストのプロセスが見えない |
| NET | ネットワークスタック | 独自のインターフェース・IPアドレス・ポート・ルーティングテーブルを持つ |
| MNT | マウントポイント | 独自のルートファイルシステム(イメージの中身)が / に見える |
| UTS | ホスト名 | コンテナごとに異なるhostnameを持てる |
| IPC | 共有メモリ・セマフォ | プロセス間通信をコンテナ内に閉じる |
| USER | UID/GID | コンテナ内のrootをホストの一般ユーザーに対応付けられる(rootlessモード) |
各プロセスが属する名前空間は /proc/<PID>/ns/ のシンボリックリンクで確認でき、lsns コマンドで一覧できます。「コンテナ内でPID 1のプロセスにSIGTERMが届かない」「docker run -p をしていないのにホストからポートに繋がらない」といった現象は、それぞれPID名前空間とNET名前空間の性質から説明できます。プロセスの基礎はLinuxのプロセス管理入門を参照してください。
cgroups|使える資源を制限する
CPUとメモリの上限はカーネルが強制する
namespacesが「見える範囲」なら、cgroups(control groups)は「使える量」です。プロセスをグループにまとめ、グループ単位でCPU時間・メモリ・I/O帯域・プロセス数の上限をカーネルが強制します。現在の主要ディストリではcgroup v2が標準で、/sys/fs/cgroup/ 以下の階層として見えます。
# メモリ256MB・CPU 0.5コア相当に制限して起動
docker run -d --name limited --memory=256m --cpus=0.5 myapp
# カーネル側に書き込まれた実際の値を見る(cgroup v2)
CID=$(docker inspect -f '{{.Id}}' limited)
cat /sys/fs/cgroup/system.slice/docker-${CID}.scope/memory.max # 268435456
cat /sys/fs/cgroup/system.slice/docker-${CID}.scope/cpu.max # 50000 100000
# 使用状況の確認
docker stats --no-stream limited
cpu.max の「50000 100000」は「100ms(100000µs)のうち50ms(50000µs)まで使える」という意味で、これが --cpus=0.5 の実体です。メモリは上限を超えると、コンテナ内のプロセスがOOM Killerによって強制終了され、コンテナは終了コード137(128+SIGKILLの9)で停止します。ホスト全体のメモリに余裕があってもこれは起きます。
OverlayFS|イメージレイヤーの正体
読み取り専用の層を重ね、書き込みは最上層だけ
Dockerイメージは複数のレイヤーでできており、各レイヤーはDockerfileの命令ごとに生成された「ファイルの差分」のディレクトリです。コンテナ起動時、OverlayFSという機能でこれらを重ね合わせて1つのディレクトリツリーに見せ、その上にコンテナ専用の書き込み可能な層を1枚乗せます。
# イメージのレイヤー構成(各行が1レイヤー)
docker image history nginx:alpine
# 実際にどのディレクトリが重ねられているか
docker inspect -f '{{json .GraphDriver.Data}}' web | python3 -m json.tool
# "LowerDir": イメージの各レイヤー(読み取り専用、複数)
# "UpperDir": このコンテナの書き込み層
# "MergedDir": 重ね合わせた結果(コンテナ内の / として見える)
重要な性質が2つあります。1つはコピーオンライトで、下層のファイルを書き換えると、書き換えた時点でファイル全体が上層にコピーされてから変更されます。大きなファイルを頻繁に書き換える用途にはコンテナ層は向かず、ボリュームを使うべき理由です。もう1つは削除しても下層は消えないことです。あるレイヤーで rm したファイルは「ホワイトアウト」という印で隠されるだけで、下のレイヤーには残ったままです。RUN apt-get install と RUN rm -rf /var/lib/apt/lists を別の命令で書いてもイメージが小さくならないのはこのためで、同じ RUN 内で削除するか、マルチステージビルドで必要なものだけを最終ステージにコピーする必要があります。
runc・containerd・Dockerの関係
実際にコンテナを作るのはruncという小さなプログラム
ここまでの3機能はすべてLinuxカーネルのものであり、Dockerはそれらを組み合わせて呼び出しているにすぎません。その呼び出しは階層化されています。
- docker CLI: ユーザーのコマンドをAPIリクエストにしてdockerdへ送る。
- dockerd: イメージのビルド・ネットワーク・ボリュームなど高レベルの機能を担当。
- containerd: イメージの展開とコンテナのライフサイクル管理。Kubernetesはdockerdを介さずここに直接話しかける。
- containerd-shim: コンテナ1つにつき1プロセス。containerdが再起動してもコンテナが落ちないよう間に立つ。
- runc: OCI仕様に従い、namespacesとcgroupsを作ってプロセスを起動し、自分はすぐに終了する。
ps -ef | grep containerd-shim を実行すると、動いているコンテナの数だけshimが見えます。runcは起動時だけ現れて消えるので、通常のpsには映りません。Kubernetesが「Dockerを非推奨にした」という話は、この階層のうちdockerdを飛ばしてcontainerdを直接使うようになった、という意味です。
unshareで手作りコンテナを体験する
Dockerなしで同じことをやってみる
理解を確かめる最良の方法は、Dockerを使わずに同じ状態を作ることです。unshare コマンド(util-linux)は新しい名前空間を作ってコマンドを実行します。alpineのルートファイルシステムを取り出し、PID・MNT・UTS・NETの名前空間で隔離して起動してみます。
# 1. ルートファイルシステムを用意(一度だけDockerを借りる)
mkdir rootfs
docker export $(docker create alpine:3.20) | tar -C rootfs -xf -
# 2. 名前空間を作って隔離環境に入る
sudo unshare --pid --fork --mount --uts --net --ipc \
chroot rootfs /bin/sh -c "mount -t proc proc /proc && hostname handmade && exec /bin/sh"
# 隔離環境の中で確認
/ # ps # PID 1 が sh になっている
/ # hostname # handmade(ホストには影響しない)
/ # ip addr # lo しかない(ネットワークが隔離されている)
# 3. 別の端末でcgroupによるメモリ制限を追加(cgroup v2)
sudo mkdir /sys/fs/cgroup/handmade
echo 100M | sudo tee /sys/fs/cgroup/handmade/memory.max
echo <隔離環境のshのホスト側PID> | sudo tee /sys/fs/cgroup/handmade/cgroup.procs
これで「PIDが1から始まり、ホスト名が独立し、ネットワークが隔離され、メモリが100MBに制限された、alpineの中身が / に見えるプロセス」ができました。Dockerがやっていることの本質はこれに、ネットワーク設定・イメージ管理・便利なCLIを加えたものです。Linux上でのDocker運用の実際はLinux×Docker入門も参考にしてください。
トラブル事例:ホストのメモリは余っているのにコンテナが137で落ちる
症状
Javaアプリのコンテナが数時間おきに再起動し、docker inspect の State.OOMKilled が true、終了コードは137でした。ホストは16GBのうち10GB以上が空いています。
原因
Composeファイルで mem_limit: 1g を指定していた一方、JVMの起動オプションは -Xmx2g でした。cgroupsによる1GBの上限はカーネルが強制するため、JVMが2GBまで使おうとした時点でOOM Killerに殺されます。ホストの空き容量は関係ありません。古いJVMやNode.jsはcgroupの上限を認識しないため、アプリ側の設定がコンテナの上限を超えることが起こりがちです。
対処
- JVMを
-XX:MaxRAMPercentage=75のようにコンテナ上限に対する割合で指定し、cgroup上限を自動で認識させる(Java 10以降はデフォルトでコンテナを認識)。 - Node.jsなら
--max-old-space-sizeをコンテナ上限より十分小さく設定する。 - OOM発生時のログは
dmesg | grep -i "killed process"やホストのjournalで確認し、どのcgroupで発生したかを見る。
コンテナのセキュリティ面(rootで動かさない、capabilityを絞る)はDockerセキュリティのベストプラクティスで扱っています。namespacesを理解すると、なぜそれらが必要かも見えてきます。
まとめ
コンテナは魔法ではなく、Linuxカーネルの3つの機能でプロセスを包んだものです。
- namespaces: PID・ネットワーク・マウントなど「見える範囲」を隔離する。
- cgroups: CPU・メモリなど「使える量」を制限する。超えれば137で殺される。
- OverlayFS: 読み取り専用レイヤーを重ね、書き込みは最上層だけ。削除しても下層は消えない。
- runc/containerd: それらを組み合わせて呼び出す実行系で、Dockerはその上の使いやすい皮。
手元のLinux(WSL2でも可)で一度unshareを試してみると、以降のDockerのエラーメッセージがずっと読みやすくなります。コンテナ基盤の設計や既存環境の安定化については、Harmonic Societyのシステム開発・インフラ支援でご相談ください。
Harmonic Society
この記事の内容、自社の業務でも活かせそうですか?
ローカルLLM・AI・クラウドなどの技術導入を、要件整理からPoC・社内展開まで代表エンジニアが伴走します。オンライン対応・全国OK。まずは30分の無料相談から。売り込みはしません。
関連記事
Related / 9 articles
Notes & Insights
- プログラミング
DDoS攻撃の仕組みと対策入門|レイヤー別の防御とCDN・クラウドの活用
DDoS攻撃をボリューム型・プロトコル型・アプリ層に分けて仕組みを解説し、自前サーバーで防げない理由、CloudflareやAWS Shieldの標準防御、オリジンIPの隠し方、レートリミットとBot対策、攻撃を受けたときの初動、費用が跳ね上がるDenial of Walletへの備えまでわかります。
- プログラミング
WAFとは?仕組み・導入パターン・誤検知対策|Webアプリを攻撃から守る実践ガイド
WAFがファイアウォールやIDSと何が違うのか、シグネチャとマネージドルールの仕組み、Cloudflare WAF・AWS WAF・ModSecurityの比較、フォーム送信がブロックされる誤検知の調査と例外設定、ログ監視、WAFが代替できないことまで実践的に解説します。
- プログラミング
セキュリティヘッダー入門|CSP・HSTS・X-Frame-Optionsの設定と効果を実践解説
CSP・HSTS・X-Frame-Options・X-Content-Type-Optionsなど主要セキュリティヘッダーが防ぐ攻撃と、CSPのReport-Onlyからの段階導入、nonce/hash、HSTS preloadの不可逆リスク、Nginx・Next.js・Astroでの設定例、確認方法を解説します。
- プログラミング
クラウドの通信費(Egress)入門|データ転送量課金の仕組みと転送コストを抑える設計
クラウドの「受信無料・送信有料」の原則、AZ間・リージョン間・インターネット向けの単価差、NATゲートウェイ処理料の罠、CDNで転送量を減らす方法、バックアップやログ転送の見落とし、請求書で転送料を特定する手順を解説。想定外の請求を防げます。
- プログラミング
秘密情報をGitに入れない仕組み|.gitignore・git-secrets・履歴から漏れた鍵の削除
APIキーや.envをGitにコミットしてしまう典型経路と、.gitignore・.env.exampleの運用、pre-commitでのgitleaks検知、GitHub secret scanningの活用、漏れた鍵の無効化と履歴書き換え(git filter-repo)の手順を解説。仕組みで再発を防げます。
- プログラミング
開発・ステージング・本番環境の分離設計|環境差分をなくす構成とアクセス制御
開発・ステージング・本番それぞれの目的と、構成をコードで揃える方法、環境別の設定注入、本番データを使わないテストデータ戦略、ステージングの保護(Basic認証・IP制限・noindex)、コストを抑える運用までを解説。環境差分による本番障害を防げます。
- プログラミング
ngrok・Cloudflare Tunnelでローカルを公開|Webhook開発とデモ環境の作り方
NAT内のローカル環境にStripeやLINEのWebhookを届けるトンネリングの仕組みを解説。ngrok・Cloudflare Tunnel・localtunnelの比較、固定ドメインと認証、リクエスト検査、公開時のセキュリティ、自宅サーバー公開への応用までわかります。
- プログラミング
ローカル開発環境のHTTPS化|mkcert・hostsファイル・自己署名証明書の正しい使い方
ローカル開発をHTTPS前提にすべき理由(Secure Cookie・Service Worker・OAuth)と、mkcertでローカルCAを作りhostsで独自ドメインを割り当ててVite・Next.js・Dockerで使う手順を解説。証明書警告を無視する癖の危険も理解できます。
- プログラミング
localhost・0.0.0.0・127.0.0.1の違い|ポートとUnixソケットを理解して「つながらない」を解決
localhost・127.0.0.1・0.0.0.0の意味の違い、Dockerで外から接続できない原因、host.docker.internal、ポート競合の調べ方、Unixソケットの利点と権限、1024未満ポートの制約を解説。「つながらない」を仕組みから解決できます。
Harmonic Society
「読んで終わり」にせず、自社の業務で試してみませんか?
AI・ローカルLLM・クラウドの導入を、要件整理からPoC・社内展開まで代表エンジニアが伴走します。オンライン対応・全国OK・売り込みなし。
無料・30分・オンラインOK|1営業日以内に返信します