还记得我之前提出过的0号规律吗?边际收益为0的重构不要去做。我已经遇过2次因为底层go版本的实现变化,而导致的无法升级项目go版本问题。
由于工作环境的限制,CI的不支持,导致无法在工作环境中使用最用最新版本的golang。
如果使用VS code作为编辑器,就会产生一个问题:
dlv 基本用最新版本的golang去构建,而 dlv 依赖于 go mod tidy。
如果使用 go mod tidy ,会根据依赖图修剪策略,使用高版本go后会重算依赖树,最终导致 go.mod 被重写。
要在 Windows 上让多个版本的 Go 语言共存,并且不影响 go mod 和 go install 的本地缓存,
Go 官方提出了一个多版本管理工具 go install golang.org/dl/go<version>@latest ,核心思路是:将不同版本的 Go 安装在不同的目录,并通过环境变量灵活切换。
各版本 SDK 隔离存放,互不影响。 go mod 和 go install 的缓存(默认在 %USERPROFILE%\go\pkg\mod 和 %USERPROFILE%\go\bin)是全局共享的。这是设计使然,因为模块缓存和安装的可执行文件通常是版本无关或向后兼容的。使用不同版本 Go 编译同一模块时,会利用已有缓存,节省空间和时间。
但在实践过程中会出现一点问题。
卡点
配置了 GOPROXY 之后,本地安装当前golang的最高版本(go 1.26.1)并指向path路径。
再安装特定版本的 Go 工具链:
1
2
3
4
5
#打开 PowerShell 或 CMD,使用 go install 命令安装你需要的版本。这些命令会下载一个版本特定的启动器。
go install golang.org/dl/go1.23.12@latest
# 之后可以看到 `%USERPROFILE%\go\bin` 中出现了 `go1.23.12.exe`。
go1.23.12 download
# 这一步由于网络问题,国内会下载失败!
我下载了 go1.23.12.windows-amd64.zip 并放置到 %USERPROFILE%\sdk\go1.23.12 目录,然后把压缩包中的go目录提取到 %USERPROFILE%\sdk\go1.23.12 中,还是提示下载失败。
于是我翻阅了这个命令对应的项目源码。go1.23.12其实对应 https://github.com/golang/dl 中的 dl\go1.23.12\main.go,重点在于 dl\internal\version\version.go中的文件判断。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
func install(targetDir, version string) error {
if _, err := os.Stat(filepath.Join(targetDir, unpackedOkay)); err == nil {
log.Printf("%s: already downloaded in %v", version, targetDir)
return nil
}
if err := os.MkdirAll(targetDir, 0755); err != nil {
return err
}
goURL := versionArchiveURL(version)
res, err := http.Head(goURL)
if err != nil {
return err
}
if res.StatusCode == http.StatusNotFound {
return fmt.Errorf("no binary release of %v for %v/%v at %v", version, getOS(), runtime.GOARCH, goURL)
}
if res.StatusCode != http.StatusOK {
return fmt.Errorf("server returned %v checking size of %v", http.StatusText(res.StatusCode), goURL)
}
base := path.Base(goURL)
archiveFile := filepath.Join(targetDir, base)
if fi, err := os.Stat(archiveFile); err != nil || fi.Size() != res.ContentLength {
if err != nil && !os.IsNotExist(err) {
// Something weird. Don't try to download.
return err
}
if err := copyFromURL(archiveFile, goURL); err != nil {
return fmt.Errorf("error downloading %v: %v", goURL, err)
}
fi, err = os.Stat(archiveFile)
if err != nil {
return err
}
if fi.Size() != res.ContentLength {
return fmt.Errorf("downloaded file %s size %v doesn't match server size %v", archiveFile, fi.Size(), res.ContentLength)
}
}
wantSHA, err := slurpURLToString(goURL + ".sha256")
if err != nil {
return err
}
if err := verifySHA256(archiveFile, strings.TrimSpace(wantSHA)); err != nil {
return fmt.Errorf("error verifying SHA256 of %v: %v", archiveFile, err)
}
log.Printf("Unpacking %v ...", archiveFile)
if err := unpackArchive(targetDir, archiveFile); err != nil {
return fmt.Errorf("extracting archive %v: %v", archiveFile, err)
}
if err := os.WriteFile(filepath.Join(targetDir, unpackedOkay), nil, 0644); err != nil {
return err
}
log.Printf("Success. You may now run '%v'", version)
return nil
}
go1.23.12.windows-amd64.zip 文件下载之后会计算 SHA256 ,并与远程登记的值进行对比,之后再解压,创建 .unpacked-success 文件。
如果我本地想跳过这个过程,要么修改 dl\internal\version\version.go 重新编译;要么在 %USERPROFILE%\sdk\go1.23.12 目录中创建 .unpacked-success 文件,我选择第二种。
go1.23.12 version
go version go1.23.12 windows/amd64
命令成功之后,我找了一个项目构建,设置 env 之后依旧构建失败了。
1
2
3
4
5
6
7
8
9
10
11
go1.23.12 clean -cache
go1.23.12 clean -modcache
go1.23.12 clean -testcache
# go1.23.12 env -w GOPROXY=
# go1.23.12 env -w GONOPROXY=
# go1.23.12 env -w GOPRIVATE=
# go1.23.12 env -w GOINSECURE=
go1.23.12 env -w GOSUMDB=off
go1.23.12 env -w GO111MODULE=on
go1.23.12 build -v -ldflags="-checklinkname=0"
于是我决定回退一开始的方案,使用 path 路径切换 go 版本 🤣。 在win11中,使用1.26,就把1.26 go版本的path上移到1.23的上面。这样新开的命令窗口,就是使用1.26版本,反之同理。
One more thing
构建项目可以通过go-size-analyzer分析二进制文件的大小。
- 禁用CGO
- 去除调试信息
- 移除路径信息
减少二进制文件大小。
1
CGO_ENABLED=0 go build -ldflags="-s -w" -trimpath -o myapp main.go
而代码层面,为了减少最终二进制文件大小,应该做的是
- 减少代码的动态反射
- 使用构建标签减少非必要的引用
- 谨慎使用匿名导入
- 使用构建标签减少非必要的引用
db_basic.go
1
2
3
4
5
6
7
8
9
//go:build !cloud_db
package main
import "fmt"
func connectDB() {
fmt.Println("Connecting to local SQLite database...")
}
db_cloud.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
//go:build cloud_db
package main
import (
"fmt"
// 假设这个包非常大,有很多依赖
// "github.com/aws/aws-sdk-go/service/dynamodb"
)
func connectDB() {
fmt.Println("Connecting to heavy Cloud Database...")
// 初始化云数据库的代码
}
main.go
1
2
3
4
5
package main
func main() {
connectDB()
}
直接运行 go build,编译器会包含 db_basic.go,排除 db_cloud.go。编译出来的文件很小。
如果你想要包含高级特性的版本,运行 go build -tags cloud_db,编译器会包含 db_cloud.go,排除 db_basic.go,此时才会把那些沉重的云数据库依赖打包进去。
相当于一种选择编译的技巧。
- 谨慎使用匿名导入
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// main_plugin.go
package main
import (
"fmt"
_ "plugin" // 仅仅是导入了 plugin 包,哪怕使用空白标识符忽略它
)
type HeavyService struct{}
// 未导出的方法 1(未使用)
func (s *HeavyService) unusedMethod1() {
fmt.Println("This is a very complex method doing a lot of things...")
}
// 未导出的方法 2(未使用)
func (s *HeavyService) unusedMethod2() {
fmt.Println("Another unused complex method...")
}
func main() {
fmt.Println("Hello, World!")
}
编译行为分析: 当你使用 go build main_plugin.go 编译时,由于引入了 plugin 包,链接器将该二进制文件标记为支持动态链接。
链接器会这样想:“既然支持动态加载插件,我不知道未来加载进来的插件会不会通过反射或其他机制偷偷调用 HeavyService.unusedMethod1。为了安全起见,我不能删掉它!”
结果是:
- unusedMethod1、unusedMethod2 等所有未导出的方法都会被强行保留。
- 这些方法内部引用的其他包(如 fmt 内部更深层的依赖)、常量、字符串,也全都会被牵连保留。
- 最终编译出的二进制文件体积会异常膨胀。
参考链接
【1】 Datadog 如何将其 Agent 的 Go 二进制文件缩减 77% https://mp.weixin.qq.com/s/SW3-tI-OdtvladmWf-SLpg
Do you still remember the “rule zero” I brought up before? Do not refactor when the marginal benefit is zero. I have already run into twice the situation where I could not upgrade a project’s Go version because the behavior of the underlying Go toolchain changed.
Because of limits in my work environment—CI not supporting it—I cannot use the latest golang there day to day.
If you use VS Code as your editor, a tension appears:
dlv is generally built with the newest golang, and dlv relies on go mod tidy.
If you run go mod tidy, the dependency graph pruning rules mean that after moving to a higher Go version the tree is recomputed and go.mod ends up rewritten.
To keep multiple Go versions on Windows at once without breaking local caches for go mod and go install, the Go project documents a multi-version workflow: go install golang.org/dl/go<version>@latest. The core idea is to put each Go version in its own directory and switch flexibly via environment variables.
Each SDK version lives in isolation and does not disturb the others.
Caches for go mod and go install (by default %USERPROFILE%\go\pkg\mod and %USERPROFILE%\go\bin) are shared globally. That is intentional: the module cache and installed binaries are usually version-agnostic or backward compatible. When different Go versions compile the same module, they reuse the cache and save disk space and time.
In real use, a few snags still show up.
Sticking points
After configuring GOPROXY, I installed the newest golang available for my current setup (go 1.26.1) and put it on PATH.
Then I installed a specific Go toolchain:
1
2
3
4
5
# In PowerShell or CMD, use go install to fetch the version you need. These commands download a version-specific launcher.
go install golang.org/dl/go1.23.12@latest
# You should then see go1.23.12.exe under `%USERPROFILE%\go\bin`.
go1.23.12 download
# This step often fails in China because of network issues!
I downloaded go1.23.12.windows-amd64.zip, placed it under %USERPROFILE%\sdk\go1.23.12, and extracted the go directory into %USERPROFILE%\sdk\go1.23.12, but the tool still reported a download failure.
So I read the source for that command. go1.23.12 maps to dl\go1.23.12\main.go in https://github.com/golang/dl; the important part is the file checks in dl\internal\version\version.go.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
func install(targetDir, version string) error {
if _, err := os.Stat(filepath.Join(targetDir, unpackedOkay)); err == nil {
log.Printf("%s: already downloaded in %v", version, targetDir)
return nil
}
if err := os.MkdirAll(targetDir, 0755); err != nil {
return err
}
goURL := versionArchiveURL(version)
res, err := http.Head(goURL)
if err != nil {
return err
}
if res.StatusCode == http.StatusNotFound {
return fmt.Errorf("no binary release of %v for %v/%v at %v", version, getOS(), runtime.GOARCH, goURL)
}
if res.StatusCode != http.StatusOK {
return fmt.Errorf("server returned %v checking size of %v", http.StatusText(res.StatusCode), goURL)
}
base := path.Base(goURL)
archiveFile := filepath.Join(targetDir, base)
if fi, err := os.Stat(archiveFile); err != nil || fi.Size() != res.ContentLength {
if err != nil && !os.IsNotExist(err) {
// Something weird. Don't try to download.
return err
}
if err := copyFromURL(archiveFile, goURL); err != nil {
return fmt.Errorf("error downloading %v: %v", goURL, err)
}
fi, err = os.Stat(archiveFile)
if err != nil {
return err
}
if fi.Size() != res.ContentLength {
return fmt.Errorf("downloaded file %s size %v doesn't match server size %v", archiveFile, fi.Size(), res.ContentLength)
}
}
wantSHA, err := slurpURLToString(goURL + ".sha256")
if err != nil {
return err
}
if err := verifySHA256(archiveFile, strings.TrimSpace(wantSHA)); err != nil {
return fmt.Errorf("error verifying SHA256 of %v: %v", archiveFile, err)
}
log.Printf("Unpacking %v ...", archiveFile)
if err := unpackArchive(targetDir, archiveFile); err != nil {
return fmt.Errorf("extracting archive %v: %v", archiveFile, err)
}
if err := os.WriteFile(filepath.Join(targetDir, unpackedOkay), nil, 0644); err != nil {
return err
}
log.Printf("Success. You may now run '%v'", version)
return nil
}
After go1.23.12.windows-amd64.zip is present, the tool computes SHA256, compares it with the value published remotely, unpacks, and writes the .unpacked-success marker.
To skip that locally I could either edit dl\internal\version\version.go and rebuild, or create .unpacked-success under %USERPROFILE%\sdk\go1.23.12; I picked the second option.
1
2
go1.23.12 version
go version go1.23.12 windows/amd64
With that working, I tried building a project; even after adjusting env vars the build still failed.
1
2
3
4
5
6
7
8
9
10
11
go1.23.12 clean -cache
go1.23.12 clean -modcache
go1.23.12 clean -testcache
# go1.23.12 env -w GOPROXY=
# go1.23.12 env -w GONOPROXY=
# go1.23.12 env -w GOPRIVATE=
# go1.23.12 env -w GOINSECURE=
go1.23.12 env -w GOSUMDB=off
go1.23.12 env -w GO111MODULE=on
go1.23.12 build -v -ldflags="-checklinkname=0"
So I went back to the first approach: switching Go versions by reordering entries on PATH 🤣.
On Windows 11, to use 1.26, move the 1.26 go bin directory above the 1.23 one on PATH. New command windows then use 1.26; reverse the order for 1.23.
One more thing
When you build, you can use go-size-analyzer to study binary size.
- Disable CGO
- Strip debug symbols
- Drop path metadata from the binary
to shrink the executable.
1
CGO_ENABLED=0 go build -ldflags="-s -w" -trimpath -o myapp main.go
At the source level, to reduce final binary size you should:
- Cut down on dynamic reflection
- Use build tags to drop optional imports
- Be careful with blank imports
- Use build tags to omit non-essential references
db_basic.go
1
2
3
4
5
6
7
8
9
//go:build !cloud_db
package main
import "fmt"
func connectDB() {
fmt.Println("Connecting to local SQLite database...")
}
db_cloud.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
//go:build cloud_db
package main
import (
"fmt"
// Suppose this package is huge with many dependencies
// "github.com/aws/aws-sdk-go/service/dynamodb"
)
func connectDB() {
fmt.Println("Connecting to heavy Cloud Database...")
// Code to initialize the cloud database
}
main.go
1
2
3
4
5
package main
func main() {
connectDB()
}
A plain go build includes db_basic.go and excludes db_cloud.go, yielding a small binary.
For a build with the advanced path, run go build -tags cloud_db: the compiler includes db_cloud.go, excludes db_basic.go, and only then pulls in the heavy cloud database dependencies.
That is essentially a selective compilation technique.
- Be careful with blank imports
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// main_plugin.go
package main
import (
"fmt"
_ "plugin" // Only imports plugin; even with _, the package is linked
)
type HeavyService struct{}
// Unexported method 1 (unused)
func (s *HeavyService) unusedMethod1() {
fmt.Println("This is a very complex method doing a lot of things...")
}
// Unexported method 2 (unused)
func (s *HeavyService) unusedMethod2() {
fmt.Println("Another unused complex method...")
}
func main() {
fmt.Println("Hello, World!")
}
What the linker does:
When you go build main_plugin.go, importing plugin marks the binary as supporting dynamic loading.
The linker reasons: “Because plugins may be loaded later, I cannot know whether a plugin will call HeavyService.unusedMethod1 via reflection or other tricks. To be safe I must keep it!”
The outcome:
unusedMethod1,unusedMethod2, and similar unexported methods are retained.- Packages they pull in (deeper dependencies under
fmt, constants, strings, etc.) are kept as well. - The final binary balloons in size.
References
【1】
How Datadog shrunk its Agent’s Go binary by 77%
https://mp.weixin.qq.com/s/SW3-tI-OdtvladmWf-SLpg
以前に挙げた「ゼロ番の法則」を覚えていますか。限界利益がゼロになるリファクタはやらない、というやつです。底辺の Go 実装の挙動変化のせいで、プロジェクトの Go バージョンを上げられなくなった経験は、すでに 2 回あります。
職場環境の制約(CI が対応していないなど)のため、業務環境では常に最新の golang を使えません。
エディタに VS Code を使うと、次のような問題が出ます。
dlv はだいたい最新の golang でビルドされ、go mod tidy に依存します。
go mod tidy を実行すると、依存グラフのトリミング方針により、高い Go バージョンにすると依存ツリーが再計算され、最終的に go.mod が書き換わります。
Windows 上で複数バージョンの Go を共存させ、go mod と go install のローカルキャッシュを壊さないようにするには、公式が示す複数バージョン管理の流れ go install golang.org/dl/go<version>@latest があります。考え方は、バージョンごとに別ディレクトリへインストールし、環境変数で柔軟に切り替えることです。
各バージョンの SDK は隔離され、互いに干渉しません。
go mod と go install のキャッシュ(既定では %USERPROFILE%\go\pkg\mod と %USERPROFILE%\go\bin)はグローバルで共有されます。モジュールキャッシュとインストールした実行ファイルは多くの場合バージョンに依存しないか後方互換なので、設計としてそうなっています。異なる Go で同一モジュールをビルドすると既存キャッシュを使え、容量と時間を節約できます。
実運用では、いくつかつまずきどころがあります。
つまずき
GOPROXY を設定したうえで、手元の golang として利用できる最新(go 1.26.1)を入れ、PATH に載せました。
続いて特定バージョンの Go ツールチェーンを入れます。
1
2
3
4
5
# PowerShell または CMD で、必要なバージョンを go install で取得する。バージョン専用のランチャーが落ちてくる。
go install golang.org/dl/go1.23.12@latest
# `%USERPROFILE%\go\bin` に `go1.23.12.exe` ができる。
go1.23.12 download
# 中国ではネットワークの都合で、このステップが失敗しがち
go1.23.12.windows-amd64.zip を %USERPROFILE%\sdk\go1.23.12 に置き、アーカイブ内の go フォルダを同じ %USERPROFILE%\sdk\go1.23.12 に展開しても、やはりダウンロード失敗のままでした。
そこでこのコマンドのソースを読みました。go1.23.12 は https://github.com/golang/dl の dl\go1.23.12\main.go に対応し、肝は dl\internal\version\version.go のファイル判定です。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
func install(targetDir, version string) error {
if _, err := os.Stat(filepath.Join(targetDir, unpackedOkay)); err == nil {
log.Printf("%s: already downloaded in %v", version, targetDir)
return nil
}
if err := os.MkdirAll(targetDir, 0755); err != nil {
return err
}
goURL := versionArchiveURL(version)
res, err := http.Head(goURL)
if err != nil {
return err
}
if res.StatusCode == http.StatusNotFound {
return fmt.Errorf("no binary release of %v for %v/%v at %v", version, getOS(), runtime.GOARCH, goURL)
}
if res.StatusCode != http.StatusOK {
return fmt.Errorf("server returned %v checking size of %v", http.StatusText(res.StatusCode), goURL)
}
base := path.Base(goURL)
archiveFile := filepath.Join(targetDir, base)
if fi, err := os.Stat(archiveFile); err != nil || fi.Size() != res.ContentLength {
if err != nil && !os.IsNotExist(err) {
// Something weird. Don't try to download.
return err
}
if err := copyFromURL(archiveFile, goURL); err != nil {
return fmt.Errorf("error downloading %v: %v", goURL, err)
}
fi, err = os.Stat(archiveFile)
if err != nil {
return err
}
if fi.Size() != res.ContentLength {
return fmt.Errorf("downloaded file %s size %v doesn't match server size %v", archiveFile, fi.Size(), res.ContentLength)
}
}
wantSHA, err := slurpURLToString(goURL + ".sha256")
if err != nil {
return err
}
if err := verifySHA256(archiveFile, strings.TrimSpace(wantSHA)); err != nil {
return fmt.Errorf("error verifying SHA256 of %v: %v", archiveFile, err)
}
log.Printf("Unpacking %v ...", archiveFile)
if err := unpackArchive(targetDir, archiveFile); err != nil {
return fmt.Errorf("extracting archive %v: %v", archiveFile, err)
}
if err := os.WriteFile(filepath.Join(targetDir, unpackedOkay), nil, 0644); err != nil {
return err
}
log.Printf("Success. You may now run '%v'", version)
return nil
}
go1.23.12.windows-amd64.zip を取得したあと SHA256 を計算し、リモートの値と突き合わせ、展開して .unpacked-success を作ります。
ローカルでこの過程を飛ばすには、dl\internal\version\version.go を直して再ビルドするか、%USERPROFILE%\sdk\go1.23.12 に .unpacked-success を置くか—こちらは後者を選びました。
1
2
go1.23.12 version
go version go1.23.12 windows/amd64
コマンドが通ったあと、あるプロジェクトをビルドしましたが、env を設定してもビルドは失敗したままでした。
1
2
3
4
5
6
7
8
9
10
11
go1.23.12 clean -cache
go1.23.12 clean -modcache
go1.23.12 clean -testcache
# go1.23.12 env -w GOPROXY=
# go1.23.12 env -w GONOPROXY=
# go1.23.12 env -w GOPRIVATE=
# go1.23.12 env -w GOINSECURE=
go1.23.12 env -w GOSUMDB=off
go1.23.12 env -w GO111MODULE=on
go1.23.12 build -v -ldflags="-checklinkname=0"
最初の方針に戻り、PATH の順で Go バージョンを切り替えることにしました 🤣。
Windows 11 では 1.26 を使うなら、1.26 の go を 1.23 より上に並べる。新しいコマンドウィンドウでは 1.26 が使われ、逆にすれば 1.23 側になります。
One more thing
ビルド時は go-size-analyzer でバイナリサイズを分析できます。
- CGO を無効にする
- デバッグ情報を除く
- パス情報を落とす
ことでバイナリを小さくできます。
1
CGO_ENABLED=0 go build -ldflags="-s -w" -trimpath -o myapp main.go
コード面では、最終バイナリを小さくするために次を意識します。
- 動的リフレクションを減らす
- ビルドタグで不要な参照を外す
- 匿名インポートに注意する
- ビルドタグで不要な参照を減らす
db_basic.go
1
2
3
4
5
6
7
8
9
//go:build !cloud_db
package main
import "fmt"
func connectDB() {
fmt.Println("Connecting to local SQLite database...")
}
db_cloud.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
//go:build cloud_db
package main
import (
"fmt"
// このパッケージが巨大で依存が多いと仮定
// "github.com/aws/aws-sdk-go/service/dynamodb"
)
func connectDB() {
fmt.Println("Connecting to heavy Cloud Database...")
// クラウド DB 初期化のコード
}
main.go
1
2
3
4
5
package main
func main() {
connectDB()
}
そのまま go build すると db_basic.go が入り db_cloud.go は外れ、出力は小さくなります。
高度な構成にしたい場合は go build -tags cloud_db とすると db_cloud.go が入り db_basic.go は外れ、重いクラウド DB 依存が初めてバンドルされます。
選択的コンパイルのテクニックに相当します。
- 匿名インポートに注意する
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// main_plugin.go
package main
import (
"fmt"
_ "plugin" // plugin をインポートするだけ。空白識別子でもリンクされる
)
type HeavyService struct{}
// エクスポートされていないメソッド 1(未使用)
func (s *HeavyService) unusedMethod1() {
fmt.Println("This is a very complex method doing a lot of things...")
}
// エクスポートされていないメソッド 2(未使用)
func (s *HeavyService) unusedMethod2() {
fmt.Println("Another unused complex method...")
}
func main() {
fmt.Println("Hello, World!")
}
コンパイル・リンクの挙動:
go build main_plugin.go で plugin を入れると、バイナリは動的ロード対応として印が付きます。
リンカの考え方は「動的プラグインを将来読み込むかもしれない。読み込まれた側がリフレクションなどで HeavyService.unusedMethod1 を呼ぶかもしれない。安全のため削れない」となります。
結果:
unusedMethod1やunusedMethod2など、エクスポートされていないメソッドが残される- それらが引っ張る他パッケージ(
fmtより深い依存など)、定数、文字列も連鎖的に残る - 最終バイナリが異常に肥大化する
参考リンク
【1】
Datadog が Agent の Go バイナリを 77% 削減した話(記事紹介・中国語)
https://mp.weixin.qq.com/s/SW3-tI-OdtvladmWf-SLpg
Помните «правило ноль», о котором я уже говорил? Не делайте рефакторинг с нулевой предельной отдачей. У меня уже дважды была ситуация, когда из‑за изменений в реализации нижележащей версии Go нельзя было обновить версию Go в проекте.
Из‑за ограничений рабочей среды (CI не поддерживает нужное) нельзя постоянно пользоваться самым свежим golang в том же окружении, где ведётся работа.
Если редактор — VS Code, возникает такая связка:
dlv обычно собирают на новейшем golang, а dlv опирается на go mod tidy.
После go mod tidy политика обрезки графа зависимостей при переходе на более высокую версию Go пересчитывает дерево и в итоге переписывает go.mod.
Чтобы на Windows держать несколько версий Go и не ломать локальный кэш для go mod и go install, в экосистеме Go есть схема с go install golang.org/dl/go<version>@latest: разные версии ставятся в разные каталоги, переключение — через переменные окружения.
SDK разных версий изолированы и не мешают друг другу.
Кэши go mod и go install (по умолчанию %USERPROFILE%\go\pkg\mod и %USERPROFILE%\go\bin) общие для пользователя — так задумано: кэш модулей и установленные бинарники обычно не привязаны к одной версии компилятора или обратно совместимы. Разные версии Go при сборке одного и того же модуля могут переиспользовать кэш и экономить место и время.
На практике всё равно всплывают узкие места.
Где застреваешь
После настройки GOPROXY я поставил максимально новый golang для текущей связки (go 1.26.1) и добавил его в PATH.
Затем ставлю конкретную цепочку инструментов Go:
1
2
3
4
5
# В PowerShell или CMD: go install качает нужную версию — это отдельный лаунчер для версии.
go install golang.org/dl/go1.23.12@latest
# В `%USERPROFILE%\go\bin` появится go1.23.12.exe.
go1.23.12 download
# В Китае этот шаг часто падает из‑за сети.
Я скачал go1.23.12.windows-amd64.zip, положил в %USERPROFILE%\sdk\go1.23.12, распаковал каталог go в %USERPROFILE%\sdk\go1.23.12 — сообщение о неудачной загрузке всё равно оставалось.
Тогда я прочитал исходники этой команды. go1.23.12 соответствует dl\go1.23.12\main.go в https://github.com/golang/dl; ключевое — проверки файлов в dl\internal\version\version.go.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
func install(targetDir, version string) error {
if _, err := os.Stat(filepath.Join(targetDir, unpackedOkay)); err == nil {
log.Printf("%s: already downloaded in %v", version, targetDir)
return nil
}
if err := os.MkdirAll(targetDir, 0755); err != nil {
return err
}
goURL := versionArchiveURL(version)
res, err := http.Head(goURL)
if err != nil {
return err
}
if res.StatusCode == http.StatusNotFound {
return fmt.Errorf("no binary release of %v for %v/%v at %v", version, getOS(), runtime.GOARCH, goURL)
}
if res.StatusCode != http.StatusOK {
return fmt.Errorf("server returned %v checking size of %v", http.StatusText(res.StatusCode), goURL)
}
base := path.Base(goURL)
archiveFile := filepath.Join(targetDir, base)
if fi, err := os.Stat(archiveFile); err != nil || fi.Size() != res.ContentLength {
if err != nil && !os.IsNotExist(err) {
// Something weird. Don't try to download.
return err
}
if err := copyFromURL(archiveFile, goURL); err != nil {
return fmt.Errorf("error downloading %v: %v", goURL, err)
}
fi, err = os.Stat(archiveFile)
if err != nil {
return err
}
if fi.Size() != res.ContentLength {
return fmt.Errorf("downloaded file %s size %v doesn't match server size %v", archiveFile, fi.Size(), res.ContentLength)
}
}
wantSHA, err := slurpURLToString(goURL + ".sha256")
if err != nil {
return err
}
if err := verifySHA256(archiveFile, strings.TrimSpace(wantSHA)); err != nil {
return fmt.Errorf("error verifying SHA256 of %v: %v", archiveFile, err)
}
log.Printf("Unpacking %v ...", archiveFile)
if err := unpackArchive(targetDir, archiveFile); err != nil {
return fmt.Errorf("extracting archive %v: %v", archiveFile, err)
}
if err := os.WriteFile(filepath.Join(targetDir, unpackedOkay), nil, 0644); err != nil {
return err
}
log.Printf("Success. You may now run '%v'", version)
return nil
}
После появления go1.23.12.windows-amd64.zip считается SHA256, сверяется с удалённым значением, архив распаковывается и создаётся маркер .unpacked-success.
Обойти это локально можно либо правкой dl\internal\version\version.go и пересборкой, либо созданием файла .unpacked-success в %USERPROFILE%\sdk\go1.23.12 — я выбрал второе.
1
2
go1.23.12 version
go version go1.23.12 windows/amd64
Когда команды прошли, я попробовал собрать проект: даже после настройки переменных окружения сборка не удалась.
1
2
3
4
5
6
7
8
9
10
11
go1.23.12 clean -cache
go1.23.12 clean -modcache
go1.23.12 clean -testcache
# go1.23.12 env -w GOPROXY=
# go1.23.12 env -w GONOPROXY=
# go1.23.12 env -w GOPRIVATE=
# go1.23.12 env -w GOINSECURE=
go1.23.12 env -w GOSUMDB=off
go1.23.12 env -w GO111MODULE=on
go1.23.12 build -v -ldflags="-checklinkname=0"
Вернулся к исходной идее: переключать версии Go порядком записей в PATH 🤣.
В Windows 11 для 1.26 поднимите каталог с go от 1.26 выше, чем от 1.23. В новом окне терминала будет 1.26; обратный порядок — 1.23.
One more thing
При сборке можно анализировать размер бинарника через go-size-analyzer.
- Отключить CGO
- Убрать отладочную информацию
- Убрать сведения о путях
— так уменьшается размер исполняемого файла.
1
CGO_ENABLED=0 go build -ldflags="-s -w" -trimpath -o myapp main.go
На уровне кода, чтобы уменьшить итоговый бинарник:
- Меньше динамической рефлексии
- Сборочные теги, чтобы не тянуть лишние импорты
- Осторожнее с пустым импортом
_
- Сборочные теги для отсечения ненужных ссылок
db_basic.go
1
2
3
4
5
6
7
8
9
//go:build !cloud_db
package main
import "fmt"
func connectDB() {
fmt.Println("Connecting to local SQLite database...")
}
db_cloud.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
//go:build cloud_db
package main
import (
"fmt"
// Допустим, пакет огромный и тянет много зависимостей
// "github.com/aws/aws-sdk-go/service/dynamodb"
)
func connectDB() {
fmt.Println("Connecting to heavy Cloud Database...")
// Инициализация облачной БД
}
main.go
1
2
3
4
5
package main
func main() {
connectDB()
}
Обычный go build подключает db_basic.go и исключает db_cloud.go — бинарник маленький.
Нужна «тяжёлая» конфигурация — go build -tags cloud_db: включается db_cloud.go, исключается db_basic.go, и только тогда подтягиваются зависимости облачной БД.
По сути приём выборочной компиляции.
- Пустой импорт использовать осмотрительно
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// main_plugin.go
package main
import (
"fmt"
_ "plugin" // Импортирован пакет plugin, даже через пустой идентификатор
)
type HeavyService struct{}
// Неэкспортируемый метод 1 (не используется)
func (s *HeavyService) unusedMethod1() {
fmt.Println("This is a very complex method doing a lot of things...")
}
// Неэкспортируемый метод 2 (не используется)
func (s *HeavyService) unusedMethod2() {
fmt.Println("Another unused complex method...")
}
func main() {
fmt.Println("Hello, World!")
}
Поведение линкера:
при go build main_plugin.go из‑за plugin бинарник помечается как поддерживающий динамическую загрузку.
Линкер рассуждает так: «Раз возможна подгрузка плагинов, неизвестно, не вызовет ли плагин HeavyService.unusedMethod1 через рефлексию. Ради безопасности методы не выкидываю».
Итог:
unusedMethod1,unusedMethod2и прочие неэкспортируемые методы остаются.- Остаются связанные с ними пакеты (включая глубже
fmt), константы, строки. - Размер бинарника сильно растёт.
Ссылки
【1】
Как Datadog уменьшил Go-бинарник своего Agent на 77% (обзорная статья, китайский язык)
https://mp.weixin.qq.com/s/SW3-tI-OdtvladmWf-SLpg
💬 讨论 / Discussion
对这篇文章有想法?欢迎在 GitHub 上发起讨论。
Have thoughts on this post? Start a discussion on GitHub.