Docker 上級 #3 イメージセキュリティ — non-root, distroless, Trivy スキャン
ここまで回してきたイメージを一度止めてセキュリティ視点で点検します。コンテナセキュリティは深く入れば一冊分ありますが、現場で効果が大きな道具をいくつか 手に馴染ませれば、リスクの大きな塊は視界に入ります。
Docker 上級 シリーズでこの記事の位置:
- #1 BuildKit と buildx
- #2 マルチアーキテクチャイメージ
- #3 イメージセキュリティ — non-root, distroless, scan(Trivy) ← この記事
- #4 SBOM と署名 (cosign)
- #5 リソース制限と cgroups
- #6 プロダクション運用 — restart 方針、healthcheck、graceful shutdown
コンテナセキュリティ一枚絵 #
脅威を二つに分けると道具も自然に分かれます。
ランタイム隔離 イメージ衛生
──────────── ──────────
• 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 面が明らかに大きくなります。
基本パターン:
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) |
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 --read-only myappservices:
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 個でも動きます。
docker run --cap-drop=ALL myappservices:
web:
image: myapp
cap_drop:
- ALL
cap_add: # 本当に必要なものだけ追加
- NET_BIND_SERVICE # 1024 未満ポートのバインド (USER が非特権なら必要)--cap-drop=ALL 一行で攻撃面が明らかに減ります。アプリが壊れたらそのとき必要な capability を一つずつ追加する流れが安全です。
よく追加し直す capability:
NET_BIND_SERVICE— 非特権ユーザが 1024 未満ポートをバインドCHOWN、DAC_OVERRIDE、FOWNER、SETGID、SETUID— entrypoint が権限作業をするとき (普通の非対話 entrypoint は不要)KILL— 別プロセスに信号を送る (PID 1 が子を管理するときに稀に)
--security-opt no-new-privileges
#
setuid ビットで権限を上げる試みをコンテナ内部で阻止します。
docker run --security-opt no-new-privileges myappservices:
web:
security_opt:
- no-new-privileges:trueコンテナの中の setuid バイナリ (例: sudo、passwd) が権限を上げられないようにします。コンテナの中にそんなバイナリがあるケースはほとんどありませんが (distroless ならそもそもない)、一行追加に対する安全マージンは十分に大きいです。
4) Distroless 再び — セキュリティ視点 #
中級 #1 でスリミング道具として触れた distroless をセキュリティ視点で見ます。
distroless のセキュリティ効果:
- シェルなし — 侵入者が入っても
bash/shがなくインタラクティブシェル取得が困難 - パッケージマネージャなし —
apt/yumで道具を取って入れることができない - コアユーティリティなし —
cat、ls、wget、curlもない nonrootユーザを事前に準備 —USER nonroot:nonroot一行で終わり
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/static | distroless static に似ている |
cgr.dev/chainguard/python | minimal Python |
cgr.dev/chainguard/node | minimal Node |
cgr.dev/chainguard/nginx | minimal Nginx |
特徴は CVE 更新速度 が速く SBOM / 署名がデフォルトで付いてくる点。この記事のテーマと次の記事 (#4) の SBOM・署名を両方満たすベース候補です。Docker 公式イメージで十分な場面が多いですが、セキュリティゲートが厳しい環境でよく見ます。
6) Trivy — 既知脆弱性スキャン #
イメージに含まれるパッケージの既知の脆弱性 (CVE) を見つけるツール。ほぼ標準 です。
brew install aquasecurity/trivy/trivy
# Docker で直接
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy:latest image myapp:1.0trivy image myapp:1.0出力例:
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 ゲートパターン #
trivy image --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed myapp:1.0--exit-code 1— 発見時に終了コード 1 (CI が失敗と認識)--severity— どの深刻度から数えるか--ignore-unfixed— パッチがまだ出ていないものは除く (修正できないものに足を取られないため)
GitHub Actions と相性がよいです。
- 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.sarifSARIF 形式で出力して GitHub Security タブに結果を上げられます。
他のスキャナ #
- Grype — Anchore のツール。似た機能、似た使い方
- Snyk — マネージドスキャン SaaS、豊富な統合
- Docker Scout — Docker Desktop に内蔵
ほとんどが同じ CVE DB を参照する — ツール選択は環境 / 好み。一つを CI に組み込むかどうかが組み込まないより遥かに大きな差です。
7) Hadolint — Dockerfile lint #
イメージのセキュリティ問題は Dockerfile の書き方から始まることが多いです。Hadolint はこれを静的に掴む linter。
brew install hadolinthadolint Dockerfile出力例:
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よく引っかかるルール:
| ルール | 意味 |
|---|---|
| DL3008 | apt パッケージのバージョンピン |
| DL3018 | apk パッケージのバージョンピン |
| DL3009 | rm -rf /var/lib/apt/lists/* 漏れ |
| DL3015 | --no-install-recommends 推奨 |
| DL3025 | CMD/ENTRYPOINT exec form |
| DL3002 | USER を非特権に落としていない |
| DL3007 | ベースイメージに latest 使用 |
CI に hadolint Dockerfile 一行を入れるとベース衛生が明らかに良くなります。
セキュリティチェックリスト — 一箇所に #
運用コンテナを点検するときに回せるチェックリスト。
□ FROM に明示的なバージョンタグ (latest 禁止)
□ マルチステージでビルドツール分離 ([#1])
□ USER を非特権に落とす
□ COPY --chown で権限明示
□ RUN の中で apt キャッシュ整理
□ CMD/ENTRYPOINT は exec form
□ ビルド時に --mount=type=secret で秘密処理 ([中級 #5])
□ hadolint 通過
□ Trivy HIGH/CRITICAL 通過□ 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+ tmpfs、cap_drop: ALL、no-new-privilegesが運用コンテナの安全デフォルト- distroless / Chainguard ベースで攻撃面を狭める — デバッグトレードオフは一段落だけ
- Trivy / Grype / Docker Scout で既知 CVE を CI ゲートに
- Hadolint で Dockerfile 自体を lint — よく引っかかるルールいくつか
- コンテナ一個の点検チェックリスト — ビルド / ランタイムの二束
次の記事 (#4 SBOM と署名 (cosign)) では一歩進んで — このイメージに何が入っているか を機械が読める形 (SBOM) で作り、そのイメージが 誰が作ったものか を cosign 署名で検証する話に進みます。サプライチェーンセキュリティの入り口です。