極光日記

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 完全静的リンク前提。glibclibsslなど一切含まない。最小構成。
base glibclibsslなどの共有ライブラリを含む。CGOを含むアプリに対応可能。

distrolessなど各種軽量イメージを使ってビルドしてみる

対象ソースコード

Goのシンプルなアプリケーションを含んだDockerイメージを作成してみます。
次のようなコードです。
あまりにシンプルなのも検証にならないため、外部ライブラリとしてlogrusを利用し、ログ出力しています。また、後述しますが、cgoを利用するようにnetをimportに追加し、ホスト名のDNS解決処理を行っています。
go run main.gocurl 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 --from=builder /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 --from=builder /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 --from=builder /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 --from=builder /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 --from=builder /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 --from=builder /build/main ./main

USER nonroot

ENTRYPOINT ["./main"]

一方、distroless/baseの場合は上にある例と同じシンプルなDockerfileで動作させることができます。

GoアプリケーションをDockerでデプロイする際どのベースイメージを選ぶべきか?

今回はgo-sqlite3を例としましたが、他にもCGOが必須でビルドが比較的難しいライブラリはあります。
個人的には多少(20MB程度)容量が大きくなっても問題ないならばdistroless/baseを使うのが無難かもしれないとも思います。
現在のアプリがCGOへの依存がなくても将来追加される可能性があり、その際面倒になります。
それを受け入れられ、少しでもイメージを小さくしたい場合はstaticやscratchを選び、シェルが必要などの事情がある場合はdebian-slimということになるでしょうか。