Модуль 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(), который закрывается, когда клиент рвёт соединение:
goselect { 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 про shutdown | long-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)
🧩 Проверь себя
select { case gitSem <- struct{}{}: case <-r.Context().Done(): return }?gitSem := make(chan struct{}, 5) в GitHandler?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.