Модули и тулчейн: MVS, go.sum, воркспейсы и инструменты CI
require в go.mod — минимумы, MVS берёт их максимум: детерминированно, без солвера и внезапных апгрейдов; go.sum — журнал хешей, не lock-файл. replace действует только в главном модуле; go.work — для мультимодульной разработки. CI: vet, staticcheck, -race, проверки дрейфа.
В пятницу после обеда покраснели разом все пайплайны организации: verifying github.com/acme/sigutil@v1.4.2: checksum mismatch. Инцидент безопасности? Почти наоборот. Автор библиотеки нашёл баг в теге, удалил v1.4.2 и перетегировал исправленный коммит той же версией — тихая маленькая переписка истории. Go отказался подыгрывать: go.sum в каждом потребляющем репозитории хранил хеш исходного v1.4.2, публичная база контрольных сумм соглашалась, и новые байты под старым именем провалили верификацию везде. Половина команды с npm-прошлым прочитала ошибку как аварию; вторая половина — как работу системы: кто-то изменил опубликованный код, не изменив версию, и сборка сказала «нет». Автор выпустил v1.4.3, одна строка go get — и всё зазеленело, а единственным пунктом постмортема стала ссылка на документацию MVS: в Go ничто в графе зависимостей не двигается, пока не подвинете вы.
MVS: минимальный выбор версий, а не солвер
Почему Go-сборка воспроизводится байт-в-байт на машине коллеги через шесть месяцев, тогда как аналогичный npm-проект требует lock-файла и всё равно порой отличается? Ответ — один алгоритм, умещающийся на салфетке.
Строка require в go.mod — это минимум, а не диапазон. Чтобы собрать итоговый список версий, Go обходит граф модулей, собирает требуемый минимум каждого модуля для каждой зависимости и берёт максимум из минимумов — наименьшую версию, устраивающую всех. Это весь алгоритм:
// ваш модуль требует: A v1.2.0, B v1.1.0
// A требует: C v1.3.0
// B требует: C v1.5.0
//
// MVS выбирает C v1.5.0 — максимум из требуемых минимумов.
// НЕ последний релиз C v1.9.2: MVS никогда не прыгает дальше
// того, что кто-то в графе явно попросил.
//
// Апгрейды всегда явные:
// go get C@v1.6.0 // поднять собственный минимум
// go get -u ./... // поднять всё (осознанно)
// go mod tidy // синхронизировать go.mod с импортамиСравните с моделью npm: semver-диапазоны (^1.3.0), разрешаемые в момент установки, означают, что один и тот же package.json в разные дни даёт разные деревья, — и для восстановления детерминизма приходится прикручивать lock-файл. MVS для выбора версий lock-файл не нужен: список сборки — чистая функция от go.mod-файлов графа, вычислимая вручную, одинаковая на каждой машине, всегда. Размен честный: автоматического подхвата багфиксов нет; security-патч в C v1.5.1 доедет до вас, только когда вы или зависимость его попросите — отсюда govulncheck и регулярные PR с go get -u как противовес. go.sum добавляет не пиннинг версий, а верификацию содержимого: журнал криптографических хешей каждой когда-либо использованной версии модуля, сверяемый с transparency-логом (публичным append-only реестром хешей модулей) sum.golang.org при первом скачивании. Версии берутся из go.mod; go.sum гарантирует, что названная версия байт-в-байт совпадает с тем, что получил остальной мир, — именно это и сработало в Hook.
Ваше приложение требует A и B. A требует zap v1.21.0, B требует zap v1.24.0, последний релиз zap — v1.27.0. Какая версия попадёт в сборку и когда она изменится?
replace и go.work: локальная обвязка, которая не должна утекать
Два инструмента перенаправляют, откуда берётся исходник модуля, — с очень разным радиусом поражения. Директива replace в go.mod (replace github.com/acme/lib => ../lib) действует, только когда этот модуль — главный: replace в go.mod ваших зависимостей игнорируются целиком. Это делает replace безопасным для форков и аварийных пинов, но и классической миной: закоммиченный => ../lib собирается на вашем ноутбуке и умирает в Docker и у каждого потребителя, потому что относительный путь существует только на вашем диске. С Go 1.18 правильный инструмент мультимодульной разработки — воркспейс: файл go.work со строками use ./app и use ./lib заставляет тулчейн разрешать lib из локальных исходников для всех перечисленных модулей — без правок go.mod, и go.work по конвенции не коммитится (локальная обвязка разработчика, как конфиг IDE), так что в опубликованный модуль ничего не утекает. CI, собирающий без воркспейса, видит ровно то, что увидят потребители; GOFLAGS=-mod=readonly (поведение по умолчанию с 1.16) плюс отсутствие go.work в CI — честная конфигурация.
Тулчейн в CI: vet, staticcheck, -race, generate
Компилятор принимает массу неправильных программ; тулчейн сужает зазор слоями. go vet гоняет анализаторы багов, которые система типов не видит, — несовпадения аргументов printf, скопированные мьютексы (copylocks), недостижимый код; подмножество vet уже выполняется автоматически при go test, но CI должен гонять полный go vet ./.... staticcheck идёт на порядок дальше своими SA-проверками (запись в nil-map, неверный time.Format, отложенный Close на nil-теле ответа) и де-факто является вторым линтером серьёзных Go-команд. Детектор гонок — динамическая инструментация: go test -race ловит только гонки, которые тесты реально исполнили, по реальной цене — примерно 5–10× CPU и 5–10× памяти; поэтому его место — на CI-прогоне тестов (всегда) и, возможно, на канареечной реплике, но не на каждом продакшен-поде. go generate — это контракт с людьми, а не со сборкой: он никогда не запускается автоматически, поэтому сгенерированный код (моки, protobuf, stringer) коммитится, а CI стережёт дрейф, перегенерируя и падая на непустом git diff — тот же трюк, что и go mod tidy -diff. Для воспроизводимых релизных бинарей: запиненная директива toolchain go1.24.x в go.mod, -trimpath, чтобы пути сборщика не вшивались в бинарь, и module proxy плюс go.sum довершают дело — одинаковые входы, одинаковые байты.
Коллега удаляет go.sum, чтобы починить ошибку checksum mismatch, и сборка зеленеет. Что проект на самом деле потерял?
- 01Объясни, как MVS выбирает версии, почему Go не нужен lock-файл для этого, и точное разделение труда между go.mod и go.sum.
- 02Спроектируй тулчейн-гейты CI для репозитория сервиса: что запускается, что каждый слой ловит и что не попадает в продакшен.
История зависимостей Go детерминирована по построению. Строки require — минимумы; MVS собирает минимумы всех модулей и берёт их максимум, никогда не прыгая к latest, — поэтому список сборки является чистой функцией от go.mod-файлов, воспроизводим на любой машине без lock-файла, и ничего не двигается, пока его не подвинет go get. Цена — отсутствие автоматического подхвата багфиксов, компенсируемая govulncheck и регулярными PR на обновление. go.sum — вторая половина и другая работа: журнал хешей содержимого, сверяемый с публичным transparency-логом, — потому тихо перетегированная версия и валит сборку каждого потребителя; пятничный инцидент из Hook был успехом механизма. У локальной обвязки острые края: replace действует только в главном модуле, и закоммиченный replace с относительным путём ломает Docker и всех потребителей, тогда как go.work даёт мультимодульной разработке тот же эффект в виде локальной, по конвенции некоммитимой конфигурации. Дальше тулчейн наслаивает то, чего компилятор не обещает: go vet и staticcheck для статически находимых багов, go test -race для динамически исполненных гонок по цене 5–10× (CI — да, прод-флот — нет), go generate как запускаемый человеком шаг, чьи выводы коммитятся и проверяются на дрейф, и go mod tidy -diff, держащий манифест честным. Воспроизводимые бинари замыкают круг: пин тулчейна, -trimpath, верифицированные модули — одинаковые входы, одинаковые байты. Теперь, когда вы увидите checksum mismatch в CI или коллегу, удаляющего go.sum «чтобы починить», вы будете точно знать, что сообщение об ошибке говорило правду — и что правильный ответ это один go get с новой версией, а не удаление журнала.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.