Go言語アプリのDockerイメージをscratchで軽量化してみた

こんばんは、ねじねじおです。

Go言語で書いたアプリの Docker イメージを軽量化するには、マルチステージビルドを使って scratch をベースに構築するのがよいと聞いて、やってみました。

## 準備

まず、サンプルとして小さな Web API を echo で作ります。

```sh
$ mkdir app
$ cd app
$ go mod init example.com/example
$ go get github.com/labstack/echo/v4
```

下記のディレクトリ構成で server.go と Dockerfie を追加します。

```
-example
　|-app
    │-Dockerfile
    │-go.mod
    │-go.sum
    |-server.go
```


```golang:server.go
package main

import (
	"net/http"
	"time"

	"github.com/labstack/echo/v4"
	"github.com/labstack/echo/v4/middleware"
)

func main() {
	// Echo instance
	e := echo.New()

	// Middleware
	e.Use(middleware.Logger())
	e.Use(middleware.Recover())

	// Routes
	e.GET("/", func (c echo.Context) error {
		return c.JSON(http.StatusOK, []string{
			"Hello, World!",
			time.Now().Format(time.RFC3339),
		})
	})

	// Start server
	e.Logger.Fatal(e.Start(":1323"))
}
```

```Dockerfile:Dockerfile
FROM golang:1.14.7
WORKDIR /go/src/app

COPY ./ ./
RUN go mod download
RUN go build -o ./server ./server.go
```

ビルドして実行してみます。

```sh
$ docker build ./ -t example
$ docker run -e TZ=Asia/Tokyo -p 1323:1323 example ./server
```

動作確認。

```sh
$ curl http://localhost:1323/
["Hello, World!","2020-08-10T19:51:15+09:00"]
```

成功！!

では、イメージサイズを確認してみます。

```sh
$ docker images example
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
example             latest              5ef805ca57ec        2 minutes ago       908MB
```

908MB って、でかいのかな？
基準がわからないので、レイヤーを確認します。

```sh
$ docker history example
IMAGE               CREATED             CREATED BY                                      SIZE                COMMENT
5ef805ca57ec        2 minutes ago       /bin/sh -c go build -o ./server ./server.go     20.5MB              
13249ee42cff        2 minutes ago       /bin/sh -c go mod download                      77.3MB              
a3e0d3e82f45        3 minutes ago       /bin/sh -c #(nop) COPY dir:cccd1ec30e0093efe…   4.96kB              
aa1a8385bb42        44 hours ago        /bin/sh -c #(nop) WORKDIR /go/src/app           0B                  
baaca3151cdb        3 days ago          /bin/sh -c #(nop) WORKDIR /go                   0B    
... 省略 ...
```

マルチステージビルドを使うと、go mod download と go build の領域を節約できそうです。

## マルチステージビルドを使う

Dockerfileを変更します。
builderステージでコンパイル、できたバイナリファイルをコピーしてproductionステージを作ります。

```Dockerfile
FROM golang:1.14.7 AS base
WORKDIR /go/src/app

FROM base AS builder
COPY ./ ./
RUN go mod download
RUN go build -o ./server ./server.go

FROM base AS production
COPY --from=builder  /go/src/app/server ./
```

ビルドします。

```sh
$ docker build ./ -t example
```

イメージのサイズとレイヤーを確認。

```sh
$ docker images example
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
example             latest              62b3a8a02af3        3 minutes ago       822MB

$ docker history example
IMAGE               CREATED             CREATED BY                                      SIZE                COMMENT
62b3a8a02af3        3 minutes ago       /bin/sh -c #(nop) COPY file:3d74d9674cd3943c…   12.2MB              
aa1a8385bb42        44 hours ago        /bin/sh -c #(nop) WORKDIR /go/src/app           0B                  
baaca3151cdb        3 days ago          /bin/sh -c #(nop) WORKDIR /go                   0B                  
... 省略 ...
```

イメージサイズは、822MB。少し小さくなりましたね。

動作確認です。

```sh
$ docker run -e TZ=Asia/Tokyo -p 1323:1323 example ./server
$ curl http://localhost:1323/
["Hello, World!","2020-08-10T19:58:28+09:00"]
```

成功！

しかし、先ほどの docker history の出力をみると、バイナリをCOPYしてくるレイヤーは、たかだか 12.2MB 。
対して、イメージ全体のサイズは、822MB。
ベースのイメージが大きいのですね。

## scratch をベースに最終イメージを構築する

Goの実行環境にはGoは不要なので、ミニマムに scratch をベースに最終イメージを構築してみます。

### 失敗 その1

```Dockerfile:Dockerfile
FROM golang:1.14.7 AS base
WORKDIR /go/src/app

FROM base AS builder
COPY ./ ./
RUN go mod download
RUN go build -o ./server ./server.go

FROM scratch AS production
WORKDIR /go/bin
COPY --from=builder /go/src/app/server ./
```

ビルドします。

```sh
$ docker build ./ -t example
```

イメージのサイズとレイヤーを確認。

```sh
$ docker images example
REPOSITORY          TAG                 IMAGE ID            CREATED              SIZE
example             latest              e1ba3f7f81f0        About a minute ago   12.2MB

$ docker history example
IMAGE               CREATED             CREATED BY                                      SIZE                COMMENT
e1ba3f7f81f0        2 minutes ago       /bin/sh -c #(nop) COPY file:3d74d9674cd3943c…   12.2MB              
06d1e3bea55e        12 minutes ago      /bin/sh -c #(nop) WORKDIR /go/bin               0B    
```

イメージサイズは、 12.2MB 。
劇的に小さくなりました！！

動作確認です。

```sh
$ docker run -e TZ=Asia/Tokyo -p 1323:1323 example ./server
standard_init_linux.go:211: exec user process caused "no such file or directory"
```

エラーが発生して、起動できない。
いろいろ調べて見ると、builder と production で実行環境が異なるので、コンパイル時に CGO を無効にする必要がありました。

### 失敗 その2

コンパイル時に CGO を無効にするように変更して再チャレンジです。

```Dockerfile:Dockerfile
FROM golang:1.14.7 AS base
WORKDIR /go/src/app

FROM base AS builder
COPY ./ ./
RUN go mod download
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o ./server ./server.go

FROM scratch AS production
WORKDIR /go/bin
COPY --from=builder /go/src/app/server ./
```

ビルドして実行します。

```sh
$ docker build ./ -t example
$ docker run -e TZ=Asia/Tokyo -p 1323:1323 example ./server
```

起動しました！
動作確認です。

```sh
$ curl http://localhost:1323/
["Hello, World!","2020-08-10T11:53:39Z"]
```

成功！
いや、否。
タイムゾーンが無視されている。。
タイムゾーンは、 アジアの都市、東京 です。"+09:00" です。

### そして成功へ

こちらのサイトに答えが書いてありました。
[Using local time in a Golang Docker container built from Scratch](https://medium.com/@mhcbinder/using-local-time-in-a-golang-docker-container-built-from-scratch-2900af02fbaf)

今回は、builder ステージがから /usr/share/zoneinfo をコピーする方法を採用しました。

```Dockerfile:Dockerfile
FROM golang:1.14.7 AS base
WORKDIR /go/src/app

FROM base AS builder
COPY ./ ./
RUN go mod download
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o ./server ./server.go

FROM scratch AS production
WORKDIR /go/bin
COPY --from=builder /go/src/app/server ./
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
```

ビルドして実行します。

```sh
$ docker build ./ -t example
$ docker run -e TZ=Asia/Tokyo -p 1323:1323 example ./server
```

動作確認です。

```sh
$ curl http://localhost:1323/
["Hello, World!","2020-08-10T21:11:29+09:00"]
```

タイムゾーンが反映されています！

イメージのサイズを確認します。

```sh
$ docker images example
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
example             latest              e673e01668c6        14 minutes ago      13.3MB

$ docker history example
IMAGE               CREATED             CREATED BY                                      SIZE                COMMENT
e673e01668c6        14 minutes ago      /bin/sh -c #(nop) COPY dir:b39c255c3688d7205…   1.21MB              
3865c579a2d0        25 minutes ago      /bin/sh -c #(nop) COPY file:bafd1cb7f8670973…   12.1MB              
06d1e3bea55e        34 minutes ago      /bin/sh -c #(nop) WORKDIR /go/bin               0B   
```

13.3 MB。

OK。
ねじねじおでした。

- 2020年8月15日 追記
  - Go1.15でタイムゾーンデータベースをバイナリに組み込めるようになったので、[こちら](https://qiita.com/nejinejio/items/1bd5a07fe4060f98cc33) に方法を書きました。