Golang 应用基于 Alpine 进行容器化构建和运行的方式

前言

容器化应用的好处自然不必多说,在我的个人服务器上所有的项目几乎都是容器化运行的,当然也包括本博客。我的博客又是一个异构集成的组件化系统,其中包括提供部分前后端 API 的一个服务层 Go 程序。

Go 和其它常见 Web 语言所不同的是,Go 编译的程序不需要运行时支持,且目标文件是纯静态编译的结果(关于这里会有补充解释)。也就是说其他脚本语言的应用容器化需要一个包含运行时的基础镜像以及在运行前安装上依赖的共享库这些对 Go 程序的容器化都不需要。即使 Java 程序可以静态打包但仍然要包含运行时(JRE),所以容器化这些语言的应用对他们有天然的好处,那就是对环境的封装。
由于 Go 程序不需要外部环境支持,所以实际上对环境的封装这个优点并不重要,也正是这个原因 Go 应用的镜像可以制作得非常小。不过 Go 的编译环境仍然可以发挥 Docker 对环境封装的作用,并且可以将构建好的镜像当做一个“编译器”使用。

Go 应用镜像

对于应用镜像而言,当然是越小越好,所以推荐的当然就是 Alpine 这类了。Alpine3.7 在仓库上只有 2MB 大小,也就是说一个基于 Apline 的 Go 应用镜像的大小 = 程序大小 + 2MB,这几乎没有什么多余存储开销。

我们先写一个最基本的 Go 程序(main.go):

package main

import "fmt"

func main() {
	fmt.Println("Hello, World!")
}

编译这个 Go 程序:

go build -o hello

在同目录下创建 Dockerfile(不同的指令换两行是我的个人习惯):

FROM alpine:3.7


ARG WORK_DIR=/home/app/


COPY ./hello $WORK_DIR


WORKDIR $WORK_DIR


ENTRYPOINT ["./hello"]

然后构建一个这个应用镜像:

docker build . -t blog/hello:v1

这样子,一个 Go 应用镜像就做好了。我们发现这个镜像的体积非常小,仅仅只有 6 MB,如果经过仓库的压缩会变成更小。理论上无论新版本上线多快,其它节点机器对应用镜像的 pull 都几乎不会造成负担。

我们运行这个镜像,看看它是否真的能毫无问题的在 Apline 环境中运行:

docker run -ti --rm blog/hello:v1

输出:

Hello, World!

看起来是完美的嗯,那么接下来我们弄点复杂的继续验证。

在项目根目录添加 glide.yaml 文件:

package: <YOUR_PACKAGE>
import:
- package: github.com/mattn/go-sqlite3
  version: v1.6.0

注意:如果项目没有迁移到 $GOPATH/src 中请自行迁移(因为 Go 项目需要创建在 GOPATH 或者 GOROOT 的 src 中):

mv $PWD $GOPATH/src/<YOUR_PACKAGE>

拉取项目依赖:

glide install

修改 main.go 代码:

package main

import (
	"fmt"
	_ "github.com/mattn/go-sqlite3"
)

func main() {
	fmt.Println("Hello, World!")
}

我们在上面到底做了些什么?答:很简单,添加了一个 sqlite3 的依赖并在代码中导入了它。

接着我们重新编译并且重新构建一次镜像,然后我们跟之前一样再基于这个应用镜像运行容器,你会惊讶的发现这次并没有输出 Hello, World! 而是报了一个跟程序逻辑无关的错误:

standard_init_linux.go:195: exec user process caused "no such file or directory"

WTF?Hello World 程序还会出错?

不,别忘了我们还导入了一个 sqlite3 的驱动,这次编译混入了其它代码,所以目标文件体积也大出了许多(7.1MB)。问题在哪儿呢?

原来 sqlite3 驱动包需要利用 GCC 进行编译,并且会链接本地环境的 libc 库。而一般来讲我们使用的主流桌面 Linux 系统使用都是 glibc,而 Alpine 使用的则是 musl libc,这样就导致了编译后的程序无法在 Alpine 环境下运行。

下面是分别基于两种 libc 库对 Dome 代码编译后的目标文件的链接信息:

// glibc
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fa091c19000)

// musl
libc.musl-x86_64.so.1 => /lib/ld-musl-x86_64.so.1 (0x55e738940000)

无论是在常见的 glibc 的系统上运行依赖 musl 编译的程序还是在 musl 的系统上运行依赖 glibc 的程序都会导致无法运行,因为这两种 libc 并不 ABI 兼容。

PS: musl 这类精简版 libc 库在嵌入式系统中很常见(例如知名的路由器 Linux 发行版 OpenWrt),Apline 号称最小的 Linux 发型版使用 musl 作为 libc 库也很合理。

所以我这里想说明的是,在使用 glibc 的系统上进行编译则等于放弃了使用 Apline 作为应用容器的基础镜像。所以我们要么使用 musl libc 编译环境,要么就得放弃 Alpine,但既然我发表了本文,当然是不会选择后者的。

Go 编译环境镜像

所以我们可以专门为 Golang 的编译环境封装一个镜像,同样基于 Alpine Linux,将它作为“构建 APP”使用。面对上述的需求,只需要基于 Golang 官方的 Alpine 镜像安装 Glide 以及 GCC 即可:

FROM golang:1.10-alpine3.7


ARG WORK_PATH=/home/src
ARG GO_PATH=/home/GOPATH
ARG TARGET=$GO_PATH/src/targetProject
ARG CACHE_DIR=/home/cache
ARG CONFIG_DIR=/root/.glide


COPY ./build.sh /usr/bin/build


RUN apk update \
    && apk add --no-cache ca-certificates git gcc musl-dev \
    && go get -u github.com/Masterminds/glide \
    && chmod +x /usr/bin/build \
    && mkdir -p $WORK_PATH "$GO_PATH/src" $TARGE $CACHE_DIR $CONFIG_DIR \
    && ln -s $WORK_PATH $TARGET \
    && ln -s $CACHE_DIR "$CONFIG_DIR/cache"



WORKDIR $WORK_PATH



ENV GOPATH=$GO_PATH


VOLUME $CACHE_DIR


CMD ["build"]

在这个 Dockefile 中,安装了 gcc 和 musl-dev 等编译环境必备的包,以及 ca-certificates(不然进行 SSL 连接会产生证书问题),然后以 go get 的方式安装了 Glide。

创建了 Glide 的缓存目录链接(原目录 /home/cache)和 $GO_PATH/src/targetProject 目录链接(原目录 /home/src),他们作用是方便启动容器时对 VOLUME 的挂载,并且无论宿主机的项目存放在哪个位置对于容器而言都在 $GO_PATH/src 下,最终都能被正确的编译。

构建这个镜像:

docker build . -t blog/golang:glide

至于 build.sh 脚本的内容,当然是先后执行 glide install 和 go build 了(自由发挥)。

假设 Demo 项目在当前路径,要么只需要启动一个构建镜像的容器,并进行 src 目录的挂载即可完成编译工作:

docker run -i --rm -v $PWD:/home/src blog/golang:glide

PS:当然你还能在上面的 build.sh 中加入测试步骤。

如果要避免反复编译项目时的重复依赖拉取造成的网络浪费,可以指定 Glide 缓存 VOLUME:

docker run -i --rm -v $PWD:/home/src -v cache-glide:/home/cache blog/golang:glide

此时编译产生的目标文件则是兼容 Alpine 环境的,那么就解决了上述的 Alpine 环境封装 Golang 应用镜像的问题。

构建过程

如果配合使用 Golang 的构建镜像和基于 Alipne 的应用镜像,那么项目的构建过程就变得非常简单了:

  1. 启动构建镜像编译项目
  2. 基于编译完成的目标文件构建应用镜像

这样做可以发挥 Glide 的缓存优势以及基于 Alipne 环境。否则如果在封装应用镜像的时候拉取依赖,要么无法使用缓存要么会导致应用镜像对外部文件系统产生依赖。或者使用宿主机编译无法基于 Alipne 等轻量级容器环境。

结束语

我创建了一个专门存放满足我个人需求的 Dockerfile 项目:https://github.com/Hentioe/dockerfiles

上面包含 Java、Ruby、Golang、Node 等各种镜像,也是我平时对 Docker 的使用方式:)