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

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

merecontainersdockerocivirtualizationsystemsdogfood

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 つの道とそれぞれの代償ごと書き残す方が、 名前だけ同じ半端な実装より役に立つ。

2026-09-20 追記: これは NAT については正しく、その後ろにあるものについては間違っていた。 覚えている設計から推論していて、ゲストのカーネルに一度も訊いていなかった。 訊いたら netfilter の芯はあり、無いのは ip_tables と nf_nat と tun —— 「意味が成立しない」ではなく「モジュールが 6 本足りない」だった。 本当に欲しい能力は任意の外向き TCP で、値段が見えた途端に安い買い方も見えた。 続きの記事に実測とその後を書いた。

数字

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 で、普通の操作は同等だ。2026-09-20 追記: いまは --cpus N がある。 1 vCPU は遅いのではなく、隣で何かが走っているときに遅い —— worker の隣の web サービスが まさにそれだ。忙しい隣人 3 つの下で、同じサービスは 1 vCPU では 100 ms から 530 ms になり、 4 vCPU では 100 ms のままだった。ほかにも: 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 —— 変換する先の上流インタフェースが存在しない。2026-09-20 追記: プロキシが CONNECT に加えて SOCKS5 も答えるようになったので、 線の外側にあるものはもっと狭く、名指しできる —— どちらのプロトコルも喋らない相手(nc・ping・psql)
  • 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