Goアプリケーションをdistrolessイメージでビルドする
作成日
目次
Goアプリケーションのコンテナ軽量化手法を比較・検証
コンテナイメージのサイズを小さくすることは、デプロイの高速化やセキュリティ向上、攻撃対象領域の縮小につながります。
その代表的な手法のひとつが、Googleが提供する Distroless イメージを使った構築です。Distrolessには以下のような特徴があります。
- パッケージマネージャを含まない(apt、yum、apkなどがない)
- シェルを含まない(sh、bash などの対話型シェルがない)
- 最小限のランタイムのみを提供
distrolessイメージのバリエーション
Goアプリケーションをビルドする場合、主に以下の2つのdistrolessイメージが選択肢になります:
gcr.io/distroless/static
gcr.io/distroless/base
baseとstaticの違いはDocumentation for gcr.io/distroless/base, gcr.io/distroless/base-nossl and gcr.io/distroless/staticにまとめられています。
違いをざっくりまとめると以下の通りです:
イメージ | 特徴 |
---|---|
static | 完全静的リンク前提。glibc やlibssl など一切含まない。最小構成。 |
base | glibc 、libssl などの共有ライブラリを含む。CGOを含むアプリに対応可能。 |
distrolessなど各種軽量イメージを使ってビルドしてみる
対象ソースコード
Goのシンプルなアプリケーションを含んだDockerイメージを作成してみます。
次のようなコードです。
あまりにシンプルなのも検証にならないため、外部ライブラリとしてlogrusを利用し、ログ出力しています。また、後述しますが、cgoを利用するようにnet
をimportに追加し、ホスト名のDNS解決処理を行っています。
go run main.go
でcurl localhost:8080
にアクセスできるようになります。
package main
import (
"fmt"
"github.com/sirupsen/logrus"
"net"
"net/http"
)
var log = logrus.New()
func hello(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello World!")
}
func main() {
log.SetLevel(logrus.InfoLevel)
hosts, err := net.LookupHost("localhost")
if err != nil {
log.WithError(err).Error("DNS lookup failed")
} else {
log.WithField("hosts", hosts).Info("DNS lookup succeeded")
}
log.Info("Starting server on :8080")
http.HandleFunc("/", hello)
if err := http.ListenAndServe(":8080", nil); err != nil {
log.WithError(err).Fatal("Server failed to start")
}
}
各イメージによるDockerfileと挙動の違い
ビルドコマンドは共通で、go build -ldflags="-s -w" -trimpath -o main .
を含みます。
-ldflags="-s -w"
:デバッグ情報を省略します。プロダクションビルドでは不要なはずです-trimpath
:バイナリからファイルパスの情報を削除します。これも不要です。
これらのフラグは基本的につけて損がないと思うので、一律でつけています。これにより若干ビルド後のバイナリやイメージサイズが減るはずです。
また、static、baseで共通ですが、distrolessイメージにはnonroot
というユーザーが用意されているため、これを利用します。
取り上げた例はいずれも
docker build -t golang-example .
docker run -p 8080:8080 golang-example
のようにビルド・起動できます。
static
staticの場合次のようなDockerfileになります。
FROM golang:1.23.1-bookworm AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -trimpath -o main .
FROM gcr.io/distroless/static@sha256:765ef30aff979959710073e7ba3b163d479a285d7d96d0020fca8c1501de48cb
COPY /build/main ./main
USER nonroot
ENTRYPOINT ["./main"]
ポイントはCGO_ENABLED=0
をつけていることです。
これがないと、イメージのビルド自体は成功しますが、起動時に、
exec ./main: no such file or directory
exit status 255
となります。これはmainというファイルがないわけではありません。
例えばENTRYPOINT ["./main"]
をENTRYPOINT ["./main_"]
のようにタイポすると
docker: Error response from daemon: failed to create task for container: failed to create shim task: OCI runtime create failed: runc create failed: unable to start container process: error during container init: exec: "./main_": stat ./main_: no such file or directory: unknown
となります。
no such file or directory
となったのは実行時に必要なライブラリがなかったからで、staticイメージにはglibcがないことによります。
そのためCGO_ENABLED=0
をつけてビルドし、静的にリンクする必要があります。
なお、ライブラリを必要としているのはnet
を使ったホスト名解決処理であり、この部分がなければCGO_ENABLED=0
なしで起動できます。
なお、CGO_ENABLED=0
をつければ-extldflags \"-static\""
をビルドコマンドにつける必要はありませんでした。
base
baseの場合次のようなDockerfileになります。
glibc
を含むため、CGO有効(CGO_ENABLED=1
)のままでも動作します。
FROM golang:1.23.1-bookworm AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -ldflags="-s -w" -trimpath -o main .
FROM gcr.io/distroless/base@sha256:76acc040228aed628435f9951e0bee94f99645efabcdf362e94a8c70ba422f99
COPY /build/main ./main
USER nonroot
ENTRYPOINT ["./main"]
Alpine
Alpineは glibc
ではなく musl
を使っており、こちらも CGOを無効化(静的リンク) しないと起動できません。
FROM golang:1.23.1-bookworm AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -trimpath -o main .
FROM alpine:3.21.3
COPY /build/main ./main
ENTRYPOINT ["./main"]
debian-slim
debian-slimの場合はglibc
を含んでおり、CGO有効のままで動作可能です。
FROM golang:1.23.1-bookworm AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -ldflags="-s -w" -trimpath -o main .
FROM debian:12.10-slim
COPY /build/main ./main
ENTRYPOINT ["./main"]
scratch
Docker Hubなどで配布されている通常のベースイメージ(例: ubuntu, alpine, centos など)とは異なり、完全に空っぽの何の内容も含まない特殊なイメージです。
glibc
も当然含まないため、CGO_ENABLED=0
でビルドする必要があります。
FROM golang:1.23.1-bookworm AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -trimpath -o main .
FROM scratch
COPY /build/main ./main
ENTRYPOINT ["./main"]
イメージサイズと要件比較表
前述のサンプルGoアプリケーションとDockerfileでビルドされたイメージのサイズをまとめました。
golangイメージもDockerfileは載せていませんが、他と同様にマルチステージビルドしたものです。
ベースイメージ | イメージサイズ | CGO_ENABLED=0 必須 | 備考 |
---|---|---|---|
distroless/static | 8.3MB | ⭕ | 最小構成、静的リンク必須 |
distroless/base | 27.1MB | ❌ | glibcを含む |
alpine:3.21.3 | 13MB | ⭕ | muslベース |
debian:12.10-slim | 80MB | ❌ | 最小限のdebian |
scratch | 5.1MB | ⭕ | 完全空。制限が多い |
golang:1.23.1-bookworm | 843MB | ❌ | 開発用(ビルドステージ) |
この表からわかるように、scratch、alpine、distroless/staticでは静的リンクが必要ですが、debian-slim、distroless/base、golangイメージでは動的リンクでも動作します。また、イメージサイズはscratchが最も小さく、参考値ですが、golangイメージは圧倒的に大きいことがわかります。
CGOライブラリを使うケースでの注意点(例:mattn/go-sqlite3を含むアプリをビルドする場合)
例えば mattn/go-sqlite3 はCGO必須のライブラリです。これはGoでsqlite3を利用するためのライブラリです。
リポジトリのREADMEに以下のような記載があります:
Important: because this is a CGO enabled package, you are required to set the environment variable CGO_ENABLED=1 and have a gcc compiler present within your path.
distroless/static
などでは以下のように静的リンクや開発ツールの準備が必要になります。
FROM golang:1.23.1-bookworm AS builder
WORKDIR /build
# SQLite3 の開発パッケージをインストール
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libc6-dev \
libsqlite3-dev \
&& rm -rf /var/lib/apt/lists/*
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=1 go build -ldflags='-s -w -extldflags "-static"' -trimpath -o main .
FROM gcr.io/distroless/static@sha256:765ef30aff979959710073e7ba3b163d479a285d7d96d0020fca8c1501de48cb
COPY /build/main ./main
USER nonroot
ENTRYPOINT ["./main"]
一方、distroless/baseの場合は上にある例と同じシンプルなDockerfileで動作させることができます。
GoアプリケーションをDockerでデプロイする際どのベースイメージを選ぶべきか?
今回はgo-sqlite3
を例としましたが、他にもCGOが必須でビルドが比較的難しいライブラリはあります。
個人的には多少(20MB程度)容量が大きくなっても問題ないならばdistroless/baseを使うのが無難かもしれないとも思います。
現在のアプリがCGOへの依存がなくても将来追加される可能性があり、その際面倒になります。
それを受け入れられ、少しでもイメージを小さくしたい場合はstaticやscratchを選び、シェルが必要などの事情がある場合はdebian-slimということになるでしょうか。