Dockerfileベストプラクティス完全ガイド:軽量化・キャッシュ最適化・セキュリティ

Dockerを触っていると、最初は「とりあえず動けばOK」でDockerfileを書きがちです。ところが、チーム開発や本番運用に寄せていくと、Dockerfileは単なる起動手順ではなく ビルド速度・イメージサイズ・セキュリティ・再現性 を左右する重要な“設計書”になります。

この記事では、検索されやすいキーワード(Dockerfile ベストプラクティス / 最適化 / 軽量化 / キャッシュ / マルチステージ / セキュリティ / 非root / .dockerignore)を押さえながら、すぐ実務に使える書き方 を、ハンズオン形式で身につける構成にしました。


この記事でわかること

  • Dockerfileベストプラクティスの全体像(速い・軽い・安全)
  • ビルドキャッシュを効かせるDockerfileの書き方
  • イメージ軽量化(slim/alpineの考え方、不要物の除去、マルチステージ)
  • セキュアなDockerfile(非root、最小権限、秘密情報を埋め込まない)
  • apt-get / pip / npm の“やりがち”アンチパターンと改善例
  • .dockerignore の重要性と設定例
  • ハンズオン:悪いDockerfile→改善→最適化→セキュア化まで

Dockerfileの“良し悪し”は何で決まる?

実務で「良いDockerfile」とされるものは、だいたい次の条件を満たします。

1) 再現性(Reproducible)

誰が、いつ、どこでビルドしても、同じ成果物になりやすいこと。
依存関係の固定(lockファイルやバージョン固定)や、意図しないキャッシュの暴発を避けることが重要です。

2) ビルドが速い(Fast build)

CIで毎回遅いと開発体験が崩れます。
キャッシュを壊しにくいCOPY順序 と、依存インストール層の分離が鍵です。

3) イメージが軽い(Small image)

軽いほど配布が速く、起動が速く、脆弱性の面積も減ります。
マルチステージビルドや不要物削除が効きます。

4) セキュア(Secure)

“動けばOK”のDockerfileは、root実行や秘密情報混入など、事故の温床になりがちです。
最低限のガード(非root、最小パッケージ、シークレットの扱い)を入れましょう。


まず押さえるベストプラクティス10選

  1. 小さめのベースイメージを選ぶslim / distroless 等を検討)
  2. .dockerignore を必ず書く(node_modulesや.gitの混入防止)
  3. 依存関係のインストールは先に分離(キャッシュを最大化)
  4. ビルド成果物と実行環境を分ける(マルチステージ)
  5. RUNをまとめてレイヤー数と容量を減らす
  6. パッケージマネージャのキャッシュを消す(apt/pip/npm)
  7. バージョン固定(lockファイル/タグ固定)で再現性を高める
  8. rootで動かさない(USER指定)
  9. 秘密情報をDockerfileに書かない(ENVに埋めない)
  10. HEALTHCHECKや最小権限など運用前提の設計をする

このあと、ハンズオンで「悪い例→改善」を通して、体で覚えます。


ハンズオン:悪いDockerfileを改善していく(Node.js例)

準備:サンプルアプリを作る

作業ディレクトリを作って、以下を配置してください。

package.json

{
  "name": "dockerfile-best-practices",
  "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("Dockerfile Best Practices!"));
app.get("/health", (_req, res) => res.json({ ok: true }));

app.listen(3000, () => console.log("listening on 3000"));

STEP1:よくある“悪いDockerfile”(アンチパターン)

まず、あえて悪い例を作ってビルドします。

Dockerfile.bad

FROM node:20

WORKDIR /app
COPY . .

RUN npm install
EXPOSE 3000
CMD ["node", "server.js"]

何が問題?

  • COPY . . が早すぎて キャッシュが壊れやすい
  • npm install は環境差が出やすい(lock未活用)→ 再現性が落ちる
  • node:20slimより大きくなりやすい
  • .dockerignore がないと不要物混入で太る

ビルドしてサイズを見てみます:

docker build -t df-bad -f Dockerfile.bad .
docker images | head

STEP2:キャッシュが効くDockerfileに改善(依存の分離)

次に改善版です。

Dockerfile

FROM node:20-slim

WORKDIR /app

# 依存関係だけ先にコピー(キャッシュが効く)
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev

# アプリ本体を後でコピー(変更頻度が高い)
COPY server.js ./

EXPOSE 3000
CMD ["npm", "start"]

ビルド:

docker build -t df-good:1.0 .
docker images | head

改善ポイント

  • 依存のインストール層が再利用されやすい
  • npm ci は lockファイル前提で再現性が上がる(CI向き)

もし package-lock.json がない場合は、ローカルで npm i して生成してから進めると、より“実務の形”になります。


STEP3:.dockerignore でビルドコンテキストを減らす

Dockerfileが良くても、コンテキストがデカいと終わります。
以下を追加します。

.dockerignore

node_modules
npm-debug.log
.git
.gitignore
Dockerfile*
README.md

再ビルドして、体感的に速くなるか確認:

docker build -t df-good:1.1 .

STEP4:マルチステージで「ビルド用」と「実行用」を分離

今回のアプリはビルド工程が軽いですが、本番ではTypeScriptやフロントビルドで必須になります。
“形”として覚えるためにマルチステージにします。

Dockerfile(multi-stage)

# ---- build stage ----
FROM node:20-slim AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev
COPY server.js ./

# ---- runtime stage ----
FROM node:20-slim
WORKDIR /app
COPY --from=build /app /app

EXPOSE 3000
CMD ["node", "server.js"]

ビルド:

docker build -t df-good:2.0 .
docker images | head

セキュリティベストプラクティス(Dockerfileで最低限やること)

1) 非rootユーザーで実行する(最重要)

Dockerfileを書き慣れてないと、rootで動かしがちです。
しかし本番では コンテナ突破時の被害を小さくする ために、できる限り非rootで動かします。

Node公式イメージには node ユーザーが用意されています。これを使います。

改善例

FROM node:20-slim
WORKDIR /app

COPY package.json package-lock.json* ./
RUN npm ci --omit=dev

COPY server.js ./
EXPOSE 3000

USER node
CMD ["node", "server.js"]

起動:

docker build -t df-secure:1.0 .
docker run --rm -p 3000:3000 df-secure:1.0

2) 秘密情報をDockerfileに書かない

やりがち:

ENV API_KEY=xxxxx

これは イメージに残る(履歴やレイヤーに残る)可能性があり危険です。
秘密情報は実行時に渡します。

例:

docker run --rm -e API_KEY=xxxx -p 3000:3000 df-secure:1.0

(本番は Compose / Kubernetes Secrets などへ)


3) apt-getの正しい書き方(キャッシュとサイズ)

Debian系(slim等)でよくやるのが apt-get
悪い例:

RUN apt-get update
RUN apt-get install -y curl

良い例(1RUNにまとめる、不要キャッシュ削除):

RUN apt-get update \
  && apt-get install -y --no-install-recommends curl \
  && rm -rf /var/lib/apt/lists/*
  • --no-install-recommends で余計な依存を減らす
  • /var/lib/apt/lists を消して容量削減
  • 1つのレイヤーにまとめて効率化

4) COPYよりADDを乱用しない

ADD はURL取得やtar自動解凍など余計な挙動があります。
基本は COPY を使い、意図がある場合のみADDを使います。


5) HEALTHCHECK(運用で効く)

KubernetesやComposeでも死活監視は大事です。
アプリに /health があるならDockerfileでHealthcheckを入れる選択肢があります。

例:

HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD node -e "require('http').get('http://localhost:3000/health', r => process.exit(r.statusCode===200?0:1)).on('error', () => process.exit(1))"

(本番はオーケストレーター側で管理することも多いので、方針に合わせて採用してください)


ハンズオン:ビルド最適化の“効き”を確認する

1) レイヤーを確認(どこが太いか)

docker history df-good:2.0

2) 詳細設定を確認(CMD/USER/ENV)

docker inspect df-secure:1.0

3) キャッシュが効いているかを体感

server.js の文字を少し変えて再ビルド:

docker build -t df-good:2.1 .

依存インストールがスキップされればOK(ログで分かります)。


よくある質問(FAQ)

Q. alpineは使うべき?

「小さい」ことは魅力ですが、ネイティブ依存(node-gyp等)でハマることがあります。
まずは slim で安定運用し、必要に応じてalpineを検討するのが現実的です。

Q. FROMのタグは固定すべき?

本番では再現性のために固定が強いです(例:node:20.11.1-slim のように)。
ただしセキュリティ更新も取り込みたいので、チームの更新ポリシー(定期更新)とセットで考えます。


まとめ:これだけ守れば実務で強いDockerfileになる

Dockerfileベストプラクティスは、最終的に次の3つに集約されます。

  • キャッシュ最適化:依存を先に、アプリを後に(COPY順序)
  • 軽量化:.dockerignore、不要キャッシュ削除、マルチステージ
  • セキュリティ:非root、最小パッケージ、秘密情報を埋めない

投稿日

カテゴリー:

投稿者:

タグ:

コメント

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です