FROM scratchから始める軽量Docker image for Go


## はじめに
最近Goが流行ってますね． 自分は普段はScala派なのですが，やっぱりバイナリを作れるので小さいアプリを書くときはGoを使っています．(最近は[Scala Native](https://github.com/scala-native/scala-native)があるのでScalaでもバイナリは作れなくもないですが...)

今回はGoでビルドしたバイナリを実行するDocker Imageを軽量に作るにはどうすればよいの?
という部分について紹介します．

ちなみに今回の記事は最終的には下記の記事を参考に，というかほぼそのまま試したものです．
興味のある方はこちらを読んでみてください!

- [Building Minimal Docker Containers for Go Applications](https://blog.codeship.com/building-minimal-docker-containers-for-go-applications)

あとさっき確認したらこちらの方が似た目的の記事を書かれていて，こちらの方が玄人向けで質も高いのでおすすめです．

- [Dockerで最小のGoのイメージを作成する（cgo編）](https://qiita.com/shibukawa/items/fe847e800f845c2f555d)

本記事は上記の記事でいうところの
> 今度はGoでやってみます。ただし、Pure Goで最小というのはすでに方法があって、scratchという何も含まれないイメージを元に、静的リンクしたバイナリを配置するという方法です。

に当たる部分を解説したものになっています．あらかじめご承知置きを...

## 環境
- OSX 10.13.1
- Go 1.9.2 darwin/amd64
- Docker 17.09.0-ce

```bash
$ go env
GOARCH="amd64"
GOBIN=""
GOEXE=""
GOHOSTARCH="amd64"
GOHOSTOS="darwin"
GOOS="darwin"
GORACE=""
GOROOT="/usr/local/Cellar/go/1.9.2/libexec"
GOTOOLDIR="/usr/local/Cellar/go/1.9.2/libexec/pkg/tool/darwin_amd64"
GCCGO="gccgo"
CC="clang"
GOGCCFLAGS="-fPIC -m64 -pthread -fno-caret-diagnostics -Qunused-arguments -fmessage-length=0 -fdebug-prefix-map=/var/folders/9j/m283k_6d00z12bhtwvr3b42d66h90f/T/go-build309557041=/tmp/go-build -gno-record-gcc-switches -fno-common"
CXX="clang++"
CGO_ENABLED="1"
CGO_CFLAGS="-g -O2"
CGO_CPPFLAGS=""
CGO_CXXFLAGS="-g -O2"
CGO_FFLAGS="-g -O2"
CGO_LDFLAGS="-g -O2"
PKG_CONFIG="pkg-config"
```

## TL;DR

とりあえず方法さえわかればいい，という方も多いと思うのでまずは結論から．
基本的にやることは以下の2つです．

### 1. ベースイメージは `scratch` にする． 

```dockerfile
FROM scratch
```
### 2. GoのバイナリはLinux向けに静的ビルドをする．
```
$ GOOS=linux CGO_ENABLED=0 go build main.go
```
これだけです． 簡単ですね! 


## 方法

ここからは自分がハマったところから順を追って説明していきます．

### ソースコード (Go)
かなり簡略化していますが，自分のプロジェクトでハマったところが1箇所だけあったので，それを示すために以下のようなコードにしています．

```go
package main

import (
    "time"
    "net/http"
)

const layout = "2006-01-02 15:04:05"

func main() {
    http.HandleFunc("/time", func(writer http.ResponseWriter, request *http.Request) {
        l := request.URL.Query().Get("tz")
        location, err := time.LoadLocation(l)
        if err != nil {
            panic(err)
        }
        writer.Write([]byte(time.Now().In(location).Format(layout)))
    })

    http.ListenAndServe(":8080", nil)
}

```

`/time`を叩くと現在時刻を返してくれるHTTPサーバです．
Query Parameterでtzを指定するとそのタイムゾーンの時刻を返してくれます．

### ビルド
初回は何も考えずに一旦ビルドしてみます． (うまく動かない例なのでご注意を)

```
$ go build -o clock main.go 
```



### Dockerfile
```dockerfile
FROM scratch

COPY clock /clock

ENTRYPOINT ["/clock"]
```

ビルドしてできたバイナリをルートにCOPYしてENTRYPOINTで呼び出しているだけです．

**軽量化するために必要なのはベースイメージを`FROM scratch`にすることだけ**なんですねー

[DockerHub](https://hub.docker.com/_/scratch/)にも書かれている通り，`FROM scratch`はレイヤーが何もない空の状態をベースにしますよという意味です． よって基本的にはイメージのサイズはバイナリのサイズそのままになります．

念の為イメージのサイズを比べておきましょう． 

```bash
$ docker images                    
REPOSITORY            TAG                 IMAGE ID            CREATED                  SIZE
clock                 latest              95634a53e1c4        Less than a second ago   6.11MB
clock-alpine          latest              db323e49ed1c        12 seconds ago           10.1MB
clock-golang-alpine   latest              fc2bb6247afd        44 seconds ago           275MB
```
上記は，ベースイメージにそれぞれ[scratch](https://hub.docker.com/_/scratch/)，[alpine:3.6](https://hub.docker.com/_/alpine/)，[golang:1.9.2-alpine3.6](https://hub.docker.com/_/golang/)を使ったものです．

今回のコードではバイナリのサイズが`6.11MB`だったので， `FROM scratch`のものはバイナリのサイズそのものになっていることが確認できます．

alpineも`4MB`程度しかオーバーヘッドがなく評判通り軽いですね!

Go込みのイメージは`271MB`と圧倒的に大きくなります．ただ気をつけて欲しいのが，**Goはビルド時に必要なだけで，実行時には必要ない**ということです． つまり，実行するだけならこの`約269MB`は無駄なんですねー．


## いざ実行
ではいよいよ実行してみます．

```bash
$ docker run clock
standard_init_linux.go:185: exec user process caused "exec format error"
```

ダメでした^ ^;

これは単純にバイナリがOSX向けビルドになってしまっているからです．
OSX上にGoの開発環境をセットアップした場合，基本的にデフォルトではOSX向けのバイナリをビルドするような設定になっています．
逆に言うとlinux上でビルドしている場合は最初からlinux向けに設定されていることが多いので上記のようなエラーは出ていないかもしれません．

## Linux向けビルドに直して再実行

[このあたり](https://golang.org/doc/install/source#environment)を参考に `GOOS=linux`を設定する必要があります．

```bash
$ GOOS=linux go build -o clock main.go
```
これでビルドしたバイナリを使ってイメージをビルドし，再実行してみます．

```
$ docker run clock
panic: open /usr/local/Cellar/go/1.9.2/libexec/lib/time/zoneinfo.zip: no such file or directory
```
ダメですか (T . T)

一部のライブラリは依存しているファイルがあるようで，これも一緒にCOPYしてやらないといけないようです． おそらく他のライブラリにもこういう依存があるものがあると思うので注意が必要です．

[このあたりのコメント](https://github.com/golang/go/blob/2951f909eae0afc4bea4067c98fef1a4f760006c/src/time/zoneinfo.go#L278)を見ればわかるのですが，今回の`time.LoadLocation`のケースだと`$GOROOT/lib/time/zoneinfo.zip`を読みに行くようになっているようなので，Dockerfileを以下のように修正すれば解決しました．

```dockerfile
FROM scratch

COPY clock /clock

ENV GOROOT /usr/local/go
ADD https://github.com/golang/go/raw/master/lib/time/zoneinfo.zip /usr/local/go/lib/time/zoneinfo.zip

ENTRYPOINT ["/clock"]
```

`GOROOT`の設定と`zoneinfo.zip`を追加しています． 
このようなADDの使い方は，[こちら](https://docs.docker.com/engine/userguide/eng-image/dockerfile_best-practices/#add-or-copy)にもある通り，実はDocker的にはあまり推奨されていないのですが，今回は見逃してください．

## 3度目の正直

最初に貼ったブログポストだと， CGO周りでLibraryがDynamic Linkされてしまってうまく動かんよと書いてあったのですが，自分の環境だとこれであっさり動きました．

```bash
$ docker run -p 8080:8080 clock
$ curl "localhost:8080/time?tz=Asia/Tokyo"
2017-12-11 02:17:05                                                                                                   
```

実行したときに `no such file or directory`が出てしまう場合は次のようにビルドしてstatic linkされるようにすると良いそうです．

```bash
$ GOOS=linux CGO_ENABLED=0 go build -a -o clock main.go
```

go envで見る限りだと，自分の環境もデフォルトは`CGO_ENABLED=1`なのになぜ動いたのか...


## 最後に

今回はGoでビルドしたバイナリをscratchに乗っける例を紹介しました．
おそらく使ってるライブラリによっては，他にもハマりポイントがあるとは思うのですが一例として参考になれば幸いです!
最初に貼ったブログポストだとSSL証明書が必要なケースなども合わせて紹介されているので，そのあたりで詰まっている方はぜひそちらを読んでみてください!