フューチャー技術ブログ

コンテナイメージ内の実行ファイルをupxで圧縮するべきか

コンテナイメージ内の実行ファイルをupxで圧縮するべきか

福田(@knqyf263)と申します。過去にフューチャー発OSSのVuls開発を手伝っていましたが、現在はフューチャーで働いているわけでもなく完全に部外者です。今回は社員の澁川さんの推薦もあり、ブログの寄稿をさせて頂くことになりました。

背景

その理由の前に少し背景を説明しますが、自分はTrivyというOSSの脆弱性スキャナーのメンテナをやっていまして先日Goバイナリの脆弱性検知をする機能をリリースしました。

例えばコンテナイメージ内にGoでビルドしたバイナリを1つだけ置いている場合などにも検知が出来るためとても便利です(自画自賛)。stripなどでシンボルを落としていてもセクションヘッダにモジュール情報が残っているため検知は動作するのですが、upxでバイナリを圧縮している場合には動きません。

upxというのは実行ファイルを圧縮するためのツールで色々なOSのファイル形式に対応しており展開も高速なので広く使われています。多くのプラットフォームで内部的にLZMAを利用しており圧縮率もzip/gzipより高いと謳っています。

実行時にupxが自分で自分を展開してそのまま実行してくれるので、ユーザの方で一度展開してから実行するといった処理は必要ありません。単にバイナリのサイズが小さくなるイメージです。

実行ファイルのサイズが小さければダウンロードも速いですしいくつかの利点が得られるため、こういった圧縮ツールを使っている人も多いかと思います。ではコンテナイメージ内に置くような実行ファイルでも圧縮するべきかどうか、というのが今回のブログのネタです。

コンテナイメージのサイズは小さい方が良い、というのは聞いたことがある人が多いと思うので当然圧縮するべきだろうと思うかもしれませんが、コンテナイメージはレイヤー単位でgzipで圧縮されDocker Hubなどのコンテナレジストリに格納されます。単にイメージのサイズだけで考えるのではなく実際にpullする時にどう影響が出るのかについて考えるほうが良さそうであるというのは以前雑に述べていたりしました。

というような雑談をSlackでしていたところ、フューチャー社員の渋川さん( @shibu_jp )からupxでサイズ小さくすることにどのぐらいの意味があるのか?という疑問が出ました。

Slack___tsuda_knqyf263___Cyber_Security_Innovation_Group.png

ちなみに渋川さんはフューチャー技術ブログで数多くの素晴らしいブログを執筆されています。

確かにレイヤーをgzipで圧縮したらupxによる圧縮と差はそこまで大きくならないんじゃないか、という気がしたので検証してみました。upxの方は圧縮したあとに再度gzipで圧縮することになるので二度目のgzip圧縮率はかなり低いと考えられます。渋川さんから出た疑問だったのでフューチャーのブログに載せようということで今回寄稿することになりました。

検証(macho)

upxは様々なOSの実行ファイル形式に対応していますが、自分はmacOSを使っているためまずmachoで試してみます。

upxで圧縮しない場合

まずは普通にupxせずにDockerイメージを作ってみます。

バイナリのビルド

TrivyをmacOS上でビルドしてみます。

$ go build -o trivy cmd/trivy/main.go
$ du -bh trivy
41M trivy

バイナリのサイズは41MBでした。今回は ldflags '-w -s'などのオプションは付けていないためDWARFやシンボルテーブルは残っています。

イメージのbuild/push

レジストリに置いたらどのぐらいのサイズなのか?というのは実際にpushしてしまうほうが早いと思うので、buildしてレジストリにpushします。まずビルドします。

$ docker build -t knqyf263/trivy:macho-nonupx .
Sending build context to Docker daemon 49.69MB
Step 1/5 : FROM alpine:3.13
---> 6dbb9cc54074
Step 2/5 : RUN apk --no-cache add ca-certificates git
---> Using cache
---> 079d3d306dcd
Step 3/5 : COPY trivy /usr/local/bin/trivy
---> 98973bc45fe2
Step 4/5 : COPY contrib/*.tpl contrib/
---> e09be4486dd5
Step 5/5 : ENTRYPOINT ["trivy"]
---> Running in 9bc4e9b607c9
Removing intermediate container 9bc4e9b607c9
---> 1be57dd63281
Successfully built 1be57dd63281
Successfully tagged knqyf263/trivy:macho-upx

Step 3を見てもらえば分かりますが、単にホスト側にあるバイナリをCOPYで置いています。machoのバイナリをコピーしているせいで動かないのですがサイズの検証なので気にせず進めていきます。

そしてこのイメージをpushします。

$ docker push knqyf263/trivy:macho-nonupx
The push refers to repository [docker.io/knqyf263/trivy]
27289a563633: Pushed
f8edd4bba8b2: Pushed
464f5bb1fc11: Layer already exists
b2d5eeeaba3a: Layer already exists
macho-nonupx: digest: sha256:5fc7352ecd65e3f2eada6f251ef91c721c685d61046c2c948ebfabfec52f8582 size: 1159

無事にpushできました。

レイヤーサイズの確認

ではレジストリにあるレイヤーのサイズを確認してみます。craneというツールを使います。Googleのgo-containerregistryというライブラリに付随しているCLIツールになります。

https://github.com/google/go-containerregistry/tree/main/cmd/crane

$ crane manifest knqyf263/trivy:macho-nonupx
{
"schemaVersion": 2,
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
"config": {
"mediaType": "application/vnd.docker.container.image.v1+json",
"size": 2264,
"digest": "sha256:3b2add278b7f6df5e72f0d6592ece9cbb22c859fc01e6d8932e036d335f6074c"
},
"layers": [
{
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
"size": 2811969,
"digest": "sha256:540db60ca9383eac9e418f78490994d0af424aab7bf6d0e47ac8ed4e2e9bcbba"
},
{
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
"size": 6664328,
"digest": "sha256:8e566f9a0cb95716b962ba9e17b0c0e3f1b970c51424032dcb7c660dce3d5ee6"
},
{
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
"size": 18900731,
"digest": "sha256:cdcee5fd7ec2508ee27e439924a888be100e6f2cd08e8a9c89ee43911c2dd655"
},
{
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
"size": 4052,
"digest": "sha256:cbcc716d57451836029ade124851f09b7474aee4ca711741086cd54194d73102"
}
]
}

下から二番目のレイヤーがバイナリを置いたレイヤーなので18.03MB( =18900731/(1024*1024) )になっています。

Docker Hubで確認

念のためDocker HubのUIでも確認します。

Docker HubのUI

確かに18.03MBになっています。一応リンクも貼っておきます。

https://hub.docker.com/layers/knqyf263/trivy/macho-nonupx/images/sha256-5fc7352ecd65e3f2eada6f251ef91c721c685d61046c2c948ebfabfec52f8582?context=repo

サイズ比較

ということで上記の結果をまとめると以下のようになります。

  • バイナリのサイズ:41MB
  • レイヤーのサイズ:18.03MB

バイナリのサイズは41MBもあったのにレイヤーに置いてgzipすると18MBまで減っています。

upxで圧縮する場合

先程ビルドしたバイナリがあるのでこれをupxで圧縮します。

$ upx ./trivy
Ultimate Packer for eXecutables
Copyright (C) 1996 - 2020
UPX 3.96 Markus Oberhumer, Laszlo Molnar & John Reiser Jan 23rd 2020

File size Ratio Format Name
-------------------- ------ ----------- -----------
42502160 -> 19062800 44.85% macho/amd64 trivy

Packed 1 file.

upxは圧縮レベルが1から10まであり、512 KiB以下だと8が使われて大きいサイズだと7が使われるようです。10も試してみたのですが(厳密には --best)、今回の検証ではあまりサイズが変わらなかったのでとりあえず7の結果について書いています。

上のupxの出力で19MBぐらいになってサイズが44.85%になったと書いてあります。念のため確認しておきます。

$ du -h trivy
19M trivy

やはり19MBです。

イメージのbuild/push

ここは先程と同様にイメージのbuild/pushをします。

$ docker build -t knqyf263/trivy:macho-upx .
$ docker push knqyf263/trivy:macho-upx
The push refers to repository [docker.io/knqyf263/trivy]
d2fb9e0dd8ea: Pushed
d9a3ad8f3256: Pushed
464f5bb1fc11: Layer already exists
b2d5eeeaba3a: Layer already exists
macho-upx: digest: sha256:de09d822301411eb563b0e1b6fd014a0b1017eac941bb62e7bc159012b4732de size: 1159

レイヤーサイズの確認

先程同様に確認します。

$ ./crane manifest knqyf263/trivy:macho-upx
{
"schemaVersion": 2,
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
"config": {
"mediaType": "application/vnd.docker.container.image.v1+json",
"size": 2267,
"digest": "sha256:ea6eb68a192d77914019c840ab46e9e56ea69f8aa8eef27befbaca5a623bbb39"
},
"layers": [
{
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
"size": 2811969,
"digest": "sha256:540db60ca9383eac9e418f78490994d0af424aab7bf6d0e47ac8ed4e2e9bcbba"
},
{
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
"size": 6664328,
"digest": "sha256:8e566f9a0cb95716b962ba9e17b0c0e3f1b970c51424032dcb7c660dce3d5ee6"
},
{
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
"size": 18719684,
"digest": "sha256:d1471d88b38fce964464adabe62c9e2819cf66c62d0683567b9fcc090de76055"
},
{
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
"size": 4080,
"digest": "sha256:2fe2f7d7b9e2e83ce09db2a8599908864a8d9d3433bcfb8dce7aed35138ca3f4"
}
]
}

今回は17.85MBになっています。

Docker Hubで確認

やはりサイズは17.85MBです。

Docker HubのUI

https://hub.docker.com/layers/knqyf263/trivy/macho-upx/images/sha256-de09d822301411eb563b0e1b6fd014a0b1017eac941bb62e7bc159012b4732de?context=repo

サイズ比較

上記の結果をまとめると以下になります。

  • バイナリサイズ:19MB
  • レイヤーサイズ:17.85MB

サイズ比較まとめ

ではupxによって圧縮した場合としない場合のサイズを比べてみます。

  • upx圧縮しない場合
    • バイナリサイズ:41MB
    • レイヤーサイズ:18.03MB
  • upx圧縮した場合
    • バイナリサイズ:19MB
    • レイヤーサイズ:17.85MB

ということで差は0.18MB程度になりました。予想通り、upxによる圧縮はレイヤー圧縮と比較して著しく効果があるわけではなさそうです。ELFでも検証してみます。

検証(ELF)

Linuxでコンテナイメージを使うケースが大多数だと思うので、ELF形式の場合の検証もしてみます。

upxで圧縮しない場合

バイナリのビルド

linux/amd64向けにビルドします。

$ GOOS=linux GOARCH=amd64 go build -o trivy cmd/trivy/main.go
$ du -h trivy
41M trivy

machoの時と同じく41MBでした。

Docker Hubで確認

machoの時と同じくイメージをbuild/pushし、今回は直接Docker Hubで確認してみます。

Docker HubのUI

18.25MBでした。

https://hub.docker.com/layers/knqyf263/trivy/elf-nonupx/images/sha256-cea16479687eaa610bf0bfec96e415f791aea1ca19e7e26fa6240ed5a8448b75?context=repo

サイズ比較

  • バイナリのサイズ:41MB
  • レイヤーのサイズ:18.25MB

upxで圧縮する場合

バイナリのビルド

upxで圧縮します。

$ upx ./trivy
Ultimate Packer for eXecutables
Copyright (C) 1996 - 2020
UPX 3.96 Markus Oberhumer, Laszlo Molnar & John Reiser Jan 23rd 2020

File size Ratio Format Name
-------------------- ------ ----------- -----------
42878967 -> 20087380 46.85% linux/amd64 trivy

Packed 1 file.
$ du -h ./trivy
20M ./trivy

20MBになりました。

Docker Hubで確認

18.64MBでした。

Docker HubのUI

サイズ比較

ELFバイナリをupxした場合のサイズ比較は以下になります。

  • バイナリのサイズ:20MB
  • レイヤーのサイズ:18.64MB

サイズ比較まとめ

  • upx圧縮しない場合
    • バイナリサイズ:41MB
    • レイヤーサイズ:18.25MB
  • upx圧縮した場合
    • バイナリサイズ:20MB
    • レイヤーサイズ:18.64MB

恐ろしいことが起きています。サイズが逆転しました。upx圧縮した場合のほうが、0.39MBもレイヤーサイズが増えています。先程machoで削減したのが0.18MBだったことを考えるとかなり増えている印象です。

upxは内部でLZMAを使っておりgzipよりも圧縮率が高いことを謳っているので、なぜこんな事が起きるのかと思いますがupxは実行ファイル形式を保つために展開プログラムもバイナリに埋め込んでいるため、単純なgzipと比較すると大きくなってしまうことがあるのかもしれません。レイヤーは丸ごとgzipされ展開されてから利用される前提で、upxは実行ファイルのまま圧縮して実行時に展開する想定なので戦っている土俵が違う感じがあります。レイヤーgzipの場合は展開プログラムはレイヤーの外にあります。実行時に展開できる形でgzipを使うとupxに劣るという意味で、gzipよりも圧縮率が高いと言っているのかもしれません。あくまで推測なので厳密なところは分かっていません。

まとめ

今回はupxで圧縮した実行ファイルをコンテナイメージに置いた場合のサイズ削減率について検証しました。元からコンテナイメージはレイヤー単位でgzip圧縮されるため、upxで圧縮してもそこまで大きなサイズの削減は得られませんでした。

これはもちろんプラットフォームやアーキテクチャによっても異なると思いますしプロジェクトによっても異なります。TrivyのELFの例では増えてしまいましたが、フューチャーが提供しているVulsというOSSではELFでも0.1MB程度サイズが小さくなりました。stripしたりldflagsを付けたりすることで結果が変わってくる可能性もあります。

レイヤーに他のバイナリが存在したらgzipがさらに有利になる可能性もありますし、Goのバイナリ以外は検証していないのでupxの方が有利になる可能性もあります。

サイズの比較だけをしてきましたが、upxは当然実行時の展開コストもあります。以下は古いCPUにおける値なので今はもっと速いと思いますが、それでもバイナリが大きくなれば展開コストはある程度大きくなってきます。

- very fast decompression: about 10 MiB/sec on an ancient Pentium 133,
about 200 MiB/sec on an Athlon XP 2000+.

他にもイメージビルド時に圧縮の時間もかかるためCI/CDの時間は増加します。 --best をつけるとTrivyの41MBのELF実行ファイルをupxで圧縮するのに5分かかりました。イメージビルドの時間を5分増加させて0.1MBの削減だった場合にそれが見合っているのかどうかは考える必要があると思います。5分増加させた挙げ句にサイズも増えていると最悪です。

そもそもupxの導入にかかるコストもあります。Dockerfileを修正したりCI/CDの設定をしたりが必要になってきます。

今回のブログでは必ずupxするべきだ、絶対にupxするべきではない、ということは言っていません。ただ盲目的にupxで圧縮しておけば良いわけではないことを知ってもらえればと思い書いています。もし本当にイメージサイズを削減したいならきちんと検証して比較することをおすすめします。