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

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

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

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

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

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

magicpak: Build minimal Docker images without static linking

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は、以下の 2 つの方法で依存ファイルを解析しています:

静的解析
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. こういうのを 1 つのコマンドでやるの UNIX 哲学に反していて/関心の分離がうまくできてない感があって/DLC が活用できなさそうで どうなんですかという指摘があると思います。すみません↩︎

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

  4. うちはそうだった↩︎

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

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

  7. ↩︎

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