ADR 0114 — Обнаружение нативного Ollama в dev bootstrap: спросить один раз, сохранить в .env, поставить сервисы compose за profile
- Статус: Принят
- Дата: 2026-08-26
- Контекст: закрывает реальную коллизию порта, найденную в локальном dev bootstrap между сервисом
ollamaиз compose и нативной установкой Ollama, уже работающей на машине разработчика - Ссылки (без правки): ADR 0025 (решение 10 —
pnpm devиmake deploy-localнамеренно делят порты), ADR 0100 (ввёл паруollama/ollama-model-loader, которую этот ADR ставит за profile)
Контекст
scripts/dev/preflight.mjs уже существовал, чтобы ловить известное
столкновение портов до docker compose up: pnpm dev и make deploy-local
намеренно делят порты 3000/4000/8080 (ADR 0025, решение 10), а непрозрачное
port is already allocated от Docker не говорит, кто держит порт и что
делать. Preflight читает docker compose config, сопоставляет с docker ps и
ss -tlnp и сообщает каждую коллизию по имени.
OLLAMA_PORT (по умолчанию 11434) входит в тот же набор — и это также порт
по умолчанию нативной установки Ollama, такой, которую разработчик может
уже держать на своей машине по не связанным причинам. В отличие от
столкновения с k3d, у этой коллизии есть законное решение, о котором общий
отчёт о конфликтах не знает: переиспользовать нативный демон вместо запуска
второго в контейнере. Если сообщать о ней как «port 11434 ← ollama (unknown
process)», как о любом другом конфликте, исправление выглядит проблемой,
которую нужно обойти, тогда как настоящий ответ — «дайте переиспользовать».
Решение
1. Убедиться, что это действительно Ollama, прежде чем обращаться с ним особо
detectarOllamaNativo (scripts/dev/preflight.mjs:234) обращается с портом
как с особым случаем только тогда, когда живой GET на
http://localhost:<porta>/api/tags отвечает в форме, которую Ollama
возвращает всегда, даже без скачанной модели ({"models":[]}, проверяется
через ehOllama, preflight.mjs:159). Короткий таймаут (1,5 с) здесь важен:
это выполняется до подъёма любого контейнера, поэтому зависание здесь
заморозило бы весь docker compose up. Всё, что на порту не отвечает как
Ollama, попадает в уже существовавший общий отчёт о конфликтах — это решение
трогает только единственный случай, когда занимающий порт — доказуемо то, о
чём этот ADR.
2. Спросить один раз, применить значение по умолчанию без TTY и записать решение, чтобы не спрашивать снова
perguntarUsoDoOllama (preflight.mjs:198) спрашивает «Detectamos Ollama já
rodando na porta N. Usar essa instância? [S/n]» — но только когда
process.stdin.isTTY. Пункты меню bootstrap.sh выполняют команду в фоне со
stdin, перенаправленным из /dev/null, намеренно, чтобы выполняющаяся команда
никогда не крала стрелки, которые читает само меню; вопрос, пытающийся
прочитать строку из уже закрытого потока, завис бы навсегда. Без TTY функция
пишет в лог, почему, и сразу возвращает значение по умолчанию (да) — та же
форма охраны, которую остальной продукт уже использует всякий раз, когда
решению нужен человек, но смотрящего может не быть.
Ответ записывается в .env и больше не спрашивается, пока там есть
OLLAMA_MODE: detectarOllamaNativo выходит в самом начале, как только ключ
существует (preflight.mjs:236). Ничто в обычном цикле pnpm dev не
спрашивает снова — единственный способ заново открыть вопрос — новый пункт
меню из Решения 5.
3. escreverEnv — первое в этом репозитории, что пишет .env, а не только читает
Каждый существующий скрипт, касающийся .env (поиски *_TEST_KEY в
reset-total.sh, собственный lerEnv этого же файла), лишь извлекает
значение grep. Ничему до этого не нужно было обновлять значение, поэтому не
было образца на bash для переиспользования в этом направлении. escreverEnv
(preflight.mjs:118) написан на Node, потому что обновлению нужно свойство,
которое строко-ориентированные инструменты bash не дают бесплатно без
дополнительной осторожности: прочитать файл, переписать строку на месте, если
её ключ уже существует, добавить ключ в конец, если нет, и оставить нетронутыми
все комментарии и все прочие ключи. На пути «да, использовать» он записывает
OLLAMA_MODE=host и OLLAMA_HOST=http://host.docker.internal:<porta>; на
«нет» записывает OLLAMA_MODE=container плюс новый OLLAMA_PORT, найденный
перебором вверх от 11500 (proximaPortaLivre, preflight.mjs:178), чтобы
контейнер получил свободный порт, а не столкнулся с нативным, который ему
только что велели оставить в покое.
4. Поставить ollama/ollama-model-loader за profile compose
Оба сервиса (введённые ADR 0100) переходят в profiles: ["local-llm"] (docker/docker-compose.yml:82,119) — тот же образец, который
docker-compose.prod.yml уже использует для эквивалентной пары под буквальным
именем llm (другой файл, поэтому литералу не нужно совпадать). Сервис с
profile не поднимается при голом docker compose up, а это ровно то, что
нужно OLLAMA_MODE=host: не «поднялся, но недостижим», а вообще не поднялся,
поэтому никогда не борется за порт и никогда не тратит RAM на загрузку модели,
которую никто не будет запрашивать.
5. scripts/dev/perfil-ollama.sh решает --profile local-llm в момент выполнения каждой команды
Маленький скрипт, а не grep, продублированный в трёх местах: он печатает
--profile local-llm, если только .env уже не говорит OLLAMA_MODE=host, и
ничего в противном случае. Его потребляют только два вызывающих docker compose, которые поднимают стек целиком, а не названный сервис, —
«Docker › Deploy › All» и «Docker › Create» (bootstrap.sh, пункты
1.1.1/1.2) — через $(bash scripts/dev/perfil-ollama.sh), записанный
экранированным как \$(...) в исходнике дерева команд, чтобы он
вычислялся, когда пункт меню выполняется, а не когда дерево строится (то же
экранирование уже используется для \${POSTGRES_USER} в команде Database ›
Delete). Api/Engine/Web (1.1.2–1.1.4) нацелены на один сервис каждый и
никогда не трогают ollama, поэтому остались как были.
6. «Docker › Reconfigurar Ollama» (1.5) заново открывает вопрос
scripts/dev/reconfigurar-ollama.sh безусловно удаляет OLLAMA_MODE и
OLLAMA_HOST из .env, а OLLAMA_PORT — только когда забытый режим был
container: в режиме host этот порт был записан тем, кто установил Ollama
нативно, а не этим механизмом, и его удаление заставило бы следующую загрузку
молча забыть значение, которое никогда не принадлежало этой фиче. Пункту не
нужен экран подтверждения: он меняет не более трёх строк .env и ничего
разрушительного для данных — та же мерка, которой уже следуют остальные
нетривиальные, но неразрушительные пункты меню (Generate, Migrate, Seed).
Последствия
Profile, который скрывает порт, скрывает и реальный конфликт, поэтому путь
обнаружения должен был получить свой. Когда ollama под неактивным profile,
docker compose config по умолчанию перестаёт перечислять порт 11434 —
общий цикл по портам, который ловил «что-то другое занимает 11434», просто
перестал бы его видеть, молчаливая регрессия относительно поведения,
существовавшего до этого ADR. detectarOllamaNativo закрывает этот пробел
сам: когда порт занят чем-то, что отвечает чем угодно, кроме формы Ollama, он
возвращает запись конфликта ровно в том формате, который уже производит общий
цикл, и она попадает в тот же отчёт.
Удаление depends_on: ollama у api было вынужденным изменением, а не
случайным. Compose отвергает весь файл с «depends on undefined service»,
когда сервис вне неактивного profile зависит от сервиса внутри него, —
подтверждено проверкой в изолированной песочнице до прикосновения к реальному
файлу, и это ломало каждый вызов docker compose (up, down, ps, даже
config, который вызывает сам preflight.mjs). Для целей compose зависимость
уже была рудиментарной: api обращается к Ollama во время выполнения, в ходе
агента, никогда при собственной загрузке, поэтому из порядка подъёма ничего не
потерялось.
«Docker › Destroy» тоже проходит через тот же условный --profile.
docker compose down без --profile игнорирует контейнеры, принадлежащие
неактивному profile, — если ollama/ollama-model-loader поднялись под
local-llm, голый down оставляет их работающими и может оставить сеть в
состоянии «всё ещё используется». CMD["1.3"] в bootstrap.sh теперь
разрешается через тот же помощник scripts/dev/perfil-ollama.sh, что и пути
up, так что Destroy сносит ровно то, что поднял соответствующий up, —
покрыто случаем «Docker › Destroy também passa pelo perfil do Ollama» в
bootstrap.spec.ts.
Объявлено, не исправлено здесь: простаивающий контейнер ollama самого
проекта может быть принят за нативную установку. Если разработчик
переходит на OLLAMA_MODE=container, но не останавливает контейнеры заранее,
detectarOllamaNativo не может отличить «на этом порту нативная установка»
от «на этом порту всё ещё контейнер ollama самого проекта от предыдущего
запуска» — в этом случае он спросил бы снова. Не реализовано: такая
последовательность необычна (переконфигурировать, не остановив стек заранее) и
была вне рамок запроса, породившего эту фичу.
Порядок добавления в .env не курируется. Когда ни одного из трёх ключей
ещё нет, escreverEnv добавляет их в конец файла, а не рядом с блоком
OLLAMA_PORT/OLLAMA_HOST, вокруг которого .env.example уже их
документирует. Косметика, не проблема корректности — значения одинаково
валидны, где бы они ни оказались в файле.
Рассмотренные альтернативы
- Продолжать сообщать о занятом порте как об общем конфликте и оставить
разработчику решать вручную через
.env. Отвергнуто: это обычная и безобидная ситуация, а не ошибка — помечать её так же, как «кластер k3d поднят» или «на этом порту не связанный процесс», приучает разработчика обходить то, что исправляется одной строкой, каждый раз при подъёме стека. - Обнаруживать и переиспользовать нативный экземпляр молча, не
спрашивая. Отвергнуто по тому же принципу, которого остальной продукт уже
придерживается для конфигурации, которая ему не принадлежит (CLAUDE.md:
продукт никогда не перезаписывает конфигурацию пользователя без явного
решения), — даже малорисковое удобство dev получает явный вопрос, заданный
один раз, а не молчаливое значение по умолчанию, которое разработчик
обнаружит лишь позже, читая
.env. - Оставить
ollama/ollama-model-loaderвне любого profile и лишь перестать публиковать порт в режимеhost. Отвергнуто: контейнеры всё равно поднимались бы, всё равно загружали бы модель в память и всё равно тратили бы CPU/RAM, которые никто никогда не запросит, — profile не даёт им подняться, а это то, что на деле означает «использовать вместо этого нативный».