概要
多段ビルドは「ビルドに必要なもの」と「実行に必要なもの」を分離する
psycopg2のコンパイルにはgcc/build-essential/libpq-devが必要だが、実行時にはコンパイル済み成果物さえあればよい。builderステージの成果物だけをCOPY --from=builderでruntimeステージへ持ち込めば、ビルドツール自体を最終イメージから排除できる。
.dockerignoreは「ビルドコンテキストの入場制限」
COPY . .はビルドコンテキスト内の全ファイルをイメージへ焼き込む。.dockerignoreを書かないという判断は、.envや.gitを含む全ファイルをイメージ経由で配布してよいと宣言しているのと同義になる。
非root実行は「乗っ取られた場合の被害範囲」を事前に縮小する
root実行のコンテナが侵害されると特権昇格の試行余地が広がる。GKE AutopilotがRestricted相当のPod Security基準を強制するのも同じ理由で、USER追加は監査対策ではなく実害を減らす対策。
ダイジェスト固定+スキャンで供給チェーンの再現性を担保
タグ(python:3.12)は同じ名前のまま中身が変わりうるが、ダイジェストは不変。固定だけでは古い既知脆弱性を防げないため、Trivy等によるCI/CD時点のスキャンと組み合わせて初めて意味を持つ。
問題
MOps 販促システムチームでは、キャンペーン管理API(campaign-api)を GKE Autopilot 上にデプロイしている。先日、社内セキュリティ監査でこのサービスのDockerイメージがレビュー対象になり、7つの問題を指摘された。監査のきっかけは「イメージサイズが1.2GBもあり、GKE Autopilotでのスケールアウト時にPod起動が10秒以上遅延している」という運用チームからの苦情だった。
.dockerignoreが存在せず、requirements.txtにはpsycopg2(PostgreSQL接続用、コンパイル時にlibpq-dev/gccが必要)が含まれている。CI/CD(Cloud Build)はこのDockerfileをそのままビルドし、Artifact Registryへpushした後、脆弱性スキャンを経ずにGKE Autopilotへデプロイしている。
制約・前提条件
- ベースイメージはPython 3.12系を使用する(アプリはPython 3.12前提)
requirements.txtにpsycopg2(Cソース拡張のビルドが必要、実行時には不要)が含まれる- GKE AutopilotはRestricted相当のPod Security基準を適用しており、root(UID 0)実行はスケジューリング拒否・警告の対象になる
- リポジトリには
.env(DB接続文字列などローカル開発用)と.gitが存在し、誤ってイメージに含めてはならない - Cloud Build上のCI/CDでビルド時間短縮(コード変更のたびに依存関係を再インストールしないこと)が求められている
app.pyはGET /healthzを実装済みで、200を返せばアプリが正常起動していると判定できる
.dockerignore/USER/イメージ固定/HEALTHCHECK)+ 実行例(ビルド前後のイメージサイズ・脆弱性件数の比較)+ 設計意図の説明
悪いコード (Before)
FROM python:3.12
# 問題⑤: 可変(rolling)タグ、ダイジェスト未固定
WORKDIR /app
# 問題②③: pip installより先にCOPY . .を実行
# → レイヤーキャッシュが効かない、かつ.dockerignoreが無く
# .env/.gitまで無差別にイメージへ焼き込まれる
COPY . .
RUN pip install -r requirements.txt
# 問題①: ビルド専用パッケージが最終イメージに残存
RUN apt-get update && apt-get install -y gcc build-essential libpq-dev
EXPOSE 8080
# 問題④: USERディレクティブが無くroot(UID 0)のまま実行
# 問題⑥: CI/CDに脆弱性スキャン工程なし(cloudbuild.yaml側の問題)
# 問題⑦: HEALTHCHECK未定義、既存の/healthzが未活用
CMD ["python", "app.py"]
ヒント(段階的開示)
ヒント1 — 方向性
pipのキャッシュが、実行時には一切不要にもかかわらず最終イメージにそのまま残っている。「ビルドに必要なもの」と「実行に必要なもの」を混ぜて1つのステージで作っているのが根本原因。(2) 権限・機密情報の問題 — USERディレクティブがなくroot実行のままであること、.dockerignoreがなくCOPY . .が.envや.gitまで無差別にイメージへ焼き込んでいること。(3) 再現性・供給チェーンの問題 — ベースイメージのタグが固定されておらず、CI/CDに脆弱性スキャンの工程が存在しない。
「このイメージをArtifact Registryからpullできる人全員に、中身を見られても困らないか?」「このコンテナがroot権限のまま乗っ取られたら被害はどこまで広がるか?」を自問すると、(2)の指摘が自然に出てくる。
ヒント2 — アプローチ
- 問題①: ビルドツールが最終イメージに残る →
FROM ... AS builderで依存関係のビルドだけを行うステージを作り、FROM ... AS runtimeで必要なファイルだけをCOPY --from=builderする - 問題②:
COPY . .がpip installより先 → 先にrequirements.txtだけをコピーしてpip installし、その後でソースコードをコピーする順序に変える - 問題③:
.dockerignoreが無く.env/.gitが混入 →.dockerignoreを追加し機密・不要ファイルをビルドコンテキストから除外する - 問題④: rootのまま実行 →
useraddで非特権ユーザーを作成しUSERディレクティブで切り替える - 問題⑤: ベースイメージタグが可変 → SHA256ダイジェストで固定する
- 問題⑥: CI/CDに脆弱性スキャンが無い → Cloud BuildにTrivyのステップを組み込み、CRITICAL/HIGHのCVE検出時はpushを止める
- 問題⑦: 起動確認の仕組みがない → 既存の
/healthzを使ってHEALTHCHECK命令を追加する
ヒント3 — コードの骨格
# ---- builder: 依存関係のビルドだけを行う使い捨てステージ ----
FROM python:3.12-slim AS builder
WORKDIR /build
COPY requirements.txt . # 依存関係定義だけを先にコピー(レイヤーキャッシュ最適化)
RUN apt-get update && apt-get install -y --no-install-recommends gcc build-essential libpq-dev \
&& pip install --no-cache-dir --user -r requirements.txt
# ---- runtime: ビルドツールを含まない最小の実行環境 ----
FROM python:3.12-slim AS runtime
RUN useradd --uid 10001 --no-create-home --shell /usr/sbin/nologin appuser
WORKDIR /app
COPY --from=builder /root/.local /home/appuser/.local # ビルド成果物だけを持ち込む
COPY --chown=appuser:appuser . .
USER appuser
HEALTHCHECK CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/healthz')"
CMD ["python", "app.py"]
.dockerignoreの骨格:
.env
.env.*
.git
tests/
__pycache__/
*.pyc
.venv/
問題点分析(7点)
| # | 問題点 | 分類 | 改善方法 |
|---|---|---|---|
| 1 | gcc/build-essential/libpq-devが最終イメージに残存(1.2GB) | イメージサイズ | builder/runtime二段構成、成果物のみCOPY --from |
| 2 | COPY . .がpip installより先でキャッシュ無効化 | ビルド効率 | requirements.txtを先にコピーしてpip install |
| 3 | .dockerignore不在で.env/.gitがイメージに混入 | 機密情報 | .dockerignoreで除外設定を追加 |
| 4 | USER未指定でroot(UID 0)実行 | 権限設計 | useradd+USERで非特権ユーザー化 |
| 5 | python:3.12の可変タグでダイジェスト未固定 | 再現性 | SHA256ダイジェストで固定 |
| 6 | CI/CDに脆弱性スキャン工程なし | 供給チェーン | TrivyをCloud Buildステップに追加、CRITICAL/HIGHでpush停止 |
| 7 | HEALTHCHECK未定義で異常起動を検知できない | 起動確認 | /healthzを使ったHEALTHCHECK命令を追加 |
構成図 — Bad vs Good(SVG)
模範解答
FROM python:3.12
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
RUN apt-get update && apt-get install -y gcc build-essential libpq-dev
EXPOSE 8080
CMD ["python", "app.py"]
# syntax=docker/dockerfile:1.7
# ============================================================
# builder stage: 依存関係のビルドのみを行う使い捨てステージ(問題1の解決)
# psycopg2のコンパイルに必要なgcc/build-essential/libpq-devは
# このステージにしか存在せず、最終イメージには一切持ち込まれない
# ============================================================
FROM python:3.12-slim@sha256:PIN_ME_WITH_DIGEST AS builder
# ↑ 問題5の解決: `docker buildx imagetools inspect python:3.12-slim` で
# 取得したダイジェストに固定し、ベースイメージの無断すり替えを防ぐ
WORKDIR /build
# 問題2の解決: requirements.txtだけを先にコピー
# → コード変更時もこのレイヤーはキャッシュされ、pip installが再実行されない
COPY requirements.txt .
RUN apt-get update \
&& apt-get install -y --no-install-recommends gcc build-essential libpq-dev \
&& pip install --no-cache-dir --user -r requirements.txt \
&& apt-get purge -y --auto-remove gcc build-essential \
&& rm -rf /var/lib/apt/lists/*
# ============================================================
# runtime stage: ビルドツールを含まない最小の実行環境
# ============================================================
FROM python:3.12-slim@sha256:PIN_ME_WITH_DIGEST AS runtime
# 問題4の解決: 非特権ユーザーを明示的に作成
RUN groupadd --gid 10001 appgroup \
&& useradd --uid 10001 --gid appgroup --no-create-home --shell /usr/sbin/nologin appuser
WORKDIR /app
# builderステージのビルド済み依存関係のみをコピー(ビルドツールは持ち込まない)
COPY --from=builder /root/.local /home/appuser/.local
# 問題3の解決: .dockerignoreと組み合わせて.env/.git/testsを除外した上でコピー
COPY --chown=appuser:appgroup . .
ENV PATH=/home/appuser/.local/bin:$PATH \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
USER appuser
EXPOSE 8080
# 問題7の解決: 既存の/healthzを使ってコンテナレベルの起動確認を行う
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/healthz')" || exit 1
CMD ["python", "app.py"]
# .dockerignore(問題3の解決)
# .envやDB接続情報を含むファイル、Git履歴、テスト、キャッシュを
# ビルドコンテキストから除外し、COPY . . が拾えないようにする
.env
.env.*
.git
.gitignore
tests/
*.md
__pycache__/
*.pyc
.pytest_cache/
.venv/
.mypy_cache/
# cloudbuild.yaml 抜粋(問題6の解決)
steps:
- name: gcr.io/cloud-builders/docker
args: ['build', '-t', '${_IMAGE}', '.']
# Trivyでイメージをスキャンし、CRITICAL/HIGHが見つかったらpushを止める
- name: aquasec/trivy
args: ['image', '--exit-code', '1', '--severity', 'CRITICAL,HIGH', '${_IMAGE}']
- name: gcr.io/cloud-builders/docker
args: ['push', '${_IMAGE}']
ビルド比較:
Before: docker build . → イメージサイズ 1.2GB
(python:3.12フルイメージ + gcc/build-essential/libpq-dev + pipキャッシュが全て残存)
trivy image campaign-api:before → CRITICAL 3件 / HIGH 11件(ビルドツール由来のCVEを含む)
コード1行変更後の再ビルド → pip installレイヤーからキャッシュ無効化、フルリビルドで3分
After : docker build . → イメージサイズ 178MB(約85%削減)
(runtimeステージにビルドツールは存在せず、依存関係のみ)
trivy image campaign-api:after → CRITICAL 0件 / HIGH 1件(要ウォッチ、CI red扱いにはならず)
コード1行変更後の再ビルド → requirements.txt未変更のためpip installレイヤーはキャッシュヒット、再ビルド8秒
イメージ内ユーザー確認:
Before: docker run campaign-api:before whoami → root
After : docker run campaign-api:after whoami → appuser (UID 10001)
.envの混入確認:
Before: docker run campaign-api:before cat /app/.env → DB接続文字列がそのまま出力される
After : docker run campaign-api:after cat /app/.env → No such file or directory(.dockerignoreで除外済み)
| 問題 | 修正内容 | 効果 |
|---|---|---|
| ① ビルドツールが残存 | builder/runtime二段構成、成果物のみCOPY --from | イメージサイズ1.2GB→178MB、攻撃対象領域削減 |
| ② COPY . .が先でキャッシュ無効 | requirements.txtを先にコピーしてpip install | コード変更時の再ビルドが3分→8秒 |
| ③ .dockerignore不在 | .env/.git/testsを除外設定 | 機密情報のイメージ混入を構造的に防止 |
| ④ USER未指定でroot実行 | useradd+USERで非特権ユーザー化 | GKE Autopilot Restricted基準に適合、侵害時の被害範囲縮小 |
| ⑤ ベースイメージが可変タグ | SHA256ダイジェストで固定 | ビルドの再現性を担保、サプライチェーンリスク低減 |
| ⑥ CI/CDにスキャンなし | TrivyでCRITICAL/HIGH検出時にpush停止 | 既知CVE入りイメージのデプロイを構造的に阻止 |
| ⑦ HEALTHCHECK未定義 | /healthzを使った定期確認を追加 | アプリ内部異常をコンテナレベルで検知可能に |
ポイント解説
psycopg2のようなCソース拡張はコンパイル時にのみgcc/build-essential/libpq-devを必要とするが、実行時にはコンパイル済みの成果物さえあればよい。builderステージの成果物だけをCOPY --from=builderでruntimeステージへ持ち込めば、ビルドツール自体を最終イメージから完全に排除でき、イメージサイズと攻撃対象領域を同時に削減できる。requirements.txtとソースコードは変更頻度が全く違う。先に前者だけをコピーしてpip installしておけば、コード変更だけの場合は依存関係インストールのレイヤーがキャッシュされ、CI/CDのビルド時間が数分から数秒に短縮される。COPY . .はビルドコンテキスト内の全ファイルをイメージに焼き込む。.dockerignoreを書かないという判断は、.envや.gitを含む全ファイルをイメージ経由で配布してよいと宣言しているのと同義になる。USERディレクティブの追加は「監査を通すため」ではなく「実際の被害を減らすため」の対策である。実務への応用
campaign-apiのイメージサイズが1.2GB→178MBに縮小することで、GKE Autopilotでのスケールアウト時のPod起動遅延(運用チームからの苦情の原因)が直接改善する。セール当日など急激なトラフィック増加でHPAがスケールアウトする場面ほど効果が大きい。
.dockerignoreによるビルドコンテキストの制限は、campaign-apiだけでなくcoupon-service(08-15/08-22で扱った在庫引当・クーポンサービス)など他のMOpsチームのPythonサービス全体に横展開できる、最も低コストで即効性のあるセキュリティ対策の一つ。
Cloud BuildへのTrivy組み込みは、08-16で扱ったArgo WorkflowsのWorkload Identity Federation(鍵レス認証)と同じ「デプロイパイプラインに構造的な安全装置を組み込む」という設計思想の延長線上にある。CVEスキャンをCIのゲートにすることで、「気づいたら本番に既知の脆弱性入りイメージがデプロイされていた」という事故を構造的に防止できる。
今日のまとめ
.dockerignoreによる機密情報の入場制限、非root実行、ダイジェスト固定、CI/CD時点での脆弱性スキャン——これら6つはいずれも「このイメージがArtifact Registryに置かれた瞬間から、誰が・どこまで悪用できるか」という1つの問いから導かれる設計原則である。
次のステップ
- 発展問題:
campaign-apiが依存する内部ライブラリ(社内共通のprivate PyPIパッケージ)をbuilderステージでインストールする際、GKE Autopilot/Cloud Build間の認証をどう安全に扱うか(--mount=type=secretによるBuildKitシークレットマウント、Artifact Registry認証との連携)を設計する。 - 発展問題:
distrolessベースイメージ(gcr.io/distroless/python3-debian12)へさらに置き換えた場合の利点(シェル自体が存在しないためコンテナ内での不正操作がほぼ不可能になる)とデメリット(デバッグ手段が制限される)を比較する。 - 参考: Docker公式ドキュメント(Multi-stage builds / Build cache)、GKE Pod Security Standards、Trivy公式ドキュメント