Docker 上級 #3 イメージセキュリティ — non-root, distroless, Trivy スキャン

読了 8分

ここまで回してきたイメージを一度止めてセキュリティ視点で点検します。コンテナセキュリティは深く入れば一冊分ありますが、現場で効果が大きな道具をいくつか 手に馴染ませれば、リスクの大きな塊は視界に入ります。

Docker 上級 シリーズでこの記事の位置:

コンテナセキュリティ一枚絵 #

脅威を二つに分けると道具も自然に分かれます。

二つの分かれ道
   ランタイム隔離                    イメージ衛生
   ────────────                       ──────────
   • USER (非特権ユーザ)              • distroless / minimal base
   • read-only ルート                 • 最小依存
   • capabilities drop                • Trivy / Grype 脆弱性スキャン
   • seccomp / AppArmor               • hadolint Dockerfile lint
   • no-new-privileges                • SBOM ([#4])
                                      • 署名 ([#4])

この記事は すぐに手が届く道具 中心で進みます — 左の USER / read-only / capabilities、右の distroless / Trivy / hadolint。深いポリシー (seccomp、AppArmor) は運用環境によってその上に乗ります。

1) USER — 非特権ユーザに落とす #

ベースイメージはほぼ全て root で始まります。 コンテナの中の root がホスト root と同じではない (user namespace 隔離) ですが、コンテナの中で root 権限があるとコンテナ隔離を破る exploit 面が明らかに大きくなります。

基本パターン:

USER 落とし
FROM python:3.14-slim

WORKDIR /app
RUN groupadd --system app && useradd --system --gid app --home /app app

COPY --chown=app:app requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY --chown=app:app app.py .

USER app
CMD ["python", "app.py"]
  • groupadd --system app && useradd --system --gid app ... — system ユーザ (UID < 1000) として作った非対話ユーザ
  • COPY --chown=app:app — コピーする時点から権限をそのユーザに
  • USER app — 以降全ての命令 (特に CMD) がこのユーザで実行される

ベースイメージが非特権ユーザを準備しているケース #

よく使うベースイメージはすでに非特権ユーザを作っています。

イメージ既存ユーザ
node:*node (UID 1000)
nginx:*nginx
postgres:*postgres (たまに自前 entrypoint が処理)
gcr.io/distroless/*nonroot (UID 65532)
node — 一行で終わり
FROM node:20-slim
WORKDIR /app
COPY --chown=node:node . .
USER node
CMD ["node", "server.js"]

“なぜ動かない” のよくあるポイント #

USER を落としてビルド/実行したときによく出会うエラーはほぼ権限問題です。

  • ポート 80/443 バインド失敗 — root ではないユーザは 1024 未満のポートを開けません。コンテナの中のポートは 8000/8080 のような非特権ポートを使い、ホストにマップするときに -p 80:8000 でマップします。
  • /var/log/xxx 書き込み失敗 — root が作ったディレクトリへの権限がありません。chown または stdout ロギング (中級 #6) で解決。
  • pip install 時点では root だったがランタイムが非特権 — グローバル site-packages はそのまま読まれる。問題なし。

2) Read-only ルートファイルシステム #

コンテナの中でコードがディスクに書くことはありますか? よく見ると ほぼないことが多い です。であれば、ルートファイルシステム自体を読み取り専用でマウントして — 侵入者が何かを落とせる余地を減らせます。

docker run
docker run --read-only myapp
compose.yaml
services:
  web:
    image: myapp
    read_only: true
    tmpfs:
      - /tmp        # /tmp は書き込み可能なメモリマウントに
    volumes:
      - app-data:/app/data    # 永続データは named volume として

read_only: true だけだとアプリが一時ファイルを書く箇所 (例: ライブラリが /tmp に書くキャッシュ) で壊れる可能性があります。だから普通 tmpfs:/tmp をメモリマウントとして開放するパターンを一緒に使います。

利点:

  • コンテナに侵入されてもディスクに道具を持ち込んで落とせない
  • アプリが自分のコードを改ざんできない
  • どのデータが永続でどれが一時かが 明示的 になる

運用コンテナでよく見るオプションです。最初に適用するときに壊れる箇所 (ライブラリのキャッシュディレクトリなど) を一度点検する手間が必要です。

3) Capabilities drop #

Linux capability は root 権限を細かく分けた単位です。コンテナはデフォルトで 14 個の capability を持って始まりますが、ほとんどのアプリは 0 個でも動きます。

全 capability を落とす
docker run --cap-drop=ALL myapp
compose.yaml
services:
  web:
    image: myapp
    cap_drop:
      - ALL
    cap_add:                # 本当に必要なものだけ追加
      - NET_BIND_SERVICE    # 1024 未満ポートのバインド (USER が非特権なら必要)

--cap-drop=ALL 一行で攻撃面が明らかに減ります。アプリが壊れたらそのとき必要な capability を一つずつ追加する流れが安全です。

よく追加し直す capability:

  • NET_BIND_SERVICE — 非特権ユーザが 1024 未満ポートをバインド
  • CHOWNDAC_OVERRIDEFOWNERSETGIDSETUID — entrypoint が権限作業をするとき (普通の非対話 entrypoint は不要)
  • KILL — 別プロセスに信号を送る (PID 1 が子を管理するときに稀に)

--security-opt no-new-privileges #

setuid ビットで権限を上げる試みをコンテナ内部で阻止します。

権限昇格を阻止
docker run --security-opt no-new-privileges myapp
compose.yaml
services:
  web:
    security_opt:
      - no-new-privileges:true

コンテナの中の setuid バイナリ (例: sudopasswd) が権限を上げられないようにします。コンテナの中にそんなバイナリがあるケースはほとんどありませんが (distroless ならそもそもない)、一行追加に対する安全マージンは十分に大きいです。

4) Distroless 再び — セキュリティ視点 #

中級 #1 でスリミング道具として触れた distroless をセキュリティ視点で見ます。

distroless のセキュリティ効果:

  • シェルなし — 侵入者が入っても bash/sh がなくインタラクティブシェル取得が困難
  • パッケージマネージャなしapt/yum で道具を取って入れることができない
  • コアユーティリティなしcatlswgetcurl もない
  • nonroot ユーザを事前に準備USER nonroot:nonroot 一行で終わり
Go + distroless
FROM golang:1.23 AS builder
WORKDIR /build
COPY . .
RUN CGO_ENABLED=0 go build -o myapp

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /build/myapp /myapp
USER nonroot:nonroot
ENTRYPOINT ["/myapp"]

タグに :nonroot が付くと USER が nonroot に事前設定された変形です。USER nonroot:nonroot を明示的に書く方が意図が明確でよいでしょう。

distroless のトレードオフは 中級 #6 で扱ったデバッグの難しさ。だから 運用コンテナは distroless、同じアーキのデバッグコンテナは普通のベース で二種類作るパターンがよく見られます。

5) Chainguard Images — もう一つの minimal 基盤 #

distroless の強力な代替。Chainguard が作った minimal + 頻繁に更新されるイメージ群。

イメージ
cgr.dev/chainguard/staticdistroless static に似ている
cgr.dev/chainguard/pythonminimal Python
cgr.dev/chainguard/nodeminimal Node
cgr.dev/chainguard/nginxminimal Nginx

特徴は CVE 更新速度 が速く SBOM / 署名がデフォルトで付いてくる点。この記事のテーマと次の記事 (#4) の SBOM・署名を両方満たすベース候補です。Docker 公式イメージで十分な場面が多いですが、セキュリティゲートが厳しい環境でよく見ます。

6) Trivy — 既知脆弱性スキャン #

イメージに含まれるパッケージの既知の脆弱性 (CVE) を見つけるツール。ほぼ標準 です。

インストール (Homebrew)
brew install aquasecurity/trivy/trivy

# Docker で直接
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
  aquasec/trivy:latest image myapp:1.0
基本スキャン
trivy image myapp:1.0

出力例:

trivy 出力
myapp:1.0 (debian 12.5)
======================
Total: 8 (LOW: 1, MEDIUM: 4, HIGH: 2, CRITICAL: 1)

┌──────────────┬───────────────┬──────────┬──────────────────┬───────────────┬─────────────────────────────┐
│   Library    │ Vulnerability │ Severity │ Installed Version│ Fixed Version │            Title            │
├──────────────┼───────────────┼──────────┼──────────────────┼───────────────┼─────────────────────────────┤
│ libssl3      │ CVE-2024-...  │ HIGH     │ 3.0.11-1         │ 3.0.13-1      │ ...                         │
│ ...          │               │          │                  │               │                             │
└──────────────┴───────────────┴──────────┴──────────────────┴───────────────┴─────────────────────────────┘

CI ゲートパターン #

HIGH 以上発見時にビルド失敗
trivy image --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed myapp:1.0
  • --exit-code 1 — 発見時に終了コード 1 (CI が失敗と認識)
  • --severity — どの深刻度から数えるか
  • --ignore-unfixed — パッチがまだ出ていないものは除く (修正できないものに足を取られないため)

GitHub Actions と相性がよいです。

.github/workflows/scan.yml (要約)
- uses: aquasecurity/trivy-action@master
  with:
    image-ref: ghcr.io/me/myapp:${{ github.sha }}
    format: sarif
    output: trivy-results.sarif
    severity: HIGH,CRITICAL
    ignore-unfixed: true
- uses: github/codeql-action/upload-sarif@v3
  with:
    sarif_file: trivy-results.sarif

SARIF 形式で出力して GitHub Security タブに結果を上げられます。

他のスキャナ #

  • Grype — Anchore のツール。似た機能、似た使い方
  • Snyk — マネージドスキャン SaaS、豊富な統合
  • Docker Scout — Docker Desktop に内蔵

ほとんどが同じ CVE DB を参照する — ツール選択は環境 / 好み。一つを CI に組み込むかどうかが組み込まないより遥かに大きな差です。

7) Hadolint — Dockerfile lint #

イメージのセキュリティ問題は Dockerfile の書き方から始まることが多いです。Hadolint はこれを静的に掴む linter。

インストール
brew install hadolint
実行
hadolint Dockerfile

出力例:

hadolint 結果
Dockerfile:5 DL3008 warning: Pin versions in apt-get install. Instead of `apt-get install <package>` use `apt-get install <package>=<version>`
Dockerfile:7 DL3009 info: Delete the apt-get lists after installing something
Dockerfile:9 DL3018 warning: Pin versions in apk add. Instead of `apk add <package>` use `apk add <package>=<version>`
Dockerfile:12 DL3015 info: Avoid additional packages by specifying `--no-install-recommends`
Dockerfile:20 DL3025 warning: Use arguments JSON notation for CMD and ENTRYPOINT arguments

よく引っかかるルール:

ルール意味
DL3008apt パッケージのバージョンピン
DL3018apk パッケージのバージョンピン
DL3009rm -rf /var/lib/apt/lists/* 漏れ
DL3015--no-install-recommends 推奨
DL3025CMD/ENTRYPOINT exec form
DL3002USER を非特権に落としていない
DL3007ベースイメージに latest 使用

CI に hadolint Dockerfile 一行を入れるとベース衛生が明らかに良くなります。

セキュリティチェックリスト — 一箇所に #

運用コンテナを点検するときに回せるチェックリスト。

イメージ / Dockerfile
□ FROM に明示的なバージョンタグ (latest 禁止)
□ マルチステージでビルドツール分離 ([#1])
□ USER を非特権に落とす
□ COPY --chown で権限明示
□ RUN の中で apt キャッシュ整理
□ CMD/ENTRYPOINT は exec form
□ ビルド時に --mount=type=secret で秘密処理 ([中級 #5])
□ hadolint 通過
□ Trivy HIGH/CRITICAL 通過
ランタイム / compose.yaml
□ read_only: true + tmpfs (可能な範囲で)
□ cap_drop: ALL + cap_add 最小
□ security_opt: no-new-privileges:true
□ リソース制限: mem_limit、cpus ([#5])
□ healthcheck 定義 ([中級 #4])
□ restart: unless-stopped ([中級 #4]、[#6])
□ 秘密は secrets: または外部マネージャ ([中級 #5])
□ DB のような内部サービスへの -p は 127.0.0.1 バインド

まとめ #

この記事で掴んだ絵:

  • コンテナセキュリティは ランタイム隔離 + イメージ衛生 の二つ
  • USER で非特権ユーザに落とす — 最大効果 / 最小コスト
  • read_only: true + tmpfscap_drop: ALLno-new-privileges が運用コンテナの安全デフォルト
  • distroless / Chainguard ベースで攻撃面を狭める — デバッグトレードオフは一段落だけ
  • Trivy / Grype / Docker Scout で既知 CVE を CI ゲートに
  • Hadolint で Dockerfile 自体を lint — よく引っかかるルールいくつか
  • コンテナ一個の点検チェックリスト — ビルド / ランタイムの二束

次の記事 (#4 SBOM と署名 (cosign)) では一歩進んで — このイメージに何が入っているか を機械が読める形 (SBOM) で作り、そのイメージが 誰が作ったものか を cosign 署名で検証する話に進みます。サプライチェーンセキュリティの入り口です。

X