CI构建环境下的docker build最佳实践

CI构建环境下的docker build最佳实践

老魏

网易游戏运维与基础架构部,资深开发工程师,负责监控相关业务以及组内devops建设。

如今有越来越多的 CI 产品,如 AWS CodeBuild,Google Cloud 以及 gitlab ci,支持构建环境的按需生成,针对用户的每一次 build,都会生成一个全新的构建环境。相对比于不隔离的单机构建方式,CI 构建环境让 docker build 过程更安全以及易于管理, 但同时也带来了一些效率上的影响。这篇文章将会从一个简单的 Golang 应用例子展开,详述该应用的 docker build 优化历程,主要覆盖了以下几个最佳实践要点:

  • 使用 multi-stage build 的方式构建生产镜像
  • 基于 docker layer 的特点构建干净的镜像
  • 使用 cache-from,利用 docker 的 layer cache 来构建镜像

这次我们作为示例使用的 Golang 应用目录结构如下:

- go-docker
 - Dockerfile
 - go.mod (Golang 依赖包相关)
 - go.sum (Golang 依赖包相关)
 - main.go (该应用的主要代码)
 - .gitlab-ci.yml (使用的CI产品是gitlab ci)

由于我们关注的重点在 docker build 优化,所以隐藏了该应用业务和代码的具体细节。接下来我们来看看该应用的 Dockerfile 如何定义:

FROM golang:latest

# 设置工作目录
WORKDIR /app

# copy当前目录的文件
COPY . .

# 安装Golang的包依赖
RUN  export GOPROXY=https://goproxy.cn && go mod download

# 编译
RUN go build -o main .

EXPOSE 8080

CMD ["./main"]

一切看起来都很顺利,毕竟这是我们最熟悉的 Dockerfile 步骤了。我们来看看 docker build 的输出:

Step 1/7 : FROM golang:latest
 ---> 52b59e9ead8e
Step 2/7 : WORKDIR /app
 ---> 823eb122cec2
Removing intermediate container 309dc3bd23d5
Step 3/7 : COPY . .
 ---> 03ccc6d068c2
Removing intermediate container c4aebeefe146
Step 4/7 : RUN export GOPROXY=https://goproxy.cn && go mod download
 ---> Running in 443326ecda27
go: finding github.com/davecgh/go-spew v1.1.1
go: finding github.com/gin-contrib/cors v1.3.0
go: finding github.com/gin-contrib/sse v0.0.0-20190301062529-5545eab6dad3
go: finding github.com/gin-gonic/gin v1.4.0
go: finding github.com/golang/protobuf v1.3.1
go: finding github.com/json-iterator/go v1.1.6
go: finding github.com/kr/pretty v0.1.0
go: finding github.com/kr/pty v1.1.1
go: finding github.com/kr/text v0.1.0
go: finding github.com/mattn/go-isatty v0.0.7
go: finding github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd
go: finding github.com/modern-go/reflect2 v1.0.1
go: finding github.com/pmezard/go-difflib v1.0.0
go: finding github.com/stretchr/objx v0.1.0
go: finding github.com/stretchr/testify v1.3.0
go: finding github.com/ugorji/go v1.1.4
go: finding golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2
go: finding golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c
go: finding golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b
go: finding golang.org/x/text v0.3.0
go: finding gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127
go: finding gopkg.in/go-playground/assert.v1 v1.2.1
go: finding gopkg.in/go-playground/validator.v8 v8.18.2
go: finding gopkg.in/yaml.v2 v2.2.2
 ---> fcd8a5672f7c
Removing intermediate container 443326ecda27
Step 5/7 : RUN go build -o main .
 ---> Running in 8f15c7b0bedf
 ---> 56c480be7f32
Removing intermediate container 8f15c7b0bedf
Step 6/7 : EXPOSE 8080
 ---> Running in a2ca8b83723e
 ---> 8f293698e2a6
Removing intermediate container a2ca8b83723e
Step 7/7 : CMD ./main
 ---> Running in 609907dc30a2
 ---> 967a955cdb03
Removing intermediate container 609907dc30a2
Successfully built 967a955cdb03

镜像构建成功了。我们可以看到在步骤 4 中,我们如愿以偿地下载了该应用的依赖包,并在随后的步骤中成功将该应用编译为一个二进制文件 main。但是细心的读者们可能会发现,这个构建成功的镜像,实际上只需要运行一个二进制文件,但是却引入了 Golang 环境以及刚刚提及的一堆依赖包,导致该镜像显得很"臃肿",这也直接地影响到该镜像的发布效率。

Golang 的编译过程我们没办法省略,那么我们有办法解决这个问题么?一个推荐的方式是使用 multi-stage build。我们来看改造过后的 Dockerfile 是什么样子:

# 将该stage重命名为builder
FROM golang:latest as builder

WORKDIR /app

COPY . .

RUN  export GOPROXY=https://goproxy.cn && go mod download

RUN go build -o main .

# 生产镜像stage,这里使用了alpine镜像而不是原来的golang镜像
FROM alpine:latest

WORKDIR /app/

# alpine镜像没有ca-certificates,需要进行安装
RUN apk update && apk --no-cache add ca-certificates

# 从builder stage的镜像里将二进制文件copy过来
COPY --from=builder /app/main .

EXPOSE 8080

CMD ["./main"]

在新的 Dockerfile 里我们使用了两个 stage,一个是 builder stage,用来编译二进制文件,另一个是生产 stage,使用了一个很小的基础镜像 alpine 作为基础镜像,里面只放了一个从 builder copy 过来的二进制文件。通过这种方式,我们成功地将生产镜像的 size 大大减少,提高了应用的发布效率。

然而该镜像的 size 还有继续优化的空间。为了最小化生成镜像的 size,我们在 Dockerfile 里加上了这一句,希望可以清理掉 apk 生成的临时文件夹:

...
RUN apk update && apk --no-cache add ca-certificates
# 清理临时文件夹
RUN rm -rf /var/cache/apk/*
...

但实际上,这一句并没有起作用。在本地测试镜像构建,发现加上这一句之后生成的镜像 size 并没有减少。为了理解这个问题,我们首先要理解 docker layer 的设计。

docker 的镜像是由多个只读的 docker layer 叠加而成,每个 docker layer 仅包含了和上一个 docker layer 的差异部分。从这篇文章一开始的 docker build 输出可以看到,RUN 这个语句会生成一个独立的 docker layer。了解了这些之后,我们可以知道以上两个 RUN 语句会生成两个 docker layer,而第一个 docker layer 是只读的,不受第二个 layer 的影响。所以即使第二个 layer 删除了临时文件夹,第一个 layer 的大小也不会改变。为了达到减少镜像 size 的目的,我们可以通过合并 RUN 语句的方式实现,最终的 Dockerfile 如下:

FROM golang:latest as builder

WORKDIR /app

COPY . .

RUN  export GOPROXY=https://goproxy.cn && go mod download

RUN go build -o main .


FROM alpine:latest

WORKDIR /app/

# 合并ca-certificates的安装以及临时文件夹清理
RUN apk update && apk --no-cache add ca-certificates && rm -rf /var/cache/apk/*

COPY --from=builder /app/main .

EXPOSE 8080

CMD ["./main"]

通过以上的优化技巧,我们将生成镜像与 builder 镜像区分开来,并让生产镜像的 size 尽可能地小。不过在该应用的 CI 镜像构建过程中,我们发现每次都需要下载依赖包,当依赖包较多时,这个下载耗时还是很可观的。在动手优化之前,我们先来确认下这个问题的原因。我们都知道,docker 的 layer 是会进行缓存的,每次 docker build 都会去复用之前的 docker layer。而由于 giltab ci 每次都会起一个新的构建环境,导致 docker build 没办法去利用 layer cache,这就是为什么 gitlab ci 中每次 docker build 都要重新下载依赖包的原因。那在这种 CI 构建环境中,我们有办法去利用 layer cache 么?答案是使用 cache-from。

cache-from 可以指定使用哪个 image 作为 cache,当 cache image 里有重复的 layer,那在 docker build 的时候就会直接复用该 layer,而不需要重新构建。为了能使用 cache from,首先我们需要调整下我们的 Dockerfile:

FROM golang:latest as builder

WORKDIR /app

# 这里将Golang依赖定义相关文件的copy放到最前面
COPY go.mod go.sum ./

RUN  export GOPROXY=https://goproxy.cn && go mod download

COPY . .
RUN go build -o main .


FROM alpine:latest

WORKDIR /app/

RUN apk update && apk --no-cache add ca-certificates && rm -rf /var/cache/apk/*

COPY --from=builder /app/main .

EXPOSE 8080

CMD ["./main"]

Dockerfile 里唯一的改动就是将 Golang 依赖包定义相关文件的 copy 放在了最前面。这里涉及到什么时候 layer 会被复用:

  • 大部分情况下,会简单对比 Dockerfile 里的语句是否相同,相同的话就会复用 layer cache,如 RUN 语句(这里有一点需要注意的是,一旦 RUN 语句涉及外部依赖,就可能会导致非预期情况发生。如RUN apt-get update,随着软件源的更新,该命令执行结果会不一样,但是由于 RUN 语句没变化,docker 仍然会沿用之前的 cache)
  • ADD 和 COPY 命令,这两个命令涉及到外部文件依赖。docker 在对比的时候,会额外计算对应文件的 checksum,如果一致则复用 layer cache。
  • 当一个 layer cache 失效时,之后的 layer 的 cache 都会失效。

在开发过程中,代码是经常变动的,而依赖包变动很少。如果沿用原来的COPY . .语句,那会直接导致之后所有的 layer cache 失效。而如果改为COPY go.mod go.sum ./,那只要依赖包没变动,就能直接使用 layer cache,不需要再次下载重复的依赖。

除了 Dockerfile 修改之外,我们还需要修改 docker build 的命令。之前我们可能只需要一个简单的 build 语句:

IMAGE=gitlab.com/go-docker:develop
docker build -t $IMAGE .

现在我们需要改为使用 cache-from 的方式:

IMAGE=gitlab.com/go-docker:develop
BUILDER_IMAGE=gitlab.com/go-docker:builder

# 拉取builder镜像以及生产镜像,|| true用于确保语句执行成功(拉取的镜像可能不存在)
docker pull $IMAGE || true
docker pull $BUILDER_IMAGE || true

# 用--target指定构建builder镜像,使用cache-from指定之前的builder镜像作为cache
docker build  
    --target builder 
    --cache-from $BUILDER_IMAGE 
    -t $BUILDER_IMAGE .

# 构建生产镜像, 使用cache-from指定之前的builder镜像以及生产镜像作为cache
docker build 
    --cache-from $BUILDER_IMAGE 
    --cache-from $IMAGE 
    -t $IMAGE .

# 将builder镜像和生产镜像推到docker仓库
docker push $BUILDER_IMAGE
docker push $IMAGE

这里我们单独构建了 builder 的镜像(tag 固定为 builder),并 push 到 docker 仓库,之后构建镜像时可以直接将该镜像拉下来作为 cache。如果应用是多分支开发,不同分支的依赖包不一样,可以给 builder 镜像的 tag 加个分支前缀,这样 cache 就不会受其他分支影响,但代价就是新分支第一次构建时是用不到 cache 的。业务可以根据需要,自由选择使用哪种方式构建 builder 缓存。通过 cache-from 这种方式来构建镜像,依据 cache 的场景不同,往往能达到两倍甚至三倍的时间效率提升。

这篇文章通过一个简单应用的 Dockerfile 优化历程,告诉了我们该如何去构建一个干净的镜像以及如何提升构建效率。然而更重要的是从这优化历程里体现出的不断探索的精神,希望和大家共勉之。


往期精彩

Web站点接口优化实践


浏览器中执行 C 语言?WebAssembly 实践


Python简洁编码之道


终于不用为大表添加列而烦恼了!


校招面试问到Linux CPU不用怕,来看看这份宝典

原始链接

编辑于 2019-12-30