构建最小的Go程序镜像


我们知道构建一个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目录下,并将该目录设置为工作目录,然后在该目录下执行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-onbuildgolang-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应用我们往往是现在本地编译出一个可执行文件,然后将这个文件打进容器里面,而不是在容器里面进行编译,这样可以做到容器里面只有我们需要的东西,而不会引入一些只在编译过程中需要的文件。这种方式一般也有两种操作方法:第一种就是利用build编译出二进制文件,然后在Dockerfile里面引入一个操作系统镜像作为程序运行环境。这样打出的镜像也比较大,但是却不会有编译过程中依赖的一些文件,所以相比较之前的方式,这种方式打出来的镜像也会小很多。在镜像大小不是特别重要的场景下,我比较喜欢这种方式,因为基础的操作系统往往带了一些基础的命令,如果我们的程序有些异常什么的,我们可以登录到这些容器里面去查看。比如可以使用psnetstat等命令。但如果没有操作系统的话,这些命令就都没法使用了。当然,本文讨论的就是如何构建最小的镜像,所以接下来我们介绍第二种操作方法:利用scratch镜像构建最小的Go程序镜像。

scratch镜像其实是一个特殊的镜像,为什么特殊呢?因为它是一个空镜像。但是它却是非常重要的。我们知道Dockerfile文件必须以FROM开头,但如果我们的镜像真的是不依赖任何其他东西的时候,我们就可以FROM scratch。在Docker 1.5.0之后,FROM scratch已经变成一个空操作(no-op),也就是说它不会再单独占一层了。

OK,下面我们来进行具体的操作。我们先创建一个go-scratch目录,然后将先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的意思是重新编译所有包。

参考:

  1. https://blog.codeship.com/building-minimal-docker-containers-for-go-applications/
  2. https://hub.docker.com/_/golang/

已有 9 条评论

  1. AbelLai

    不错不错,我基本也是这样干的。在生产环境,我使用的是两阶段:第一阶段类似你文章一开始说,从git拉取代码,基于标准的golang docker镜像编译出bin文件(还会跑test之类的);第二阶段则是从第一阶段里面拉取出已经编译好的bin文件以及基于scratch镜像进行第二次构建,得到一个小的镜像。

    AbelLai 回复
  2. wmzy

    请教一下-installsuffix cgo参数的具体含义,多谢

    wmzy 回复
    1. 时间轨迹
      @wmzy

      可以看一下官方文档:https://golang.org/src/cmd/link/doc.go?h=-installsuffix。其实就是Go 1.5之前的版本里面引用了C代码,所以这个参数就是让编译的时候去链接cgo的库,以免有的库找不到

      时间轨迹 博主 回复
      1. wmzy
        @时间轨迹

        CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .里如果不加这个参数会有什么问题吗?编译的中间结果会有命名冲突吗?

        wmzy 回复
        1. 时间轨迹
          @wmzy

          是这样的,Go1.5版本之前,Go里面的一些库函数是用C实现的(网络方面的居多),也就是CGO。如果你的代码中使用了这些C实现的库函数,那你就要加上这个参数,让它编译的时候去CGO里面找,否则编译时会报错。但是Go1.5版本开始实现了自举,所有的标准库都是用Go代码实现,就不存在这个问题了。所以,如果你的Go版本是1.5之前的,最好加上这个参数,当然如果你的代码中没用使用C实现的库,那不加也不会报错。如果你的Go是1.5及之后的版本,就不需要再加这个参数了。

          时间轨迹 博主 回复
  3. 沙隆巴斯

    go本身就适应性强,真心没想明白还有什么必要放在容器里。

    沙隆巴斯 回复
    1. 时间轨迹
      @沙隆巴斯

      不知道你所说的适应性强是不是指其跨平台特性以及没有动态依赖?其实应用容器化更多的是为了方便部署,避免实际中开发环境和生产环境不一致而导致的部署困难,就比如Docker官方的口号就是一处编译,随处运行。但如果没有容器化,那比如在Linux上编译的就没法在Windows或者Mac上面运行。而且,容器化之后还可以对应用所使用的CPU、内存等资源做限制,这些都是容器化之后的可以带来的方便。另外,虽然Go在跨平台方面已经做的很好,但毕竟还是比较偏底层的语言,所以在涉及一些底层的操作时的API并不是在每个平台上面都表现很好,这个你看godoc时有些API就会有说明,一些第三方的库也类似。

      时间轨迹 博主 回复
  4. 老司机

    大神啊。。。2017膝盖献上

    老司机 回复
    1. 时间轨迹
      @老司机

      2017还要求老司机带呀[嘻嘻]

      时间轨迹 博主 回复

添加新评论

选择表情 captcha

友情提醒:不填或错填验证码会引起页面刷新,导致已填的评论内容丢失。

|