Go 静态编译及在构建 docker 镜像时的应用
date
Jul 16, 2024
slug
golang-static-compilation-docker-image
status
Published
tags
Go
系统和网络
summary
Go 的静态编译与 docker 镜像
type
Post
Go 语言具有跨平台和可移植的特点,同时还支持交叉编译,可以在一个系统上编译出运行在另一个系统上的二进制可执行文件,这是因为 Go 在编译时支持将依赖的库文件与源代码一起编译链接到二进制文件中,所以在实际运行时不再需要依赖运行环境中的库,而只需要一个二进制文件就可以运行,在构建 docker 镜像时就可以利用这个特点,实现减小镜像大小的目的,下面逐步介绍这中间涉及到的关键点。
链接库
什么是链接库,为什么要有链接库
链接库是高级语言经过编译后得到的二进制文件,其中包含有函数或数据,可以被其他应用程序调用,链接库根据链接方式的不同分为静态链接库和动态链接库。
以 C 语言标准 ISO C99 为例,它定义了一组广泛的标准 I/O、字符串操作和整数数学函数,例如
atoi
、printf
,scanf
、strcpy
和 rand
。它们在 libc.a 库中,对每个 C 程序来说都是可用的。ISO C99 还在 libm.a 库中定义了一组广泛的浮点数学函数,例如 sin
、cos
和 sqrt
。如果没有链接库,那么当开发者需要用到上述标准函数时有下面几种方式实现,第一种是开发者自己实现一遍,可想而知这样开发效率很低,而且容易出错;第二种是编译器解析到使用了标准函数时自动生成相应的代码实现,这种方式将给编译器增加显著的复杂性,而且每次添加、删除或修改一个标准函数时,就需要一个新的编译器版本,比较繁琐;第三种则是将标准函数的实现打包到一个标准函数目标文件中,例如 libx.o,开发者可以在编译时自行指定使用哪个标准函数目标文件。
相较而言第三种的思路更好一些,因为这种方式将编译器和标准函数的实现分离开,降低了编译器的复杂度,同时又能在标准函数的实现发生变化时以较低成本实现替换,链接库就是基于这种方式而来的。
链接库的两种类型
编译过程中编译器将源代码编译成目标文件,一般以 .o(object) 作为扩展名,之后链接器将多个目标文件链接成可执行文件或链接库文件,链接库根据被使用时的方式的不同分为静态链接库和动态链接库。
Linux 平台上静态库一般以 .a(archive) 为扩展名,动态库一般以 .so(shared object) 为扩展名;
Windows 平台上静态库一般以 .lib 为扩展名,动态库一般以 .dll(dynamic link library) 为扩展名;
静态链接库是将相关函数编译为独立的目标模块,然后封装成一个单独的静态库文件。编译程序时可以通过指定单独的文件名来使用这些在库中定义的函数。比如,使用 C 标准库和数学库中函数的程序可以用如下的命令行来编译和链接:
而在链接时,链接器只会复制被用到的目标模块,而并不会复制整个库的内容,这就减少了可执行文件在磁盘和内存中的大小。
静态链接库也有一些缺点,首先是静态链接库是在编译链接过程中被复制到可执行文件中的,当静态链接库有更新时,应用程序必须重新执行编译链接得到新的可执行文件。第二是几乎每个 C 程序都会用到标准 I/O 函数,比如
printf
和 scanf
,这些函数的代码被重复的复制到每个运行进程的文本段中,这对于内存来说是一种浪费。动态链接库避免了上述问题,应用程序在编译时只记录一些动态链接库的基础信息,在加载应用程序但还没有运行时会将依赖的动态链接库中的函数与内存中的程序链接起来形成一个完整的程序,所有引用同一个动态链接库的可执行文件共用这个库中的代码和数据,而不是像静态库的内容那样被复制和嵌入到引用它们的可执行的文件中。
使用链接库
使用静态链接库
下面用 C 语言编写两个函数,并分别生成静态链接库和动态链接库,最后在另一个程序中使用生成的链接库。
addvec.c 文件,其中
addvec
函数实现两个向量数组的相加multvec.c 文件,其中
multvec
函数实现两个数组向量的相乘定义头文件 vector.h
main2.c 用来测试使用链接库
首先编译出两个库函数的目标文件
得到两个目标文件 addvec.o 和 multvec.o,接着将两个目标文件链接成静态库,ar 命令是用来处理静态链接库的,也就是归档文件 archive
得到静态链接库 libvector.a,最后编译链接应用程序和动态链接库生成可执行文件,其中
-static
参数用来生成静态链接程序最后得到可执行文件 prog2c 并运行
当链接器运行时,它判定 main2.o 引用了 addvec.o 定义的
addvec
函数符号,所以复制 addvec.o 到可执行文件。因为程序不引用任何由 multvec.o 定义的符号,所以链接器就不会复制这个模块到可执行文件。链接器还会复制 libc.a 中的 printf.o 模块,以及许多 C 运行时系统中的其他模块。下面是使用静态链接库生成可执行文件的图示:
使用动态链接库
再看一个动态链接库的例子,代码还是一样,只是在生成链接库和编译链接的时候不太一样。使用 gcc 生成动态链接库,其中
-shared
参数表明生成共享的链接库,-fpic
参数表明生成位置无关代码(position-independent code),位置无关代码可以理解为是库中的函数都没有确定下来在内存中的具体的绝对位置,而是使用相对位置表示,只有在被链接到应用程序中才被确定最终在内存中的位置。得到动态链接库 libvector.so,之后编译链接生成可执行文件
得到可执行文件 prog2l 并运行
创建完可执行文件后,其实并没有任何 libvector.so 的代码和数据节真的被复制到可执行文件 prog2l 中。链接器仅仅是复制了一些重定位和符号表信息,它们使得运行时可以解析对 libvector.so 中代码和数据的引用,在程序加载时动态链接才真正完成。
下面是动态链接库的图示:
在程序运行中加载链接库
此外还可以在应用程序运行过程中加载指定动态链接库,但这里不展开,只列出一个典型的例子,下面例子是在应用程序运行中加载调用 libvector.so 库:
编译
动态编译与静态编译
编译应用程序时如果使用静态链接库则被称为静态编译,如果使用动态链接库则被称为动态编译。静态编译是在编译时就将依赖的静态链接库复制到可执行文件中,这样在应用程序运行起来后无需依赖外部的库,只需要单一的可执行文件即可运行,但缺点是应用程序体积相对较大,程序运行的越多重复占用的内存浪费越多。
动态编译则相当于按需加载,动态编译有好处也有弊端,好处是应用程序只需要链接用到的目标模块,这使得应用程序的体积更小,运行起来之后内存占用更低。而弊端则是如果应用程序所在的运行环境中缺少依赖的动态链接库则会导致无法正常运行。
Go 静态编译和动态编译例子
静态编译
Go 支持跨平台和可移植特性,默认使用静态编译
编译后可以通过 ldd(List Dynamic Dependencies) 命名查看可执行程序所依赖的动态链接库:
not a dynamic executable
表示没有依赖任何的动态链接库。动态编译
但并不是所有情况下都不需要依赖外部库,例如对于很多经典的 C 语言函数库来说,编程语言没必要自己重新实现一遍,需要用到时直接调用 C 语言函数库即可。下面的 Go 程序中使用了 net/http 包,其中关于网络的处理很多都是依赖 C 语言的动态链接库:
编译后用 ldd 查看
能看到输出了一些动态链接库,例如 libresolv.so.2 就是用于域名解析的库,而 libc.so.6 则是标准 C 库,含有大部分 C 函数。
下面介绍 Go 如何指定进行静态编译
Go 强制进行静态编译
如果希望将上述代码编译后运行在另一个系统中,为了保证可移植性,应该尽量使用静态编译,如果想要强制使用静态编译有两种方式。
通过关闭 CGO 实现静态编译
先介绍 CGO
CGO 是 Go 开发工具链中自带的工具,CGO 是使 Go 语言和 C 语言相互调用的桥梁。如果在 Go 代码中包含
import "C"
并且开启 CGO,那么在 go build
编译时就会通过 CGO 来处理 C 代码,生成能够让 Go 调用 C 的桥接代码,然后交给 gcc 编译得到 C 语言的目标文件,之后再编译 Go 代码得到 Go 语言的目标文件,最终将 Go 和 C 目标文件通过链接器链接到一起得到最终的可执行文件。
CGO 通过环境变量 CGO_ENABLED
控制是否启用,默认为 1 表示启用,0 表示关闭。下面一段代码展示利用 CGO 实现 Go 调用 C 函数的功能,主要有两个文件 hello.go 和 hello.c
hello.go
hello.c
查看环境变量
编译运行
可以看到用 Go 调用 C 语言函数的运行效果。
通过关闭 CGO 间接实现静态编译
按照这个思路,如果关闭 CGO 之后再编译之前的 server.go 的应用代码,Go 编译器由于无法启用 CGO 也就无法生成 Go 和 C 之间的桥接代码,无法利用 C 函数库,只能使用纯 Go 实现的函数,从而实现静态编译效果。下面就是关闭 CGO 后编译的 server.go
在
go build
前指定 CGO_ENABLED=0
来关闭 CGO,最后得到的可执行文件可以看到不再依赖动态链接库,实现静态编译。通过链接参数实现静态编译
假如我希望在代码中调用 C 函数,但又希望执行静态编译应该怎么做?也就是说我必须开启 CGO 但又希望进行静态编译。
go build
有一个 -ldflags
参数表示传给链接器的参数,参数中 -linkmode
控制使用 Go 内部自己实现的链接器 internal(默认值),还是外部链接器 external,例如使用 gcc clang 等。如果代码中只需要 net, os/user, runtime/cgo 等包则使用 internal,否则使用 external。-extldflags
表示传给外部链接器的参数,这里是 -static
表示使用静态链接方式。得到编译后的可执行文件 server,通过 ldd 查看表明这是一个静态链接的可执行文件。
利用静态编译减小 docker 镜像体积
静态编译后二进制文件可移植性较好,只需要一个单独的文件便可以运行,并且由于编译时的环境要求与运行时的环境要求不同,运行时环境中不要求有编译链接等工具,所以可以利用这个区别在构建 docker 镜像时只需要保留能够支持可执行文件运行的最少资源即可,从而缩小镜像体积。
使用两个 Dockerfile 分别构建
下面有两个 Dockerfile,第一个是 build.Dockerfile,主要是执行静态编译指令编译出可执行文件 server:
构建镜像
之后创建一个容器,测试功能正常:
此时查看一下镜像大小为 796MB。
现在测试将可执行文件转移到另一个容器环境中单独执行,首先把在第一个镜像中编译好的 server 复制出来到宿主机上。
然后在第二个名为 run.Dockerfile 的 Dockerfile 中把 server COPY 进去
构建镜像
启动容器并测试功能正常:
此时对比一下两个镜像,
go_web_build
有 796MB,而 go_web_run
仅有 15.4MB,大幅缩小了镜像的大小。不过这样做还是有点繁琐,需要编写两个 Dockerfile 同时还要手动复制可执行文件,而 docker 的多阶段构建可以简化这个过程。
使用 docker 的多阶段构建
docker 多阶段构建(multi-stage build)可以在一个 Dockerfile 中编写上述两个镜像构建过程,使用
FROM
指令表示开始一个阶段的构建,第一阶段构建用来编译得到可执行文件,在第二阶段构建时可以将上一个阶段中产出的可执行文件 COPY
到当前构建的镜像中,从而实现与上述效果相同的减少镜像体积的目的。现在使用多阶段构建结合 Go 的静态编译做一个实验,下面是名为 mutil_stage.Dockerfile 的 Dockerfile 文件:
构建镜像
启动容器运行测试正常:
查看镜像可以看到
go_web_mstage
也是 15.4MB,这样就实现了在一个 Dockerfile 中声明两个镜像并且保持镜像体积相对较小。总结
文中涉及到的相关概念比较多,这里做一个要点总结。首先介绍了链接库的概念以及静态链接库和动态链接库的区别,接着介绍了 Go 的静态编译和动态编译以及如何实现静态编译,最后举了一个实际例子,使用 Go 的静态编译结合 docker 的多阶段构建实现了减小镜像体积的效果。