前回の記事では、Dockerのレイヤ構造とキャッシュの仕組みを学び、ビルドを高速化する方法を紹介しました。
でも、ビルドしたイメージのサイズを確認してみると...
docker images
REPOSITORY TAG SIZE
express-app latest 1.17GB
1GB超え!?
こんな経験はありませんか?
-
docker imagesで見たら、イメージが1GBを超えていた - 本番環境にデプロイしたいけど、このサイズは不安
- 「Alpine使え」と言われたけど、何が違うのかわからない
イメージが大きいと、こんな問題が起きます:
- デプロイに時間がかかる(ネットワーク転送が遅い)
- ストレージを圧迫する
- 脆弱性が含まれるリスクが増える(余計なものが入っている)
この記事では、同じアプリを動かしながらイメージサイズを 10分の1以下 に減らす方法を紹介します。
読み終わる頃には、
- なぜイメージが大きくなるか説明できる
- 用途に応じてベースイメージを選べる
- マルチステージビルドが書ける
状態を目指します。
TL;TD
Dockerイメージを軽量化するテクニックは3つ。
| テクニック | 効果 |
|---|---|
| ベースイメージを変える | 1.1GB → 150MB |
| 本番用依存関係だけインストール | さらに数十MB削減 |
| マルチステージビルド | ビルドツールを除外、最小構成に |
最終的には 1.2GB → 約100MB(10分の1以下)まで減らせます。
事前準備
前回の記事で作成したExpressアプリをそのまま使います。
dockerfile-handson/
├── Dockerfile
├── index.js
├── package.json
├── package-lock.json
└── .dockerignore
今回のゴール:サイズ比較
今回の最終目標を先に見せます。
┌─────────────────────────────────────────────────────────────┐
│ イメージサイズの変化 │
│ │
│ 【Before】 │
│ node:22 + 全依存関係 │
│ ████████████████████████████████████████ 1.2GB │
│ │
│ 【Step 1】ベースイメージを変える │
│ node:22-alpine + 全依存関係 │
│ ██████ 200MB │
│ │
│ 【Step 2】本番用依存関係のみ │
│ node:22-alpine + 本番依存のみ │
│ ████ 150MB │
│ │
│ 【Step 3】マルチステージビルド │
│ ビルド環境と実行環境を分離 │
│ ██ 100MB │
│ │
│ 【発展】Distroless │
│ █ 50MB │
│ │
└─────────────────────────────────────────────────────────────┘
では、順番に試していきましょう。
Step 1:なぜイメージは大きくなるのか
まず、原因を理解しましょう。
ベースイメージの中身
node:22 というベースイメージには何が入っているでしょうか?
┌─────────────────────────────────────────────────────────────┐
│ node:22 の中身 │
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Debian Linux(フルOS) │ │
│ │ ├── apt(パッケージマネージャ) │ │
│ │ ├── bash, sh(シェル) │ │
│ │ ├── gcc, make(ビルドツール) │ │
│ │ ├── git, curl, wget(ユーティリティ) │ │
│ │ └── その他多数... │ │
│ ├─────────────────────────────────────────────────┤ │
│ │ Node.js ランタイム │ │
│ │ ├── node │ │
│ │ └── npm │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ サイズ:約 1.1GB │
│ │
│ 「え、ほとんどOS...?」 │
│ │
└─────────────────────────────────────────────────────────────┘
そうなんです。イメージが大きい主な原因は ベースイメージ です。
あなたのアプリのコードは数KBかもしれませんが、土台が1GB以上あるのです。
本当に全部必要?
Expressアプリを動かすのに必要なものを考えてみましょう。
| 必要なもの | 必要? |
|---|---|
| Node.js ランタイム | ✅ 必須 |
| npm(本番環境) | ❌ インストール済みなら不要 |
| gcc, make | ❌ ビルド時のみ必要 |
| git, curl | ❌ 開発時のみ必要 |
| apt | ❌ 本番では使わない |
実行に必要なのは Node.js だけ。 他は「あると便利」なだけで、本番環境には不要です。
Step 2:ベースイメージを変える
では、軽量なベースイメージに変えてみましょう。
選択肢の比較
Node.js の公式イメージには、いくつかのバリエーションがあります。
| イメージ | ベースOS | 特徴 | サイズ |
|---|---|---|---|
node:22 |
Debian | フル機能、何でもできる | ~1.1GB |
node:22-slim |
Debian(最小) | 不要なパッケージを削除 | ~200MB |
node:22-alpine |
Alpine Linux | 超軽量Linux | ~150MB |
サイズ比較
node:22 ████████████████████████████████████████ (1.1GB)
node:22-slim ████████ (200MB)
node:22-alpine ██████ (150MB)
Alpine Linux とは?
Alpine Linux は、セキュリティと軽量さを重視したLinuxディストリビューションです。
| 特徴 | 説明 |
|---|---|
| サイズ | 約5MB(Debianは約100MB) |
| パッケージマネージャ | apk(apt ではない) |
| シェル | ash(bash ではない) |
| Cライブラリ | musl(glibc ではない) |
注意点:
Alpine は glibc ではなく musl を使っているため、一部のネイティブモジュールが動かないことがあります。
ただし、純粋なJavaScript/Node.jsアプリならほぼ問題ありません。
実際に試してみる
Dockerfileのベースイメージを変更します。
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
CMD ["node", "index.js"]
ビルドしてサイズを確認します。
docker build -t express-app:alpine .
docker images | grep express-app
express-app alpine 198MB
express-app latest 1.17GB
約6分の1になりました!
Step 3:本番用依存関係だけをインストールする
さらに軽量化を進めましょう。
devDependencies とは
package.json には2種類の依存関係があります。
{
"dependencies": {
"express": "^4.19.2"
},
"devDependencies": {
"jest": "^29.7.0",
"typescript": "^5.5.3",
"eslint": "^8.57.0"
}
}
| 種類 | 用途 | 本番で必要? |
|---|---|---|
| dependencies | アプリの実行に必要 | ✅ 必要 |
| devDependencies | 開発・テストに必要 | ❌ 不要 |
┌─────────────────────────────────────────────────────────────┐
│ │
│ 【通常の npm install】 │
│ │
│ node_modules/ │
│ ├── express/ ← 本番で必要 │
│ ├── jest/ ← テスト用(本番では不要) │
│ ├── typescript/ ← ビルド用(本番では不要) │
│ └── eslint/ ← 開発用(本番では不要) │
│ │
│ 「半分以上いらないのでは...?」 │
│ │
└─────────────────────────────────────────────────────────────┘
本番用のみインストールする
npm ci に --omit=dev オプションをつけると、devDependencies をスキップできます。
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
CMD ["node", "index.js"]
npm ci と npm install の違い
| コマンド | 動作 |
|---|---|
npm install |
package.json を見てインストール、package-lock.json を更新することがある |
npm ci |
package-lock.json を厳密に再現、CI/CD向け、高速 |
本番ビルドでは npm ci を使うのがベストプラクティスです。
ビルドしてサイズを確認します。
docker build -t express-app:prod .
docker images | grep express-app
devDependencies がある場合、数十MBの削減が期待できます。
Step 4:マルチステージビルドを理解する
ここからが本記事のメインテーマです。
問題:ビルドと実行で必要なものが違う
TypeScriptプロジェクトを例に考えてみましょう。
┌─────────────────────────────────────────────────────────────┐
│ │
│ 【ビルド時に必要】 【実行時に必要】 │
│ │
│ - TypeScript (tsc) - Node.js │
│ - 型定義ファイル - コンパイル済みJSファイル │
│ - devDependencies - dependencies │
│ │
│ ビルドが終われば こっちだけあれば │
│ もう使わない アプリは動く │
│ │
└─────────────────────────────────────────────────────────────┘
従来のDockerfileだと、ビルドツールも最終イメージに残ってしまいます。
解決策:マルチステージビルド
マルチステージビルド は、1つのDockerfile内に複数の FROM を書く手法です。
┌─────────────────────────────────────────────────────────────┐
│ マルチステージビルドの流れ │
│ │
│ ┌─────────────────────────────────────┐ │
│ │ Stage 1: Build(ビルド環境) │ │
│ │ │ │
│ │ FROM node:22-alpine AS build │ │
│ │ - 全ての依存関係をインストール │ │
│ │ - TypeScriptをコンパイル │ │
│ │ - dist/ フォルダが生成される │ │
│ │ │ │
│ └──────────────┬──────────────────────┘ │
│ │ │
│ │ 必要なファイルだけコピー │
│ │ (COPY --from=build) │
│ ▼ │
│ ┌─────────────────────────────────────┐ │
│ │ Stage 2: Production(実行環境) │ │
│ │ │ │
│ │ FROM node:22-alpine │ │
│ │ - dist/ だけをコピー │ │
│ │ - 本番用依存関係のみ │ │
│ │ - ビルドツールは含まれない │ │
│ │ │ │
│ └─────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ 最終イメージ(軽量) │
│ │
└─────────────────────────────────────────────────────────────┘
ポイント:
- Stage 1 でビルドに必要な全てを揃える
- Stage 2 には成果物(dist/)だけをコピー
- 最終イメージにはビルドツールが含まれない
Step 5:マルチステージビルドを実践する
実際にTypeScriptプロジェクトで試してみましょう。
TypeScriptの準備
まず、プロジェクトをTypeScript化します。
npm install typescript @types/node @types/express --save-dev
npx tsc --init
tsconfig.json を編集します。
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true
}
}
src/index.ts を作成します。
// src/index.ts
import express, { Request, Response } from 'express';
const app = express();
const port = process.env.PORT || 3000;
app.get('/', (req: Request, res: Response) => {
res.send('Hello from TypeScript!');
});
app.listen(port, () => {
console.log(`Server running on port ${port}`);
});
package.json にビルドスクリプトを追加します。
{
"scripts": {
"build": "tsc",
"start": "node dist/index.js"
}
}
フォルダ構成はこうなります。
dockerfile-handson/
├── src/
│ └── index.ts
├── Dockerfile
├── package.json
├── package-lock.json
├── tsconfig.json
└── .dockerignore
マルチステージのDockerfile
# ================================
# Stage 1: ビルド環境
# ================================
FROM node:22-alpine AS build
WORKDIR /app
# 依存関係をインストール(devDependencies含む)
COPY package*.json ./
RUN npm ci
# ソースコードをコピーしてビルド
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
# ================================
# Stage 2: 実行環境
# ================================
FROM node:22-alpine AS production
WORKDIR /app
# 本番用依存関係のみインストール
COPY package*.json ./
RUN npm ci --omit=dev
# ビルド成果物をコピー
COPY --from=build /app/dist ./dist
# 環境変数とコマンド
ENV PORT=3000
CMD ["node", "dist/index.js"]
各行の解説
Stage 1(ビルド環境)
FROM node:22-alpine AS build
-
AS build:このステージに「build」という名前をつける
RUN npm ci
- TypeScriptコンパイラを含む全依存関係をインストール
RUN npm run build
- TypeScriptをJavaScriptにコンパイル →
dist/が生成される
Stage 2(実行環境)
FROM node:22-alpine AS production
- 新しいステージを開始(Stage 1 の内容はリセット)
RUN npm ci --omit=dev
- 本番用依存関係のみインストール(TypeScriptは含まない)
COPY --from=build /app/dist ./dist
-
--from=build:Stage 1 からファイルをコピー - ビルド成果物(dist/)だけを持ってくる
.dockerignore の更新
# .dockerignore
node_modules
dist
.git
*.log
.env
dist を追加しました。ローカルのビルド成果物はコピーせず、Dockerコンテナ内でビルドします。
ビルドと確認
docker build -t express-app:multistage .
docker images | grep express-app
express-app multistage 105MB
express-app alpine 198MB
express-app latest 1.17GB
約100MB まで減りました!
動作確認
docker run -p 3000:3000 express-app:multistage
ブラウザで http://localhost:3000 にアクセスし、Hello from TypeScript! と表示されれば成功です。
Step 6:【発展】Distrolessイメージ
さらに軽量化を目指すなら、Distroless イメージがあります。
Distroless とは
Googleが提供する、アプリケーション実行に必要な最小限のみ を含むイメージです。
┌─────────────────────────────────────────────────────────────┐
│ │
│ 【通常のイメージ】 【Distroless】 │
│ │
│ - OS(Debian/Alpine) - ランタイムのみ │
│ - シェル(bash/sh) - シェルなし │
│ - パッケージマネージャ - パッケージマネージャなし │
│ - 各種ユーティリティ - ユーティリティなし │
│ - ランタイム │
│ │
│ 「いろいろ便利」 「本当に必要なものだけ」 │
│ │
└─────────────────────────────────────────────────────────────┘
メリットとデメリット
| メリット | デメリット |
|---|---|
| 極めて軽量(数十MB) |
docker exec でシェルに入れない |
| 脆弱性が少ない | デバッグが難しい |
| 攻撃対象が少ない | 学習コストがある |
Distroless版のDockerfile
# ================================
# Stage 1: ビルド環境
# ================================
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
# ================================
# Stage 2: 依存関係の準備
# ================================
FROM node:22-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
# ================================
# Stage 3: 実行環境(Distroless)
# ================================
FROM gcr.io/distroless/nodejs22-debian12
WORKDIR /app
# 依存関係とビルド成果物をコピー
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
ENV PORT=3000
CMD ["dist/index.js"]
ここで気になる点がありませんか?
# 今までの書き方
CMD ["node", "dist/index.js"]
# Distroless での書き方
CMD ["dist/index.js"] # ← node がない!
これを理解するには、CMD と ENTRYPOINT の違いを知る必要があります。
CMD と ENTRYPOINT の違い
どちらも「コンテナ起動時に実行するコマンド」を指定しますが、役割が違います。
| 命令 | 役割 | 上書き |
|---|---|---|
ENTRYPOINT |
メインの実行コマンド(固定) |
docker run --entrypoint で上書き可能 |
CMD |
デフォルトの引数 |
docker run の引数で簡単に上書き |
┌─────────────────────────────────────────────────────────────┐
│ ENTRYPOINT と CMD の関係 │
│ │
│ ENTRYPOINT ["node"] + CMD ["index.js"] │
│ ↓ ↓ │
│ 固定部分 引数(上書き可能) │
│ │ │ │
│ └───────┬──────────────┘ │
│ ▼ │
│ node index.js ← 実際に実行されるコマンド │
│ │
│ docker run my-app other.js とすると... │
│ node other.js ← CMD が上書きされる │
│ │
└─────────────────────────────────────────────────────────────┘
Distroless で node を書かない理由
Distroless の Node.js イメージは、あらかじめ ENTRYPOINT に node が設定されています。
# Distroless イメージの内部設定(イメージ提供者が設定済み)
ENTRYPOINT ["node"]
# あなたが書くのはこれだけ
CMD ["dist/index.js"]
# 合体して実行される → node dist/index.js
だから CMD ["node", "dist/index.js"] と書くと、実際には node node dist/index.js となってエラーになります。
いつ ENTRYPOINT を使う?
- CMD だけで十分:ほとんどの場合
-
ENTRYPOINT が有効:
- コンテナを「特定のコマンド専用」にしたいとき
- Distroless のように「ランタイムを固定」したいとき
-
docker run image 引数の形で使いたいツール系イメージ
初心者のうちは CMD だけで問題ありません。Distroless のように「イメージ側で ENTRYPOINT が設定されている場合がある」ということだけ覚えておきましょう。
サイズ確認
docker build -t express-app:distroless .
docker images | grep express-app
express-app distroless 52MB
express-app multistage 105MB
express-app alpine 198MB
express-app latest 1.17GB
約50MB! 最初の1.2GBから 約24分の1 になりました。
シェルがないことを確認
docker run -d --name test-distroless express-app:distroless
docker exec -it test-distroless sh
OCI runtime exec failed: exec failed: unable to start container process:
exec: "sh": executable file not found in $PATH: unknown
シェルがないので、docker exec でログインできません。
これがセキュリティ上のメリットであり、デバッグ時のデメリットでもあります。
docker stop test-distroless && docker rm test-distroless
まとめ:軽量化の結果比較
お疲れさまでした。この記事で実践した軽量化の結果をまとめます。
サイズ比較
| 構成 | サイズ | 削減率 |
|---|---|---|
| node:22 + 全依存関係 | 1.17GB | - |
| node:22-alpine | 198MB | -83% |
| alpine + 本番依存のみ | ~150MB | -87% |
| マルチステージビルド | 105MB | -91% |
| Distroless | 52MB | -96% |
Before: ████████████████████████████████████████████████ (1.17GB)
After: ██ (52MB)
選び方ガイド
| 状況 | おすすめ |
|---|---|
| とりあえず動かしたい | node:22-alpine |
| 本番環境で使いたい | マルチステージ + alpine |
| セキュリティ最優先 | マルチステージ + Distroless |
| デバッグしやすさ重視 | alpine(シェルあり) |
良いDockerfileのテンプレート(TypeScript)
# Stage 1: Build
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
# Stage 2: Production
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
ENV PORT=3000
CMD ["node", "dist/index.js"]
クリーンアップ
ハンズオンで作成したリソースを削除する場合は、以下を実行してください。
# イメージの削除
docker rmi express-app:latest express-app:alpine express-app:multistage express-app:distroless
# 不要なイメージを一括削除
docker image prune
おわりに
この記事では、Dockerイメージを軽量化する3つのテクニックを学びました。
- ベースイメージを変える:node:22 → node:22-alpine
- 本番用依存関係のみ:npm ci --omit=dev
- マルチステージビルド:ビルド環境と実行環境を分離
「イメージが1GB超えてる...」という悩みは、これで解決できるはずです。