下まで全部書いたコンテナ基盤 — 自作言語で colima を置き換えるのに要ったもの
Hypervisor.framework の上の VMM、Docker Engine API を話すデーモン、OCI ランタイム、レジストリ、tar と gzip —— 自作の小さな ML 系言語で、本物の docker CLI が一行も変えずに繋がるところまで書いた記録。それが公開仕様のどこに当たるのか、何を意図的にやらないのか、そして「本物を相手にして初めて出た」欠陥たち。
Mac の docker は、手元にないデーモンと話すクライアントだ。誰かが Linux カーネルを
走らせていなければならない —— Docker Desktop か、Colima か、その下の Lima が。
私はその列を丸ごと、自作の ML 系言語 Mere で書いた programs に置き換えた。
仮想マシンモニタ、エンジン、ランタイム、レジストリ、書庫と圧縮の形式まで。
完了の基準は最初に決めて、一度も動かさなかった。本物の docker と docker 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.1(vnd.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 Spec(config.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 Linux、Devicetree 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、setns、waitpid、OpenSSL。
アルゴリズムは全部 Mere にある。inflate も deflate も tar も HTTP も JSON も、
virtio のリングも vsock の状態機械も。
形を決めた 4 つの判断
オラクルは常に他人のプログラム。 mrun は runc とフィールド単位で比べる ——
同じ 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 はどこか別の場所から書いて成功と報告すること。
どれもテストスイートでは出なかった。他人のクライアントに、他人のプログラムを審判にして、 本物のことを要求されたから出たのだ。