GoシングルバイナリDockerイメージビルドでハマったこと

こんにちは、株式会社medibaでバックエンドエンジニアをしている @mdbr92 です。
この記事は [mediba Advent Calendar 2021](https://qiita.com/advent-calendar/2021/mediba) の14日目です。
※記事の内容はあくまで個人の発信であり、会社を代表する意見や見解ではありません。

この記事では
Go 言語で記述した HTTP サーバプログラムを AWS ECS などで動かすために
シングルバイナリ Docker イメージを作成しようとした際に
ハマってしまったことを書きたいと思います。

# なぜシングルバイナリイメージにしたいのか？

Go言語で記述したプログラムはシングルバイナリ
(ランタイムを必要とせず単体で実行できるファイル)にビルドできます。

この実行ファイルのみを scratch(Docker の予約済みの最小限のイメージ)に乗せて
ビルドすると 10MB 前後の小さな Docker イメージを生成することができます。

イメージのサイズが小さいことで、
AWS ECR のようなレジストリへの保管費用や、pull時の通信費用を抑えることができるほか、
余計なアプリケーションを含まないことから脆弱性のリスクを抑えることも期待できます。

# 作成手順 まずはこんなファイルを用意しました

プロジェクトディレクトリに次のようなファイルを配置しました。

## Go ソースコード

まず、プログラムの本体です。
この例は、「Hello,World!」というテキストを返すだけの簡単な HTTP サーバプログラムです。

```go:main.go
package main

import (
	"log"
	"net/http"
)

func handleRoot(w http.ResponseWriter, r *http.Request) {
	log.Printf("%s %s", r.Method, r.URL.Path)
	w.Header().Set("Content-Type", "text/plain; charset=UTF-8")
	w.WriteHeader(200)
	_, _ = w.Write([]byte("Hello,World!"))
}

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/", handleRoot)
	srv := &http.Server{
		Addr: ":3000",
		Handler: mux,
	}
	log.Printf("start server")
	err := srv.ListenAndServe()
	if err != nil {
		log.Fatal(err)
	}
}
```

## Dockerfile

```dockerfile:Dockerfile
# 第1ステージ goのビルド
FROM golang:1.17.3-bullseye as builder
COPY . /src
WORKDIR /src
RUN go build -ldflags="-w -s" -o ./build/app ./main.go

# 第2ステージ シングルバイナリイメージのビルド
FROM scratch as prod
COPY --from=builder /src/build/app /app
CMD ['/app']
```

[マルチステージビルド](https://docs.docker.com/develop/develop-images/multistage-build/)といわれる複数回(ステージ)でイメージを作る方法で
最初のステージでは golang 公式イメージで Go ソースコードをビルドし、
次のステージで scratch イメージに、ビルドした実行ファイルのみをコピーして、最終的なイメージを生成しています。

:::note warn
なお、この内容では正しく動作しません。後述します。
:::

## docker-compose.yml

Docker イメージをビルドするために必須ではないですが、
簡単なコマンド１つでビルド・タグ付け・起動するために、
次のような docker-compose.yml を記述しました。

```yaml:docker-compose.yml
version: '3.8'

services:
  prod:
    container_name: prod
    image: 'xxx/single-binary-example:latest'
    build:
      context: .
      dockerfile: ./Dockerfile
    environment:
      TZ: Asia/Tokyo
    ports:
      - '3000:3000'
```

以下のようなコマンド ビルド＆起動 してくれます。

```sh
docker compose up --build
```

# ビルド・起動しようとしてハマったところ

## CMD はダブルクォート

go build、イメージのビルドは無事終わりましたが、コンテナ起動にあたり次のようなエラーで止まってしまいました。

```log:エラー
Error response from daemon: OCI runtime create failed: container_linux.go:380: starting container process caused: exec: "/bin/sh": stat /bin/sh: no such file or directory: unknown
```

/bin/sh が見つからず、コンテナプロセスを開始できないというエラーになっています。
scratch イメージには sh は入っていないので、見つからないのは当然ですが
/bin/sh ではなく、ビルドした app を起動するように設定したつもりです…。

エラーメッセージでググってもドンピシャの事例が見当たらずしばらく試行錯誤していましたが

```dockerfile:Dockerfile
CMD ['/app']
```

は間違いで、

```dockerfile:Dockerfile
CMD ["/app"]
```

が正しい記述でした。

※[公式のリファレンス](https://docs.docker.com/engine/reference/builder/#cmd)にもきちんと注意書きされていました。

```
The exec form is parsed as a JSON array, which means that you must use double-quotes (“) around words not single-quotes (‘).
exec形式はJSON配列として解析されます。つまり、単語の前後に引用符(')ではなく二重引用符(")を使用する必要があります。
```

こうした、単純な記述ミスを見つけるには
[hadolint](https://github.com/hadolint/hadolint)のような Dockerfile のチェックツールを導入しても良いかもしれません。

## 環境変数 CGO_ENABLED

プログラムは起動したようですが、すぐにエラーで終了しています。

```log
prod  | standard_init_linux.go:228: exec user process caused: no such file or directory
prod exited with code 1
```

これもまた、エラーメッセージでの検索すると、いろいろなパターンの事例が出てきますが、
いくつも試してみた結果、Dockerfile の最初のステージに以下の環境変数を足すことで進展しました。

```dockerfile:Dockerfile
ENV CGO_ENABLED=0
```

CGO は Go で C 言語のライブラリを利用したする機能ですが、利用していないので関係ないかと思いきや
CGO_ENABLED=0 で無効にしないと一部 C ライブラリに依存する実行ファイルになってしまうようです。

シングルバイナリイメージビルド時に限らず、ビルドしたバイナリの配布の際にも注意が必要そうですね。

## タイムゾーン

これで無事に実行できるようになりましたが、
ビルドしたイメージで実行した場合にログの時刻が日本時間と 9 時間ずれている事に気が付きました。

```yaml:docker-compose.yml
services:
  prod:
    (略)
    environment:
      TZ: Asia/Tokyo
    (略)
```

時刻を日本時間で扱いたくて環境変数`TZ`に`Asia/Tokyo`を指定しているのですが、効いていないようです。
同じ TZ 環境変数を設定した golang イメージで実行した場合には、ちゃんと日本時間になったのですが…。

[タイムゾーンデータベース(tz database)](https://ja.wikipedia.org/wiki/Tz_database)については、OS にインストールされている外部のファイルを利用しているようです。

以下のような、golang コンテナ内のタイムゾーンファイルを scratch イメージにコピーする記述を
Dockerfile の第２ステージに追加してやることで、日本時間で表示されるようになりました。

```Dockerfile
COPY --from=builder /usr/share/zoneinfo/Asia/Tokyo /usr/share/zoneinfo/Asia/Tokyo
```

※Alpine ベースの golang イメージ (golang:1.xx.xx-alpineX.XX) の場合
初期状態では /usr/share/zoneinfo にファイルが無いので apt コマンドでインストールする必要があります。

```dockerfile:Dockerfile
RUN apk add tzdata
```

:::note info
(追記) zoneinfoを配置する代わりに、ソースコードに"time/tzdata"パッケージのimportを追加する方法もあるようです。
:::


## CA 証明書

次は、go プログラム内に外部のサーバに HTTPS でアクセスする処理を追加した際に、エラーが出るようになりました。

```log:エラー内容
Get "https://example.com/test.json": x509: certificate signed by unknown authority
```

`不正な機関による証明書`というエラーですが、そんなことはないはずです…。
ルート CA 証明書がなく、SSL サーバ証明書が信頼できる証明書かどうか検証できないために起こっているようです。

こちらも、golang コンテナからコピーしてくる記述を追加することで解決しました。

```dockerfile:Dockerfile
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
```

# 最終的な Dockerfile

ここまでの修正を反映させた Dockerfile が次のようになりました。

```dockerfile:Dockerfile
FROM golang:1.17.3-bullseye as builder
ENV CGO_ENABLED=0
COPY . /src
WORKDIR /src
RUN go build -ldflags="-w -s" -o ./build/app ./main.go

FROM scratch as prod
COPY --from=builder /src/build/app /app
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=builder /usr/share/zoneinfo/Asia/Tokyo /usr/share/zoneinfo/Asia/Tokyo
CMD ["/app"]
```

# さいごに

私がハマったポイントはほんの一部だと思いますが、
この記事が似たようなケースに引っかかってしまった方の助けに少しでもなればと思います。