Модуль 3. Bulkhead — chan struct{} вместо пулов FPM

Цель модуля: применить паттерн Bulkhead (arch m5) к Git-хостингу на Go: ограничить параллелизм git-стримов через chan struct{} (максимум 5 concurrent), а API оставить без ограничения. Добиться, чтобы перегрузка git-потоком не убивала Web UI и не исчерпывала IOPS диска.

Перед началом: Модули 1–2 (Smart HTTP, exec.Command + io.Copy), arch m5 (Bulkhead, Cascading Failure) Инструменты: Go chan struct{}, select, context, k6, Prometheus Трудоёмкость: ~4 ч (теория 1 ч · практика 2.5 ч · ADR 0.5 ч)

🔥 Инцидент: 50 клонов по 1 ГБ кладут API

Релиз крупного монорепо. 50 разработчиков одновременно делают git clone репозиториев по 1 ГБ. Каждый стрим занимает goroutine на ~300 секунд (пока не выльется весь packfile через io.Copy). Go не ограничивает количество goroutines автоматически — их может быть 100 000. Все 50 goroutines заняты стримингом и держат I/O-устройство диска (IOPS) и CPU (копирование буферов).

В этот же момент Product Manager открывает Web UI, чтобы глянуть список Pull Requests. Запрос GET /api/prs делает PostgreSQL-запрос и рендер JSON — попадает на то же ядро CPU, на тот же диск, что и 50 стримов. p99 API взлетает с 50 мс до 4 секунд, Web UI тормозит. Один тип нагрузки — массовый clone — убил весь сервис целиком через общую goroutine-runtime и общую дисковую очередь.

Это классический Cascading Failure (arch m5): отказ не в компоненте, а в общем пуле ресурсов. Один медленный тип нагрузки (300-секундные стримы) вытеснил быстрый (JSON за 50 мс). На Symfony это решалось двумя пулами FPM (api-fpm с 20 воркерами и git-fpm с 5 воркерами, жёстко разделёнными на уровне Nginx upstream). На Go не нужно два процесса, не нужно Nginx upstream — нужна одна конструкция в коде: chan struct{}.

📖 Теория: Bulkhead через chan struct{}

3.1. Bulkhead (связка с arch m5)

Bulkhead (Переборка) — паттерн из arch m5: разделение ресурсов на изолированные пулы, чтобы отказ/перегрузка в одном не утянула остальные. Аналогия — переборки в корпусе корабля: если один отсек затоплен, остальные остаются сухими.

Применительно к нашему стеку (Go) изоляция делается в коде, не в инфраструктуре: семафор на chan struct{} ограничивает параллелизм определённого типа запросов, тогда как другие типы работают без ограничения.

Bulkhead — это не про «больше серверов», а про изоляцию отказов. Можно добавить 100 goroutine-слотов в общий пул — и всё равно получить зависание API при волне клонов. Пулы должны быть физически разделены в коде, чтобы исчерпание одного не влияло на другой. На Go это 5 строк кода, на Symfony — два pool.conf + Nginx upstream config.

3.2. Реализация: семафор на chan struct{}

Семафор на chan struct{} — классический Go-идиomatic подход для ограничения конкурентности. Канал с буфером N работает как пул из N слотов: отправить в канал = занять слот (блокирует, если слот занят), получить из канала = освободить слот.

go
// internal/handler/git.go
type GitHandler struct {
    root    string
    gitSem  chan struct{}     // семафор на 5 concurrent git-операций
    errorLog *log.Logger
}

func NewGitHandler(root string) *GitHandler {
    return &GitHandler{
        root:   root,
        gitSem: make(chan struct{}, 5),   // Bulkhead: максимум 5 параллельных стримов
    }
}

func (h *GitHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    // Bulkhead: пытаемся занять слот, но прерываемся, если клиент ушёл
    select {
    case h.gitSem <- struct{}{}:           // заняли слот (или блокируем, если заполнен)
        defer func() { <-h.gitSem }()      // освободить слот в конце (даже при panic)
    case <-r.Context().Done():              // клиент закрыл соединение — не ждать слот
        http.Error(w, "client gone", http.StatusServiceUnavailable)
        return
    }

    // дальше — обычный exec.Command + io.Copy из Модуля 2
    cmd := exec.Command(gitBackend)
    // ... окружение, stdin, stdout
    io.Copy(w, stdout)
}

Размер семафора намеренно мал (5). Это и есть Bulkhead: даже если все 5 слотов заняты стримом, это всего 5 goroutines — API это не затронет. «Больше слотов для git» = выше риск OOM и CPU-голода; лучше ограничить параллелизм и отбивать лишнее 503 (см. 3.4).

3.3. API без семафора (быстрый пул)

API-обработчики (GET /api/prs, GET /api/repos) — быстрые (5–10 мс на PostgreSQL-запрос + JSON). Они не блокируют I/O-диск и не стримят гигабайты. Поэтому API идёт без семафора — net/http уже даёт goroutine на каждый запрос, и 100 параллельных API-запросов нормальны (goroutines легковесны, ~2 КБ стека).

go
// internal/handler/api.go
type APIHandler struct {
    db *pgxpool.Pool // быстрый пул PG-соединений
}

func (h *APIHandler) ListPRs(w http.ResponseWriter, r *http.Request) {
    rows, _ := h.db.Query(r.Context(), "SELECT ... FROM pull_requests LIMIT 50")
    // ... ~5ms на PG-запрос, рендер JSON, 200 OK
    // Семафора нет — API не изолируется, но он и не медленный
}

На Symfony это достигалось api-fpm пулом с pm.max_children=20, max_execution_time=5s. На Go — никаких настроек FPM, просто отсутствие gitSem в обработчике. Это и есть упрощение: одна переменная решает изоляцию вместо двух пул-файлов + Nginx upstream.

3.4. Что происходит при переполнении git-семафора

6-й одновременно clone видит: все 5 слотов gitSem заняты. select ждёт либо освобождения слота, либо отмены клиентом (r.Context().Done()). Git-клиент по таймауту закрывает соединение → Go отдаёт 503 Service Unavailable. Git-клиент ретраит (с экспоненциальным backoff — он это умеет из коробки).

Критически важно: в этот момент API-обработчики продолжают отвечать за < 50 мс. Web UI жив, список PR открывается, метрики доступны. Отказ изолирован в одном отсеке — именно для этого и нужен Bulkhead.

На Symfony при переполнении git-пула Nginx отдаёт 502 Bad Gateway (FPM не принял запрос — Connection refused). На Go отдаём 503 Service Unavailable (semantic: «сервис перегружен, попробуйте позже»). 503 более семантичен — он прямо говорит «временно недоступно», а не «upstream крашнулся». Git-клиенты и CDN ретраят 503 лучше, чем 502.

3.5. select + context — таймаут вместо бесконечного ожидания

В чистом chan struct{} без select goroutine блокируется на gitSem <- struct{}{} бесконечно — если все 5 слотов заняты на 5 минут, 1000 клонов повесят 1000 goroutines. Решение — select с r.Context().Done(), который закрывается, когда клиент рвёт соединение:

go
select {
case h.gitSem <- struct{}{}:
    defer func() { <-h.gitSem }()
case <-r.Context().Done():
    http.Error(w, "503 git pool overloaded, retry later", http.StatusServiceUnavailable)
    return
}

defer func() { <-h.gitSem }() — важная деталь: освобождаем слот гарантированно, даже при panic внутри обработчика. Если забыть defer и релизить вручную — при ошибке слот «утечёт», семафор исчерпается за день. Это аналог забытого fclose($pipes[1]) в Symfony-адаптере — но компилятор Go не подскажет, поэтому правило жёсткое: всегда defer.

3.6. Связь с arch m5

Концепция arch m5Применение в Git-хостинге на Go
Cascading Failureмассовый clone вытесняет API через общую goroutine-runtime и IOPS
Bulkhead (переборка)chan struct{} семафор на 5 для git, без семафора для API
Изоляция на уровне кодаодин select в обработчике, без инфраструктуры (Nginx, FPM)
Осознанная деградацияgit-пул переполнен → 503 + ретрай, API жив

Bulkhead не устраняет перегрузку — он локализует её. В Модулях 4–5 мы добавим Outbox (push → webhook) и кэш деревьев, а в Модуле 7 — Kafka + Citus для horizontal scale. Но Bulkhead остаётся всегда — это первая линия защиты.

3.7. Компромиссы (Trade-offs)

РешениеПлюсМинусКогда выбирать
chan struct{} семафор (Bulkhead)отказ git не убивает API; 5 строк коданедоисследование ресурсов (слот фиксирован)всегда для mixed-нагрузки (10k)
Один общий пул goroutinesполное использование CPUодин тип нагрузки кладёт весь сервисNEVER для Git-хостинга
errgroup с SetLimit(N)интеграция с ctx, обработка ошибокчуть тяжеловеснее для семафоратам, где нужна агрегация ошибок
Worker pool (jobs channel + N workers)контроль backpressure, идемпотентные воркерыбольше кода, надо care про shutdownlong-running pipeline
Без select (только chan <-)простотаgoroutine вешается навечно при переполненииNEVER — нужен таймаут

Вывод: на Symfony Bulkhead = api.pool.conf + git.pool.conf + Nginx upstream config (~60 строк инфраструктуры). На Go — 5 строк chan struct{} + select + defer. Это и есть «Go упрощает изоляцию»: одна конструкция заменяет целый слой инфраструктуры.


🛠 Практические задания

Задача 1: Реализовать семафор в GitHandler

В internal/handler/git.go добавить gitSem := make(chan struct{}, 5) в конструкторе и select + defer func() { <-h.gitSem }() в начале ServeHTTP. Убедиться, что API-обработчик не использует gitSem — он в другом handler'е.

Задача 2: Нагрузочный тест k6 — изоляция под нагрузкой

Скрипт k6/bulkhead.go.js: 100 параллельных clone (через git CLI, в bash-обёртке) + 1000 API-запросов GET /api/prs. Запустить две волны одновременно:

bash
# волна 1: 100 clone репозитория 1 ГБ (упрётся в gitSem = 5)
for i in $(seq 1 100); do git clone http://localhost:8080/big.git /tmp/c$i & done

# волна 2: 1000 API-запросов
k6 run k6/api-load.js   # 1000 итераций GET /api/prs

Задача 3: Метрики и верификация SLO

Экспонировать в Prometheus: git_semaphore_inuse (gauge), git_semaphore_blocked_total (counter), http_requests_total{handler="api|git", status="503"}. Убедиться, что при перегрузке git-семафора API отвечает p99 < 50 мс, а 503 растут только на *.git/*.

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

  • gitSem в GitHandler: make(chan struct{}, 5) + select + defer func() { <-h.gitSem }()
  • API-обработчик не использует gitSem — изоляция пулов в коде видна в diff
  • k6: при 100 параллельных clone + 1000 API-запросов → p99 API < 50 мс
  • При переполнении git-пула: 6-й clone получает 503 Service Unavailable, git-клиент ретраит
  • Метрики git_semaphore_inuse доступны Prometheus; график показывает, что API не насыщается при росте git-семафора
  • ADR-GG3 «Bulkhead: chan struct{} для git, без ограничений для API» написан (со ссылкой на arch m5)

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

1. Зачем select { case gitSem <- struct{}{}: case <-r.Context().Done(): return }?
2. Как Go ограничивает количество goroutines автоматически?
3. Что делает gitSem := make(chan struct{}, 5) в GitHandler?
4. При переполнении git-семафора Go отдаёт код ошибки…
5. Что делает defer func() { <-h.gitSem }()?

🔗 Мостик к Модулю 4

Пулы изолированы, клон больше не убивает API, SLO по p99 API удерживается < 50 мс. Но после успешного git push нужно отправить вебхук в CI/CD (уведомить Jenkins/GitHub Actions о новом коммите). И здесь нас поджидает классическая ловушка из arch m2–m3 — Dual-Write: записать push в БД (обновить refs) и отправить вебхук — две разные операции, и если упадёт вторая, CI никогда не узнает о коммите. На Symfony это решалось Symfony Messenger + Redis Streams (или Kafka). В Модуле 4 на Go мы применим Transactional Outbox без брокера вообще: goroutine с бесконечным циклом, SELECT ... FOR UPDATE SKIP LOCKED через pgx, http.Post вебхука, UPDATE status='published'. Retry, DLQ, HMAC-подпись — всё на чистом Go, без Messenger и без Redis Streams.