- BrainTools - https://www.braintools.ru -

Два года, один человек, 66 контейнеров: как я построил AI‑платформу на железе под столом

Сверху вниз: Wi-Fi-роутер, мини-ПК — нода A (платформа, 4 ТБ NVMe), MikroTik, NAS с бэкапами и данными Gitea (да, тот самый из грабли №6), ИБП. Слева — нода B: башня с RTX 3090. Второй ИБП — её.

Сверху вниз: Wi‑Fi‑роутер, мини‑ПК — нода A (платформа, 4 ТБ NVMe), MikroTik, NAS с бэкапами и данными Gitea (да, тот самый из грабли № 6), ИБП. Слева — нода B: башня с RTX 3090. Второй ИБП — её.

26 августа 2024 года я получил у BotFather первый токен и написал кривой телеграм‑бот с одной моделью — обычная история «хочу ChatGPT без танцев с бубном, сделаю себе сам». Бот был честно плохой: одна модель, никакого контекста, падал от длинных сообщений. Но он работал, им начали пользоваться дети и знакомые, и я решил «немного доделать».

«Немного доделать» продолжается второй год. Сейчас это платформа с веб‑приложением, шестью каналами доставки (Telegram, VK, MAX, Discord, веб и — для уведомлений и результатов долгих задач — email), биллингом, голосовым агентом и RAG по документам. А под ней — 66 контейнеров на двух нодах, BGP‑маршрутизация, собственный CI, хелпдеск и GPU‑нода с локальными моделями. Всё на железе, которое стоит под столом и потребляло меньше, чем игровая приставка, — до недавнего появления второй ноды с 3090; теперь приставка нервно курит. Эта статья — про то, во что превращается домашняя инфраструктура, когда backend‑разработчик два года не может остановиться.

Пишу это по двум причинам. Во‑первых, когда я начинал, мне отчаянно не хватало такой статьи — целостной картины, как это выглядит, когда селф‑хостишь ВСЁ. Во‑вторых, я почти наверняка делаю что‑то не так, и комментарии Хабра — самый быстрый способ об этом узнать. Не стесняйтесь.

Почему не облако

Экономика простая. AI‑платформе нужны: постоянно живой backend, очереди, воркеры, две БД, объектное хранилище, векторная база и — в идеале — GPU для локального инференса. Посчитайте это в ценах любого облака с GPU‑инстансами: по моим прикидкам получается от 40–80 тысяч в месяц при нуле пользователей (поправьте в комментариях, если у вас выходит иначе). Железо под столом — единоразовые расходы и электричество. Для соло‑проекта без инвестиций выбор делается сам.

Второй аргумент — управляемость доступа. У платформы два класса провайдеров: российские (GigaChat, YandexGPT, SpeechKit, Yandex OCR, YandexART) и зарубежные (OpenAI, Anthropic и прочие), плюс локальные модели на GPU‑ноде. Российские должны ходить напрямую — гонять их через туннель значит добавлять задержку и точку отказа на ровном месте; зарубежные — стабильно и с резервированием. Разделять это я хочу сам, на своём маршрутизаторе, а не через саппорт хостинга.

Слой 1: сеть, или почему у меня дома BGP

Начнём с самого спорного — сети. Задача: трафик к российским сервисам должен ходить напрямую (быстро и без посредников), трафик к зарубежным AI‑провайдерам — стабильно и отказоустойчиво.

Решение выглядит так:

  • Дома стоит MikroTik. К нему — два аплинка от провайдера.

  • Российский трафик уходит напрямую через ISP.

  • Зарубежный трафик маршрутизатор отправляет в ECMP‑балансировку по нескольким туннелям до VPS. Часть туннелей терминируется прямо на сервере платформы — он сам является одним из egress‑узлов собственной сети.

  • Списки «что считать зарубежным» не ведутся руками: на VPS живёт сервис, который собирает актуальные списки префиксов, рендерит из них конфиг BIRD и отдаёт маршруты на MikroTik по BGP. Обновляются сами.

Звучит оверинжинирингом? Может быть. Но с тех пор как это заработало, я ни разу не трогал маршрутизацию руками, а падения туннелей, которые я видел за это время, до пользователей не доходили — ECMP просто перебалансировал трафик.

Два года, один человек, 66 контейнеров: как я построил AI‑платформу на железе под столом - 2

Грабли № 1: первый вариант был на ручных списках маршрутов. Поддерживать их руками — работа на полставки. BGP‑фид решил это навсегда, но пришлось разобраться, как подружить BIRD с RouterOS.

Грабли № 2 (свежие, август): боты в Telegram молчали почти сутки, а MAX и VK при этом работали. Причина — не Telegram и не туннель. Ночью unattended-upgrades обновил openssl и перезапустил systemd-networkd, а networkd по умолчанию считает чужие правила policy routing своими и удаляет их (ManageForeignRoutingPolicyRules=yes). Правила, которые заворачивали трафик Telegram в туннель, исчезли; таблицы маршрутов остались — поэтому диагностика по «ip route» ничего подозрительного не показывала. Лечится одной строкой в drop‑in’е networkd (/etc/systemd/networkd.conf.d/*.conf, ManageForeignRoutingPolicyRules=no), но чтобы её написать, нужно сначала сутки не понимать, что происходит. Мораль: всё, что вы делаете с ip rule руками или своими юнитами, должно либо жить внутри networkd, либо явно запрещать ему трогать чужое.

Слой 2: платформа, или зачем одному человеку микросервисы

Классический вопрос: «зачем соло‑разработчику 66 контейнеров, возьми монолит». Отвечаю честно: ядро и есть монолит (FastAPI), а контейнеры — это нарезка по зонам отказа, а не по моде.

Каналы доставки — отдельные гейтвеи: Telegram, VK, MAX, Discord, веб‑сокеты, плюс интеграционный шлюз для n8n. Упал Telegram‑гейтвей (или Telegram в очередной раз лёг сам) — веб и VK живут. Биллинг — отдельно, потому что деньги. Медиа и файлы — отдельно, потому что это самые прожорливые и падучие части. Воркеры очередей — отдельно, потому что фоновая нагрузка не должна толкаться с запросами пользователей. Голосовой агент, веб‑поиск, детектор типов файлов, антивирус для загрузок — тоже каждый в своей коробке.

Вокруг ядра: PostgreSQL 17, Redis, MinIO (S3-совместимое хранилище), Qdrant (векторная база для RAG по документам). Схема БД — 95 alembic‑миграций, и это одна из причин, почему я боюсь её больше, чем всего остального вместе взятого (об этом — в грабли № 4).

Грабли № 3: docker из snap. Не повторяйте. Пути volumes, AppArmor‑сюрпризы и обновления в неожиданный момент. В июле я писал здесь «переезд на apt в бэклоге, трогать прод страшно». В августе переехал — и это стоило пяти алертов по диску за один вечер. Что узнал:

  • docker save пишет несжатые слои. Считать «хватит ли места» надо по docker system df, а не по размерам образов в реестре. — Новая установка Docker CE 29 (со снапа переезжал именно на чистый apt‑пакет; в новых установках Engine 29+ containerd image store включён по умолчанию) — и в нём у всех образов пересчитываются ID. Если вы где‑то пинили образы по ID — всё развалится. Я вернул старый стор через "containerd-snapshotter": false в daemon.json ради совместимости миграции; это мой выбор, а не рецепт для всех.

  • Пины по digest (image@sha256:…) не переживают save/load — RepoDigests теряются, публичные образы надо дотягивать pull’ом.

  • snap remove без --purge делает снапшот данных — то есть ещё раз забивает диск сотнями гигабайт, которые вы только что освобождали.

Грабли № 4: три миграции хранилища за лето. Началось с того, что платформа жила на китайском ноунейм‑SSD из комплекта такого же китайского мини‑ПК: он работал на скорости SATA3, постоянно перегревался и тротлил, а бесконечные алерты о температуре навели на мысль, что долго он так не продержится и в какой‑то момент тихо умрёт. Первый переезд — на 480 ГБ, которые достались в комплекте с нодой B. Хватило ненадолго: диск кончился, и второй переезд — на 4 ТБ NVMe двухфазным rsync‑клоном (система живёт, клонируется, потом короткая пауза и финальная дельта). Главный сюрприз — после ребута поднялись только контейнеры с restart: always: те, что были unless-stopped и остановлены руками перед клоном, Docker честно не тронул. 18 из 51. Теперь перед любой остановкой стека я сохраняю список запущенных контейнеров в файл. Третья миграция — PostgreSQL 15 → 17. Казалось бы, pg_dumpall и всё. Нет: часть контрактов платформы хранит физические OID ролей и базы, и dump/restore их меняет. Только pg_upgrade, и только на копии сначала: у мажорных версий PostgreSQL есть свои сюрпризы с членством в ролях, и ловить их лучше не на проде.

Слой 3: свой CI/CD, потому что GitHub Actions — это чужой компьютер

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

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

Gitea + Actions‑совместимые раннеры (один на каждой ноде) + свой registry. Пуш → компилируемость дерева → юниты по зонам (платформа, каналы, API, координатор) → контракт‑гейты (это отдельные наборы тестов на инварианты: биллинг, каналы, Redis, документация — около двадцати make quality-* целей). Деплой — отдельный workflow: сборка образов → push в registry → preflight по реестру → rolling обновление стека без down → пост‑деплойные гейты по ключевым пользовательским сценариям.

Фронт (PWA) проверяется Playwright’ом: около двухсот spec‑файлов на трёх устройствах — десктопный Chromium, iPhone и Pixel. Публикация статики — сборка в стейдж‑каталог, rsync --delete в прод, прямой sync в прод запрещён флагом и разрешён только для учебного отката.

Грабли № 5: долго жил без смоуков после деплоя и узнавал о сломанном SSO от единственного пользователя (себя). Смоуки после каждого выката — самое дешёвое спокойствие в моей жизни.

Грабли № 6 (совсем свежие, вчера): данные Gitea лежат на NAS по NFS. У NAS был аптайм 280 дней, и его NFS‑демон тихо завис: маунт превратился в Stale file handle, пуши перестали проходить. Дальше я сделал классическую ошибку [1] — docker restart gitea. Контейнер поднялся на пустом каталоге под протухшим маунтом и начал свежую установку: сгенерировал новые SSH‑хосткеи, и git с ужасом закричал «REMOTE HOST IDENTIFICATION HAS CHANGED». Данные не пострадали, но урок дорогой: если данные stateful‑сервиса лежат на сетевом томе, то restart без проверки маунта — это не рестарт, а переустановка. Сначала stop, потом маунт, потом start. Лечение NAS — перезагрузка; 280 дней аптайма — это не медаль.

Слой 4: наблюдаемость и «энтерпрайз для одного»

Prometheus → Targets: 37 таргетов на двух нодах, все UP — от nginx и PostgreSQL до GPU-экспортёра, STT и TTS на ноде B

Prometheus → Targets: 37 таргетов на двух нодах, все UP — от nginx и PostgreSQL до GPU‑экспортёра, STT и TTS на ноде B

Prometheus + Alertmanager + Grafana + экспортёры на всё, что шевелится: ноды, nginx, Redis, PostgreSQL, Qdrant, контейнеры (cAdvisor), blackbox‑проверки снаружи, даже антивирус. Алерты прилетают в Telegram через собственного бота‑админа, который заодно умеет перезапускать сервисы.

Оба узла, роутеры и NAS — на ИБП; состояние батарей тоже уходит в Prometheus через NUT, а алерт «работаем от батареи» — в тот же Telegram.

Рядом — вещи, которые обычно считают «рано для пет‑проекта»:

  • Zammad (хелпдеск) — поставил одним из первых, до пользователей. Логика [2]: фидбек‑петля должна существовать ДО того, как понадобится.

  • Plausible (аналитика) — селф‑хостед, без передачи данных пользователей третьим лицам.

  • Portainer — не для управления (всё в compose), а чтобы с телефона посмотреть, что горит.

  • Nextcloud на NAS — файлы и бытовые бэкапы.

Да, я знаю, как это выглядит: хелпдеск без тикетов и аналитика без трафика. Об этом — в честном финале.

Слой 5: GPU‑нода — уже не план, а прод

Две резидентные модели в VRAM: роутер (2,3 ГБ) и 14B-помощник (10 ГБ). В простое — 29 Вт и 32 °C

Две резидентные модели в VRAM: роутер (2,3 ГБ) и 14B‑помощник (10 ГБ). В простое — 29 Вт и 32 °C

В июльской версии этой статьи GPU‑нода была «свежей главой» и чек‑листом покупки на Авито. С тех пор она в проде.

Железо: б/у десктоп с RTX 3090 на 24 ГБ, 32 потока, 62 ГБ RAM. Проверка перед покупкой — memtest_vulkan всей VRAM, прогрев с контролем температуры памяти [3] (GDDR6X у 3090 — отдельная боль [4]), сверка Device ID против перешитого VBIOS. Да, такое реально продают. На эту покупку я потратил отложенные на отпуск деньги и, кажется, всю свою удачу: карта прошла стресс‑тесты лучше референсных значений, а продавец оказался на редкость доброжелательным человеком.

Что на ней крутится (12 контейнеров):

  • Ollama с двумя резидентными моделями: маленькая 3B — роутер запросов (решение о классе задачи за десятки миллисекунд) и 14B — локальный помощник, который делает сводки при опросе нескольких моделей и переводит. Обе принудительно держатся в VRAM sidecar‑контейнером, который каждые полминуты проверяет, что модели загружены, и перепинывает keep_alive — иначе Ollama выгружает их после простоя, и первый запрос ждёт секунды.

  • STT на Whisper large‑v3 — распознавание речи в 10–20 раз быстрее реального времени. В проде стоит первым в порядке провайдеров: локальная модель, а облако — фолбэк.

  • TTSOCR и эмбеддинги (bge‑m3 для RAG) — каждый своим сервисом. — Сервис индексации базы знаний со своим Redis, антивирус для загружаемых файлов, второй CI‑раннер.

Архитектурно это вторая нода за тем же MikroTik: платформа ходит к ней по внутренней сети, а при её недоступности деградирует обратно на облачные API. Прод при этом не мигрировал вообще — нода добавлена рядом, сервис за сервисом. Домашний каталог ноды смонтирован по NFS с основного сервера (см. грабли № 6 — да, я знаю).

Грабли № 7: 30B‑модель в MoE‑исполнении выглядела бесплатным апгрейдом — «активных параметров всего 3B». Но MoE экономит вычисления, а не память: резидентно она заняла 18 ГБ и вытеснила роутер из VRAM. Плюс конкретно Qwen3 30B‑A3B в моей сборке Ollama на think: false не реагировала и начинала каждый ответ с «Хорошо, мне нужно…» на тысячу токенов — поддержка выключения рассуждений зависит от модели. 14B оказалась быстрее на тёплом вызове в 3–5 раз. Бенчмаркать нужно на своих промптах и со своей резидентностью, а не по tok/s из чужого поста.

Грабли № 8: на GPU‑ноде переменные окружения docker указывают на демон основного сервера — это удобно для оркестрации с одного места, но означает, что голый docker ps на ноде показывает чужой прод. Один раз я почти пересоздал не тот контейнер. Теперь любой скрипт на ноде начинается с явного DOCKER_CONTEXT=default и проверки имени демона.

Слой 6: как один человек это тянет

Честный ответ — не один. Последние месяцы рядом со мной работают ИИ‑агенты как напарники: один ведёт длинные инженерные линии (миграции, релизные контракты, церемонии выката), второй — ревью и инфраструктурные инциденты. У них общая доска задач в репозитории (обычный markdown с карточками и статусами) и жёсткое правило: тот, кто собирает релиз, не имеет права сам объявить его готовым — другой агент разворачивает тот же SHA в отдельном worktree, гоняет тесты и пишет на доску GREEN или RED. Прод руками не трогает никто; каждый шаг с мутацией — через явное «GO» от меня.

За одну неделю такой связки один релизный слайс прошёл четырнадцать итераций, и каждая остановка была на настоящем дефекте, который иначе доехал бы до прода. Медленно — но это «медленно» я выбрал осознанно. Как это устроено, где агенты друг друга ловили, где ловили меня и где я их ловил — тема следующей статьи.

Честный финал: чего всё это НЕ решило

Теперь неудобная часть, ради которой я частично и пишу.

За два года я построил всё вышеописанное — а пользователей за пределами круга знакомых и тестеров привлёк примерно ноль. Понятен ли продукт, нужен ли он, удерживает ли — я честно не знаю: 100% моего времени уходило в инженерию и 0% — в то, чтобы это выяснить. Две статьи на VC — весь мой маркетинг за два года.

Классическая ловушка инженера: строить приятно, продавать страшно. Инфраструктура выше — во многом памятник этой ловушке. Красивый, работающий, наблюдаемый памятник.

RED-дашборд платформы за сегодняшний вечер: 0 ops/s, ошибок 0 %. Идеальные SLO — потому что запросов нет. Вот так памятник выглядит на графике.

RED‑дашборд платформы за сегодняшний вечер: 0 ops/s, ошибок 0%. Идеальные SLO — потому что запросов нет. Вот так памятник выглядит на графике.

В июле я договорился с собой: до 1 сентября — последняя инженерная фаза, дальше квартал только на дистрибуцию. Сегодня 2 сентября. GPU‑нода в проде, миграции сделаны, docker больше не из snap. Эта статья — первый шаг того квартала, и она выходит с опозданием на день, потому что вчера я чинил NFS. Символично.

Поэтому две просьбы к комментариям:

  1. Инженерная: где я перемудрил, а где, наоборот, наивно? Особенно интересны сеть, история с networkd и связка «две ноды + NFS».

  2. Человеческая: если вы вылезали из такой же ловушки — расскажите, что сработало.

Продукт называется VoiceMind, ссылки в профиле — но статья, честно, не ради ссылок, а ради пункта 2.

Автор: sguliaev

Источник [5]


Сайт-источник BrainTools: https://www.braintools.ru

Путь до страницы источника: https://www.braintools.ru/article/35236

URLs in this post:

[1] ошибку: http://www.braintools.ru/article/4192

[2] Логика: http://www.braintools.ru/article/7640

[3] памяти: http://www.braintools.ru/article/4140

[4] боль: http://www.braintools.ru/article/9901

[5] Источник: https://habr.com/ru/articles/1080138/?utm_campaign=1080138&utm_source=habrahabr&utm_medium=rss

www.BrainTools.ru

Rambler's Top100