Generic selectors
Exact matches only
Search in title
Search in content
Post Type Selectors

OpenCode в Docker: изолированный агент с прокси и своим Docker

Linux и DevOps

Самостоятельно развёрнутый ИИ-агент для разработки упирается в три проблемы инфраструктурного уровня. Каждая новая сессия заново ставит Playwright, хотя пакет уже лежит на диске. Агент с правами на выполнение команд работает прямо на хосте, где рядом живут базы данных, панели управления и десяток контейнеров с проектами. И часть нужных агенту адресов недоступна без прокси — от репозитория пакетов GitHub CLI до интерфейсов моделей.

Все три решаются одной сборкой: окружение уходит в образ, агент — в контейнер, весь его трафик — в прокси. Ниже разобрана production-ready конфигурация с акцентом на места, где развёртывание ломается неочевидно.

Содержание
  1. Содержание
  2. Архитектура: четыре контейнера в одной сети
  3. Структура каталогов и перенос сборки
  4. Почему npm не работает через socks5
  5. Как передать прокси в docker build
  6. Почему apt игнорирует переменные окружения
  7. NO_PROXY и имя контейнера с демоном Docker
  8. Изолированный Docker вместо доступа к хостовому
  9. Сравнение: docker.sock vs dind
  10. Предустановка окружения в образ
  11. Инструкции агенту: файл AGENTS.md
  12. Зачем фиксировать версию OpenCode
  13. Пароль веб-интерфейса обязателен
  14. Настройка Nginx для потоковой передачи
  15. Проверка после развёртывания
  16. Полные тексты файлов
  17. Dockerfile
  18. docker-compose.yml
  19. opencode.jsonc
  20. AGENTS.md
  21. .env.example
  22. bootstrap.sh
  23. Частые вопросы
  24. Можно ли обойтись без gost и настроить socks5 напрямую?
  25. Насколько dind медленнее хостового Docker?
  26. Что делать, если после обновления версии серверы MCP не подключились?
  27. Как перенести сборку на другую машину?
  28. Обязателен ли пароль, если порт не выставлен в интернет?
  29. Заключение

Содержание

  1. Архитектура: четыре контейнера в одной сети
  2. Структура каталогов и перенос сборки
  3. Почему npm не работает через socks5
  4. Как передать прокси в docker build
  5. Почему apt игнорирует переменные окружения
  6. NO_PROXY и имя контейнера с демоном Docker
  7. Изолированный Docker вместо доступа к хостовому
  8. Предустановка окружения в образ
  9. Инструкции агенту: файл AGENTS.md
  10. Зачем фиксировать версию OpenCode
  11. Пароль веб-интерфейса обязателен
  12. Настройка Nginx для потоковой передачи
  13. Проверка после развёртывания
  14. Полные тексты файлов
  15. Частые вопросы
  16. Заключение

Архитектура: четыре контейнера в одной сети

Сборка состоит из четырёх сервисов, объединённых собственной сетью Docker. Каждый решает одну задачу, зависимости выстроены по цепочке.

opencode-xray    → читает существующий /opt/xray/config.json, отдаёт socks5 на 10808
      ↑
opencode-proxy   → gost, преобразует HTTP :8118 в socks5, чтобы заработал npm
      ↑
opencode-dind    → отдельный демон Docker, полностью изолированный от хоста
      ↑
opencode         → сам агент, порт 4096

Вся конфигурация вместе с состоянием — историей сессий, кэшами, образами внутреннего Docker — лежит в одном каталоге /opt/opencode. Резервное копирование сводится к архивации одной папки.

Структура каталогов и перенос сборки

/opt/opencode/
├── Dockerfile
├── docker-compose.yml
├── opencode.jsonc          → монтируется в /root/.config/opencode/
├── AGENTS.md               → копируется в каждый проект
├── bootstrap.sh
├── .env                    ← ключи и пароль
├── ssh/                    → /root/.ssh
├── gitconfig               → /root/.gitconfig
├── workspace/              → /workspace, здесь живут проекты
└── data/                   ← постоянные данные
    ├── dind/                  образы и слои внутреннего Docker
    ├── opencode/              история сессий, авторизация
    ├── bun-cache/             кэш модулей MCP
    └── uv-cache/              кэш инструментов Python

Постоянные данные вынесены в bind mount внутри той же папки вместо именованных томов Docker. Именованные тома удобнее в управлении, но физически лежат в недрах /var/lib/docker, и перенос сборки на другую машину превращается в отдельную операцию. С bind mount достаточно tar одного каталога.

Каталог data/dind растёт быстрее остальных: там хранятся слои всех образов, которые собирает агент. Периодическая очистка через docker exec opencode docker system prune -a удерживает размер в разумных пределах.

Почему npm не работает через socks5

Xray отдаёт socks5. Для приложений на Python этого достаточно — библиотека requests в связке с PySocks работает штатно. Но в образ нужно поставить языковые серверы, форматтеры и Playwright через npm, а npm поддерживает прокси только по HTTP. Указание socks5:// в его настройках не срабатывает вообще.

Решение — лёгкий преобразователь между протоколами. gost принимает соединения по HTTP и пересылает их в socks5 одной строкой запуска, без файла настроек.

proxy:
  image: gogost/gost:latest
  command: -L http://:8118 -F socks5://xray:10808

После этого npm, pip, apt и curl видят обычный прокси по HTTP, а наружу трафик уходит через Xray.

Как передать прокси в docker build

Логичный шаг — указать при сборке адрес http://proxy:8118. Он не работает: имена контейнеров резолвятся только в рантайме, а docker build идёт в отдельной сети, где сервиса proxy не существует.

Обходится двумя настройками. Порт преобразователя публикуется на локальный адрес хоста, а образ собирается в сети хоста.

proxy:
  ports:
    - "127.0.0.1:8118:8118"

opencode:
  build:
    network: host
    args:
      BUILD_PROXY: "http://127.0.0.1:8118"

Отсюда следует обязательный порядок запуска: сначала поднимается только сетевая часть, затем проверяется доступность прокси, и лишь после этого собирается образ.

docker compose up -d xray proxy
curl -x http://127.0.0.1:8118 -s -o /dev/null \
     -w 'proxy: %{http_code}\n' https://cli.github.com/
docker compose up -d --build

Публикация порта на 127.0.0.1, а не на 0.0.0.0 — принципиальный момент. Открытый наружу HTTP-прокси без проверки подлинности превращается в открытый ретранслятор для всей локальной сети.

Почему apt игнорирует переменные окружения

Переменные http_proxy и https_proxy в образе выставлены, curl через прокси ходит, а apt-get update по репозиторию с адресом https:// всё равно уходит напрямую и упирается в таймаут. Типичный симптом — падение сборки на шаге установки GitHub CLI с кодом выхода 28.

Пакетному менеджеру прокси задаётся отдельным файлом настроек.

RUN if [ -n "${BUILD_PROXY}" ]; then \
      printf 'Acquire::http::Proxy "%s";\nAcquire::https::Proxy "%s";\n' \
        "${BUILD_PROXY}" "${BUILD_PROXY}" > /etc/apt/apt.conf.d/00proxy; \
    fi

В последнем слое образа этот файл удаляется, иначе настройки прокси уедут в готовый образ и будут мешать при запуске на машине без него.

NO_PROXY и имя контейнера с демоном Docker

Агент обращается к внутреннему демону Docker по адресу tcp://dind:2375. Если прокси включён, а dind не исключён из списка, командная строка Docker пытается достучаться до внутреннего сервиса через внешний прокси и получает отказ.

Симптом обманчивый: всё остальное работает — модели отвечают, пакеты ставятся, — а Docker внутри контейнера просто не находит демон.

- NO_PROXY=localhost,127.0.0.1,::1,dind,proxy,xray,opencode
- no_proxy=localhost,127.0.0.1,::1,dind,proxy,xray,opencode

Обе переменные задаются одновременно, в верхнем и нижнем регистре: разные инструменты читают разные варианты написания, единого стандарта здесь нет.

Изолированный Docker вместо доступа к хостовому

Самый быстрый способ дать агенту Docker — примонтировать /var/run/docker.sock. Работает мгновенно, но означает полное управление всеми контейнерами хоста, включая возможность запустить контейнер с монтированием корня файловой системы. Для машины, где рядом работают базы данных, это неприемлемая цена: ровно через открытый порт агента панели управления с монтированием файловой системы хоста происходит значительная часть заражений майнерами.

Альтернатива — отдельный демон в контейнере-соседе.

dind:
  image: docker:27-dind
  privileged: true
  environment:
    - DOCKER_TLS_CERTDIR=
  volumes:
    - ./data/dind:/var/lib/docker

Флаг privileged выглядит настораживающе, но нужен демону для работы с пространствами имён внутри своего же контейнера, а не для доступа к хосту. Контейнеры, которые создаёт агент, физически исполняются внутри этого соседа и файлов хоста не видят.

Отсюда важное ограничение, которое нужно явно объяснить агенту. Монтирование вида -v /workspace/project:/app внутри такого запуска не сработает: для демона в контейнере-соседе пути /workspace не существует. Код передаётся в тестовый контейнер через docker build — контекст отправляется демону архивом, это работает корректно — либо через docker cp.

Загрузка образов из реестра через прокси при этом работает, если задать демону те же переменные окружения. Проверяется одной командой.

docker exec -it opencode docker run --rm hello-world

Сравнение: docker.sock vs dind

Монтирование сокета даёт нулевые накладные расходы, общий кэш слоёв с хостом и рабочие bind-mount, но обнуляет изоляцию — доступ к сокету эквивалентен правам root на хосте. Отдельный демон требует собственного дискового пространства под слои и запрещает монтирование хостовых путей, зато удерживает агента в границах его контейнеров. Для среды, где на одной машине живут рабочие сервисы, второй вариант — единственный разумный.

Предустановка окружения в образ

Основная причина, по которой агент переустанавливает Playwright каждую сессию, — не отсутствие пакета, а отсутствие информации о том, что он уже есть. Лечится с двух сторон: физически положить всё в образ и написать об этом в инструкциях.

В образ уходит один слой.

ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
RUN npm install -g --no-fund --no-audit playwright @playwright/test \
    && npx --yes playwright install --with-deps chromium \
    && rm -rf /var/lib/apt/lists/*

Отдельно стоит прогреть кэш серверов MCP, чтобы первая сессия не ждала загрузку из сети.

RUN bunx --yes @playwright/mcp@latest --help > /dev/null 2>&1 || true
RUN uvx --from git+https://github.com/oraios/serena serena --help > /dev/null 2>&1 || true

Обёртка || true здесь осознанная: если сеть подведёт, сборка не должна падать из-за необязательного прогрева. По той же логике оборачивается установка GitHub CLI — полезная, но не критичная.

Предустановка в образ сокращает старт сессии с нескольких минут до секунд и убирает сетевую зависимость из горячего пути. Latency первого ответа перестаёт зависеть от скорости реестра пакетов.

Инструкции агенту: файл AGENTS.md

Файл AGENTS.md в корне проекта — то, что превращает предустановленное окружение в реально используемое. Без него агент будет действовать по привычным шаблонам: ставить пакеты заново и монтировать хостовые пути.

## Предустановленный инструментарий — НЕ переустанавливать

**Поиск и файлы:** rg (ripgrep), fd, fzf, jq
**Python:** python3, ruff, pyright, uv/uvx, pgcli, httpie
**Node:** node 22, npm, bun/bunx, tsc, typescript-language-server, prettier, eslint
**Базы:** psql
**Docker:** docker, docker compose, docker buildx — через изолированный dind
**Git:** git, gh
**Документы:** convert (ImageMagick), pdftotext/pdftoppm, pandoc
**Прочее:** tmux, htop, ncdu, procps, lsof

## Playwright

Playwright и Chromium уже установлены на этапе сборки образа.
Браузеры лежат в /ms-playwright (переменная PLAYWRIGHT_BROWSERS_PATH).
НЕ запускай `npm install playwright` и `playwright install`.
Если получаешь «browser not found» — сначала проверь `ls /ms-playwright/`.

## Docker

Docker работает через изолированный dind (DOCKER_HOST=tcp://dind:2375).
Контейнеры, которые ты создаёшь, НЕ видят файлы хоста и НЕ видят /workspace.
Для передачи кода используй docker build (COPY из контекста) или docker cp,
а не монтирование хостовых путей.

## Версия OpenCode

Версия зафиксирована в образе, автообновление отключено.
Не обновляй OpenCode изнутри контейнера — это ломает воспроизводимость
и откатится при следующей пересборке.

Зачем фиксировать версию OpenCode

Фиксация версии — не перестраховка, а защита от тихой поломки. У OpenCode параллельно существуют две схемы файла настроек. В старой серверы MCP описываются как mcp.{имя}, права — объектом permission. В новой — вложенностью mcp.servers.{имя} и массивом permissions с объектами. В списке изменений отдельно указано, что чтение старого файла настроек на новых серверах пропускается, чтобы избежать ложных ошибок загрузки.

«Пропускается» означает, что часть настроек не применится. Без ошибки, без предупреждения. Серверы MCP не подключатся, форматтеры не запустятся, а причина будет искаться долго.

ARG OPENCODE_VERSION=1.18.3
RUN curl -fsSL https://opencode.ai/install | VERSION=${OPENCODE_VERSION} bash
RUN opencode --version

Последняя строка — проверка на этапе сборки: если установщик отдаст не ту версию, это выяснится сразу, а не через неделю работы.

Обновление при такой схеме становится осознанным шагом: поменять значение аргумента, пересобрать, проверить opencode mcp list. Если серверы отвалились — вернуть прежнее значение и пересобрать, откат мгновенный.

Пароль веб-интерфейса обязателен

При старте без пароля сервер выводит предупреждение.

Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.

Отнестись к нему стоит серьёзнее, чем к обычному предупреждению. Веб-интерфейс агента с правами на редактирование файлов и выполнение команд — это фактически доступ к оболочке на машине с проектами. Если порт слушает все интерфейсы, доступ имеет любое устройство в сети, включая гостевую зону Wi-Fi, если она не изолирована.

echo "OPENCODE_SERVER_PASSWORD=$(openssl rand -base64 32 | tr -d '/+=' | head -c 40)" >> .env

Деталь, которая сбивает с толку при первом входе: браузер покажет окно с двумя полями — имя пользователя и пароль. Имени в настройках нет и быть не может, сервер проверяет только пароль. Поле имени рисует сам браузер, потому что базовая проверка подлинности по стандарту требует пару значений. В поле имени вводится произвольная строка.

Базовая проверка подлинности передаёт пароль в открытом виде. Внутри локальной сети это условно допустимо, но публикация наружу возможна только через обратный прокси с сертификатом TLS.

Настройка Nginx для потоковой передачи

При публикации агента через обратный прокси три директивы решают проблему, которая иначе выглядит как «модель медленно отвечает».

proxy_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;

Без первой строки ответ приходит не по частям, а крупными блоками с задержкой — субъективно ощущается как торможение, хотя модель работает нормально. Вторая и третья не дают обрываться длинным задачам, где агент десятки минут выполняет цепочку команд.

Проверка после развёртывания

Один вызов, показывающий состояние всей сборки: версия агента, доступность внешней сети через прокси, связь с демоном Docker, наличие браузеров и инструментов.

docker exec -it opencode bash -c '
echo "=== Версия ==="
opencode --version
echo; echo "=== Прокси наружу ==="
curl -s -o /dev/null -w "api.anthropic.com: %{http_code}\n" https://api.anthropic.com
curl -s -o /dev/null -w "github.com:        %{http_code}\n" https://github.com
echo; echo "=== Docker ==="
docker version --format "сервер: {{.Server.Version}}"
echo; echo "=== Playwright ==="
ls /ms-playwright/
echo; echo "=== Инструменты ==="
rg --version | head -1; fd --version; uvx --version; bun --version
'
docker exec -it opencode opencode mcp list
docker exec -it opencode docker run --rm hello-world

Код 404 от api.anthropic.com — норма: у корневого пути нет обработчика. Значение имеет сам факт ответа. Отсутствие связи выглядит как 000.

Ожидаемый результат: четыре сервера MCP со статусом подключения, версия демона Docker, каталоги браузеров Playwright, отработавший hello-world.

Полные тексты файлов

Все файлы размещаются в /opt/opencode. Ключи и пароль вынесены в .env, в остальных файлах секретов нет.

Dockerfile

FROM debian:bookworm-slim

ENV DEBIAN_FRONTEND=noninteractive
ENV LANG=C.UTF-8

# Прокси только на время сборки: в последних слоях переменные сбрасываются,
# в рантайме прокси задаётся заново через docker-compose.yml.
ARG BUILD_PROXY=
ARG NO_PROXY=localhost,127.0.0.1,::1,dind,proxy,opencode,xray
ENV http_proxy=${BUILD_PROXY} \
    https_proxy=${BUILD_PROXY} \
    HTTP_PROXY=${BUILD_PROXY} \
    HTTPS_PROXY=${BUILD_PROXY} \
    NO_PROXY=${NO_PROXY} \
    no_proxy=${NO_PROXY}

# apt не подхватывает переменные окружения для https-репозиториев надёжно —
# прокси задаётся явно в его файле настроек. npm и pip переменные окружения
# читают сами (важно лишь, что прокси именно HTTP, а не socks5).
RUN if [ -n "${BUILD_PROXY}" ]; then \
      printf 'Acquire::http::Proxy "%s";\nAcquire::https::Proxy "%s";\n' \
        "${BUILD_PROXY}" "${BUILD_PROXY}" > /etc/apt/apt.conf.d/00proxy; \
    fi

# ---------- Системные пакеты ----------
RUN apt-get update && apt-get install -y --no-install-recommends \
      curl wget ca-certificates gnupg git unzip \
      build-essential pkg-config \
      ripgrep fd-find fzf jq \
      python3 python3-pip python3-venv \
      postgresql-client \
      imagemagick poppler-utils pandoc \
      dnsutils mtr-tiny \
      tmux htop ncdu nano procps psmisc lsof \
    && ln -sf "$(which fdfind)" /usr/local/bin/fd \
    && rm -rf /var/lib/apt/lists/*

# ---------- Node.js 22 ----------
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
    && apt-get install -y --no-install-recommends nodejs \
    && rm -rf /var/lib/apt/lists/*

# ---------- Bun (нужен для bunx-модулей и серверов MCP) ----------
RUN curl -fsSL https://bun.sh/install | bash
ENV BUN_INSTALL="/root/.bun"
ENV PATH="/root/.bun/bin:${PATH}"

# ---------- Docker CLI + compose (демон снаружи, в контейнере-соседе) ----------
RUN install -m 0755 -d /etc/apt/keyrings \
    && curl -fsSL https://download.docker.com/linux/debian/gpg \
       -o /etc/apt/keyrings/docker.asc \
    && chmod a+r /etc/apt/keyrings/docker.asc \
    && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian bookworm stable" \
       > /etc/apt/sources.list.d/docker.list \
    && apt-get update \
    && apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin docker-buildx-plugin \
    && rm -rf /var/lib/apt/lists/*

# ---------- GitHub CLI (необязательно: пропускается, если сеть недоступна) ----------
RUN set -eux; \
    ( curl -fsSL --retry 3 --retry-delay 2 --connect-timeout 15 --max-time 120 \
        https://cli.github.com/packages/githubcli-archive-keyring.gpg \
        -o /usr/share/keyrings/githubcli-archive-keyring.gpg \
      && chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg \
      && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
         > /etc/apt/sources.list.d/github-cli.list \
      && apt-get update \
      && apt-get install -y --no-install-recommends gh \
    ) || echo "ПРЕДУПРЕЖДЕНИЕ: gh не установлен (сеть недоступна). Установить позже вручную."; \
    rm -rf /var/lib/apt/lists/*

# ---------- Инструменты Python ----------
RUN pip3 install --break-system-packages --no-cache-dir \
      ruff pyright uv pgcli httpie

# uv/uvx понадобятся для сервера MCP Serena
ENV PATH="/root/.local/bin:${PATH}"

# ---------- Инструменты Node (языковые серверы, форматтеры) ----------
RUN npm install -g --no-fund --no-audit \
      typescript \
      typescript-language-server \
      prettier \
      eslint

# ---------- Playwright + Chromium с системными зависимостями ----------
# Ставится ОДИН РАЗ на этапе сборки. Браузеры лежат в /ms-playwright.
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
RUN npm install -g --no-fund --no-audit playwright @playwright/test \
    && npx --yes playwright install --with-deps chromium \
    && rm -rf /var/lib/apt/lists/*

# ---------- Серверы MCP: прогрев кэша ----------
# Загружаются заранее, чтобы первая сессия не ждала скачивание из сети.
RUN bunx --yes @playwright/mcp@latest --help > /dev/null 2>&1 || true
RUN uvx --from git+https://github.com/oraios/serena serena --help > /dev/null 2>&1 || true

# ---------- OpenCode ----------
# Версия зафиксирована осознанно: старая схема настроек (mcp.{имя},
# permission объектом) применяется целиком. Обновление = поменять ARG
# и пересобрать, а не получить сюрприз в рантайме.
ARG OPENCODE_VERSION=1.18.3
RUN curl -fsSL https://opencode.ai/install | VERSION=${OPENCODE_VERSION} bash
ENV PATH="/root/.opencode/bin:${PATH}"

RUN opencode --version

# Файл настроек прокси для apt убирается из образа: в рантайме прокси
# задаётся переменными окружения из docker-compose.yml.
RUN rm -f /etc/apt/apt.conf.d/00proxy

# Переменные прокси из ARG сбрасываются: в рантайме они задаются заново
# через environment в docker-compose.yml (http://proxy:8118).
ENV http_proxy= \
    https_proxy= \
    HTTP_PROXY= \
    HTTPS_PROXY=

WORKDIR /workspace

EXPOSE 4096

# hostname 0.0.0.0 обязателен внутри контейнера,
# иначе снаружи порт будет недоступен
CMD ["opencode", "serve", "--hostname", "0.0.0.0", "--port", "4096"]

docker-compose.yml

services:
  # Отдельный экземпляр xray. Использует существующий файл конфигурации,
  # но контейнер свой — независимость от других стеков на хосте.
  xray:
    image: teddysun/xray:latest
    container_name: opencode-xray
    restart: unless-stopped
    volumes:
      - /opt/xray/config.json:/etc/xray/config.json:ro
    networks:
      - opencode-net
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

  # Преобразователь HTTP -> SOCKS5.
  # Нужен потому, что npm и часть инструментов не умеют socks5,
  # а xray отдаёт именно socks5. gost принимает HTTP и пересылает в xray.
  proxy:
    image: gogost/gost:latest
    container_name: opencode-proxy
    restart: unless-stopped
    depends_on:
      - xray
    command: -L http://:8118 -F socks5://xray:10808
    ports:
      # Публикация на локальный адрес хоста: на этапе docker build сети compose
      # ещё нет, имена xray/proxy не резолвятся, поэтому сборка идёт
      # через 127.0.0.1:8118.
      - "127.0.0.1:8118:8118"
    networks:
      - opencode-net
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

  # Отдельный демон Docker — полностью изолирован от хоста.
  # Агент внутри opencode создаёт контейнеры ЗДЕСЬ, а не на хосте.
  dind:
    image: docker:27-dind
    container_name: opencode-dind
    privileged: true   # требование dind; изоляция от хоста сохраняется
    restart: unless-stopped
    depends_on:
      - proxy
    environment:
      - DOCKER_TLS_CERTDIR=   # TLS не нужен: трафик не покидает сеть compose
      # Прокси нужен демону для загрузки образов из реестра
      - HTTP_PROXY=http://proxy:8118
      - HTTPS_PROXY=http://proxy:8118
      - http_proxy=http://proxy:8118
      - https_proxy=http://proxy:8118
      - NO_PROXY=localhost,127.0.0.1,::1,dind,proxy,xray,opencode
      - no_proxy=localhost,127.0.0.1,::1,dind,proxy,xray,opencode
    volumes:
      - ./data/dind:/var/lib/docker   # образы и слои переживают перезапуск
    networks:
      - opencode-net

  opencode:
    build:
      context: .
      # Сборка через сеть хоста, чтобы получить прокси по 127.0.0.1:8118.
      # Контейнеры xray и proxy должны быть запущены ДО первой сборки.
      network: host
      args:
        OPENCODE_VERSION: "1.18.3"
        BUILD_PROXY: "http://127.0.0.1:8118"
    container_name: opencode
    restart: unless-stopped
    depends_on:
      - dind
      - proxy
    ports:
      # Порт слушает все интерфейсы: обратный прокси работает на другой машине,
      # сам хост наружу напрямую не выставлен.
      # Защита — OPENCODE_SERVER_PASSWORD, он обязателен.
      - "4096:4096"
    environment:
      - OPENCODE_SERVER_PASSWORD=${OPENCODE_SERVER_PASSWORD}
      - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
      - PIXELLAB_API_KEY=${PIXELLAB_API_KEY}
      - DOCKER_HOST=tcp://dind:2375
      # Прокси в рантайме: интерфейсы моделей, MCP, npm, git
      - HTTP_PROXY=http://proxy:8118
      - HTTPS_PROXY=http://proxy:8118
      - http_proxy=http://proxy:8118
      - https_proxy=http://proxy:8118
      # КРИТИЧНО: dind, proxy, xray в обход прокси,
      # иначе docker CLI не найдёт демон
      - NO_PROXY=localhost,127.0.0.1,::1,dind,proxy,xray,opencode
      - no_proxy=localhost,127.0.0.1,::1,dind,proxy,xray,opencode
    volumes:
      - ./opencode.jsonc:/root/.config/opencode/opencode.jsonc:ro
      - ./workspace:/workspace
      - ./ssh:/root/.ssh:ro
      - ./gitconfig:/root/.gitconfig:ro
      - ./data/opencode:/root/.local/share/opencode
      - ./data/bun-cache:/root/.bun/install/cache
      - ./data/uv-cache:/root/.cache/uv
    networks:
      - opencode-net
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

networks:
  opencode-net:
    driver: bridge

opencode.jsonc

{
  "$schema": "https://opencode.ai/config.json",
  "server": {
    "port": 4096,
    "hostname": "0.0.0.0"
  },
  "default_agent": "build",
  "autoupdate": false,
  "lsp": {
    "python": {
      "command": ["pyright-langserver", "--stdio"],
      "extensions": [".py", ".pyi"]
    },
    "typescript": {
      "command": ["typescript-language-server", "--stdio"],
      "extensions": [".ts", ".tsx", ".js", ".jsx"]
    }
  },
  "formatter": {
    "ruff": {
      "command": ["ruff", "format", "$FILE"],
      "extensions": [".py"]
    },
    "prettier": {
      "command": ["prettier", "--write", "$FILE"],
      "extensions": [".ts", ".tsx", ".js", ".jsx", ".json", ".css"]
    }
  },
  "mcp": {
    "context7": {
      "type": "remote",
      "url": "https://mcp.context7.com/mcp",
      "enabled": true
    },
    "serena": {
      "type": "local",
      "command": [
        "uvx", "--from", "git+https://github.com/oraios/serena",
        "serena", "start-mcp-server",
        "--context", "claude-code",
        "--project", "/workspace/project",
        "--transport", "stdio"
      ],
      "enabled": true
    },
    "pixellab": {
      "type": "remote",
      "url": "https://api.pixellab.ai/mcp",
      "enabled": true,
      "oauth": false,
      "headers": {
        "Authorization": "Bearer {env:PIXELLAB_API_KEY}"
      }
    },
    "playwright": {
      "type": "local",
      "command": ["bunx", "@playwright/mcp@latest"],
      "enabled": true
    }
  },
  "instructions": ["AGENTS.md", ".cursor/rules/*.md"],
  "permission": {
    "edit": "allow",
    "bash": {
      "rm *": "ask",
      "git push *": "ask",
      "*": "allow"
    }
  },
  "compaction": {
    "auto": true
  }
}

AGENTS.md

Копируется в корень каждого проекта внутри workspace/.

# Окружение

## Предустановленный инструментарий — НЕ переустанавливать

В образе уже установлено глобально. Не выполняй `apt install`, `npm install -g`,
`pip install` или `playwright install` для перечисленного ниже — просто используй.

**Поиск и файлы:** `rg` (ripgrep), `fd`, `fzf`, `jq`
**Python:** python3, `ruff`, `pyright`, `uv`/`uvx`, `pgcli`, `httpie`
**Node:** node 22, npm, `bun`/`bunx`, `tsc`, `typescript-language-server`, `prettier`, `eslint`
**Базы:** `psql` (postgresql-client)
**Docker:** `docker`, `docker compose`, `docker buildx` — через изолированный dind
**Git:** `git`, `gh` (GitHub CLI)
**Документы:** `convert` (ImageMagick), `pdftotext`/`pdftoppm` (poppler), `pandoc`
**Сеть:** `curl`, `wget`, `dig`, `mtr`
**Прочее:** `tmux`, `htop`, `ncdu`, `nano`

## Playwright

Playwright и Chromium **уже установлены** на этапе сборки образа.
Браузеры лежат в `/ms-playwright` (переменная `PLAYWRIGHT_BROWSERS_PATH`).

НЕ запускай `npm install playwright` и `playwright install`.
Если получаешь «browser not found» — сначала проверь `ls /ms-playwright/`,
и только потом рассматривай переустановку.

Использование:
    npx playwright test

## Docker

Docker работает через **изолированный dind** (`DOCKER_HOST=tcp://dind:2375`).
Демон Docker находится в отдельном контейнере, а не на хосте.

Важные следствия:
- Контейнеры, которые ты создаёшь, **не видят файлы хоста** и не видят `/workspace`.
- Bind-mount вида `-v /workspace/project:/app` **не сработает** — для демона dind
  этих путей не существует.
- Для передачи кода используй `docker build` (контекст отправляется демону архивом,
  это работает корректно) или `docker cp`.
- `docker compose up` для интеграционных тестов (Postgres, Redis, MinIO) работает
  нормально — всё поднимается внутри изолированной сети dind.

## Рабочая директория

Проекты живут в `/workspace` (на хосте это `/opt/opencode/workspace`).
Правь код здесь — изменения сразу видны на хосте.

## Версия OpenCode

Версия зафиксирована в образе (`ARG OPENCODE_VERSION`), автообновление отключено.
Не пытайся обновить OpenCode изнутри контейнера — это ломает воспроизводимость
образа и откатится при следующей пересборке. Обновление делается на хосте:
поменять версию в `docker-compose.yml` → `docker compose up -d --build`.

.env.example

# Ключи API
ANTHROPIC_API_KEY=sk-ant-...
PIXELLAB_API_KEY=...

# Пароль веб-интерфейса OpenCode.
# ОБЯЗАТЕЛЕН: порт 4096 слушает все интерфейсы, а агент имеет права
# edit/bash над проектами — без пароля это открытая оболочка для локальной сети.
# Сгенерировать:  openssl rand -base64 32 | tr -d '/+=' | head -c 40
OPENCODE_SERVER_PASSWORD=

bootstrap.sh

Создаёт структуру каталогов, переносит ключи и проверяет конфигурацию Xray.

#!/usr/bin/env bash
set -euo pipefail

BASE=/opt/opencode

echo "=== Проверка конфигурации xray ==="
if [ ! -f /opt/xray/config.json ]; then
  echo "  ОШИБКА: /opt/xray/config.json не найден."
  echo "  Файл нужен — контейнер xray читает его в режиме только для чтения."
  exit 1
fi
echo "  /opt/xray/config.json найден"

# Проверка наличия socks-инбаунда на порту 10808
if grep -q '10808' /opt/xray/config.json; then
  echo "  Порт 10808 в конфигурации присутствует"
else
  echo "  ВНИМАНИЕ: порт 10808 в конфигурации не найден."
  echo "  Уточнить, какой порт слушает socks-инбаунд, и поправить"
  echo "  строку command у сервиса proxy в docker-compose.yml."
fi

echo "=== Создание структуры каталогов в ${BASE} ==="
mkdir -p "${BASE}"/{workspace,ssh,data/{dind,opencode,bun-cache,uv-cache}}

echo "=== Копирование ключей SSH для работы git изнутри контейнера ==="
for k in id_ed25519 id_rsa; do
  [ -f /root/.ssh/$k ] && cp /root/.ssh/$k /root/.ssh/$k.pub "${BASE}/ssh/" 2>/dev/null || true
done
[ -f /root/.ssh/known_hosts ] && cp /root/.ssh/known_hosts "${BASE}/ssh/"
chmod 700 "${BASE}/ssh"
chmod 600 "${BASE}/ssh/"* 2>/dev/null || true

echo "=== Копирование gitconfig ==="
if [ -f /root/.gitconfig ]; then
  cp /root/.gitconfig "${BASE}/gitconfig"
else
  touch "${BASE}/gitconfig"
  echo "  /root/.gitconfig не найден — создан пустой файл"
fi

echo "=== Подготовка .env ==="
if [ ! -f "${BASE}/.env" ]; then
  cp "${BASE}/.env.example" "${BASE}/.env" 2>/dev/null || touch "${BASE}/.env"
  chmod 600 "${BASE}/.env"
  echo "  Создан ${BASE}/.env — вписать ключи"
fi

echo
echo "=== Структура готова ==="
echo
echo "Дальше по шагам:"
echo "  1. nano ${BASE}/.env"
echo "  2. cd ${BASE}"
echo "  3. docker compose up -d xray proxy       — сначала только сеть"
echo "  4. проверка прокси:"
echo "       curl -x http://127.0.0.1:8118 -s -o /dev/null \\"
echo "            -w 'proxy: %{http_code}\\n' https://cli.github.com/"
echo "  5. docker compose up -d --build          — сборка через этот прокси"
echo "  6. перенести проекты в ${BASE}/workspace/"

Частые вопросы

Можно ли обойтись без gost и настроить socks5 напрямую?

Для интерфейсов моделей и Python-инструментов — да. Но npm, а следовательно установка языковых серверов, форматтеров и Playwright, работает только с прокси по HTTP. Преобразователь протоколов остаётся единственным способом покрыть весь инструментарий одной настройкой.

Насколько dind медленнее хостового Docker?

Разница в производительности исполнения контейнеров практически отсутствует — используется то же ядро. Заметна только изоляция кэша слоёв: образы, уже загруженные на хосте, во внутреннем демоне придётся получить заново. После первой сборки кэш сохраняется в ./data/dind и переживает перезапуски.

Что делать, если после обновления версии серверы MCP не подключились?

Проверить opencode mcp list. Пустой список или отсутствие части серверов означает смену схемы файла настроек: mcp.{имя} нужно перенести в mcp.servers.{имя}, а объект permission — в массив permissions. Быстрый откат — вернуть прежнее значение ARG OPENCODE_VERSION и пересобрать образ.

Как перенести сборку на другую машину?

Остановить сервисы, заархивировать каталог /opt/opencode целиком, распаковать на новом хосте, проверить наличие /opt/xray/config.json и выполнить docker compose up -d xray proxy, затем docker compose up -d --build. Именно ради этого сценария постоянные данные хранятся в bind mount, а не в именованных томах.

Обязателен ли пароль, если порт не выставлен в интернет?

Да. Порт 4096 слушает все интерфейсы, а значит доступен любому устройству локальной сети, включая гостевой сегмент Wi-Fi, если он не изолирован на уровне маршрутизатора. Веб-интерфейс агента с правами edit и bash эквивалентен доступу к оболочке.

Заключение

Такая конфигурация даёт четыре измеримых эффекта: сессии стартуют с готовым окружением без доустановки пакетов, агент свободно запускает контейнеры для сборок и тестов, не имея доступа к хостовому демону Docker, весь трафик — запросы к моделям, загрузка пакетов, работа с репозиториями — идёт через прокси без отдельной настройки в каждом инструменте, а зафиксированная версия исключает тихую поломку файла настроек при обновлении. Отдельным результатом становится ревизия открытых портов на хосте: аудит вывода ss -tlnp при настройке сети регулярно выявляет службы, слушающие все интерфейсы без необходимости, — и это тот же класс уязвимости, через который на самостоятельно развёрнутую инфраструктуру попадают майнеры.

Оцените статью
ctrllife.ru
Подписаться
Уведомить о
guest
0 комментариев
Старые
Новые Популярные
0
Оставьте комментарий! Напишите, что думаете по поводу статьи.x