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

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

650MB イメージがきっかけで考えたこと

先週の金曜午後、K8s ダッシュボードで 5 分経ってもまだイメージを pull し続けている Pod を見つめながら、イライラしていました。シンプルな Spring Boot アプリが Docker イメージにしたら 650MB。チームに入ったばかりのインターンが「このイメージ、なんでこんなに大きいんですか?」と聞いてきて、私は一瞬固まりました。自分もこの問題を真剣に考えたことがないと、はっと気づいたのです。

その夜、一晩中調べました。翌朝、同じアプリのイメージは 89MB だけ。デプロイ時間は 5 分から 1 分未満に。インターンの目つきが変わりました。

98%
Go イメージ最適化
295MB → 6.47MB
86%
Java イメージ最適化
650MB → 89MB
99.4%
Rust イメージ最適化
2GB → 11MB
89MB
最適化後のイメージサイズ
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 MB6.47 MB98%
Java Spring Boot650 MB89 MB86%
Rust アプリ2.1 GB11.2 MB99.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 は、コンパイルツールとベースイメージのオーバーヘッドです。

この肥大化は、ストレージの無駄だけではありません。本当の痛みはここにあります。

  1. イメージ pull が遅い:CI/CD パイプラインで毎回デプロイのたびにイメージを pull する。650MB のイメージはネットワークが悪いと、待ち時間に疑問を抱くほど
  2. セキュリティリスク:本番イメージにコンパイラ、ソースコード、ビルドツールが含まれるのは、攻撃者に道具を渡すようなもの
  3. ビルドキャッシュの無駄: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 つです。

  1. 最初の FROM の後に AS builder を付け、このステージに builder という名前を付ける
  2. 2 つ目の FROM で新しいステージを開始。軽量な alpine イメージを使用
  3. 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.modgo.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.tomlCargo.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——どれも推薦する記事があります。実際の経験から整理した比較表です。

イメージタイプサイズ含まれる内容メリットデメリット適用シーン
scratch0 MB完全空白最小サイズ、攻撃面最小shell なし、デバッグツールなし、CA 証明書なしGo 静的コンパイル、Rust 静的コンパイル
distroless2〜20 MBランタイムライブラリ、CA 証明書shell なし、セキュリティ高、サイズ小デバッグ困難Go、Java、Rust、Node.js
alpine5〜40 MBmusl libc、パッケージマネージャーサイズ小、shell ありmusl libc 互換性問題、DNS 問題glibc 非依存のアプリ
slim70〜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 を推薦しますが、私はいくつかハマりました。

  1. musl libc 互換性:Alpine は glibc ではなく musl libc を使用。Java や Python など一部プログラムで奇妙なバグが出る
  2. DNS 解決の問題:Alpine 上の Go プログラムで DNS 解決タイムアウトが起きることがあり、特別な設定が必要
  3. タイムゾーンの問題:デフォルトでタイムゾーンデータなし。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 点

マルチステージビルドの本質はとてもシンプルです。

  1. ビルドステージ:完全なイメージでコードをコンパイル
  2. 実行ステージ:軽量イメージでアプリを実行
  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

    ステップ1: マルチステージビルドの原理を理解する

    マルチステージビルドの原理:
    • コンパイル言語には、ソースコードを実行ファイルに変換するコンパイラとビルドツールが必要
    • しかし実行時にはそれらは一切不要
    • 従来の Dockerfile は Maven、Gradle、Go コンパイラをすべて詰め込んでいた
    • 引っ越しのときに、内装用の電動ドリルやセメントまで新居に持ち込むようなもの——まったく不要

    問題の核心:
    • コンパイル言語にはコンパイラとビルドツールが必要
    • しかし実行時にはそれらは不要

    マルチステージビルドの手順:
    • 第 1 ステージ(ビルドステージ):完全なビルドイメージ(コンパイラ・ビルドツール付き)でソースをコンパイルし、実行ファイルを生成
    • 第 2 ステージ(実行ステージ):最小の実行イメージ(ランタイムのみ)を使い、第 1 ステージから実行ファイルをコピー
    • 最終イメージにはランタイムと実行ファイルだけが含まれる
  2. 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

    ステップ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

    ステップ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 マルチステージビルドとは何ですか?なぜ必要なのですか?
マルチステージビルドの原理:コンパイル言語にはコンパイラとビルドツールが必要だが、実行時にはそれらは不要。従来の Dockerfile は Maven、Gradle、Go コンパイラをすべて詰め込んでいた。引っ越しのときに内装用の電動ドリルやセメントまで新居に持ち込むようなもの——まったく不要。

問題の核心:コンパイル言語にはコンパイラとビルドツールが必要だが、実行時にはそれらは不要。

マルチステージビルドの手順:
• 第 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 マルチステージビルドはどう実装しますか?
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 マルチステージビルドはどう実装しますか?
Java マルチステージビルド:

第 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日

関連記事

コメント

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