Docker マルチステージビルド実践:Go/Java/Rust イメージを GB から MB へ徹底スリム化
650MB イメージがきっかけで考えたこと
先週の金曜午後、K8s ダッシュボードで 5 分経ってもまだイメージを pull し続けている Pod を見つめながら、イライラしていました。シンプルな Spring Boot アプリが Docker イメージにしたら 650MB。チームに入ったばかりのインターンが「このイメージ、なんでこんなに大きいんですか?」と聞いてきて、私は一瞬固まりました。自分もこの問題を真剣に考えたことがないと、はっと気づいたのです。
その夜、一晩中調べました。翌朝、同じアプリのイメージは 89MB だけ。デプロイ時間は 5 分から 1 分未満に。インターンの目つきが変わりました。
実は Dockerfile の数行を変えただけです。このテクニックは「マルチステージビルド」と呼ばれます。
正直、最初は「大したことないだろう」と思っていました。チームの Go プロジェクトは 295MB、Java プロジェクトは 500MB 超えが当たり前。慣れると Docker イメージはそんなもんだと思い込んでいました。ある日、誰かが Rust イメージを 2GB から 11MB に圧縮した話を見て——そう、GB から MB です——自分がずっと間違った方法でイメージを作っていたのではないか、と気づきました。
問題の核心はこうです。コンパイル言語にはコンパイラとビルドツールが必要ですが、実行時にはそれらは一切不要。従来の Dockerfile は Maven、Gradle、Go コンパイラをすべて詰め込んでいました。引っ越しのときに、内装用の電動ドリルやセメントまで新居に持ち込むようなもの——まったく不要です。
今日は Go、Java、Rust の 3 つの実例で、マルチステージビルドを使ってイメージをスリム化する方法を紹介します。
- Go アプリ:295MB → 6.47MB(98% 削減)
- Java Spring Boot:650MB → 89MB(86% 削減)
- Rust アプリ:2GB → 11.2MB(99.4% 削減)
特定の言語だけ気になる場合は、該当セクションに直接ジャンプしてください。各ケースは独立しており、コードをそのままコピーして実行できます。
なぜイメージはこんなに肥大化するのか
まず、以前テストした実データの比較表を見てください。
| 言語 | 従来のシングルステージビルド | マルチステージビルド | 削減率 |
|---|---|---|---|
| Go アプリ | 295 MB | 6.47 MB | 98% |
| Java Spring Boot | 650 MB | 89 MB | 86% |
| Rust アプリ | 2.1 GB | 11.2 MB | 99.4% |
この表を初めて見たとき、私は呆然としました。特に Rust の数字——2GB から 11MB。最適化というより魔法です。
その後、イメージの構成を詳しく分析して、問題の所在がわかりました。コンパイル言語の特徴は、コンパイラがソースコードをバイナリ実行ファイルに変換することです。Go には golang コンパイラ、Java には Maven か Gradle、Rust には Cargo が必要。これらのツールはどれもサイズが大きいです。
- Go コンパイラ:約 300MB
- Maven + OpenJDK:約 500MB
- Rust ツールチェーン:約 1.5GB
従来の Dockerfile はこんな感じです。
FROM golang:1.21
WORKDIR /app
COPY . .
RUN go build -o myapp
CMD ["./myapp"]
問題なさそうに見えますよね?実は大問題です。この Dockerfile は golang:1.21 イメージ全体(295MB)をベースにしており、Go コンパイラ、各種ビルドツール、デバッグツールがすべて含まれています。アプリのコンパイルが終わっても、これらは 1 つも削除されず、最終イメージにすべて詰め込まれます。
スケルトン状態の家を買って、内装業者にリフォームを依頼したところ。工事が終わったのに、セメント、電動ドリル、切断機、職人の工具箱を全部部屋に閉じ込めたまま引っ越す——こんな話、笑えますよね。でも多くの人の Dockerfile はまさにそうなっています。
実行時に本当に必要なのは、コンパイル済みのバイナリファイルだけです。Go のバイナリは通常数 MB から数十 MB。Java の jar も少し大きめですが、数十 MB 程度。Rust のバイナリも小さい。残りの数百 MB は、コンパイルツールとベースイメージのオーバーヘッドです。
この肥大化は、ストレージの無駄だけではありません。本当の痛みはここにあります。
- イメージ pull が遅い:CI/CD パイプラインで毎回デプロイのたびにイメージを pull する。650MB のイメージはネットワークが悪いと、待ち時間に疑問を抱くほど
- セキュリティリスク:本番イメージにコンパイラ、ソースコード、ビルドツールが含まれるのは、攻撃者に道具を渡すようなもの
- ビルドキャッシュの無駄:1 行コードを変えるだけでイメージ全体を再ビルド。コンパイラとコードが一体化しているため
あるとき Alibaba Cloud にデプロイした際、チーム 5 人が同時に新バージョンをデプロイし、各自 500MB 超のイメージで社内帯域を使い切ったことがあります。その後、イメージ最適化を本気で調べる決意をしました。
マルチステージビルド:1 つの Dockerfile で 2 つの環境
マルチステージビルドは Docker 17.05 で導入された機能です。核心はとてもシンプル。1 つの Dockerfile 内に複数のステージを定義し、前半のステージでコンパイル、後半で実行。途中で必要な成果物だけを渡します。
わかりやすく言うと:第 1 ステージで内装業者が家を仕上げ、仕上がった家だけを第 2 ステージに運び、セメントや電動ドリルは第 1 ステージに残す——そんなイメージです。
最もシンプルな例を見てみましょう。
# 第 1 ステージ:ビルドステージ
FROM golang:1.21 AS builder
WORKDIR /app
COPY . .
RUN go build -o myapp
# 第 2 ステージ:実行ステージ
FROM alpine:3.18
WORKDIR /app
COPY --from=builder /app/myapp .
CMD ["./myapp"]
ポイントは 3 つです。
- 最初の FROM の後に
AS builderを付け、このステージに builder という名前を付ける - 2 つ目の FROM で新しいステージを開始。軽量な alpine イメージを使用
- COPY —from=builder で builder ステージからコンパイル済みバイナリだけをコピー。他はすべて破棄
第 1 ステージの golang:1.21 イメージは 295MB ありますが、最終イメージは alpine のサイズ(5MB)にバイナリ(数 MB)を足した程度——合計 10 数 MB 程度です。
この原理を理解したとき、頭の中で電球が点灯しました——「結果だけ欲しくて、過程は不要」ということです。コンパイラ、ソースコード、中間ファイルはすべて過程。最終的なバイナリこそが結果。Docker は第 1 ステージのイメージを自動的に捨て、第 2 ステージだけを残します。
第 1 ステージをデバッグしたい場合は?Docker には非常に便利なコマンドがあります。
docker build --target builder -t myapp:debug .
--target builder は builder ステージまでだけビルドし、第 2 ステージは実行しない、という意味です。第 1 ステージのコンテナに入ってデバッグできます。
この設計は本当にエレガントです。3 段、4 段、それ以上のステージも可能です。
FROM node:18 AS frontend-builder
# フロントエンドビルド
FROM golang:1.21 AS backend-builder
# バックエンドビルド
FROM nginx:alpine
# フロントエンドとバックエンドの成果物をコピー
COPY --from=frontend-builder /app/dist /usr/share/nginx/html
COPY --from=backend-builder /app/api /usr/local/bin/api
各ステージが独立してタスクを完了し、最後に実行ステージに集約されます。Dockerfile のロジックがとても明確になります。
今では、コンパイル言語の Dockerfile を書くとき、デフォルトでマルチステージビルドを使う習慣が身についています。
Go アプリの徹底スリム化
Go はイメージ最適化が最も好きな言語です。なぜか?Go がコンパイルするバイナリは静的リンクで、システムライブラリに依存しない。空白イメージにそのまま投げ込んで動かせます。
まず従来のやり方(真似しないでください)。
FROM golang:1.21
WORKDIR /app
COPY . .
RUN go build -o myapp
CMD ["./myapp"]
ビルドしてみます。
docker build -t myapp:old .
docker images myapp:old
# REPOSITORY TAG IMAGE ID SIZE
# myapp old abc123def456 295MB
295MB。シンプルな HTTP サービスにしては大きすぎます。
マルチステージ最適化版を見てみましょう。
# ビルドステージ
FROM golang:1.21-alpine AS builder
WORKDIR /app
# 依存ファイルをコピーしてダウンロード(キャッシュ活用)
COPY go.mod go.sum ./
RUN go mod download
# ソースコードをコピーしてコンパイル
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o myapp .
# 実行ステージ
FROM scratch
WORKDIR /app
COPY --from=builder /app/myapp .
EXPOSE 8080
CMD ["./myapp"]
もう一度ビルドします。
docker build -t myapp:new .
docker images myapp:new
# REPOSITORY TAG IMAGE ID SIZE
# myapp new def456ghi789 6.47MB
6.47MB!295MB から 6.47MB へ、98% 削減です。
この Dockerfile の重要ポイントは次のとおりです。
1. CGO_ENABLED=0
この環境変数は Go コンパイラに CGO を無効化し、純粋な静的リンクバイナリを生成させます。SQLite など C ライブラリを使っているコードでは効かないので、別のベースイメージが必要です。
2. -ldflags=“-w -s”
コンパイラ最適化パラメータです。
-w:デバッグ情報を除去-s:シンボルテーブルを除去
バイナリをさらに 20%〜30% 小さくできます。本番環境ではほぼ使わない情報なので、削除して問題ありません。
3. FROM scratch
scratch は Docker の空白イメージで、中身はゼロ、サイズ 0 バイト。Go の静的バイナリは scratch 上でそのまま動き、システムライブラリは不要です。
4. 依存キャッシュのテクニック
go.mod と go.sum を先にコピーしてから go mod download を実行している点に注目してください。2 ファイルが変わらない限り、Docker は依存レイヤーのキャッシュを使い、再ダウンロードしません。ビジネスコードを変更しても依存の再ダウンロードは発生せず、ビルドが大幅に速くなります。
このテクニックを使ったあと、コード変更後の再ビルド時間は 2 分から 15 秒に短縮されました。
上級編:タイムゾーンと CA 証明書の扱い
scratch イメージはあまりにもクリーンで、タイムゾーンデータも CA 証明書もありません。HTTPS リクエストを送ったりタイムゾーンを扱うアプリではエラーになります。builder ステージからこれらのファイルをコピーする方法があります。
FROM scratch
WORKDIR /app
# タイムゾーンデータをコピー
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
# CA 証明書をコピー
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/myapp .
ENV TZ=Asia/Shanghai
CMD ["./myapp"]
または gcr.io/distroless/static-debian11 を scratch の代わりに使う方法もあります。このイメージは 2MB だけですが、タイムゾーンデータと CA 証明書が含まれています。
FROM gcr.io/distroless/static-debian11
COPY --from=builder /app/myapp /app/myapp
CMD ["/app/myapp"]
私は今、だいたい distroless を使っています。ずっと楽です。
Java/Spring Boot のスマートなスリム化
Java のイメージ最適化は Go より少し複雑です。Java には JRE 実行環境が必要で、Go のように scratch イメージは使えません。ただし正しい方法を使えば、サイズはかなり圧縮できます。
従来のやり方(多くの Java プロジェクトがこうしています)。
FROM maven:3.8-openjdk-17
WORKDIR /app
COPY . .
RUN mvn clean package -DskipTests
CMD ["java", "-jar", "target/myapp.jar"]
ビルド後のイメージは 650MB。Maven イメージ自体が 500MB、jar と各種キャッシュが加わり、サイズが制御不能になるのは自然です。
マルチステージ最適化版:
# ビルドステージ
FROM maven:3.8-openjdk-17-slim AS builder
WORKDIR /app
# 先に pom.xml をコピーして依存をダウンロード(キャッシュ活用)
COPY pom.xml .
RUN mvn dependency:go-offline -B
# ソースコードをコピーしてパッケージング
COPY src ./src
RUN mvn package -DskipTests
# 実行ステージ
FROM openjdk:17-jre-slim
WORKDIR /app
# jar だけをコピー
COPY --from=builder /app/target/*.jar app.jar
# JVM パラメータ調整
ENV JAVA_OPTS="-Xms128m -Xmx512m -XX:+UseContainerSupport"
EXPOSE 8080
CMD java $JAVA_OPTS -jar app.jar
ビルド後:
docker images myapp:new
# REPOSITORY TAG IMAGE ID SIZE
# myapp new xyz789abc012 89MB
650MB から 89MB へ、86% 削減です。
重要ポイントの解説
1. JDK と JRE
ビルドステージは openjdk-17(コンパイラ付き)、実行ステージは openjdk-17-jre-slim(ランタイムのみ)。JRE は JDK より半分以上小さいです。
- OpenJDK 17:約 500MB
- OpenJDK 17 JRE:約 200MB
- OpenJDK 17 JRE Slim:約 80MB
2. Maven 依存キャッシュ
先に pom.xml をコピーして mvn dependency:go-offline で全依存をダウンロード。pom.xml が変わらなければこのレイヤーはキャッシュされます。ビジネスコードを変更しても依存の再ダウンロードは発生しません。
このテクニックは本当に重要です。以前は使っていなくて、1 行コードを変えるたびに依存を再ダウンロード。Maven リポジトリが海外だと、10 数分止まることも。今は数秒で済みます。
3. JVM パラメータ調整
-XX:+UseContainerSupport は JVM にコンテナのメモリ制限を認識させます。これがないと JVM はホストマシンのメモリでヒープを割り当て、コンテナが OOM になりやすいです。
-Xms128m -Xmx512m はヒープサイズの範囲設定。アプリの実際の需要に合わせて調整してください。盲目的に 1G に設定しないこと。
Gradle 版
Gradle を使う場合は、少し書き方を変えます。
FROM gradle:8.5-jdk17 AS builder
WORKDIR /app
# Gradle 設定ファイルをコピー
COPY build.gradle settings.gradle ./
COPY gradle ./gradle
# 依存をダウンロード
RUN gradle dependencies --no-daemon
# ソースコードをコピーしてビルド
COPY src ./src
RUN gradle bootJar --no-daemon
FROM openjdk:17-jre-slim
WORKDIR /app
COPY --from=builder /app/build/libs/*.jar app.jar
ENV JAVA_OPTS="-Xms128m -Xmx512m -XX:+UseContainerSupport"
CMD java $JAVA_OPTS -jar app.jar
落とし穴:Spring Boot レイヤー化ビルド
Spring Boot 2.3 以降は jar のレイヤー化に対応。依存とビジネスコードを分離し、キャッシュをさらに最適化できます。少し上級ですが、コードはこんな感じです。
FROM maven:3.8-openjdk-17-slim AS builder
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline -B
COPY src ./src
RUN mvn package -DskipTests
RUN java -Djarmode=layertools -jar target/*.jar extract
FROM openjdk:17-jre-slim
WORKDIR /app
# レイヤーの順序でコピー。依存レイヤーは変更頻度が最も低い
COPY --from=builder /app/dependencies/ ./
COPY --from=builder /app/spring-boot-loader/ ./
COPY --from=builder /app/snapshot-dependencies/ ./
COPY --from=builder /app/application/ ./
CMD ["java", "org.springframework.boot.loader.JarLauncher"]
ビジネスコードを変更しても依存レイヤーは無効化されず、ビルドが速くなります。ただ正直、私はあまり使いません。メンテナンスコストがやや高く、上のシンプル版で十分なことが多いからです。
Rust アプリの最小デプロイ
Rust のイメージ最適化効果は最も劇的です。Rust ツールチェーンは超大(1.5GB 超)ですが、コンパイルされるバイナリはとても小さい。初めて最適化効果を見たときは驚きました——2.1GB から 11.2MB。信じられない数字です。
従来のやり方:
FROM rust:1.75
WORKDIR /app
COPY . .
RUN cargo build --release
CMD ["./target/release/myapp"]
ビルド後のイメージは 2.1GB。Rust ツールチェーンが大きすぎます。rustc コンパイラ、cargo、各種依存がすべて詰め込まれます。
マルチステージ最適化版:
# ビルドステージ
FROM rust:1.75 AS builder
WORKDIR /app
# 依存ファイルをコピーし、先に依存をコンパイル(キャッシュ活用)
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release
RUN rm -rf src
# 本物のコードをコピーしてアプリをコンパイル
COPY src ./src
RUN touch src/main.rs # タイムスタンプを更新し、再コンパイルをトリガー
RUN cargo build --release
# 実行ステージ
FROM gcr.io/distroless/cc-debian11
WORKDIR /app
COPY --from=builder /app/target/release/myapp .
CMD ["./myapp"]
ビルド後:
docker images myapp:new
# REPOSITORY TAG IMAGE ID SIZE
# myapp new rst345uvw678 11.2MB
11.2MB!99.4% 削減です。
Rust 特有のキャッシュ最適化
Rust の依存コンパイルは特に遅く、10 数分かかることも珍しくありません。上記テクニックの核心は、ダミーの main.rs を作って cargo に依存をコンパイルさせ、ダミーを削除してから本物のコードをコピーしてコンパイルすることです。
Cargo.toml と Cargo.lock が変わらなければ依存レイヤーはキャッシュされます。ビジネスコードの変更時は自分のコードだけ再コンパイルすればよく、依存の再コンパイルは不要です。
中規模プロジェクトで実測したところ、このテクニックでコード変更後の再ビルド時間は 15 分から 2 分に短縮されました。
静的リンク vs 動的リンク
Rust はデフォルトで glibc に依存するバイナリを生成することがあります。コードが完全に純 Rust で C ライブラリを使っていなければ、musl で完全静的リンクにコンパイルし、scratch イメージを使えます。
FROM rust:1.75 AS builder
WORKDIR /app
# musl ツールチェーンをインストール
RUN rustup target add x86_64-unknown-linux-musl
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release --target x86_64-unknown-linux-musl
RUN rm -rf src
COPY src ./src
RUN touch src/main.rs
RUN cargo build --release --target x86_64-unknown-linux-musl
# 実行ステージ
FROM scratch
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/myapp /myapp
CMD ["/myapp"]
イメージはさらに小さくなり、5〜8MB 程度になることもあります。
ただ正直、私はだいたい gcr.io/distroless/cc を直接使います。互換性が良く、サイズも数 MB 多いだけ。それだけの価値はあります。
落とし穴:Cargo キャッシュの場所
一部の記事は /usr/local/cargo ディレクトリもキャッシュするよう教えています。やめてください。このディレクトリには大量のコンパイル中間成果物があり、キャッシュレイヤーが巨大化して逆に遅くなります。私もこの罠にはまり、キャッシュレイヤーが 800MB になって毎回長時間待たされました。
ビルドを速く安定させる上級テクニック
ここまではマルチステージビルドの基本用法でした。このセクションでは、ビルドをより速く、より安定させるテクニックを紹介します。
1. .dockerignore ファイル
これは非常に重要ですが、多くの人が見落とします。.dockerignore は .gitignore と同様に、Docker にビルドコンテキストへコピーしてほしくないファイルを伝えます。
以前は使っていなくて、毎回 Docker がプロジェクトディレクトリ全体(node_modules、.git、target など含む)を Docker デーモンに送っていました。数百 MB のプロジェクトでは、コンテキスト送信だけで 30 秒待つことも。
今はすべてのプロジェクトで .dockerignore を作成しています。
# バージョン管理
.git
.gitignore
# 依存ディレクトリ
node_modules
target
dist
build
# IDE
.vscode
.idea
*.swp
# テストとドキュメント
**/*_test.go
**/*_test.rs
*.md
docs/
# 環境変数と秘密鍵
.env
.env.local
*.key
*.pem
このファイルを追加してから、ビルド速度は 3〜5 倍に。CI/CD パイプラインでは特に効果が顕著です。
2. マルチステージビルドのデバッグ
前面で --target パラメータに触れましたが、ここで詳しく説明します。builder ステージでビルドが失敗した場合、どうデバッグするか?
# builder ステージまでだけビルド
docker build --target builder -t myapp:debug .
# このイメージに入る
docker run -it myapp:debug sh
# コンテナ内で手動でビルドコマンドを実行し、エラー箇所を特定
非常に実用的な方法です。ある Go プロジェクトでビルドが失敗し、奇妙なエラーが出たことがありました。この方法で builder コンテナに入り、手動で go build を実行したところ、Go バージョンと依存の非互換が原因だとわかりました。
3. ビルドステージへの命名
各ステージに意味のある名前を付けましょう。stage1、stage2 のような名前は避けてください。比較してみてください。
# 良くない
FROM golang:1.21 AS stage1
FROM node:18 AS stage2
FROM nginx AS stage3
# 良い
FROM golang:1.21 AS backend-builder
FROM node:18 AS frontend-builder
FROM nginx AS runtime
数か月後にコードを見返したとき、丁寧に命名した自分に感謝するはずです。
4. BuildKit 並列ビルド
Docker BuildKit は次世代のビルドエンジンで、並列ビルドとより優れたキャッシュ機構をサポートします。有効化方法:
export DOCKER_BUILDKIT=1
docker build .
またはビルド時に一時的に有効化:
DOCKER_BUILDKIT=1 docker build .
BuildKit のメリット:
- 独立した複数ステージを並列ビルド可能
- よりスマートなキャッシュ戦略
- ビルド出力がより見やすい
今はデフォルトで BuildKit を使い、CI/CD でも DOCKER_BUILDKIT=1 を設定しています。
5. ベースイメージのバージョン固定
FROM golang:latest は罠です。本番環境では必ずバージョンを固定してください。
# 良くない — 挙動が不確定
FROM golang:latest
# 良い — バージョンを明示
FROM golang:1.21.5-alpine3.18
# さらに良い — SHA256 で固定
FROM golang@sha256:abc123...
以前、golang:latest が更新されたせいで突然デプロイが失敗し、半日調査したことがあります。依存との非互換が原因でした。それ以来、常にバージョンを固定しています。
6. COPY キャッシュの適切な使い方
COPY 命令の順序は非常に重要です。変更頻度の低いファイルを先に、高いファイルを後に。
# 良い順序
FROM golang:1.21-alpine AS builder
WORKDIR /app
# 1. 依存ファイルを先に(ほとんど変わらない)
COPY go.mod go.sum ./
RUN go mod download
# 2. ソースコードを後に(よく変わる)
COPY . .
RUN go build -o myapp
# 良くない順序
COPY . . # すべてのファイルを一度にコピー
RUN go mod download && go build -o myapp
前者ならコード変更で依存の再ダウンロードは発生しません。後者ならどんなファイルを変更しても依存を再ダウンロードします。
このテクニックは前面のケースで何度も登場しましたが、改めて強調します——キャッシュ最適化の核心原理です。
ベースイメージ選択ガイド
ベースイメージの選択は悩ましいところです。Alpine、Slim、Distroless、Scratch——どれも推薦する記事があります。実際の経験から整理した比較表です。
| イメージタイプ | サイズ | 含まれる内容 | メリット | デメリット | 適用シーン |
|---|---|---|---|---|---|
| scratch | 0 MB | 完全空白 | 最小サイズ、攻撃面最小 | shell なし、デバッグツールなし、CA 証明書なし | Go 静的コンパイル、Rust 静的コンパイル |
| distroless | 2〜20 MB | ランタイムライブラリ、CA 証明書 | shell なし、セキュリティ高、サイズ小 | デバッグ困難 | Go、Java、Rust、Node.js |
| alpine | 5〜40 MB | musl libc、パッケージマネージャー | サイズ小、shell あり | musl libc 互換性問題、DNS 問題 | glibc 非依存のアプリ |
| slim | 70〜120 MB | 軽量版 Debian/Ubuntu | 完全な glibc、互換性良好 | サイズやや大 | C ライブラリ依存のアプリ |
| 完全イメージ | 200MB+ | 完全な OS | すべてのツール付き | サイズ大、セキュリティリスク高 | 本番非推奨 |
私の選択戦略:
Go アプリ
- 第一選択:
gcr.io/distroless/static-debian11(CA 証明書付き) - 代替:
scratch(CA 証明書とタイムゾーンデータを手動コピー) - CGO 使用時:
gcr.io/distroless/base-debian11またはalpine
Java アプリ
- 第一選択:
openjdk:17-jre-slim(完全 JRE、互換性良好) - 上級:
gcr.io/distroless/java17-debian11(より小さく安全だがデバッグ困難) - 避ける:
openjdk:17-alpine(Alpine 上の JVM にはいくつかバグあり)
Rust アプリ
- 第一選択:
gcr.io/distroless/cc-debian11(C ランタイムライブラリ付き) - 完全静的コンパイル時:
scratch - 避ける:完全な
rustイメージ(本番環境では不要)
Alpine の落とし穴
多くの記事が Alpine を推薦しますが、私はいくつかハマりました。
- musl libc 互換性:Alpine は glibc ではなく musl libc を使用。Java や Python など一部プログラムで奇妙なバグが出る
- DNS 解決の問題:Alpine 上の Go プログラムで DNS 解決タイムアウトが起きることがあり、特別な設定が必要
- タイムゾーンの問題:デフォルトでタイムゾーンデータなし。
tzdataパッケージを手動インストールが必要
私の提案:Alpine に詳しくなければ、-slim イメージの方が安全。サイズは数十 MB 多いかもしれませんが、落とし穴は少ないです。
Distroless について
Distroless は Google が提供する最小化イメージで、shell もパッケージマネージャーもなく、ランタイムに必要なものだけが含まれます。
メリット:
- 攻撃面が極小。ハッカーが入っても shell がないのでコマンド実行不可
- サイズが小さい
- 公式メンテナンス、定期的なセキュリティパッチ
デメリット:
- デバッグ困難。
docker exec -itで中に入れない - ビルドステージですべてのファイルを準備する必要がある
今は本番環境ではほぼ Distroless を使っています。デバッグ時は --target builder でビルドステージのコンテナに入り、本番は Distroless で動かします。
意思決定ツリー
どれを選ぶか迷ったら、このフローに従ってください。
アプリの言語は?
├─ Go
│ ├─ 純 Go コード → distroless/static または scratch
│ └─ CGO 使用 → distroless/base または alpine
├─ Java
│ ├─ 安定性重視 → openjdk:jre-slim
│ └─ サイズ重視 → distroless/java
├─ Rust
│ ├─ 純 Rust → distroless/cc または scratch
│ └─ C ライブラリ使用 → distroless/cc
└─ その他
└─ まず slim を試し、問題があれば変更
この意思決定ツリーは実プロジェクトからまとめたもので、90% のシーンをカバーできます。
まとめ:核心は 3 点
マルチステージビルドの本質はとてもシンプルです。
- ビルドステージ:完全なイメージでコードをコンパイル
- 実行ステージ:軽量イメージでアプリを実行
- COPY —from:必要な成果物だけを渡す
それだけです。効果は本当に劇的——サイズ 70%〜90% 削減、キャッシュ最適化後は 2〜3 倍速、セキュリティも大幅向上。
今の習慣は、新プロジェクトの最初の作業がマルチステージ Dockerfile を書くこと。Go、Java、Rust 問わず、デフォルトでこのパターンを使います。もう筋肉記憶になっています。
いくつか行動提案です。
今日すぐできること:
- 既存プロジェクトを 1 つ選び、マルチステージビルドを試す
docker imagesで最適化前後のサイズを比較する- 効果が良ければ、すぐに他のプロジェクトにも展開する
深く学ぶ価値があること:
- Docker 公式ドキュメントのベストプラクティスを読む
diveツールでイメージレイヤーを分析する(docker run --rm -it wagoodman/dive:latest your-image)- BuildKit の高度な機能を調べる
長期最適化の方向性:
- CI/CD パイプラインでイメージキャッシュを設定する
- ベースイメージのバージョンを定期的に更新し、セキュリティ脆弱性を修正する
- セキュリティスキャンツール(Trivy など)でイメージをチェックする
最後に、率直な話を。Docker イメージ最適化は技術的には難しくない。難しいのは意識の問題。多くのチームのイメージは数年前に作られたまま放置され、どんどん肥大化する。ある日デプロイが遅すぎて耐えられなくなって、ようやく最適化を思い出す——そんなパターンが多い。
その日まで待たないでください。今日マルチステージビルドを試してみてください。イメージがこんなに小さく、ビルドがこんなに速くなることに驚くはずです。
コメント欄で教えてください。あなたのプロジェクトのイメージはどのくらいのサイズですか?最適化後の効果は?どんな落とし穴に遭遇しましたか?すべて興味深いです。
Docker マルチステージビルド完全最適化フロー
Go/Java/Rust イメージを GB から MB へ徹底スリム化。完全な Dockerfile とハマりどころの経験談付き
⏱️ 目安時間: 1 時間
- 1
ステップ1: マルチステージビルドの原理を理解する
マルチステージビルドの原理:
• コンパイル言語には、ソースコードを実行ファイルに変換するコンパイラとビルドツールが必要
• しかし実行時にはそれらは一切不要
• 従来の Dockerfile は Maven、Gradle、Go コンパイラをすべて詰め込んでいた
• 引っ越しのときに、内装用の電動ドリルやセメントまで新居に持ち込むようなもの——まったく不要
問題の核心:
• コンパイル言語にはコンパイラとビルドツールが必要
• しかし実行時にはそれらは不要
マルチステージビルドの手順:
• 第 1 ステージ(ビルドステージ):完全なビルドイメージ(コンパイラ・ビルドツール付き)でソースをコンパイルし、実行ファイルを生成
• 第 2 ステージ(実行ステージ):最小の実行イメージ(ランタイムのみ)を使い、第 1 ステージから実行ファイルをコピー
• 最終イメージにはランタイムと実行ファイルだけが含まれる - 2
ステップ2: Go マルチステージビルド実践
Go マルチステージビルド:
第 1 ステージ(ビルドステージ):
• FROM golang:1.21 AS builder
• WORKDIR /app
• COPY go.mod go.sum ./
• RUN go mod download
• COPY . .
• RUN go build -o app
第 2 ステージ(実行ステージ):
• FROM alpine:latest
• RUN apk --no-cache add ca-certificates
• WORKDIR /root/
• COPY --from=builder /app/app .
• CMD ["./app"]
最適化効果:
• コンパイル済みバイナリだけをコピー
• イメージは 295MB から 6.47MB へ(98% 削減)
• デプロイ時間は 5 分から 1 分未満へ
• イメージサイズが大幅に削減 - 3
ステップ3: Java と Rust マルチステージビルド実践
Java マルチステージビルド:
第 1 ステージ(ビルドステージ):
• FROM maven:3.9 AS builder
• WORKDIR /app
• COPY pom.xml .
• RUN mvn dependency:go-offline
• COPY src ./src
• RUN mvn clean package -DskipTests
第 2 ステージ(実行ステージ):
• FROM eclipse-temurin:17-jre-alpine
• WORKDIR /app
• COPY --from=builder /app/target/app.jar app.jar
• CMD ["java", "-jar", "app.jar"]
最適化効果:イメージは 650MB から 89MB へ(86% 削減)
Rust マルチステージビルド:
第 1 ステージ(ビルドステージ):
• FROM rust:1.75 AS builder
• WORKDIR /app
• COPY Cargo.toml Cargo.lock .
• RUN cargo fetch
• COPY src ./src
• RUN cargo build --release
第 2 ステージ(実行ステージ):
• FROM alpine:latest
• RUN apk --no-cache add ca-certificates
• WORKDIR /root/
• COPY --from=builder /app/target/release/app .
• CMD ["./app"]
最適化効果:イメージは 2GB から 11MB へ(99.4% 削減) - 4
ステップ4: ベストプラクティスと長期最適化
ベストプラクティス:
1. 適切な実行イメージを選ぶ
• Go は distroless/static または scratch
• Java は openjdk:jre-slim または distroless/java
• Rust は distroless/cc または scratch
2. .dockerignore で不要なファイルを除外する
3. ビルドキャッシュを活用する(依存ファイルを先に COPY、ソースコードは後)
4. ベースイメージのバージョンを定期的に更新し、セキュリティ脆弱性を修正する
長期最適化の方向性:
• CI/CD パイプラインでイメージキャッシュを設定する
• ベースイメージのバージョンを定期的に更新し、セキュリティ脆弱性を修正する
• セキュリティスキャンツール(Trivy など)でイメージをチェックする
Docker イメージ最適化は技術的には難しくない。難しいのは意識の問題。多くのチームのイメージは数年前に作られたまま放置され、どんどん肥大化する。ある日デプロイが遅すぎて耐えられなくなって、ようやく最適化を思い出す——そんなパターンが多い。
FAQ
Docker マルチステージビルドとは何ですか?なぜ必要なのですか?
問題の核心:コンパイル言語にはコンパイラとビルドツールが必要だが、実行時にはそれらは不要。
マルチステージビルドの手順:
• 第 1 ステージ(ビルドステージ):完全なビルドイメージ(コンパイラ・ビルドツール付き)でソースをコンパイルし、実行ファイルを生成
• 第 2 ステージ(実行ステージ):最小の実行イメージ(ランタイムのみ)を使い、第 1 ステージから実行ファイルをコピー
• 最終イメージにはランタイムと実行ファイルだけが含まれる
マルチステージビルドの最適化効果はどのくらいですか?
• Go アプリは 295MB から 6.47MB へ(98% 削減)
• Java Spring Boot は 650MB から 89MB へ(86% 削減)
• Rust イメージは 2GB から 11MB へ(99.4% 削減)
• デプロイ時間は 5 分から 1 分未満へ
実例:シンプルな Spring Boot アプリを Docker イメージにしたところ 650MB。マルチステージビルド後は 89MB だけ。デプロイ時間は 5 分から 1 分未満に短縮。
Go マルチステージビルドはどう実装しますか?
第 1 ステージは golang:1.21 イメージでコンパイル:
• FROM golang:1.21 AS builder
• WORKDIR /app
• COPY go.mod go.sum ./
• RUN go mod download
• COPY . .
• RUN go build -o app
第 2 ステージは alpine:latest イメージで実行:
• FROM alpine:latest
• RUN apk --no-cache add ca-certificates
• WORKDIR /root/
• COPY --from=builder /app/app .
• CMD ["./app"]
コンパイル済みバイナリだけをコピーし、イメージは 295MB から 6.47MB へ(98% 削減)。デプロイ時間は 5 分から 1 分未満に短縮され、イメージサイズが大幅に削減。
Java と Rust マルチステージビルドはどう実装しますか?
第 1 ステージは maven:3.9 イメージでコンパイル:
• FROM maven:3.9 AS builder
• WORKDIR /app
• COPY pom.xml .
• RUN mvn dependency:go-offline
• COPY src ./src
• RUN mvn clean package -DskipTests
第 2 ステージは eclipse-temurin:17-jre-alpine イメージで実行:
• FROM eclipse-temurin:17-jre-alpine
• WORKDIR /app
• COPY --from=builder /app/target/app.jar app.jar
• CMD ["java", "-jar", "app.jar"]
イメージは 650MB から 89MB へ(86% 削減)
Rust マルチステージビルド:
第 1 ステージは rust:1.75 イメージでコンパイル:
• FROM rust:1.75 AS builder
• WORKDIR /app
• COPY Cargo.toml Cargo.lock .
• RUN cargo fetch
• COPY src ./src
• RUN cargo build --release
第 2 ステージは alpine:latest イメージで実行:
• FROM alpine:latest
• RUN apk --no-cache add ca-certificates
• WORKDIR /root/
• COPY --from=builder /app/target/release/app .
• CMD ["./app"]
イメージは 2GB から 11MB へ(99.4% 削減)
マルチステージビルドのベストプラクティスは何ですか?
1) 適切な実行イメージを選ぶ:
• Go は distroless/static または scratch
• Java は openjdk:jre-slim または distroless/java
• Rust は distroless/cc または scratch
2) .dockerignore で不要なファイルを除外する
3) ビルドキャッシュを活用する(依存ファイルを先に COPY、ソースコードは後)
4) ベースイメージのバージョンを定期的に更新し、セキュリティ脆弱性を修正する
長期最適化の方向性:
• CI/CD パイプラインでイメージキャッシュを設定する
• ベースイメージのバージョンを定期的に更新し、セキュリティ脆弱性を修正する
• セキュリティスキャンツール(Trivy など)でイメージをチェックする
Docker イメージ最適化は技術的には難しくない。難しいのは意識の問題。多くのチームのイメージは数年前に作られたまま放置され、どんどん肥大化する。ある日デプロイが遅すぎて耐えられなくなって、ようやく最適化を思い出す——そんなパターンが多い。
7分で読めます · 公開日: 2025年12月17日 · 更新日: 2026年6月8日
関連記事
Dockerfile入門:ゼロから最初の Docker イメージを作る(実例付き)
Dockerfile入門:ゼロから最初の Docker イメージを作る(実例付き)
Docker vs 仮想マシン:5分で理解する性能差とシーン別選び方ガイド
Docker vs 仮想マシン:5分で理解する性能差とシーン別選び方ガイド
Docker インストールの落とし穴ガイド 2025:permission denied から正常起動までの完全解決策
コメント
GitHubアカウントでログインしてコメントできます