设计一个golang项目的容器构建流程

前言

TL;DR

怎样才能给一个GO语言开发的项目打包成一个容器镜像, 并且做到又快又小(嗯?).

  1. 妥善安排Dockerfile里面的layer

  2. 尽量选择体积小的基础镜像

  3. 容器内容尽量精简

Golang-with-docker

demo介绍

我把这个demo里面涉及到的代码, 依赖, 以及好几个Dockerfile都放到了github上, 大家可以自便:

https://github.com/RondoChen/docker-go-demo.git

顺便剧透一下, 最后构建出来的镜像, 在dockerhub: https://hub.docker.com/repository/docker/rondochen/go-demo-app

1
2
3
4
5
# 镜像下载以及试运行

docker pull rondochen/go-demo-app:multistage

docker run -d -p 3000:3000 rondochen/go-demo-app:multistage

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package main

import (
"net/http"
"os"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)

func main() {

e := echo.New()

e.Use(middleware.Logger())

e.GET("/", func(c echo.Context) error {
return c.HTML(http.StatusOK, "Hello, Docker! <3")
})

e.GET("/ping", func(c echo.Context) error {
return c.JSON(http.StatusOK, struct{ Status string }{Status: "OK"})
})

httpPort := os.Getenv("HTTP_PORT")
if httpPort == "" {
httpPort = "3000"
}

e.Logger.Fatal(e.Start(":" + httpPort))
}

smoke test

在运行代码之前, 你需要先有一个Golang的运行环境: https://go.dev/doc/tutorial/getting-started

笔者使用的是Go的1.18版本.

1
2
# go version
go version go1.18.4 linux/amd64

安装好go的运行环境之后, 我们可以直接试运行一下demo代码:

1
2
3
4
5
6
git clone https://github.com/RondoChen/docker-go-demo.git
cd docker-go-demo
# 使用国内代理, 可以大大加快依赖下载速度
export GOPROXY=https://goproxy.cn
go mod download && go mod verify
go run main.go

demo会使用3000端口运行一个简单的http服务, 如果一切顺利(小心端口冲突), 运行起来之后是这样子的:

1
2
3
4
5
6
7
8
9
10
11
root@docker:/tmp/docker-go-demo# go run main.go

____ __
/ __/___/ / ___
/ _// __/ _ \/ _ \
/___/\__/_//_/\___/ v4.7.2
High performance, minimalist Go web framework
https://echo.labstack.com
____________________________________O/_______
O\
⇨ http server started on [::]:3000

使用curl简单测试一下:

1
2
3
4
5
6
root@docker:~# curl 127.0.0.1:3000
Hello, Docker! <3root@docker:~#
root@docker:~#
root@docker:~# curl 127.0.0.1:3000/ping
{"Status":"OK"}
root@docker:~#

后面我们就都围绕着这个简单demo去做下面的测试.

构建docker镜像

我假设你已经调试好docker的运行环境了: https://docs.docker.com/get-started/

另外, 基于我们特殊的网络环境, 我有两个小建议:

  1. 找一个好使的网络代理

  2. 设置好国内的docker源

以上两点二选一, 但我个人倾向第一点, 大家可以自行斟酌.

Dockerfile.beginner

基于能跑就行的原则, 我们只需要找一个golang的镜像, 再把代码以及项目文件放进去就可以运行了.

这里, 编写一个Dockerfile.beginner, 内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# syntax=docker/dockerfile:1

FROM golang:1.18

ARG GOPROXY=https://goproxy.cn,direct

WORKDIR /app

COPY main.go .
COPY go.sum .
COPY go.mod .

RUN go mod download

RUN go build -o /docker-go-demo

EXPOSE 3000

CMD [ "/docker-go-demo" ]

制作镜像

编写好Dockerfile之后, 我们就可以使用docker build去构建镜像了

1
2
3
4
5
6
cd docker-go-demo
docker build --build-arg GOPROXY=https://mirrors.aliyun.com/goproxy/ -f ./Dockerfile.beginner -t rondochen/go-demo-app:beginner .

docker image ls rondochen/go-demo-app:beginner
REPOSITORY TAG IMAGE ID CREATED SIZE
rondochen/go-demo-app beginner 7e2412bdac47 17 hours ago 1.06GB

使用容器运行

1
2
3
4
5
6
7
8
9
10
11
12
13
docker run -d -p 3000:3000 --name beginner rondochen/go-demo-app:beginner

docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
7de5413c2c91 rondochen/go-demo-app:beginner "/docker-go-demo" 3 seconds ago Up 2 seconds 0.0.0.0:3000->3000/tcp, :::3000->3000/tcp beginner

# 简单测试一下

root@docker:/tmp/docker-go-demo# curl 127.0.0.1:3000
Hello, Docker! <3
root@docker:/tmp/docker-go-demo# curl 127.0.0.1:3000/ping
{"Status":"OK"}

好了, 现在已经能跑了.

但如果在生产环境里面用这种镜像, 可能你的老板都想让你跑.

后面我们来改进一下.

改进过程

上面的镜像, 很明显存在几个问题:

  1. 镜像体积过于巨大, 1GB有余.

  2. layer的安排不合理

  3. 太臃肿

初始镜像的选择

在dockerhub上面搜索镜像时, 同一个版本下往往还会有不同的区分, 常见的会有下面的后缀:

  • buster

  • alpine

  • slim

  • windowsservercore

  • 等等

在上面的Dockerfile.beginner中直接使用golang:1.18. 一般情况下, 这是最稳妥的镜像, 不会出问题, 但是副作用就是过分巨大, 类似这种大而全的镜像更适合用在开发环境的调试阶段.

另外, 像buster, bullseye这些后缀, 对应的是不同的Linux发行版的版本代号.

在大部分时候, 为了减少镜像体积, 我们会使用基于Alpine Linux的镜像. 所以, 我们后面可以尝试使用golang:1.18-alpine.

layer的概念

为了节省空间, 容器镜像都是建立在layer(层)上的, 多个不同的镜像很有可能会重复利用相同的层. 也因此, Docker在管理镜像的时候, 其实是在记录每一个镜像都由哪些layer组成. 当你运行一个docker build命令的时候, Dockerfile中的RUN, COPY, ADD都会创建新的layer.

而为了最大限度地让docker减少创建新的layer, 在不影响程序运行的前提下, 我们在编写Dockerfile的时候应当尽量先把不经常变更的操作排在前面, 以使docker build过程中可以尽可能多地复用已有的layer.

在我们的这个例子中, 像go.modgo.sum这些记录代码依赖的文件, 相对来说不会频繁变更, 就应该先传入镜像并完成go mod download操作. 最后再把其余会频繁变更的代码文件*.go传入镜像, 最后再执行go build.

在我们构建一个容器镜像的时候, 一条长期被强调的原则, 就是要尽可能地复用已有的layer以及最大限度地减少layer的数量.

Dockerfile.better

经过上面的两点优化, 我们就有了这个改进版的Dockerfile.better:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# syntax=docker/dockerfile:1

FROM golang:1.18-alpine

ARG GOPROXY=https://goproxy.cn,direct

WORKDIR /app

COPY go.sum .

COPY go.mod .

RUN go mod download

COPY *.go .

RUN go build -o /docker-go-demo

EXPOSE 3000

CMD [ "/docker-go-demo" ]

我们选择了一个alpine后缀的基础镜像, 再把COPY *.GO .移到了RUN go mod download后面.

最后构建完成之后, 镜像的体积减少到了422MB.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# docker build  -f ./Dockerfile.better -t rondochen/go-demo-app:better .
Sending build context to Docker daemon 25.8MB
Step 1/10 : FROM golang:1.18-alpine
---> 759ab1463be2
Step 2/10 : ARG GOPROXY=https://goproxy.cn,direct
---> Using cache
---> 204adfd69217
Step 3/10 : WORKDIR /app
---> Using cache
---> 89a7735a4ff3
Step 4/10 : COPY go.sum .
---> Using cache
---> a38f9e5c252b
Step 5/10 : COPY go.mod .
---> Using cache
---> 6ba7a33d6f78
Step 6/10 : RUN go mod download
---> Running in 9a833e235590
Removing intermediate container 9a833e235590
---> db120a440ec2
Step 7/10 : COPY *.go .
---> 843be98721cf
Step 8/10 : RUN go build -o /docker-go-demo
---> Running in b09c13e7b039
Removing intermediate container b09c13e7b039
---> 7c66ec913475
Step 9/10 : EXPOSE 3000
---> Running in 339a6d8978f4
Removing intermediate container 339a6d8978f4
---> 5e367446610e
Step 10/10 : CMD [ "/docker-go-demo" ]
---> Running in 6fe046776e1a
Removing intermediate container 6fe046776e1a
---> cb4971c4d949
Successfully built cb4971c4d949
Successfully tagged rondochen/go-demo-app:better

# docker image ls rondochen/go-demo-app:better
REPOSITORY TAG IMAGE ID CREATED SIZE
rondochen/go-demo-app better cb4971c4d949 36 seconds ago 422MB

另外, 如果我们重复执行同一个docker build的命令, 往往会很快完成, 屏幕的输出会有Using cache的提示, 这也在侧面印证了前面说到的layer的概念.

Multi-stage builds

镜像体积减少到422MB, 还是太大了, 就引出了灵魂拷问:

程序都编译完了, 为什么还需要在容器里面保留一个编译环境?

那既然这样, 我能不能直接把编译好的程序放到一个小容器里运行就完了?

这就是Multi-stage builds.

Dockerfile.multistage

你或许会以为我们需要两个Dockerfile, 一个完成代码的编译并把文件复制到容器外, 再创建一个镜像把编译好的文件重新传回去镜像里面, 但这两个步骤可以合并在一个Dockerfile中完成.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# syntax=docker/dockerfile:1

# Build

FROM golang:1.18-buster AS build
ARG GOPROXY=https://goproxy.cn,direct
WORKDIR /app

COPY go.mod .
COPY go.sum .
RUN go mod download

COPY *.go ./

RUN go build -o /docker-go-demo

# Deploy

FROM gcr.io/distroless/base-debian10

WORKDIR /

COPY --from=build /docker-go-demo /docker-go-demo

EXPOSE 3000

USER nonroot:nonroot

CMD ["/docker-go-demo"]

docker build 过程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# docker build --build-arg GOPROXY=https://mirrors.aliyun.com/goproxy/,direct -f ./Dockerfile.multistage -t rondochen/go-demo-app:multistage .
Sending build context to Docker daemon 25.8MB
Step 1/14 : FROM golang:1.18-buster AS build
---> 0e87973a8632
Step 2/14 : ARG GOPROXY=https://goproxy.cn,direct
---> Running in 80fd51d9706b
Removing intermediate container 80fd51d9706b
---> 17a8aadfb51e
Step 3/14 : WORKDIR /app
---> Running in 8035a66663e0
Removing intermediate container 8035a66663e0
---> 582d8078abd6
Step 4/14 : COPY go.mod .
---> 80e5770bc3c5
Step 5/14 : COPY go.sum .
---> cde03c8a05a5
Step 6/14 : RUN go mod download
---> Running in f728ea778625
Removing intermediate container f728ea778625
---> f430f98f5fb8
Step 7/14 : COPY *.go ./
---> e059f4113c90
Step 8/14 : RUN go build -o /docker-go-demo
---> Running in 14676183967c
Removing intermediate container 14676183967c
---> d941227ea866
Step 9/14 : FROM gcr.io/distroless/base-debian10
---> 87cb41f09abc
Step 10/14 : WORKDIR /
---> Using cache
---> 6264ead9986c
Step 11/14 : COPY --from=build /docker-go-demo /docker-go-demo
---> 9382789816bf
Step 12/14 : EXPOSE 3000
---> Running in 18384c0619aa
Removing intermediate container 18384c0619aa
---> 059b50232036
Step 13/14 : USER nonroot:nonroot
---> Running in 39f500eb3775
Removing intermediate container 39f500eb3775
---> 7711ca40b235
Step 14/14 : CMD ["/docker-go-demo"]
---> Running in 9b04d0fe4448
Removing intermediate container 9b04d0fe4448
---> 5b83d87470c8
Successfully built 5b83d87470c8
Successfully tagged rondochen/go-demo-app:multistage

最后我们获得了一个26.7MB的镜像:

1
2
3
# docker image ls rondochen/go-demo-app:multistage
REPOSITORY TAG IMAGE ID CREATED SIZE
rondochen/go-demo-app multistage 5b83d87470c8 7 minutes ago 26.7MB

Dockerfile.multistage 测试

再跑一个测试, 我们可以发现, 这个26.7MB的镜像, 运行起来和那个1GB的完全一样.

1
2
3
4
5
6
7
8
9
10
root@docker:~# docker run -d -p 3000:3000 --name multistage rondochen/go-demo-app:multistage
10bb8ca346957c196ab011021ee4e82fae6aca9ea6ab9445d501cc78e88632f0
root@docker:~# curl http://127.0.0.1:3000
Hello, Docker!
root@docker:~# curl http://127.0.0.1:3000/ping
{"Status":"OK"}
root@docker:~# docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
10bb8ca34695 rondochen/go-demo-app:multistage "/docker-go-demo" 24 seconds ago Up 23 seconds 0.0.0.0:3000->3000/tcp, :::3000->3000/tcp multistage
root@docker:~#

这个镜像已经上传到dockerhub, 大家可以自便:

1
docker pull rondochen/go-demo-app:multistage

拾遗

本来弄出这个demo是为了在介绍CI/CD工具的时候用的, 但是在捣鼓这个demo的过程中也发现有很多值得讨论的话题, 就有了这篇文章. 诚然, 在现实生产环境中不会用到这么简单的demo, 但大致就是这样的一个打磨过程. 囿于篇幅, 很多话题也没能一一展开详细说明, 这里先留一个楔子, 后面有空再回头想想.

关于alpine和buster的选择

你或许会发现, 我在Dockerfile.multistage中使用了golang:1.18-buster镜像而不是golang:1.18-alpine去编译, 一方面是因为golang:<version>-alpine还处于验证阶段, 没得到Go项目的官方认可, 另一方面是因为编译出来的二进制文件没能成功地在gcr.io/distroless/base-debian10运行, 所以最后选择了buster镜像去编译.

关于golang的容器镜像部分后缀的说明, 以及alpine的一些说明, 在dockerhub的golang页面有大概描述过: https://hub.docker.com/_/golang

Dockerfile.tiny

笔者在选择buster之前, 还真用过alpine去编译代码, 再用alpine去运行, 最后构建出来的镜像只有13MB, 也可以正常运行.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# syntax=docker/dockerfile:1

# Build

FROM golang:1.18-alpine AS build
ARG GOPROXY=https://goproxy.cn,direct
WORKDIR /app

COPY go.mod .
COPY go.sum .
RUN go mod download

COPY *.go ./

RUN go build -o /docker-go-demo

## Deploy

FROM alpine:3.15

RUN adduser -D nonroot

WORKDIR /

COPY --from=build /docker-go-demo /docker-go-demo

EXPOSE 3000

USER nonroot:nonroot

CMD ["/docker-go-demo"]

但基于上面提到的原因, 所以最后不打算使用这个基于alpine的镜像作为最终成果.

是的, 设计容器构建方案就是这样一个反复斟酌和权衡的过程.

在开发过程灵活使用容器

虽说现在在很多场景下都流行使用容器, 但是在开发过程中, 我们未必要总是不断重复这个docker builddocker run的过程. 相反, 在开发阶段, 容器更适合作为一个灵活的运行或者编译环境使用.

就比如说, 我想要快速看看这个demo在golang:1.18-bustergolang:1.18-alpine中编译出来有什么不一样, 我就可以这样简单测试:

1
2
3
4
5
6
7
8
cd docker-go-demo && go mod verify && go mod vendor

cd ..

docker run --rm -v $(pwd)/docker-go-demo:/docker-go-demo golang:1.18-alpine sh -c 'cd /docker-go-demo && go build -o app-build-from-alpine'

docker run --rm -v $(pwd)/docker-go-demo:/docker-go-demo golang:1.18-buster sh -c 'cd /docker-go-demo && go build -o app-build-from-buster'

这样我就分别在两个不同的golang镜像中给代码编译了一次, 继而可以查看两个不同的镜像编译出来的程序有什么不一样.

我们可以在下面的输出看到, 用alpine编译出来的程序, 引用了libc.musl-x86_64.so.1, 看起来不是很主流, 起码在我使用的Ubuntu 20.04.3 LTS和镜像gcr.io/distroless/base-debian10中缺失了.

1
2
3
4
5
6
7
8
9
10
cd docker-go-demo
ldd app-build-from-alpine app-build-from-buster
app-build-from-alpine:
linux-vdso.so.1 (0x00007ffe5a1df000)
libc.musl-x86_64.so.1 => not found
app-build-from-buster:
linux-vdso.so.1 (0x00007ffdc7572000)
libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f12ac1ac000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f12abfba000)
/lib64/ld-linux-x86-64.so.2 (0x00007f12ac1d8000)

而上面的两个直接挂载项目路径再用用docker run运行镜像去编译代码的场景, 就非常适合用在日常的开发调试阶段.

是否要使用vendor功能

你或许又发现了, 我在项目路径里面运行了go mod vendor命令, 这样, 在临时的golang的镜像中编译的时候, 就省去了反复go mod download的时间了. 在我们设计CI/CD的过程, 这个也是其中一个考虑因素, 就是是否要把vendor中的内容也作为源代码的一部分.

得益于我们前面讨论过的使用了镜像layer可以复用相同的内容, 只要go.modgo.sum文件没有产生变化, 每次在docker build的时候即使有go mod download操作, 也可以直接调用缓存, 不至于反复下载, 所以也不算是一个痛点.

但当然, 如果你每次docker build的时候都把vendor文件夹里面的内容放到容器里面, 也可以.

笔者认为, 这只是一个偏好问题, 可以灵活处理.

注意权限和安全设置

留坑.

扩展阅读

Dockerfile新手可以重点了解一下一些常用命令的区别:

  1. COPY VS ADD

    https://docs.docker.com/develop/develop-images/dockerfile_best-practices/#add-or-copy

    https://docs.docker.com/engine/reference/builder/#add

    https://docs.docker.com/engine/reference/builder/#copy

  2. ARG VS ENV

    https://docs.docker.com/engine/reference/builder/#arg

    https://docs.docker.com/engine/reference/builder/#env

    https://docs.docker.com/develop/develop-images/dockerfile_best-practices/#env

  3. CMD VS ENTRYPOINT

    https://docs.docker.com/develop/develop-images/dockerfile_best-practices/#entrypoint

    https://docs.docker.com/engine/reference/builder/#cmd

    https://docs.docker.com/engine/reference/builder/#entrypoint

  4. 区分好docker daemondocker client的proxy设置

    https://docs.docker.com/network/proxy/

    https://docs.docker.com/config/daemon/systemd/#httphttps-proxy