Два года, один человек, 66 контейнеров: как я построил AI‑платформу на железе под столом. bgp.. bgp. docker.. bgp. docker. gitea.. bgp. docker. gitea. gpu.. bgp. docker. gitea. gpu. MikroTik.. bgp. docker. gitea. gpu. MikroTik. ollama.. bgp. docker. gitea. gpu. MikroTik. ollama. PostgreSQL.. bgp. docker. gitea. gpu. MikroTik. ollama. PostgreSQL. Prometheus.. bgp. docker. gitea. gpu. MikroTik. ollama. PostgreSQL. Prometheus. self-hosted.. bgp. docker. gitea. gpu. MikroTik. ollama. PostgreSQL. Prometheus. self-hosted. домашний сервер.
Сверху вниз: 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, пуши перестали проходить. Дальше я сделал классическую ошибку — 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 (хелпдеск) — поставил одним из первых, до пользователей. Логика: фидбек‑петля должна существовать ДО того, как понадобится.

  • 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, прогрев с контролем температуры памяти (GDDR6X у 3090 — отдельная боль), сверка 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

Источник