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 のコマンドを使う想定ですが、確認はしていません。
参考
- Multi-stage builds | Docker Docs
- Troubleshoot topics for Docker Desktop | Docker Docs(Git Bashのパス変換)
- cgo command – cmd/cgo – Go Packages
- tzdata package – time/tzdata – Go Packages
- link command – cmd/link – Go Packages
- go command – cmd/go – Go Packages
- distroless: Documentation for gcr.io/distroless/base and static

コメント