Модуль 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).

go
func 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_children FPM.

Где 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) + stream packfile.
  • Репликация: каждая 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-бинарнике).
go
writer := &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
RuntimeGo binary, 5 ВМ, chan bulkheadGo binary, 50 ВМ — рантайм НЕ меняется—❌ нет аналога «PHP-FPM → RoadRunner»
Storageодин SSD/NFS, все репо10–20 Storage Nodes (NVMe), шард по repo_idIOPS одного диска — потолоктот же Gitaly-паттерн
BrokerRedis Streams (Outbox, Модуль 3)Kafka, partitioning по repo_id, RF=3однопоточность Redis, персистентностьтот же переход, segmentio/kafka-go на Go
DBPostgreSQL 16 + PgBouncer + 2 реплики+ Citus, шард по repo_id на 5 data nodesмастер не справляется с записьюpgx-клиент не меняется
CacheRedis Cache-Aside деревьев (Модуль 5)+ CDN для immutable /tree/{sha} (1 час)80 % трафика — immutable чтениетот же паттерн
Loggingstdout / файлыKafka + Loki/ClickHouse (структурированный)объём логов × 10slog на Go вместо Monolog
Сеть (K8s)kube-proxy/iptablesCilium (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 с планом миграции.

  1. Альтернативы (мин. 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, сложность миграции.
  2. Метрики (обязательно): $/мес, p99 latency, RTO, write-RPS на мастере PG, IOPS на диске, runtime.NumGoroutine на ВМ.

  3. План миграции без простоя:

    • 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).

✅ Готово, когда:

  • 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).

Вопросы студенту:

  1. Почему «Go быстрый» — недостаточный аргумент, если диск — NFS? (рантайм быстрым, но I/O остаётся узким местом; 100k горутин в iowait = 100k простаивающих горутин).
  2. Почему Redis Cluster не решит проблему «hot репо»? (партиционирование по ключу: hot repo → одна партиция перегружена, балансировка не помогает).
  3. Почему read-реплики PG не снимут write-CPU? (реплики масштабируют только чтение; запись остаётся на мастере → нужен Citus/шардирование).

📐 Задание по визуализации (Mermaid)

Нарисуйте топологию профиля 100k на Go: git-gateway, 50 ВМ Go runtime, Storage Nodes (NVMe), Kafka (3 брокера), Citus (5 data nodes), CDN.

Шаблон для ориентира:

Топология 100k RPS на Go: Storage Nodes + Kafka + Citus + CDN
Сравнение 10k vs 100k на Go: рантайм НЕ меняется, storage/broker/DB — да

📖 Компромиссы (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/DB100k 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 scalecross-shard JOIN, сложность>10k write RPS
CDN для immutable80% трафика не доходит до бэкенданастраивать 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.


🧩 Проверь себя

1. Почему Kafka, а не Redis Streams при 100k событий/сек?
2. Зачем шардировать bare-репозитории по Storage Nodes (Gitaly-паттерн)?
3. Почему на Go НЕ нужен RoadRunner при переходе 10k → 100k?
4. CDN на GET /tree/{sha} — зачем, если уже есть Redis-кэш (Модуль 5)?
5. Что на Go меняется при 10k → 100k (отметьте верное)?

🤖 Инструкция для LLM (Coding Plan Assistant) — Режим Модуля 7

Привет! Я в Модуле 7 курса «Git Hosting на Go» — масштабирование с 10k до 100k RPS.

  1. Дай короткий ответ (2–3 предложения): почему на Go не нужен RoadRunner (в отличие от Symfony-курса), и какие три слоя всё-таки надо менять?

  2. Объясни Storage Nodes (Gitaly-паттерн) и Kafka (segmentio/kafka-go, партиционирование по repo_id). Почему HashBalancer важен для упорядоченности webhook'ов?

  3. Покажи Антипаттерн («Go быстрый + NFS + Redis Cluster без Citus») и попроси объяснить, почему runtime-ускорение бесполезно при узком диске.

  4. Дай задание: написать 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-отчёт и пройдёте ролевую защиту.