ADR 0128 — Порт Docker и доказательство упаковки, которое выбрало реализацию
Контекст
У плана выполнения в реальном контейнере есть два места, которым нужно говорить с
демоном Docker: runner (Docker машины пользователя, для проекта в
режиме runner) и будущий серверный broker (PR 1.2, для проекта в режиме
container). Api никогда не получает сокет, и это закрытое решение.
Выбор библиотеки зависел от доказательства, а не от предпочтения.
dockerode — фактический Node-клиент для Docker, но runner распространяется
ТРЕМЯ путями — клонирование monorepo, npm install -g @brabo/runner (ADR 0106) и
автономный бинарник через bun build --compile (ADR 0112) — и третий из них самый
хрупкий: ему уже понадобился собственный генератор манифеста
(scripts/build-bin.mjs), чтобы встроить .node из node-pty, потому что Bun встраивает
только файл, достигаемый СТАТИЧЕСКИМ спецификатором.
Дерево, которое тянет dockerode@5, велико — docker-modem, ssh2,
@grpc/grpc-js, protobufjs, tar-fs, — а ssh2 несёт НЕОБЯЗАТЕЛЬНЫЙ нативный
binding (cpu-features). Отсюда порядок, зафиксированный владельцем продукта: сначала
доказать, потом проектировать, с решением, уже записанным для обоих исходов —
зелёный: dockerode остаётся; сломалось: runner переходит на execFile('docker', …), и
этот ADR фиксирует точную ошибку как причину. Никаких патчей бандлера или shim:
креативный обходной путь здесь становится долгом, который потом никто не понимает.
Решение
Доказательство и что оно вернуло
dockerode@5.0.1 + @types/dockerode@4.0.1 вошли в apps/runner, с
адаптером, который ИМПОРТИРУЕТ, СОЗДАЁТ ЭКЗЕМПЛЯР (new Docker()) и ВЫЗЫВАЕТ (ping()), — проверяемым
флагом --self-test-docker, запускаемым против артефактов, а не через
import, который бандлер мог бы удалить.
| шаг | результат |
|---|---|
pnpm --filter runner build (tsup) | прошёл — но tsup оставляет dependencies как внешний require (так сегодня уже живёт jose), поэтому dockerode даже не входил в бандл |
pnpm --filter runner smoke | прошёл — node dist/index.cjs --self-test-docker разрешил dockerode из node_modules и поговорил с демоном |
pnpm --filter runner build:bin (bun build --compile) | ПРОВАЛИЛСЯ |
Ошибка, вставленная целиком:
3 | const binding = require('../build/Release/cpufeatures.node');
^
error: Could not resolve: "../build/Release/cpufeatures.node"
at /…/node_modules/.pnpm/cpu-features@0.0.10/node_modules/cpu-features/lib/index.js:3:25
Цепочка обязательна, а не случайна — прочитана в установленном коде, а не предположена:
dockerode → docker-modem (lib/modem.js, linha 6: require('./ssh'))
→ ssh2 (lib/protocol/constants.js, linha 7: require('cpu-features'))
→ cpu-features/lib/index.js → require('../build/Release/cpufeatures.node')
lib/modem.js делает require('./ssh') в начале модуля: SSH-транспорт входит
в граф даже в этом runner, который говорит только с локальным unix-сокетом. require
cpu-features обёрнут в try/catch внутри ssh2 и изящно деградирует
в runtime — но граф здесь разрешает БАНДЛЕР, во время
сборки, а у бандлера нет try/catch. Это тот же класс проблемы, что
ADR 0112 задокументировал с node-pty, с решающим отличием: там
binding СУЩЕСТВЕНЕН (без него нет терминала, и поэтому стоило построить
механизм встраивания), а здесь это необязательное ускорение транспорта, который мы не
используем.
Измерено и НЕ принято, для протокола: bun build --compile --external cpu-features компилируется (280 модулей против 79), даёт бинарник в 84 473 032 B
(+1,7 MB), и dockerode там РАБОТАЕТ — ping отвечает против реального
демона, потому что try/catch в ssh2 проглатывает заглушку, которую Bun оставляет на месте. Это
записано, чтобы никому не пришлось повторять исследование, и это ровно тот
обходной путь, который решение владельца продукта исключило заранее: он затягивает
необязательный .node из мёртвого SSH-дерева в пять бинарников по
платформам, и через полгода долг стал бы нечитаемым.
Следовательно: runner использует execFile('docker', …). dockerode удалён из
package.json и из lockfile; от него ничего не осталось в дереве. Broker (PR 1.2) НЕ
наследует это решение — он работает в образе, который строим мы, и никогда не становится
автономным бинарником, поэтому dockerode остаётся там кандидатом, и именно для
этого существует порт ниже.
Размеры, измеренные
| артефакт | до | после | дельта |
|---|---|---|---|
dist/index.cjs | 91 843 B | 106 221 B | +14 378 B (+15,7 %) |
dist-bin/brabo-runner-linux-x64 | 82 777 288 B | 82 789 576 B | +12 288 B (+0,015 %) |
Рост — это новый код (порт + адаптер + самотест), а не зависимость:
execFile приходит из node:child_process, и встраивать нечего. Для
сравнения, отвергнутая альтернатива стоила +1,7 MB на бинарник, умноженные на пять
платформ.
Порт
apps/runner/src/docker-port.ts, в той же форме, что и два контракта, которые
репозиторий уже использует для изоляции внешнего поставщика (LLMProvider из ADR 0041 и
GitProviderContract Фазы 2): абстрактный класс, собственные доменные типы,
ошибки, нормализованные по КЛАССУ, — потребитель решает по типу ошибки, а не по
подстроке сообщения поставщика.
Пять операций, а не шесть: start, stop, remove, inspect, exec.
Сдерживание — это ТИП, а не дисциплина вызывающего. То, что порт не позволяет ЗАПИСАТЬ, — это важная половина:
privilegedиcap_add— поля нет, и ни один библиотечный тип не пересекает порт: спецификация ЗАКРЫТА, а payload собирает адаптер. Неоткуда скопировать опцию;network: host—RedeDoContainer— это объединение'none' | 'egress', та же позиция границы, что в ADR 0065.host— не значение типа, поэтому это не фраза, которую можно произнести;- свободный bind-mount — СПИСКА монтирований нет. Есть ОДНА папка, место назначения —
константа
PONTO_DE_MONTAGEM(/work), а тип папки — это МАРКА (RaizDeProjeto): толькоraizDeProjetoValidada()производит значение этого типа, и она отклоняет относительный путь, путь с.., с NUL, корень файловой системы и системные папки (/etc,/var— где живёт сокет самого Docker, —/devи другие). Смонтировать/здесь не «не рекомендуется»: это не компилируется.
Ни одна операция не получает id контейнера. Все исходят из workspaceDirName
(RN-109, замороженного при создании), выводят brabo-<workspace_dir_name> и разрешают
через docker ps, ОТФИЛЬТРОВАННЫЙ по имени и по метке brabo.managed=true. «Останови
контейнер X» с произвольным X — не фраза, которую этот порт умеет услышать, — и
тёзка без метки возвращает ContainerNaoGerenciadoError, никогда не null:
трактовка его как отсутствующего заставила бы start попытаться создать поверх и упасть с «name
already in use» — сообщением, которое ничего не говорит о единственном, что
на самом деле произошло.
Где живёт порт и почему. В apps/runner/src/, потому что runner —
единственный потребитель, существующий сегодня. packages/shared был рассмотрен и отвергнут
из-за инварианта, который сам пакет объявляет и который тест api держит честным
(packages-shared-so-tipos.spec.ts): он на 100 % ТИПОВОЙ, ничто там не может пережить
tsc, потому что main указывает на сырой .ts, и production-образ api
умер бы с ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING. Порт с классами
ошибок и функцией проверки — это runtime-код. Разделить контракт на
«типы там, ошибки здесь» создало бы два источника для одной вещи — а это дефект,
для избежания которого существует порт. Когда родится apps/broker, файл ПЕРЕЕДЕТ
в пакет workspace; он не КОПИРУЕТСЯ, и это записано в его
docblock, потому что второй файл с теми же пятью операциями и шестой «только в
broker» — это начало конца сдерживания выше.
Классификация сбоев
Три именованных исхода, в словаре продукта (infra | modelo | código | política), и четвёртый намеренно БЕЗ источника:
| класс | источник | когда |
|---|---|---|
DockerIndisponivelError | infra | демон не ответил. Сообщение говорит, что ДЕЛАТЬ (docker info, группа docker в Linux), и что ни один контейнер не был затронут |
DockerCliAusenteError | infra | исполняемого файла docker нет в PATH. Это СОБСТВЕННАЯ ошибка, а не «демон недоступен»: они чинятся по-разному, и их слияние — урок RN-475 на этаж ниже |
ContainerNaoGerenciadoError | política | существует контейнер с выведенным именем и БЕЗ метки brabo.managed=true |
ComandoDeDockerFalhouError | нет | демон жив и отклонил команду. Его сообщение передаётся целиком |
Четвёртый намеренно не объявляет источник: выбирать между infra и кодом для «No such image» значило бы ставить диагноз методом исключения, а это дефект, который ADR 0020 фиксирует как тот, который не следует повторять. Он говорит, что это остаток, и показывает, что сказал демон.
Разделение между «демон недоступен» и «демон отклонил» делается по КОМАНДЕ, а не по
подстроке: любой ненулевой выход запускает подтверждающий docker version, и
именно ЕГО результат решает класс. Это стоит одного лишнего вызова и только на пути
сбоя — цена отказа от классификации по тексту поставщика, которую ADR 0002 (git) и
ADR 0041 (LLM) уже запрещают с двух других сторон.
Последствия
Ничто пока не поднимает контейнер, и это и есть объём. Порт существует, адаптер
существует, и никто их не вызывает: ни одного нового сообщения в канале Phoenix, ни одного
наблюдаемого изменения поведения. start/stop/remove/inspect/exec
покрыты дублёрами в docker-cli.spec.ts, и ни одна из них не проверялась
против настоящего демона — проверен только ping, через --self-test-docker. Это
та же дисциплина «capability объявляется, только когда доказана»: доказывает
все пять PR 1.3, которая подключает их к каналу.
Runner начинает требовать БИНАРНИК docker в PATH, а не только сокет. На
машине того, кто запускает Docker, это практически всегда так, а у отсутствия есть
собственная ошибка. Это самое заметное различие между этим выбором и отвергнутым:
dockerode говорит с сокетом напрямую и не нуждался бы в CLI.
Вывод CLI — текст, а текст — не версионируемый контракт. Каждое чтение использует
--format '{{json .}}' с терпимым JSON.parse (нечитаемая строка становится null,
никогда исключением), никогда не вырезание колонок. Версия CLI — это версия на машине
пользователя, поэтому никакие экзотические флаги не используются — только опции, существующие много
версий. И всё же: изменение формата docker ps сломало бы чтение,
и это риск, которого у dockerode не было бы.
Таймаут exec убивает клиента, а не команду. docker exec, завершённый
по таймауту, оставляет процесс работающим ВНУТРИ контейнера, и нет способа убить его
без второго exec и PID, которого этот путь не знает. Вывод ГОВОРИТ это,
вместо того чтобы позволить читающему предположить, что команда умерла.
Ни одна RN здесь не рождается, и это объявлено. Нет наблюдаемого поведения, которое
описывало бы правило: продукт не вызывает порт. Правило для фиксации — только
контейнер с brabo.managed=true останавливается, удаляется или используется как цель
exec — родится в PR, который действительно поднимет контейнер, вместе с
тестовым доказательством, которое его покрывает.
Доказательство остаётся живым. --self-test-docker выполняется в обоих smoke
(pnpm --filter runner smoke и smoke:bin) и проходит в ОБОИХ исходах
самотеста — демон ответил, демон не ответил, — потому что вопрос касается
АРТЕФАКТА, а не машины: требование демона заставило бы smoke перестать работать
ровно там, где он нужнее всего (в CI, где нет Docker для runner).
Отныне он гарантирует, что порт доходит целым до dist и до
бинарника, а отсутствие Docker становится ИМЕНОВАННОЙ ошибкой, а не сырым stack trace.
Чего никому не нужно переделывать. Если однажды кто-то снова предложит dockerode в runner,
ответ уже измерен выше: он ломает build:bin, а единственный
известный способ его уместить — отвергнутый --external cpu-features. В
BROKER вопрос другой и остаётся открытым — там нет автономного
бинарника, а порт — то, что делает этот второй ответ реализацией, а не
переписыванием.