Модуль 1. Setup — Go binary, docker-compose, первый clone
Цель модуля: поднять локальный Git-хостинг на Go из готового boilerplate, разобраться в архитектуре из трёх слоёв (Web App + Git Gateway + Storage) и понять, почему bare-репозитории требуют POSIX-совместимую файловую систему — и почему Go-бинарник упрощает развёртывание, но не отменяет этого ограничения.
Перед началом: курс «Архитектура распределённых систем» (Модули 1, 5) Инструменты: Docker, Docker Compose, Go 1.22+, Git,
os/execТрудоёмкость: ~3 ч (теория 1 ч · практика 1.5 ч · ADR 0.5 ч)
🔥 Инцидент: git clone возвращает 404
Студент разворачивает свежий boilerplate, создаёт первый репозиторий через Web UI и делает то, ради чего всё затевалось:
bashgit clone http://localhost:8080/test.git
В ответ — fatal: repository 'http://localhost:8080/test.git/' not found (HTTP 404). При этом curl http://localhost:8080/api/health отвечает 200 OK, список репозиториев в UI отображается корректно, а в логах Go-сервера — тишина: роутер net/http не знает про *.git/* и отдаёт 404 Not Found дефолтным http.ServeMux.
На Symfony для старта нужен Nginx + PHP-FPM + два пула +
spawn-fcgiдляgit-http-backend. На Go — один бинарник:go build -o server ./cmd/server && ./server. Но bare-репозитории требуют POSIX-диск, аgit-http-backendнужно вызывать черезos/exec. Звучит просто? Спойлер: переменные окружения CGI — это боль.
Git-хостинг — это не «HTTP-сервер, который отдаёт файлы репозитория». Это три отдельные подсистемы с разной нагрузкой: Web UI (быстрые JSON-ответы), Git Gateway (стриминг бинарных packfile по 100 МБ–1 ГБ) и Storage (bare-репозитории на диске). Слить их в один слой — первая и самая дорогая ошибка, и Go её не прощает: один
http.HandleFuncна всё — это антипаттерн.
📖 Теория: Архитектура Git-хостинга на Go и ограничения bare-репозиториев
1.1. Три слоя архитектуры
Git-хостинг уровня GitHub/GitLab состоит из трёх независимо масштабируемых подсистем:
| Слой | Назначение | Нагрузка | В нашем стеке (Go) |
|---|---|---|---|
| Web App | REST API, Web UI (список репо, PR, просмотр кода) | быстрые JSON, p99 < 200 мс | Go net/http + pgx + encoding/json |
| Git Gateway | приём и отдача packfile по Smart HTTP | длинные стримы, 100 МБ–1 ГБ на запрос | Go + exec.Command("git-http-backend") + io.Copy |
| Storage | хранение bare-репозиториев как файлов на диске | POSIX I/O, атомарные блокировки | bare-репозитории в /var/git/*.git |
Ключевой архитектурный принцип (из arch m1): каждый слой масштабируется под свою нагрузку отдельно. Web App горизонтально масштабируется копированием бинарника (stateless). Git Gateway требует ограничения конкурентности (Bulkhead в Модуле 3). Storage не масштабируется «облаком» — он требует POSIX-диск (см. 1.2).
1.2. Почему bare-репозитории не живут в S3 / Serverless
Bare-репозиторий (.git без рабочей копии) — это каталог файлов и каталогов на диске. Git активно использует операции, которых нет в объектных хранилищах:
- Жёсткие ссылки (hardlinks) —
git gcиgit repackдедуплицируют объекты черезlink(2). S3 не поддерживает hardlinks. - Атомарные блокировки через
flock(2)—git receive-packберёт лок наrefs/черезO_CREAT | O_EXCLиflock. S3 — eventual consistency, атомарного лок-файла нет. rename(2)для атомарности ref-обновлений — Git пишет во временный файл, затемrenameповерх целевого. Это гарантирует, что ref либо полностью обновлён, либо нет. В S3PutObjectне атомарен относительно других операций.inotify/mmap— индексы и packfile рассчитаны на POSIX-семантику файлов.
Следствие: хранить bare-репозитории можно только на POSIX-совместимом диске: Compute Instance с NVMe, Managed Service for Kubernetes с persistent volume, Network SSD. Yandex Object Storage (S3), Cloud Functions и Serverless Containers без постоянного диска НЕ подходят. Это не вопрос производительности — это фундаментальное ограничение Git, и Go его не отменяет:
os.Renameна S3 не вызвать.
1.3. Почему Go лучше PHP для Git-хостинга
Курс «Git Hosting на Symfony» тратит Модуль 2 на борьбу с OOM: PHP материализует packfile в память, приходится настраивать Nginx → CGI и fastcgi_request_buffering off. На Go этой проблемы не существует:
| Проблема | Symfony | Go |
|---|---|---|
| packfile streaming | Nginx → CGI workaround (proc_open + StreamedResponse) | cmd.Stdin = r.Body + io.Copy(w, cmd.Stdout) — нативный стрим, ноль аллокаций |
| Долгоживущий процесс | PHP-FPM: fork per request, max_execution_time=300s | Go IS long-lived: один бинарник работает днями, goroutine на запрос |
| Конкурентность | Пул FPM воркеров (pm.max_children=20) | goroutines легковесны — go handle(w, r) в net/http уже делает это |
| Развёртывание | Nginx + PHP-FPM + pool config + spawn-fcgi | go build → один бинарник, ./server → готово |
Но у Go есть свои подводные камни: goroutines не лимитируются автоматически (Модуль 3 — Bulkhead через chan struct{}), а exec.Command с CGI требует careful настройки окружения (Модуль 2 — GIT_PROJECT_ROOT, PATH_INFO, REQUEST_METHOD).
Go не решает архитектурные проблемы (Outbox, Saga, шардирование — те же), но убирает инфраструктурные костыли: нет CGI, нет RoadRunner, нет двух пулов FPM. Цена — больше ответственности в коде: лимиты конкурентности, graceful shutdown, observability вы пишете руками.
1.4. Структура boilerplate
В репозитории курса boilerplate/ собран каркас, который мы будем эволюционировать все 8 модулей:
git-hosting-go/
├── cmd/
│ └── server/
│ └── main.go # точка входа: net/http.ListenAndServe
├── internal/
│ ├── handler/ # HTTP-хендлеры: API, Git, Health
│ │ ├── api.go # REST: список репо, PR (pgx)
│ │ ├── git.go # exec.Command + io.Copy (Модуль 2)
│ │ └── health.go # GET /api/health
│ ├── repository/ # доступ к БД и диску
│ │ ├── repo.go # CRUD репозиториев
│ │ └── git_storage.go # /var/git операции (init, ls-tree)
│ └── outbox/ # Transactional Outbox (Модуль 4)
│ ├── worker.go # goroutine + FOR UPDATE SKIP LOCKED
│ └── publisher.go # HTTP-клиент для вебхуков
├── docker/
│ └── docker-compose.yml # server + postgres + (опц.) git
├── go.mod # pgx, chi (опц.), prometheus client
└── Makefile # make up / make git-init / make build
Один Dockerfile-профиль: Go-бинарник + PostgreSQL. В отличие от Symfony (где было два профиля 10k/ и 100k/ с RoadRunner), на Go рантайм не меняется — на 100k RPS меняется инфраструктура вокруг (Kafka, Citus, CDN), а не сам бинарник. Это ключевое упрощение.
1.5. Git Smart HTTP Protocol
Smart HTTP — протокол поверх HTTP, по которому Git общается с сервером. Три ключевых endpoint:
GET /<repo>.git/info/refs?service=git-upload-pack— первоначальное «рукопожатие»: сервер отдаёт список refs (веток/тегов) и capabilities. Запрос лёгкий.POST /<repo>.git/git-upload-pack— fetch/clone: клиент получает packfile. Это тяжёлый бинарный стрим (может быть гигабайты).POST /<repo>.git/git-receive-pack— push: клиент отправляет packfile. Тоже тяжёлый стрим.
На Go эти endpoint'ы мы зарегистрируем в
http.ServeMuxв Модуле 2. Пока — только API и health, поэтомуgit cloneпадает с 404. Это нормально на этом этапе.
1.6. Компромиссы (Trade-offs)
| Решение | Плюс | Минус | Когда выбирать |
|---|---|---|---|
| POSIX-диск (Compute/NFS) | hardlinks, flock, rename — Git работает «как у себя» | нельзя Serverless, нужен постоянный диск | всегда для bare-репозиториев |
| S3 / Object Storage | бесконечная ёмкость, дёшево | нет hardlinks/flock/rename — Git ломается | NEVER для bare-репо (только артефакты/бэкапы) |
| Один Go-бинарник | простота развёртывания, нативный стриминг, long-lived | наблюдаемость и лимиты — руками (Модули 3, 7) | MVP, 10k RPS (Модули 1–6) |
| Nginx reverse proxy перед Go | TLS, rate limiting, статики, slowloris-защита | лишний слой инфраструктуры | продакшен (Модуль 7) |
| PostgreSQL (через pgx) | транзакции, FOR UPDATE SKIP LOCKED, JSONB | чуть сложнее database/sql | всегда — нужен для Outbox и метаданных |
Вывод: Go убирает нужду в RoadRunner и CGI-склевке, но НЕ заменяет Nginx в продакшене (TLS, rate limiting, статики) и НЕ отменяет требование POSIX для Storage. Выбор runtime — один бинарник на dev,
Nginx + binaryна prod.
1.7. Go НЕ заменяет Nginx в продакшене
Студенты часто думают: раз Go — это один бинарник с net/http, значит Nginx не нужен. На dev — да, можно поднять ./server на :8080 и работать. В продакшене нужен reverse proxy перед Go:
- TLS-терминация — Nginx (или Yandex Cloud ALB) терминирует HTTPS, Go-бинарник слушает HTTP внутри сети.
- Rate limiting —
limit_req_zoneв Nginx проще и быстрее in-memory лимитера Go (особенно при нескольких репликах). - Static files — статику (иконки, фавикон) раздаёт Nginx, не Go.
- Slowloris-защита — Nginx режет медленных клиентов на уровне соединения, Go-стриминг может зависнуть на
io.Copyот медленного клиента. - Connection draining — graceful shutdown Nginx аккуратно сливает соединения, Go-бинарник — через
http.Server.Shutdown(ctx).
На Symfony слой Nginx нужен был и на dev (без него PHP-FPM вообще не работает). На Go dev — без Nginx, prod — с Nginx. Это и есть упрощение: необязательный Nginx вместо обязательного.
🛠 Практические задания
Сценарий: поднять boilerplate через make up, проверить health API, создать первый bare-репозиторий и убедиться, что API работает (Git Smart HTTP подключим в Модуле 2).
Задача 1: Поднять окружение
bashmake up # docker compose up -d (server + postgres) make logs # проверить, что server стартовал: "listening on :8080" curl localhost:8080/api/health # ожидаем {"status":"ok"}
Задача 2: Создать bare-репозиторий вручную
bashdocker compose exec server bash mkdir -p /var/git && cd /var/git git init --bare test.git # bare-репозиторий chown -R appuser:appuser /var/git chmod -R 0755 /var/git ls test.git/refs test.git/objects # убедиться в структуре POSIX
✅ Готово, когда:
-
make upподнимает 2 контейнера (server, postgres) без ошибок -
GET /api/healthотвечает200 OKза < 20 мс (Go быстрее PHP-FPM cold start) -
test.gitсоздан, владелецappuser, права0755 - В Web UI репозиторий
testвиден в списке (через Go-репозиторий, читающий/var/git) - ADR-GG1 «POSIX-диск обязателен (Go не отменяет)» написан (обоснование: hardlinks, flock, rename)
⚠️ На этом этапе
git clone http://localhost:8080/test.gitвсё ещё падает с 404 — это нормально. Роутер Go не настроен на Git Smart HTTP. Это задача Модуля 2.
🧩 Проверь себя
exec.Command("git-http-backend") в Go-сервере?🔗 Мостик к Модулю 2
Система поднята, API отвечает, репозитории создаются. Но git clone падает с 404, потому что net/http не знает про *.git/*. В Модуле 2 мы зарегистрируем Git-роуты и вызовем git-http-backend через exec.Command с нативным стримингом: cmd.Stdin = r.Body, io.Copy(w, cmd.Stdout). Никакого Nginx, никакого CGI-сокета, никакого fastcgi_request_buffering off — Go стримит packfile прямо из stdin в stdout процесса. Но git-http-backend — это CGI-скрипт, и ему нужны GIT_PROJECT_ROOT, PATH_INFO, REQUEST_METHOD, QUERY_STRING в окружении. Не передали — 500.