From a65d6a249ed60f372ed73665be75c94d9067fe0a Mon Sep 17 00:00:00 2001 From: gilfanovr Date: Tue, 8 Sep 2026 15:26:16 +0300 Subject: [PATCH] =?UTF-8?q?imgbot:=20=D1=81=D1=82=D0=B5=D0=BA=20=D0=B8?= =?UTF-8?q?=D0=B7=20=D0=B4=D0=B2=D1=83=D1=85=20=D1=81=D0=B5=D1=80=D0=B2?= =?UTF-8?q?=D0=B8=D1=81=D0=BE=D0=B2=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE?= =?UTF-8?q?=20=D0=BF=D1=80=D0=BE=D1=86=D0=B5=D1=81=D1=81=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D1=85=D0=BE=D1=81=D1=82=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Проект агента (~tgclaude/imgbot) работал тремя голыми python-процессами, запущенными руками из SSH-сессии: без юнита, без restart-политики, при Linger=no. Держались только на KillUserProcesses=no и не пережили бы перезагрузку, причём молча. Один образ на базе playwright/python:v1.62.0-noble (версия пакета playwright обязана совпадать с тегом, иначе разъезжается ревизия chromium), два сервиса: - imgbot-daemon — APScheduler: refresh 30 мин, analyze 5 мин - imgbot-http — объединил прежние mcp-http (8765) и file-server (8766): MCP как под-приложение Starlette плюс маршрут /photo/ Неочевидное, из-за чего это ломается молча (в compose есть комментарии): - данные монтируются в тот же путь, что на хосте: images.file_path в SQLite абсолютный, и его читают обе стороны - корней хранения может быть больше одного — проверять все префиксы file_path, а не только data_dir - HOME обязателен: collector.py вызывает Path.home(), а uid 1001 не имеет записи в /etc/passwd образа - у MCP своя защита от DNS-rebinding: без IMGBOT_ALLOWED_HOSTS отдаёт 421 на любой Host кроме localhost Порт не публикуется на хост, сервис доступен из общей docker-сети с claude-channels — агент наконец ходит по MCP, а не по SSH. Co-Authored-By: Claude Opus 5 (1M context) --- stacks/imgbot/Dockerfile | 40 +++++++++++ stacks/imgbot/README.md | 68 +++++++++++++++++++ stacks/imgbot/docker-compose.yml | 112 +++++++++++++++++++++++++++++++ 3 files changed, 220 insertions(+) create mode 100644 stacks/imgbot/Dockerfile create mode 100644 stacks/imgbot/README.md create mode 100644 stacks/imgbot/docker-compose.yml diff --git a/stacks/imgbot/Dockerfile b/stacks/imgbot/Dockerfile new file mode 100644 index 0000000..11032e6 --- /dev/null +++ b/stacks/imgbot/Dockerfile @@ -0,0 +1,40 @@ +# Среда запуска для imgbot-daemon. Сам код — не наш: проект живёт у агента +# в /home/tgclaude/imgbot и правится им же. Здесь только окружение. +# +# База — официальный образ playwright с тегом ровно под версию из uv.lock +# проекта (playwright 1.62.0): в нём уже есть chromium и его системные +# библиотеки. Именно поэтому в контейнере не нужен костыль +# ~tgclaude/chrome-libs/extract/usr/lib/... , которым это лечилось на хосте. +FROM mcr.microsoft.com/playwright/python:v1.62.0-noble + +# В этом образе лежат только браузеры (/ms-playwright, PLAYWRIGHT_BROWSERS_PATH +# уже выставлен), а python-пакета playwright нет — его ставим сами, и версию +# фиксируем ровно 1.62.0 = версия из uv.lock проекта = ревизия chromium-1234, +# вкомпилированная в этот тег. Разъезд версии пакета и образа даёт +# "Executable doesn't exist" при launch(), поэтому теги менять только парой. +# +# Остальное — по фактическим импортам обоих сервисов из этого образа: +# collector/fetcher → playwright +# storage/analyze → pillow +# daemon → apscheduler (tzlocal подтянётся зависимостью) +# app/mcp_server → mcp (с ним приходят starlette, uvicorn, httpx) +# Версии — из uv.lock проекта, чтобы образ и хостовый venv не разъезжались. +RUN pip install --no-cache-dir \ + playwright==1.62.0 \ + pillow==12.3.0 \ + apscheduler==3.11.3 \ + mcp==1.29.1 + +# Исходники НЕ копируются в образ — они монтируются снаружи (см. compose), +# чтобы правки в проекте применялись рестартом контейнера, без пересборки. +# +# HOME задан не случайно: collector.py безусловно вызывает Path.home(), а uid +# 1001 внутри образа не имеет записи в /etc/passwd — без HOME expanduser +# упадёт с RuntimeError. +ENV PYTHONPATH=/app/src \ + PYTHONUNBUFFERED=1 \ + IMGBOT_DATA_DIR=/home/tgclaude/imgbot-data \ + HOME=/tmp + +WORKDIR /app +CMD ["python3", "-c", "from imgbot.daemon import main; main()"] diff --git a/stacks/imgbot/README.md b/stacks/imgbot/README.md new file mode 100644 index 0000000..91acc1c --- /dev/null +++ b/stacks/imgbot/README.md @@ -0,0 +1,68 @@ +# imgbot-daemon + +Контейнер для фонового демона проекта **imgbot** — headless-поиска и скачивания +картинок. Проект принадлежит агенту, живёт в `/home/tgclaude/imgbot` и правится +им же; здесь только среда запуска и присмотр. + +## Зачем + +До этого демон работал как голый процесс на хосте: запущен руками из +SSH-сессии, `PPID=1`, без юнита и restart-политики, `Linger=no` у пользователя. +Держался только потому, что в Ubuntu `KillUserProcesses=no` — **после +перезагрузки не поднялся бы**, и сигнала об этом не было бы. Плюс его 15% +ядра не были ничем ограничены и не были видны мониторингу. + +Контейнер даёт: автозапуск и `restart: unless-stopped`, потолки CPU/памяти, +видимость в cAdvisor (а значит и в Grafana), и снимает костыль с руками +распакованными библиотеками chromium. + +## Что тут НЕ запускается и почему + +Два других процесса проекта — `imgbot-mcp-http` (порт 8765) и +`imgbot-file-server` (8766) — оставлены вне контейнеров сознательно: + +- оба **жёстко забиндены в коде на `172.22.0.1`** — адрес шлюза docker-сети + `openclaw_default`. В контейнере такой адрес не поднять, а сама привязка + ломается при пересоздании сети; +- до них всё равно никто не дотягивается: единственный живой потребитель — + агент в контейнере `claude-channels` — находится в другой docker-сети и + ходит через SSH, а не по HTTP (проверено: `curl` из контейнера даёт `000`); +- `imgbot-mcp-http` вообще без авторизации, в отличие от file-server с его + Bearer-токеном. + +Их имеет смысл контейнеризовать только вместе с правкой бинда на `0.0.0.0` +(две строки: `HOST` в `file_server.py`, `host=` в `mcp_server.py`) и уже в +общей docker-сети с `claude-channels` — тогда агент получит MCP по токену +вместо SSH. Это решение владельца кода, не инфраструктуры. + +## Запуск + +Собрать и поднять (на сервере каталог стека — `~/imgbot-daemon/`, чтобы не путался с самим проектом в `~tgclaude/imgbot`): + + docker compose build + # погасить запущенный руками демон, иначе будет два и двойной analyze: + pkill -u tgclaude -f imgbot-daemon + docker compose up -d + docker logs -f imgbot-daemon + +Проверка, что расписание пошло: в `~tgclaude/imgbot-data/daemon.log` должны +появляться строки `_job_analyze ... executed successfully` каждые 5 минут и +`_job_refresh` каждые 30. + +## Откат + + docker compose down + # и вернуть как было, из-под tgclaude: + cd ~/imgbot && .venv/bin/imgbot-daemon >> ~/imgbot-data/daemon.log 2>&1 & + +## Важно для владельца кода + +Перезапуск демона теперь `docker restart imgbot-daemon`, а не `pkill` плюс +ручной старт. Если запустить процесс руками при живом контейнере — получатся +два демона, которые будут конкурировать за одни и те же строки SQLite +(в проекте это уже случалось с cron без `flock` и выглядело как зависание). + +Правки кода пересборки не требуют: исходники монтируются read-only, достаточно +`docker restart imgbot-daemon`. Пересобирать нужно только при изменении +зависимостей в `pyproject.toml` — и тогда версию playwright в `Dockerfile` +надо держать совпадающей с `uv.lock`, иначе разойдётся ревизия браузера. diff --git a/stacks/imgbot/docker-compose.yml b/stacks/imgbot/docker-compose.yml new file mode 100644 index 0000000..b36bddf --- /dev/null +++ b/stacks/imgbot/docker-compose.yml @@ -0,0 +1,112 @@ +name: imgbot + +services: + imgbot-daemon: + build: . + image: imgbot-daemon:local + container_name: imgbot-daemon + restart: unless-stopped + + # uid:gid пользователя tgclaude: .env лежит с mode 600, данные принадлежат + # ему же. Под другим uid контейнер не прочитает ключи и не запишет картинки. + user: "1001:1001" + + volumes: + # Исходники read-only: правки агента применяются `docker restart`. + - /home/tgclaude/imgbot:/app:ro + # Данные: images/, manifest.db, куки google_state/reddit_state, логи. + # ВАЖНО: путь внутри контейнера обязан совпадать с путём на хосте. + # И проверять надо ВСЕ префиксы file_path в манифесте, а не только этот + # (был второй корень — легаси-архив ~tgclaude/images, снесён 2026-09-08): + # select substr(file_path,1,28), count(*) from images group by 1; + # В SQLite `images.file_path` лежит абсолютным путём хоста, и его читают + # как этот контейнер, так и оставшийся на хосте imgbot-file-server. + # Любое другое место монтирования ломает обе стороны: analyze падает с + # FileNotFoundError на старых строках, а новые строки становятся + # нечитаемыми для хостовых процессов. + - /home/tgclaude/imgbot-data:/home/tgclaude/imgbot-data + # Таймзона хоста: без этого контейнер пишет в общий daemon.log в UTC, + # а строки от прежнего хостового запуска — в CEST, и лог мешает два пояса. + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + + environment: + IMGBOT_DATA_DIR: /home/tgclaude/imgbot-data + PYTHONPATH: /app/src + HOME: /tmp + # .env подхватывается самим приложением: config.py ищет его как + # parents[2]/.env относительно пакета, то есть /app/.env через монтирование. + # Через env_file не пробрасываем, чтобы ключи не размножать. + + # chromium падает на дефолтных 64 МБ /dev/shm + shm_size: 1gb + + # Демон ровно жрёт ~15% ядра постоянно (analyze каждые 5 мин, 3 vision-запроса + # на картинку). Потолок — чтобы refresh с playwright не разогнался на все 8. + cpus: "2.0" + mem_limit: 3g + + # Лог остаётся там, где его читает агент, и дублируется в `docker logs`. + command: > + sh -c 'python3 -c "from imgbot.daemon import main; main()" 2>&1 | tee -a /home/tgclaude/imgbot-data/daemon.log' + + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + + # Объединённый HTTP-сервис: MCP (/mcp) и раздача картинок (/photo/) в + # одном процессе. Раньше это были два процесса на двух портах, оба + # захардкоженные на 172.22.0.1 — адрес шлюза docker-сети openclaw_default. + imgbot-http: + build: . + image: imgbot-daemon:local + container_name: imgbot-http + restart: unless-stopped + user: "1001:1001" + + volumes: + - /home/tgclaude/imgbot:/app:ro + - /home/tgclaude/imgbot-data:/home/tgclaude/imgbot-data + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + + environment: + IMGBOT_DATA_DIR: /home/tgclaude/imgbot-data + PYTHONPATH: /app/src + HOME: /tmp + # URL, который сервис выдаёт в image_url: имя контейнера в общей + # docker-сети с claude-channels, откуда его и качает агент. + IMGBOT_PUBLIC_BASE: http://imgbot-http:8765 + + # Порт НЕ публикуется на хост: сервис доступен только из docker-сетей, + # к которым подключён контейнер (и с самой машины). Авторизации у него + # нет сознательно — см. README проекта. + expose: + - "8765" + + networks: + - default + - claude-channels + + # request_photos гоняет playwright внутри этого же процесса, поэтому + # лимиты и shm такие же, как у демона. + shm_size: 1gb + cpus: "2.0" + mem_limit: 3g + + command: python3 -c "from imgbot.app import main; main()" + + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + +networks: + # Сеть проекта claude-channels: агент внутри того контейнера обращается к + # imgbot по имени imgbot-http. Тот же приём, что у gitea с monitoring. + claude-channels: + external: true + name: claude-channels_default