24 контейнера, 6 ГБ RAM, $30/мес. И все работает. Ну почти
Стек
Компонент | Версия |
|---|---|
Сервер | VPS 2 vCPU, 6 ГБ RAM, 29 ГБ SSD, Ubuntu 22.04 |
Оркестрация | Docker Compose v2 |
Reverse proxy | nginx:alpine |
Базы данных | MySQL 8.0, Redis 7, Elasticsearch 8.12.2 |
Рантаймы | PHP 8.3 (FPM), Node.js 20, Python 3.11 |
SSL | getssl (Let's Encrypt) + Cloudflare proxy |
Мониторинг | Docker healthcheck + bash watchdog + Telegram-алерты |
Проблема
Managed Elasticsearch в AWS стоит $35-50/мес. Managed MySQL еще $15-25. Redis, headless-браузер, LLM-инференс через API. Для 7 рабочих проектов это $150-250/мес, и это минимум.
Но вот в чем дело: это не стартап с инвесторами. Это набор рабочих инструментов: Telegram-бот для EdTech-платформы, антиспам-сервис, AI-бэкенд, метапоисковик, локальный LLM-инференс. Платить $200+ за инфраструктуру, которая приносит $0 выручки, не хочется.
Поэтому все живет на одном VPS за $30. Работает больше года. Uptime на момент написания: 27 дней (последний ребут из-за обновления ядра, не из-за проблем).
Но "ну почти" в заголовке не просто так.
Что крутится: все 24 контейнера
Данные из docker stats --no-stream, снятые прямо сейчас:
Контейнер | Образ | RAM | CPU | Назначение |
|---|---|---|---|---|
Проект 1: EdTech Telegram Bot (Laravel) | ||||
bot-nginx | nginx:alpine | 3.5 МБ | 0.00% | Reverse proxy, SSL, webhooks |
bot-php | php-fpm (custom) | 12 МБ | 0.00% | Обработка Telegram webhooks |
bot-worker | php-fpm (custom) | 79 МБ | 0.00% | Queue worker (redis) |
bot-scheduler | php-fpm (custom) | 15 МБ | 0.00% | Laravel scheduler (cron) |
bot-redis | redis:7-alpine | 2.7 МБ | 0.43% | Очереди и кэш |
bot-ssh-tunnel | alpine:3.19 | 2.1 МБ | 2.07% | SSH-туннель к удаленной БД |
Проект 2: Антиспам-сервис | ||||
antispam-nginx | nginx:alpine | 7.4 МБ | 0.00% | Reverse proxy |
antispam-app-1 | php-fpm (custom) | 28 МБ | 0.00% | PHP-воркер |
antispam-app-2 | php-fpm (custom) | 1.2 МБ | 0.00% | Второй инстанс (standby) |
antispam-supervisor | custom | 92 МБ | 0.23% | Supervisor фоновых задач |
antispam-db | mysql:8.0 | 127 МБ | 0.83% | Выделенная БД |
antispam-adminer | adminer | 6.4 МБ | 0.00% | DB management UI |
antispam-messenger | custom (Node.js) | 33 МБ | 0.00% | Бот для мессенджера |
Проект 3: AI-бэкенд | ||||
ai-backend-nginx | nginx:alpine | 5 МБ | 0.00% | Reverse proxy |
ai-backend-php | php-fpm (custom) | 76 МБ | 0.00% | API-сервер |
ai-backend-worker | php-fpm (custom) | 48 МБ | 0.00% | Queue worker |
ai-backend-redis | redis:7-alpine | 2.1 МБ | 0.41% | Очереди |
ai-backend-ssh-tunnel | alpine:3.19 | 2.9 МБ | 2.11% | SSH-туннель к БД |
Инфраструктурные сервисы | ||||
elasticsearch | elasticsearch:8.12.2 | 1.47 ГБ | 0.27% | Полнотекстовый поиск, логи |
chrome-headless | zenika/alpine-chrome | 636 МБ | 27.27% | Headless-браузер (скрейпинг) |
searxng | searxng/searxng | 1.8 МБ | 0.00% | Приватный метапоисковик |
llama-server | llama.cpp:server | 116 МБ | 0.00% | Локальный LLM (эмбеддинги) |
Утилиты | ||||
crosspost | custom (Node.js) | 46 МБ | 0.01% | Кросспостинг-сервис |
sticker-bot | custom (Node.js) | 75 МБ | 0.00% | Telegram-бот для стикеров |
Суммарно: ~2.9 ГБ из 5.8 ГБ. Вроде запас есть. Но это без учета swap, который забит полностью. К этому вернемся.
Сетевая архитектура
Каждый проект живет в изолированной Docker-сети:
$ docker network ls NAME DRIVER bot_default bridge # EdTech Bot antispam_default bridge # Антиспам ai-backend_default bridge # AI-бэкенд sticker-bot_default bridge # Стикер-бот crosspost_default bridge # Кросспостинг shared bridge # Общая сеть
На хост-уровне всё идет через порты:
Интернет │ ├─ :80/:443 ──→ bot-nginx ──→ Telegram webhooks ├─ :83 ───────→ antispam-nginx ──→ antispam API ├─ :8080/8443 → ai-backend-nginx ─→ AI API ├─ :3101 ────→ antispam-messenger ─→ messenger API │ └─ localhost only: ├─ :9222 → chrome-headless ├─ :8888 → searxng ├─ :8088 → llama.cpp └─ :9200 → elasticsearch
Все внутренние сервисы привязаны к 127.0.0.1. Снаружи не доступны.
docker-compose.yml: как устроен один проект
Полный compose-файл EdTech Bot (без секретов):
# Бот живет на VPS, основное приложение на shared-хостинге. # Общая БД доступна через SSH-туннель. services: ssh-tunnel: image: alpine:3.19 command: - sh - -c - | apk add --no-cache openssh-client autossh && mkdir -p /root/.ssh && cp /ssh-key/id_rsa /root/.ssh/id_rsa && chmod 600 /root/.ssh/id_rsa && AUTOSSH_GATETIME=0 autossh -M 0 -N \ -o StrictHostKeyChecking=no \ -o ServerAliveInterval=30 \ -o ServerAliveCountMax=3 \ -L 0.0.0.0:3306:${DB_INTERNAL_HOST}:3306 \ ${SSH_USER}@${SSH_HOST} volumes: - ./ssh-key:/ssh-key:ro healthcheck: test: ["CMD", "nc", "-z", "127.0.0.1", "3306"] interval: 10s timeout: 5s retries: 5 start_period: 15s restart: unless-stopped nginx: image: nginx:alpine ports: - "80:80" - "443:443" volumes: - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro - ../../public:/var/www/html/public:ro - ./ssl:/etc/nginx/ssl:ro depends_on: - php restart: unless-stopped php: build: context: . dockerfile: Dockerfile volumes: - ../../:/var/www/html depends_on: ssh-tunnel: condition: service_healthy redis: condition: service_started restart: unless-stopped worker: build: { context: ., dockerfile: Dockerfile } command: > php artisan queue:work redis --queue=telegram --sleep=3 --tries=3 --max-time=3600 volumes: - ../../:/var/www/html depends_on: ssh-tunnel: condition: service_healthy restart: unless-stopped scheduler: build: { context: ., dockerfile: Dockerfile } command: > sh -c "while true; do php artisan schedule:run --verbose --no-interaction; sleep 60; done" volumes: - ../../:/var/www/html depends_on: ssh-tunnel: condition: service_healthy restart: unless-stopped redis: image: redis:7-alpine volumes: - redis_data:/data restart: unless-stopped volumes: redis_data:
6 контейнеров. Суммарное потребление: 115 МБ RAM. Ключевой паттерн: ssh-tunnel с healthcheck как dependency. PHP, worker и scheduler не стартуют, пока туннель не поднялся.
nginx: только webhooks, все остальное в 404
Бот не обслуживает сайт. Nginx настроен минимально:
server { listen 80; server_name _; return 301 https://$host$request_uri; } server { listen 443 ssl; server_name _; ssl_certificate /etc/nginx/ssl/cert.pem; ssl_certificate_key /etc/nginx/ssl/key.pem; root /var/www/html/public; location /health { return 200 'ok'; add_header Content-Type text/plain; } # Lightweight webhook, без Laravel bootstrap location = /webhook/advert { fastcgi_pass php:9000; fastcgi_param SCRIPT_FILENAME /var/www/html/public/webhook-advert.php; include fastcgi_params; fastcgi_read_timeout 30s; } # Telegram API routes через Laravel location ~ ^/api/telegram/ { try_files $uri $uri/ /index.php?$query_string; } # Все остальное location / { return 404; } location ~ \.php$ { fastcgi_pass php:9000; fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; include fastcgi_params; fastcgi_read_timeout 120s; } }
Обратите внимание на /webhook/advert. Отдельный PHP-файл, который обрабатывает вебхук без загрузки всего Laravel. Bootstrap занимает 50-100 мс, Telegram ждет ответ 60 секунд, и при нагрузке каждая миллисекунда на счету.
SSH-туннели вместо открытых портов
Два проекта используют MySQL на shared-хостинге. Прямой доступ к БД извне закрыт. Managed база стоит денег.
Решение: контейнер на alpine (2 МБ RAM), внутри autossh:
Поднимает SSH-туннель с port forwarding
Переподключается при обрыве автоматически
Healthcheck через
nc -z 127.0.0.1 3306Зависимые сервисы ждут, пока туннель не заработает (
condition: service_healthy)
Дешево, надежно, порты наружу не открыты. Работает с любым shared-хостингом, у которого есть SSH.
Elasticsearch: слон в комнате
Вот тут начинается "ну почти".
Elasticsearch занимает 1.47 ГБ RAM из 5.8 доступных. Это 25% всей памяти сервера на один контейнер.
elasticsearch: image: docker.elastic.co/elasticsearch/elasticsearch:8.12.2 environment: - discovery.type=single-node - bootstrap.memory_lock=true - xpack.security.enabled=true deploy: resources: limits: memory: 2g
Лимит 2g критичен. Без него ES выделит себе всю свободную RAM и начнет вытеснять соседей в swap. OOM-killer в Docker сработает непредсказуемо, может убить что угодно. Спросите, откуда знаю.
Для моих задач (индекс ~50K документов, полнотекстовый поиск + логирование) 2 ГБ лимита хватает. Если бы начинал сегодня, взял бы Meilisearch: то же самое на 100-200 МБ, без JVM overhead. Но миграция существующих индексов это отдельный проект.
Chrome headless: 636 МБ за задачу, которая работает раз в час
Второй по потреблению: zenika/alpine-chrome (636 МБ). Нужен для скрейпинга страниц с JS-рендерингом.
Проблема в том, что Chrome жив 24/7, а реально используется пару раз в час. 90% времени просто держит память.
Варианты:
On-demand через
docker run --rm. Экономия RAM, но +5-10 сек на каждый запускPlaywright в serverless (AWS Lambda). Идеально по RAM, но latency и стоимость
Lighter headless (Playwright в Python). Все равно ~400 МБ
Пока оставил always-on. Если RAM станет критично, это первый кандидат.
SSL: три подхода на одном сервере
getssl + Let's Encrypt для антиспам-сервиса. Auto-renewal по cron, сертификаты маунтятся в nginx
Cloudflare proxy для большинства доменов. SSL terminates на Cloudflare, до сервера идет HTTP
Ручное обновление для AI-бэкенда (в процессе автоматизации)
Для нового проекта я бы сразу ставил Caddy вместо nginx. Встроенный ACME, автоматический Let's Encrypt, zero-config SSL. Но мигрировать 3 nginx-контейнера ради этого не буду.
Мониторинг без Prometheus
Prometheus + Grafana это еще +500 МБ RAM. Которых нет.
Вместо этого: bash-скрипт по cron каждые 15 минут.
#!/bin/bash # watchdog.sh # HTTP-статус публичных эндпоинтов for url in "https://antispam.example.com" "https://ai-api.example.com/health"; do status=$(curl -o /dev/null -s -w "%{http_code}" "$url") if [ "$status" != "200" ]; then send_telegram_alert "$url returned $status" fi done # Контейнеры unhealthy=$(docker ps --filter "health=unhealthy" --format "{{.Names}}") if [ -n "$unhealthy" ]; then send_telegram_alert "Unhealthy: $unhealthy" fi # Диск disk_usage=$(df / --output=pcent | tail -1 | tr -dc '0-9') if [ "$disk_usage" -gt 90 ]; then send_telegram_alert "Disk: ${disk_usage}%" fi # Swap swap_used=$(free | grep Swap | awk '{print $3}') if [ "$swap_used" -gt 2000000 ]; then send_telegram_alert "Swap thrashing: ${swap_used}K used" fi
Упало что-то, алерт в Telegram. Не красиво, зато ноль дополнительных ресурсов.
Сколько это стоит: $30 vs облако
Сервис | Облако (минимум) | Мой VPS |
|---|---|---|
VPS 6 ГБ RAM | $30/мес | |
Managed Elasticsearch | $35-50/мес | $0 |
Managed MySQL | $15-25/мес | $0 (shared-хостинг) |
Managed Redis x2 | $20-30/мес | $0 |
Headless Chrome API | $30-50/мес | $0 |
LLM inference API | $10-30/мес | $0 (llama.cpp) |
Итого | $110-185/мес | $30/мес |
Разница в 4-6 раз. Но это не "бесплатно". Я плачу своим временем на настройку, обновления и дебаг. Для одного человека с 7 проектами это оправданно. Для команды из 5 человек уже нет.
Состояние диска
$ df -h / Filesystem Size Used Avail Use% /dev/vda1 29G 23G 5.2G 82% $ docker system df TYPE TOTAL ACTIVE SIZE RECLAIMABLE Images 19 19 10.52 GB 0 B (0%) Containers 24 24 109.8 MB 0 B (0%) Local Volumes 6 5 5.32 MB 36 B (0%)
82% заполнения. 19 образов занимают 10.5 ГБ, reclaimable = 0%, потому что все активные. Одна неудачная пересборка с --no-cache, и No space left on device.
Что не работает: честный список
Swap забит полностью. 2 ГБ из 2 ГБ. При пиковых нагрузках (Chrome + ES одновременно) система уходит в swap thrashing. Лечится добавлением RAM, но текущий тариф: максимум 6 ГБ
Одна точка отказа. VPS упал, упало все. Бэкапы есть (Restic в S3, ежедневно), но RTO минимум 30 минут
Нет CI/CD. Деплой:
cd /opt/project && git pull && docker compose up -d --buildПри 7 проектах иногда хочется нормальный pipeline
Chrome-headless ест 636 МБ впустую 90% времени
Нет autoscaling. Если один проект внезапно получит трафик, он задавит соседей
Когда это перестанет работать
Конкретные пределы:
CPU: 2 vCPU хватает. Нагрузка spiky, load average 0.4 при 24 контейнерах
RAM: 6 ГБ это потолок. Еще один тяжелый сервис, и придется мигрировать
Диск: 29 ГБ уже тесно. Следующий сервер будет с 50+ ГБ
SLA: для рабочих инструментов нормально. Для SaaS с обещанным uptime 99.9% нет
Если один из проектов вырастет, он уедет на отдельный сервер. Docker Compose позволяет вынести любой сервис без переписывания кода: меняешь docker-compose.yml, DNS-запись, готово.
Итого
24 контейнера, 7 проектов, $30/мес. Работает больше года.
Ключевые решения:
SSH-туннели вместо открытых портов и managed-баз
Жесткие memory limits на тяжелые сервисы (ES, Chrome)
nginx:alpine вместо полноценного nginx, экономия 50 МБ на инстанс
redis:7-alpine, 2.7 МБ вместо 30+ у стандартного образа
Один compose-файл на проект, изоляция без overhead
Пока вы выбираете между AWS и GCP, кто-то деплоит 7 проектов на VPS за $30 и спокойно спит. Ну, почти спокойно. Swap все-таки забит.
В следующей статье расскажу, как оркестрировать Claude, GPT и Gemini на таком же сервере, и почему OAuth-подписка экономит 18x по сравнению с API.
