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

24 контейнера на VPS за $30-мес: как я заменил облака одним сервером

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;
    }
}

Обратите внимание [1] на /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% всей памяти [2] сервера на один контейнер.

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% времени просто держит память.

Варианты:

  1. On-demand через docker run --rm. Экономия RAM, но +5-10 сек на каждый запуск

  2. Playwright в serverless (AWS Lambda). Идеально по RAM, но latency и стоимость

  3. Lighter headless (Playwright в Python). Все равно ~400 МБ

Пока оставил always-on. Если RAM станет критично, это первый кандидат.

SSL: три подхода на одном сервере

  1. getssl + Let’s Encrypt для антиспам-сервиса. Auto-renewal по cron, сертификаты маунтятся в nginx

  2. Cloudflare proxy для большинства доменов. SSL terminates на Cloudflare, до сервера идет HTTP

  3. Ручное обновление для 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.

Что не работает: честный список

  1. Swap забит полностью. 2 ГБ из 2 ГБ. При пиковых нагрузках (Chrome + ES одновременно) система уходит в swap thrashing. Лечится добавлением RAM, но текущий тариф: максимум 6 ГБ

  2. Одна точка отказа. VPS упал, упало все. Бэкапы есть (Restic в S3, ежедневно), но RTO минимум 30 минут

  3. Нет CI/CD. Деплой:

    cd /opt/project && git pull && docker compose up -d --build
    

    При 7 проектах иногда хочется нормальный pipeline

  4. Chrome-headless ест 636 МБ впустую 90% времени

  5. Нет 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

Источник [3]


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

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

URLs in this post:

[1] внимание: http://www.braintools.ru/article/7595

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

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

www.BrainTools.ru

Rambler's Top100