弱点補強: Dockerfile多段ビルド×非root実行×イメージ脆弱性スキャン — 単一ステージでビルドツールが最終イメージに残存(1.2GB)→builder/runtime分離で178MBに削減 × COPY . .がpip installより先でレイヤーキャッシュ無効化→依存関係定義を先行コピー × .dockerignore無しで.env/.gitがイメージに混入→除外設定を追加 × USER未指定でroot実行→GKE Autopilotの制約に対応した非rootユーザー化 × python:3.12可変タグでダイジェスト未固定→SHA256固定 × CI/CDにTrivyスキャン工程なし→CRITICAL/HIGH検出でpush停止 × HEALTHCHECK未定義で異常起動を検知できない→既存/healthzを活用(MOps 販促システムチーム campaign-api GKE Autopilot Bad→Good 7点)

2026-08-23 (Day 138) 日曜 弱点補強 ★★★★☆ Docker / GKE Autopilot 多段ビルド / 非root / Trivy

概要

🏗️

多段ビルドは「ビルドに必要なもの」と「実行に必要なもの」を分離する

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を返せばアプリが正常起動していると判定できる
期待する回答形式: 問題点の列挙(番号付き)+ 改善後コード(Dockerfile多段ビルド/.dockerignore/USER/イメージ固定/HEALTHCHECK)+ 実行例(ビルド前後のイメージサイズ・脆弱性件数の比較)+ 設計意図の説明

悪いコード (Before)

このコードには 7つの構造的な問題 が隠れています。見つけてみてください。
現状 — 単一ステージ・root実行のDockerfile
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"]
問題点サマリー(7点)
1ビルド専用パッケージが最終イメージに残存 — gcc/build-essential/libpq-devが実行時には不要なのに1.2GBのイメージに含まれる
2COPY . .がpip installより先 — コード1行の変更で依存関係インストールのキャッシュが毎回無効化される
3.dockerignore不在 — .env(DB接続文字列)・.gitがそのままイメージレイヤーに焼き込まれる
4USER未指定でroot実行 — GKE AutopilotのRestricted基準に抵触、侵害時の被害範囲が拡大
5ベースイメージタグが可変 — python:3.12はダイジェスト固定されておらず再現性がない
6CI/CDに脆弱性スキャンなし — 既知CVEを含むイメージがそのままGKE Autopilotへデプロイされうる
7HEALTHCHECK未定義 — /healthzが実装済みなのに起動確認に使われていない

ヒント(段階的開示)

ヒント1 — 方向性
問題は3層に分類できる。(1) イメージの「太さ」の問題 — ビルド専用のパッケージ(gcc/build-essential/libpq-dev)や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点)

#問題点分類改善方法
1gcc/build-essential/libpq-devが最終イメージに残存(1.2GB)イメージサイズbuilder/runtime二段構成、成果物のみCOPY --from
2COPY . .がpip installより先でキャッシュ無効化ビルド効率requirements.txtを先にコピーしてpip install
3.dockerignore不在で.env/.gitがイメージに混入機密情報.dockerignoreで除外設定を追加
4USER未指定でroot(UID 0)実行権限設計useradd+USERで非特権ユーザー化
5python:3.12の可変タグでダイジェスト未固定再現性SHA256ダイジェストで固定
6CI/CDに脆弱性スキャン工程なし供給チェーンTrivyをCloud Buildステップに追加、CRITICAL/HIGHでpush停止
7HEALTHCHECK未定義で異常起動を検知できない起動確認/healthzを使ったHEALTHCHECK命令を追加

構成図 — Bad vs Good(SVG)

Bad(変更前)— 単一ステージ・root実行(1.2GB) python:3.12(フルイメージ・可変タグ) 問題⑤: ダイジェスト未固定、中身がビルド毎に変わりうる 問題②③: COPY . . が先 → .env/.gitも混入、キャッシュ無効 問題①: gcc/build-essential/libpq-devがそのまま残存 問題④: USER未指定 → root(UID 0)のまま実行 問題⑦: HEALTHCHECK未定義 スキャン無しでpush Cloud Build(脆弱性スキャン工程なし) 問題⑥: 既知CVEを含むイメージがそのままpushされる GKE Autopilot(Pod起動10秒以上遅延) 1.2GBのpull+Restricted基準への抵触リスク × イメージが太くpull/起動が遅い × .env/.gitがpull可能な全員に漏洩 × root実行で侵害時の被害範囲が広い × ベースイメージがすり替わっても気づけない × 既知CVE入りイメージがそのまま本番へ Good(変更後)— builder/runtime分離・非root(178MB) builder stage(使い捨て・ダイジェスト固定) 修正⑤: sha256固定 / requirements.txt先行コピー 修正②: gcc/build-essential/libpq-devはここだけに存在 COPY --from=builder(成果物のみ) runtime stage(ビルドツールなし・178MB) 修正④: useradd + USER appuser(UID 10001) 修正③: .dockerignoreで.env/.git/testsを除外 修正⑦: HEALTHCHECKが/healthzを定期確認 Trivyスキャン通過後にpush Cloud Build + Trivy(CRITICAL/HIGH検出でpush停止) 修正⑥: 既知CVE入りイメージを構造的にブロック GKE Autopilot(Pod起動が高速化・Restricted適合) 178MBの軽量イメージ+非rootでスケジューリング安定 ✓ イメージサイズ約85%削減、起動高速化 ✓ .env/.gitはイメージに一切含まれない ✓ 非root実行で侵害時の被害範囲を縮小 ✓ ダイジェスト固定で再現性を担保 ✓ CVE入りイメージをCIで構造的にブロック 修正

模範解答

Before — 単一ステージ・root実行
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"]
After — builder/runtime分離 + 非root + ダイジェスト固定
# 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を使った定期確認を追加アプリ内部異常をコンテナレベルで検知可能に

ポイント解説

1多段ビルドは「ビルドに必要なもの」と「実行に必要なもの」を構造的に分離する仕組み — psycopg2のようなCソース拡張はコンパイル時にのみgcc/build-essential/libpq-devを必要とするが、実行時にはコンパイル済みの成果物さえあればよい。builderステージの成果物だけをCOPY --from=builderでruntimeステージへ持ち込めば、ビルドツール自体を最終イメージから完全に排除でき、イメージサイズと攻撃対象領域を同時に削減できる。
2レイヤーの順序は「何が変わったらキャッシュを捨てるべきか」の意思表示 — requirements.txtとソースコードは変更頻度が全く違う。先に前者だけをコピーしてpip installしておけば、コード変更だけの場合は依存関係インストールのレイヤーがキャッシュされ、CI/CDのビルド時間が数分から数秒に短縮される。
3.dockerignoreは「ビルドコンテキストの入場制限」であり、セキュリティ境界そのもの — COPY . .はビルドコンテキスト内の全ファイルをイメージに焼き込む。.dockerignoreを書かないという判断は、.envや.gitを含む全ファイルをイメージ経由で配布してよいと宣言しているのと同義になる。
4非rootユーザーでの実行は「コンテナが乗っ取られた場合の被害範囲」を事前に縮小する設計 — root実行のコンテナが侵害されると特権昇格の試行余地が広がる。GKE AutopilotがRestricted相当のPod Security基準を強制しているのも同じ理由で、USERディレクティブの追加は「監査を通すため」ではなく「実際の被害を減らすため」の対策である。
5イメージのダイジェスト固定と脆弱性スキャンは、供給チェーン全体の再現性と検証可能性を担保する — タグは同じ名前のまま中身が更新されうるが、ダイジェストは不変である。ダイジェスト固定だけでは「固定された古い脆弱性」を防げないため、Trivy等によるCI/CD時点でのスキャンと組み合わせて初めて「再現性」と「既知CVEの検知」の両方を満たせる。

実務への応用

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のゲートにすることで、「気づいたら本番に既知の脆弱性入りイメージがデプロイされていた」という事故を構造的に防止できる。

非rootユーザー化とダイジェスト固定は、コスト面でも副次効果がある。イメージサイズの削減はArtifact Registryのストレージコストとネットワーク転送コストの両方を下げるため、08-21で扱ったコスト最適化の文脈でも「小さな対策の積み重ねが月次コストに効く」実例として説明できる。

今日のまとめ

Dockerfileの弱点は「動くイメージを作ること」と「小さく・再現可能で・root権限を持たないイメージを作ること」の違いを意識していない点にあった。多段ビルドによるビルド/実行環境の分離、レイヤー順序によるキャッシュ最適化、.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公式ドキュメント

自己評価(あとで記入)

自分の回答

気づき・メモ