下まで全部書いたコンテナ基盤 — 自作言語で colima を置き換えるのに要ったもの

Hypervisor.framework の上の VMM、Docker Engine API を話すデーモン、OCI ランタイム、レジストリ、tar と gzip —— 自作の小さな ML 系言語で、本物の docker CLI が一行も変えずに繋がるところまで書いた記録。それが公開仕様のどこに当たるのか、何を意図的にやらないのか、そして「本物を相手にして初めて出た」欠陥たち。

merecontainersdockerocivirtualizationsystemsdogfood

Mac の docker は、手元にないデーモンと話すクライアントだ。誰かが Linux カーネルを 走らせていなければならない —— Docker Desktop か、Colima か、その下の Lima が。 私はその列を丸ごと、自作の ML 系言語 Mere で書いた programs に置き換えた。 仮想マシンモニタ、エンジン、ランタイム、レジストリ、書庫と圧縮の形式まで。

完了の基準は最初に決めて、一度も動かさなかった。本物の dockerdocker compose が、 一行も変えずにクライアントであること。 自作のクライアントではなく。互換の皮でもなく。 Docker が配っているバイナリが、ソケットを指して、期待通りの答えを受け取ること。

いま、それができている。docker compose up は VM の中で 2 サービスのアプリを立ち上げ、 サービス同士は名前で見つけ合い、公開ポートは macOS から答え、docker exec -i は シェルにスクリプトを流し込み、docker push は private registry に画像を送って、 本物の docker がそれを pull して実行する

「docker を自作した」は、両方向に違う

過大だ。docker は CLI であり BuildKit であり containerd であり Desktop でありエコシステムだ。 CLI は書いていない —— そして書かないことが肝だ。クライアントがオラクルなのだから。 自作のクライアントで確かめられるのは「自分のプロトコルの読み方と書き方が一致している」ことだけになる。

過小でもある。docker は VMM を含まない。macOS では別の何かが Linux 機械を用意していて、 それもここにある。全部を書いている言語自体も。

刺さる一文はこれだ。colima を置き換えるつもりで、colima の下にあるものを全部書いた。

層を、公開仕様で並べる

このプロジェクトで面白いのは「動くこと」ではない。ほぼ全部の層が公開仕様で、 列全体を通してベンダ API はちょうど 1 つしかない、ということだ。

作ったもの 公開仕様 範囲と、意図的に無いもの
クライアント (書いていない) 本物の docker / docker compose が客でありオラクル
エンジン API mengd Docker Engine API(v1.54 を名乗る、min 1.40)、HTTP/1.1(chunked と Connection: Upgrade のハイジャック) images / containers / exec / networks / volumes / build / events / auth。BuildKit は無い(classic builder のみ)
画像 mengd + mtar OCI Image Spec v1.1.1vnd.oci.image.{index,config,layer})と Docker manifest v2、whiteout 規約(.wh. / .wh..wh..opq manifest・config・レイヤ・whiteout を往復。manifest の型はレイヤの実体に従う
レジストリ mreg(サーバ)+ mengd(クライアント) OCI Distribution Spec v1.1.1、Bearer トークン認証、Basic(RFC 7617)、TLS pull も push も。blob upload は2 段の方
ランタイム mrun OCI Runtime Specconfig.json namespaces(path で既存のものに入るのを含む)・mounts・pivot_root・process/env/cwd・capabilities・masked/readonly paths・cgroupsPath と resources。hooks / seccomp / LSM / uid mapping は無い
OS 機能 Linux: cgroup v2、overlayfs、namespaces、rtnetlink、bridge ioctl、RFC 4193 の ULA 利用であって実装ではない。ここを自作と言えば嘘になる
デバイス mvm VIRTIO v1.3(mmio、VIRTIO_F_VERSION_1 のみ、blk 1 キュー・vsock 3 キュー) indirect descriptor は提供しない、vsock SEQPACKET も。提供しなければドライバは要求できない
ブート mvm + mkdtb Booting AArch64 LinuxDevicetree Specification(生成する、コンパイルしない)、ARM PSCI GIC・PL011・PL031、そして機械を終わらせる 2 つの PSCI 呼び出し
CPU mvm Apple Hypervisor.framework —— 列で唯一のベンダ API
形式 mtar / mgz POSIX ustar(IEEE 1003.1)、RFC 1951 DEFLATE と RFC 1952 gzip tar は読み書き両方。PAX・GNU long name・base-256 サイズは名指しで拒否。gzip も両方向 —— 圧縮器も自分で書いた

標準でないのは 2 つだけ。Apple の Hypervisor.framework と、Docker Engine API。 後者は Docker のもので、公開され版管理されている —— そして位置に注目してほしい。 私はそのクライアントを実装していない。本物に答えているのだ。

中身

部品 対応物 Mere C shim 検査
mvm Lima/Desktop の VM 部分 1,945 909 1,513 行・10 本
mengd dockerd 4,933 1,502 1,256 行・5 本
mrun runc 767 390 runc オラクル
mreg distribution 634 138
mtar / mgz tar / gzip 1,739 254 962 行・11 本

C は FFI 境界が要求する分だけだ —— socket、netlink、ioctl、setnswaitpid、OpenSSL。 アルゴリズムは全部 Mere にある。inflate も deflate も tar も HTTP も JSON も、 virtio のリングも vsock の状態機械も。

形を決めた 4 つの判断

オラクルは常に他人のプログラム。 mrunruncフィールド単位で比べる —— 同じ bundle を両方に渡し、5 ケース 100 超のフィールドを 1 つずつ、合否ではなく差分で報告する。 レイヤの書き出しはカーネル自身に答え合わせさせる —— 本物の overlay を mount して段を走らせ、 merged == apply(lower) then apply-as-layer(upper) を要求する。 push は本物の docker が pull して実行することで確かめる。 どれも「自分のコードが正しいか」を自分のコードに訊いていない。

レイヤとは段が作った差分で、差分は私には計算できない —— カーネルにはできる。 ビルドの段は overlay mount の上で走り、upper ディレクトリがそのままレイヤだ。 ビルドキャッシュのために保存形式を発明する必要もなかった —— 前に走った段とは「upper が既にそこにある段」のこと。 誰も使っていない名前で、既にディスク上にあった。

namespace はプロセスより先に作る。 コンテナに網を与える道は 2 つある。 ランタイムに namespace を作らせて後から配線するか、先に作って渡すか。 前者はコンテナが負けうる競合で、しかもたいてい勝つ —— それが悪い。 稀な失敗は網のせいにされるからだ。OCI 仕様は namespace を path で指名できる(CNI と同じ形)ので、 何かが覗く前に配線が終わっている状態にできる。

NAT は「無い」のではなく「意味が成立しない」。 ゲストには網のインタフェースが 1 枚も無い —— VMM が見せるのは block と vsock だけで、出入りは全部後者を通る。 NAT とは上流への変換のことで、上流が無い。 それを、変えるための 3 つの道とそれぞれの代償ごと書き残す方が、 名前だけ同じ半端な実装より役に立つ。

数字

mvm + mengd Colima
docker run --rm alpine echo 418 / 432 ms 428 ms
docker ps 90 ms 105 ms
ゲスト起動 → デーモン待受 1.2 秒
vCPU 1 6

1 vCPU で、普通の操作は同等だ。ほかにも: 1 段 1 レイヤで最後の段のレイヤは 8,940,544 バイトから 2,048 になり、圧縮して 116 になった。 ビルドキャッシュは 2 段のビルドを 4 秒から 0 秒にした。 自作 gzip は実際の画像レイヤで system gzip より 0.6% 大きいだけで、1.3 秒 —— 最初の版は 369 秒かかっていた。

本物を相手にして初めて出た欠陥

どれも、その時点のすべての検査を通っていた。

何も圧縮しない圧縮器。 mgz には検査が 3 本あって、全部が「出力は正しいか」を訊いていた —— gunzip が受け取る、CRC が合う、バイトが戻る。stored ブロックを吐くだけの圧縮器は 3 本とも通る。 実際の画像レイヤを当てたら、10,000 バイトが 10,023 バイトになった —— 入力より大きい。 符号長の 1 つが 8 ビットを要求して、ブロックごと stored に落ちていた。 足りなかった検査は gzip とサイズを比べるものだった。

キーは部分文字列ではない。 docker compose build はクエリに t=target= の両方を送る。 私のパラメータ探索は target= の中の t= に一致した。画像はタグ無しで作られ、 “Successfully built” は出て “Successfully tagged” は出ず、compose は今ビルドした画像に 「No such image」と言った。ビルド自体は何一つ壊れていない。

満杯で死んで見えるデーモン。 Mere の spawn は handle を返し、捨てると joinable なスレッドが残る —— runtime のソースの、detach が定義されているその場所にそう書いてある。 私は接続ごとに捨てていた。そこにクライアントが去った後も 5 分間ポーリングする /wait が加わり、 コンテナ 30 個あたりで runtime がスレッドを作れなくなり、デーモンは何も受け付けなくなった —— compose は無出力、trace には要求が 1 件も無い。40 個の起動は 304 秒から 14 秒になった。

親を殺すと子は孤児になる。 ランタイムが親で、コンテナの init はその子だ。 docker stop はランタイムに signal し、docker rm -f はディレクトリを消していた —— どちらもコンテナのプロセスを、誰も知らないまま走らせ続けた。削除のたびに 1 個ずつ、 機械がコンテナより多くのランタイムを持つまで。

store への書き込みは raise してはいけない。 Mere では raise はプロセスの終わりだ。 他の要求が消している最中のディレクトリへの write_file は、書き込みでなくデーモンを終わらせる。 削除の途中で、最後の言葉としてパスを 1 行だけ残して死んだ —— 2 回、2 回目は file_exists のガードを競合がまたいで。

そして一度、壊れていたのはクライアントだった。 compose up --build が通らなくなり、 デーモンは無実だった。この macOS の Docker CLI では DOCKER_BUILDKIT=0 docker build本物の docker 相手でも永久に止まる。ハングは「デーモンが答えなくなった」と同じ見た目だ。 そうでないと示したのは、誰も疑わないデーモンに同じコマンドを向けたことだった。 ゲートはいま、先にその問いを期限付きで訊き、名指しで SKIP する —— 「デーモンが壊れた」と「クライアントが壊れた」を区別できない検査はハングし、ハングは何も言わない

やらないこと

仕様の語彙で言う。名指しできる境界は設計判断で、名指しできない境界は穴だからだ。

  • 外向き NAT —— 変換する先の上流インタフェースが存在しない
  • OCI の hooks / seccomp / LSM ラベル / uid mapping —— bundle から読んでいない
  • VIRTIO の indirect descriptor、vsock SEQPACKET —— 提供しない
  • PAX・GNU long name・base-256 サイズ —— 推測せず名指しで拒否
  • BuildKit・swarm・plugin・TTY —— docker exec -t はコンテナの中の pty を要求する別の機構

なぜこれをやるのか

dogfood だからだ。問いは「自分の Docker が欲しいか」では一度もなく、 設計中の言語が実システムを支えられるかだった。 いちばん価値のある成果はこのスタックではない。これが言語側に見つけた欠陥の一覧だ —— region の回収は字句的で、呼び出しを region で包んでも呼ばれた側が確保した物は戻らないこと。 C backend の lambda lifter が自由変数を名前で解決するので、 取り込む側のプログラムが同じ名前を束縛していると内側のクロージャが壊れること。 捨てたスレッド handle が漏れること。プログラムが持つアリーナの「ポインタ」はオフセットで、 それをアドレスと取り違えた shim はどこか別の場所から書いて成功と報告すること。

どれもテストスイートでは出なかった。他人のクライアントに、他人のプログラムを審判にして、 本物のことを要求されたから出たのだ。

← Back to Notes