DockerのマルチステージビルドでGoのイメージを981MBから6.53MBにする

Docker
スポンサーリンク

GoのアプリのDockerイメージを、マルチステージビルドで小さくする方法です。単一ステージのイメージと、scratch・distroless・alpineを使ったマルチステージのイメージを実際に作り、サイズと動作を比べました。scratchで実際に起きたエラーと、その直し方も載せています。

結論

単一ステージで981MBだったイメージが、scratchを使ったマルチステージで6.53MBになりました。scratchには証明書とタイムゾーンのデータがないので、HTTPSの通信とタイムゾーンの読み込みが失敗します。どちらも、ビルド用のステージから証明書をコピーし、-tags timetzdata をつけてビルドすると動きました。

実行用のイメージサイズHTTPSタイムゾーン
単一ステージ(golang:1.27.3 のまま)981MB○○
scratch(CGO_ENABLED=0 だけ)8.7MB××
scratch(証明書と -tags timetzdata を追加)9.34MB○○
distroless(static-debian13)11.1MB○○
alpine(3.24.3)17.1MB○×
scratch(証明書、-tags timetzdata、-trimpath -ldflags="-s -w")6.53MB○○

最後の行のDockerfileです。

FROM golang:1.27.3 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -tags timetzdata -o /app .

FROM scratch
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /app /app
ENTRYPOINT ["/app"]

動作確認した環境

  • Go 1.27.3(Dockerイメージ golang:1.27.3)
  • 実行用のイメージ: scratch、gcr.io/distroless/static-debian13(digestで固定)、alpine:3.24.3
  • Docker 29.7.2(Windows 11 の Docker Desktop、Linuxコンテナの設定、x86_64)
  • 確認日: 2026年11月5日

この記事は、MacとWindowsの両方の読者を対象にしています。ビルドと実行はDockerの中で行うので、使うのは docker のコマンドだけです。この記事のコマンドは、Claude(AI)が実行して確認しました。本文の出力は、Windows 11 のGit Bashから上から順に実行した結果です。PowerShellで実行した結果は、最後の章にまとめました。Macでは確認していません。

イメージのサイズは、CPUの種類(x86_64かArmか)で変わります。Apple シリコンのMacでは、Armのイメージができるので、数字は今回と同じにはならない見込みです(未確認)。

準備

空のフォルダに、次の2つのファイルを作ります。引数で動きを変える小さなプログラムで、hello はGoのバージョンを表示し、https は https://go.dev/ に接続し、tz は日本時間を表示します。まず go.mod です。

module example.com/hello

go 1.27

次に main.go です。

package main

import (
	"fmt"
	"net/http"
	"os"
	"runtime"
	"time"
)

func main() {
	cmd := "hello"
	if len(os.Args) > 1 {
		cmd = os.Args[1]
	}
	switch cmd {
	case "hello":
		fmt.Println("hello from", runtime.Version())
	case "https":
		resp, err := http.Get("https://go.dev/")
		if err != nil {
			fmt.Println("error:", err)
			os.Exit(1)
		}
		resp.Body.Close()
		fmt.Println("status:", resp.Status)
	case "tz":
		loc, err := time.LoadLocation("Asia/Tokyo")
		if err != nil {
			fmt.Println("error:", err)
			os.Exit(1)
		}
		t := time.Date(2026, 11, 5, 0, 0, 0, 0, time.UTC)
		fmt.Println("Asia/Tokyo:", t.In(loc).Format(time.RFC3339))
	}
}

Dockerfileは、比べるためにファイル名を分けて作ります(Dockerfile.single など)。イメージ名には、確認用に gomulti- をつけています。docker build の出力は省略しています。

WindowsのGit Bashで実行する場合は、先に export MSYS_NO_PATHCONV=1 を実行してください。Git Bashは、/app のような引数をWindowsのパスに変換することがあり、Dockerの公式ドキュメントでもこの設定が案内されています。本文の確認でも、Git Bashでこの設定をして実行しました。

単一ステージ

ビルドに使った golang のイメージを、そのまま実行にも使います。Dockerfile.single です。

FROM golang:1.27.3
WORKDIR /src
COPY . .
RUN go build -o /app .
CMD ["/app"]
$ docker build -f Dockerfile.single -t gomulti-single .
$ docker run --rm gomulti-single
hello from go1.27.3
$ docker run --rm gomulti-single /app https
status: 200 OK
$ docker run --rm gomulti-single /app tz
Asia/Tokyo: 2026-11-05T09:00:00+09:00

すべて動きますが、イメージにはGoのツールやDebianのファイルがすべて入るので、サイズは981MBになりました(サイズは最後にまとめて比べます)。

マルチステージにする

今回のバイナリは、CGO_ENABLED=0 をつけないとscratchで動かなかった

ビルド用のステージで作ったバイナリだけを、空のイメージ scratch にコピーします。まず、go build をそのままにした Dockerfile.multi-cgo です。

FROM golang:1.27.3 AS build
WORKDIR /src
COPY . .
RUN go build -o /app .

FROM scratch
COPY --from=build /app /app
ENTRYPOINT ["/app"]

ビルドは通りましたが、実行すると /app があるのに「no such file or directory」になりました。

$ docker build -f Dockerfile.multi-cgo -t gomulti-multi-cgo .
$ docker run --rm gomulti-multi-cgo
exec /app: no such file or directory

原因は、バイナリがC言語のライブラリ(libc)に依存していることです。golang のイメージでは CGO_ENABLED が 1 でした。同じ go build で作った単一ステージのイメージのバイナリを ldd で調べると、libcに動的リンクされていました。scratchにはlibcも、それを読み込むローダー(/lib64/ld-linux-x86-64.so.2)もないため、実行できません。

$ docker run --rm gomulti-single ldd /app
	linux-vdso.so.1 (0x000076ae3affd000)
	libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x000076ae3adfe000)
	/lib64/ld-linux-x86-64.so.2 (0x000076ae3afff000)
$ docker run --rm gomulti-single go env CGO_ENABLED
1

Goの公式ドキュメントによると、cgoは、Cコンパイラが見つかる環境のネイティブビルドでは既定で有効で、クロスコンパイルのときなどは既定で無効です。すべてのGoのバイナリがlibcに依存するわけではありませんが、今回のプログラムは CGO_ENABLED=0 をつけてビルドすると、scratchで起動できました。Dockerfile.scratch です。

FROM golang:1.27.3 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /app .

FROM scratch
COPY --from=build /app /app
ENTRYPOINT ["/app"]

scratchでは、HTTPSとタイムゾーンが失敗する

今度は起動しました。ただし、HTTPSの通信は証明書を確認できずに失敗し、タイムゾーンも読み込めませんでした。scratchには、証明書(CA証明書)もタイムゾーンのデータも入っていないためです。

$ docker build -f Dockerfile.scratch -t gomulti-scratch .
$ docker run --rm gomulti-scratch
hello from go1.27.3
$ docker run --rm gomulti-scratch https
error: Get "https://go.dev/": tls: failed to verify certificate: x509: certificate signed by unknown authority
$ docker run --rm gomulti-scratch tz
error: unknown time zone Asia/Tokyo

証明書は、ビルド用のステージ(Debian)にある /etc/ssl/certs/ca-certificates.crt をコピーします。タイムゾーンは、-tags timetzdata をつけてビルドすると、タイムゾーンのデータがバイナリに埋め込まれます(Goの公式ドキュメント。約450KB増える)。Dockerfile.scratch-fixed です。

FROM golang:1.27.3 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -tags timetzdata -o /app .

FROM scratch
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /app /app
ENTRYPOINT ["/app"]
$ docker build -f Dockerfile.scratch-fixed -t gomulti-scratch-fixed .
$ docker run --rm gomulti-scratch-fixed
hello from go1.27.3
$ docker run --rm gomulti-scratch-fixed https
status: 200 OK
$ docker run --rm gomulti-scratch-fixed tz
Asia/Tokyo: 2026-11-05T09:00:00+09:00

バイナリのサイズは、-tags timetzdata なしの8,704,061バイトから、9,115,957バイトになりました(約412KB増)。

distrolessとalpineの場合

distrolessの static には、CA証明書とタイムゾーンのデータが入っています(distrolessの公式ドキュメント)。証明書のコピーや -tags timetzdata なしで、すべて動きました。Dockerfile.distroless です。digestで固定しています。

FROM golang:1.27.3 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /app .

FROM gcr.io/distroless/static-debian13@sha256:448810357cc8e67b7208988abdba0f5ec3d3aebee0a5b13eb624979792cffc0f
COPY --from=build /app /app
ENTRYPOINT ["/app"]
$ docker build -f Dockerfile.distroless -t gomulti-distroless .
$ docker run --rm gomulti-distroless
hello from go1.27.3
$ docker run --rm gomulti-distroless https
status: 200 OK
$ docker run --rm gomulti-distroless tz
Asia/Tokyo: 2026-11-05T09:00:00+09:00

alpineは、最後の FROM を alpine:3.24.3 にしただけの Dockerfile.alpine で試しました。HTTPSは動きましたが、タイムゾーンは読み込めませんでした。

$ docker build -f Dockerfile.alpine -t gomulti-alpine .
$ docker run --rm gomulti-alpine
hello from go1.27.3
$ docker run --rm gomulti-alpine https
status: 200 OK
$ docker run --rm gomulti-alpine tz
error: unknown time zone Asia/Tokyo

-trimpath と -ldflags=”-s -w” でさらに小さくする

-ldflags="-s -w" は、バイナリからシンボル表とデバッグ情報を除きます。Goの公式ドキュメントでは、-s だけでも -w が含まれます。-trimpath は、バイナリに残るビルド時のファイルパスを除くオプションです(go build の公式ドキュメント)。冒頭の「結論」に載せた Dockerfile.final で試しました。

$ docker build -f Dockerfile.final -t gomulti-final .
$ docker run --rm gomulti-final
hello from go1.27.3
$ docker run --rm gomulti-final https
status: 200 OK
$ docker run --rm gomulti-final tz
Asia/Tokyo: 2026-11-05T09:00:00+09:00

バイナリは、9,115,957バイトから6,303,904バイトになりました。デバッグ情報がなくなるので、デバッガでの調査がしにくくなる点には注意が必要です。

サイズを比べる

$ docker image ls --filter "reference=gomulti-*"
IMAGE                          ID             DISK USAGE   CONTENT SIZE   EXTRA
gomulti-alpine:latest          7ab9466363a3       17.1MB             0B
gomulti-distroless:latest      ed534e485d1f       11.1MB             0B
gomulti-final:latest           e8382ad9ccc9       6.53MB             0B
gomulti-multi-cgo:latest       42b7fdf6d2ae       8.78MB             0B
gomulti-scratch-fixed:latest   5fa049530bb5       9.34MB             0B
gomulti-scratch:latest         00b5459ee1e2        8.7MB             0B
gomulti-single:latest          6f607a6a81fd        981MB             0B        

Docker 29.7.2 では、イメージのサイズは DISK USAGE の列に表示されました。scratchのイメージは、ほぼバイナリと証明書の大きさです。distrolessとalpineは、そのぶん少し大きくなりますが、証明書などが最初から入っています。

WindowsのPowerShellで実行する場合

Windows 11 のPowerShell 7.6 と Windows PowerShell 5.1 から、Dockerfile.final を同じコマンドでビルドして実行しました。ファイルの改行はCRLF(Windowsのエディタで保存したとき)にして試しています。どちらのシェルでも、ビルドは通り、3つのコマンドの出力とイメージのサイズ(6.53MB)は本文と同じでした。

PS> docker build -q -f Dockerfile.final -t gomulti-win-ps51 .
PS> docker run --rm gomulti-win-ps51 tz
Asia/Tokyo: 2026-11-05T09:00:00+09:00
PS> docker image ls --filter "reference=gomulti-win-ps51"
IMAGE                     ID             DISK USAGE   CONTENT SIZE   EXTRA
gomulti-win-ps51:latest   9dabb190b79e       6.53MB             0B

上はWindows PowerShell 5.1 の例です。Macのターミナルでも同じ docker のコマンドを使う想定ですが、確認はしていません。

参考

スポンサーリンク
DockerGo
スポンサーリンク
シェアする
hayoをフォローする

コメント

タイトルとURLをコピーしました