0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【図解ハンズオン】Dockerイメージを10分の1に軽量化する3つのテクニック

0
Posted at

前回の記事では、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 cinpm 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 がない!

これを理解するには、CMDENTRYPOINT の違いを知る必要があります。

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 イメージは、あらかじめ ENTRYPOINTnode が設定されています。

# 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超えてる...」という悩みは、これで解決できるはずです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?