В части 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 53 | DNS | A-запись на 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.
«Та же архитектура» ≠ «тот же код»
Схема переносится, код — не весь. Три места, где подмена коробки меняет характер работы:
- Lambda — не «процесс, который всегда работает». Node.js-контейнер живёт на VM постоянно: холодных стартов нет, но платите за VM целиком и в простое. Autoscaling по числу запросов тоже нет: нагрузка выросла — растёт задержка, и расширяете её вы (второй инстанс, балансер, VM побольше), а не платформа.
- DynamoDB → PostgreSQL — смена модели данных, а не строки подключения. Вместо partition key и sort key — таблицы, JOIN и миграции; код доступа к данным переписывается. В плюс — SQL, транзакции и целостность на уровне базы; в минус — пулы соединений и схемы миграций становятся вашими.
- Cognito → Keycloak — мощнее и тяжелее. Keycloak — полноценный IAM на OpenID Connect: готовые экраны входа, MFA, соцпровайдеры, админка. Но это ещё один сервис, который ест память, требует обновлений и ходит в бэкап вместе со всеми.
Мониторинг повторяет ту же логику: Prometheus собирает метрики со всех девяти сервисов — то, что в AWS делал CloudWatch. Разница в том, что CloudWatch включался сам, а экспортеры для Postgres, NGINX и RabbitMQ настраиваете вы.
Бэкапы и обновления — то, чего не было в схеме
В AWS-схеме не было ни одной коробки «бэкап» — и это не упущение: DynamoDB и S3 реплицирует сервис. На VM всё наоборот: девять контейнеров, и данные каждого живут на диске, который умрёт вместе с диском. Минимальный слой:
- pg_dump по cron — раз в сутки, архив уезжает в MinIO или наружу за пределы VM. Конфигурация Keycloak тоже живёт в Postgres, так что уезжает тем же дампом.
- периодическая проверка восстановления — бэкап, который ни разу не восстанавливали, — это не бэкап, а предположение.
- обновления — раз в месяц
docker compose pull && docker compose up -d; пропущенные полгода — уже security-долг, а не экономия.
Без этого слоя self-hosted превращается в лотерею — на удачную сторону.
Сколько стоит «$0/мес»
Бейдж на референсе честно разложить по строкам:
- софт — правда $0: все девять сервисов — open source;
- железо — уже не $0: VM 2 vCPU / 4 ГБ (меньше Keycloak не любит) — это $6–15/мес за VPS или своя малина/старый ноут плюс электричество;
- домен — порядка $10/год, TLS — Let’s Encrypt бесплатно.
Итого реальный счёт — от ~$7/мес, а не ноль. Но и serverless-стек из части 1 при хобби-нагрузке стоит почти ноль: free tier AWS — миллион Lambda-запросов и 25 ГБ DynamoDB в месяц бесплатно, бессрочно. Выходит, на маленькой нагрузке оба варианта почти бесплатны — и выбор между ними не про деньги:
| Serverless на AWS | Self-hosted на своей VM | |
|---|---|---|
| Счёт за софт | за запросы | $0 — всё open source |
| Счёт за железо | внутри счёта за запросы | VM целиком, 24/7, даже в простое |
| При хобби-нагрузке | ≈ $0 (free tier) | ≈ $7–15/мес (VM + домен) |
| При постоянной нагрузке | растёт с каждым запросом | фиксирован размером VM |
| Волна с Hacker News | масштабируется сама | упирается в VM, скейл — ваш |
| Обновления и патчи | их | ваши: docker compose pull |
| Бэкапы | сервис | ваши: pg_dump + проверка |
| Где лежат данные | у провайдера | полностью у вас |
| Порог входа | выучить сервисы и лимиты AWS | Linux, 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.