Модуль 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 и делает то, ради чего всё затевалось:

bash
git 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 AppREST 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 либо полностью обновлён, либо нет. В S3 PutObject не атомарен относительно других операций.
  • 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 этой проблемы не существует:

ПроблемаSymfonyGo
packfile streamingNginx → CGI workaround (proc_open + StreamedResponse)cmd.Stdin = r.Body + io.Copy(w, cmd.Stdout) — нативный стрим, ноль аллокаций
Долгоживущий процессPHP-FPM: fork per request, max_execution_time=300sGo IS long-lived: один бинарник работает днями, goroutine на запрос
КонкурентностьПул FPM воркеров (pm.max_children=20)goroutines легковесны — go handle(w, r) в net/http уже делает это
РазвёртываниеNginx + PHP-FPM + pool config + spawn-fcgigo 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:

  1. GET /<repo>.git/info/refs?service=git-upload-pack — первоначальное «рукопожатие»: сервер отдаёт список refs (веток/тегов) и capabilities. Запрос лёгкий.
  2. POST /<repo>.git/git-upload-pack — fetch/clone: клиент получает packfile. Это тяжёлый бинарный стрим (может быть гигабайты).
  3. 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 перед GoTLS, 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: Поднять окружение

bash
make up          # docker compose up -d (server + postgres)
make logs        # проверить, что server стартовал: "listening on :8080"
curl localhost:8080/api/health   # ожидаем {"status":"ok"}

Задача 2: Создать bare-репозиторий вручную

bash
docker 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.


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

1. Почему Go лучше PHP для Git-хостинга?
2. Зачем Go-серверу нужны CGI-переменные окружения (GIT_PROJECT_ROOT, PATH_INFO)?
3. Где хранятся bare-репозитории в boilerplate на Go?
4. Что делает exec.Command("git-http-backend") в Go-сервере?
5. Почему bare-репозиторий нельзя хранить в S3, даже на 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.