Dockerは「動くものを早く作れる」反面、Dockerfileを雑に書くと 脆弱性の混入・権限過多・秘密情報漏えい が起きやすくなります。特に本番運用では、Dockerfileは単なるビルド手順ではなく セキュリティ境界の一部 です。
この記事では、Dockerfileの“書き方”でできるセキュリティ強化を中心に、すぐ実務に使える形でまとめます。最後に 危険なDockerfile→改善→より堅牢化 を手を動かして体感できるハンズオンも用意しました。
この記事で扱うこと(SEOを意識したキーワード)
- Dockerfile セキュリティ / セキュアなDockerfile / ベストプラクティス
- 非root(rootless)実行 / 最小権限 / ファイル権限
- ベースイメージ最小化(slim / distroless)/ マルチステージ
- 秘密情報(APIキー・トークン)をイメージに入れない
- 依存関係固定(lock)/ 供給網(サプライチェーン)対策の入口
- 脆弱性スキャン・SBOM・署名(運用の次の一歩)
なぜDockerfileが危険になりやすいのか
Dockerfileが抱えがちなリスクは、ざっくり次の3つに集約されます。
- 権限が強すぎる
デフォルトrootで実行してしまう/不要なLinux capabilityを持ったまま動く、など。 - 不要なものを入れすぎる
ビルド用ツール・シェル・パッケージが本番イメージに残り、攻撃面が増える。 - 秘密情報が混入する
ENVにAPIキーを書いたり、ビルド時に設定ファイルをコピーしてレイヤーに残ったり。
Dockerは「コンテナの外に出られないから安全」と思われがちですが、現実は コンテナ突破(脆弱性、設定不備、権限過多) は普通に起こり得ます。だからこそ、Dockerfile側で “突破されても被害を最小化する” 設計が重要です。
Dockerfileセキュリティ強化チェックリスト(まずはこれ)
ここから先の内容を、実務でそのままチェックに使えるようにまとめます。
A. 最小化(Attack Surfaceを減らす)
- ベースは必要最小限(
slim/ distroless を検討) - マルチステージでビルド用依存をruntimeに残さない
--no-install-recommends(Debian系)などで余計な依存を入れない- パッケージマネージャのキャッシュ削除(apt lists / pip cache / npm cache)
B. 最小権限(Least Privilege)
USERを指定して非rootで動かす- 実行ファイル・設定の所有者/パーミッションを適正化(
chown/chmod) - 可能なら読み取り専用で動かせる構造にする(ログはstdoutへ)
C. 再現性・供給網(Supply Chain)
- 依存関係はlockで固定(npm/pip/aptのバージョン方針)
- ベースイメージのタグ固定(方針により、少なくともメジャー固定など)
.dockerignoreで意図しないファイル混入を防ぐ
D. 秘密情報(Secrets)
- DockerfileにAPIキーを直書きしない(
ENV/ARG乱用NG) - ビルド時の秘密はBuildKit secrets等で渡す(レイヤーに残さない)
.envや鍵ファイルをCOPYしない(.dockerignoreで遮断)
ハンズオン:危険なDockerfileを“安全寄り”に改善する(Node.js例)
ここでは「Dockerfileでできる対策」を体感するため、Node.jsの最小Webアプリで進めます。
目的は“完璧な要塞”ではなく、現場で効く改善を積み上げる感覚を掴むことです。
0) サンプルアプリを用意
作業ディレクトリに以下を作成します。
package.json
{
"name": "dockerfile-secure-handson",
"version": "1.0.0",
"main": "server.js",
"scripts": { "start": "node server.js" },
"dependencies": { "express": "^4.19.2" }
}
server.js
const express = require("express");
const app = express();
app.get("/", (_req, res) => res.send("Secure Dockerfile!"));
app.get("/health", (_req, res) => res.json({ ok: true }));
app.listen(3000, () => console.log("listening on 3000"));
1) まず“危険寄り”Dockerfile(アンチパターン)
Dockerfile.bad
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
ENV API_KEY="hard-coded-secret" # ❌ 絶対にやらない
EXPOSE 3000
CMD ["node", "server.js"]
ビルド:
docker build -t app:bad -f Dockerfile.bad .
このDockerfileの問題点:
ENV API_KEY=...により秘密情報が イメージに残るCOPY . .が早すぎて、.envや鍵などを混入しやすい- root実行(デフォルト)になりやすい
npm installは再現性が落ちがち(lock未活用)
2) .dockerignore で秘密・不要物の混入を止める(最優先)
まずは入口を塞ぎます。
.dockerignore
node_modules
npm-debug.log
.env
*.key
*.pem
.git
.gitignore
Dockerfile*
ポイント:.env や鍵ファイルが「うっかり混入」しないように 明示的に遮断 します。Dockerfileだけ頑張っても、コンテキストに入っていたら事故ります。
3) 依存関係を分離してキャッシュ&再現性を上げる(安全にも効く)
Dockerfile.good(まずは改善版)
FROM node:20-slim
WORKDIR /app
# 依存だけ先に(キャッシュが効く+差分が明確)
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev
# アプリ本体は後で
COPY server.js ./
EXPOSE 3000
CMD ["node", "server.js"]
ビルド&起動:
docker build -t app:good -f Dockerfile.good .
docker run --rm -p 3000:3000 app:good
ここではまだ“セキュア化”の核心(非root等)が未実装ですが、まず 事故(混入)と再現性 を改善しています。
4) 非rootで動かす(Dockerfileセキュリティの核)
rootで動くコンテナは、万一侵入されたときの被害が大きくなりがちです。
Node公式イメージには node ユーザーが用意されているので、それを使います。
Dockerfile.secure(非root+権限整理)
FROM node:20-slim
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev
# 所有者をnodeにしてから実行(権限事故を減らす)
COPY --chown=node:node server.js ./
EXPOSE 3000
USER node
CMD ["node", "server.js"]
ビルド&起動:
docker build -t app:secure -f Dockerfile.secure .
docker run --rm -p 3000:3000 app:secure
ここが重要ポイント
USER nodeで非root化COPY --chown=node:nodeで権限を揃える(後からchownするより安全&レイヤー効率も良い)
5) マルチステージで“本番に不要なもの”を落とす(攻撃面を削る)
アプリが大きくなると、ビルド用ツールが残りがちです。
ビルド用と実行用を分けることで、実行イメージをスリムにできます。
Dockerfile.secure.multi
# ---- build ----
FROM node:20-slim AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev
COPY --chown=node:node server.js ./
# ---- runtime ----
FROM node:20-slim
WORKDIR /app
# 実行に必要なものだけ
COPY --from=build /app /app
EXPOSE 3000
USER node
CMD ["node", "server.js"]
ビルド:
docker build -t app:secure2 -f Dockerfile.secure.multi .
docker images | head
秘密情報(Secrets)を“イメージに残さない”実務ルール
NG例:Dockerfileに埋める(レイヤーに残る可能性)
ENV API_KEY=...ARG TOKEN=...→ 使い方次第でビルドログや履歴に残るCOPY .env .など
OK例:実行時に渡す(まずはこれで事故が減る)
docker run --rm -e API_KEY=xxxx -p 3000:3000 app:secure2
発展:BuildKit secrets(ビルド時に必要な秘密を“レイヤーに残さない”)
たとえばプライベートレジストリのトークン等、ビルド時にだけ必要な秘密があります。
BuildKitのsecret機能を使うと、イメージレイヤーに残さずに利用できます。
例(概念サンプル):
# syntax=docker/dockerfile:1.7
FROM node:20-slim
WORKDIR /app
COPY package.json package-lock.json* ./
# ビルド時だけ必要なトークンをsecretで渡す(レイヤーに残さない)
RUN --mount=type=secret,id=npm_token \
sh -c 'echo "//registry.npmjs.org/:_authToken=$(cat /run/secrets/npm_token)" > .npmrc && npm ci --omit=dev && rm -f .npmrc'
COPY --chown=node:node server.js ./
USER node
CMD ["node", "server.js"]
ビルド例(手元に npm_token.txt がある想定):
docker build --secret id=npm_token,src=npm_token.txt -t app:secure-secret .
これにより「秘密情報をDockerfileに書かない」「ビルドログやレイヤーに残さない」を両立できます。
Dockerfileだけじゃ足りない:起動時に“さらに堅くする”実務オプション(おまけ)
Dockerfileが良くても、起動時の設定で被害が増減します。最低限だけ紹介します(本番でかなり効きます)。
docker run --rm -p 3000:3000 \
--read-only \
--tmpfs /tmp \
--cap-drop ALL \
--security-opt no-new-privileges:true \
--pids-limit 200 \
--memory 256m \
app:secure2
--read-only:ルートFSを書き込み不可(改ざん耐性が上がる)--cap-drop ALL:不要capabilityを落とす(最小権限)no-new-privileges:権限昇格の抑制- リソース制限:DoS耐性の基本
(この辺はCompose/Kubernetes側で管理することも多いので、環境に合わせて適用します)
よくある落とし穴(セキュリティ観点)
- 「とりあえずalpine」:小さいが、ネイティブ依存でハマることがある(まずslimで安定→必要に応じて検討)
curl | shをDockerfileで実行:供給網リスクが跳ね上がる(取得元固定・検証・最小化が必要)apt-get updateとapt-get installを別RUN:古いインデックスや余計なレイヤーを残しやすい(まとめる)- ログをファイルに書く設計:read-only化の邪魔になる(stdout/stderrに出す設計が運用でも安全)
まとめ:Dockerfileセキュリティ強化の最短ルート
Dockerfileで“効きが大きい順”にやるなら、次の順が現実的です。
.dockerignoreで混入を止める(秘密・鍵・git等)- 依存分離+lockで再現性を上げる(事故と差分を減らす)
USER指定で非root化(最小権限の核)- マルチステージで不要物を落とす(攻撃面を削る)
- secretsは「実行時」or BuildKit secretsで(レイヤーに残さない)
コメントを残す