DockerfileのマルチステージビルドとCompose設計|本番イメージの軽量化・セキュリティ・環境分離の実践手順

宮崎智広 この記事の監修:宮崎智広(Linux実務・教育歴20年以上・受講者3,100名超)
HOMELinux技術 リナックスマスター.JP(Linuxマスター.JP)Docker > DockerfileのマルチステージビルドとCompose設計|本番イメージの軽量化・セキュリティ・環境分離の実践手順
「Dockerfileを書いてみたけど、イメージが500MBを超えている」「開発環境と本番環境でDockerfileを使い分けたい」
そう感じたことはないでしょうか。

Dockerfileの基本構文は比較的すぐ覚えられますが、本番環境で安全に使えるイメージを作るとなると話が変わります。サイズの肥大化、rootで動くコンテナ、開発用の認証情報が本番イメージに残ったまま——こうした問題は、設計をきちんと理解していないと見過ごしがちです。

この記事では、マルチステージビルドを中心に、本番向けDockerfileの設計と、Docker Composeを使った環境分離の実践手順を解説します。alpine・slim・distrolessといったベースイメージの選び方、.envファイルによる認証情報の安全な管理、override構成・profilesを使った環境切り替えまで、「なぜそう書くのか」という設計の意図まで踏み込みます。

この記事のポイント

・マルチステージビルド+distrolessで本番イメージを1/10以下に軽量化できる
・Dockerfile設計の鉄則はCOPY --from でビルド成果物だけを本番に持ち込むこと
・GoはCGO_ENABLED=0+distroless/staticでバイナリ1本分のイメージを作れる
・コンテナをroot以外のユーザーで動かすことがセキュリティの最低ライン
・.envファイルは自動読み込みされるがGitに含めてはいけない——認証情報はファイルで分離する
・Composeのoverride構成とprofilesで開発・本番・CI環境を1ファイル体系で管理できる


「このままじゃマズい」と感じていませんか?
参考書を開く気力もない、同年代に取り残される不安——
でも安心してください。プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
姓・名・メールの3つだけ/30秒/解除は3秒 / 詳細はこちら

なぜDockerfileの設計が重要なのか

Dockerfileは「動けばいい」で書いてしまうと、後で必ず痛い目に遭います。よくある失敗パターンを4つ挙げます。

1. イメージが巨大になる
python:3.12 のベースイメージにpipでパッケージを入れると、すぐ1GB超えが起きます。コンパイラやヘッダーファイルなど、ビルドにしか使わないツールが本番イメージに丸ごと含まれるためです。Go製のAPIサーバーでも同様で、golang:1.22 ベースのイメージはそれだけで約800MBあります。実行に必要なのはコンパイル済みバイナリ1つだけなのに、ビルドツール一式が本番に混入してしまいます。

2. rootでプロセスが動く
デフォルトでコンテナ内のプロセスはrootで動きます。コンテナブレイクアウト(コンテナ脱出)攻撃が成功したとき、rootのままではホストへの影響が甚大です。

3. 開発用の認証情報が残る
.env ファイルや開発用APIキーをビルドコンテキストに含めてしまい、本番イメージにそのまま焼き込まれてしまうケースです。イメージをDocker Hubにpushした瞬間に情報漏洩が起きます。

4. ベースイメージに不要なコンポーネントが含まれる
ubuntu:22.04 などのフルイメージには、aptパッケージマネージャ・bash・curl・wget等のシステムユーティリティが大量に含まれています。本番アプリの動作には不要ですが、コンテナに含まれていれば攻撃対象面(attack surface)になります。「使わないから安全」ではなく、含まれているだけでCVEスキャンのアラート対象になります。

この4つを解消するのが、マルチステージビルドと適切なベースイメージ選択、そしてCompose設計です。

動作確認環境: RHEL 9.4 / Ubuntu 24.04 LTS / Docker Engine 27.x / Docker Compose v2.27

マルチステージビルドの基本構造

1. シングルステージの問題点を確認する

まず、よくあるシングルステージのDockerfileを見てみましょう。

# NG例: ビルドツールが本番イメージに残る FROM python:3.12 WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . CMD ["python", "app.py"]

このDockerfileをビルドすると、python:3.12のベースイメージ(約1GB)+ライブラリが含まれます。アプリ本体は数十MBでも、実行に不要なものが大量についてきます。

2. マルチステージビルドへ書き直す

マルチステージビルドは、1つのDockerfileの中で複数の FROM 命令を使い、「ビルド用ステージ」と「実行用ステージ」を分離する技術です。各 FROM ブロックが独立したステージになり、AS キーワードでステージに名前をつけられます。

Pythonアプリでは、venv(仮想環境)ごとコピーする方法が依存管理をクリーンに保てます。ビルドステージでのみコンパイラ(gcc等)を使い、本番ステージにはインストール済みのvenvディレクトリだけを渡す構成です。

# Stage 1: ビルドステージ(builder) FROM python:3.12-slim AS builder WORKDIR /app COPY requirements.txt . # venvをビルドステージで作成してpip install RUN python -m venv /opt/venv \ && /opt/venv/bin/pip install --upgrade pip \ && /opt/venv/bin/pip install --no-cache-dir -r requirements.txt # Stage 2: 実行ステージ(本番用) FROM python:3.12-slim WORKDIR /app # builderステージのvenvディレクトリだけをコピー(gccなどは含まれない) COPY --from=builder /opt/venv /opt/venv ENV PATH="/opt/venv/bin:$PATH" # アプリケーションのソースコードをコピー COPY app.py . # rootで動かさない RUN groupadd -r appuser && useradd -r -g appuser appuser USER appuser CMD ["python", "app.py"]

COPY --from=builder の一行が核心です。builderステージで作ったvenv(/opt/venv)だけを本番ステージに持ち込み、コンパイラやpipの本体は捨てます。ENV PATH="/opt/venv/bin:$PATH" でvenv内のPythonとコマンドをデフォルトパスに追加します。

COPY --from= の参照方法は3種類あります。ステージ名で参照するのが最も安全です。

# ステージ名で参照する方法(推奨: Dockerfileを編集しても壊れない) COPY --from=builder /opt/venv /opt/venv # ステージ番号で参照する方法(0始まり: 行順変更で壊れやすいため非推奨) COPY --from=0 /opt/venv /opt/venv # 外部イメージから直接コピーする方法(設定ファイルの取り込みなどに使う) COPY --from=nginx:1.25 /etc/nginx/nginx.conf /etc/nginx/nginx.conf

チーム開発ではステージ名を小文字統一(builder、installer、runner等)にすることで、typoによる参照エラーを防げます。ステージ番号はDockerfileの行順変更に脆弱なため、本番コードでは使わないのが無難です。

実際のビルド結果を確認してみます。

$ docker build -t myapp:prod . $ docker images myapp REPOSITORY TAG IMAGE ID CREATED SIZE myapp prod a3f1b2c4d5e6 2 minutes ago 189MB

ビルドツール込みのシングルステージ版が850MBだったとすると、同じアプリで189MBまで縮小できます。

3. マルチステージビルドのステージ構成を理解する

ステージは2つに限りません。たとえばJavaのアプリケーションでは次のような3ステージ構成が一般的です。

# Stage 1: 依存関係の解決 FROM maven:3.9-eclipse-temurin-21 AS deps WORKDIR /build COPY pom.xml . RUN mvn dependency:go-offline # Stage 2: コンパイル・テスト FROM deps AS builder COPY src ./src RUN mvn package -DskipTests=false # Stage 3: 実行(JREのみ) FROM eclipse-temurin:21-jre WORKDIR /app COPY --from=builder /build/target/app.jar . RUN groupadd -r javauser && useradd -r -g javauser javauser USER javauser EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"]

Maven(ビルドツール)はStage 1・2にしか存在せず、本番ステージはJREだけで動きます。

4. distrolessを実行ステージに使う(セキュリティ最優先構成)

Google製の gcr.io/distroless/ イメージは、シェルもパッケージマネージャも含まない「必要最小限のランタイムだけ」で構成されたベースイメージです。マルチステージビルドと組み合わせることで、ビルドの利便性を保ちながら、本番イメージを極限まで安全にできます。

同じPythonアプリを4種のベースイメージでビルドした場合のサイズと脆弱性の差です。

# イメージサイズの比較(Flaskアプリ・同一requirements.txt) $ docker images | grep flaskapp REPOSITORY TAG IMAGE ID CREATED SIZE flaskapp ubuntu a1b2c3d4e5f6 2 minutes ago 354MB flaskapp slim b2c3d4e5f6a7 3 minutes ago 112MB flaskapp alpine c3d4e5f6a7b8 4 minutes ago 58MB flaskapp distroless d4e5f6a7b8c9 5 minutes ago 38MB # trivyによるHIGH+CRITICAL脆弱性スキャン結果 $ trivy image --severity HIGH,CRITICAL flaskapp:ubuntu 2>&1 | tail -3 Total: 48 (HIGH: 35, CRITICAL: 13) $ trivy image --severity HIGH,CRITICAL flaskapp:slim 2>&1 | tail -3 Total: 12 (HIGH: 9, CRITICAL: 3) $ trivy image --severity HIGH,CRITICAL flaskapp:alpine 2>&1 | tail -3 Total: 3 (HIGH: 2, CRITICAL: 1) $ trivy image --severity HIGH,CRITICAL flaskapp:distroless 2>&1 | tail -3 Total: 0 (HIGH: 0, CRITICAL: 0)

ubuntuベースでHIGH+CRITICALが48件出ていたものが、distrolessではゼロになります。CI/CDパイプラインにtrivyスキャンを組み込んでいる場合、アラート対応コストが大幅に削減できます。

distrolessをランタイムイメージに使ったPythonアプリのマルチステージビルド実装例です。

# Stage 1: ビルドステージ(slim で pip install を実行) FROM python:3.12-slim AS builder WORKDIR /app RUN pip install --upgrade pip COPY requirements.txt . # --target でインストール先ディレクトリを指定 RUN pip install --no-cache-dir --target /app/packages -r requirements.txt # Stage 2: 実行ステージ(distroless: シェルなし・最小構成) FROM gcr.io/distroless/python3-debian12 WORKDIR /app # builderからインストール済みパッケージだけをコピー COPY --from=builder /app/packages /app/packages COPY . . # PYTHONPATHでパッケージの場所を指示 ENV PYTHONPATH=/app/packages # distrolessはシェルがないのでexec形式(JSON配列)で書く CMD ["/usr/bin/python3", "/app/app.py"]

2点押さえてください。

PYTHONPATHの設定: --target /app/packages でインストールしたパッケージは、デフォルトのsite-packagesパスにないため、ENV PYTHONPATH=/app/packages で参照先を指定します。

CMDはexec形式(JSON配列)で書く: distrolessにはシェル(shbash)がありません。CMD python app.py のようなシェル形式はシェルを呼び出すため、distrolessでは使えません。CMD ["/usr/bin/python3", "/app/app.py"] のようにJSON配列形式(exec形式)で記述します。

Goアプリはさらにシンプルになります。 CGO_ENABLED=0 で完全静的バイナリを生成すると外部共有ライブラリへの依存がゼロになるため、gcr.io/distroless/static-debian12(シェルもlibcも含まない)にバイナリ1本だけを渡す構成が可能です。

FROM golang:1.22-alpine AS builder WORKDIR /app # モジュールキャッシュを先に取得(Dockerfileのキャッシュ効率化) COPY go.mod go.sum ./ RUN go mod download COPY . . # CGO_ENABLED=0: スタティックリンク(外部ライブラリ依存なし) # -ldflags="-w -s": デバッグ情報を削除してバイナリを最小化 RUN CGO_ENABLED=0 GOOS=linux go build \ -ldflags="-w -s" \ -o /app/bin/server ./cmd/server # distroless/static: シェルもlibcも含まない・CA証明書のみ内包 FROM gcr.io/distroless/static-debian12 COPY --from=builder /app/bin/server /server USER nonroot:nonroot EXPOSE 8080 ENTRYPOINT ["/server"]

# ビルド結果の確認(Rocky Linux 9.4 / Docker 25.0.5) $ docker build -t myapp-go:prod . $ docker images myapp-go:prod REPOSITORY TAG IMAGE ID CREATED SIZE myapp-go prod a3f912d8c401 30 seconds ago 22.3MB

golang:1.22をそのまま使ったシングルステージ版が約1.1GBだったのに対し、約22MBまで圧縮できます。

注意点: scratch(何も含まれていない空のイメージ)に比べて distroless/static はCA証明書が含まれているため、外部HTTPS APIを呼び出すGoアプリには distroless/static を選んでください。完全オフライン動作のバイナリには FROM scratch でも動作します。

Dockerfile設計の重要ポイント

1. .dockerignoreで不要ファイルを除外する

Dockerfileと同じディレクトリに .dockerignore を置くことで、ビルドコンテキストから除外するファイルを指定できます。これを怠ると、.git ディレクトリや .env ファイルがイメージに混入するリスクがあります。

# .dockerignore の例 .git .env .env.* *.log __pycache__ .pytest_cache node_modules README.md

特に .env ファイルの除外は必須です。開発用の認証情報が含まれていることが多く、イメージ内に残るとDockerイメージ全体が情報漏洩の媒体になります。

2. レイヤーキャッシュを意識した命令の順序

Dockerはレイヤーをキャッシュするため、命令の順序がビルド時間に直結します。

# NG: ソースを先にコピーするとキャッシュが効かない FROM python:3.12-slim WORKDIR /app COPY . . # ソース変更のたびにRUNが走る RUN pip install -r requirements.txt # OK: 依存関係ファイルを先にコピーしてキャッシュを活用 FROM python:3.12-slim WORKDIR /app COPY requirements.txt . # requirements.txtが変わらない限りキャッシュ有効 RUN pip install --no-cache-dir -r requirements.txt COPY . . # ソースは最後

「変更頻度が低いものを先に」が鉄則です。依存関係(requirements.txt、package.json等)は頻繁には変わらないため、先にコピー&インストールしておけば、ソースコードを変更しても pip install のレイヤーキャッシュが再利用されます。

Goの場合は go.modgo.sum を先にコピーして go mod download を実行するパターンが定番です。ソースコードだけを変更した場合でも依存ダウンロードのキャッシュが維持されるため、ビルド時間を大幅に短縮できます。

# Go: go.mod / go.sum を先にコピーしてキャッシュを効かせる FROM golang:1.22-alpine AS builder WORKDIR /app COPY go.mod go.sum ./ # 依存定義ファイルを先にコピー RUN go mod download # ここまでのキャッシュはソース変更では壊れない COPY . . # ソースコードはあとに COPY RUN CGO_ENABLED=0 GOOS=linux go build -o /app/bin/server .

3. RUN命令はなるべくまとめる

RUN を複数行に分けると、その分だけレイヤーが増えてイメージが肥大化します。

# NG: RUNを分けるとレイヤーが3つ増える RUN apt-get update RUN apt-get install -y curl RUN rm -rf /var/lib/apt/lists/* # OK: &&でまとめてレイヤーを1つに RUN apt-get update && \ apt-get install -y --no-install-recommends curl && \ rm -rf /var/lib/apt/lists/*

rm -rf /var/lib/apt/lists/* を同一の RUN に入れることも重要です。別のレイヤーで削除しても、先のレイヤーにはデータが残り続けるため、イメージサイズの削減になりません。

4. rootで動かさない(USER命令)

コンテナ内のプロセスをroot以外で動かすことは、最低限のセキュリティ対策です。

# ユーザーとグループを作成 RUN groupadd --gid 1001 appuser && \ useradd --uid 1001 --gid 1001 --no-create-home appuser # ファイルの所有者をappuserに変更 RUN chown -R appuser:appuser /app # 以降の命令はappuserとして実行 USER appuser

rootでないと動かないアプリもありますが、多くのWebアプリやAPIサーバーはroot権限不要です。UID/GIDを固定しておくと、ホスト側でのファイル権限管理も明確になります。

5. ベースイメージを選ぶ(alpine・slim・distroless・scratch)

Dockerfileの FROM 命令はイメージの土台を選ぶ決定です。ここで何を選ぶかで、最終イメージのサイズと脆弱性の数がほぼ決まります。主要な選択肢と用途を整理します。

ベースイメージ サイズ目安 適した用途 主な制約
ubuntu:22.04 78MB~ 開発・デバッグ 本番非推奨・CVE多め
debian:12-slim 97MB~ 本番(汎用・互換性重視) apt互換・追加容易
python:3.12-slim 130MB~ Pythonアプリ(互換優先) glibc互換・apt使用可
python:3.12-alpine 55MB~ Pythonアプリ(サイズ優先) musl非互換に注意
gcr.io/distroless/python3 40MB~ 本番(セキュリティ最重視) シェルなし・MS必須
gcr.io/distroless/static 2MB~ Go等の静的バイナリ(CA証明書付き) 静的リンク必須
scratch 0MB+バイナリ Go等の静的バイナリ(最小) 完全静的リンク必須・CA証明書なし
alpineの注意点: alpine(約7MB)は最小サイズですが、musl libcというCライブラリを採用しており、ubuntu/debianが使うglibcとは完全互換ではありません。PythonのC拡張(numpy・cryptography・psycopg2等)はalpineでビルドに失敗するケースがあります。C拡張モジュールが多いアプリは python:3.12-slim(debianベース)を使う方が確実です。

distroless/staticとscratchの使い分け: GoアプリがHTTPSで外部APIを呼び出す場合はCA証明書が含まれる distroless/static-debian12 を選んでください。完全にオフライン動作するバイナリやCA証明書が不要なアプリは FROM scratch でイメージサイズをさらに削減できます。

# Go製アプリのscratch構成(静的バイナリを使う例・CA証明書不要な場合) FROM golang:1.22-alpine AS builder WORKDIR /build COPY . . # CGO_ENABLED=0で完全静的バイナリ(glibc依存なし)を生成 RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o app . # scratchに静的バイナリだけをコピー FROM scratch COPY --from=builder /build/app /app ENTRYPOINT ["/app"]

開発中(docker compose upで作業中)はフルイメージ(ubuntu/debian)で開発速度を優先し、本番ではマルチステージビルドで小さなイメージに置き換える——これがDockerfile設計の基本パターンです。

Docker Composeで環境分離する設計

1. 環境変数を渡す3つの方法

コンテナはそれ自体がポータブルな実行環境であるため、開発・ステージング・本番で接続先のDBホスト名やAPIキー、ログレベルが異なります。これを解決するのが環境変数(Environment Variables)ですが、「どこで管理するか」が設計の肝です。Docker Composeで環境変数を設定する方法は主に3種類あります。

方法1: environment: で直接記述する(センシティブでない設定向け)

# docker-compose.yml — 可読性は高いがパスワードを書いてはいけない services: web: image: nginx:alpine environment: - APP_ENV=development - LOG_LEVEL=debug - APP_PORT=8080

変数名だけ書いて値を省略すると、ホストの環境変数を引き継ぎます。- DB_HOST のように書くと、ホストで export DB_HOST=10.0.0.5 してある値がコンテナに渡ります。

方法2: .envファイルで一元管理する(推奨)
docker-compose.ymlと同じディレクトリに .env という名前のファイルを置くと、Docker Composeは自動的にそのファイルを読み込みます。

方法3: env_file: で複数ファイルから読み込む

services: app: image: myapp:latest env_file: - .env.common # 全環境共通の設定 - .env.development # 開発環境固有の設定(上書き)

複数ファイルを指定した場合、後から読み込むファイルの値が優先されます。「共通設定+環境固有設定」の階層化に使えます。

2. .envファイルで本番・開発を分離する

本番と開発で設定を分離する最もシンプルな方法は、環境ごとに.envファイルを作成し、--env-file オプションで切り替えるやり方です。

# ディレクトリ構成 . ├── docker-compose.yml ├── .env.example # リポジトリに含める(値は空またはダミー) ├── .env.development # ローカル開発用(.gitignore対象) └── .env.production # 本番用(.gitignore対象・サーバー上にのみ置く)

注意: .env 系ファイルは必ず .gitignore に追加してください。 認証情報がリポジトリに入ると、GitHub等でチーム全員・さらに第三者に流出するリスクがあります。

# .gitignore に追加(要注意: 追加しないとGit経由で認証情報が漏洩する) .env .env.development .env.production .env.staging

.env.example はリポジトリに含め、新メンバーが必要な変数名を把握できるようにします。

# .env.example(値はダミーまたは空。リポジトリに含める) DB_HOST= DB_PORT=5432 DB_NAME= DB_USER= DB_PASSWORD= APP_ENV= SECRET_KEY= DATABASE_URL=

環境を切り替えるときは --env-file で明示します。

# 開発環境での起動 docker compose --env-file .env.development up -d # 本番環境での起動 docker compose --env-file .env.production up -d # 展開後の設定を本番デプロイ前に必ず確認する docker compose --env-file .env.production config | grep -A5 environment

--env-file を省略すると、自動読み込みの .env が使われます。本番デプロイ時に指定し忘れると開発設定が本番に入るため、要注意です。

また、DockerfileのARG(ビルド時変数)とENV(実行時変数)の違いも押さえておく必要があります。

# Dockerfile: ARGはビルド時のみ有効、ENVは実行時も有効 ARG APP_VERSION=latest # ビルド時のみ参照可能 ENV APP_ENV=production # コンテナ実行時も参照できる # docker-compose.ymlでARGに値を渡す(buildセクション) services: app: build: context: . args: APP_VERSION: ${APP_VERSION} # .envから取得

ARGで渡した値はイメージに固定されます。実行時に変えたい場合はENVを使います。また、ARGの内容は docker history コマンドで見えてしまうため、パスワードやシークレットをARGに渡すのは禁止です。

3. docker-compose.ymlの基本構成

開発・本番・CI の各環境で設定を分離するために、Composeでは override構成 を使うのが定番です。

# ディレクトリ構成 project/ ├── Dockerfile ├── docker-compose.yml # 共通設定 ├── docker-compose.override.yml # 開発環境用(自動適用) ├── docker-compose.ci.yml # CI環境用(明示指定) └── docker-compose.prod.yml # 本番環境用(明示指定)

まず共通設定ファイルを作ります。

# docker-compose.yml(共通) services: app: image: myapp ports: - "8080:8080" networks: - app-net db: image: postgres:16 environment: POSTGRES_DB: mydb volumes: - db-data:/var/lib/postgresql/data networks: - app-net networks: app-net: volumes: db-data:

4. 開発環境用override設定とマージルール

docker-compose.override.yml は、docker-compose.yml と同じディレクトリに置くだけで 自動的にマージされる 特殊なファイルです。docker compose up を実行すると、Composeは共通ファイルを読み込んだ後、docker-compose.override.yml を自動的に重ねます。

マージの動作には重要なルールがあります。

スカラー値(文字列・数値)は上書き: imagecommandrestart などのシングル値は override.yml の値で上書きされます。
リスト(ports・volumes・environment)は追加: リストの要素は override.yml の内容が追加されます。上書きではありません。ports に特に注意してください。
サービスの追加: docker-compose.yml にないサービスを override.yml で定義できます。phpmyadmin など開発専用コンテナをここに書くと、本番構成から自動的に切り離せます。

# docker-compose.override.yml(開発環境・自動適用) services: app: build: context: . target: builder # マルチステージのbuilderステージで起動 volumes: - .:/app # ホットリロード用のボリュームマウント environment: - DEBUG=true - DATABASE_URL=postgresql://dev:devpass@db:5432/mydb command: ["python", "-m", "uvicorn", "app:app", "--reload"] db: environment: POSTGRES_USER: dev POSTGRES_PASSWORD: devpass ports: - "5432:5432" # 開発時はホストから直接接続できるようにする phpmyadmin: image: phpmyadmin:latest ports: - "8081:80" environment: - PMA_HOST=db

override.yml に書くべき内容は「ローカル開発だけに必要な設定」に限定してください。バインドマウント・デバッグポート・開発専用ツール(Mailhog・phpMyAdmin)はここに書き、本番の DB 接続情報や API キーは絶対に書かないようにします。

5. CI環境用設定(docker-compose.ci.yml)

CI(GitHub Actions等)ではテスト実行に特化した設定が必要です。永続化が不要なためtmpfsを使い、テスト用DBを高速起動できます。

# docker-compose.ci.yml(CI環境用オーバーライド) services: app: environment: - APP_ENV=test - DATABASE_URL=postgresql://root:test@db/testdb db: environment: POSTGRES_USER: root POSTGRES_PASSWORD: test POSTGRES_DB: testdb tmpfs: - /var/lib/postgresql/data # CI はtmpfsで高速化(永続化不要)

各環境での起動コマンドです。

# ローカル開発(docker-compose.override.yml が自動マージ) docker compose up -d # 本番(-f で明示指定) docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d # CI(-f で明示指定) docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d

重要な注意点として、-f フラグを1つでも指定すると docker-compose.override.yml の自動マージが無効になります。本番・CI環境で明示指定するときは、必ず共通ファイルを先頭に書いてください。

6. 本番環境用設定

# docker-compose.prod.yml(本番環境・明示指定) services: app: build: context: . target: production # 本番ステージのみビルド environment: - DEBUG=false - DATABASE_URL=${DATABASE_URL} # .env.productionから取得(ファイルに書かない) restart: unless-stopped deploy: resources: limits: cpus: '1.0' memory: 512M db: environment: POSTGRES_USER: ${DB_USER} POSTGRES_PASSWORD: ${DB_PASSWORD} # 本番ではポートを外に公開しない(appコンテナからのみアクセス)

7. profilesでサービスを選択的に起動する

profiles は Docker Compose v2.0 以降で使える機能で、サービスにタグを付けて起動対象を切り替える仕組みです。-f フラグとは異なり、1つのComposeファイルのまま開発専用コンテナを本番環境から除外できます。

services: app: image: myapp:latest ports: - "8080:80" # profiles を指定しないサービスは常時起動 db: image: postgres:16 # profiles を指定しないので常時起動 phpmyadmin: image: phpmyadmin:latest ports: - "8081:80" profiles: - dev # dev プロファイル指定時のみ起動 mailhog: image: mailhog/mailhog ports: - "8025:8025" profiles: - dev # dev プロファイル指定時のみ起動

profiles を指定しないサービス(appdb)は常に起動します。dev プロファイルが付いたサービスは、明示的に dev を指定したときのみ起動します。

# 通常起動(--profile なし): app と db だけ起動 $ docker compose up -d [+] Running 2/2 ✔ Container myapp-db-1 Started ✔ Container myapp-app-1 Started # dev プロファイルを有効にして起動: phpmyadmin と mailhog も起動 $ docker compose --profile dev up -d [+] Running 4/4 ✔ Container myapp-db-1 Started ✔ Container myapp-app-1 Started ✔ Container myapp-phpmyadmin-1 Started ✔ Container myapp-mailhog-1 Started

毎回 --profile dev を打つのが煩わしい場合は、COMPOSE_PROFILES 環境変数で省略できます。

# 環境変数で指定(.env ファイルに書いても有効) export COMPOSE_PROFILES=dev docker compose up -d # 複数プロファイルをカンマ区切りで指定 export COMPOSE_PROFILES=dev,monitoring docker compose up -d

8. ヘルスチェックとdepends_onの連携

db サービスが起動してから app サービスを起動させたい場合、depends_on だけでは不十分です。コンテナの起動とPostgreSQLの「接続受付準備完了」は別物だからです。

services: db: image: postgres:16 healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-dev}"] interval: 10s timeout: 5s retries: 5 app: depends_on: db: condition: service_healthy # dbがhealthyになるまで待機

condition: service_healthy を使うことで、DBのヘルスチェックが通過してから app が起動します。本番環境でアプリが「DB接続エラーで即死」するのを防げます。

本番Dockerfileの完成形

ここまでの要素をまとめた、Pythonアプリ向けの本番Dockerfileです。実際のサーバー(Rocky Linux 9.4)で動作確認しています。psycopg2等のネイティブ拡張が含まれるアプリには --prefix インストールパターンが安定します。

# syntax=docker/dockerfile:1 # === Stage 1: 依存関係のインストール === FROM python:3.12-slim AS builder WORKDIR /build # 依存関係ファイルをコピー(ソースより先) COPY requirements.txt . # ビルド用ツールをインストール(builderステージのみ) RUN apt-get update && \ apt-get install -y --no-install-recommends gcc libpq-dev && \ rm -rf /var/lib/apt/lists/* && \ pip install --no-cache-dir --prefix=/install -r requirements.txt # === Stage 2: 本番イメージ === FROM python:3.12-slim AS production WORKDIR /app # ランタイム依存のみインストール RUN apt-get update && \ apt-get install -y --no-install-recommends libpq5 && \ rm -rf /var/lib/apt/lists/* # builderステージの成果物をコピー(ビルドツールは含まれない) COPY --from=builder /install /usr/local # 非rootユーザーを作成 RUN groupadd --gid 1001 appuser && \ useradd --uid 1001 --gid 1001 --no-create-home appuser # アプリをコピーして所有者を変更 COPY --chown=appuser:appuser app/ ./app/ # 非rootで実行 USER appuser # ヘルスチェック HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')" EXPOSE 8080 CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]

ネイティブ拡張が不要なシンプルなPythonアプリや、FastAPI・Flask等の純粋Pythonアプリには、venv(仮想環境)ごとコピーするパターンも有効です。依存のパスが明確になり、マルチステージビルドとの相性も良好です。

# venvパターン(ネイティブ拡張が不要なPythonアプリ向け) # === Stage 1: venvで依存をインストール === FROM python:3.12-slim AS builder WORKDIR /app COPY requirements.txt . RUN python -m venv /opt/venv \ && /opt/venv/bin/pip install --upgrade pip \ && /opt/venv/bin/pip install --no-cache-dir -r requirements.txt # === Stage 2: 本番イメージ === FROM python:3.12-slim AS production # venvディレクトリだけをコピー(gccなどは一切含まれない) COPY --from=builder /opt/venv /opt/venv ENV PATH="/opt/venv/bin:$PATH" WORKDIR /app COPY --chown=appuser:appuser app/ ./app/ RUN groupadd --gid 1001 appuser && \ useradd --uid 1001 --gid 1001 --no-create-home appuser USER appuser EXPOSE 8000 CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

このDockerfileのビルド後イメージサイズを確認します。

$ docker build --target production -t myapp:prod . # サイズを確認 $ docker images myapp:prod REPOSITORY TAG IMAGE ID CREATED SIZE myapp prod d4e7f8a9b0c1 30 seconds ago 203MB # 本番ステージのみビルドしているため、builderステージのgcc等は含まれない $ docker history myapp:prod | head -10 IMAGE CREATED CREATED BY SIZE d4e7f8a9b0c1 30 seconds ago CMD ["python" "-m" "uvicorn" "app.main:app" 0B 30 seconds ago EXPOSE 8080 0B 30 seconds ago HEALTHCHECK ... 0B 30 seconds ago USER appuser 0B

トラブルシュート・よくあるエラー対処

「COPY --from が失敗する」場合

# エラー例 failed to solve: failed to read dockerfile: COPY --from requires --from to be a stage name # 原因1: AS で名前をつけていない FROM python:3.12-slim # NGの例(AS builderが抜けている) # 修正: AS で必ずステージ名をつける FROM python:3.12-slim AS builder

ステージ名の大文字・小文字も区別されるため注意が必要です。FROM golang:1.22-alpine AS Builder と書いた場合は --from=Builder(大文字B)で参照しなければなりません。ステージ名はすべて小文字(builder、installer、runner等)に統一するのが慣例です。

# NG: ステージ名の大文字・小文字が不一致 FROM python:3.12-slim AS Builder ... COPY --from=builder /install /usr/local # 小文字b でエラー # OK: 大文字・小文字を完全一致させる(ステージ名は小文字に統一推奨) FROM python:3.12-slim AS builder ... COPY --from=builder /install /usr/local

「non-root userでPermission denied」が出る場合

# エラー例 PermissionError: [Errno 13] Permission denied: '/app/logs/app.log' # 原因: ログディレクトリの所有者がrootのまま # 修正: USER切替前にchownしておく RUN mkdir -p /app/logs && \ chown -R appuser:appuser /app/logs USER appuser

「docker compose upでdbより先にappが起動してしまう」場合

# 原因: depends_onにconditionを指定していない depends_on: - db # コンテナ起動を待つだけで、DB接続受付は待たない # 修正: service_healthyを使う(dbにhealthcheckを設定した上で) depends_on: db: condition: service_healthy

「variable is not set」エラーが出る場合

.envファイルに変数が定義されていないか、--env-file のパス指定が違う場合に発生します。

# エラー例 WARN[0000] The "DB_PASSWORD" variable is not set. Defaulting to a blank string. # 確認1: .envファイルが存在するか ls -la .env # 確認2: docker compose configで変数展開を確認 docker compose config # 対処: デフォルト値を設定する(変数未設定時に使われる) # docker-compose.yml内での記法 environment: - DB_HOST=${DB_HOST:-localhost}

alpineで「exec format error」または「No such file or directory」が出る場合

alpineはmusl libcを採用しているため、glibcにリンクされたバイナリはalpine上で動作しません。

# glibcリンクのバイナリがalpine(musl)で動かない場合のエラー例 standard_init_linux.go:228: exec user process caused: no such file or directory # 動的リンクの依存関係を確認する $ ldd /usr/local/bin/myapp linux-vdso.so.1 (0x00007fff...) libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f...)

ldd の出力に /lib/x86_64-linux-gnu/libc.so.6(glibcのパス)が含まれている場合、そのバイナリはalpineでは動きません。

対処方針:
debian:12-slim に切り替える(glibc環境のためバイナリをそのまま動かせる)
・Pythonの場合は pip install --prefer-binary でwheelを優先してCコンパイルを回避する
・Goアプリの場合は CGO_ENABLED=0 go build で完全静的バイナリを生成してscratchかdistroless/staticを使う

distrolessで「sh: executable file not found」エラーが出る場合

distrolessにはシェルが含まれていないため、CMDやENTRYPOINTのシェル形式は使えません。

# シェル形式のCMDがdistrolessで失敗するエラー例 Error response from daemon: failed to create task for container: exec: "sh": executable file not found in $PATH # NG: シェル形式(distrolessでは使えない) CMD python app.py # OK: exec形式(JSON配列形式)で書く CMD ["/usr/bin/python3", "/app/app.py"] # デバッグが必要な場合は:debugバリアントを使う # FROM gcr.io/distroless/python3-debian12:debug # → busyboxシェルが含まれており、docker exec でshが使える $ docker exec -it myapp sh

本番デプロイ時は :debug なしのイメージに戻すことを忘れないでください。

「compose.override.ymlの設定が反映されない」場合

-f フラグを1つでも使った瞬間に、docker-compose.override.yml の自動マージが無効になります。本番・CI用ファイルを指定するとき、共通ファイルを先頭に書かないと意図した設定にならないことがあります。

# NG: -f を使うと override.yml が読まれない docker compose -f docker-compose.prod.yml up -d # OK: 共通ファイルを先頭に明示する docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d

「portsが意図せず追加される」場合

docker-compose.ymlports: - "8080:80" があり、compose.override.yml に別のポートを追加すると、両方のポートマッピングが有効になります。リスト型の設定は上書きではなく「追加」だからです。本番では不要なポートが残らないよう、ベースファイルの ports 設定を整理しておくか、本番用ファイルで明示的に管理してください。

# docker-compose.yml services: app: ports: - "8080:80" # compose.override.yml に追加した場合 services: app: ports: - "9090:80" # 上書きではなく追加になる # 実際のポートマッピング(docker compose config で確認) # ports: # - "8080:80" # 共通ファイルのポート # - "9090:80" # override.yml のポート(両方が公開される)

マージ後の最終設定を docker compose config で確認する

デプロイ前に docker compose configマージ後の最終設定を必ず確認することが、override設計での最重要習慣です。意図しない設定で起動していても、コンテナが立ち上がってしまえばログで気づくのが遅れます。

# マージ後の完全な設定を出力 $ docker compose config # 本番用ファイルを指定した場合のマージ結果を確認 $ docker compose -f docker-compose.yml -f docker-compose.prod.yml config # 起動対象のサービス一覧だけを確認 $ docker compose config --services app db

本番デプロイ前に config --services を実行し、phpmyadminmailhog が表示されていないことを確認してください。開発専用サービスが本番に紛れ込んでいれば、ここで必ず検出できます。

本記事のまとめ

やりたいこと 方法
ビルドツールを本番イメージから除外する FROM ... AS builderCOPY --from=builder
PythonのvenvをCOPYして依存を持ち込む python -m venv /opt/venvCOPY --from=builder /opt/venv /opt/venv
Goをスタティックリンクしてイメージをバイナリだけにまとめる CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s"FROM distroless/static
機密情報をイメージに含めない .dockerignore で .env 等を除外し、実行時に環境変数で渡す
レイヤー数を減らしてサイズを削減する RUN 命令を && でまとめ、apt キャッシュを同一レイヤーで削除
コンテナをrootで動かさない USER 命令で非rootユーザーに切り替え、ファイルを --chown で渡す
認証情報をGitから守る .env.gitignore に追加し、.env.example だけリポジトリに含める
環境ごとに設定を切り替える docker compose --env-file .env.production up -d
開発・本番・CIの設定をコードで管理する override.yml(開発)・ci.yml(CI)・prod.yml(本番)を使い分ける
開発専用サービスを本番から除外する profiles: [dev] を定義し --profile dev で起動
環境変数でプロファイルを切り替える COMPOSE_PROFILES=dev docker compose up
DBが起動してからアプリを起動する healthcheckdepends_on: condition: service_healthy
CVEスキャンのアラートをゼロにする マルチステージビルド + distrolessベースイメージを実行ステージに使う
Go/Rustアプリのイメージを最小化する CGO_ENABLED=0 で静的バイナリを生成し FROM scratch にコピーする
マージ後の設定を確認する docker compose config(デプロイ前の必須確認)
マルチステージビルドは、一度書いてしまえばCI/CDパイプラインにそのまま組み込めます。開発者がローカルで docker build するときも、本番と同じDockerfileを使えるので、「開発環境では動くのに本番で動かない」問題を根本から断てます。

Goアプリなら CGO_ENABLED=0 で静的バイナリを作り distroless/static に渡すだけで約22MBのイメージに圧縮できます。PythonアプリはvenvごとCOPYするパターンが依存のパスを明確に保てます。ベースイメージの選択も、最初の FROM 一行の判断ですが、そこでイメージのサイズと脆弱性の数がほぼ決まります。互換性が重要なアプリはslim系、セキュリティ要件が厳しいアプリはdistroless——この使い分けを習慣にするだけで、セキュリティと運用コストが同時に改善します。

.envファイルの分離と --env-file による切り替えも、現場でよくある「本番に開発設定が入った」系の運用事故を防ぐ鉄則です。本番デプロイ前に docker compose config で変数展開後の設定を必ず確認する習慣をつけてください。

overrideファイルとprofilesは用途が違います。設定値の差し替えには -f フラグとoverride構成、サービス自体の有無の切り替えにはprofiles——適材適所で使い分けてください。

現場で通用する安全なLinuxサーバー構築の「型」を体系的に身につけたい方へ、DockerfileのマルチステージビルドからCompose設計・環境変数の安全な管理まで、実機ハンズオンで学べます。Docker実践講座(linuxmaster.jp)では、コンテナ設計・セキュリティ・本番運用のノウハウを現役エンジニアが丁寧に解説しています。

無料メルマガで学習を続ける

Linuxの実践スキルをメールで毎週お届け。
登録は30秒、解除もいつでも可。

登録無料・いつでも解除できます

暗記不要・1時間後にはサーバーが動く

3,100名以上が実践した「型」を無料で公開中

プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
その「型」を図解60Pにまとめた入門マニュアルを、完全無料でプレゼントしています。

姓・名・メールの3つだけ/30秒/解除は3秒 / 詳細はこちら

Linux無料マニュアル(図解60P) 名前とメールで30秒登録
宮崎 智広

この記事を書いた人

宮崎 智広(みやざき ともひろ)

株式会社イーネットマーキュリー代表。現役のLinuxサーバー管理者として20年以上の実務経験を持ち、これまでに累計3,100名以上のエンジニアを指導してきたLinux教育のプロフェッショナル。「現場で本当に使える技術」を体系的に伝えることをモットーに、実践型のLinuxセミナーの開催や無料マニュアルの配布を通じてLinux人材の育成に取り組んでいる。

趣味は、キャンプにカメラ、トラウト釣り。好きな食べ物は、ラーメンにお酒。休肝日が作れない、酒量を減らせないのが悩み。最近、ドラマ「フライトエンジェル」を観て涙腺が崩壊しました。