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.
Автор: StudyQA


