Go で書いたアプリを distroless 使って動かす方法

Motivation

  • Go で書いたアプリを Docker で動かしたい場面は多々ある
  • Docker イメージのサイズは小さければ小さいほど良い(諸説あり)
  • 軽量 Docker イメージといえば alpine を使用したものが一般的だったが、昨今は distroless の方が良いらしい
  • そのへんの理解を整理しつつ、 Go で書いたアプリを distroless で動かす所まで試したメモ

Organize my understanding

alpine ってなんだっけ?

  • Alpine Linux をベースとした Docker イメージ
  • Alpine Linux 自体が軽量な Linux ディストリビューションであり、 Docker イメージとして使用する場合も容量が小さいのが嬉しいポイントだった

なんで alpine は使わないほうがいいんだっけ?

  • libc には一部互換性の不足がある1
    • Native モジュールをバンドルしているアプリケーションで問題が発生する可能性がある
    • python:3-slim, python:3-alpine3.6 イメージでベンチマークを動かした際に alpine の方がパフォーマンスが低い結果が出ている2
      • alpine は libc として glibc ではなく musl libc というのを使っている
      • 割と様々な違いがある3
    • Go なら、そもそも libc つかってなくない?
      • net パッケージを使っている場合、デフォルトだと動的リンクで依存するようになっている4
        • これは cgo パッケージが使われるようになったため
          • cgo パッケージは Go から C のコードを呼び出せるようにするもの
      • ビルド時にオプションを指定することで無効化することはできるものの、おそらくパフォーマンスは低下する
        • https://golang.org/src/net/net.go あたりのコメントを読んだ感じ、 cgo を使える場合には名前解決周りのパフォーマンスが向上しているようだ
        • よって cgo を無効化する場合には、これらの恩恵は無くなる
  • 他ベースイメージも軽量化が進んでおり、イメージサイズの差がなくなってきた

Distroless は何がいいの?

  • Distroless は Google がメンテナンスしている軽量 Docker イメージ
    • Google が公式にメンテナンスしており、もちろん軽量
      • Debian ベース
    • 各プログラミング言語向けのイメージも提供している
    • Docker イメージは grc.io でホストされている
  • Go で作成したアプリを動かすなら distroless/base が良さそう

公開されている Go の Dockerfile 例

FROM golang:1.12 as build-env

WORKDIR /go/src/app
ADD . /go/src/app

RUN go get -d -v ./...

RUN go build -o /go/bin/app

FROM gcr.io/distroless/base
COPY --from=build-env /go/bin/app /
CMD ["/app"]

まぁマルチステージビルドを使っていて、 golang 公式イメージでバイナリをビルドして、ビルドしたバイナリを distroless/base にコピーして動かすという感じ

distroless/base には何が入っているの?

  • README に書いてある
  • distroless/static イメージがベースとなっている
    • これは libc を必要としない static にコンパイルされた Go アプリケーション等で使える
    • 以下のものが含まれている
      • ca-certificates
      • root ユーザー用の /etc/passwd エントリ
      • /tmp ディレクトリ
      • tzdata
  • distroless/base
    • libc/cgo を必要とする Go アプリなど、 distroless/static では不十分なほとんどのアプリケーションに使う
    • distroless/static に含まれるものに加えて、以下のパッケージが含まれている
      • glibc
      • libssl
      • openssl

ということで、基本は distroless/base を使えば良い

How to Run Go App with distroless

set up project

mkdir my-go-app && cd my-go-app
go mod init example.com/my-go-app
touch main.go

implementation go app

ここではシンプルに UUIDv4 を返すアプリとする


main.go

package main

import (
	"fmt"

	"github.com/google/uuid"
)

func main() {

	uuidv4, err := uuid.NewRandom()
	if err != nil {
		fmt.Println("err")
		return
	}

	fmt.Println(uuidv4)
}

Docker 無しで普通に動かしてみる

$ go run main.go 
go: finding module for package github.com/google/uuid
go: found github.com/google/uuid in github.com/google/uuid v1.2.0
81224ca2-780c-4dad-acb4-9acb366a7371

make Dockerfile

touch Dockerfile

Dockerfile

FROM golang:1.15.8-buster as gobuilder
WORKDIR /go/src/app
COPY go.mod go.sum ./
RUN go mod download
COPY main.go ./
RUN go build -ldflags="-w -s" -o /go/bin/app

FROM gcr.io/distroless/base
COPY --from=gobuilder /go/bin/app /
CMD ["/app"]
  • distroless が debian ベースなので、念の為 golang:xxx-buster を使ってビルドしている

go mod download

Go Modules で管理している依存関係をモジュールキャッシュにダウンロードする5

go build -ldflags=“-w -s”

  • -ldflags: go tool link の起動時に渡される引数6
  • "-w -s": go tool link のオプション7
    • go tool link は main パッケージの Go アーカイブまたはオブジェクトを、依存関係とともに読み込んで、実行可能なバイナリに結合するツール
    • -w: DWARF のシンボルテーブルを省略する
      • DWARF (ドワーフ) は、デバッグ用のデータフォーマットのこと
    • -s: シンボルテーブルとデバッグ情報を省略する

自分の理解では、 Go アプリを Linux 向けにビルドすると ELF 形式のバイナリが生成され、ビルドの際に上記オプションを指定することで、生成されるバイナリのサイズを小さくできるものと理解している

docker build & run

docker build my-go-app .
docker run my-go-app

実行例

$ docker build -t my-go-app .
Sending build context to Docker daemon   5.12kB
Step 1/10 : FROM golang:1.15.8-buster as gobuilder
1.15.8-buster: Pulling from library/golang
0ecb575e629c: Pull complete 
7467d1831b69: Pull complete 
feab2c490a3c: Pull complete 
f15a0f46f8c3: Pull complete 
2ea92ed63b96: Pull complete 
223c5fc7af76: Pull complete 
764ae8cdbfbc: Pull complete 
Digest: sha256:56e443b088657df2d9ff891b043aead11aedd94f8413959a93364af22564d6d7
Status: Downloaded newer image for golang:1.15.8-buster
 ---> 7185d074e387
Step 2/10 : WORKDIR /go/src/app
 ---> Running in 36e0f7e7225f
Removing intermediate container 36e0f7e7225f
 ---> 4590930d6d64
Step 3/10 : COPY go.mod go.sum ./
 ---> afbb960dc817
Step 4/10 : RUN go mod download
 ---> Running in 4a14cb2323a0
Removing intermediate container 4a14cb2323a0
 ---> 2a4a4045965a
Step 5/10 : COPY main.go ./
 ---> c6c18189789f
Step 6/10 : RUN go generate
 ---> Running in aa34e2d9aa29
Removing intermediate container aa34e2d9aa29
 ---> 881d69b323fa
Step 7/10 : RUN go build -ldflags="-w -s" -o /go/bin/app
 ---> Running in afc76bbeebc7
Removing intermediate container afc76bbeebc7
 ---> 9e98cf748f86
Step 8/10 : FROM gcr.io/distroless/base
 ---> a4cf6da932ac
Step 9/10 : COPY --from=gobuilder /go/bin/app /
 ---> e506aa225384
Step 10/10 : CMD ["/app"]
 ---> Running in a0de36104140
Removing intermediate container a0de36104140
 ---> 9f0084a99b9e
Successfully built 9f0084a99b9e
Successfully tagged my-go-app:latest
$ docker images my-go-app
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
my-go-app           latest              9f0084a99b9e        2 minutes ago       20.9MB
$ docker run my-go-app
a8c80ca7-08c0-4b62-af48-afe39d11e130

試した環境

$ uname -a
Linux ip-172-31-255-5.ap-northeast-1.compute.internal 4.14.231-173.361.amzn2.x86_64 #1 SMP Mon Apr 26 20:57:08 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux
$ cat /etc/os-release 
NAME="Amazon Linux"
VERSION="2"
ID="amzn"
ID_LIKE="centos rhel fedora"
VERSION_ID="2"
PRETTY_NAME="Amazon Linux 2"
ANSI_COLOR="0;33"
CPE_NAME="cpe:2.3:o:amazon:amazon_linux:2"
HOME_URL="https://amazonlinux.com/"
$ docker --version
Docker version 19.03.13-ce, build 4484c46
$ go version
go version go1.15.8 linux/amd64