LoginSignup
5
4

【Golang】Docker + scratch で "net" モジュールを使った静的リンクバイナリを使う際のビルドの注意【"net/http" "net/url" など】

Last updated at Posted at 2022-03-08

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 (今北産業)

  1. ca-certificates ファイルも格納する。
    net モジュールで https://〜 にアクセスする場合、認証局Certification Authorityの証明書が必要です。scratch には含まれないので、最新の ca-certificates(CA 証明書)も別途コンテナに格納しておく必要があります。
  2. 環境変数 CGO_ENABLED0(ゼロ、オフ)にセットする。
    盲目的に 0(オフ)にセットすれば良いというわけではありません。「なぜデフォルトで 1(オン)なのか」を理解した上で変更すると、急がば回れで無駄なトライ&エラーをせずに済みます(→ 俺

    まず、ビルド時の -ldflags フラグの -extldflags "-static" を使ってビルドすると、静的リンク(関連した外部モジュールが同梱)されたアプリのバイナリが作成できます。完全な単体バイナリなので、Docker のマルチステージ・ビルドには打ってつけです。しかし、この時 Go 側で利用しているパッケージのモジュールが cgo1 を使っている場合に、わかりづらい問題が発生します。一言で言うと「ある種の組み合わせ問題」です。

    先に言ってしまうと、cgo でしか動かない」と分かっている Go モジュールを使う場合は、ぶっちゃけ scratch よりは Alpine、 Debian-slim、gcr.io/distroless/static などの軽量イメージを使う方が時間を溶かさずに済みます。その場合は、ビルドしたバイナリを ldd で確認すれば紐づいてリンクしている C/C++ モジュールを確認できます。そしてバイナリと、それらを一緒にアーカイブ(圧縮)しておき、Docker の最終ステージにコピーして、同じパスに展開(解凍)すれば動くからです。普通にコピーしてしまうとリンクが切れてしまいます。

    とはいえ、余計なことをされないようベースイメージの脆弱性を突かれないよう、やはり scratch で余計なものなど一切ない超軽量なコンテナを作りたいことは多いです。仕組みを理解すれば、Kubernetes などクラスタ利用時にも強力な助っ人になります。

    まず、CGO_ENABLED1(オン、デフォルト値)にセットされていると、cgo1 を使った 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 モジュールには netCgoC 版)と netGo(Go 版)の 2 種類があります
    netCgo は、低レベル・アクセス可能で OS に最適化されている C 言語で書かれたものを使うラッパー(cgo)です。対して netGo は速度よりも汎用性を持たせた Go で書かれた代替コードです。

    デフォルトで netCgocgo)を使うのですが、ビルド時に netgo のタグを付けると netgo のモジュールを替わりに使うことができます。しかし、CGO_ENABLED0(オフ)に設定されている場合は、そもそも cgo を使わないので netgo が使われます。

    netgo タグを記載する情報があるのは「他のモジュールは cgo 版を使いたいが、net のみ netgo 版を使いたい」場合のためで、その場合は netgo タグを付けます。そのため、CGO_ENABLED=0 の場合は netgo のビルドタグは通常必要ありません。Go 1.4 時代の吸った揉んだした名残です。
  3. Go v1.4 時代の net モジュール関連の情報はあてにできない。
    どうも Go v1.4 の変更はチャレンジ冒険したものが多かったようで、v1.5 以降で修正 or v1.3 状態に差し戻されたものが多くありました。このことから、試行錯誤した際の Go v1.4 の情報が逆に多くあります。さらに Golang のバージョン情報が記載されていない記事も多く、コピペピピックでドツボにハマるので注意しましょう(→ 俺。
    特に 1.5 以降では cgonet と、netgonet の切り替えで、ビルド時の -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 に設置する例です。

Dockerfile
# -----------------------------------------------------------------------------
#  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"]

scratchnobody ユーザー(root 以外)で実行

上記の最終イメージは scratch ベースで軽量にはなったものの、ユーザーは root のままです。

実行ユーザが root のままだと dockleSnyk/Advisor といったコンテナ・イメージの脆弱性スキャンなどでワーニングヽ(`Д´)ノ ウワァァァンが出ます。

以下は nobody ユーザーに変更する例です。事前に権限なしのユーザーを作成しておき、コピーします。この時、シェルを /bin/false で無効にしておくと、より安心です。

Dockerfile
# -----------------------------------------------------------------------------
#  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"]

参考文献

  1. cgo とは、C/C++ で書かれたライブラリを Go から呼び出して使えるようにするための Go の標準ツールです。つまり C/C++ ライブラリのラッパーを Go で作る場合に必要なツールです。「cgo を使う」「cgo に対応」という表現は、Go でラッパー関数やラッパー用モジュールなど作り、Go から C/C++ のライブラリを使ったり、それらを使うように構成されている、などの意味になります。 2

5
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
4