imgbot: стек из двух сервисов вместо процессов на хосте
Проект агента (~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/<id>
Неочевидное, из-за чего это ломается молча (в 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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
bf4708b775
commit
a65d6a249e
@@ -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()"]
|
||||||
@@ -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`, иначе разойдётся ревизия браузера.
|
||||||
@@ -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/<id>) в
|
||||||
|
# одном процессе. Раньше это были два процесса на двух портах, оба
|
||||||
|
# захардкоженные на 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
|
||||||
Reference in New Issue
Block a user