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

> Go 言語（以下 Golang）v1.16 以降で、"`net`" モジュールを使ったバイナリを [Docker](https://ja.wikipedia.org/wiki/Docker) のマルチステージ・ビルドでコンパイルし、<strong>どーしても `scratch` イメージ</strong>で使いたい。

例えば "`net/http`" や "`net/url`" パッケージなどを使って `HTTP GET` リクエストするものなど。

しかし、静的リンクしないと `standard_init_linux.go:228: exec user process caused: no such file or directory` エラーが発生するし、静的リンクさせてもデータが取得できない。

「素直に [gcr.io/distroless/static](https://github.com/GoogleContainerTools/distroless/blob/main/base/README.md) を[使え](#%E5%8F%82%E8%80%83%E6%96%87%E7%8C%AE)」って話しなんですが、<ruby>やんごとなき事情<rt>リソース不足</rt></ruby>で半分以下のサイズ（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 <sub><sup><sub><sup>（今北産業）</sup></sub></sup></sub>

1. **`ca-certificates` ファイルも格納する。**<br><details><sub>`net` モジュールで `https://〜` にアクセスする場合、[<ruby>認証局<rt>Certification Authority</rt></ruby>](https://ja.wikipedia.org/wiki/%E8%AA%8D%E8%A8%BC%E5%B1%80)の証明書が必要です。`scratch` には含まれないので、最新の `ca-certificates`（CA 証明書）も別途コンテナに格納しておく必要があります。</sub></details>
1. **環境変数 `CGO_ENABLED` を `0`（ゼロ、オフ）にセットする。**<br><details><sub>盲目的に `0`（オフ）にセットすれば良いというわけではありません。「なぜデフォルトで `1`（オン）なのか」を理解した上で変更すると、急がば回れで無駄なトライ＆エラーをせずに済みます（→ 俺<br><br>まず、ビルド時の `-ldflags` フラグの `-extldflags "-static"` を使ってビルドすると、静的リンク（関連した外部モジュールが同梱）されたアプリのバイナリが作成できます。完全な単体バイナリなので、Docker の[マルチステージ・ビルド](https://matsuand.github.io/docs.docker.jp.onthefly/develop/develop-images/multistage-build/)には打ってつけです。しかし、この時 Go 側で利用しているパッケージのモジュールが `cgo`[^cgo] を使っている場合に、わかりづらい問題が発生します。一言で言うと「[ある種の組み合わせ問題](https://github.com/mattn/go-sqlite3/issues/384)」です。<br><br>先に言ってしまうと、<strong>「`cgo` でしか動かない」と分かっている Go モジュールを使う場合は、ぶっちゃけ `scratch` よりは Alpine、 Debian-slim、[gcr.io/distroless/static](https://github.com/GoogleContainerTools/distroless/blob/main/base/README.md) などの軽量イメージを使う方が時間を溶かさずに済みます</strong>。その場合は、ビルドしたバイナリを `ldd` で確認すれば<ruby>紐づいて<rt>リンクして</rt></ruby>いる C/C++ モジュールを確認できます。そしてバイナリと、それらを一緒にアーカイブ（圧縮）しておき、Docker の最終ステージにコピーして、同じパスに展開（解凍）すれば動くからです。普通にコピーしてしまうとリンクが切れてしまいます。<br><br>とはいえ、<ruby>余計なことをされないよう<rt>ベースイメージの脆弱性を突かれないよう</rt></ruby>、やはり `scratch` で余計なものなど一切ない超軽量なコンテナを作りたいことは多いです。仕組みを理解すれば、Kubernetes などクラスタ利用時にも強力な助っ人になります。<br><br>まず、`CGO_ENABLED` が `1`（オン、デフォルト値）にセットされていると、`cgo`[^cgo] を使った Go モジュールは OS 側にある C/C++ ライブラリを使う設定でビルドされます。この時、`-ldflags` フラグの "`-static`" 付きでビルドすると、関連する <strong>C/C++ のモジュールも全て静的リンクでビルドされたものであれば</strong>、一緒に同梱されます。OS のパッケージマネージャーで `〜-dev` と付いたものをインストール必要があるのは、そのため（C/C++ モジュールを静的リンクで再コンパイルさせるため）です。そのため [gcc](https://ja.wikipedia.org/wiki/GNU%E3%82%B3%E3%83%B3%E3%83%91%E3%82%A4%E3%83%A9%E3%82%B3%E3%83%AC%E3%82%AF%E3%82%B7%E3%83%A7%E3%83%B3) といったコンパイラも必要にもなります。問題は、エラーが発生した時の内容です。<br><br>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](https://ja.wikipedia.org/wiki/Open_Container_Initiative) 互換のために利用している [OpenContainer の CLI 用ライブラリ](https://github.com/opencontainers/runc)の [standard_init_linux.go](https://github.com/opencontainers/runc/blob/main/libcontainer/standard_init_linux.go) が吐き出しています。（これに気付くのに時間がかかりました）<br><br>次の問題は Go 製アプリを Docker コンテナで実行した時に、このエラーが発生した時の原因です。ビルド時の `CGO_ENABLED=1` の設定により、アプリは OS 側にある C 言語のライブラリのモジュールを使おうとします。しかし、`scratch` イメージには標準ライブラリ含め、ほとんど何も入っていないため `no such file or directory` とあるように「このコンテナには、そのファイル（モジュール）はないよ」と返しているのです。Go アプリに限らず、ファイルの改行コードが `CRLF` だった場合や、シェル・スクリプトの `shebang` のパスがおかしいといった場合などにも同じエラーが表示されるのも、パスがおかしくなってしまいファイルが見つからないため Docker 側が出力しているのです。<br><br>さて、問題の `net` モジュールには `netCgo`（`C` 版）と `netGo`（Go 版）の<a href="https://cs.opensource.google/go/go/+/master:src/net/net.go;l=96-97" target="_balnk"> 2 種類があります</a>。<br>`netCgo` は、低レベル・アクセス可能で OS に最適化されている C 言語で書かれたものを使うラッパー（`cgo`）です。対して `netGo` は速度よりも汎用性を持たせた <a href="https://cs.opensource.google/go/go/+/master:src/net/cgo_stub.go;l=5" target="_blank">Go で書かれた代替コード</a>です。<br><br>デフォルトで `netCgo`（`cgo`）を使うのですが、ビルド時に `netgo` のタグを付けると `netgo` のモジュールを替わりに使うことができます。しかし、**`CGO_ENABLED` が `0`（オフ）に設定されている場合は、そもそも `cgo` を使わない**ので `netgo` が使われます。<br><br>`netgo` タグを記載する情報があるのは「他のモジュールは `cgo` 版を使いたいが、`net` のみ `netgo` 版を使いたい」場合のためで、その場合は `netgo` タグを付けます。そのため、`CGO_ENABLED=0` の場合は `netgo` のビルドタグは通常必要ありません。Go 1.4 時代の吸った揉んだした名残です。</sub></details>
1. <strong>Go v1.<ruby>4<rt>・</rt></ruby> 時代の `net` モジュール関連の情報はあてにできない。</strong><br><details><sub>どうも Go v1.**4** の変更は<ruby>チャレンジ<rt>冒険</rt></ruby>したものが多かったようで、v1.5 以降で修正 or v1.3 状態に差し戻されたものが多くありました。このことから、試行錯誤した際の **Go v1.4 の情報が逆に多くあります**。さらに Golang のバージョン情報が記載されていない記事も多く、コピペピピックでドツボにハマるので注意しましょう（→ 俺。<br>特に 1.**5** 以降では `cgo` 版 `net` と、`netgo` 版 `net` の切り替えで、ビルド時の `-a` は[必要ありません](https://go-review.googlesource.com/c/go/+/10761/)。`-a` オプションは、ビルド時に「インポートするパッケージをビルドしなおしてから利用する」設定なのですが、Go v1.4 では `cgo` の切り替えも行う煩雑な処理をするようになっていたようです。しかし Go v1.5 以降で、前述した `CGO_ENABLED` の設定でビルド時に切り替えるように修正されています。モジュール・モードの現在、大抵の場合は `-a` は利用する必要はありません。</sub></details>

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

:::note info
【**`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 <sub><sup><sub><sup>（マスター動くものをくれ）</sup></sub></sup></sub>

以下は Alpine ベースの Go イメージでビルドを行い、成果物を `scratch` に設置する例です。

```docker: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"]
```

### `scratch` で `nobody` ユーザー（`root` 以外）で実行

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

実行ユーザが `root` のままだと [dockle](https://github.com/goodwithtech/dockle) や [Snyk/Advisor](https://snyk.io/advisor/docker) といったコンテナ・イメージの脆弱性スキャンなどで<ruby>ワーニング<rt>ヽ(\`Д´)ﾉ ｳﾜｧｧｧﾝ</rt></ruby>が出ます。

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

```docker: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"]
```

## 参考文献

- Distroless vs scratch
    - [Distroless Containers: Hype or True Value?](https://hackernoon.com/distroless-containers-hype-or-true-value-2rfl3wat) @ hackernoon.com
    - [Distroless or scratch for Go apps?](https://blog.baeke.info/2021/03/28/distroless-or-scratch-for-go-apps/) | blog @ baeke.info
    - [Why should I use distroless images?](https://github.com/GoogleContainerTools/distroless#why-should-i-use-distroless-images) | distroless | GoogleContainerTools @ GitHub
    - [What's Inside Of a Distroless Container Image: Taking a Deeper Look](https://iximiuz.com/en/posts/containers-distroless-images/) @ iximiuz.com