プログラマのための IT 教科書

Docker を使いこなす

この部の 7 / 8 章 ・ 全体で 11 / 76 章 ・ 読了目安 60 分

この章を読むとできるようになること
  • コンテナが仮想マシンとどう違うかを説明できる
  • コンテナが起動しない原因を自分で切り分けられる
  • キャッシュの効く Dockerfile と compose.yaml を書ける
  • 本番向けイメージの作法(非 root・exec 形式・秘密情報)を守れる

実務では、手元のサービスも依存も、ほぼすべてコンテナで動きます。

docker compose up は毎日打つことになりますが、 その裏で何が起きているかを知らないと、詰まった時に何もできません。

「コンテナが起動しない」
「ビルドが10分かかる」
「保存したファイルが消えた」
「ポートに繋がらない」
「ディスクが足りない」

この章では、コンテナとは何かを理解した上で、Docker を道具として使いこなすところまでやります。

そもそもコンテナとは何か

何を解決したかったのか

コンテナが無かった頃、こういうことが日常的に起きていました。

「私の環境では動きます」
   → 開発者は Node 20、本番サーバーは Node 18 だった

「新しいサービスを同じサーバーに載せたい」
   → 片方が要求する OpenSSL のバージョンが、もう片方を壊す

「サーバーを1台増やしたい」
   → 手順書が30ページあり、誰がやっても同じ結果にならない

原因は1つです。アプリケーションが、置かれたマシンの状態に依存していたこと。

これを解決する方法として、まず仮想マシンが広まりました。 OS ごと丸ごと1セット用意すれば、確かに独立します。 しかし OS が丸ごと入るので、起動に数分かかり、1台あたり数 GB を消費します。 サービスを10個動かすために、OS を10個起動するのは重すぎました。

そこで出てきた発想が、「OS は共有したまま、プロセスから見える世界だけを分ける」 です。

仮想マシンとの違い

仮想マシン                        コンテナ
┌──────────┬──────────┐          ┌──────────┬──────────┐
│  アプリ   │  アプリ   │          │  アプリ   │  アプリ   │
│ ライブラリ │ ライブラリ │          │ ライブラリ │ ライブラリ │
│  ゲストOS │  ゲストOS │  ← 重い  ├──────────┴──────────┤
├──────────┴──────────┤          │   コンテナランタイム   │
│   ハイパーバイザ      │          ├─────────────────────┤
├─────────────────────┤          │      ホストOS        │
│      ホストOS        │          │  (カーネルを共有)    │
└─────────────────────┘          └─────────────────────┘
     起動: 数十秒〜数分                 起動: 1秒未満
     容量: 数 GB                        容量: 数十〜数百 MB

コンテナはOS を丸ごと持ちません。ホストのカーネルをそのまま使い、 プロセスから見える世界だけを分離します。だから軽く、起動が速いのです。

正体は「隔離されたプロセス」

ここが最も大事なところです。

コンテナは、軽い仮想マシンではありません。ホスト上でそのまま動いている、ただのプロセスです。

docker run したコンテナは、ホストの ps を見れば普通のプロセスとして見えます。 仮想化されているのは「そのプロセスから何が見えるか」だけです。

Linux カーネルの2つの機能でできています。

仕組み何をするかそれで何が起きるか
名前空間(namespace)見える範囲を区切るコンテナの中で ps を打つと自分のプロセスしか見えない。/ も、ネットワークも、ホスト名も、コンテナ専用のものに見える
cgroup使える量を区切るCPU とメモリの上限を決められる。超えるとプロセスとメモリで扱った OOMKilled が起きる

「隔離」であって「別のマシン」ではない、という理解が実務では効いてきます。

コンテナの中身は Linux

マシンとシェルで触れた話に戻ります。あなたが macOS で開発していても、 コンテナの中は Linuxです。

そのため次の差が出ます。

  • シェルスクリプトの挙動(BSD と GNU)
  • ファイルパスの大文字小文字の扱い(macOS は区別しないことが多い)
  • ネイティブモジュールのビルド対象(CPU アーキテクチャも含む)

「ローカルで動くのにコンテナで動かない」の大半がこれです。

なお Mac と Windows にはそもそも Linux カーネルがありません。 Docker Desktop は裏で小さな Linux 仮想マシンを動かし、その中でコンテナを動かしています。 Mac でファイル同期が遅いのは、この仮想マシンとの境界を越えているからです。

Docker とコンテナは別のもの

コンテナは仕組みの名前、Docker はそれを使う道具の1つです。

仕様は OCI(Open Container Initiative)として標準化されているので、 Docker で作った標準的なイメージは containerd や Podman でも動きます (Docker 固有の機能や実行時の設定まで完全に同じになるわけではありません)。 Kubernetes が動かしているのも、Docker ではなく containerd であることがほとんどです。

「Docker イメージ」と呼んでいるものの実体は「OCI イメージ」 で、 特定の会社の製品に縛られているわけではありません。

イメージとコンテナ

最初に、この2つを区別してください。

イメージ    起動していない、読み取り専用のテンプレート(設計図)
コンテナ    イメージから起動した、動いているプロセス(実体)

1つのイメージから、コンテナは何個でも作れます。 そしてコンテナを消しても、イメージは残ります。

docker images         # 手元にあるイメージ一覧
docker ps             # 動いているコンテナ
docker ps -a          # 止まっているものも含める  ← 落ちた原因を調べる時はこれ

日常で使うコマンド

# 起動する(--rm で終了時に自動削除。試すだけならこれを付ける)
docker run --rm -it postgres:17 bash
 
# 名前を付けて、バックグラウンドで、ポートを公開して起動
docker run -d --name db -p 5432:5432 -e POSTGRES_PASSWORD=pw postgres:17
 
# ログを見る(-f で追従。コマンドラインで生きるの tail -f と同じ)
docker logs -f db
docker logs --tail 100 db
 
# 動いているコンテナの中でコマンドを実行する
docker exec -it db psql -U postgres
 
# 止める・消す
docker stop db
docker rm db
 
# イメージを作る
docker build -t myapp:dev .
オプション意味
-dバックグラウンドで動かす
-it対話的に使う(シェルに入る時)
-p 8080:80ホストの8080 → コンテナの80 に転送
-e KEY=value環境変数を渡す
-vボリューム / バインドマウント(後述)
--rm終了時にコンテナを削除
`-p` の左右を間違えない

-p 8080:80 は ホスト:コンテナ の順です。

ブラウザで開くのは左(8080)、 アプリが listen しているのは右(80)です。

逆に書くと「起動しているのに繋がらない」になります。

調べる — ここが本題

コンテナのトラブルは、中を見る手段を知っているかどうかで解決速度が変わります。

# 1. まず状態を見る(Exited なら終了コードが出る)
docker ps -a
 
# 2. ログを見る(アプリのエラーはたいていここ)
docker logs <name>
 
# 3. 中に入って確認する
docker exec -it <name> sh        # bash が無いイメージも多いので sh を試す
 
# 4. 設定を丸ごと見る(環境変数・マウント・ネットワークが全部見える)
docker inspect <name>
 
# 5. リソース消費を見る
docker stats
落ちたコンテナには exec できない

docker exec は動いているコンテナにしか入れません。 起動直後に落ちる場合は、次のどちらかを使います。

# 起動コマンドを上書きして、シェルで起動する
docker run --rm -it --entrypoint sh myapp:dev
 
# 落ちたコンテナのログを見る
docker logs <name>

--entrypoint sh は、「イメージの中身は正しいが、起動コマンドが失敗している」 のか 「そもそもファイルが入っていない」 のかを切り分ける時に効きます。

Dockerfile を書く

FROM golang:1.24 AS build
WORKDIR /src
 
# 依存の解決だけを先にやる。コードを直してもこの層のキャッシュが効く
COPY go.mod go.sum ./
RUN go mod download
 
COPY . .
RUN CGO_ENABLED=0 go build -o /out/server ./cmd/server
 
# 2段目: バイナリだけの最小イメージ
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/server /server
USER nonroot
EXPOSE 8080
CMD ["/server"]

レイヤーとキャッシュ

Dockerfile の各命令が1つのレイヤーになり、レイヤー単位でキャッシュされます。

ある行が変わると、その行以降のキャッシュがすべて無効になる

だから、変わりにくいものを先に、変わりやすいものを後に書きます。

# 悪い: ソースを1文字直すたびに、依存のインストールが走る
COPY . .
RUN pnpm install
 
# 良い: 依存ファイルだけ先にコピーする
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .

「ビルドが遅い」の原因は、ほぼこの順序です。

.dockerignore を必ず置く

node_modules
.git
*.log
.env
dist

無いと、COPY . . で巨大な node_modules や .git まで送られます。 ビルドが遅くなるだけでなく、.env がイメージに入る事故も起きます。

CMD は exec 形式で書く

CMD ["/server"]        # 良い(server が PID 1 になる)
CMD /server            # 避ける(/bin/sh が PID 1 になる)

shell 形式で書くと、PID 1 になるのは /bin/sh で、アプリはその子プロセスになります。 プロセスとメモリで扱ったとおり、 この時アプリに SIGTERM が届く保証がありません(shell の実装や書き方によって変わります)。

届かないと、Kubernetes は猶予時間を待った後に SIGKILL します。 処理中のリクエストが切られ、後始末も走りません。 どうしても起動スクリプトを挟みたい場合は、末尾を exec "$@" にしてアプリを PID 1 に置き換えます。

非 root で動かす

USER nonroot

コンテナを破られた時にできることを減らします。 本番イメージでは必ず設定してください。

シークレットを Dockerfile に書かない
ENV API_KEY=secret123        # 絶対に駄目。イメージに焼き込まれる
ARG TOKEN                    # 秘密情報を渡す手段ではない

イメージのレイヤーには履歴が残ります。 途中で削除しても、前のレイヤーから取り出せます。

ARG も安全ではありません。値そのものが常に docker history に出るとは限りませんが、 展開されたコマンド・ビルドログ・キャッシュのどこかに残り得ます。 ビルド時にどうしても秘密情報が要る場合は、BuildKit の secret mount を使います。

RUN --mount=type=secret,id=npmrc \
    npm ci --userconfig /run/secrets/npmrc
docker history myapp:dev     # 誰でも見られる

秘密情報は実行時に環境変数やシークレットで渡します(セキュリティ)。 ビルド時にどうしても必要なら、BuildKit のシークレットマウントを使います。

データの永続化

コンテナを削除すると、中で書いたファイルは消えます。 これは仕様であって、故障ではありません。

# ボリューム: Docker が管理する領域。DB のデータはこれ
docker run -v pgdata:/var/lib/postgresql/data postgres:17
 
# バインドマウント: ホストのディレクトリを直接見せる。開発中のソースはこれ
docker run -v "$(pwd)":/app myapp:dev
ボリュームバインドマウント
用途データの永続化(DB)開発中のソース共有
場所Docker が管理ホストの任意のパス
速度速いmacOS では遅いことがある
ファイルの権限で詰まる

バインドマウントでコンテナ内から書いたファイルが、 ホスト側で root 所有になっていることがあります(Linux で特に)。

docker run -u "$(id -u):$(id -g)" -v "$(pwd)":/app myapp:dev

-u で自分の UID/GID を指定すると回避できます。 macOS の Docker Desktop では自動的に調整されるため、 「Linux の同僚だけ困っている」という形で表面化しがちです(開発環境を作る)。

ネットワーク

# コンテナ同士は、Compose のサービス名で通信できる
# アプリから DB へは localhost ではなく「db」
DATABASE_URL=postgres://user:pw@db:5432/app
コンテナの中の localhost は、そのコンテナ自身

これが最頻出のつまずきです。

アプリのコンテナから見た localhost = アプリのコンテナ自身
                                    (DB のコンテナではない)
  • コンテナ間: サービス名(db, redis)で呼ぶ
  • ホストのサービスをコンテナから呼ぶ: host.docker.internal
  • コンテナ内で listen する時: 127.0.0.1 ではなく 0.0.0.0

最後の項目も定番です。127.0.0.1 で listen すると、 コンテナの外からは繋がりません(開発環境を作る)。

Docker Compose

なぜ必要か

ここまでのコマンドは、コンテナ1つを動かすためのものでした。 しかし実際のサービスは1つでは終わりません。

API サーバー + PostgreSQL + Redis + メール受信用のダミー

これを docker run で立てると、こうなります。

docker network create myapp
docker run -d --name db --network myapp -e POSTGRES_PASSWORD=... -v pgdata:/var/lib/postgresql/data postgres:17
docker run -d --name redis --network myapp redis:7
docker run -d --name api --network myapp -p 8080:8080 -e DATABASE_URL=... myapp:dev

打つ順番も、オプションも、全員が同じにしなければ揃いません。 新人が入るたびに、この4行を口伝することになります。

Compose は、これを1つのファイルに宣言として書くものです。 やっていること自体は docker run の集合体で、魔法ではありません。

compose.yaml を読む

services:
  api:
    build: .                      # この場所の Dockerfile からビルドする
    ports:
      - '8080:8080'               # ホスト:コンテナ
    environment:
      DATABASE_URL: postgres://app:secret@db:5432/app
      REDIS_URL: redis://cache:6379
    volumes:
      - .:/app                    # ソースを共有して、保存したら即反映する
      - /app/node_modules         # ここだけホスト側で上書きさせない
    depends_on:
      db:
        condition: service_healthy   # DB が受付可能になるまで待つ
      cache:
        condition: service_started
 
  db:
    image: postgres:17
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: app
    volumes:
      - pgdata:/var/lib/postgresql/data   # ここが無いと消える
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U app']
      interval: 5s
      retries: 10
    ports:
      - '5432:5432'               # 手元の GUI ツールから繋ぐために開ける
 
  cache:
    image: redis:7
 
volumes:
  pgdata:

読むべき点は4つです。

書き方意味
build: と image:自分で作るのか、既成のイメージを持ってくるのか
depends_on + healthcheck起動順の制御。これが無いと API が DB より先に立ち上がって落ちる(ただし後述)
volumes:消えては困るデータと、ホストと共有したいソース
environment:接続先。ホスト名はサービス名(db cache)になる

日常で使うコマンド

docker compose up -d           # 起動
docker compose up -d --build   # ビルドし直してから起動
docker compose ps              # 状態
docker compose logs -f api     # 特定サービスのログを追う
docker compose exec api sh     # 動いているコンテナに入る
docker compose run --rm api pnpm test   # 使い捨てのコンテナで1回だけ実行する
docker compose down            # 停止して削除
docker compose down -v         # ボリュームごと消す(DB のデータも消える)
docker compose config          # 変数を展開した最終形を表示する(デバッグ用)
起動順を制御しても、再接続は要る

depends_on + healthcheck が効くのは最初の起動時だけです。

□ DB が後から再起動したら?
□ ネットワークが一瞬切れたら?
□ 本番(Kubernetes)には、そもそもこの起動順の仕組みが無い

アプリ自身が「接続に失敗したら少し待って繋ぎ直す」を持っていないと、結局落ちます。 Compose の healthcheck は、開発中の面倒を減らす補助と考えてください。 本番で必要になるリトライの設計はマイクロサービスの実務で扱います。

`exec` と `run` を使い分ける
exec  今動いているコンテナの中で実行する    → ログ調査、DB に繋ぐ、状態を見る
run   新しいコンテナを立てて実行する        → テスト、マイグレーション、単発の処理

run は毎回まっさらな状態から始まるので、再現性が要る作業に向きます。 --rm を付けないとコンテナが溜まっていきます。

接続先はサービス名で書く

新人が最も詰まるのがここです。

✕ DATABASE_URL=postgres://app:secret@localhost:5432/app
○ DATABASE_URL=postgres://app:secret@db:5432/app

コンテナの中の localhost は、そのコンテナ自身を指します。 API コンテナから見た DB は別のマシンなので、localhost には居ません。

Compose は自動でネットワークを作り、サービス名を名前解決できるようにしてくれます (DNS・TCP・TLSで扱った名前解決が、ここでも効いています)。

一方、手元の GUI ツールやホストのアプリから繋ぐ時は localhost です。 ports: で開けた穴を通るためで、この2つは別の話です。

`down -v` は DB のデータを消す

「データがおかしくなったので作り直したい」時には正しい操作ですが、 うっかり打つと開発中のデータが全部消えます。

逆に、「マイグレーションを試したいのに前の状態が残っている」時は、 これで初期化するのが確実です。

docker compose down だけならボリュームは残るので、まずはこちらを使ってください。

Compose は本番のオーケストレータではない

Compose は1台のマシンの中で複数コンテナを動かす道具です。

□ 台数を増やして負荷を分散する
□ 落ちたら別のマシンで立て直す
□ 無停止で新しい版に入れ替える

これらはできません。本番でこれが必要になった時に出てくるのが コンテナと Kubernetesです。

手元の開発環境は Compose、本番は Kubernetes、という住み分けが一般的です。

よくあるトラブル

症状原因
繋がらない-p の左右、0.0.0.0 で listen していない、コンテナ間で localhost
ビルドが遅いレイヤー順序、.dockerignore が無い
変更が反映されないイメージを再ビルドしていない、キャッシュ
ディスクが足りない使っていないイメージ・ボリュームの蓄積
起動直後に落ちるログを見る。--entrypoint sh で切り分け
やたら遅い / 起動しないCPU アーキテクチャの不一致
Apple Silicon での arch 不一致

Mac(arm64)で、amd64 用のイメージを動かすと、 エミュレーションで動作し極端に遅くなるか、そもそも起動しません。

docker inspect <image> | grep Architecture
 
# 明示的に指定する(遅いが動く)
docker run --platform linux/amd64 ...
 
# 本番向けに両対応のイメージを作る
docker buildx build --platform linux/amd64,linux/arm64 -t myapp:1.0 .

本番が amd64、手元が arm64 という構成は非常に多いので、 「手元では動くのに本番で動かない」の原因になります。

# ディスクの使用状況を見る
docker system df
 
# 使っていないものを消す(-a は使われていないイメージも全部)
docker system prune
 
# ボリュームも消す(DB のデータが消えるので、確認してから)
docker system prune -a --volumes

セキュリティと運用の作法

□ タグに latest を使わない(何が動いているか特定できなくなる)
□ ベースイメージは小さく、更新されているものを選ぶ(distroless / alpine / slim)
□ 非 root で動かす
□ シークレットをイメージに焼き込まない
□ イメージの脆弱性スキャンを CI に入れる(ライセンスと依存ライブラリ)
□ CMD は exec 形式
開発用と本番用は別物でよい
開発用: ソースをバインドマウントし、ホットリロードが効く。デバッガも入れる
本番用: マルチステージでビルドし、バイナリと最小限のファイルだけ

同じ Dockerfile に無理やり詰め込むより、 ステージを分けるかファイルを分けるほうが素直です。

コードを1行直すたびに、Docker のビルドで依存パッケージのインストールが毎回走ります。どう直しますか。

実務の落とし穴まとめ

  1. -p の左右を逆にする — ホスト:コンテナの順
  2. コンテナ内で 127.0.0.1 を listen — 外から繋がらない。0.0.0.0 を使う
  3. コンテナ間で localhost を使う — サービス名で呼ぶ
  4. COPY . . を先に書く — キャッシュが効かず、ビルドが遅い
  5. .dockerignore が無い — 巨大なファイルが送られ、.env が混入する
  6. CMD を shell 形式で書く — SIGTERM が届かない(プロセスとメモリ)
  7. シークレットを ENV / ARG に書く — レイヤー履歴に残る
  8. latest タグ — 何が動いているか特定できない
  9. root で動かす — 破られた時の被害が大きい
  10. docker compose down -v をうっかり打つ — DB のデータが消える
  11. arch の不一致に気づかない — 極端に遅い / 動かない

まとめ

  • イメージは設計図、コンテナは実体。コンテナを消してもイメージは残る
  • 調べる順番は ps -a → logs → exec → inspect。 起動直後に落ちるなら --entrypoint sh で切り分ける
  • Dockerfile は変わりにくいものを先に書く。これがビルド時間を決める
  • .dockerignore は必ず置く
  • CMD は exec 形式、非 root、シークレットは焼き込まない
  • データはボリューム(永続化)とバインドマウント(開発時のソース)を使い分ける
  • コンテナ内の localhost は自分自身。listen は 0.0.0.0
  • ディスクは放っておくと埋まる。docker system df と prune
  • Apple Silicon と本番の arch 差は「手元では動くのに」の定番原因

公式ドキュメント

迷ったら一次情報に戻ってください。

対象リンク
Docker ドキュメントhttps://docs.docker.com/
Dockerfile のベストプラクティスhttps://docs.docker.com/build/building/best-practices/
Docker Compose 仕様https://docs.docker.com/compose/
OCI(コンテナの標準仕様)https://opencontainers.org/

章末問題

Compose で起動したアプリから DB に接続できません。設定は `postgres://user:pw@localhost:5432/app` です。原因は?

Pod の削除に毎回30秒かかり、処理中のリクエストが切られています。Dockerfile を見ると `CMD ./server` と書かれていました。

次の章では、この Docker を使って手元に開発環境を組み立てる話に移ります。

読み終わったら記録しておくと、目次で進み具合が分かります。