GOROOT указывает на место, где лежит сам тулчейн Go: компилятор, команда go и стандартная библиотека. GOPATH (по умолчанию ~/go) раньше был единственным рабочим пространством, где обязан был лежать весь ваш код; с модулями в нём остались только кеш загрузок и установленные бинарники.
С Go 1.16 модульный режим включён по умолчанию: проект может лежать в любой директории, а путь импорта задаёт его go.mod, а не расположение.
$GOPATH/pkg/mod служит кешем только для чтения со всеми скачанными версиями модулей, а в $GOPATH/bin go install кладёт бинарники, если не задан GOBIN.
GOROOT вручную почти никогда не задают. go env GOROOT GOPATH показывает, что на самом деле использует команда go.
GoMiddle
Модули, сборка и тулинг
go.mod и MVS, build-теги, embed, ldflags, линтеры, статические бинарники
Закрыто вопросов
0 / 24
- 0Повторить
- 3Часто спрашивают
Вопросы
Пакетом называют директорию с .go-файлами с одинаковой строкой package: единицу, которую импортируют и собирает компилятор. Модулем называют дерево пакетов с go.mod в корне: единицу, которую версионируют, тегируют и скачивают.
Путь импорта равен пути модуля плюс директория: github.com/acme/billing/internal/order указывает на пакет order внутри модуля github.com/acme/billing.
Версии принадлежат модулям, а не пакетам. Тег v1.4.0 выпускает сразу все пакеты дерева.
В репозитории обычно один модуль. Вложенный go.mod отрезает своё поддерево в отдельный модуль со своими версиями.
Решает go.mod: в нём путь модуля, версия Go и минимальная версия каждой зависимости. go.sum не lock-файл, а список контрольных сумм, которым обязан соответствовать скачанный код этих версий.
В go.sum по два хеша на версию: один на zip с исходниками модуля, второй только на его go.mod.
Каждая загрузка сверяется с ним до компиляции, поэтому подменённый или перетегированный релиз валит сборку, а не уезжает в прод. Когда версию скачивают впервые, её хеш подтверждает публичная база контрольных сумм sum.golang.org.
Коммитьте оба файла. Конфликт слияния в go.sum решается через go mod tidy, а не удалением файла.
go mod tidy приводит go.mod и go.sum в соответствие с импортами: добавляет недостающие требования и убирает лишние. go get меняет требование и заново запускает выбор версий, а go mod verify проверяет, что кеш модулей не изменён.
**go mod tidy** смотрит на все пакеты и тесты модуля под все платформы и build-теги, поэтому зависимость, нужная только в _test.go или _windows.go, остаётся. Уже указанные версии он никогда не поднимает. В CI go mod tidy -diff (Go 1.23+) падает, если кто-то забыл его выполнить, и при этом ничего не переписывает.
**go get pkg@v1.5.0** поднимает или опускает это требование, подстраивает всё, что нужно новой версии, и дописывает строки в go.sum. Ручная правка ничего этого не делает, а с Go 1.16 сборка идёт с -mod=readonly: она падает с updates to go.mod needed, а не исправляет файл молча.
**go mod verify** заново хеширует кеш модулей и печатает all modules verified, если с момента загрузки ничего не изменилось. Это защита от локальной порчи; загрузку защищает go.sum.
// indirect помечает модуль, который не импортирует ни один ваш пакет: он есть в go.mod, потому что он нужен зависимости или потому что вы сами намеренно подняли его версию.
С go 1.17 главный go.mod перечисляет все модули, которые дают пакеты в сборку, а не только импортируемые напрямую. Это учёт, а не новые зависимости.
Когда полный список записан, команде go не нужно читать go.mod большинства зависимостей (pruning графа модулей), и загружает она меньше.
go mod tidy держит косвенные требования в отдельном блоке require и сам ставит и снимает пометку, так что править её руками незачем.
Поднимать indirect-версию считается нормальной практикой: go get golang.org/x/crypto@v0.35.0 забирает security-фикс, не дожидаясь промежуточной библиотеки.
Используйте go install <пакет>@<версия>. С Go 1.18 go get только управляет требованиями в go.mod; сборка и установка команд переехали в go install.
С суффиксом версии go install go.uber.org/mock/mockgen@v0.6.0 игнорирует go.mod в текущей директории, поэтому установка утилиты не может изменить ваши зависимости.
Бинарник попадает в GOBIN, а если он не задан, то в $(go env GOPATH)/bin (~/go/bin). Эта директория должна быть в PATH.
Фиксируйте версию всего, что генерирует код: с @latest две машины могут сгенерировать разные файлы.
Чтобы зафиксировать утилиту для всей команды, смотрите go get -tool (Go 1.24+).
go generate выполняет команды, записанные в комментариях //go:generate, в директории каждого файла, чтобы написать Go-код: моки, методы String, запросы sqlc. Сборка его никогда не запускает, поэтому результат коммитят вместе с кодом, как любой другой исходник.
Почему сборка его не запускает: сборка должна работать из одного модуля. У того, кто делает go get или go install pkg@version, нет mockgen или protoc, а сборка, выполняющая команды из зависимости, была бы дырой в безопасности.
Коммитьте результат: тогда модуль собирается где угодно, а на ревью видно, что поменял генератор. Сгенерированные файлы начинаются со строки, подходящей под ^// Code generated .* DO NOT EDIT\.$: по ней линтеры их пропускают, а люди понимают, что править их руками нельзя.
Следите за актуальностью в CI: запускайте go generate ./..., а затем git diff --exit-code, который падает, если кто-то поменял исходник и забыл перегенерировать.
Детали: после // нет пробела; shell не участвует, поэтому пайпов и глобов нет; заданы $GOFILE и $GOPACKAGE; -run stringer запускает только подходящие директивы. Вызывайте генератор через go tool с версией, зафиксированной через go get -tool, чтобы на всех машинах получались одинаковые байты.
gofmt выравнивает форматирование, go vet находит код, который компилируется, но почти наверняка ошибочен, staticcheck добавляет более глубокие проверки на баги и упрощения, а golangci-lint гоняет их и многие другие за один проход с одним конфигом.
**gofmt / goimports**: один канонический формат, поэтому на ревью никто не спорит о стиле. CI падает, если gofmt -l . печатает хоть один файл (или запускает golangci-lint fmt --diff).
**go vet** идёт вместе с Go: глаголы Printf, не совпадающие с аргументом, выброшенный cancel из context.WithTimeout, скопированный sync.Mutex. go test уже запускает надёжное подмножество проверок и не запускает тесты, если оно сработало.
**staticcheck: вызовы устаревших API, возможные разыменования nil, значения, которые присвоили и ни разу не прочитали, код, который можно упростить.
golangci-lint**: это раннер. v2 по умолчанию включает govet, errcheck, staticcheck, ineffassign и unused; команды часто добавляют gosec, errorlint, bodyclose и revive. Фиксируйте его версию в CI, как любую зависимость; на легаси-кодовой базе --new-from-rev=origin/main заставляет проходить проверку только новый код.
Doc-комментарием служит обычный комментарий // прямо над экспортируемым объявлением, который начинается с имени описываемой сущности. go doc и pkg.go.dev отображают его, так что отдельный формат документации поддерживать не нужно.
Комментарий пакета ставится над строкой package в одном файле (часто doc.go) и начинается с Package billing .... go doc billing.Charge печатает один символ, а go doc -all ./billing печатает весь пакет.
Допустим лёгкий синтаксис: абзацы через пустую строку, отступленные блоки кода, ссылки [ErrDeclined] на другие символы, заголовки # и списки -. gofmt приводит doc-комментарии к каноническому виду.
Абзац, начинающийся с Deprecated:, помечает API как устаревший, и такие инструменты, как staticcheck, начинают помечать его вызовы.
v0.31.0. Go использует Minimal Version Selection: каждая строка require задаёт минимум, и сборка берёт наибольший из заявленных минимумов, а не более новый релиз только потому, что он существует. Одни и те же go.mod всегда дают один и тот же ответ, поэтому lock-файл не нужен.
npm или Cargo разрешают диапазоны версий по тому, что опубликовано сегодня, поэтому им нужен lock-файл, чтобы заморозить результат. MVS читает только файлы go.mod в графе, так что тот же коммит выберет те же версии и через годы.
go get github.com/acme/kit@v1.4.0 сдвинет x/net, только если собственный go.mod kit v1.4.0 просит больше v0.31.0.
Обновления всегда явные: go get -u ./... берёт свежие minor- и patch-версии, -u=patch остаётся на текущих minor-линиях, а go get golang.org/x/net@v0.45.0 поднимает один минимум.
go list -m all печатает выбранный список сборки, а go list -m -u all показывает, что можно обновить, ничего не меняя.
Это semantic import versioning: начиная с v2 мажорная версия входит в путь модуля (module github.com/acme/lib/v2) и в каждый импорт, потому что ломающий релиз считается другим модулем. Если поставить тег v2.0.0 без этого, им никто не сможет воспользоваться.
github.com/acme/lib и github.com/acme/lib/v2 считаются разными модулями. go get -u по пути v1 никогда не перейдёт на v2, а явный @v2.0.0 упадёт с module contains a go.mod file, so module path must match major version ("github.com/acme/lib/v2").
Как выпустить v2: заменить строку module на путь с /v2, поправить импорты собственных пакетов модуля, потом поставить тег.
v0 ничего не обещает, v1 обещает совместимость. Поэтому v0 и v1 не нужен суффикс, а любому ломающему изменению после v1 нужен новый путь.
На время миграции v1 и v2 можно импортировать рядом, но их типы не связаны: lib.Client нельзя передать туда, где ждут libv2.Client.
Репозитории, которые получили теги выше v1 ещё до появления модулей, видны как v2.1.0+incompatible.
replace и exclude действуют только в главном модуле (том, который собирают). Когда собирается сервис, go.mod вашей библиотеки отдаёт только свои строки require и больше ничего, поэтому правило подмены игнорируется.
Требования наследуются, так что поднимите минимум: require github.com/them/thing v1.4.2. Каждый потребитель тогда соберёт как минимум эту версию.
Если апстрим заброшен, опубликуйте форк под собственным путём модуля и импортируйте этот путь. Тогда это настоящая зависимость, а не правило подмены.
replace уместен в модуле, который вы собираете: принудительно подставить пропатченную версию в сервисе или указать на локальный чекаут через replace example.com/lib => ../lib (для локальной работы с несколькими модулями обычно лучше go.work).
Опубликованный модуль с replace ещё и нельзя поставить через go install pkg@version: команда go откажется, потому что как зависимость модуль собрался бы иначе, чем сам по себе.
Задайте в GOPRIVATE префиксы приватных путей, например git.corp.example/*. Это говорит команде go тянуть такие пути прямо из Git, а не через публичный прокси, и никогда не искать их в публичной базе контрольных сумм.
Без этого дефолтный GOPROXY=https://proxy.golang.org,direct сначала идёт в публичное зеркало, а запрос в sum.golang.org падает с ошибками вроде 410 Gone.
GOPRIVATE задаёт сразу GONOPROXY и GONOSUMDB. Отдельно их ставят, только когда приватные модули должен отдавать корпоративный прокси вроде Athens или Artifactory: GOPROXY=https://goproxy.corp.example,direct.
Аутентификация идёт через обычный Git: SSH-ключ с перезаписью insteadOf или токен в ~/.netrc на CI.
go.sum по-прежнему работает: приватные модули сверяются с закоммиченными хешами.
Вендоринг (go mod vendor и коммит vendor/) кладёт исходники всех зависимостей в репозиторий. Пока vendor/ существует, сборка берёт код из него, и ей не нужны ни сеть, ни доступы. Это нужно для изолированных сборок или когда безопасность хочет видеть диффы зависимостей на ревью; ценой становятся раздутый репозиторий и повторный go mod vendor после каждого изменения зависимостей.
Строка go задаёт версию языка, с которой компилируется код модуля, и, начиная с Go 1.21, минимальный релиз Go, которому разрешено его собирать. Если установленный Go старше, по умолчанию он скачивает нужный тулчейн и перезапускается.
Правила языка следуют строке go, а не установленному компилятору: с go 1.21 даже Go 1.25 отвергнет for i := range 3, а переменная цикла на каждую итерацию из Go 1.22 для этого модуля останется выключенной.
Строка toolchain называет предпочтительный более новый тулчейн. Она учитывается, только когда модуль главный; в зависимостях её игнорируют.
GOTOOLCHAIN=auto (по умолчанию) разрешает скачивание. GOTOOLCHAIN=local запрещает его, и старый Go падает с go.mod requires go >= 1.24.0 (running go 1.23.6; GOTOOLCHAIN=local). В CI-образах часто ставят local, чтобы артефакт собирал именно Go из образа.
Зависимость с более новой строкой go поднимает вашу при добавлении. Меняйте обе строки через go get go@1.24.0 и go get toolchain@go1.25.3, а не руками.
С Go 1.24 используйте директиву tool: go get -tool pkg@version записывает утилиту в go.mod, а go tool <имя> запускает ровно эту версию. До 1.24 то же самое делали файлом tools.go с пустыми импортами за build-тегом.
**Директива tool**: версия живёт в go.mod и go.sum, как у любой зависимости, go tool mockgen не требует настройки PATH, а собранный бинарник кешируется. go install tool ставит все перечисленные утилиты, go get tool обновляет их все.
**tools.go**: //go:build tools плюс import _ "go.uber.org/mock/mockgen", запуск через go run go.uber.org/mock/mockgen. Работает, но это соглашение, о котором команда go ничего не знает.
Цена обоих способов: зависимости утилиты попадают в ваш граф модулей. mockgen v0.6.0 приносит golang.org/x/tools v0.36.0 (плюс x/mod и x/sync), и MVS может поднять библиотеку, которую использует ваш собственный код. Если это важно, держите утилиты в отдельном файле модуля: go get -tool -modfile=tools/go.mod ... и go tool -modfile=tools/go.mod mockgen.
Некоторые утилиты, в том числе golangci-lint, рекомендуют ставить их релизный бинарник, а не собирать из вашего модуля.
Держите структуру плоской и группируйте по доменам: по директории cmd/<бинарник> на каждый исполняемый файл с тонким main, а остальной код кладите в internal/. Команда go запрещает импортировать пакет из internal снаружи дерева, растущего из его родительской директории.
internal работает как проверка, а не соглашение об именах: импорт из другого модуля падает с use of internal package ... not allowed. Поэтому эти API можно менять, никого не ломая.
Называйте пакеты по тому, что они дают (order, payment, postgres), а не по слоям вроде models, utils или helpers, которые в итоге импортирует каждый пакет: это обычный источник циклических импортов.
cmd/api/main.go читает конфиг и связывает зависимости. Логику, оставленную в main, нельзя импортировать, а значит, нельзя ни протестировать, ни переиспользовать.
pkg/ необязателен, и многие команды обходятся без него. Популярный репозиторий golang-standards/project-layout не является официальным стандартом; собственные рекомендации команды Go лежат на go.dev/doc/modules/layout.
Начинайте с малого: одного пакета хватает, пока это не начинает мешать.
Go компилирует и инициализирует пакеты по одному в порядке зависимостей, а цикл не оставляет допустимого порядка, поэтому import cycle not allowed считается ошибкой сборки. Разорвать цикл означает направить импорты в одну сторону, чаще всего через интерфейс, объявленный в пакете, которому он нужен.
Если store должен вызывать service, пусть store объявит маленький интерфейс с единственным нужным ему методом. service удовлетворяет ему, не будучи импортированным, потому что интерфейсы в Go реализуются неявно, а связывает их main.
Либо вынесите общие типы в третий, более низкоуровневый пакет, который импортируют оба.
Либо слейте пакеты: цикл часто означает, что разделения на самом деле не было.
Правило держит граф пакетов ацикличным, и именно это позволяет Go компилировать пакеты параллельно и кешировать каждый отдельно.
Ограничение сборки (build constraint) решает, входит ли файл в сборку при текущих GOOS, GOARCH, версии Go и -tags. Кросс-компиляция сводится просто к указанию цели, GOOS=linux GOARCH=arm64 go build, без дополнительного тулчейна, пока cgo выключен.
//go:build linux && (amd64 || arm64) ставится над строкой package. Суффиксы имени файла вроде _windows.go или _linux_arm64.go работают как неявные ограничения, а файлы _test.go собирает только go test.
Собственные теги включают необязательный код: //go:build integration плюс go test -tags integration ./....
Файл, исключённый ограничением, не видят ни компилятор, ни go vet, ни gopls. Код за тегом, который никто не передаёт, может незаметно перестать компилироваться, поэтому CI должен собирать и прогонять vet со всеми наборами тегов, которые вы выпускаете, например go vet -tags integration ./....
При кросс-компиляции cgo по умолчанию выключен. Коду, которому нужен cgo, нужен C-кросс-компилятор под целевую платформу (задаётся через CC), и это одна из причин, почему большинство сервисов собирают с CGO_ENABLED=0.
go tool dist list печатает все поддерживаемые пары GOOS/GOARCH.
Она напечатает 1.4.0 none 2026-10-14 "". -X перезаписывает только string-переменные уровня пакета, которые не инициализированы или инициализированы константой, и молчит всякий раз, когда применить флаг нельзя.
version и buildTime выставляются: у одной инициализатор задан константой, у другой его нет.
commit объявлен как константа. Константы подставляются при компиляции, переменной, в которую мог бы записать линкер, нет, и флаг игнорируется.
env линкер записывает, но при старте её перезаписывает инициализатор os.Getenv. Любой инициализатор, который вызывает функцию или читает другую переменную, сводит -X на нет.
Опечатка так же беззвучна: -X main.Version=... с заглавной V оставляет version равной "dev". Ловите это запуском ./app --version в CI.
Вне main нужен полный путь импорта: -X github.com/acme/app/internal/build.Version=1.4.0.
Используйте multi-stage сборку: соберите статический бинарник с CGO_ENABLED=0 в образе golang, а затем скопируйте только его в distroless/static или scratch. На scratch ломается обычно не бинарник, а файлы, которые он читает в рантайме: CA-сертификаты и база часовых поясов.
Скопируйте go.mod и go.sum и выполните go mod download до копирования исходников, чтобы слой с зависимостями оставался в кеше; --mount=type=cache для кеша сборки ещё сильнее ускоряет пересборки.
CGO_ENABLED=0 даёт статически слинкованный бинарник. С включённым cgo (по умолчанию при нативной сборке под Linux) бинарнику нужны libc и динамический загрузчик, и на scratch он падает с exec /app: no such file or directory.
На scratch первый HTTPS-запрос падает с x509: certificate signed by unknown authority (нет /etc/ssl/certs), а time.LoadLocation падает с unknown time zone (нет /usr/share/zoneinfo). Скопируйте бандл сертификатов из build-стадии и добавьте import _ "time/tzdata" (около 450 КБ), чтобы вшить базу поясов.
В gcr.io/distroless/static-debian12:nonroot уже есть сертификаты, tzdata и пользователь без root, поэтому обычно берут его. В любом случае запускайте не от root.
-trimpath -ldflags="-s -w" убирает локальные пути и отладочные символы, и бинарник становится меньше; стектрейсы паник при этом работают.
govulncheck сообщает об уязвимости, только если ваш код действительно может дотянуться до уязвимой функции: он использует граф вызовов и базу уязвимостей Go.
Сканеры, сравнивающие версии, помечают каждую уязвимую версию модуля в go.sum, включая те, чью уязвимую функцию вы не вызываете. govulncheck ./... выносит такие отдельно, как присутствующие, но не вызываемые, и проваливает запуск только на достижимых, так что на нём можно строить проверку в CI.
Он ещё и сверяет стандартную библиотеку с вашей версией Go. Уязвимость в net/http или crypto/tls исправляется обновлением тулчейна, а не через go get.
govulncheck -mode=binary ./app сканирует собранный бинарник, например тот, что реально задеплоен.
Чтобы закрыть достижимую уязвимость, поднимите минимум сами, например go get golang.org/x/net@v0.38.0, не дожидаясь промежуточной библиотеки.
Чтобы понять, почему помеченный модуль вообще в сборке, go mod why -m <модуль> печатает цепочку импортов; он отвечает (main module does not need module ...), если модуль не импортирует ни один пакет; тогда он в go.mod только ради графа версий. go mod graph | grep x/crypto@ показывает, какая зависимость какую версию просит.
go.work меняет граф модулей на той машине, где он лежит: его строки use делают перечисленные директории главными модулями, поэтому ноутбуки берут example.com/acme/lib из соседней директории. У CI нет go.work, он читает только api/go.mod и не находит там require для lib.
go work init ./api ./lib записывает эти строки use. После этого любая команда go ниже этой директории предпочитает локальные копии, а go env GOWORK показывает действующий файл.
Воспроизведите CI перед пушем межмодульной правки: GOWORK=off go build ./... и GOWORK=off go test ./... ловят строку require, которую никто не добавил.
Выкатывайте по порядку: тег на lib (в монорепозитории тег получает префикс директории: lib/v0.4.0), потом go get example.com/acme/lib@v0.4.0 в api. Воркспейс закрывает время до тега, а не заменяет тег.
Воркспейс лучше закоммиченных строк replace ../lib: те меняют сборку для всех и мешают go install.
Коммитьте go.work в монорепозитории, где все модули всегда собираются вместе из одного коммита; держите его вне git, когда модули выпускаются и потребляются как версии.
Сборка Go детерминирована при тех же исходниках, зависимостях, тулчейне, флагах и окружении. На практике различия дают абсолютные пути к файлам (лечится -trimpath), другой релиз Go, cgo, который тянет C-тулчейн хоста, или флаг, который меняется от сборки к сборке.
Источники различий
Без -trimpath путь чекаута (/home/runner/work/...) вшивается ради стектрейсов, и раннеры с разными рабочими директориями дают разные байты.
Разные патч-релизы Go генерируют разный код. Фиксируйте его строкой toolchain или GOTOOLCHAIN=go1.25.3; сам тулчейн Go воспроизводим с Go 1.21.
go.sum уже фиксирует байты зависимостей. CGO_ENABLED=0 убирает из уравнения C-компилятор и libc хоста.
Держите одинаковыми GOFLAGS, GOAMD64/GOARM64 и -ldflags. Время сборки, переданное через -X, по определению делает каждую сборку уникальной; используйте время коммита.
Что записано в бинарнике
go version -m ./app печатает версию Go, каждую зависимость с её хешем, флаги сборки, CGO_ENABLED, vcs.revision, vcs.time и vcs.modified.
debug.ReadBuildInfo() читает те же данные в рантайме, так что сервис может сообщить свой коммит без всякого -X.
vcs.modified=true или версия главного модуля с +dirty в Go 1.24+ означает, что в дереве были незакоммиченные изменения, и пересобрать этот артефакт из git не сможет никто.
PGO передаёт компилятору CPU-профиль из продакшена, и тот оптимизирует код, который действительно горячий. Положите профиль в default.pgo рядом с пакетом main, и начиная с Go 1.21 go build использует его по умолчанию; типичный выигрыш составляет несколько процентов CPU.
Что она оптимизирует
Инлайнинг: горячие места вызова получают гораздо больший бюджет на инлайнинг, чем разрешают обычные эвристики.
Девиртуализацию: горячий вызов через интерфейс, у которого в профиле преобладает один конкретный тип, превращается в прямой вызов за проверкой типа, и его тоже можно заинлайнить.
Release notes Go называют примерно 2–14% выигрыша по CPU в зависимости от программы, плюс некоторый рост времени сборки и размера бинарника.
Как внедрить
Снимите репрезентативный CPU-профиль в продакшене через net/http/pprof (/debug/pprof/profile?seconds=30) с нескольких инстансов и слейте профили через go tool pprof -proto.
Закоммитьте его как cmd/api/default.pgo. Дефолтный -pgo=auto подхватит его для этого main-пакета и применит ко всем пакетам, которые входят в бинарник.
Время от времени обновляйте профиль. Устаревший профиль деградирует мягко: изменённый код просто теряет оптимизацию, поэтому собирать каждый релиз с профилем предыдущего считается нормальным процессом.
Подтверждайте выигрыш метриками CPU в продакшене или benchstat и не берите профили с микробенчмарков: они оптимизируют не те пути.