Сборка сервиса на Go в 2016 году была обрядом. Код обязан был лежать строго внутри GOPATH/src, зависимости качались командой go get всегда из свежего состояния ветки master, а две версии одной библиотеки в соседних проектах не уживались. Go modules, появившись в Go 1.11 в 2018 году, закрыл боль одним файлом go.mod и обещанием воспроизводимости. Обещание проверяют годы, а не релизы. Ниже разбор того, что именно фиксируют модули, какие сценарии рвут воспроизводимость даже сегодня и как хранить проект, чтобы он собирался через пять лет без археологии.

Как выглядел хаос с GOPATH и почему модули стали неизбежными

GOPATH был одной большой общей рабочей областью с каталогами src, pkg и bin. Компилятор искал импортируемый пакет по пути на диске, поэтому адрес библиотеки в import и её физическое положение жёстко совпадали. Перенос проекта на другую машину или в другую папку ломал сборку, пока пути не совпадали до последнего сегмента. Версий не было вовсе: go get тянул код из внешнего репозитория в текущем состоянии, и сборка сегодня отличалась от сборки вчера без единого изменения в чужом коде.

Третьей болью была невоспроизводимость самих багов: коллеги собирали один и тот же проект в разное время суток и получали разные бинарники. Отчёт об ошибке воспроизводился только у автора, и вся проверка держалась на честном слове.

Сообщество лечило боль вендорингом: godep, glide, govendor и dep копировали зависимости внутрь проекта каждый по своим правилам. Каталог vendor разросся до гигабайтов, конфликты списков решались вручную, а инструменты друг друга не понимали. Предложение vgo Russ Cox-а в 2018 году вызвало бурю споров, но именно оно легло в основу модулей. Go 1.11 включил их как эксперимент, Go 1.13 автоматически включал модульный режим при наличии go.mod, Go 1.16 сделал его единственным умолчанием, заодно запретив сборке молча дописывать go.mod и передав установку инструментов отдельной команде go install pkg@version. Спор закончился, осталась дисциплина.

Ветераны той эпохи до сих пор встречают в компаниях легаси, которое собирается с переменной окружения GO111MODULE=off: код настолько завязан на GOPATH, что переезд на модули откладывается годами. Для новых проектов путь назад закрыт, и это к лучшему: чистый импорт по адресу модуля вместо привязки к папке на диске стоил сообществу года споров и стал главной победой перехода.

Minimal Version Selection предсказуемость вместо разрешения конфликтов

Сердце модулей это алгоритм выбора версий. Каждая библиотека заявляет минимальную версию каждой зависимости, а сборка выбирает максимум из заявленных. Никакого перебора вариантов, никакой математики удовлетворения ограничений, один проход по графу:

A требует D v1.2.0
B требует D v1.4.0
C требует D v1.3.0
итог сборки: D v1.4.0

Следствие стоит дороже самой механики: два инженера с одинаковым go.mod получают одинаковый граф зависимостей без всяких лок-файлов. В экосистемах с разрешением конфликтов добавление одной транзитивной версии переворачивает полсписка, у Go результат меняется только там, где версия действительно выросла. Обновление одной библиотеки либо проходит тихо, либо падает явной ошибкой несовместимости, серой зоны нет.

Платой за предсказуемость стал консерватизм. MVS выбирает версию, которую кто-то уже признал минимально необходимой, а не последнюю доступную. Модуль, чей автор не выпускает теги годами, годами и тянется в сборку, обновить его можно только явно.

Посмотреть работу алгоритма глазами позволяют две команды:

go mod graph
go list -m all

Первая разворачивает полный граф требований от корня до листьев, вторая показывает итоговый плоский список выбранных версий. Разница между ними и есть работа MVS: в графе требований одна библиотека встречается десятки раз, в итоговом списке она одна, с максимальной версией. Для долгоживущего проекта это простая бухгалтерия: претензии к зависимостям читаются из плоского списка, а не из многостраничного журнала разрешения конфликтов.

go.mod и go.sum что именно фиксируется в проекте

Манифест проекта короткий и читаемый:

module example.com/service

go 1.22

require (
    github.com/jackc/pgx/v5 v5.5.3
    golang.org/x/sync v0.6.0
)

Номера версий условные, суть в структуре. Строка module задаёт путь импорта, директива go фиксирует версию языка, require перечисляет прямые зависимости с точными версиями. С Go 1.21 рядом живёт строка toolchain, указывающая нужную версию компилятора, которую тулчейн умеет скачать сам.

Файл go.sum хранит криптографические контрольные суммы: отдельные строки на содержимое архива и на манифест каждой версии каждой зависимости, включая транзитивные. Хеш с префиксом h1 построен на SHA-256 и покрывает содержимое архива целиком. Команда go mod verify пересчитывает суммы по кэшу и падает на любом несовпадении, а попытка подсунуть другой код под той же версией превращается в ошибку checksum mismatch до линковки. Кэш модулей хранит распакованное содержимое с правами только на чтение, случайная правка чужой библиотеки в кэше исключена. Растёт кэш неспешно, но без остановки, и чистится командой go clean -modcache, без которой домашний каталог за пару лет обрастает десятками гигабайтов исходников под все версии всех проектов сразу.

Гигиену поддерживают две команды. go mod tidy убирает из манифеста лишнее и добавляет отсутствующее, его запускают перед коммитом. go mod verify подтверждает, что кэш соответствует контрольным суммам, его запускают в CI.

Строка из go.sum выглядит так:

golang.org/x/sync v0.6.0 h1:cme0K1D...
golang.org/x/sync v0.6.0/go.mod h1:qgoX7fT...

Хеши сокращены, но две строки на модуль неслучайны: одна покрывает архив целиком, вторая отдельный go.mod зависимости. Лишние записи в go.sum не мешают сборке, недостающие ломают её начиная с Go 1.16, поэтому чисткой занимается tidy, а не человек.

Приватные модули исключаются из проверки контрольных сумм через GOPRIVATE, иначе тулчейн потащится с внутренним кодом к публичной базе:

go env -w GOPRIVATE=git.mycorp.example/*

Одна строка объясняет компилятору, что корпоративные репозитории живут в обход зеркала и базы сумм.

Сценарии которые рвут воспроизводимость даже сегодня

Первый сценарий старый как сами модули: мажорные версии обязаны жить в пути импорта. Вторая версия библиотеки импортируется как pkg/v2, забытый суффикс ломает сборку на чужой машине, а смешение v1 и v2 в одном проекте рождает загадочные несоответствия типов, которые маскируются под логическую ошибку.

Второй сценарий это replace с локальными путями. Разработчик подменяет зависимость каталогом на своей машине, фиксирует правку и уходит в отпуск. CI собирает то же самое из чужого репозитория и падает, потому что у него каталога нет. Лечение простое: replace с путями живёт в личных настройках и не попадает в общий коммит.

Третий сценарий болезненный: приватные репозитории исчезают. Публичное зеркало кэширует опубликованные версии и держит их, даже если автор удалил исходники, но частные модули оно не видит. Компания закрывает старый git-сервер, проект трёхлетней давности перестаёт собираться, и драма разворачивается ровно в день релиза.

Четвёртый тихий: дрейф тулчейна. Одинаковый go.mod собран Go 1.19 и Go 1.23 даёт разные бинарники и иногда разное поведение. Директива toolchain фиксирует нужную версию начиная с Go 1.21, а флаг -trimpath вычищает из бинарника локальные пути сборки, чтобы две машины с разной раскладкой каталогов дали побайтно одинаковый результат.

Пятый рождается из старой привычки: команда go get -u ./... обновляет разом всё до свежих минимальных версий. В проекте с десятками транзитивных зависимостей такой заход переворачивает тридцать модулей, и поиск причины падения превращается в разгром. Точечные обновления по одной библиотеке с прогоном тестов скучнее, но предсказуемее, а разница между скучным и захватывающим обновлением и есть цена дисциплины.

Vendor каталог как страховка от исчезнувших репозиториев

Команда go mod vendor выгружает исходники всех зависимостей в каталог vendor внутри проекта. Начиная с Go 1.14 сборка замечает каталог сама и использует его без обращения к сети. Полный комплект для архива складывается в три составляющие: манифест, контрольные суммы и vendor, всё это лежит в git вместе с кодом.

Сборка без сети проверяется одной строкой:

go build -mod=vendor ./...

Если сборка идёт в режиме vendor, значит, всё нужное лежит внутри репозитория, и исчезновение внешнего сервера ему не грозит. Плата очевидна: каталог vendor весит десятки мегабайтов и обновляется отдельной командой, а ревью зависимостей превращается в ревью диффа каталога. Команды с сотнями микросервисов держат внутреннее зеркало, одиночки и небольшие команды чаще выбирают vendor.

Зеркало разворачивается готовым сервером Athens, который кэширует и публичные, и частные модули за одним адресом, страхуя от исчезновения чужих репозиториев. Каталог vendor из проекта при этом пропадает, а на его место встаёт зависимость от доступности зеркала: у каждой стратегии своя точка отказа, и выбирать её лучше честно, а не по привычке.

Решение принимает и git: каталог vendor коммитят целиком или восстанавливают командой перед сборкой. Первый вариант тяжелее для репозитория, зато честнее для архива и переживает любую смену инфраструктуры. Второй экономит место, но добавляет шаг в конвейер и зависимость от работающей сети в момент сборки. Проекты с памятью на годы выбирают первое.

Обновления по расписанию и контроль уязвимостей

Дисциплина обновлений выглядит скучно и работает. Один раз в спринт запускается проверка свежести:

go list -m -u all
govulncheck ./...
go version -m ./bin/service

Первая команда перечисляет доступные обновления, вторая сверяет версии с базой уязвимостей самого языка и стандартной библиотеки, третья читает зашитую в бинарник информацию о модулях и отвечает на вопрос, из чего именно собран файл. Инструмент govulncheck появился у команды языка в 2023 году и проверяет не только факт уязвимости, но и достижимость вызова из кода проекта.

Чистоту манифеста проверяет сам конвейер: tidy запускается в CI, и если он изменил go.mod или go.sum, сборка падает на разнице:

go mod tidy
git diff --exit-code go.mod go.sum

Небрежный коммит с забытым импортом или лишней зависимостью отлавливается машиной, а не ревьюером. Правило дёшево, а экономит часы разбора, почему локально собирается, а в конвейере нет.

Окно обновлений держится узким: минорные версии транзитивных библиотек обновляются пачкой, мажорные по одной, каждая с прогоном тестов. Граф зависимостей время от времени пересчитывается и просматривается глазами: сотня транзитивных модулей это норма, тысяча повод для разбора.

Ритм важнее списка. Двадцать минут каждую неделю поднимают минорные версии без стресса, пока мажорные не успевают превратиться в событие квартала. Команда, отложившая обновления на год, получает пропасть в десятки версий, и прыжок через неё превращается в проект с ревью и планом отката. Дисциплина здесь буквально дешевле героизма.

Практические правила хранения зависимостей на годы

Опыт команд, чьи проекты переживают смену поколений разработчиков, сводится к короткому списку правил:
1. репозиторий хранит go.mod, go.sum и vendor целиком;
2. сборка в CI идёт в режиме vendor с флагом -trimpath;
3. версия тулчейна фиксируется строкой toolchain и образом контейнера;
4. replace с локальными путями не попадает в общий коммит;
5. обновления проходят по расписанию окнами, а не по прихоти библиотеки;
6. уязвимости проверяются govulncheck в каждом пайплайне.

Хаос GOPATH действительно остался в прошлом: путь импорта больше не зависит от расположения на диске, версии зафиксированы, контрольные суммы сверяются. Но воспроизводимость это не кнопка в go.mod, а привычка проверять сборку без сети, держать vendor актуальным и фиксировать тулчейн. Проект, за которым так ухаживают, собирается через пять лет одной командой с чистой совестью. Проект, который полагался на удачу, рано или поздно встретит удалённый репозиторий, и вот тогда разница между двумя подходами станет стоить пары потерянных ночей.