Модуль 7. Scale to 100k — Go не нужен RoadRunner, но нужен Kafka + Citus + CDN
Цель модуля: Научить студента эволюционировать Git-хостинг на Go с 10 000 RPS до 100 000 RPS. Главный вывод модуля: на Go не нужно менять рантайм (PHP-FPM → RoadRunner), как в Symfony-курсе — Go уже long-lived из коробки, горутины дёшевы. Узкое место переезжает в инфраструктуру: диск (Storage Nodes по образцу Gitaly), брокер (Redis Streams → Kafka), БД (Citus), edge-кэш (CDN для immutable SHA). 10× — это не «добавить ноль к числу ВМ», а смена инфраструктурных слоёв, а не рантайма.
Перед началом: Модули 1–6 (особенно Модуль 5 — Cache-Aside, Модуль 3 — Outbox горутиной) Инструменты:
segmentio/kafka-go, Citus, Yandex CDN, consistent hashing, NVMe Storage Nodes Трудоёмкость: ~7 ч (теория 2.5 ч · практика 3.5 ч · ADR 1 ч)
🔥 Инцидент: 10× — это не «добавить ВМ»
Система держит 10 000 RPS на 5 ВМ с Go (по ~10k горутин на ВМ — runtime.GOMAXPROCS(4)). Все паттерны на месте: Bulkhead (chan struct{}), Outbox (FOR UPDATE SKIP LOCKED), Cache-Aside деревьев в Redis, PR Saga. Графики зелёные, p99 < 50 мс.
В четверг — письмо от продукта: «В следующем квартале ожидаем 100 000 RPS: массовая миграция с GitLab, вирусный рост публичных репозиториев». Команда прикидывает линейно: 10 000 RPS = 5 ВМ → 100 000 RPS = 50 ВМ. На Symfony-курсе на этом этапе появлялась проблема PHP-FPM: 50 ВМ × 50 процессов = 2 500 процессов Symfony, fork + bootstrap = 20–30 мс overhead, нужен RoadRunner. На Go этой проблемы нет: 50 ВМ × 10k горутин = 500k горутин, runtime thread pool (GOMAXPROCS) един, fork/exec нет, bootstrap один раз на ВМ. Рантайм не меняется.
Но всплывают три другие узкие места:
- Диск: один NFS/SSD держит все bare-репозитории.
git cloneкрупного репо = чтение packfile в сотни МБ. IOPS упираются в потолок,iostatпоказывает 100 % util. - Redis Streams для Outbox (Модуль 3): при 100k событий/сек Redis упирается в однопоточность (одно ядро на весь broker), репликация отстаёт, персистентности «из коробки» для multi-broker нет.
- PostgreSQL (мастер): write-CPU 95 %, read-реплики не помогают (мастерируют только чтение, не запись).
Линейное масштабирование Go-бинарника до 50 ВМ — технически работает (рантайм не упирается). Но три инфраструктурных слоя — диск, брокер, БД — упираются. 10× требует смены их, а не рантайма. Это и есть отличие от Symfony-курса, где первым менялся рантайм (FPM → RoadRunner).
📖 Теория: Раскрытые термины и скрытые риски
Глоссарий модуля:
- Long-lived runtime (Go) — один бинарник живёт весь lifecycle процесса; запросы обслуживаются горутинами (легковесными, ~2 КБ стека), без fork. В отличие от PHP-FPM, не нужен RoadRunner, чтобы стать long-lived.
- Storage Node — ВМ с High-IOPS SSD (NVMe, ~100k IOPS), держащая подмножество bare-репозиториев. Шардирование по
repo_id(consistent hashing). Образец — Gitaly в GitLab. - Kafka partitioning — партиционирование топика по ключу (
repo_id): все события одного репозитория попадают в одну партицию и обрабатываются последовательно. - Citus — расширение PostgreSQL для шардирования: таблица
repositoriesразбивается поrepo_idна data nodes, распределённые JOIN'ы — прозрачны. - Immutable object (CDN) — объект, идентифицируемый SHA (
/tree/{sha},/blob/{sha}): содержание никогда не меняется → кэшируется на CDN на 1 час. - segmentio/kafka-go — чисто-Go Kafka-клиент (без cgo), подходит для Yandex Cloud Managed Kafka.
7.1 Главное отличие от Symfony: Go НЕ нужен RoadRunner
PHP-FPM на каждый запрос делает fork() (копирование процесса) + bootstrap Symfony (DI-контейнер, кэш) — это 20–30 мс «чистого» overhead, прежде чем начнётся бизнес-логика. На 10k RPS этим можно пренебречь. На 100k RPS — 7 500 воркеров × 30 мс = константа, съедающая CPU. RoadRunner (Symfony-курс, Модуль 7) решает это: bootstrap один раз, воркеры живут в памяти, запрос за 1–2 мс.
На Go этот шаг отсутствует как отдельная технология: бинарник и так long-lived. Один main() стартует http.Server, каждый запрос — горутина (легче OS-трека в 100×), bootstrap регается один раз в init()/при старте. Не нужен RoadRunner, не нужен PHP-FPM pool, не нужны два пула (api-pool + git-pool из Symfony-курса Модуль 3).
gofunc main() { srv := &http.Server{Addr: ":8080", Handler: router} go srv.ListenAndServe() // graceful shutdown <-ctx.Done() srv.Shutdown(shutdownCtx) }
- Bulkhead (Модуль 3) уже на
chan struct{}— не нужны два пула FPM + Nginx upstream, одна конструкция. - Memory — GC управляет; streaming packfile через
io.Readerбез буферизации (Модуль 2), нет OOM как в PHP на >16 МБ packfile. - Worker pool —
runtime.GOMAXPROCS(n)+workerPoolсchan; не нуженpm.max_childrenFPM.
Где Go всё-таки требует изменений при 100k: не в рантайме, а в эксплуатации —
gRPCмежду сервисами (нужен Service Mesh при 50+ подах), мониторинг горутин (runtime.NumGoroutine, pprof), graceful shutdown (context.WithTimeoutна 30 с для in-flight merges).
7.2 Шардирование хранилища: Storage Nodes (как Gitaly)
git clone крупного репозитория читает packfile в сотни МБ. Один диск на все репо → IOPS упираются в потолок. Решение — Storage Nodes: 10–20 ВМ с High-IOPS SSD (NVMe, ~100k IOPS каждая), репозитории распределены по repo_id (consistent hashing → минимум перемещений при добавлении ноды).
ALB / Ingress
│
├── git-gateway (Go binary, exec.Command streaming — Модуль 2)
│ │
│ └── storage-router: repo_id → Storage Node (directory-сервис в PG)
│ │
├── Storage Node 0 (repo_id: 0..999) ── bare repos на NVMe
├── Storage Node 1 (repo_id: 1000..1999)
└── ...
- Gitaly-паттерн (GitLab): git-gateway не читает файлы сам — он делает RPC к Storage Node, на которой лежит репозиторий. На Go это
grpc.Dial(storageNodeAddr)+streampackfile. - Репликация: каждая Storage Node имеет read-реплику (rsync/git-fsck по расписанию) для отказоустойчивости. RPO = интервал репликации (1 мин).
- Resharding: при добавлении ноды consistent hashing перемещает только часть репо. Перенос =
git clone --bareсо старой ноды + switch в directory-сервисе.
Trade-off: cross-repo операции (fork, mirror) теперь требуют RPC между Storage Nodes. Гарантия «один репо — одна нода» упрощает locking и packfile-стриминг. См. Модуль 2 (streaming) — паттерн сохраняется, добавляется только routing-слой.
7.3 Kafka вместо Redis Streams
На 10k RPS Outbox-воркер (Модуль 3) публиковал события в Redis Streams. Redis однопоточен: один поток на весь broker. При 100k событий/сек ядро Redis уходит в 100 % CPU, XREAD отстаёт, репликация в Redis Sentinel деградирует. Плюс персистентность Redis — AOF/RDB, что не гарантирует zero-loss при падении нескольких брокеров.
Kafka решает обе проблемы. На Go используем github.com/segmentio/kafka-go (чисто-Go, без cgo — важно для Yandex Serverless):
- Партиционирование по
repo_id: топикgit-push-eventsразбит на 100 партиций,partition = hash(repo_id) % 100. Все события одного репо идут в одну партицию → последовательная обработка (важно для упорядоченности webhook'ов, см. Модуль 4 — Outbox). - Персистентность: Kafka пишет на диск (append-only лог) и реплицирует на N брокеров (RF=3). Переживает падение 1–2 брокеров без потери событий.
- Consumer groups: параллельность = число партиций. 100 партиций → 100 консьюмеров независимо масштабируются (горутины в Go-бинарнике).
gowriter := &kafka.Writer{ Addr: kafka.TCP("kafka-1:9092", "kafka-2:9092", "kafka-3:9092"), Topic: "git-push-events", Balancer: &kafka.HashBalancer{}, // partitioning по ключу RequiredAcks: kafka.RequireAll, } writer.WriteMessages(ctx, kafka.Message{ Key: []byte(strconv.FormatInt(repoID, 10)), // ключ → hash → partition Value: payload, Headers: []kafka.Header{{Key: "trace_id", Value: []byte(traceID)}}, })
Trade-off: Kafka тяжелее в эксплуатации (брокеры, retention, monitoring). Redis Streams были «из коробки» с Redis-кэшем. Переход оправдан только при > 30k событий/сек или требованиях к персистентности. На 10k — оставайтесь на Redis. См. ADR-GG7.
7.4 PostgreSQL Citus: шардирование метаданных
Метаданные (таблицы repositories, pull_requests, users) при 100k RPS записи упираются в мастер. Сначала исчерпываем стандартную эволюцию из arch m8 (PgBouncer → read-реплики → партиционирование). Когда запись на мастере упирается в CPU — Citus.
Citus — расширение PostgreSQL, превращающее кластер в распределённую БД. На Go используем тот же github.com/jackc/pgx/v5 — Citus совместим на уровне протокола, отличия только в SQL:
repositoriesшардится поrepo_id(HASH) на 5 data nodes:SELECT create_distributed_table('repositories', 'repo_id');- Запрос
SELECT * FROM repositories WHERE repo_id = 42идёт на одну ноду (co-locator). - Запрос без
repo_id(cross-shard) рассылается на все ноды — дороже, но редок (metadata-запросы почти всегда по репо).
Связь с arch m8 (§8.11): шардирование — крайняя мера. Сначала PgBouncer, read-реплики, кэш (Модуль 5). Citus подключается, когда мастер не справляется именно с записью. Cross-shard JOIN'ы (например,
pull_requests JOIN users) требуют co-location по одному ключу — проектируйте схему так, чтобы агрегат лежал на одном шарде.
7.5 CDN для immutable-объектов
80 % трафика Git-хостинга — чтение: GET /tree/{sha}, GET /blob/{sha}, git clone (packfile immutable по SHA). Содержание дерева с данным SHA никогда не меняется (SHA — это хэш содержания). Значит, ответ кэшируется навсегда.
CDN (Yandex CDN) стоит перед Go-бэкендом:
GET /tree/{sha}→Cache-Control: public, max-age=3600, immutable. CDN кэширует на 1 час.- 80 % трафика не доходит до бэкенда — CDN отдаёт из своего кэша.
- Эффективно: вместо 100k RPS на Go — только 20k RPS (новые/уникальные SHA), остальное снимает CDN.
Связь с arch m5 (Cache-Aside → CQRS): CDN — это Cache-Aside на стероидах, на edge-уровне. Дальнейшая эволюция — CQRS Read Models: консьюмеры Kafka строят оптимизированные проекции деревьев, CDN кэширует уже их. Но immutable-SHA — самый дешёвый первый шаг.
7.6 Сводная таблица: что меняется 10k → 100k (ветка Go)
| Слой | 10k RPS (Модули 1–6) | 100k RPS (Модуль 7) | Почему меняем | Отличие от Symfony |
|---|---|---|---|---|
| Runtime | Go binary, 5 ВМ, chan bulkhead | Go binary, 50 ВМ — рантайм НЕ меняется | — | ❌ нет аналога «PHP-FPM → RoadRunner» |
| Storage | один SSD/NFS, все репо | 10–20 Storage Nodes (NVMe), шард по repo_id | IOPS одного диска — потолок | тот же Gitaly-паттерн |
| Broker | Redis Streams (Outbox, Модуль 3) | Kafka, partitioning по repo_id, RF=3 | однопоточность Redis, персистентность | тот же переход, segmentio/kafka-go на Go |
| DB | PostgreSQL 16 + PgBouncer + 2 реплики | + Citus, шард по repo_id на 5 data nodes | мастер не справляется с записью | pgx-клиент не меняется |
| Cache | Redis Cache-Aside деревьев (Модуль 5) | + CDN для immutable /tree/{sha} (1 час) | 80 % трафика — immutable чтение | тот же паттерн |
| Logging | stdout / файлы | Kafka + Loki/ClickHouse (структурированный) | объём логов × 10 | slog на Go вместо Monolog |
| Сеть (K8s) | kube-proxy/iptables | Cilium (eBPF) | сетевой overhead при 100k соединений | не зависит от языка |
Главный вывод: на Go меняются три слоя (брокер, storage, БД) + добавляется CDN — но рантайм остаётся прежним. В Symfony-курсе первым менялся рантайм (FPM → RoadRunner); на Go этот шаг — пустой. Симметрия с arch m8: «каждая ступень масштабирования решает проблему предыдущей».
7.7 Связь с курсом «Архитектура распределённых систем»
- arch m8 (Эволюция БД): линейка PgBouncer → read-реплики → кэш → партиции → шардирование (Citus) → CQRS. Мы на шаге шардирования.
- arch m5 (Cache-Aside): Redis-кэш → CDN (edge-cache) → CQRS Read Models. CDN — следующий шаг для immutable-объектов.
- arch m3 (Outbox): Redis Streams → Kafka — эволюция транспортного слоя при росте нагрузки и требований к персистентности.
🛠 Практика: ADR «Что менять при переходе с 10k на 100k на Go»
Сценарий: Git-хостинг на Go вырос с 10 000 RPS до 100 000 RPS. Текущая архитектура (Модули 1–6) — рантайм не упирается (Go long-lived), но IOPS диска, Redis, мастер PG — на пределе.
Задача: написать ADR-GG7 с планом миграции.
-
Альтернативы (мин. 3):
- A. Линейное масштабирование Go: 50 ВМ × 10k горутин. Метрика: $/мес, p99, RTO (рантайм справится, но IOPS/PG — нет).
- B. CDN + Kafka (cache + broker): 50 ВМ Go + CDN отсекает 80 % + Kafka (3 брокера). Метрика: во сколько раз дешевле? Что ещё упирается?
- C. Полный переход (B + Storage Nodes + Citus): 50 ВМ Go + CDN + Kafka + 15 Storage Nodes (NVMe) + Citus (5 data nodes). Метрика: $/мес, p99, RTO, сложность миграции.
-
Метрики (обязательно): $/мес, p99 latency, RTO, write-RPS на мастере PG, IOPS на диске,
runtime.NumGoroutineна ВМ. -
План миграции без простоя:
- CDN: настраиваем впереди бэкенда,
Cache-Control: immutableдля/tree/{sha}— rollback = убрать CDN. - Storage Nodes: dual-write (новые репо на Storage Nodes, старые мигрируются
git clone --bare) → cut-over в directory-сервисе. - Kafka: dual-publish (Outbox пишет и в Redis Streams, и в Kafka) → переключение консьюмеров → отключение Redis Streams.
- Citus: dual-write таблиц → backfill → cut-over (как в arch m8 §8.11).
- CDN: настраиваем впереди бэкенда,
✅ Готово, когда:
- ADR-GG7: 3 альтернативы с количественным сравнением ($/мес, p99, RTO, IOPS)
- Выбрана альтернатива C и обоснована (почему B — недостаточен: диск/мастер PG упираются)
- В ADR отмечено, что рантайм Go не меняется (5 ВМ → 50 ВМ — больше того же бинарника)
- План миграции без простоя (dual-write + cut-over для каждого слоя)
⚠️ Антипаттерн для разбора
textСценарий: 100 000 RPS на Go. Команда «решает» масштабирование: — Go и так быстрый, просто поднимаем 50 ВМ, рантайм не трогаем. — Диск: оставляем один NFS на все репозитории (RAID-10, 1 ТБ). — Redis Streams оставляем, просто поднимаем Redis Cluster. — Citus «пока не трогаем», обойдёмся read-репликами PG. Симптомы через неделю: — Go-бинарники держат 100k горутин, но все они ждут I/O на NFS (диск — узкое место). — Redis Cluster: партиции бьются неравномерно (hot репо → hot партиция). — Мастер PG: 100 % CPU на записи (read-реплики не снимают write).
Вопросы студенту:
- Почему «Go быстрый» — недостаточный аргумент, если диск — NFS? (рантайм быстрым, но I/O остаётся узким местом; 100k горутин в
iowait= 100k простаивающих горутин). - Почему Redis Cluster не решит проблему «hot репо»? (партиционирование по ключу: hot repo → одна партиция перегружена, балансировка не помогает).
- Почему read-реплики PG не снимут write-CPU? (реплики масштабируют только чтение; запись остаётся на мастере → нужен Citus/шардирование).
📐 Задание по визуализации (Mermaid)
Нарисуйте топологию профиля 100k на Go: git-gateway, 50 ВМ Go runtime, Storage Nodes (NVMe), Kafka (3 брокера), Citus (5 data nodes), CDN.
Шаблон для ориентира:
📖 Компромиссы (Trade-offs)
| Подход | Плюс | Минус | Когда выбирать |
|---|---|---|---|
| Go binary 10k (5 ВМ) | long-lived без RoadRunner, стриминг без OOM, chan bulkhead | один диск/PG/Redis упираются при росте | до 10k RPS |
| Go binary 100k (50 ВМ) — рантайм тот же | не нужен RoadRunner; просто больше того же бинарника | IOPS/PG/Redis упираются → нужно менять storage/broker/DB | 100k RPS |
| Redis Streams | просто, встроено в Redis, consumer groups | пропускная способность ниже Kafka, less durable | до 10k сообщений/сек |
Kafka (segmentio/kafka-go) | персистентность, партиционирование, переживает падение брокера | сложнее в эксплуатации, больше инфраструктуры | >10k сообщений/сек, продакшен |
| Single PostgreSQL | просто, ACID, один инстанс | CPU упирается при росте writes | до 10k write RPS |
| Citus (sharded PG) | шардирование по repo_id, горизонтальный write scale | cross-shard JOIN, сложность | >10k write RPS |
| CDN для immutable | 80% трафика не доходит до бэкенда | настраивать cache invalidation (для mutable) | tree/{sha}, blob/{sha} |
Главный вывод: 10k → 100k на Go — это не «PHP-FPM → RoadRunner» (этого шага нет). Это смена парадигмы в инфраструктуре: Redis → Kafka, single PG → Citus, 1 диск → 10–20 Storage Nodes, Nginx cache → CDN. Бизнес-логика (Go
cmd/) не меняется — меняется только infrastructure layer.
🧩 Проверь себя
🤖 Инструкция для LLM (Coding Plan Assistant) — Режим Модуля 7
Привет! Я в Модуле 7 курса «Git Hosting на Go» — масштабирование с 10k до 100k RPS.
Дай короткий ответ (2–3 предложения): почему на Go не нужен RoadRunner (в отличие от Symfony-курса), и какие три слоя всё-таки надо менять?
Объясни Storage Nodes (Gitaly-паттерн) и Kafka (
segmentio/kafka-go, партиционирование поrepo_id). ПочемуHashBalancerважен для упорядоченности webhook'ов?Покажи Антипаттерн («Go быстрый + NFS + Redis Cluster без Citus») и попроси объяснить, почему runtime-ускорение бесполезно при узком диске.
Дай задание: написать ADR-GG7 «Что менять при переходе с 10k на 100k на Go» — 3 альтернативы (линейный / CDN+Kafka / полный переход), метрики ($/мес, p99, RTO, IOPS), план миграции без простоя.
НЕ ПИШИ за меня ADR. Дождись моего плана.
📝 Чек‑лист ментора
- Runtime: Go binary long-lived — без RoadRunner. 5 ВМ → 50 ВМ — больше того же бинарника. Bulkhead на
chan struct{}(Модуль 3) сохраняется. - Storage: шардирование bare-репозиториев по Storage Nodes (NVMe, High-IOPS) по
repo_id. Один диск — только для 10k-профиля. - Broker: Kafka (
segmentio/kafka-go) с партиционированием поrepo_id(RF=3),HashBalancer. Обоснован порог перехода (> 30k событий/сек или персистентность). - DB: Citus подключён только когда мастер не справляется с записью. Шард по
repo_id.pgx-клиент не меняется. - CDN:
GET /tree/{sha}кэшируется (immutable, 1 час). Оценка: 80 % трафика снимается на edge. - ADR-GG7: 3 альтернативы, метрики ($/мес, p99, RTO, IOPS), план миграции без простоя (dual-write + cut-over).
- Symmetry с Symfony-курсом: явно отмечено, что шаг «PHP-FPM → RoadRunner» на Go пустой; меняются broker/storage/DB.
🔗 Мостик к Модулю 8 (Defense)
Архитектура спроектирована: Go-хостинг на 100k RPS — streaming, Storage Nodes, Kafka, Citus, CDN. А главное — мы пропустили шаг RoadRunner (и сэкономили на эксплуатации отдельной технологии). Теперь защитите её перед комитетом — и сравните TCO Go vs Symfony. CTO спросит: «Почему Go, а не PHP?». Финдиректор: «Сколько сэкономили на RoadRunner?». В Модуле 8 вы соберёте 4 ADR, C4, SLO/Cost-отчёт и пройдёте ролевую защиту.