我们知道构建一个Docker镜像的时候往往需要引入一些程序依赖的东西,最常见的就是引入一个基础操作系统镜像,但这样往往会使得编译出来的镜像特别大。但是对于go语言,我们可以使用静态编译的方式构建出超小的镜像。有人会问Go本身不就是静态编译吗?请接着往下看。
示例程序
package main
import (
"fmt"
"io/ioutil"
"net/http"
"os"
)
func main() {
resp, err := http.Get("http://baidu.com")
check(err)
body, err := ioutil.ReadAll(resp.Body)
check(err)
fmt.Println(len(body))
}
func check(err error) {
if err != nil {
fmt.Println(err)
os.Exit(1)
}
}
这个程序很简单,就是访问一个网页,然后打印body的大小。
构建docker镜像
使用golang:onbuild镜像
我们先简单介绍一下golang:onbuild
镜像以及如何使用它。这个镜像是golang官方提供的一个用于编译运行go程序的镜像,它包含了很多ONBUILD
触发器,可以涵盖大多数Go程序。其实它就是预置了一些自动执行的命令:
COPY . /go/src/app
RUN go get -d -v
RNU go install -v
它使用起来很简单:创建一个目录,把你的go文件放到该目录。然后增加Dockerfile文件,内容就一行:
FROM golang:onbuild
然后执行docker build
命令构建镜像。这个golang:onbuild
镜像就会自动将这个目录下的所有文件拷贝过去,并编译安装。比如对于本文的例子,我创建了一个app-onbuild
目录,里面是Dockerfile和app.go文件:
➜ app-onbuild ll
total 8.0K
-rw-r--r-- 1 Allan 20 Jan 2 12:21 Dockerfile
-rw-r--r-- 1 Allan 291 Jan 2 12:20 app.go
➜ app-onbuild cat Dockerfile
FROM golang:onbuild
然后我执行编译镜像的命令docker build -t app-onbuild .
,整个过程如下:
➜ app docker build -t app-onbuild .
Sending build context to Docker daemon 3.072 kB
Step 1 : FROM golang:onbuild
onbuild: Pulling from library/golang
75a822cd7888: Already exists
57de64c72267: Pull complete
4306be1e8943: Pull complete
4ba540d49835: Pull complete
5b23b80eb526: Pull complete
981c210a3af4: Pull complete
73f7f7662eed: Pull complete
520a90f1995e: Pull complete
Digest: sha256:d3cbc855152e8672412fc32d7f19371816d686b0dfddedb8fce86245910b31ac
Status: Downloaded newer image for golang:onbuild
# Executing 3 build triggers...
Step 1 : COPY . /go/src/app
Step 1 : RUN go-wrapper download
---> Running in 4d183d7e1e8a
+ exec go get -v -d
Step 1 : RUN go-wrapper install
---> Running in 31b6371f1a4f
+ exec go install -v
app
---> 94cc8fb334ea
Removing intermediate container f13df1977590
Removing intermediate container 4d183d7e1e8a
Removing intermediate container 31b6371f1a4f
Successfully built 94cc8fb334ea
我们可以看到整个过程如前面所述。编译完以后,golang:onbuild
镜像默认还包含CMD ["app"]
执行来运行编译出来的镜像。当然如果你的程序有参数,我们可以在启动的时候加命令行参数。
最后让我们来看看golang:onbuild
的Dockerfile吧(这里以目前最新的1.8为例):
FROM golang:1.8
RUN mkdir -p /go/src/app
WORKDIR /go/src/app
# this will ideally be built by the ONBUILD below ;)
CMD ["go-wrapper", "run"]
ONBUILD COPY . /go/src/app
ONBUILD RUN go-wrapper download
ONBUILD RUN go-wrapper install
我们可以看到其实golang:onbuild
镜像其实引用的还是golang标准镜像,只不过封装了一些自动执行的动作,使用使用起来更加方便而已。接下里我们看看如何直接基于标准的golang镜像来构建我们自己的镜像。
使用golang:latest镜像
相比于使用golang:onbuild
的便利性,golang:latest
给了我们更多的灵活性。我们以构建app镜像为例:创建app-golang
目录,目录内容如下所示:
➜ app-golang ll
total 8.0K
-rw-r--r-- 1 Allan 101 Jan 2 12:32 Dockerfile
-rw-r--r-- 1 Allan 291 Jan 2 12:32 app.go
➜ app-golang cat Dockerfile
FROM golang:latest
RUN mkdir /app
ADD . /app/
WORKDIR /app
RUN go build -o main .
CMD ["/app/main"]
执行构建命令docker build -t app-golang .
➜ app-golang docker build -t app-golang .
Sending build context to Docker daemon 3.072 kB
Step 1 : FROM golang:latest
latest: Pulling from library/golang
75a822cd7888: Already exists
57de64c72267: Already exists
4306be1e8943: Already exists
4ba540d49835: Already exists
5b23b80eb526: Already exists
981c210a3af4: Already exists
73f7f7662eed: Already exists
Digest: sha256:5787421a0314390ca8da11b26885502b58837ebdffda0f557521790c13ddb55f
Status: Downloaded newer image for golang:latest
---> 6639f812dbc7
Step 2 : RUN mkdir /app
---> Running in a6f105ecc042
---> f73030d40507
Removing intermediate container a6f105ecc042
Step 3 : ADD . /app/
---> 3fcc194ce29d
Removing intermediate container 013c2192f90e
Step 4 : WORKDIR /app
---> Running in b8a2ca8d7ae0
---> 853dfe15c6cd
Removing intermediate container b8a2ca8d7ae0
Step 5 : RUN go build -o main .
---> Running in e0de5c273d7b
---> 28ef112e8c23
Removing intermediate container e0de5c273d7b
Step 6 : CMD /app/main
---> Running in 82c67389d9ab
---> 139ad10f61dc
Removing intermediate container 82c67389d9ab
Successfully built 139ad10f61dc
其实golang标准镜像还有一个非常方便的用途。比如我们需要开发go应用,但是又不想安装go环境或者还没有安装,那么我们可以直接使用golang标准镜像来在容器里面编译go程序。假设当前目录是我们的工程目录,那么我们可以使用如下命令来编译我们的go工程:
$ docker run --rm -v "$PWD":/usr/src/myapp -w /usr/src/myapp golang:latest go build -v
这条命令的含义是把当前目录作为一个卷挂载到golang:latest
镜像的/usr/src/myapp
目录下,并将该目录设置为工作目录,然后在该目录下执行go build -v
,这样就会在该目录下编译出一个名为myapp
的可执行文件。当然默认编译出的是linux/amd64
架构的二进制文件,如果我们需要编译其他系统架构的文件,可以加上相应的参数,比如我们要编译Windows下的32位的二进制文件,可执行如下命令:
$ docker run --rm -v "$PWD":/usr/src/myapp -w /usr/src/myapp -e GOOS=windows -e GOARCH=386 golang:latest go build -v
当然,我们也可以shell脚本一次编译出多种OS下的文件:
$ docker run --rm -it -v "$PWD":/usr/src/myapp -w /usr/src/myapp golang:latest bash
$ for GOOS in darwin linux windows; do
> for GOARCH in 386 amd64; do
> go build -v -o myapp-$GOOS-$GOARCH
> done
> done
OK,言归正传,我们继续来进行本文要讨论的话题。我们看一下上述两种方式编译出来的镜像的大小:
➜ app-golang docker images | grep -e golang -e app
app-golang latest 139ad10f61dc 36 minutes ago 679.3 MB
app-onbuild latest 94cc8fb334ea 39 minutes ago 679.3 MB
golang onbuild a422f764b58c 2 weeks ago 674 MB
golang latest 6639f812dbc7 2 weeks ago 674 MB
可以看到,golang-onbuild
和golang-latest
两个基础镜像大小都为647MB,而我们编译出来的自己的镜像大小为679.3MB,也就是说我们自己的程序其实只有5.3MB。这是为什么呢?因为我们使用的两个基础镜像是通用镜像,他们包含了go依赖的所有东西。比如我们看一下golang-1.8
的Dockerfile:
FROM buildpack-deps:jessie-scm
# gcc for cgo
RUN apt-get update && apt-get install -y --no-install-recommends \
g++ \
gcc \
libc6-dev \
make \
pkg-config \
&& rm -rf /var/lib/apt/lists/*
ENV GOLANG_VERSION 1.8beta2
ENV GOLANG_DOWNLOAD_URL https://golang.org/dl/go$GOLANG_VERSION.linux-amd64.tar.gz
ENV GOLANG_DOWNLOAD_SHA256 4cb9bfb0e82d665871b84070929d6eeb4d51af6bedbc8fdd3df5766e937ef84c
RUN curl -fsSL "$GOLANG_DOWNLOAD_URL" -o golang.tar.gz \
&& echo "$GOLANG_DOWNLOAD_SHA256 golang.tar.gz" | sha256sum -c - \
&& tar -C /usr/local -xzf golang.tar.gz \
&& rm golang.tar.gz
ENV GOPATH /go
ENV PATH $GOPATH/bin:/usr/local/go/bin:$PATH
RUN mkdir -p "$GOPATH/src" "$GOPATH/bin" && chmod -R 777 "$GOPATH"
WORKDIR $GOPATH
COPY go-wrapper /usr/local/bin/
这样我们就不奇怪了,因为基础镜像里面包含了太多的东西,但其实我们的程序只使用了极少的一部分东西。下面我们就介绍一种方法可以只编译我们程序依赖的东西,这样编译出来的镜像非常的小。
静态编译
其实在生产环境中,对于Go应用我们往往是现在本地编译出一个可执行文件,然后将这个文件打进容器里面,而不是在容器里面进行编译,这样可以做到容器里面只有我们需要的东西,而不会引入一些只在编译过程中需要的文件。这种方式一般也有两种操作方法:第一种就是利用go build
编译出二进制文件,然后在Dockerfile里面引入一个操作系统镜像作为程序运行环境。这样打出的镜像也比较大,但是却不会有编译过程中依赖的一些文件,所以相比较之前的方式,这种方式打出来的镜像也会小很多。在镜像大小不是特别重要的场景下,我比较喜欢这种方式,因为基础的操作系统往往带了一些基础的命令,如果我们的程序有些异常什么的,我们可以登录到这些容器里面去查看。比如可以使用ps
、netstat
等命令。但如果没有操作系统的话,这些命令就都没法使用了。当然,本文讨论的就是如何构建最小的镜像,所以接下来我们介绍第二种操作方法:利用scratch
镜像构建最小的Go程序镜像。
scratch
镜像其实是一个特殊的镜像,为什么特殊呢?因为它是一个空镜像。但是它却是非常重要的。我们知道Dockerfile文件必须以FROM
开头,但如果我们的镜像真的是不依赖任何其他东西的时候,我们就可以FROM scratch
。在Docker 1.5.0之后,FROM scratch
已经变成一个空操作(no-op),也就是说它不会再单独占一层了。
OK,下面我们来进行具体的操作。我们先创建一个go-scratch
目录,然后将先go build
编译出来一个二进制文件拷贝到这个目录,并增加Dockerfile文件:
➜ app-scratch ll
total 5.1M
-rw-r--r-- 1 Allan 36 Jan 2 13:44 Dockerfile
-rwxr-xr-x 1 Allan 5.1M Jan 2 13:42 app
➜ app-scratch cat Dockerfile
FROM scratch
ADD app /
CMD ["/app"]
然后打镜像:
➜ app-scratch docker build -t app-scratch .
Sending build context to Docker daemon 5.281 MB
Step 1 : FROM scratch
--->
Step 2 : ADD app /
---> 65d4b96cf3a3
Removing intermediate container 2a6498e02c75
Step 3 : CMD /app
---> Running in c8f2958f09e2
---> dcd05e331135
Removing intermediate container c8f2958f09e2
Successfully built dcd05e331135
可以看到,FROM scratch
并没有单独占一层。然后我们运行构建出来的镜像:
➜ app-scratch docker images app-scratch
REPOSITORY TAG IMAGE ID CREATED SIZE
app-scratch latest 7ef9c5620b4f 5 minutes ago 5.259 MB
只有5.259MB,和之前相比是不是超级小呢?
NB(不是牛逼,是nota bene):
我们知道Go的编译是静态的,但是如果你的Go版本是1.5之前的,那你编译你的程序时最好使用如下命令去编译:
CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .
因为Go 1.5之前Go依赖了一些C的一些库,Go编译的时候是动态链接这些库的。这样你使用scratch
的方式打出来的镜像在运行时会因为找不到这些库而报错。CGO_ENABLED=0
表示静态编译cgo,里面的GOOS
改为你程序运行的目标机器的系统,-a
的意思是重新编译所有包。
参考:
不错不错,我基本也是这样干的。在生产环境,我使用的是两阶段:第一阶段类似你文章一开始说,从git拉取代码,基于标准的golang docker镜像编译出bin文件(还会跑test之类的);第二阶段则是从第一阶段里面拉取出已经编译好的bin文件以及基于scratch镜像进行第二次构建,得到一个小的镜像。
请教一下
-installsuffix cgo
参数的具体含义,多谢可以看一下官方文档:https://golang.org/src/cmd/link/doc.go?h=-installsuffix。其实就是Go 1.5之前的版本里面引用了C代码,所以这个参数就是让编译的时候去链接cgo的库,以免有的库找不到
在
CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .
里如果不加这个参数会有什么问题吗?编译的中间结果会有命名冲突吗?是这样的,Go1.5版本之前,Go里面的一些库函数是用C实现的(网络方面的居多),也就是CGO。如果你的代码中使用了这些C实现的库函数,那你就要加上这个参数,让它编译的时候去CGO里面找,否则编译时会报错。但是Go1.5版本开始实现了自举,所有的标准库都是用Go代码实现,就不存在这个问题了。所以,如果你的Go版本是1.5之前的,最好加上这个参数,当然如果你的代码中没用使用C实现的库,那不加也不会报错。如果你的Go是1.5及之后的版本,就不需要再加这个参数了。
go本身就适应性强,真心没想明白还有什么必要放在容器里。
不知道你所说的适应性强是不是指其跨平台特性以及没有动态依赖?其实应用容器化更多的是为了方便部署,避免实际中开发环境和生产环境不一致而导致的部署困难,就比如Docker官方的口号就是一处编译,随处运行。但如果没有容器化,那比如在Linux上编译的就没法在Windows或者Mac上面运行。而且,容器化之后还可以对应用所使用的CPU、内存等资源做限制,这些都是容器化之后的可以带来的方便。另外,虽然Go在跨平台方面已经做的很好,但毕竟还是比较偏底层的语言,所以在涉及一些底层的操作时的API并不是在每个平台上面都表现很好,这个你看godoc时有些API就会有说明,一些第三方的库也类似。
大神啊。。。2017膝盖献上
2017还要求老司机带呀[嘻嘻]