feature image

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

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

この記事はtraPアドベントカレンダー2020の 12日目(11/25) の記事です。
19の翠(sappi_red)です。普段はSysAd班で部内サービスを触ってます。

この記事は先週の(1)に引き続き、イメージサイズを減らしていく話をします。
先週の記事では327kBまで減らしました。ここからさらに削っていきます。

32bit vs 64bit

今、既にコンテナ内にあるのはgoのバイナリだけです。つまり、サイズを減らすにはバイナリを削らないといけません。そこでポインタの長さを削るために64bitではなく32bitとしてビルドするようにします。goでは環境変数のGOARCHを指定することで変更することができます。

FROM golang:1.7 AS builder

ADD . /work

WORKDIR /work

RUN GOARCH=386 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"]

これで309240Bになりました。18kBの削減です。大きいですね。

さらにここでインライン展開を無効化すると、308216Bになりました。

バイナリ調査

ここで一旦バイナリを調査してみます。そもそもgoのバイナリがどうなってるのか知らなかったので…

いい感じの記事([Linux] バイナリファイル(ELFファイル)の調査に使えるコマンドまとめ - Qiita)があったのでそこにあるコマンド何個かたたきました。

$ readelf -w -a /appを叩いた結果のセクション部分が以下のようになってました。

Section Headers:
  [Nr] Name              Type            Addr     Off    Size   ES Flg Lk Inf Al
  [ 0]                   NULL            00000000 000000 000000 00      0   0  0
  [ 1] .text             PROGBITS        08049000 001000 084ea0 00  AX  0   0 16
  [ 2] .rodata           PROGBITS        080ce000 086000 02ee23 00   A  0   0 32
  [ 3] .typelink         PROGBITS        080fce24 0b4e24 000b68 00   A  0   0  4
  [ 4] .itablink         PROGBITS        080fd98c 0b598c 000030 00   A  0   0  4
  [ 5] .gosymtab         PROGBITS        080fd9bc 0b59bc 000000 00   A  0   0  1
  [ 6] .gopclntab        PROGBITS        080fd9c0 0b59c0 03a1cd 00   A  0   0 32
  [ 7] .shstrtab         STRTAB          00000000 0efba0 00007c 00      0   0  1
  [ 8] .noptrdata        PROGBITS        08138000 0f0000 001ce8 00  WA  0   0 32
  [ 9] .data             PROGBITS        08139d00 0f1d00 000f08 00  WA  0   0 32
  [10] .bss              NOBITS          0813ac20 0f2c20 00ff18 00  WA  0   0 32
  [11] .noptrbss         NOBITS          0814ab40 102b40 004080 00  WA  0   0 32
  [12] .note.go.buildid  NOTE            08048fc8 000fc8 000038 00   A  0   0  4

$ strings /appでは大量のソースへのパスが出てきました。
(/usr/local/go/src/fmtのような)

.note.go.buildid

.note.go.buildidは見た感じデバッグ情報っぽくて要らなさそうなセクションなので、削ってみました。

FROM golang:1.7 AS builder

ADD . /work

WORKDIR /work

RUN GOARCH=386 CGO_ENABLED=0 go build -o /app -ldflags="-s -w" -gcflags="-l"
RUN objcopy --remove-section .note.go.buildid /app

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"]

objcopyコマンドで消しています。

これで少し削れて308168Bになりました。

パスを削る

stringsの結果から/usr/local/goという部分が結構あることがわかったのでその部分が消えるようにgo本体の位置を移動した上でコンパイルするようにします。

FROM golang:1.7 AS builder

RUN mv /usr/local/go /g
ENV GOROOT /g
ENV PATH $PATH:$GOROOT/bin

WORKDIR /
ADD . /

RUN GOARCH=386 CGO_ENABLED=0 go build -o /app -ldflags="-s -w" -gcflags="-l"
RUN objcopy --remove-section .note.go.buildid /app

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"]

これでさらに少し削れて308116Bになりました。

.gopclntab

それぞれのセクションについて調べているとどうやらこの.gopclntabというセクションが削れそうということがわかりました。

このセクションにはruntime.FuncのNameメソッドで利用される情報が格納されてるようです。
しかし、この情報はこのプログラムでは不要なので削れます。(panic時は利用されますが、panicしないかつデバッグはしないという前提で)

バイナリ内の文字列置換

とりあえず簡単にできる文字列置換をしてみることにしました。
stringsの結果で関数名っぽいやつでよく使われてる文字列を置換します。
これによってエントロピーを下げてより圧縮が効くようにします。upxではLZMAが使われているので、元となるLZ77を多少考えて(細かいことはわからないんですが…)同じ文字が連続するように置換します。
ここで文字列の長さを変えてしまうとバイナリ内で使われているオフセット位置が破壊されるので同じ文字数に置換します。(正確にはバイト数)
ちなみにバイナリ内の別のセクションで同じバイト列がたまたま出現していると場合によってはプログラムが壊れます。これを行っても実行は問題なくできたので、おそらく出現していなかったんでしょう。

FROM golang:1.7 AS builder

RUN mv /usr/local/go /g
ENV GOROOT /g
ENV PATH $PATH:$GOROOT/bin

WORKDIR /
ADD . /

RUN GOARCH=386 CGO_ENABLED=0 go build -o /app -ldflags="-s -w" -gcflags="-l"
RUN objcopy --remove-section .note.go.buildid /app

FROM perl AS replacer

COPY --from=builder /app /app-org

RUN perl -pi -e 's/runtime/rrrrrrr/g' /app-org
RUN perl -pi -e 's/reflect/rrrrrrr/g' /app-org
RUN perl -pi -e 's/fmt/rrr/g' /app-org
RUN perl -pi -e 's/strconv/rrrrrrr/g' /app-org
RUN perl -pi -e 's/\/g\/src\//rrrrrrr/g' /app-org
RUN perl -pi -e 's/\(\*funcTypeFixed/rrrrrrrrrrrrrrr/g' /app-org

FROM gruebel/upx:latest as upx

COPY --from=replacer /app-org /app-org

RUN upx --ultra-brute -o /app /app-org

FROM scratch

COPY --from=upx /app /app

CMD ["/app"]

これで307844Bになりました。300Bくらい削れました。

gopclntabの文字列をすべて空にする

さすがに小手先だけの戦法だとこれ以上はつらいのでしっかりコードを書いて書き換えるようにします。

先ほど挙げたリンクを参考にしつつ、goのソースコード内gopclntabの仕様へのリンクがあるので、それも参考にしながら文字列を空にするコードを書きました。

package main

import (
	"bytes"
	"debug/elf"
	"encoding/binary"
	"io"
	"io/ioutil"
	"log"
	"os"
)

// 32bit

const (
	filepath    = "../app"
	newFilepath = "../app-new"

	HEADER_SIZE = 8
	ADDR_SIZE   = 4
)

type pclntabt struct {
	header uint64
	size   uint32
	funcs  []funct
	offset uint64
}

type funct struct {
	funcOffset uint32
	nameOffset uint32
	nameAddr   uint32
	name       string
}

func getString(data []byte, pos uint32) string {
	b := make([]byte, 0, 10)
	for i := uint32(0); data[pos+i] != 0x00; i++ {
		b = append(b, data[pos+i])
	}
	return string(b)
}

func setBlankString(data []byte, pos uint32) {
	for i := uint32(0); data[pos+i] != 0x00; i++ {
		data[pos+i] = 0x00
	}
}

func parseFunc(buf io.Reader, data []byte) (f funct, err error) {
	err = binary.Read(buf, binary.LittleEndian, &f.funcOffset)
	if err != nil {
		return
	}
	err = binary.Read(buf, binary.LittleEndian, &f.nameOffset)
	if err != nil {
		return
	}

	nameAddrAddr := ADDR_SIZE + f.nameOffset
	f.nameAddr = binary.LittleEndian.Uint32(data[nameAddrAddr : nameAddrAddr+ADDR_SIZE])
	f.name = getString(data, f.nameAddr)
	return
}

func extractPclntab(filepath string) (*pclntabt, error) {
	f, err := elf.Open(filepath)
	defer f.Close()
	if err != nil {
		return nil, err
	}

	pclntabSection := f.Section(".gopclntab")
	pclntabDat, err := pclntabSection.Data()
	if err != nil {
		return nil, err
	}

	pos := uint32(0)

	st := pclntabt{
		offset: pclntabSection.Offset,
	}
	buf := bytes.NewReader(pclntabDat)

	// ヘッダー
	err = binary.Read(buf, binary.LittleEndian, &st.header)
	if err != nil {
		return nil, err
	}
	pos += HEADER_SIZE

	// サイズ
	err = binary.Read(buf, binary.LittleEndian, &st.size)
	if err != nil {
		return nil, err
	}
	pos += ADDR_SIZE

	end := pos + (st.size * ADDR_SIZE * 2)

	fs := make([]funct, 0)
	for pos < end {
		pos += 2 * ADDR_SIZE
		f, err := parseFunc(buf, pclntabDat)
		if err != nil {
			return nil, err
		}
		fs = append(fs, f)
	}
	st.funcs = fs

	return &st, err
}

func main() {
	pclntab, err := extractPclntab(filepath)
	if err != nil {
		log.Fatal(err)
	}

	b, err := ioutil.ReadFile(filepath)
	if err != nil {
		log.Fatal(err)
	}

	for _, f := range pclntab.funcs {
		setBlankString(b, uint32(pclntab.offset)+f.nameAddr)
	}

	err = ioutil.WriteFile(newFilepath, b, os.ModePerm)
	if err != nil {
		log.Fatal(err)
	}
}

関数名の箇所をすべてNULL文字で置き換えています。バイナリサイズ自体は変わりませんが圧縮のタイミングで大きく削ることができるようになります。

これを実行するようにしたDockerfileがこれです。

FROM golang:1.7 AS builder

RUN mv /usr/local/go /g
ENV GOROOT /g
ENV PATH $PATH:$GOROOT/bin

WORKDIR /
ADD . /

RUN GOARCH=386 CGO_ENABLED=0 go build -o /app -ldflags="-s -w" -gcflags="-l"
RUN objcopy --remove-section .note.go.buildid /app

WORKDIR /r
RUN go run main.go

FROM gruebel/upx:latest as upx

COPY --from=builder /app-new /app-org

RUN upx --ultra-brute -o /app /app-org

FROM scratch

COPY --from=upx /app /app

CMD ["/app"]

これで300628Bになりました。7kB削れました。

関数名へのポインタを同一にする

funct型のnameAddrは結局すべてNULL文字へのポインタなのですべて同じ箇所を指すようにします。こうすればそこの箇所も効率よく圧縮できるようになるはずです。

package main

import (
	"bytes"
	"debug/elf"
	"encoding/binary"
	"io"
	"io/ioutil"
	"log"
	"os"
)

// 32bit

const (
	filepath    = "../app"
	newFilepath = "../app-new"

	HEADER_SIZE = 8
	ADDR_SIZE   = 4
)

type pclntabt struct {
	header uint64
	size   uint32
	funcs  []funct
	offset uint64
}

type funct struct {
	funcOffset uint32
	nameOffset uint32
	nameAddr   uint32
	name       string
}

func getString(data []byte, pos uint32) string {
	b := make([]byte, 0, 10)
	for i := uint32(0); data[pos+i] != 0x00; i++ {
		b = append(b, data[pos+i])
	}
	return string(b)
}

func setBlankString(data []byte, pos uint32) {
	for i := uint32(0); data[pos+i] != 0x00; i++ {
		data[pos+i] = 0x00
	}
}

func setNameAddr(data []byte, namePos uint32, newNameAddr uint32) {
	newNameAddrB := make([]byte, 4)
	binary.LittleEndian.PutUint32(newNameAddrB, newNameAddr)

	for i := uint32(0); i < ADDR_SIZE; i++ {
		data[namePos+ADDR_SIZE+i] = newNameAddrB[i]
	}
}

func parseFunc(buf io.Reader, data []byte) (f funct, err error) {
	err = binary.Read(buf, binary.LittleEndian, &f.funcOffset)
	if err != nil {
		return
	}
	err = binary.Read(buf, binary.LittleEndian, &f.nameOffset)
	if err != nil {
		return
	}

	nameAddrAddr := ADDR_SIZE + f.nameOffset
	f.nameAddr = binary.LittleEndian.Uint32(data[nameAddrAddr : nameAddrAddr+ADDR_SIZE])
	f.name = getString(data, f.nameAddr)
	return
}

func extractPclntab(filepath string) (*pclntabt, error) {
	f, err := elf.Open(filepath)
	defer f.Close()
	if err != nil {
		return nil, err
	}

	pclntabSection := f.Section(".gopclntab")
	pclntabDat, err := pclntabSection.Data()
	if err != nil {
		return nil, err
	}

	pos := uint32(0)

	st := pclntabt{
		offset: pclntabSection.Offset,
	}
	buf := bytes.NewReader(pclntabDat)

	// ヘッダー
	err = binary.Read(buf, binary.LittleEndian, &st.header)
	if err != nil {
		return nil, err
	}
	pos += HEADER_SIZE

	// サイズ
	err = binary.Read(buf, binary.LittleEndian, &st.size)
	if err != nil {
		return nil, err
	}
	pos += ADDR_SIZE

	end := pos + (st.size * ADDR_SIZE * 2)

	fs := make([]funct, 0)
	for pos < end {
		pos += 2 * ADDR_SIZE
		f, err := parseFunc(buf, pclntabDat)
		if err != nil {
			return nil, err
		}
		fs = append(fs, f)
	}
	st.funcs = fs

	return &st, err
}

func main() {
	pclntab, err := extractPclntab(filepath)
	if err != nil {
		log.Fatal(err)
	}

	b, err := ioutil.ReadFile(filepath)
	if err != nil {
		log.Fatal(err)
	}

	firstNameAddr := pclntab.funcs[0].nameAddr
	for _, f := range pclntab.funcs {
		setBlankString(b, uint32(pclntab.offset)+f.nameAddr)
		setNameAddr(b, uint32(pclntab.offset)+f.nameOffset, firstNameAddr)
	}

	err = ioutil.WriteFile(newFilepath, b, os.ModePerm)
	if err != nil {
		log.Fatal(err)
	}
}

setNameAddrというのが変わった箇所です。

Dockerfileは変わらずで、この結果298004Bになりました。
これで300kBを切ることができました。

ちなみに.gopclntabをすべて消すと211kBになるので、オフセット位置の調整など行えばさらに小さくすることも可能だと考えられます。(流石に大変なので手を出さなかった)

終わりに

元のイメージサイズが796MBだったので、およそ99.963%の削減ですね。
やっていく中でLinuxの実行ファイルの知識やgoのビルドやバイナリの知識がたくさん得られたので、かなり面白かったです。


明日は @temma さんの記事です。お楽しみに!

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

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

この記事をシェア

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

関連する記事

2020年12月4日
【一緒に始めよう】VSTプラグインをつくる【AdC2020 21日目】
liquid1224 icon liquid1224
2020年12月21日
かけだしデザイナーが「ほぼ」1ヵ月間毎日ロゴを描いてみた話【AdC2020 38日目】
WhiteFox icon WhiteFox
2020年12月17日
一人でゲームを作ったハッカソン【AdC2020 34日目】
Komichi icon Komichi
2020年12月17日
JavaScriptの非同期処理についてのメモ【AdC2020 33日目】
reyu icon reyu
2020年12月13日
全方位木DPについて
ebi icon ebi
2020年12月12日
VRSNSのすゝめ【AdC2020 29日目】
wataame89 icon wataame89
記事一覧 タグ一覧 Google アナリティクスについて