Модуль 2. Git Smart HTTP — exec.Command с нативным стримингом
Цель модуля: реализовать GitHandler на Go, который стримит бинарный packfile (100 МБ–1 ГБ) через exec.Command("git-http-backend") + io.Copy, без буферизации в память. Настроить CGI-окружение (GIT_PROJECT_ROOT, PATH_INFO, REQUEST_METHOD, QUERY_STRING), закрыть Directory Traversal и зафиксировать решение в ADR.
Перед началом: Модуль 1 (архитектура, boilerplate, bare-репозитории) Инструменты: Go
os/exec,io,net/http,path/filepath,git-http-backend(CGI), k6 Трудоёмкость: ~5 ч (теория 1.5 ч · практика 3 ч · ADR 0.5 ч)
🔥 Инцидент: 500 Internal Server Error на первом clone
Студент поднял сервер (Модуль 1), зарегистрировал роут *.git/* и сразу попробовал клонировать:
bashgit clone http://localhost:8080/test.git
В ответ — fatal: unable to access 'http://localhost:8080/test.git/': The requested URL returned error: 500. В логах Go-сервера:
git-http-backend: fatal: No GIT_PROJECT_ROOT specified
На Symfony вам пришлось настраивать Nginx → CGI, отключать fastcgi_request_buffering, бороться с OOM. На Go стрим — тривиальный (cmd.Stdin = r.Body и io.Copy(w, cmd.Stdout)), но есть подвох: git-http-backend — это CGI-скрипт, и он читает конфигурацию не из аргументов, а из переменных окружения. Передали пустое окружение — 500. Не передали PATH_INFO — 404. Не передали REQUEST_METHOD — 500. Забыли QUERY_STRING — info/refs не работает.
На PHP переменные CGI прокидывает Nginx через
fastcgi_param— это инфраструктура, а не код. На Go вы обязаны собрать окружение руками вcmd.Env. Это и есть «Go лучше PHP для стриминга, но CGI-окружение — это боль, которую нельзя пропустить».
📖 Теория: exec.Command, CGI-окружение, нативный стриминг, валидация имён
2.1. Проблема: PHP и большие тела запроса (которой на Go нет)
В курсе «Git Hosting на Symfony» Модуль 2 начинается с инцидента OOM: Request->getContent() загружает packfile в память, воркер падает на 500 МБ. На Go этой проблемы не существует — http.ResponseWriter и *http.Request.Body это io.Writer/io.Reader, и стриминг делается одной строкой io.Copy. Архитектурно Go не материализует тело запроса/ответа в память: r.Body — это поток, w — это поток.
Единственное, что нужно Go-разработчику для стриминга гигабайт — НЕ вызывать
io.ReadAll(r.Body)(что скопирует всё в[]byte) и НЕjson.NewDecoder(r.Body).Decode(&v)(та же ошибка). Использоватьio.Copyнапрямую.
2.2. Решение: exec.Command("git-http-backend") + io.Copy
git-http-backend — официальный CGI-скрипт из состава Git (/usr/lib/git-core/git-http-backend), стримящий packfile порциями через стандартный ввод/вывод CGI. На Go вызов выглядит так:
go// internal/handler/git.go const gitBackend = "/usr/lib/git-core/git-http-backend" func (h *GitHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { repoName := h.extractRepo(r.URL.Path) // "test" из "/test.git/info/refs" h.authorize(r, repoName) // (Модуль 7) — Bearer/ACL h.assertSafeRepo(repoName) // Directory Traversal (2.5) cmd := exec.Command(gitBackend) cmd.Env = append(os.Environ(), "GIT_PROJECT_ROOT="+h.root, // /var/git "GIT_HTTP_EXPORT_ALL=1", "PATH_INFO=/"+repoName+".git"+r.URL.Path, // /test.git/info/refs "REQUEST_METHOD="+r.Method, // GET / POST "QUERY_STRING="+r.URL.RawQuery, // service=git-upload-pack "CONTENT_TYPE="+r.Header.Get("Content-Type"), ) cmd.Stdin = r.Body // HTTP request body → process stdin (push) stdout, err := cmd.StdoutPipe() if err != nil { http.Error(w, "git backend failed", http.StatusInternalServerError) return } cmd.Stderr = h.errorLog // лог ошибок git if err := cmd.Start(); err != nil { http.Error(w, "git backend start failed", http.StatusInternalServerError) return } // Стрим stdout процесса в HTTP-ответ (clone/fetch) — ноль аллокаций, буфер 32KB io.Copy(w, stdout) cmd.Wait() }
Ключевые моменты:
cmd.Stdin = r.Body— тело HTTP-запроса (POST /git-receive-pack) напрямую течёт в stdingit-http-backend. Память не растёт.cmd.StdoutPipe()+io.Copy(w, stdout)— stdout процесса порциями льётся вhttp.ResponseWriter.io.Copyиспользует буфер 32 КБ и не аллоцирует под всё тело.cmd.Wait()в конце — дождаться завершения процесса, проверить exit code.
На Symfony то же самое — это
proc_open+StreamedResponse+fread($pipes[1], 8192)в цикле. На Go — две строки (io.Copy) и ноль ручного управления буфером. Но окружение CGI нужно собирать руками в обоих случаях.
2.3. Настройка CGI-окружения: 5 переменных, без которых ничего не работает
git-http-backend читает конфигурацию из os.Environ(). Пять переменных обязательны:
| Переменная | Значение | Пример |
|---|---|---|
GIT_PROJECT_ROOT | корень bare-репозиториев | /var/git |
PATH_INFO | путь внутри root (= repo + URL) | /test.git/info/refs |
REQUEST_METHOD | метод HTTP | GET (info/refs) / POST (upload-pack) |
QUERY_STRING | query string URL | service=git-upload-pack |
CONTENT_TYPE | заголовок Content-Type | application/x-git-upload-pack-request |
Дополнительно — GIT_HTTP_EXPORT_ALL=1, иначе git-http-backend требует файл git-daemon-export-ok в каждом репозитории (как git daemon).
Антипаттерн — передать
cmd.Env = os.Environ()без дополнений:git-http-backendне найдётGIT_PROJECT_ROOTи упадёт с 500. На Symfony эти переменные прокидывал Nginx (fastcgi_param), и про них легко забыть при миграции на Go. Обязательно — модульный тест на стриминг с заполненным окружением.
2.4. Content-Type: git-http-backend отдаёт специфичные типы
git-http-backend отдаёт не application/octet-stream, а специализированные Content-Type:
application/x-git-upload-pack-advertisement— ответ наinfo/refs?service=git-upload-packapplication/x-git-upload-pack-result— результатPOST /git-upload-packapplication/x-git-receive-pack-result— результатPOST /git-receive-pack
Сам git-http-backend пишет Content-Type в stdout перед телом, и io.Copy передаст эти заголовки как часть тела — это сломает HTTP-ответ. Решение: git-http-backend знает CGI-соглашение и выводит HTTP-заголовки (Content-Type: первой строкой), а Go должен их распарсить и выставить через w.Header() перед io.Copy.
Упрощённо (без полного CGI-парсера): первые строки stdout — заголовки \r\n, потом пустая строка, потом тело. Парсер:
go// Читаем заголовки CGI из stdout, выставляем в w.Header() scanner := bufio.NewReader(stdout) for { line, err := scanner.ReadString('\n') if err != nil || strings.TrimSpace(line) == "" { break // пустая строка — конец заголовков } if idx := strings.Index(line, ":"); idx > 0 { w.Header().Set(strings.TrimSpace(line[:idx]), strings.TrimSpace(line[idx+1:])) } } // Далее — io.Copy(w, scanner) для тела
На Symfony об этом заботился
StreamedResponse+header(). На Go CGI-парсинг — ваша ответственность. Альтернатива — библиотекаgithub.com/icholy/cgi, но в курсе мы пишем минимальный парсер руками, чтобы понять механику.
2.5. Directory Traversal: валидация имён репозиториев
git-http-backend обращается к ФС по пути GIT_PROJECT_ROOT + PATH_INFO. Без валидации имени это открывает Directory Traversal — GET /..%2f..%2fetc%2fpasswd.git/info/refs раскроется в /var/git/../../etc/passwd.
Защита на Go — три уровня в assertSafeRepo():
gofunc (h *GitHandler) assertSafeRepo(name string) error { // (1) Whitelist-регекс: никаких /, \, .., %2f if !repoNameRe.MatchString(name) { return fmt.Errorf("invalid repository name") } // (2) Clean + проверка, что путь внутри /var/git clean := filepath.Clean(filepath.Join(h.root, name+".git")) rel, err := filepath.Rel(h.root, clean) if err != nil || strings.HasPrefix(rel, "..") { return fmt.Errorf("directory traversal detected") } return nil } var repoNameRe = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
На Go
filepath.Cleanделает нормализацию (a/../b→b), аfilepath.Relпроверяет, что путь не выходит за корень. Это надёжнееrealpath()из PHP, потому что Go не ходит на диск — работает чисто с строками. Но в продакшене дополнительно проверяютos.Statна существование и запрет симлинков вGIT_PROJECT_ROOT.
2.6. Сравнение с Symfony: чего на Go НЕТ
| Что на Symfony | Что на Go |
|---|
| proc_open + fread в цикле по 8192 байт | io.Copy(w, cmd.StdoutPipe()) — 2 строки |
| StreamedResponse(function () { ... }) | http.ResponseWriter — поток из коробки |
| fastcgi_request_buffering off (Nginx config) | Не нужно — Go не буферизует r.Body |
| client_max_body_size 0 (Nginx config) | Не нужно — r.Body читается лениво, лимита нет |
| fastcgi_buffering off (Nginx config) | Не нужно — io.Copy не буферизует |
| auth_request subrequest для авторизации | обычный middleware: h.authorize(r, repo) |
| OCI-образ с PHP-FPM + Nginx + spawn-fcgi | один scratch-образ с Go-бинарником + git |
На Symfony 60 строк Nginx-конфига + PHP-адаптер +
spawn-fcgi— это «модуль 2». На Go этоexec.Command+io.Copy+ парсер CGI-заголовков — всего ~80 строк Go-кода, без инфраструктуры. Это и есть «Go лучше PHP для Git-хостинга»: меньше слоёв, меньше настраиваемых директив, меньше точек отказа.
2.7. Компромиссы (Trade-offs)
| Подход | Плюс | Минус | Когда выбирать |
|---|---|---|---|
exec.Command + io.Copy | нативный стрим, 0 аллокаций, длинный процесс | subprocess на каждый запрос — overhead fork | всегда для Git Smart HTTP (базовый подход) |
| Pure Go (libgit2 bindings) | нет subprocess, ниже latency | сложнее — C-зависимость, особый API Git | high-perf, экспериментально |
io.ReadAll(r.Body) (антипаттерн) | простота | OOM на packfile > 16MB | NEVER для Git |
| Ручной CGI-парсер заголовков | контроль, понимание механики | 20 строк кода | MVP, курс |
Библиотека icholy/cgi | меньше кода | лишняя зависимость | продакшен |
Вывод: стриминг через
exec.Commandна Go — не оптимизация, а единственный идиоматичный способ пропустить гигабайтный packfile через Go-процесс. Цена — fork-overhead на каждый запрос (решается Bulkhead в Модуле 3) и ручной CGI-парсинг заголовков.
🛠 Практические задания
Задача 1: Зарегистрировать Git-роуты и поднять GitHandler
В cmd/server/main.go добавить роут для *.git/* и реализовать GitHandler по шаблону из 2.2:
gomux := http.NewServeMux() mux.HandleFunc("/api/", apiHandler) mux.HandleFunc("/api/health", healthHandler) mux.Handle("/", gitHandler) // fallback для *.git/* — должен идти после API
Проверка:
curl http://localhost:8080/test.git/info/refs?service=git-upload-pack— должен вернуть200сContent-Type: application/x-git-upload-pack-advertisement.
Задача 2: Создать bare-репозиторий и сделать push/clone
bashdocker compose exec server bash -lc 'cd /var/git && git init --bare demo.git && chown -R appuser:appuser demo.git && chmod -R 0755 demo.git' git clone http://localhost:8080/demo.git && cd demo && echo "hello" > README.md git add . && git commit -m "init" && git push origin main git clone http://localhost:8080/demo.git /tmp/demo2 # проверить, что push дошёл
Задача 3: Directory Traversal-тесты и тест на стриминг 100 МБ
Реализовать assertSafeRepo (2.5) и покрыть таблицей тестов: /etc/passwd, ..%2f, a/../b, repo;rm -rf, валидный my-repo_1.0. Локально — стрим 100 МБ репозитория:
bashcd /tmp && git clone http://localhost:8080/big-repo.git # во время стрима следим за RSS go-процесса: pprof или /metrics
runtime.MemStats в Go покажет, что память процесса не растёт выше ~10 МБ независимо от размера packfile — стриминг работает честно.
✅ Готово, когда:
-
git clone http://localhost:8080/demo.git→200, а не404 -
git pushпроходит, повторныйcloneсодержит закоммиченные данные - Clone 100 МБ не повышает RSS Go-процесса выше ~10 МБ (стриминг работает,
io.Copy) - Directory Traversal-тесты:
..,%2f,;, пробелы — отклоняются с400 -
/var/git—appuser:appuser, права0755(без0777); ADR-GG2 написан
🧩 Проверь себя
cmd.Stdin = r.Body в GitHandler?io.Copy(w, cmd.StdoutPipe()) не вызывает OOM на 1 ГБ packfile?🔗 Мостик к Модулю 3
Clone работает, push проходит, память Go-процесса не растёт. Но теперь всплывает новая беда: 100 одновременных клонов репозиториев по 1 ГБ забивают все goroutines. Go не ограничивает количество goroutines автоматически — их может быть 100 000. CPU и IOPS диска исчерпаны, а API (просмотр кода) тормозит, потому что планировщик не успевает переключаться. На Symfony — два пула FPM (Модуль 3 Symfony-курса). На Go — одна конструкция: chan struct{}. В Модуле 3 мы применим паттерн Bulkhead (arch m5): gitSem := make(chan struct{}, 5) — максимум 5 concurrent git-операций, остальное → 503, но API продолжает отвечать за < 50 мс.