言語を切り替える
テーマを切り替える

Dockerマルチステージビルド実践:Go/Java/RustイメージをGBからMBへ劇的スリム化

650MBのイメージが引き起こした疑問

先週の金曜日の午後、K8sダッシュボードで5分経っても「Pulling image」のままのPodを見つめながら、私はイライラしていました。単純なSpring Bootアプリなのに、Dockerイメージにすると650MBもあるのです。チームの新入りインターン生に「なんでこのイメージこんなに大きいんですか?」と聞かれ、私は一瞬言葉に詰まりました。自分でも当たり前だと思って、真剣に考えたことがなかったのです。

その夜、私は徹底的に調べました。翌朝、同じアプリのイメージは89MBになっていました。デプロイ時間は5分から1分未満に短縮されました。インターン生の私を見る目が変わったのは言うまでもありません。

98%
Goイメージ最適化
295MB → 6.47MB
86%
Javaイメージ最適化
650MB → 89MB
99.4%
Rustイメージ最適化
2GB → 11MB
89MB
最適化後のサイズ

やったことは、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 MB6.47 MB98%
Java Spring Boot650 MB89 MB86%
Rustアプリ2.1 GB11.2 MB99.4%

初めてこの表を見たとき、Rustの2GBから11MBという数字は魔法かと思いました。

原因は明白です。コンパイル型言語はビルドツールを必要とします。

  • Goコンパイラ:約300MB
  • Maven + OpenJDK:約500MB
  • Rustツールチェーン:約1.5GB

従来のDockerfile(やってはいけない例):

FROM golang:1.21
WORKDIR /app
COPY . .
RUN go build -o myapp
CMD ["./myapp"]

これの何が問題か?ベースイメージの golang:1.21 (295MB) をそのまま使っていることです。コンパイル後も、コンパイラやデバッグツールがそのまま残っています。

弊害は容量だけではありません

  1. プルが遅い:CI/CDで毎回650MBを転送していては、ネットワークが少し遅いだけで致命的です。
  2. セキュリティリスク:本番環境にソースコードやコンパイラがあるのは、攻撃者に武器を渡しているようなものです。
  3. キャッシュの無駄:コードを1行変えただけで、巨大なイメージ全体を再ビルドする必要があります。

マルチステージビルド:1つのDockerfile、2つの世界

Docker 17.05で導入されたマルチステージビルドの仕組みは単純です。「ビルド環境」と「実行環境」を分けるのです。

最もシンプルな例を見てみましょう:

# 第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"]

ポイントは:

  1. 最初の FROMAS builder と名前をつける。
  2. 2つ目の FROM で新しいベースイメージ(alpine)から開始。
  3. COPY --from=builder で、ビルドした成果物だけをコピー。

これで、最初のステージにあった295MBのコンパイラたちはすべて破棄されます。残るのは数MBのAlpineと、あなたのアプリだけです。

Goアプリの究極スリム化

Goは静的リンクされたバイナリを生成できるため、最も劇的な効果が得られます。

最適化版 Dockerfile

# ビルドステージ
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"]

結果:6.47MB

解説

  1. CGO_ENABLED=0:C言語ライブラリ依存を排除し、完全な静的バイナリを作成。
  2. -ldflags="-w -s":デバッグ情報とシンボルテーブルを削除し、サイズを縮小。
  3. FROM scratch:Dockerの「何もない」空イメージ。Goの静的バイナリはこれだけで動きます。

注意点scratch にはシェルもタイムゾーンもCA証明書もありません。HTTPS通信が必要な場合は、gcr.io/distroless/static-debian11 を使うか、証明書を手動でコピーする必要があります。

Java/Spring Bootのエレガントな減量

JavaはJRE(Java Runtime Environment)が必要なので、Goほど小さくはなりませんが、それでも効果は絶大です。

最適化版 Dockerfile

# ビルドステージ
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

結果:650MB → 89MB

解説

  1. JDK vs JRE:ビルドにはJDKが必要ですが、実行はJREで十分です。JREはJDKの半分以下のサイズです。
  2. Mavenキャッシュ:pom.xmlだけ先にコピーして依存をダウンロードすることで、ソースコード変更時に依存解決をスキップできます。

Rustアプリの最小構成

Rustのツールチェーンは巨大ですが、生成物は非常にコンパクトです。

最適化版 Dockerfile

# ビルドステージ
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"]

結果:2.1GB → 11.2MB

解説
Rustの依存コンパイルは時間がかかります。ダミーの main.rs を作って cargo build することで、依存ライブラリのコンパイル結果をキャッシュさせ、ソース変更時のビルド時間を劇的に短縮しています。

ベストプラクティスとよくある罠

  1. .dockerignore を忘れない
    .git ディレクトリや node_modules、ローカルの target ディレクトリはDockerコンテキストに送る必要はありません。.dockerignore ファイルでこれらを除外すると、ビルド開始が速くなります。

  2. ベースイメージのバージョン固定
    golang:latest はやめましょう。いつの間にかバージョンが上がり、ビルドが壊れる原因になります。golang:1.21golang:1.21-alpine のように指定しましょう。

  3. Distrolessイメージの活用
    Googleが提供する distroless イメージ(gcr.io/distroless/...)は、シェルやパッケージマネージャを含まない最小限の実行用イメージです。セキュリティ的に非常に堅牢でおすすめです。

基礎イメージ選択ガイド

どれを選べばいいかわからない?目安はこちら:

イメージタイプサイズ内容メリットデメリット推奨言語
scratch0 MB完全に空最小・最強のセキュリティシェルなし、デバッグ不可Go, Rust (静的ビルド)
distroless2-20 MBランタイムのみシェルなし、セキュアデバッグ困難Go, Java, Rust, Node
alpine5-40 MBmusl libc, apk小さい、シェルありglibc互換性問題あり汎用
slim70-120 MB最小Debianglibcあり、互換性良少し大きいJava, Python

まとめ:今日からできること

マルチステージビルドの核心は3点です:

  1. ビルドステージ:重装備のイメージでコンパイル
  2. 実行ステージ:最小限のイメージで実行
  3. COPY —from:成果物だけを持ち出す

これは技術的に難しいことではありませんが、意識の問題です。放置された巨大なイメージは、デプロイ速度とセキュリティを蝕みます。

アクションプラン

  1. 手元のプロジェクトを1つ選び、マルチステージ化してみてください。
  2. docker images でサイズを比較してニヤリとしてください。
  3. もし劇的に減ったら、チームに自慢しましょう。

Dockerイメージのダイエットは、最もコストパフォーマンスの高い最適化の1つです。ぜひ試してみてください。

Dockerマルチステージビルド導入フロー

Go/Java/RustアプリのDockerイメージサイズを90%以上削減する具体的な手順

⏱️ Estimated time: 1 hr

  1. 1

    Step1: マルチステージビルドの原理理解

    基本概念:
    • ビルドに必要なツール(コンパイラ等)と実行に必要な環境(ランタイム)を分ける
    • 第1ステージで作ったバイナリやJarファイルだけを、第2ステージの軽量イメージにコピーする
    • 結果として、ビルドツールを含まない超軽量イメージができる
  2. 2

    Step2: Go言語での実践

    Dockerfile構成:
    1. FROM golang:1.21 AS builder でビルド
    2. CGO_ENABLED=0 で静的バイナリ作成
    3. FROM scratch(またはalpine)で実行
    4. COPY --from=builder でバイナリをコピー
    結果:数百MB → 数MB
  3. 3

    Step3: Java/Spring Bootでの実践

    Dockerfile構成:
    1. FROM maven:3.8-jdk-17 AS builder でビルド
    2. mvn package でJar作成
    3. FROM openjdk:17-jre-slim で実行(JDKではなくJREを使うのがコツ)
    4. COPY --from=builder でJarをコピー
    結果:650MB → 89MB

FAQ

マルチステージビルドのメリットは何ですか?
最大のメリットはイメージサイズの劇的な削減です。これによりデプロイ速度が向上し、ストレージコストが下がります。また、ソースコードやビルドツールが最終イメージに含まれないため、セキュリティリスクも低減します。
Scratchイメージとは何ですか?
ScratchはDockerが予約している空のイメージです。中身は空っぽ(0バイト)です。GoやRustのように外部ライブラリに依存しない静的リンクバイナリを作成できる言語では、このScratchイメージ上で直接アプリケーションを動かすことができ、極限まで小さなイメージを作れます。
マルチステージビルドでデバッグはどうすればいいですか?
実行用イメージ(特にDistrolessやScratch)にはシェルがないことが多く、docker execで入れないことがあります。
デバッグ時は docker build --target builder ... のようにターゲットを指定してビルドステージのイメージを作成し、そこに入って調査するのが有効です。

4 min read · 公開日: 2025年12月17日 · 更新日: 2026年1月22日

コメント

GitHubアカウントでログインしてコメントできます

関連記事