distrolessコンテナイメージ使おうとして依存が足りないときはどうすればいいですか?あ、OCamlなんですけど。 - エムスリーテックブログ

エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

distrolessコンテナイメージ使おうとして依存が足りないときはどうすればいいですか?あ、OCamlなんですけど。

この記事は基盤開発チームブログリレー4日目の記事です。

こんにちは、エンジニアリンググループ基盤チームリーダー兼General Managerの横本(@yokomotod)です。

ありのまま今起こった事を話すぜ…おれは基盤チームブログリレーを走ると思っていたらいつのまにかOCamlブログリレーだった… 何を言ってるのかわからねーと思うがおれも何をされたのかわからなかった…頭がモナドになりそうだった…

www.m3tech.blog

www.m3tech.blog

www.m3tech.blog

3日間、Web・CLI・機械学習と OCaml で攻め続ける先人達により、OCaml デビューする以外の道は閉ざされました。「じゃーまずはデプロイの仕方から学ぶかー。distroless で動くのかな?」ということで本記事が生まれました。

以前 distrolessコンテナイメージの中を覗いて「なんか軽くてセキュアらしい」より理解を深める という記事を書きました。公式には提供されていない OCaml の場合、どうするんでしょうか。

これはつまり、distroless × Python などで「distroless で依存不足で困った」という状況と同じようなものです。OCamlには馴染みのない方も「たまたまOCamlが題材なだけ」と思って安心してお読みください。*1

まずは hello world を distroless に載せてみよう

何はともあれ、OCaml の hello world をビルドして distroless に載せてみます。

(* bin/hello.ml *)
let () = print_endline "Hello, World!"

ビルドは ocaml/opam:debian-13-ocaml-5.4 で行い、出来上がったバイナリを各 runtime イメージにコピーする multi-stage Dockerfile を書きます。

FROM ocaml/opam:debian-13-ocaml-5.4 AS build
WORKDIR /home/opam/app
RUN opam update
COPY --chown=opam dune-project ocaml_distroless.opam ./
RUN opam install . --deps-only --yes
COPY --chown=opam bin/ ./bin/
COPY --chown=opam lib/ ./lib/
RUN opam exec -- dune build --release ./bin/hello.exe

# 調査用: file / ldd を使えるようにしておく
RUN sudo apt-get update && sudo apt-get install --yes --no-install-recommends \
    binutils file \
 && sudo rm -rf /var/lib/apt/lists/*

# ここから各種runtime

FROM scratch AS runtime-scratch
COPY --from=build /home/opam/app/_build/default/bin/hello.exe /hello
ENTRYPOINT ["/hello"]

FROM gcr.io/distroless/static-debian13:nonroot AS runtime-static
COPY --from=build /home/opam/app/_build/default/bin/hello.exe /hello
ENTRYPOINT ["/hello"]

FROM gcr.io/distroless/base-debian13:nonroot AS runtime-base
COPY --from=build /home/opam/app/_build/default/bin/hello.exe /hello
ENTRYPOINT ["/hello"]

各ターゲットをビルドして実行してみます。

$ docker run --rm ocaml-distroless-hello:scratch
exec /hello: no such file or directory

$ docker run --rm ocaml-distroless-hello:static
exec /hello: no such file or directory

$ docker run --rm ocaml-distroless-hello:base
Hello, World!

scratchdistroless/static ではエラー。distroless/base で動きました。ほーん。

なぜ scratch で動かないのか?

distroless イメージにはシェルもコマンドもないので、バイナリの調査にも builder を使えるように、Dockerfile で filebinutils を入れておいて使うことにします。

$ docker run --rm ocaml-distroless-hello:build file _build/default/bin/hello.exe

ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV),
dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, ...

dynamically linked 。Go の CGO_ENABLED=0 ビルドが statically linked になるのとは違い、OCaml のデフォルトネイティブビルドは動的リンクされたバイナリを生成するんですね。

ldd で依存を確認すると:

$ docker run --rm ocaml-distroless-hello:build ldd _build/default/bin/hello.exe

linux-vdso.so.1
libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6
/lib64/ld-linux-x86-64.so.2

libclibm、そして動的リンカ(ld-linux-x86-64.so.2)に依存しています。distroless の公式 README*2にも「Statically compiled applications (Go) that do not require libc can use the gcr.io/distroless/static image」とある通り、distroless/static は glibc を含みません。distroless/basestatic の内容に加えて glibc を含むので、納得の結果です*3

OCaml の native build は、OCaml ランタイム(GC 等)はバイナリに埋め込まれますが、C ランタイム(libc, libm)には動的リンクで依存して、Go の CGO_ENABLED=0 のように「何にも依存しない単体バイナリ」にはなってないことがわかります。

ひとまず distroless/base で hello world は動くことがわかりました。では、もっと実用的なアプリケーションではどうなるでしょうか。

HTTP サーバを載せてみる — cohttp-eio

リレー1日目の田尻さんの記事で、Eio + cohttp が「実用に十分」と紹介されていました。HTTP サーバにすると依存はどう変わるのか、簡単なサーバを書いて確認してみます。

(* bin/cohttp_server.ml *)
let () =
  Eio_main.run @@ fun env ->
  Eio.Switch.run @@ fun sw ->
  let socket =
    Eio.Net.listen env#net ~sw ~backlog:128 ~reuse_addr:true
      (`Tcp (Eio.Net.Ipaddr.V4.any, 8080))
  in
  let handler _socket _request _body =
    Cohttp_eio.Server.respond_string ~status:`OK ~body:"Hello from Cohttp!" ()
  in
  let server = Cohttp_eio.Server.make ~callback:handler () in
  Cohttp_eio.Server.run socket server ~on_error:raise

ldd で確認してみると、hello world と同じでした。HTTP サーバなのに依存が増えていません。cohttp-eio は HTTP パーサも非同期ランタイム(Eio)も OCaml で書かれているので、C ライブラリを引き込まないんですね。

なので distroless/base に載せてみると:

$ docker run --rm -d -p 8080:8080 ocaml-distroless-cohttp:base

$ curl http://localhost:8080/
Hello from Cohttp!

distrolessでHTTPサーバが動きました!pure OCaml のスタックであれば、HTTP サーバでも distroless/base のまま追加の対処なしで動きます。

さて、cohttp-eio は依存が増えませんでしたが、Web フレームワーク、Dream で試してみます。

Dream を載せてみる

(* bin/dream_server.ml *)
let () =
  Dream.run ~interface:"0.0.0.0" ~port:8080
  @@ Dream.router
       [
         Dream.get "/" (fun _ -> Dream.html "Hello from Dream!");
         Dream.get "/health" (fun _ -> Dream.json {|{"status":"ok"}|});
       ]

ldd すると

$ docker run --rm ocaml-distroless-dream:build ldd _build/default/bin/dream_server.exe
linux-vdso.so.1
libssl.so.3 => /lib/x86_64-linux-gnu/libssl.so.3
libcrypto.so.3 => /lib/x86_64-linux-gnu/libcrypto.so.3
libev.so.4 => /lib/x86_64-linux-gnu/libev.so.4
libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6
/lib64/ld-linux-x86-64.so.2
libz.so.1 => /lib/x86_64-linux-gnu/libz.so.1
libzstd.so.1 => /lib/x86_64-linux-gnu/libzstd.so.1

一気に増えました。libssllibcrypto(OpenSSL)、libev(イベントループ)、libzlibzstd(圧縮)。Dream は内部で Lwt(非同期ランタイム、libev に依存)と OpenSSL を使っているようです。これは distroless/base には入っていなさそう… 載せてみると

$ docker run --rm ocaml-distroless-dream:base

/dream: error while loading shared libraries: libev.so.4:
cannot open shared object file: No such file or directory

やはりエラーになります。distroless/base には glibc は入っていますが、libevlibssl のようなアプリケーション固有のライブラリまでは含まれていないわけです。

distroless で .so が足りないとき

いよいよ「distroless に載らない」壁にぶつかりました。これは OCaml 固有の問題ではなく、Python や Node.js で distroless を使っていても、ネイティブ拡張が特定の .so に依存していれば同じことが起きます*4

対処法はシンプルです。ldd で何が足りないかは既にわかっています。builder イメージにはビルド時に使った .so が全部残っているので、足りない分を COPY --from=build で持ってくればいい。ldd は依存を再帰的に解決するので、出力を網羅すれば漏れはありません。(フラグ)

FROM gcr.io/distroless/base-debian13:nonroot AS runtime-base
COPY --from=build /lib/x86_64-linux-gnu/libssl.so.3 /lib/x86_64-linux-gnu/
COPY --from=build /lib/x86_64-linux-gnu/libcrypto.so.3 /lib/x86_64-linux-gnu/
COPY --from=build /lib/x86_64-linux-gnu/libev.so.4 /lib/x86_64-linux-gnu/
COPY --from=build /lib/x86_64-linux-gnu/libz.so.1 /lib/x86_64-linux-gnu/
COPY --from=build /lib/x86_64-linux-gnu/libzstd.so.1 /lib/x86_64-linux-gnu/
COPY --from=build /home/opam/app/_build/default/bin/dream_server.exe /dream
ENTRYPOINT ["/dream"]
$ docker run --rm -d -p 8080:8080 ocaml-distroless-dream:base

$ curl http://localhost:8080/
Hello from Dream!

ほらやっぱり。 ldd で特定して COPY する — distroless で .so が足りないときの対処法はこれだけです。(フラグ)

同じフレームワークでも、cohttp-eio(OCaml で書かれた HTTP パーサ + Eio)は追加の .so が不要、Dream(OpenSSL + Lwt/libev)は .so のコピーが必要、と結果が分かれました。使うライブラリが C に依存するかどうかで、distroless での扱いが変わるわけです。

というわけで、タイトルの疑問「distroless で依存が足りないときはどうすりゃいいんです?」への(まず最初の)答えはldd などで不足を特定して、builder から COPY する」でした。ldd のほかにも opam tree dream などでも依存が確認できるようです。 COPY 元は必ずしもbuilderである必要はありませんが、ランタイムと互換性を保つため同じDebianなどから取得します。

ここまでがOCamlを題材とした依存の状況や不足時の対応の第一歩でした。 ここから先は、これが通用しないケースや、さらに高みを目指した構成への挑戦です。興味があればお付き合いください。

チャレンジ1: LightGBM — ldd、信じていたのに

lddCOPY は本当にいつもうまくいくのでしょうか。ちょうど河合さんがリレー3日目で OCaml から LightGBM を C FFI で呼び出していました。OCaml で機械学習して、それを distroless に載せる — Python のMLバッチでもdistrolessに載せるのは一般的とは言えないと思いますが、試してみましょう。ldd から。

$ docker run --rm ocaml-distroless-lgbm:build ldd _build/default/bin/lightgbm_predict.exe
linux-vdso.so.1
libffi.so.8 => /lib/x86_64-linux-gnu/libffi.so.8
libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6
/lib64/ld-linux-x86-64.so.2

libffi が1つ増えただけ。Dream の5つに比べるとずっと少ないですが distroless/base + libffi のコピーでいけるかというと

$ docker run --rm ocaml-distroless-lgbm:base-ldd-only

Fatal error: exception Dl.DL_error("lib_lightgbm.so: cannot open shared object file: No such file or directory")

Fatal error。lib_lightgbm.so が見つからないと言われます。 ldd には出ていなかったのに…。

ldd の限界 — dlopen

コードを見ると、lightgbm-ocaml は ctypes-foreign を使っていて、Dl.dlopen ~filename:"lib_lightgbm.so" で LightGBM のライブラリを実行時にロードしています。

OCaml バイナリ
  ├── ldd で見える: libffi.so.8, libm, libc
  └── dlopen(ldd に出ない): lib_lightgbm.so
                                  └── ldd で見える: libstdc++.so.6, libgcc_s.so.1, ...

ldd はバイナリの ELF ヘッダに記録された依存を再帰的に解決しますが、dlopen() で実行時にロードされる .so は ELF ヘッダに記録されないので出てきません。世の中そんなに甘くなく、 ldd だけでは依存を網羅できないケースがあるということで、フラグ回収でした。

動かしてみて判明したので、 lib_lightgbm.so も builder からコピーして再挑戦します。

$ docker run --rm ocaml-distroless-lgbm:base

Fatal error: exception Dl.DL_error("libstdc++.so.6: cannot open shared object file: No such file or directory")

今度は libstdc++.so.6。LightGBM は C++ で書かれており C++ ランタイムが必要ですが、 distroless/base には入っていません。

base と cc の違い

ここで distroless/cc の出番です。distroless の公式 README*5の通り、ccbase の内容に加えて libgcc1 とその依存(libstdc++ を含む)を追加したイメージです。こちらを使うと

$ docker run --rm ocaml-distroless-lgbm:cc

[LightGBM] [Info] Number of positive: 3716, number of negative: 3284
[LightGBM] [Info] Total Bins 6132
...
result: 381 / 500

動きました! 学習・予測ともに成功しています。

Dream では distroless/base + 自前 .so コピーでしたが、C++ ライブラリを使う場合はせっかくなら distroless/cc が使えます。

OCaml で LightGBM を回して distroless で運用する MLOps、実現!…と言いたいところですが、依存漏れを潰していくのはやはり辛さがありますね。distroless化でそれに見合う恩恵を得られるかの判断になります。

チャレンジ2: distrolessを超えてscratchへ

Go は CGO_ENABLED=0scratch に載ります。Rust も --target x86_64-unknown-linux-muslscratch に載ります。OCaml が distroless/base 止まりなのは本当に仕方ないのでしょうか。

Dune の設定で -ccopt -static があるようです*6

このオプションを追加してビルドしてみると

(executable
 (name hello_static)
 (ocamlopt_flags (:standard -ccopt -static)))
$ docker run --rm ocaml-distroless-hello-static:build file _build/default/bin/hello_static.exe

ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux),
statically linked, ...

$ docker run --rm ocaml-distroless-hello-static:build ldd _build/default/bin/hello_static.exe

not a dynamic executable

dynamically linked から statically linked に変わりました。全ての依存がバイナリに埋め込まれ、外部の .so への依存なし。lddnot a dynamic executable。これなら…

$ docker run --rm ocaml-distroless-hello-static:scratch

Hello, World!

動きました! OCaml でも scratch に載ります!

…ただ、glibc は名前解決(DNS)に NSS(Name Service Switch)という仕組みを使っており、これが内部で .sodlopen するため、static link したバイナリでは名前解決が壊れるという話を聞きます。ビルド時にもこんな警告が出ていました:

warning: Using 'getaddrinfo' in statically linked applications requires
at runtime the shared libraries from the glibc version used for linking

hello world は動いても、DNS 解決が必要になったら壊れる予感がします。

DNS 解決を試す

Unix.getaddrinfoexample.com を解決するプログラムを glibc static でビルドして、scratch で動かしてみます。

(* bin/hello_dns.ml *)
let () =
  let results = Unix.getaddrinfo "example.com" "443" [] in
  List.iter (fun ai ->
    match ai.Unix.ai_addr with
    | Unix.ADDR_INET (addr, _) ->
      Printf.printf "%s\n" (Unix.string_of_inet_addr addr)
    | _ -> ()
  ) results
$ docker run --rm ocaml-distroless-hello-dns:scratch

example.com:443 -> 93.184.215.14:443
example.com:443 -> [2606:2800:21f:cb07:6820:80da:af6b:8b2c]:443

…なんか動きました。あの警告は一体…?

調べてみると、最近の glibc では nss_files / nss_dns が libc 側へ移され、少なくとも files / dns の範囲では NSS module の dlopen なしに名前解決できるようになっているようです。*7

DNS も動くなら、Dream も static にすれば scratch に載るのでは?とやってみると

$ docker build ... # -ccopt -static で Dream をビルド

error: undefined reference to 'ZSTD_compress'

案の定リンクエラーです。libcrypto.a(OpenSSL の static 版)が ZSTD_compress を要求しています。それをリンクさせるために -lzstd を足してみると今度は inflate が undefined。-lz を足すと libz.a が存在しない。zlib1g-dev をインストールして再ビルドすると…また別のエラー…

dynamic link では .so が自分の依存を自動的に解決しますが、static link ではリンカに全ての .a を手で列挙させられます。これは辛い。

なんとか全部倒して再ビルド。scratch に載せてみます。

$ docker run --rm -d -p 8080:8080 ocaml-distroless-dream-static:scratch

$ curl http://localhost:8080/
Hello from Dream!

う、動いた~~

気合で動かしてみましたが、 CGO_ENABLED=0 の一発で scratch に載るGo との差は歴然ですね。

C関連のビルドに慣れている方からするとここまでの挙動はごく当たり前のことだったかもしれません。これら苦労の原因は C ライブラリへの依存であり、標準ライブラリがすべて pure に実装されているGoではこういう苦労が起きません。言語設計の段階で「単一バイナリでデプロイ」を目標に据えた設計思想の強さを感じます。*8

pure OCaml TLS で HTTPS — scratch vs distroless/static

ではOCaml でも pure OCaml のスタック(cohttp-eio + tls-eio)を選べばこうした問題なしで static ビルドで動かせるんでしょうか?

pure OCaml の TLS 実装(tls-eio)で HTTPS クライアントを作り、static build して scratch に載せてみます。リクエストを受けると https://example.com に HTTPS GET して結果を返すサーバです。

(* bin/cohttp_tls_server.ml — 抜粋 *)
let () =
  Eio_main.run @@ fun env ->
  Mirage_crypto_rng_unix.use_default ();
  let client = Client.make ~https:(Some (https ~authenticator)) env#net in
  Eio.Switch.run @@ fun sw ->
  (* ... listen on port 8080 ... *)
  let handler _socket _request _body =
    let resp, body =
      Client.get ~sw client (Uri.of_string "https://example.com")
    in
    (* fetched bytes を返す *)
  in
  Cohttp_eio.Server.run socket (Cohttp_eio.Server.make ~callback:handler ())
    ~on_error:raise

hello world と同様、ビルドは問題なく通ります。しかし…

$ docker run --rm ocaml-distroless-cohttp-tls-static:scratch

Fatal error: exception Failure("Failed to create system store X509 authenticator: ...")

scratch では動きません。C 依存を避けて pure OCaml にしても、まだ別の壁があります。これは前回の Go × distroless の記事でも見た問題で、static build でバイナリの .so 依存をゼロにしても、バイナリ以外の必要ファイル(CA 証明書)がないので動きません

こういった「シングルバイナリであっても必要なファイル」を持たせたのが distroless/static でした。 distroless/static であれば無事動きます。*9

$ docker run --rm -d -p 8080:8080 ocaml-distroless-cohttp-tls-static:static

$ curl http://localhost:8080/
Fetched 528 bytes from https://example.com

前回の記事でも見た通り、scratchdistroless/static の差は CA 証明書、タイムゾーンデータ、/etc/passwd などのファイル群です。static link されたバイナリでも、HTTPS の通信、タイムゾーンの処理、ユーザー情報の参照といった基本的なことをするなら distroless/static が必要になります。

scratch を目指してみて

OCaml でも glibc static で scratch に載せられることはわかりました。やはり依存のモグラ叩きは発生しますし、lightgbm-ocamlで見たような dlopen を使うアプリはそもそも static 化できません。pure OCamlから遠い場合はまだ distroless/base + .so コピーの方が楽でしょうか。

なお、OCaml には ocaml-option-static(musl ベース)というstatic ビルド方法もあるようです。これがどれくらいのサポート状況なのか興味ありつつ、今回は未検証です。

まとめ

全体を振り返ります。

dynamic build

アプリ 依存の特性 scratch static base cc
hello world pure OCaml
cohttp-eio pure OCaml
Dream C 依存(OpenSSL, libev)
Dream + .so コピー 〃 + ldd → COPY - -
LightGBM + ldd の .so dlopen(ldd に出ない) - -
LightGBM + dlopen 先含む 〃 + C++ runtime - -

glibc static build

アプリ 依存の特性 scratch static base
hello world pure OCaml
DNS 解決 glibc NSS(files/dns built-in)
Dream + .a 追加 C 依存(.a 手動追加)
pure OCaml TLS CA 証明書が必要

対策まとめ

  • distroless で .so が足りないときは、ldd などで不足を特定して配置
  • dlopen で実行時にロードされる .soldd に出ないので注意
  • C++ ライブラリを使う場合は distroless/cc

static link すれば distroless/staticscratch まで攻められますが、C ライブラリに依存しているとリンクエラーとの戦いになります。

Go ほど簡単ではありませんが、OCaml も distroless に載せる道はありました。

何故かOCamlをdistrolessに載せる話になりましたが lddfile やパッケージ管理ツールでバイナリの依存を調べて distroless のイメージに追加する — この考え方は OCaml に限らずどの言語でも同じはずです。お手元の distroless で .so が足りなくて困ったときに、この記事が参考になれば幸いです。

We are hiring!

エムスリーでは、ソフトウェアエンジニアを募集しています。Platform Engineer、SRE を絶賛採用中です。コンテナやインフラの深掘りが好きな方も、ブログリレー全日 OCaml のチームが気になった方も、ぜひお話しましょう!

なおOCamlは必須要件ではありません笑

エンジニア採用ページはこちら

jobs.m3.com

カジュアル面談もお気軽にどうぞ

jobs.m3.com

インターンも常時募集しています

open.talentio.com

*1:我ながらホントかよと思いながらやりましたが、コンパイル型でリンク方法なども制御でき悪くない題材だった気もしてきています

*2:https://github.com/GoogleContainerTools/distroless/blob/main/base/README.md

*3:エラーメッセージの「no such file or directory」は /hello バイナリが見つからないのではなく、ELF interpreter /lib64/ld-linux-x86-64.so.2 が見つからないという意味です

*4:distroless/python3 には libssl、libcrypto、libz、libsqlite3 など Python 標準ライブラリが必要とする .so が同梱されています。ただしそれ以外の .so(例えば画像処理ライブラリの依存など)は含まれないので、やはり同じ問題に直面しえます

*5:https://github.com/GoogleContainerTools/distroless/blob/main/cc/README.md

*6:-ccopt は「C コンパイラ/リンカにオプションを渡す」という OCaml コンパイラのフラグ。ocamlopt は最終的に C のリンカ(ld)を使ってリンクするので、-ccopt -static で ld に -static が渡り、.so ではなく .a(静的ライブラリ)を探すようになります

*7:nss_files: Move into libc https://sourceware.org/pipermail/glibc-cvs/2021q3/073626.html / nss: Directly load nss_dns, without going through dlsym/dlopen https://git.zx2c4.com/glibc/commit/resolv?h=glibc-2.40.9000&id=ee5ed99922ca90bcea4a2f9a48a0c9ae4b534ece

*8:一方で AI コーディングの時代となった今、Go が揃えたような pure 標準ライブラリを、他の言語でも AI が…?と思ったりします

*9:省略しましたが distroless/static は SSL_CERT_FILE 環境変数も設定しており、その設定も必要です。OCaml の ca-certs ライブラリは SSL_CERT_FILE があればそのファイルを直接使い、なければ OS 判定のために uname コマンドを実行しようとして、コマンドが無くて失敗します。