바닥까지 전부 만든 컨테이너 스택 — 자작 언어로 colima를 대체하는 데 필요했던 것
Hypervisor.framework 위의 VMM, Docker Engine API를 말하는 데몬, OCI 런타임, 레지스트리, tar와 gzip —— 직접 만든 작은 ML 계열 언어로, 진짜 docker CLI가 한 줄도 바뀌지 않은 채 붙는 지점까지 만든 기록. 그것이 공개 명세의 어디에 해당하는지, 무엇을 의도적으로 하지 않는지, 그리고 '진짜를 상대해야만 나온' 결함들.
Mac의 docker는 이 기계에 없는 데몬과 이야기하는 클라이언트다. 누군가는 리눅스 커널을
돌리고 있어야 한다 —— Docker Desktop이든, Colima든, 그 아래의 Lima든.
나는 그 줄 전체를, 직접 설계 중인 ML 계열 언어 Mere로 쓴 프로그램들로 바꿨다.
가상 머신 모니터, 엔진, 런타임, 레지스트리, 아카이브와 압축 포맷까지.
완료 기준은 처음에 정하고 한 번도 옮기지 않았다. 진짜 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에서는 다른 무언가가 리눅스 기계를 마련해 준다. 그것도 여기 있다. 이 전부를 쓰는 데 쓰인 언어까지도.
정확히 말하면 이렇다. colima를 대체하려다, colima 아래에 있는 것을 전부 만들었다.
층을, 공개 명세로 늘어놓기
이 프로젝트에서 흥미로운 건 “돌아간다”는 게 아니다. 거의 모든 층이 공개 명세이고, 줄 전체를 통틀어 벤더 API가 정확히 하나뿐이라는 점이다.
| 층 | 만든 것 | 공개 명세 | 범위와, 의도적으로 없는 것 |
|---|---|---|---|
| 클라이언트 | (만들지 않음) | — | 진짜 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 업로드는 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, 그리고 기계를 끝내는 두 개의 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도 양방향 —— 압축기도 직접 |
표준이 아닌 건 둘뿐이다. 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 상태 기계도.
모양을 결정한 네 가지 판단
오라클은 언제나 남의 프로그램이다. mrun은 runc와 필드 단위로 비교한다 ——
같은 bundle을 둘 다에게 주고, 5개 케이스 100개 넘는 필드를 하나씩, 합격이 아니라 차이로 보고한다.
레이어 쓰기는 커널 자신에게 채점시킨다 —— 진짜 overlay를 mount해 단계를 돌리고,
merged == apply(lower) then apply-as-layer(upper)를 요구한다.
push는 진짜 docker가 pull해서 실행하는 것으로 확인한다.
어느 것도 “내 코드가 맞느냐”를 내 코드에게 묻지 않는다.
레이어란 단계가 만든 차이이고, 차이는 내가 계산할 수 없다 —— 커널은 할 수 있다. 빌드 단계는 overlay mount 위에서 돌고, upper 디렉터리가 그대로 레이어다. 빌드 캐시를 위해 저장 포맷을 발명할 필요도 없었다 —— 전에 돌았던 단계란 “upper가 이미 거기 있는 단계”다. 아무도 쓰지 않던 이름으로, 이미 디스크에 있었다.
namespace는 프로세스보다 먼저 만든다. 컨테이너에 망을 주는 길은 둘이다.
런타임이 namespace를 만들게 하고 나중에 배선하거나, 먼저 만들어서 건네거나.
앞의 것은 컨테이너가 질 수 있는 경쟁이고, 게다가 대개 이긴다 —— 그게 나쁘다.
드물게 나는 실패가 망 탓으로 돌려지기 때문이다. OCI 명세는 namespace를 path로 지목할 수 있으므로(CNI와 같은 형태),
무엇이든 들여다보기 전에 배선이 끝나 있게 만들 수 있다.
NAT는 “없는” 게 아니라 “말이 안 되는” 것이다. 게스트에는 네트워크 인터페이스가 한 장도 없다 —— VMM이 보여주는 건 block과 vsock뿐이고, 드나듦은 전부 후자를 지난다. NAT란 상류로의 변환인데, 상류가 없다. 그것을 바꾸는 세 갈래 길과 각각의 대가까지 기록해 두는 편이, 이름만 같은 어중간한 구현보다 쓸모 있다.
숫자
| mvm + mengd | Colima | |
|---|---|---|
docker run --rm alpine echo |
418 / 432 ms | 428 ms |
docker ps |
90 ms | 105 ms |
| 게스트 부팅 → 데몬 수신 | 1.2초 | — |
| vCPU | 1 | 6 |
vCPU 하나로 일상 동작은 대등하다. 그 밖에: 한 단계 한 레이어로, 마지막 단계의 레이어는 8,940,544바이트에서 2,048로, 압축해서 116이 됐다. 빌드 캐시는 2단계 빌드를 4초에서 0초로 만들었다. 자작 gzip은 실제 이미지 레이어에서 system gzip보다 0.6% 크고 1.3초다 —— 첫 버전은 369초였다.
진짜를 상대해야만 나온 결함들
전부, 그 시점의 모든 검사를 통과하고 있었다.
아무것도 압축하지 않는 압축기. mgz에는 검사가 3개 있었고, 전부 “출력이 올바른가“를 물었다 —— gunzip이 받아들이는가, CRC가 맞는가, 바이트가 돌아오는가. stored 블록만 뱉는 압축기는 셋 다 통과한다. 실제 이미지 레이어를 먹였더니 10,000바이트가 10,023바이트가 됐다 —— 입력보다 크다. 부호 길이 하나가 8비트를 요구해서 블록째 stored로 떨어지고 있었다. 없던 검사는 gzip과 크기를 비교하는 것이었다.
키는 부분 문자열이 아니다. docker compose build는 쿼리에 t=와 target=을 같이 보낸다.
내 파라미터 탐색은 target= 안의 t=에 걸렸다. 이미지는 태그 없이 만들어졌고,
“Successfully built”는 나오는데 “Successfully tagged”는 안 나왔고, compose는 방금 빌드한 이미지에
“No such image”라고 말했다. 빌드 자체는 아무것도 망가지지 않았다.
가득 차서 죽은 것처럼 보이는 데몬. Mere의 spawn은 handle을 돌려주고, 버리면
joinable한 스레드가 남는다 —— 런타임 소스에서 detach가 정의된 바로 그 자리에 그렇게 쓰여 있다.
나는 연결마다 버리고 있었다. 거기에 클라이언트가 떠난 뒤로도 5분을 폴링하는 /wait가 더해져,
컨테이너 30개쯤에서 런타임이 스레드를 만들지 못하고 데몬이 아무것도 받지 않게 됐다 ——
compose는 출력이 없고, trace에는 요청이 한 건도 없다. 40개 기동은 304초에서 14초가 됐다.
부모를 죽이면 자식은 고아가 된다. 런타임이 부모이고, 컨테이너의 init은 그 자식이다.
docker stop은 런타임에 시그널을 보냈고, docker rm -f는 디렉터리를 지웠다 ——
둘 다 컨테이너의 프로세스를, 아무도 모르는 채 계속 돌게 뒀다. 삭제할 때마다 하나씩,
기계가 컨테이너보다 런타임을 더 많이 가질 때까지.
store 쓰기는 raise하면 안 된다. Mere에서 raise는 프로세스의 끝이다.
다른 요청이 지우고 있는 디렉터리로의 write_file은 쓰기가 아니라 데몬을 끝낸다.
삭제 도중에, 마지막 말로 경로 한 줄만 남기고 죽었다 ——
두 번, 두 번째는 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은 엉뚱한 곳에 쓰고 성공했다고 보고한다는 것.
어느 것도 테스트 스위트에서는 나오지 않았다. 남의 클라이언트를 상대로, 남의 프로그램을 심판으로 두고, 진짜를 요구받았기 때문에 나왔다.