Перейти к содержимому
HoginHogin
Назад

Та же архитектура, но self-hosted: AWS-схема из части 1 на своей VM за $0/мес (часть 2/2)

10 мин чтения

В части 1 мы разобрали serverless-схему на AWS по слоям и собрали её уменьшенную копию одним CloudFormation-шаблоном. У того же цикла иллюстраций, из которого пришла первая схема, есть парная: грид из девяти сервисов на одной ubuntu-vm с бейджем «$0/mo» — Docker, Kong, Node.js, PostgreSQL, MinIO, NGINX, Prometheus, Keycloak, RabbitMQ. Это та же архитектура, собранная у себя: разберём коробку за коробкой, честно отметим места, где «то же самое» перестаёт быть тем же, и соберём весь стек одним docker-compose.yml.

Содержание

Открыть содержание

Рекап: что было в части 1

Схема AWS читалась одним правилом: у запроса две дорожки — статика (Route 53 → CloudFront → S3) и динамика (API Gateway → Lambda → DynamoDB), а всё остальное — обвязка: SQS с воркером для тяжёлого, Cognito для токенов, CloudWatch для логов и метрик. Деплой этой схемы — один template.yaml: обновили стек — обновился прод.

Парная схема обещает то же самое, но на своей VM: девять прямоугольников, в которых знакомые роли, — только вместо управляемых сервисов AWS зрелые опенсорс-проекты. Роли совпадают настолько, что схему можно собрать переводчиком, строка за строкой.

Переводчик: AWS → self-hosted

Переводчик схемы

В облачной схемеРольНа своей VM
API Gatewayвход в API, токены, лимитыKong
Lambdaваш кодNode.js-контейнер
DynamoDBбазаPostgreSQL
S3файлыMinIO
CloudFrontстатика, TLS, сжатиеNGINX
CloudWatchлоги и метрикиPrometheus
Cognitoрегистрация, логин, токеныKeycloak
SQS + Workerфоновые задачиRabbitMQ + Node.js-консюмер
Route 53DNSA-запись на VM
CloudFormation template.yamlвесь стек одним файломdocker-compose.yml

Каждая строка справа — проект, которому больше десяти лет продакшена: Kong — API-шлюз с декларативной конфигурацией, RabbitMQ — очереди с подтверждением доставки, MinIO — S3-совместимое хранилище, Keycloak — IAM на OpenID Connect. Подмена честная: ролей столько же, лицензий — ноль.

Зачем в схеме и NGINX, и Kong

В облачной схеме перед кодом стояли двое ворот: CloudFront (TLS, статика, сжатие) и API Gateway (роутинг, проверка токена, лимиты). Self-hosted вариант повторяет эту пару: NGINX спереди — терминирует TLS на бесплатном сертификате Let’s Encrypt, отдаёт статику фронтенда из volume и сжимает ответы; Kong за ним — принимает только /api, проверяет JWT от Keycloak, режет rate-limit и роутит на приложение.

«Зачем два, если NGINX умеет proxy_pass?» — умеет. Но тогда роутинг, лимиты и проверку токенов придётся нести в конфиг NGINX и в код приложения. Kong оставляет эту работу там же, где она была в облачной схеме, конфигурируется декларативным файлом — аналог template.yaml — и не торчит наружу: единственные открытые порты VM — 80 и 443 у NGINX.

«Та же архитектура» ≠ «тот же код»

Дорожки запроса на VM

Схема переносится, код — не весь. Три места, где подмена коробки меняет характер работы:

Мониторинг повторяет ту же логику: Prometheus собирает метрики со всех девяти сервисов — то, что в AWS делал CloudWatch. Разница в том, что CloudWatch включался сам, а экспортеры для Postgres, NGINX и RabbitMQ настраиваете вы.

Бэкапы и обновления — то, чего не было в схеме

В AWS-схеме не было ни одной коробки «бэкап» — и это не упущение: DynamoDB и S3 реплицирует сервис. На VM всё наоборот: девять контейнеров, и данные каждого живут на диске, который умрёт вместе с диском. Минимальный слой:

Без этого слоя self-hosted превращается в лотерею — на удачную сторону.

Сколько стоит «$0/мес»

Бейдж на референсе честно разложить по строкам:

Итого реальный счёт — от ~$7/мес, а не ноль. Но и serverless-стек из части 1 при хобби-нагрузке стоит почти ноль: free tier AWS — миллион Lambda-запросов и 25 ГБ DynamoDB в месяц бесплатно, бессрочно. Выходит, на маленькой нагрузке оба варианта почти бесплатны — и выбор между ними не про деньги:

Serverless на AWSSelf-hosted на своей VM
Счёт за софтза запросы$0 — всё open source
Счёт за железовнутри счёта за запросыVM целиком, 24/7, даже в простое
При хобби-нагрузке≈ $0 (free tier)≈ $7–15/мес (VM + домен)
При постоянной нагрузкерастёт с каждым запросомфиксирован размером VM
Волна с Hacker Newsмасштабируется самаупирается в VM, скейл — ваш
Обновления и патчиихваши: docker compose pull
Бэкапысервисваши: pg_dump + проверка
Где лежат данныеу провайдераполностью у вас
Порог входавыучить сервисы и лимиты AWSLinux, Docker и compose

Граница выгоды читается прямо из таблицы: постоянная и предсказуемая нагрузка — фиксированная цена VM рано или поздно обгоняет счёт за запросы, и self-hosted становится дешевле; редкие всплески или нагрузки ещё нет — serverless держит ноль и не требует дежурств. Отдельная причина уйти на свою VM не-денежная: данные, которых не должно быть у провайдера.

Что нужно, чтобы собрать схему за вечер

Весь стек в одном файле

Нужны VM с Ubuntu, установленные Docker с compose-плагином и домен, чья A-запись указывает на VM. Проект — пять файлов и две папки:

selfhosted-app/
├── docker-compose.yml
├── nginx.conf
├── kong.yml
├── prometheus.yml
├── site/            # собранный фронтенд
└── app/             # Node.js: server.js + worker.js

docker-compose.yml — весь стек, аналог template.yaml из части 1:

services:
  nginx:
    image: nginx:1.27
    ports: ["80:80", "443:443"]
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
      - ./site:/var/www/site:ro
      - /etc/letsencrypt:/etc/letsencrypt:ro
    depends_on: [kong]

  kong:
    image: kong:3.7
    environment:
      KONG_DATABASE: "off"                      # DB-less: конфиг из файла
      KONG_DECLARATIVE_CONFIG: /kong/kong.yml
    volumes:
      - ./kong.yml:/kong/kong.yml:ro

  app:                                          # дорожка динамики
    build: ./app
    environment: &app_env
      DATABASE_URL: postgres://app:secret@postgres:5432/app
      AMQP_URL: amqp://app:secret@rabbitmq:5672/
    depends_on: [postgres, rabbitmq]

  worker:                                      # консюмер очереди
    build: ./app
    command: node worker.js
    environment: *app_env
    depends_on: [postgres, rabbitmq]

  postgres:
    image: postgres:17
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: app
    volumes: [pgdata:/var/lib/postgresql/data]

  rabbitmq:
    image: rabbitmq:4-management

  minio:
    image: minio/minio
    command: server /data --console-address ":9001"
    environment:
      MINIO_ROOT_USER: app
      MINIO_ROOT_PASSWORD: secret
    volumes: [miniodata:/data]

  prometheus:
    image: prom/prometheus
    volumes: [./prometheus.yml:/etc/prometheus/prometheus.yml:ro]

  keycloak:
    image: quay.io/keycloak/keycloak:26
    command: start --proxy-headers=xforwarded --hostname=auth.example.com
    environment:
      KC_BOOTSTRAP_ADMIN_USERNAME: admin
      KC_BOOTSTRAP_ADMIN_PASSWORD: secret
      KC_DB: postgres
      KC_DB_URL: jdbc:postgresql://postgres:5432/app
    depends_on: [postgres]

volumes:
  pgdata: {}
  miniodata: {}

kong.yml — те же «ворота» API, декларативно:

_format_version: "3.0"
services:
  - name: app
    url: http://app:3000
    routes:
      - name: api
        paths: ["/api"]
    plugins:
      - name: jwt                  # JWT от Keycloak; openid-connect — Kong Enterprise
      - name: rate-limiting
        config:
          minute: 60

nginx.conf — пара CloudFront + S3 в одном файле:

server {
    listen 443 ssl;
    server_name app.example.com;

    ssl_certificate     /etc/letsencrypt/live/app.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;
    gzip on;

    location / {                    # статика — из volume на диске
        root /var/www/site;
        try_files $uri /index.html;
    }

    location /api {                 # динамика — дальше Kong
        proxy_pass http://kong:8000;
        proxy_set_header Host $host;
    }
}

app/server.js — то, что в части 1 делала Lambda, теперь просто HTTP-сервер:

import express from "express";
import pg from "pg";

const db = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const app = express();

app.get("/api/visits", async (_req, res) => {
  await db.query("insert into visits(at) values (now())");
  const { rows } = await db.query("select count(*)::int as n from visits");
  res.json({ ok: true, visits: rows[0].n });
});

app.listen(3000);

app/worker.js — то, что триггерила SQS:

import amqp from "amqplib";
import pg from "pg";

const db = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const ch = await (await amqp.connect(process.env.AMQP_URL)).createChannel();
await ch.assertQueue("jobs");

ch.consume("jobs", async (msg) => {
  await db.query("insert into jobs_log(payload) values ($1)", [msg.content.toString()]);
  ch.ack(msg);                      // не подтвердили — вернётся в очередь
});

Бэкапы — строкой в cron, уезжают в тот же MinIO:

# /etc/cron.d/app-backup
30 3 * * * cd /srv/app && docker compose exec -T postgres pg_dump -U app app | gzip \
  | docker run --rm -i minio/mc sh -c \
    'mc alias set b http://<VM-IP>:9000 app secret && mc pipe b/backups/app-$(date +\%F).sql.gz'

Деплой — одна команда, как sam deploy в части 1:

docker compose up -d --build

Как проверить, что всё работает

Первая проверка — все девять контейнеров живы:

docker compose ps

Вторая — анонима не пускает Kong, как в части 1 не пускал API Gateway:

curl -i https://app.example.com/api/visits   # 401 — нет токена

Третья — полный круг: токен из Keycloak, запрос, счётчик растёт:

TOKEN=$(curl -s https://auth.example.com/realms/app/protocol/openid-connect/token \
  -d grant_type=password -d client_id=app \
  -d [email protected] -d password='Demo-pass-1!' | jq -r .access_token)

curl -H "Authorization: Bearer $TOKEN" https://app.example.com/api/visits
# {"ok":true,"visits":1,...}

Четвёртая — мониторинг и бэкап: у Prometheus все таргеты живы, дамп восстанавливается:

curl -s localhost:9090/api/v1/query?query=up | jq '.data.result[].metric.job'

zcat backups/app-2026-10-20.sql.gz | \
  docker compose exec -T postgres psql -U app -d app_check   # проверка восстановления

Четыре проверки — и схема из референса живая: ворота отбивают анонима, токен открывает дверь, код пишет строку, метрики и бэкап на месте.

Итог

Схема действительно та же: две дорожки запроса, очередь для тяжёлого, IAM для токенов, метрики для сна по ночам — и весь стек в одном файле, только template.yaml стал docker-compose.yml. Изменился владелец коробок: обновления, health, бэкапы и рестарт в 03:00 — теперь ваши, и «$0/мес» на бейдже — это $0 за софт, а не за эксплуатацию. Поэтому честный выбор между этой схемой и serverless из части 1 — не между деньгами, а между контролем над каждым байтом и полным отсутствием эксплуатации; при постоянной нагрузке и чужих данных побеждает своя VM, при редких всплесках и «продукт к пятнице» — serverless. А если своя VM уже стоит, логичная соседняя коробка — WAF на минималках, который ставится ровно перед этим NGINX.


Поделиться:

Следующая статья
Cloud-архитектура приложения на AWS на минималках: serverless-стек — что делает каждый сервис и как читается схема (часть 1/2)