feature image

2020年11月18日 | ブログ記事

ICTSC予選「ダイエットしようぜ!」で極限ダイエットする (1) 【AdC2020 5日目】

この記事は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 -wgo tool linkシンボルテーブルとデバッグ情報を取り除くオプションです。

この結果が1.47MBでした。

2回目

その後いくつかほかの問題を解いて、すべての問題をチームメンバーが解いたか解いている状態だったので、相対評価である可能性を考えてさらに小さくするために考え直しました。

-trimpath

go1.13で追加されたバイナリからファイルパスを取り除くgo buildのオプションです。
これ試してみたのですが、goのバージョンを1.13にあげると逆に大きくなってしまって-trimpathで減る分の影響がなかったです。

goのバージョン

先ほど1.13でサイズが大きくなっていたので、逆にバージョンを下げたりしたら小さくなるだろうと考えてgo1.6からgo1.11まで総当たりで試してみました。

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のオプション-lgo 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 さんの記事です。お楽しみに!

sappi_red icon
この記事を書いた人
sappi_red

19B。SysAd班。 JavaScript書いたりTypeScript書いたりGo書いたりRust書いたり…

この記事をシェア

このエントリーをはてなブックマークに追加
共有

関連する記事

2021年8月12日
CPCTFを支えたWebshell
mazrean icon mazrean
2021年5月19日
CPCTF2021を実現させたスコアサーバー
xxpoxx icon xxpoxx
2023年3月13日
GoでWebSocketのテスト書く
Ras icon Ras
2022年4月5日
アーキテクチャとディレクトリ構造
mazrean icon mazrean
2020年12月4日
【一緒に始めよう】VSTプラグインをつくる【AdC2020 21日目】
liquid1224 icon liquid1224
2022年7月26日
なろう講習会で(圧倒的)成長した
ikura-hamu icon ikura-hamu
記事一覧 タグ一覧 Google アナリティクスについて