Перейти к основному содержимому

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.cjs91 843 B106 221 B+14 378 B (+15,7 %)
dist-bin/brabo-runner-linux-x6482 777 288 B82 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), и четвёртый намеренно БЕЗ источника:

классисточниккогда
DockerIndisponivelErrorinfraдемон не ответил. Сообщение говорит, что ДЕЛАТЬ (docker info, группа docker в Linux), и что ни один контейнер не был затронут
DockerCliAusenteErrorinfraисполняемого файла docker нет в PATH. Это СОБСТВЕННАЯ ошибка, а не «демон недоступен»: они чинятся по-разному, и их слияние — урок RN-475 на этаж ниже
ContainerNaoGerenciadoErrorpolí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 вопрос другой и остаётся открытым — там нет автономного бинарника, а порт — то, что делает этот второй ответ реализацией, а не переписыванием.