Go 言語(以下 Golang)v1.16 以降で、"
net
" モジュールを使ったバイナリを Docker のマルチステージ・ビルドでコンパイルし、どーしてもscratch
イメージで使いたい。
例えば "net/http
" や "net/url
" パッケージなどを使って HTTP GET
リクエストするものなど。
しかし、静的リンクしないと standard_init_linux.go:228: exec user process caused: no such file or directory
エラーが発生するし、静的リンクさせてもデータが取得できない。
「素直に gcr.io/distroless/static を使え」って話しなんですが、やんごとなき事情で半分以下のサイズ(1.5 MB 程度)に収まる超軽量のイメージが欲しいのです。
Go v1.16 + Docker v20.10 以降で動く情報が欲しかったのですが、色々ググっても Go v1.4 時代の古い情報や、古い Golang と Docker のバージョンの組み合わせ情報しかありませんでした。あっても新旧混在の組み合わせだったりと、なかなか動かなかったので未来の自分のググラビリティとして。
- 検証環境
- Go v1.17.8 darwin/amd64
- Docker v20.10.12, build e91ed57
- macOS v10.15.7 (Mac OSX, 19H1715)
TL; DR (今北産業)
-
ca-certificates
ファイルも格納する。net
モジュールでhttps://〜
にアクセスする場合、認証局の証明書が必要です。scratch
には含まれないので、最新のca-certificates
(CA 証明書)も別途コンテナに格納しておく必要があります。 -
環境変数
CGO_ENABLED
を0
(ゼロ、オフ)にセットする。盲目的に0
(オフ)にセットすれば良いというわけではありません。「なぜデフォルトで1
(オン)なのか」を理解した上で変更すると、急がば回れで無駄なトライ&エラーをせずに済みます(→ 俺
まず、ビルド時の-ldflags
フラグの-extldflags "-static"
を使ってビルドすると、静的リンク(関連した外部モジュールが同梱)されたアプリのバイナリが作成できます。完全な単体バイナリなので、Docker のマルチステージ・ビルドには打ってつけです。しかし、この時 Go 側で利用しているパッケージのモジュールがcgo
1 を使っている場合に、わかりづらい問題が発生します。一言で言うと「ある種の組み合わせ問題」です。
先に言ってしまうと、「cgo
でしか動かない」と分かっている Go モジュールを使う場合は、ぶっちゃけscratch
よりは Alpine、 Debian-slim、gcr.io/distroless/static などの軽量イメージを使う方が時間を溶かさずに済みます。その場合は、ビルドしたバイナリをldd
で確認すれば紐づいている C/C++ モジュールを確認できます。そしてバイナリと、それらを一緒にアーカイブ(圧縮)しておき、Docker の最終ステージにコピーして、同じパスに展開(解凍)すれば動くからです。普通にコピーしてしまうとリンクが切れてしまいます。
とはいえ、余計なことをされないよう、やはりscratch
で余計なものなど一切ない超軽量なコンテナを作りたいことは多いです。仕組みを理解すれば、Kubernetes などクラスタ利用時にも強力な助っ人になります。
まず、CGO_ENABLED
が1
(オン、デフォルト値)にセットされていると、cgo
1 を使った Go モジュールは OS 側にある C/C++ ライブラリを使う設定でビルドされます。この時、-ldflags
フラグの "-static
" 付きでビルドすると、関連する C/C++ のモジュールも全て静的リンクでビルドされたものであれば、一緒に同梱されます。OS のパッケージマネージャーで〜-dev
と付いたものをインストール必要があるのは、そのため(C/C++ モジュールを静的リンクで再コンパイルさせるため)です。そのため gcc といったコンパイラも必要にもなります。問題は、エラーが発生した時の内容です。
Go + Docker の組み合わせで、よく見かけるエラーにstandard_init_linux.go:228: exec user process caused: no such file or directory
エラーがあります。実は、このエラーはコンテナではなく Docker 自身が出力しています。というのも Docker も Golang で作られているため、Docker のstandard_init_linux.go
が、このエラーを出力しています。厳密には Docker が OCI 互換のために利用している OpenContainer の CLI 用ライブラリの standard_init_linux.go が吐き出しています。(これに気付くのに時間がかかりました)
次の問題は Go 製アプリを Docker コンテナで実行した時に、このエラーが発生した時の原因です。ビルド時のCGO_ENABLED=1
の設定により、アプリは OS 側にある C 言語のライブラリのモジュールを使おうとします。しかし、scratch
イメージには標準ライブラリ含め、ほとんど何も入っていないためno such file or directory
とあるように「このコンテナには、そのファイル(モジュール)はないよ」と返しているのです。Go アプリに限らず、ファイルの改行コードがCRLF
だった場合や、シェル・スクリプトのshebang
のパスがおかしいといった場合などにも同じエラーが表示されるのも、パスがおかしくなってしまいファイルが見つからないため Docker 側が出力しているのです。
さて、問題のnet
モジュールにはnetCgo
(C
版)とnetGo
(Go 版)の 2 種類があります。netCgo
は、低レベル・アクセス可能で OS に最適化されている C 言語で書かれたものを使うラッパー(cgo
)です。対してnetGo
は速度よりも汎用性を持たせた Go で書かれた代替コードです。
デフォルトでnetCgo
(cgo
)を使うのですが、ビルド時にnetgo
のタグを付けるとnetgo
のモジュールを替わりに使うことができます。しかし、CGO_ENABLED
が0
(オフ)に設定されている場合は、そもそもcgo
を使わないのでnetgo
が使われます。netgo
タグを記載する情報があるのは「他のモジュールはcgo
版を使いたいが、net
のみnetgo
版を使いたい」場合のためで、その場合はnetgo
タグを付けます。そのため、CGO_ENABLED=0
の場合はnetgo
のビルドタグは通常必要ありません。Go 1.4 時代の吸った揉んだした名残です。 -
Go v1.4 時代の
net
モジュール関連の情報はあてにできない。どうも Go v1.4 の変更はチャレンジしたものが多かったようで、v1.5 以降で修正 or v1.3 状態に差し戻されたものが多くありました。このことから、試行錯誤した際の Go v1.4 の情報が逆に多くあります。さらに Golang のバージョン情報が記載されていない記事も多く、コピペピピックでドツボにハマるので注意しましょう(→ 俺。
特に 1.5 以降ではcgo
版net
と、netgo
版net
の切り替えで、ビルド時の-a
は必要ありません。-a
オプションは、ビルド時に「インポートするパッケージをビルドしなおしてから利用する」設定なのですが、Go v1.4 ではcgo
の切り替えも行う煩雑な処理をするようになっていたようです。しかし Go v1.5 以降で、前述したCGO_ENABLED
の設定でビルド時に切り替えるように修正されています。モジュール・モードの現在、大抵の場合は-a
は利用する必要はありません。
【build constraints exclude all Go files in ...
エラー】
このエラーは「ビルドの制約により ...
のコードが全て除外された」という内容で、代替コードが見つからなかったために発生するエラーです。
CGO_ENABLED=0
かつ -ldflags="-extldflags \"-static\""
の設定で静的ビルド(静的リンクでビルド)を行った際に上記エラーが出る場合は、利用しているパッケージのモジュールで cgo
を使ったモジュール(C ライブラリのラッパーなど)が Pure Go で動く代替モジュールを備えていません。そのため、ゴッソリと読み込まれなくなったことによるエラーです。
逆に、CGO_ENABLED=1
で静的ビルドしても ld: library not found for -lxxxx.o
エラーがでる場合は、問題のモジュールが利用する C ライブラリ自体が -static
でコンパイルされていません。ビルドする際に C ライブラリも一緒に埋め込むためには、関連する C ライブラリの全てのモジュールが -static
でコンパイルされている必要があります。
TS; DR (マスター動くものをくれ)
以下は Alpine ベースの Go イメージでビルドを行い、成果物を scratch
に設置する例です。
# -----------------------------------------------------------------------------
# Build Stage
# -----------------------------------------------------------------------------
FROM golang:alpine AS build
RUN apk add --no-cache \
# ビルド時 .h なファイルがないとか言われたら入れてみるもの
# alpine-sdk \
# build-base \
ca-certificates
COPY . /workspace
WORKDIR /workspace
ENV CGO_ENABLED 0
RUN go mod download
RUN \
go build \
-ldflags="-s -w -extldflags \"-static\"" \
-o /go/bin/myapp \
./cmd/myapp/main.go \
# Smoke test
&& /go/bin/myapp -h
# -----------------------------------------------------------------------------
# Final Stage
# -----------------------------------------------------------------------------
FROM scratch
COPY --from=build /go/bin/myapp /usr/bin/myapp
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
ENTRYPOINT ["/usr/bin/myapp"]
scratch
で nobody
ユーザー(root
以外)で実行
上記の最終イメージは scratch
ベースで軽量にはなったものの、ユーザーは root
のままです。
実行ユーザが root
のままだと dockle や Snyk/Advisor といったコンテナ・イメージの脆弱性スキャンなどでワーニングが出ます。
以下は nobody
ユーザーに変更する例です。事前に権限なしのユーザーを作成しておき、コピーします。この時、シェルを /bin/false
で無効にしておくと、より安心です。
# -----------------------------------------------------------------------------
# Build Stage
# -----------------------------------------------------------------------------
FROM golang:alpine AS build
RUN apk add --no-cache \
# ビルド時 .h なファイルがないとか言われたら入れてみるもの
# alpine-sdk \
# build-base \
ca-certificates
COPY . /workspace
WORKDIR /workspace
ENV CGO_ENABLED 0
RUN go mod download
RUN \
go build \
-ldflags="-s -w -extldflags \"-static\"" \
-o /go/bin/myapp \
./cmd/myapp/main.go \
# Smoke test
&& /go/bin/myapp -h
# Create a "nobody" non-root user.
RUN echo 'nobody:*:65534:65534:nobody:/_nonexistent:/bin/false' > /tmp/tmp_passwd
# -----------------------------------------------------------------------------
# Final Stage
# -----------------------------------------------------------------------------
FROM scratch
# /bin/false は /usr/bin/false にある場合もある
COPY --from=build /bin/false /bin/false
COPY --from=build /tmp/tmp_passwd /etc/passwd
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=build /go/bin/myapp /usr/bin/myapp
USER nobody
ENTRYPOINT ["/usr/bin/myapp"]
参考文献
- Distroless vs scratch
- Distroless Containers: Hype or True Value? @ hackernoon.com
- Distroless or scratch for Go apps? | blog @ baeke.info
- Why should I use distroless images? | distroless | GoogleContainerTools @ GitHub
- What's Inside Of a Distroless Container Image: Taking a Deeper Look @ iximiuz.com