この記事はtraPアドベントカレンダー2020の 5日目(11/18) の記事です。
19の翠(sappi_red)です。普段はSysAd班で部内サービスを触ってます。
この記事では、先月末に行われてたICTSCの予選で出た問題である「ダイエットしようぜ!」のwriteupみたいなものです。
この記事の一週間後に続きの記事を出します。
問題内容
公式の解説に詳しい話はあるのですが、要約するとgoのソースコードが渡されるので、それが実行できるDockerイメージを可能な限り小さいイメージサイズで実現するという問題でした。
初期状態
FROM golang:1.11
ADD . /work
WORKDIR /work
CMD go run hash.go
hash.go
は問題の制約で手を入れられないので省略します。
予選でやったこと
1回目
「可能な限り小さくしてほしい」の採点基準が相対評価か絶対評価かわからなかったので、とりあえず絶対評価でマルチステージビルドが想定解だろうということで、最初からそこまで細かくやらなかったです。
FROM golang:1.11 AS builder
ADD . /work
WORKDIR /work
RUN CGO_ENABLED=0 go build -o app -ldflags="-s -w"
FROM scratch
COPY --from=builder /work/app /app
ENTRYPOINT ["/app"]
マルチステージビルドにして、scratch
イメージに入れるというのが主な変更ですね。
ここで注意すべきなのがENTRYPOINT
のところをENTRYPOINT /app
にすると動かないという点です。ENTRYPOINT
の後に続く部分が配列みたいになっているのがexec形式、ただの文字列になっているのがシェル形式というもの(ENTRYPOINT - Dockerfile リファレンス)なのですが、前者はシェルを呼び出さないのに対して後者はシェルを呼び出します。前者だとシェルがないのでできることは限られますが、後者はシェルをscratch
に含める必要が出てきます。今回はサイズを削りたいので前者を利用することで、sh
を含めるの回避できます。
ほかの変更として、-ldflags="-s -w"
の指定をしています。
go build
では内部的に複数のプログラムが呼び出されていくのですが、それのうちgo tool link
に渡す引数を指定するオプションがldflags
です。-s -w
はgo tool link
のシンボルテーブルとデバッグ情報を取り除くオプションです。
この結果が1.47MBでした。
2回目
その後いくつかほかの問題を解いて、すべての問題をチームメンバーが解いたか解いている状態だったので、相対評価である可能性を考えてさらに小さくするために考え直しました。
-trimpath
go1.13で追加されたバイナリからファイルパスを取り除くgo build
のオプションです。
これ試してみたのですが、goのバージョンを1.13
にあげると逆に大きくなってしまって-trimpath
で減る分の影響がなかったです。
goのバージョン
先ほど1.13
でサイズが大きくなっていたので、逆にバージョンを下げたりしたら小さくなるだろうと考えてgo1.6からgo1.11まで総当たりで試してみました。
- 1.11 (1.47MB)
- 1.10 (1.3MB)
- 1.9 (1.32MB)
- 1.8 (1.11MB)
- 1.7 (1.11MB)
- 1.6 (1.68MB)
go1.7とgo1.8が一番小さくなっていますね。go1.6までで止めたのはgo1.7でバイナリサイズの最適化が入ったためです。
バイナリの圧縮 (upx
)
さて、バイナリ自体を小さくするのは多少行ったので、さらに小さくするためにバイナリに圧縮をかけます。
それを行うためにupx
というツールを使います。upx
のオプションに目を通しつつ、いくつか試すと--ultra-brute
というのが一番圧縮してくれるっぽいのでそれを利用することにしました。ここでさっきgo1.7とgo1.8のファイルサイズが変わらなかったので、両方試しました。結果として、go1.7では327kB、go1.8では333kBでした。
最終的なDockerfile
FROM golang:1.7 AS builder
ADD . /work
WORKDIR /work
RUN CGO_ENABLED=0 go build -o /app -ldflags="-s -w"
FROM gruebel/upx:latest as upx
COPY --from=builder /app /app-org
RUN upx --ultra-brute -o /app /app-org
FROM scratch
COPY --from=upx /app /app
CMD ["/app"]
この327kBで提出しました。
実はこれだとupxのバージョンが3.94と古いものが入るので、ちゃんと新しいバージョンも試したほうがよいです。(あとで確認したところ、今回は古いほうがほんの少し小さかったです。)
FROM alpine:latest as upx
RUN apk add --no-cache upx=3.96-r0
COPY --from=builder /app /app-org
RUN upx --ultra-brute -o /app /app-org
予選終わった直後に思い付いたこと
インライン展開の無効化
goはコンパイル時に、関数呼び出しのオーバーヘッドを減らすために、短い関数はインライン展開します。これは逆に同じ処理が複数の箇所で含まれるようになるということなので、場合によってはファイルサイズをごくわずかに大きくしている可能性があります。そこで、go tool compileのオプションの-l
をgo build
の-gcflags
(これはgo tool compile
に渡すオプションを指定できる)に指定することで、インライン展開を無効化してみました。
結果としては、327736B
と元の327228
よりも大きくなりました。おそらくupxの圧縮で同一箇所がうまく圧縮されていたのだと思います。
最適化の無効化
変わらないだろうと思いながら一応最適化の無効化も試してみました。go build
に-gcflags "-N"
をつけることでできます。やっぱりサイズはほぼ変わらずでした。
gccgoの利用
goのコンパイラは複数の種類があります。そのうち、go build
で利用できるものとしては、通常使われるgcと、gccを利用したgccgoがあります。
動くかは別にしてサイズ減りそうなフラグをてんこもりにしてみたのですが、752kBと全然でした。
FROM golang:1.7 AS builder
RUN apt-get update && apt-get install -y \
gccgo
ADD . /work
WORKDIR /work
RUN go build -compiler gccgo -o /app -gccgoflags '-Os -g0 -s -w -fuse-ld=gold -static -fno-go-check-divide-zero -fno-go-check-divide-overflow -momit-leaf-frame-pointer -fno-math-errno -fdata-sections -ffunction-sections -Wl,--gc-sections'
FROM gruebel/upx:latest as upx
COPY --from=builder /app /app-org
RUN upx --ultra-brute -o /app /app-org
FROM scratch
COPY --from=upx /app /app
CMD ["/app"]
ちなみにこの状態だと動かなかったです。
終わりに
といった感じで予選の時には327kBにできました。
その後、終わったあとにいじって300kB切りを達成したので、来週の記事ではそれについて書こうと思います!
明日は @tatyam さんの記事です。お楽しみに!