molecular coordinates

/var/log/coord_e.log

magicpak: 静的リンクなしで小さなDockerイメージを作る

おはようございます!!!coord_eです、よろしくどうぞ。

実行に必要なファイルだけをうまく集めれば、静的リンクせずとも小さなDockerイメージを作ることができます。本記事では、その作業を自動で行ってくれるツール magicpak を作ったので、紹介します。

Dockerイメージ縮小オタク2(ツー)

coordination.hatenablog.com

☝️前編です。実行に必要なファイルだけをうまく集めれば別に静的リンクしないでも小さなDockerイメージが作れるよねって話です。

本記事では、上の記事でやったこと(実行に必要なファイルを集める)を自動でやってくれるツール magicpak を紹介します*1

magicpak: Build minimal docker images without static linking

github.com

magicpakは、対象の実行可能ファイルの実行時の依存ファイルを解析して集めるツールです。これを使い、静的リンクなしで小さなDockerイメージを作ることができます。

加えて、集まった依存ファイルが対象の実行可能ファイルを動かすのに十分かどうかテストする機能、また対象の実行可能ファイルをupxで圧縮する機能が付いています。これらは便利機能であり、便利です*2

静的リンクがどうしてもできなくて*3前記事のようなことをしなければならなくなった時に重宝すると思います。また、実行可能ファイルをそのまま(ビルドし直したりすることなく)Dockerイメージにできるのも強みで、ビルドが単純に面倒だったりソースが公開されていなかったりする時に力を発揮するのではないでしょうか。

詳しい使い方についてはREADMEを参照してください。

magicpakを使い、brittanyというHaskellコードフォーマッタのDockerfileを以下のように書くことができます。このDockerfileから作られるイメージのサイズはたった15.6MBです。

FROM magicpak/haskell:8

RUN cabal new-update
RUN cabal new-install brittany

RUN magicpak $(which brittany) /bundle -v  \
      --dynamic                            \
      --dynamic-stdin "a = 1"              \
      --compress                           \
      --upx-arg -9                         \
      --upx-arg --brute                    \
      --test                               \
      --test-stdin "a= 1"                  \
      --test-stdout "a = 1"                \
      --install-to /bin/

FROM scratch
COPY --from=0 /bundle /.

CMD ["/bin/brittany"]

この他にもいくつかの例が example/ 以下にあります。

しくみ

magicpakは、以下の二つの方法で依存ファイルを解析しています:

  • 静的解析. ELFを解析して動的リンクされたライブラリを集めます。
  • 動的解析. その他の依存ファイルを、システムコールのトレースによって集めます。

ここではこれらの解析とテスト機能の実装について簡単に説明します。

静的解析: 動的リンクの依存ライブラリの解析

前記事ではldd(1)を使って依存ライブラリを一覧していました。もちろん magicpak でもldd(1) の結果をパースすることはできますが、一方でこの出力はいわばログのようなものです。ldd(1) の manpage に出力の例は載っていますが、正確な文法は記載されていません。さらに、環境によってはldd(1)は不正確な結果を表示することのある簡単なシェルスクリプトの実装になっていることがあります*4。こういった理由からmagicpakではldd(1)の出力をパースせずに、documented な方法のみを用いて解析を行うことにしました*5

さて、動的リンクされる依存ライブラリの名前はELFにそのまま格納されています。readelf(1)で見てみましょう。

$ readelf -d /bin/bash
Dynamic section at offset 0xd8450 contains 25 entries:
Tag                Type     Name/Value
0x0000000000000001 (NEEDED) Shared library: [libreadline.so.8]
0x0000000000000001 (NEEDED) Shared library: [libdl.so.2]
0x0000000000000001 (NEEDED) Shared library: [libc.so.6]
... (省略)...

libreadline.so.8libdl.so.2 など、依存ライブラリの名前が確かに埋め込まれています。しかしここで必要なのは、これらライブラリとしてロードされる実際のファイルのパスです。依存ライブラリ名から実際のパスを見つけ出す必要があります。

GNU/Linuxで(依存ライブラリの探索を含めて)動的リンクを担っているのはld.so(8)です。実はld.so(8)のmanpageに依存ライブラリの探索方法は書いてあります。そのためそれに従って探索をしていけばいいようにも思えるのですが、現実は非情、探索方法がドキュメント化されているといってもいくつかの部分が実装依存です*6。そのため、実際に探索をさせてみるまでどのライブラリが使われるか予測できないことが多いです。

magicpakでは対象の実行可能ファイルが用いるld.so(8)に実際に探索を行わせつつ、様々な事情から一部は自前で探索を行うことでなるべく現実の挙動と同じになるように依存ライブラリを探索しています。依存ライブラリの探索について筑波大学情報科学類誌WORD 入学祝い号2020 の『動的リンクの依存関係を解析しよう』という記事に書いたので、詳しくはそちらを参照してください。

動的解析: システムコールトレーサによる解析

さて、実行可能ファイルが実行時に必要とするファイルは動的リンクされるライブラリだけではありません。例えば辞書データや画像などの*7リソースを実行可能ファイルと別に配置し、実行時に読み込んで使用するといったことは往々にしてあると思います。そういった依存ファイルを見つけ出すために、magicpakは動的解析を行います。

プロセスとOSとのやりとりはシステムコールを用いて行われます。ファイルを開く操作もシステムコールを介して行われるため、実行の過程でシステムコール呼び出しがどのように行われているかを解析できれば依存ファイルを調べることができそうです。そして、システムコール呼び出しをトレースすることができるのがptrace(2)です。

ptrace(2) を用いた便利なCLIとして strace(1) があります。これは引数のコマンドを実行し、そのプロセスのシステムコール呼び出しとその引数、戻り値を標準出力に出力してくれるツールです。strace(1)を用いてシステムコール呼び出しの様子を見てみます:

$ strace bash -c true 2>&1 | grep open
openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
openat(AT_FDCWD, "/usr/lib/libreadline.so.8", O_RDONLY|O_CLOEXEC) = 3
openat(AT_FDCWD, "/usr/lib/libdl.so.2", O_RDONLY|O_CLOEXEC) = 3
openat(AT_FDCWD, "/usr/lib/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
openat(AT_FDCWD, "/usr/lib/libncursesw.so.6", O_RDONLY|O_CLOEXEC) = 3
... (省略)...

このように、ptrace(2)を使うとプロセスの呼び出したシステムコールとその引数を追いかけることができます。すなわち、ptrace(2)でopen系のシステムコールの呼び出しを記録していけばプロセスが開いたファイルを一覧することができます。 magicpakでは対象の実行可能ファイルを実際に実行し、システムコール呼び出しをptrace(2)でトレースしています。そしてopen(2)やopenat(2)で開いているパスが存在した時、対象の実行可能ファイルはそのファイルに依存するとみなして記録していきます。ptrace(2)を用いた解析アルゴリズムについても簡単に筑波大学情報科学類誌WORD 入学祝い号2020 の『動的リンクの依存関係を解析しよう』という記事に書きました。詳しくはそちらや、magicpakでの実装を参照してください。

テスト

対象の実行可能ファイルが実行時に必要とするファイルをmagicpakが必ず集めることができるとは限りません。なぜなら、プログラムの実行時の挙動を実行前に完璧に把握することはできないためです。

そのような状況で、生成されたイメージについてある程度安心するためにmagicpakはテスト機能を備えています。これは生成された依存ファイルと対象の実行可能ファイルだけを置いた時に(=生成されるDockerイメージと同じ状況)、正しくテストコマンドが実行できるのか確認するものです。テスト機能の実装にはchroot(2)を使っています*8

おわりに

今回は少し設計に気を使って、(気負わない程度に)クリーンアーキテクチャを意識してコードを書いてみたんですがどうなんでしょう。そのおかげか開発体験は良かったです。それから、Rustをやってるとオブジェクトが今どこにいてどう動くのかコード上で表現できていいですね...フフ...

*1:紹介しますというか作ったから見て〜

*2:こういうのを一つのコマンドでやるのUNIX哲学に反していて/関心の分離がうまくできてない感があって/DLCが活用できなさそうで どうなんですかという指摘があると思います。すみません

*3:C++とかHaskellを書いていてシングルバイナリ吐けね〜経験が結構あり、そういう経緯があってこういう拗らせオタクをやっているわけですね。

*4:うちはそうだった

*5:結局さらに不正確な感じになってそうだけど、実際どうとかより自分のオタク性を満足させる方が重要

*6:実装依存って書いてあるわけではなく、現状として実装によって挙動が違う

*7:

*8:chrootに権限が必要なのでユニットテストなどがしんどい。助けてください