この記事は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
というセクションが削れそうということがわかりました。
- golang/go#36313: runtime: pclntab is too big
- Golang function's name obfuscation : How to fool analysis tools? - CTF
- Golang Internals – changing a function name - Harpaz's blog
このセクションには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 さんの記事です。お楽しみに!