clowdr: деплой на VPS за $6 без SSH, Docker Hub и Kubernetes
У маленьких проектов есть неудобная особенность: инфраструктура для них почти всегда либо слишком ручная, либо слишком тяжёлая. Ручная — это ssh на сервер, git pull, пересборка на месте и надежда, что за пару секунд перезапуска никто не зайдёт на сайт. Тяжёлая — это Kubernetes, внешний registry, ingress-контроллер и манифесты, которые занимают больше строк, чем сам сервис. Между этими крайностями есть целая полка готовых инструментов, и всё равно я написал свой. Он называется clowdr, лежит в открытом доступе под лицензией Apache-2.0, и в этой статье я хочу объяснить, зачем он появился и как устроен.
Сразу зафиксирую, чего здесь не будет. Не будет тезиса «Kubernetes не нужен» и не будет обещания, что теперь деплой стал простым для всех. clowdr решает узкую задачу: один небольшой сервер, условно 1 vCPU и 1 GB памяти за шесть долларов в месяц, на котором живут несколько проектов, иногда разных людей. Для этой задачи готовые инструменты оказались либо слишком прожорливыми, либо требовали того, чего я как раз хотел избежать — SSH-доступа к хосту и внешнего registry.
Это не попытка переизобрести оркестратор, а попытка убрать из деплоя всё, что не нужно одной маленькой машине. Дальше — по порядку: почему не готовые решения, сколько это стоит в мегабайтах, как устроена архитектура и где у такого подхода заканчиваются полномочия.
Почему не Kamal, Dokku или Coolify?
Первая реакция на любой самописный деплой-инструмент обычно одна: «есть же готовое». И это справедливая реакция. Kamal 2 аккуратно деплоит образы через SSH и держит лёгкий kamal-proxy. Dokku даёт привычный git push и собирает на сервере. Coolify и Dokploy дают веб-интерфейс, в котором можно накликать почти всё. Nomad и k3s — полноценные планировщики. У каждого из них есть своя аудитория, и для неё они хороши.
Проблема не в качестве этих инструментов, а в том, на какую машину и на какую модель доступа они рассчитаны. Kamal ходит на хост по SSH и ожидает внешний registry, а значит, право деплоить проект — это фактически право зайти на сервер. Dokku и UI-платформы собирают образы на самом сервере, и на машине с одним ядром сборка фронтенда легко съедает всё, что есть, вместе с соседними сайтами. Nomad и k3s требуют job-файлов или манифестов, отдельного registry и отдельного ingress. Для кластера это нормальная цена. Для одного VPS это налог, который платишь каждый месяц, ничего не получая взамен.
Мне нужен был другой набор свойств. Сборка — на машине разработчика, где есть ядра и память. Доставка — только тех слоёв образа, которых на сервере ещё нет. Доступ — по токену проекта, а не по SSH-ключу к хосту. И всё это должно оставлять большую часть гигабайта самим сервисам, а не инфраструктуре вокруг них.
Гигабайт памяти — это бюджет, и его видно в цифрах
На большом сервере разница в сотню мегабайт между инструментами — статистическая погрешность. На машине с 1 GB это разница между «влезает ещё один проект» и «не влезает». Поэтому сравнение я сделал именно в памяти на холостом ходу, без идущих деплоев.
У clowdr демон clowdrd занимает около 22 MB RSS, nginx — около 12 MB при одном worker на одно ядро; каждый дополнительный worker добавляет примерно 6 MB на ядро. Это замеры на Docker 29.9 иnginx:alpine. Для остальных я брал документированные минимумы или опубликованные замеры, и они, конечно, зависят от конфигурации: у Kamal 2 kamal-proxy порядка 15 MB, у Dokku нагрузка тоже небольшая — nginx плюс shell-плагины. Dokploy держит 300–400 MB, Coolify — от 0,5 до 1,2 GB. Nomad — от 100 MB на агента. k3s — минимум 512 MB, а с обычным набором компонентов около 1,6 GB, то есть больше, чем вся машина.
Важно не сделать из этой таблицы неверный вывод. Kamal и Dokku по памяти вполне сопоставимы с clowdr, и выбирать между ними по мегабайтам бессмысленно. Разница там в другом — в модели доступа и в том, где происходит сборка. А вот UI-платформы и оркестраторы на такой машине съедают от трети до всей памяти ещё до того, как запущен первый сервис. Это не недостаток этих систем, а честная цена их возможностей, которые одному маленькому серверу просто не нужны.
Один бинарник, две роли
Архитектурно clowdr — это один Go-бинарник, который работает в двух ролях. На сервере этоclowdrd, демон под systemd: HTTP API, registry, логика деплоя, генерация конфигурации nginx и получение сертификатов Let's Encrypt. На машине разработчика это clowctl, CLI для Windows, Linux и macOS: он собирает образы локально, пушит только новые слои и запускает деплой.
Состояние демон хранит в bbolt, а с Docker разговаривает через минимальный клиент Docker Engine API. Требования к серверу скромные: Linux и Docker 29+ с containerd image store, который на новых установках включён по умолчанию. Установка — одна команда install.sh с флагом --domain: API и registry поднимаются на deploy.example.com с сертификатом от Let's Encrypt. Без домена они слушают :7443 с самоподписанным сертификатом, а install.sh печатает его отпечаток, который clowctl context add --fingerprint закрепляет на стороне клиента.
При первом старте демон пишет /etc/clowdr/platform.yml, поднимает nginx на 80 и 443 и печатает admin-токен. Дальше администратор заводит проекты, а каждому проекту выдаёт его собственный токен. SSH в этой схеме нужен только тому, кто обслуживает саму машину, — не тому, кто деплоит.
Registry, которому не нужен Docker Hub
Самое заметное решение в clowdr — встроенный registry. Он реализует OCI distribution push/pull, но хранит данные не в отдельной директории, а прямо в content store containerd — том самом, из которого Docker 29 запускает контейнеры. Почему это важно? Потому что слой, который пришёл через push, не нужно потом ещё раз скачивать, распаковывать и копировать. Он уже лежит там, где его будет искать рантайм.
Отсюда и вся цепочка деплоя. clowctl выполняет команду build изdeploy.yml и docker build --platform linux/amd64 на локальном движке — Docker Desktop на Windows и macOS или colima. Затем docker push отправляет на сервер только те слои, которых там ещё нет. Если поменялся код, а базовый образ и зависимости остались прежними, по сети уходит несколько мегабайт, а не весь образ. Сервер при этом никогда ничего не компилирует.
У registry есть и защитные ограничения, без которых общий сервер быстро превращается в свалку: блобы принадлежат конкретному проекту, размер блоба и число открытых загрузок на проект ограничены, а при почти заполненном диске приём push-ей ставится на паузу. И ещё одна деталь, которая выглядит мелочью, пока не наступишь: clowctl deploy отказывается работать, если docker context илиDOCKER_HOST указывает на другую машину через ssh:// или удалённыйtcp://. Сборка локальная — значит, действительно локальная.
Если демон упадёт, сайты этого не заметят
Трафик в clowdr обслуживает обычный nginx, а не сам демон. clowdrd только рендерит конфигурацию из своего состояния, проверяет её через nginx -t и делает graceful reload. Это сознательное разделение ответственности: data plane — это проверенный годами nginx, control plane — мой код. Если clowdrd остановится, упадёт или будет обновляться, сайты продолжат открываться, потому что nginx ничего о нём не знает в момент обработки запроса.
Обратная сторона тоже продумана: демон восстанавливается после того, как его убили, следит за nginx через watchdog, а reload дожидается появления новых worker-ов. Это не то место, где хочется полагаться на удачу, поэтому сценарии с убитым процессом проверяются отдельными chaos-тестами.
Ingress умеет и то, что нужно реальным сервисам: маршруты по путям, в том числе на другой сервис проекта, отдельные таймауты и отключение буферизации для WebSocket, лимиты на размер тела запроса. Для домена сtls: direct clowdr сам получает сертификат Let's Encrypt. Без этого флага домен считается стоящим за Cloudflare и отвечает только соединениям от trusted_proxies, так что сайт нельзя открыть в обход Cloudflare по IP-адресу сервера.
Новая версия получает трафик только после того, как ответила на health
Деплой в clowdr — это последовательность из шести шагов, и каждый из них можно назвать по имени: build, push новых слоёв, старт, health, switch, drain. Демон поднимает новые контейнеры рядом со старыми и ждёт, пока endpoint из поля health начнёт отвечать. Если проверка не прошла, новые контейнеры удаляются, а старые продолжают обслуживать трафик, как будто ничего не случилось.
Если проверка прошла, upstream в nginx переключается на новые контейнеры, а старые ещё какое-то время дорабатывают уже начатые запросы — это и есть drain, его длительность настраивается полемdrain. Сервер хранит текущий и предыдущий образ каждого сервиса, поэтомуclowctl rollback не требует пересборки. Более старые образы удаляются автоматически, и так же поступает clowctl deploy на машине, которая их собрала.
Есть и честное исключение. Когда памяти не хватает на две копии сервиса одновременно или когда сервис помечен switch: stop-first, потому что ему нельзя работать в двух экземплярах, старая копия останавливается первой. Если новая не поднимется, старую запустят снова, но короткий простой в этом режиме неизбежен. На маленькой машине это осознанный компромисс, а не скрытый дефект.
Весь проект описывается одним файлом рядом с кодом
Конфигурация проекта — это deploy.yml в репозитории. Никаких job-файлов в отдельном месте и никаких настроек, которые живут только в веб-интерфейсе. Вот пример, близкий к тому, что лежит в README:
project: shop
envs:
prod: { server: https://deploy.example.com }
addons: [redis] # own redis instance, reachable as "redis"
services:
api:
path: api
build: yarn install && yarn build
dockerfile: Dockerfile
port: 3000
health: /health # the switch waits for this endpoint
memory: 300m
uses: [redis]
domains:
prod: [api.example.com]
routes:
- { path: /ws, timeout: 24h, buffering: false }
volumes:
- uploads:/app/uploads
web:
path: web
port: 8080
memory: 32m
domains: { prod: [example.com, www.example.com] }Дальше работа сводится к нескольким командам. clowctl deploy api -e prod проходит всю цепочку от сборки до drain. clowctl diff -e prod показывает расхождение между файлом и сервером, clowctl apply применяет его. ps, logs -f иrollback делают ровно то, что обещают их названия. Токен берётся из переменнойCLOWDR_TOKEN или из .clowdr/token.prod, который не попадает в git. Для CI естьCLOWDR_SERVER: он закрепляет сервер, которому можно отправить токен, независимо от того, что написано в deploy.yml.
Обратите внимание на поле memory. Это не косметика: если у проекта есть квота, лимит памяти у каждого сервиса обязателен. Именно так на одной машине с гигабайтом можно заранее понять, что поместится, а что нет, вместо того чтобы узнавать это от OOM killer.
Соседи по серверу не должны видеть друг друга без приглашения
Несколько проектов на одном хосте — это не только вопрос памяти, но и вопрос доверия. Администратор создаёт проект командой вроде clowctl project create shop --domains example.com,*.example.com --platform redis --memory 600m и выдаёт токен через clowctl token create. У проекта свой токен, свой список разрешённых доменов, своя квота памяти и своя сеть. Задеплоить домен, который проекту не разрешён, нельзя, а образ чужого проекта запустить тоже нельзя.
Если одному проекту нужно обращаться к сервису другого, это делается явно и с двух сторон: сервис объявляет expose_to, а принимающий проект перечисляет разрешённых гостей вaccept_from. Без согласия второй стороны деплой отклоняется, а если проект убрать из списка, его уже запущенные контейнеры отключаются от сети. Каждый контейнер дополнительно получает ограничение на число процессов и CPU, no-new-privileges и не получаетNET_RAW, а доступ к облачным metadata-адресам из контейнеров закрыт.
Отдельная история — nginx_extra, сырой фрагмент конфигурации nginx. Он доступен только если администратор разрешил его проекту, и даже тогда принимает лишь ограниченный набор директив: заголовки, тюнинг proxy_*, return и rewrite,proxy_pass только на сервисы своего проекта. Директивы, работающие с файлами, — отroot до сертификатов — отклоняются. Это не паранойя, а простое следствие того, что конфигурация nginx общая для всех проектов на хосте.
Инструменту деплоя тоже нужно доверять
Утилита, которая сама себя обновляет и ставится через curl | sh, — очевидная точка атаки. Поэтому обновление и сервера, и CLI проходит через проверку подписи. install.sh иclowctl self-update берут релизы из GitHub Releases и принимают их только если файлchecksums.txt подписан ed25519-ключом проекта, встроенным в оба инструмента;install.sh проверяет подпись через openssl 3. Подпись покрывает и тег релиза, поэтому файлы старой версии нельзя выдать за новую.
Обновление сервера — это повторный запуск install.sh, состояние при этом сохраняется. А уверенность в том, что деплой действительно бесшовный, держится не на словах: e2e-тест в CI на каждом push ставит демон на настоящий Linux-хост с Docker 29 и деплоит пример под нагрузкой с нулём неудачных запросов, проверяет проваленный health, rollback, масштабирование и перезапуски. По ночам гоняются fuzz-тесты.
Одна машина — это и преимущество, и потолок
Теперь о том, где clowdr заканчивается. Он сознательно рассчитан на один сервер. Если у вас несколько машин, нужно размещать нагрузку между ними, переживать падение узла и переносить сервисы автоматически — это работа планировщика, и Kubernetes или Nomad окупаются именно там. Пытаться растянуть clowdr на кластер — значит воспроизводить их хуже.
Второй сценарий — когда нужен веб-интерфейс и сборка на сервере. Если проектом управляют люди, которым удобнее кнопки, чем CLI, или если локальная сборка невозможна, Coolify и Dokploy подходят лучше, и их память в таком случае — оплата реальной пользы. clowdr же требует локального docker с buildx на машине того, кто деплоит, а на Windows ещё и Git Bash для команд сборки.
И третье: высокая доступность здесь ограничена самой физикой. Health-gated switch и drain убирают простой при деплое, nginx переживает остановку демона, но если сама машина за шесть долларов перезагрузилась, сайты недоступны, пока она не поднимется. Режим stop-first при нехватке памяти тоже даёт короткий простой. Для личных проектов, лендингов, небольших API и ботов это нормальная цена. Для сервиса, у которого есть SLA, — уже нет.
Итог
clowdr появился не потому, что существующие инструменты плохи, а потому, что для одного маленького сервера с несколькими проектами каждый из них приносил что-то лишнее: SSH как модель доступа, внешний registry, сборку на слабой машине или сотни мегабайт памяти на интерфейс. Убрав это, я получил связку из демона на 22 MB и nginx, registry прямо в containerd image store, локальную сборку с доставкой только новых слоёв, переключение по health-check и изоляцию проектов токенами, квотами и сетями.
Если сформулировать главный практический вывод совсем прямо: на машине с одним ядром и гигабайтом памяти лучший деплой — тот, который ничего не собирает на сервере, не держит лишних процессов и не требует пускать людей по SSH, а всё остальное стоит оставить кластерам, которым оно действительно нужно. Код, README и пример проекта лежат в репозитории veksa/clowdr.