コンテナイメージ内の実行ファイルをupxで圧縮するべきか
福田(@knqyf263)と申します。過去にフューチャー発OSSのVuls開発を手伝っていましたが、現在はフューチャーで働いているわけでもなく完全に部外者です。今回は社員の澁川さんの推薦もあり、ブログの寄稿をさせて頂くことになりました。
背景
その理由の前に少し背景を説明しますが、自分はTrivyというOSSの脆弱性スキャナーのメンテナをやっていまして先日Goバイナリの脆弱性検知をする機能をリリースしました。
例えばコンテナイメージ内にGoでビルドしたバイナリを1つだけ置いている場合などにも検知が出来るためとても便利です(自画自賛)。stripなどでシンボルを落としていてもセクションヘッダにモジュール情報が残っているため検知は動作するのですが、upxでバイナリを圧縮している場合には動きません。
upxというのは実行ファイルを圧縮するためのツールで色々なOSのファイル形式に対応しており展開も高速なので広く使われています。多くのプラットフォームで内部的にLZMAを利用しており圧縮率もzip/gzipより高いと謳っています。
実行時にupxが自分で自分を展開してそのまま実行してくれるので、ユーザの方で一度展開してから実行するといった処理は必要ありません。単にバイナリのサイズが小さくなるイメージです。
実行ファイルのサイズが小さければダウンロードも速いですしいくつかの利点が得られるため、こういった圧縮ツールを使っている人も多いかと思います。ではコンテナイメージ内に置くような実行ファイルでも圧縮するべきかどうか、というのが今回のブログのネタです。
コンテナイメージのサイズは小さい方が良い、というのは聞いたことがある人が多いと思うので当然圧縮するべきだろうと思うかもしれませんが、コンテナイメージはレイヤー単位でgzipで圧縮されDocker Hubなどのコンテナレジストリに格納されます。単にイメージのサイズだけで考えるのではなく実際にpullする時にどう影響が出るのかについて考えるほうが良さそうであるというのは以前雑に述べていたりしました。
自分もデバッグ大変になるの嫌なのであまりまとめないことが多いです。ただイメージサイズ変わらなくてもpullする時はlayer単位でtar.gzになるのでまとめると圧縮が効いてかなり小さくなるとかはありますかね。一方で並列度が上がる利点もありますが。https://t.co/KqEVsDwxDJ
— イスラエルいくべぇ (@knqyf263) January 21, 2021
というような雑談をSlackでしていたところ、フューチャー社員の渋川さん( @shibu_jp)からupxでサイズ小さくすることにどのぐらいの意味があるのか? という疑問が出ました。
ちなみに渋川さんはフューチャー技術ブログで数多くの素晴らしいブログを執筆されています。
確かにレイヤーをgzipで圧縮したらupxによる圧縮と差はそこまで大きくならないんじゃないか、という気がしたので検証してみました。upxの方は圧縮したあとに再度gzipで圧縮することになるので二度目のgzip圧縮率はかなり低いと考えられます。渋川さんから出た疑問だったのでフューチャーのブログに載せようということで今回寄稿することになりました。
検証(macho)
upxは様々なOSの実行ファイル形式に対応していますが、自分はmacOSを使っているためまずmachoで試してみます。
upxで圧縮しない場合
まずは普通にupxせずにDockerイメージを作ってみます。
バイナリのビルド
TrivyをmacOS上でビルドしてみます。
$ go build -o trivy cmd/trivy/main.go |
バイナリのサイズは41MBでした。今回は ldflags '-w -s'
などのオプションは付けていないためDWARFやシンボルテーブルは残っています。
イメージのbuild/push
レジストリに置いたらどのぐらいのサイズなのか? というのは実際にpushしてしまうほうが早いと思うので、buildしてレジストリにpushします。まずビルドします。
$ docker build -t knqyf263/trivy:macho-nonupx . |
Step 3を見てもらえば分かりますが、単にホスト側にあるバイナリをCOPYで置いています。machoのバイナリをコピーしているせいで動かないのですがサイズの検証なので気にせず進めていきます。
そしてこのイメージをpushします。
$ docker push knqyf263/trivy:macho-nonupx |
無事にpushできました。
レイヤーサイズの確認
ではレジストリにあるレイヤーのサイズを確認してみます。craneというツールを使います。Googleのgo-containerregistryというライブラリに付随しているCLIツールになります。
https://github.com/google/go-containerregistry/tree/main/cmd/crane
$ crane manifest knqyf263/trivy:macho-nonupx |
下から2番目のレイヤーがバイナリを置いたレイヤーなので18.03MB( =18900731/(1024*1024)
)になっています。
Docker Hubで確認
念のためDocker HubのUIでも確認します。
確かに18.03MBになっています。一応リンクも貼っておきます。
サイズ比較
ということで上記の結果をまとめると以下のようになります。
- バイナリのサイズ:41MB
- レイヤーのサイズ:18.03MB
バイナリのサイズは41MBもあったのにレイヤーに置いてgzipすると18MBまで減っています。
upxで圧縮する場合
先程ビルドしたバイナリがあるのでこれをupxで圧縮します。
$ upx ./trivy |
upxは圧縮レベルが1から10まであり、512 KiB以下だと8が使われて大きいサイズだと7が使われるようです。10も試してみたのですが(厳密には --best
)、今回の検証ではあまりサイズが変わらなかったのでとりあえず7の結果について書いています。
上のupxの出力で19MBぐらいになってサイズが44.85%になったと書いてあります。念のため確認しておきます。
$ du -h trivy |
やはり19MBです。
イメージのbuild/push
ここは先程と同様にイメージのbuild/pushをします。
$ docker build -t knqyf263/trivy:macho-upx . |
レイヤーサイズの確認
先程同様に確認します。
$ ./crane manifest knqyf263/trivy:macho-upx |
今回は17.85MBになっています。
Docker Hubで確認
やはりサイズは17.85MBです。
サイズ比較
上記の結果をまとめると以下になります。
- バイナリサイズ: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 |
machoの時と同じく41MBでした。
Docker Hubで確認
machoの時と同じくイメージをbuild/pushし、今回は直接Docker Hubで確認してみます。
18.25MBでした。
サイズ比較
- バイナリのサイズ:41MB
- レイヤーのサイズ:18.25MB
upxで圧縮する場合
バイナリのビルド
upxで圧縮します。
$ upx ./trivy |
20MBになりました。
Docker Hubで確認
18.64MBでした。
サイズ比較
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, |
他にもイメージビルド時に圧縮の時間もかかるためCI/CDの時間は増加します。 --best
をつけるとTrivyの41MBのELF実行ファイルをupxで圧縮するのに5分かかりました。イメージビルドの時間を5分増加させて0.1MBの削減だった場合にそれが見合っているのかどうかは考える必要があると思います。5分増加させた挙げ句にサイズも増えていると最悪です。
そもそもupxの導入にかかるコストもあります。Dockerfileを修正したりCI/CDの設定をしたりが必要になってきます。
今回のブログでは必ずupxするべきだ、絶対にupxするべきではない、ということは言っていません。ただ盲目的にupxで圧縮しておけば良いわけではないことを知ってもらえればと思い書いています。もし本当にイメージサイズを削減したいならきちんと検証して比較することをおすすめします。