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

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 не даёт им подняться, а это то, что на деле означает «использовать вместо этого нативный».